Skip to content

canvod.filemap API Reference

Package

canvod-filemap: naming recipes for non-canonical GNSS filenames.

VirtualFile dataclass

Physical file mapped to its canVOD conventional name.

Source code in packages/canvod-filemap/src/canvod/filemap/mapping.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@dataclass(frozen=True)
class VirtualFile:
    """Physical file mapped to its canVOD conventional name."""

    physical_path: Path
    conventional_name: CanVODFilename

    @property
    def canonical_str(self) -> str:
        """The conventional filename as a string."""
        return self.conventional_name.name

    def open(self, mode: str = "rb"):
        """Open the physical file."""
        return self.physical_path.open(mode)

canonical_str property

The conventional filename as a string.

open(mode='rb')

Open the physical file.

Source code in packages/canvod-filemap/src/canvod/filemap/mapping.py
26
27
28
def open(self, mode: str = "rb"):
    """Open the physical file."""
    return self.physical_path.open(mode)

NamingRecipe

Bases: BaseModel

A user-defined mapping from arbitrary filenames to canVOD names.

Serialize to YAML for sharing, to JSON for API transport.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
class NamingRecipe(BaseModel):
    """A user-defined mapping from arbitrary filenames to canVOD names.

    Serialize to YAML for sharing, to JSON for API transport.
    """

    name: str = Field(description="Recipe identifier (e.g. 'rosalia_reference')")
    description: str = ""

    # Canonical identity
    site: SiteId
    agency: AgencyId
    receiver_number: int = Field(ge=1, le=99)
    receiver_type: Literal["reference", "canopy"] = "canopy"
    sampling: Duration = "05S"
    period: Duration = "15M"
    content: ContentCode = "AA"
    file_type: Literal["rnx", "sbf", "ubx", "nmea"] = "rnx"

    # File discovery
    glob: str = Field(description="Glob pattern to find files (e.g. '*.??o', '*.rinex')")

    # Field extraction: sequential left-to-right
    # Each entry is a single-key dict: {field_name: width}
    fields: list[dict[str, int]] = Field(
        description="Sequential field extraction. Each entry: {field_name: width}"
    )

    @model_validator(mode="after")
    def _validate_fields(self) -> NamingRecipe:
        for i, entry in enumerate(self.fields):
            if len(entry) != 1:
                msg = f"fields[{i}]: each entry must be a single key-value pair, got {entry}"
                raise ValueError(msg)
            field_name = next(iter(entry))
            width = entry[field_name]
            if field_name not in KNOWN_FIELDS:
                msg = f"fields[{i}]: unknown field '{field_name}'. Known: {sorted(KNOWN_FIELDS)}"
                raise ValueError(msg)
            if not isinstance(width, int) or width < 1:
                msg = f"fields[{i}]: width must be a positive integer, got {width}"
                raise ValueError(msg)
        return self

    # -- Parsing ---------------------------------------------------------------

    def parse_filename(self, filename: str) -> dict[str, str | int]:
        """Extract fields from a physical filename.

        Parameters
        ----------
        filename
            Bare filename (no directory components).

        Returns
        -------
        dict
            Extracted field values. Integer fields (year, doy, etc.) are
            returned as ``int``.  ``hour_letter`` is returned as ``str``.
            ``skip`` fields are not included.

        Raises
        ------
        ValueError
            If the filename is too short for the field spec.
        """
        pos = 0
        result: dict[str, str | int] = {}

        for entry in self.fields:
            field_name = next(iter(entry))
            width = entry[field_name]

            if pos + width > len(filename):
                msg = (
                    f"Filename {filename!r} too short: need {width} chars "
                    f"at position {pos} for '{field_name}', "
                    f"but only {len(filename) - pos} remain"
                )
                raise ValueError(msg)

            raw = filename[pos : pos + width]
            pos += width

            if field_name == "skip":
                continue
            elif field_name == "hour_letter":
                result["hour_letter"] = raw
            else:
                try:
                    result[field_name] = int(raw)
                except ValueError:
                    msg = f"Cannot parse '{field_name}' as integer from {raw!r} in {filename!r}"
                    raise ValueError(msg) from None

        return result

    def to_virtual_file(self, file_path: Path) -> VirtualFile:
        """Map a physical file to a VirtualFile using this recipe.

        Parameters
        ----------
        file_path
            Path to the physical file.

        Returns
        -------
        VirtualFile
            The mapped virtual file.

        Raises
        ------
        ValueError
            If the filename cannot be parsed.
        """
        parsed = self.parse_filename(file_path.name)

        def _require_int(parsed_key: str) -> int:
            value = parsed[parsed_key]
            if isinstance(value, int):
                return value
            msg = (
                f"Recipe '{self.name}': expected integer field '{parsed_key}' "
                f"for {file_path.name!r}, got {value!r}"
            )
            raise ValueError(msg)

        def _require_str(parsed_key: str) -> str:
            value = parsed[parsed_key]
            if isinstance(value, str):
                return value
            msg = (
                f"Recipe '{self.name}': expected string field '{parsed_key}' "
                f"for {file_path.name!r}, got {value!r}"
            )
            raise ValueError(msg)

        # Resolve year
        if "year" in parsed:
            year = _require_int("year")
        elif "yy" in parsed:
            year = resolve_year_from_yy(_require_int("yy"))
        else:
            raise ValueError(
                f"Recipe '{self.name}': no 'year' or 'yy' field "
                f"in parsed result for {file_path.name!r}"
            )

        # Resolve DOY (from doy directly, or from month+day)
        if "doy" in parsed:
            doy = _require_int("doy")
        elif "month" in parsed and "day" in parsed:
            from datetime import date

            doy = date(year, _require_int("month"), _require_int("day")).timetuple().tm_yday
        else:
            raise ValueError(
                f"Recipe '{self.name}': no 'doy' or 'month'+'day' fields "
                f"in parsed result for {file_path.name!r}"
            )

        # Resolve hour
        if "hour" in parsed:
            hour = _require_int("hour")
        elif "hour_letter" in parsed:
            hour = hour_letter_to_int(_require_str("hour_letter"))
        else:
            hour = 0

        # Resolve minute
        minute = _require_int("minute") if "minute" in parsed else 0

        # Daily file: no hour in the name, or the RINEX 2 daily session "0"
        # (hourly sessions are "a"-"x", so one recipe matches both)
        period = self.period
        has_hour = any("hour" in e or "hour_letter" in e for e in self.fields)
        if parsed.get("hour_letter") == "0" or (hour == 0 and minute == 0 and not has_hour):
            period = "01D"

        rx_type = (
            ReceiverType.REFERENCE if self.receiver_type == "reference" else ReceiverType.ACTIVE
        )

        conventional = CanVODFilename(
            site=self.site,
            receiver_type=rx_type,
            receiver_number=self.receiver_number,
            agency=self.agency,
            year=year,
            doy=doy,
            hour=hour,
            minute=minute,
            period=period,
            sampling=self.sampling,
            content=self.content,
            file_type=FileType(self.file_type),
        )

        return VirtualFile(physical_path=file_path, conventional_name=conventional)

    @property
    def expected_length(self) -> int:
        """Total number of characters consumed by the field spec."""
        return sum(next(iter(e.values())) for e in self.fields)

    def matches(self, filename: str) -> bool:
        """Check if a filename can be parsed by this recipe."""
        if len(filename) != self.expected_length:
            return False
        try:
            self.parse_filename(filename)
            return True
        except ValueError:
            return False

    # -- Serialization ---------------------------------------------------------

    def to_yaml(self) -> str:
        """Serialize to YAML string."""
        return yaml.dump(
            self.model_dump(mode="json"),
            default_flow_style=False,
            sort_keys=False,
            allow_unicode=True,
        )

    def save(self, path: Path) -> None:
        """Write recipe to a YAML file."""
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(self.to_yaml(), encoding="utf-8")

    @classmethod
    def from_yaml(cls, text: str) -> NamingRecipe:
        """Load from a YAML string."""
        data = yaml.safe_load(text)
        return cls.model_validate(data)

    @classmethod
    def load(cls, path: Path) -> NamingRecipe:
        """Load from a YAML file."""
        text = path.read_text(encoding="utf-8")
        return cls.from_yaml(text)

expected_length property

Total number of characters consumed by the field spec.

parse_filename(filename)

Extract fields from a physical filename.

Parameters

filename Bare filename (no directory components).

Returns

dict Extracted field values. Integer fields (year, doy, etc.) are returned as int. hour_letter is returned as str. skip fields are not included.

Raises

ValueError If the filename is too short for the field spec.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def parse_filename(self, filename: str) -> dict[str, str | int]:
    """Extract fields from a physical filename.

    Parameters
    ----------
    filename
        Bare filename (no directory components).

    Returns
    -------
    dict
        Extracted field values. Integer fields (year, doy, etc.) are
        returned as ``int``.  ``hour_letter`` is returned as ``str``.
        ``skip`` fields are not included.

    Raises
    ------
    ValueError
        If the filename is too short for the field spec.
    """
    pos = 0
    result: dict[str, str | int] = {}

    for entry in self.fields:
        field_name = next(iter(entry))
        width = entry[field_name]

        if pos + width > len(filename):
            msg = (
                f"Filename {filename!r} too short: need {width} chars "
                f"at position {pos} for '{field_name}', "
                f"but only {len(filename) - pos} remain"
            )
            raise ValueError(msg)

        raw = filename[pos : pos + width]
        pos += width

        if field_name == "skip":
            continue
        elif field_name == "hour_letter":
            result["hour_letter"] = raw
        else:
            try:
                result[field_name] = int(raw)
            except ValueError:
                msg = f"Cannot parse '{field_name}' as integer from {raw!r} in {filename!r}"
                raise ValueError(msg) from None

    return result

to_virtual_file(file_path)

Map a physical file to a VirtualFile using this recipe.

Parameters

file_path Path to the physical file.

Returns

VirtualFile The mapped virtual file.

Raises

ValueError If the filename cannot be parsed.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
def to_virtual_file(self, file_path: Path) -> VirtualFile:
    """Map a physical file to a VirtualFile using this recipe.

    Parameters
    ----------
    file_path
        Path to the physical file.

    Returns
    -------
    VirtualFile
        The mapped virtual file.

    Raises
    ------
    ValueError
        If the filename cannot be parsed.
    """
    parsed = self.parse_filename(file_path.name)

    def _require_int(parsed_key: str) -> int:
        value = parsed[parsed_key]
        if isinstance(value, int):
            return value
        msg = (
            f"Recipe '{self.name}': expected integer field '{parsed_key}' "
            f"for {file_path.name!r}, got {value!r}"
        )
        raise ValueError(msg)

    def _require_str(parsed_key: str) -> str:
        value = parsed[parsed_key]
        if isinstance(value, str):
            return value
        msg = (
            f"Recipe '{self.name}': expected string field '{parsed_key}' "
            f"for {file_path.name!r}, got {value!r}"
        )
        raise ValueError(msg)

    # Resolve year
    if "year" in parsed:
        year = _require_int("year")
    elif "yy" in parsed:
        year = resolve_year_from_yy(_require_int("yy"))
    else:
        raise ValueError(
            f"Recipe '{self.name}': no 'year' or 'yy' field "
            f"in parsed result for {file_path.name!r}"
        )

    # Resolve DOY (from doy directly, or from month+day)
    if "doy" in parsed:
        doy = _require_int("doy")
    elif "month" in parsed and "day" in parsed:
        from datetime import date

        doy = date(year, _require_int("month"), _require_int("day")).timetuple().tm_yday
    else:
        raise ValueError(
            f"Recipe '{self.name}': no 'doy' or 'month'+'day' fields "
            f"in parsed result for {file_path.name!r}"
        )

    # Resolve hour
    if "hour" in parsed:
        hour = _require_int("hour")
    elif "hour_letter" in parsed:
        hour = hour_letter_to_int(_require_str("hour_letter"))
    else:
        hour = 0

    # Resolve minute
    minute = _require_int("minute") if "minute" in parsed else 0

    # Daily file: no hour in the name, or the RINEX 2 daily session "0"
    # (hourly sessions are "a"-"x", so one recipe matches both)
    period = self.period
    has_hour = any("hour" in e or "hour_letter" in e for e in self.fields)
    if parsed.get("hour_letter") == "0" or (hour == 0 and minute == 0 and not has_hour):
        period = "01D"

    rx_type = (
        ReceiverType.REFERENCE if self.receiver_type == "reference" else ReceiverType.ACTIVE
    )

    conventional = CanVODFilename(
        site=self.site,
        receiver_type=rx_type,
        receiver_number=self.receiver_number,
        agency=self.agency,
        year=year,
        doy=doy,
        hour=hour,
        minute=minute,
        period=period,
        sampling=self.sampling,
        content=self.content,
        file_type=FileType(self.file_type),
    )

    return VirtualFile(physical_path=file_path, conventional_name=conventional)

matches(filename)

Check if a filename can be parsed by this recipe.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
309
310
311
312
313
314
315
316
317
def matches(self, filename: str) -> bool:
    """Check if a filename can be parsed by this recipe."""
    if len(filename) != self.expected_length:
        return False
    try:
        self.parse_filename(filename)
        return True
    except ValueError:
        return False

to_yaml()

Serialize to YAML string.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
321
322
323
324
325
326
327
328
def to_yaml(self) -> str:
    """Serialize to YAML string."""
    return yaml.dump(
        self.model_dump(mode="json"),
        default_flow_style=False,
        sort_keys=False,
        allow_unicode=True,
    )

save(path)

Write recipe to a YAML file.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
330
331
332
333
def save(self, path: Path) -> None:
    """Write recipe to a YAML file."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(self.to_yaml(), encoding="utf-8")

from_yaml(text) classmethod

Load from a YAML string.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
335
336
337
338
339
@classmethod
def from_yaml(cls, text: str) -> NamingRecipe:
    """Load from a YAML string."""
    data = yaml.safe_load(text)
    return cls.model_validate(data)

load(path) classmethod

Load from a YAML file.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
341
342
343
344
345
@classmethod
def load(cls, path: Path) -> NamingRecipe:
    """Load from a YAML file."""
    text = path.read_text(encoding="utf-8")
    return cls.from_yaml(text)

RecipeNotFoundError

Bases: FileNotFoundError

No recipe file exists for a site and recipe name.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe_files.py
20
21
class RecipeNotFoundError(FileNotFoundError):
    """No recipe file exists for a site and recipe name."""

create_recipe(config_dir, site, name, *, overwrite=False)

Create the recipe name of site from the template.

The new file has name filled in. The receiver identity and the fields list must be edited before the recipe can be loaded.

Parameters

config_dir : Path The configuration directory (the one holding the settings file). site : str Site name in the settings file. name : str Recipe name, the receiver's recipe: setting. overwrite : bool Replace an existing recipe file.

Returns

Path The created file, <config_dir>/recipes/<site>/<name>.yaml.

Raises

FileExistsError If the recipe file exists and overwrite is false.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe_files.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def create_recipe(config_dir: Path, site: str, name: str, *, overwrite: bool = False) -> Path:
    """Create the recipe ``name`` of ``site`` from the template.

    The new file has ``name`` filled in. The receiver identity and the
    ``fields`` list must be edited before the recipe can be loaded.

    Parameters
    ----------
    config_dir : Path
        The configuration directory (the one holding the settings file).
    site : str
        Site name in the settings file.
    name : str
        Recipe name, the receiver's ``recipe:`` setting.
    overwrite : bool
        Replace an existing recipe file.

    Returns
    -------
    Path
        The created file, ``<config_dir>/recipes/<site>/<name>.yaml``.

    Raises
    ------
    FileExistsError
        If the recipe file exists and ``overwrite`` is false.
    """
    path = recipe_path(config_dir, site, name)
    if path.exists() and not overwrite:
        msg = f"Recipe file already exists: {path}"
        raise FileExistsError(msg)
    text = TEMPLATE_PATH.read_text(encoding="utf-8")
    text = text.replace("name: CHANGEME", f"name: {name}", 1)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")
    return path

find_recipe(config_dir, site, name)

Path of the existing recipe file name of site.

Parameters

config_dir : Path The configuration directory (the one holding the settings file). site : str Site name in the settings file. name : str Recipe name, the receiver's recipe: setting.

Returns

Path <config_dir>/recipes/<site>/<name>.yaml.

Raises

RecipeNotFoundError If that file does not exist. If the recipe is found directly in <config_dir>/recipes/, the message says where to move it.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe_files.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def find_recipe(config_dir: Path, site: str, name: str) -> Path:
    """Path of the existing recipe file ``name`` of ``site``.

    Parameters
    ----------
    config_dir : Path
        The configuration directory (the one holding the settings file).
    site : str
        Site name in the settings file.
    name : str
        Recipe name, the receiver's ``recipe:`` setting.

    Returns
    -------
    Path
        ``<config_dir>/recipes/<site>/<name>.yaml``.

    Raises
    ------
    RecipeNotFoundError
        If that file does not exist. If the recipe is found directly in
        ``<config_dir>/recipes/``, the message says where to move it.
    """
    path = recipe_path(config_dir, site, name)
    if path.is_file():
        return path
    flat = Path(config_dir) / "recipes" / f"{name}.yaml"
    if flat.is_file():
        msg = (
            f"Recipe '{name}' of site '{site}' must be in a folder named after "
            f"the site. Move {flat} to {path}"
        )
        raise RecipeNotFoundError(msg)
    msg = f"Recipe file not found for '{name}' of site '{site}': {path}"
    raise RecipeNotFoundError(msg)

recipe_path(config_dir, site, name)

Path of the recipe name of site, whether it exists or not.

Parameters

config_dir : Path The configuration directory (the one holding the settings file). site : str Site name in the settings file. name : str Recipe name, the receiver's recipe: setting.

Returns

Path <config_dir>/recipes/<site>/<name>.yaml.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe_files.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def recipe_path(config_dir: Path, site: str, name: str) -> Path:
    """Path of the recipe ``name`` of ``site``, whether it exists or not.

    Parameters
    ----------
    config_dir : Path
        The configuration directory (the one holding the settings file).
    site : str
        Site name in the settings file.
    name : str
        Recipe name, the receiver's ``recipe:`` setting.

    Returns
    -------
    Path
        ``<config_dir>/recipes/<site>/<name>.yaml``.
    """
    return Path(config_dir) / "recipes" / site / f"{name}.yaml"

Recipe

Naming recipe: user-defined mapping from arbitrary filenames to canVOD names.

A recipe is a small YAML file that describes: 1. The canonical identity of a receiver (site, agency, sampling, etc.) 2. How to extract date/time fields from the user's physical filenames.

The field extraction is a sequential left-to-right walk over the filename. Each entry in the fields list consumes characters and either extracts a named value or skips literal characters.

Recognized field names

  • year 4-digit year (e.g. 2025)
  • yy 2-digit year (80-99 → 19xx, 00-79 → 20xx)
  • doy day of year (1-366)
  • month month (01-12)
  • day day of month (01-31)
  • hour hour (00-23)
  • minute minute (00-59)
  • hour_letter RINEX 2 session letter (a-x = hours 0-23; 0 = daily file)
  • skip ignore these characters

Example recipe (YAML)

::

name: rosalia_reference
description: Rosalia forest, reference receiver

site: ROS
agency: TUW
receiver_number: 1
receiver_type: reference
sampling: "05S"
period: "15M"
content: "AA"
file_type: rnx

glob: "*.??o"

# Example: rref001a15.25o
fields:
  - skip: 4
  - doy: 3
  - hour_letter: 1
  - minute: 2
  - skip: 1
  - yy: 2
  - skip: 1

Example recipe for exotic filenames

::

# Example: STATION_2025_042_00_15.rinex
fields:
  - skip: 8
  - year: 4
  - skip: 1
  - doy: 3
  - skip: 1
  - hour: 2
  - skip: 1
  - minute: 2
  - skip: 6

NamingRecipe

Bases: BaseModel

A user-defined mapping from arbitrary filenames to canVOD names.

Serialize to YAML for sharing, to JSON for API transport.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
class NamingRecipe(BaseModel):
    """A user-defined mapping from arbitrary filenames to canVOD names.

    Serialize to YAML for sharing, to JSON for API transport.
    """

    name: str = Field(description="Recipe identifier (e.g. 'rosalia_reference')")
    description: str = ""

    # Canonical identity
    site: SiteId
    agency: AgencyId
    receiver_number: int = Field(ge=1, le=99)
    receiver_type: Literal["reference", "canopy"] = "canopy"
    sampling: Duration = "05S"
    period: Duration = "15M"
    content: ContentCode = "AA"
    file_type: Literal["rnx", "sbf", "ubx", "nmea"] = "rnx"

    # File discovery
    glob: str = Field(description="Glob pattern to find files (e.g. '*.??o', '*.rinex')")

    # Field extraction: sequential left-to-right
    # Each entry is a single-key dict: {field_name: width}
    fields: list[dict[str, int]] = Field(
        description="Sequential field extraction. Each entry: {field_name: width}"
    )

    @model_validator(mode="after")
    def _validate_fields(self) -> NamingRecipe:
        for i, entry in enumerate(self.fields):
            if len(entry) != 1:
                msg = f"fields[{i}]: each entry must be a single key-value pair, got {entry}"
                raise ValueError(msg)
            field_name = next(iter(entry))
            width = entry[field_name]
            if field_name not in KNOWN_FIELDS:
                msg = f"fields[{i}]: unknown field '{field_name}'. Known: {sorted(KNOWN_FIELDS)}"
                raise ValueError(msg)
            if not isinstance(width, int) or width < 1:
                msg = f"fields[{i}]: width must be a positive integer, got {width}"
                raise ValueError(msg)
        return self

    # -- Parsing ---------------------------------------------------------------

    def parse_filename(self, filename: str) -> dict[str, str | int]:
        """Extract fields from a physical filename.

        Parameters
        ----------
        filename
            Bare filename (no directory components).

        Returns
        -------
        dict
            Extracted field values. Integer fields (year, doy, etc.) are
            returned as ``int``.  ``hour_letter`` is returned as ``str``.
            ``skip`` fields are not included.

        Raises
        ------
        ValueError
            If the filename is too short for the field spec.
        """
        pos = 0
        result: dict[str, str | int] = {}

        for entry in self.fields:
            field_name = next(iter(entry))
            width = entry[field_name]

            if pos + width > len(filename):
                msg = (
                    f"Filename {filename!r} too short: need {width} chars "
                    f"at position {pos} for '{field_name}', "
                    f"but only {len(filename) - pos} remain"
                )
                raise ValueError(msg)

            raw = filename[pos : pos + width]
            pos += width

            if field_name == "skip":
                continue
            elif field_name == "hour_letter":
                result["hour_letter"] = raw
            else:
                try:
                    result[field_name] = int(raw)
                except ValueError:
                    msg = f"Cannot parse '{field_name}' as integer from {raw!r} in {filename!r}"
                    raise ValueError(msg) from None

        return result

    def to_virtual_file(self, file_path: Path) -> VirtualFile:
        """Map a physical file to a VirtualFile using this recipe.

        Parameters
        ----------
        file_path
            Path to the physical file.

        Returns
        -------
        VirtualFile
            The mapped virtual file.

        Raises
        ------
        ValueError
            If the filename cannot be parsed.
        """
        parsed = self.parse_filename(file_path.name)

        def _require_int(parsed_key: str) -> int:
            value = parsed[parsed_key]
            if isinstance(value, int):
                return value
            msg = (
                f"Recipe '{self.name}': expected integer field '{parsed_key}' "
                f"for {file_path.name!r}, got {value!r}"
            )
            raise ValueError(msg)

        def _require_str(parsed_key: str) -> str:
            value = parsed[parsed_key]
            if isinstance(value, str):
                return value
            msg = (
                f"Recipe '{self.name}': expected string field '{parsed_key}' "
                f"for {file_path.name!r}, got {value!r}"
            )
            raise ValueError(msg)

        # Resolve year
        if "year" in parsed:
            year = _require_int("year")
        elif "yy" in parsed:
            year = resolve_year_from_yy(_require_int("yy"))
        else:
            raise ValueError(
                f"Recipe '{self.name}': no 'year' or 'yy' field "
                f"in parsed result for {file_path.name!r}"
            )

        # Resolve DOY (from doy directly, or from month+day)
        if "doy" in parsed:
            doy = _require_int("doy")
        elif "month" in parsed and "day" in parsed:
            from datetime import date

            doy = date(year, _require_int("month"), _require_int("day")).timetuple().tm_yday
        else:
            raise ValueError(
                f"Recipe '{self.name}': no 'doy' or 'month'+'day' fields "
                f"in parsed result for {file_path.name!r}"
            )

        # Resolve hour
        if "hour" in parsed:
            hour = _require_int("hour")
        elif "hour_letter" in parsed:
            hour = hour_letter_to_int(_require_str("hour_letter"))
        else:
            hour = 0

        # Resolve minute
        minute = _require_int("minute") if "minute" in parsed else 0

        # Daily file: no hour in the name, or the RINEX 2 daily session "0"
        # (hourly sessions are "a"-"x", so one recipe matches both)
        period = self.period
        has_hour = any("hour" in e or "hour_letter" in e for e in self.fields)
        if parsed.get("hour_letter") == "0" or (hour == 0 and minute == 0 and not has_hour):
            period = "01D"

        rx_type = (
            ReceiverType.REFERENCE if self.receiver_type == "reference" else ReceiverType.ACTIVE
        )

        conventional = CanVODFilename(
            site=self.site,
            receiver_type=rx_type,
            receiver_number=self.receiver_number,
            agency=self.agency,
            year=year,
            doy=doy,
            hour=hour,
            minute=minute,
            period=period,
            sampling=self.sampling,
            content=self.content,
            file_type=FileType(self.file_type),
        )

        return VirtualFile(physical_path=file_path, conventional_name=conventional)

    @property
    def expected_length(self) -> int:
        """Total number of characters consumed by the field spec."""
        return sum(next(iter(e.values())) for e in self.fields)

    def matches(self, filename: str) -> bool:
        """Check if a filename can be parsed by this recipe."""
        if len(filename) != self.expected_length:
            return False
        try:
            self.parse_filename(filename)
            return True
        except ValueError:
            return False

    # -- Serialization ---------------------------------------------------------

    def to_yaml(self) -> str:
        """Serialize to YAML string."""
        return yaml.dump(
            self.model_dump(mode="json"),
            default_flow_style=False,
            sort_keys=False,
            allow_unicode=True,
        )

    def save(self, path: Path) -> None:
        """Write recipe to a YAML file."""
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(self.to_yaml(), encoding="utf-8")

    @classmethod
    def from_yaml(cls, text: str) -> NamingRecipe:
        """Load from a YAML string."""
        data = yaml.safe_load(text)
        return cls.model_validate(data)

    @classmethod
    def load(cls, path: Path) -> NamingRecipe:
        """Load from a YAML file."""
        text = path.read_text(encoding="utf-8")
        return cls.from_yaml(text)

expected_length property

Total number of characters consumed by the field spec.

parse_filename(filename)

Extract fields from a physical filename.

Parameters

filename Bare filename (no directory components).

Returns

dict Extracted field values. Integer fields (year, doy, etc.) are returned as int. hour_letter is returned as str. skip fields are not included.

Raises

ValueError If the filename is too short for the field spec.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
def parse_filename(self, filename: str) -> dict[str, str | int]:
    """Extract fields from a physical filename.

    Parameters
    ----------
    filename
        Bare filename (no directory components).

    Returns
    -------
    dict
        Extracted field values. Integer fields (year, doy, etc.) are
        returned as ``int``.  ``hour_letter`` is returned as ``str``.
        ``skip`` fields are not included.

    Raises
    ------
    ValueError
        If the filename is too short for the field spec.
    """
    pos = 0
    result: dict[str, str | int] = {}

    for entry in self.fields:
        field_name = next(iter(entry))
        width = entry[field_name]

        if pos + width > len(filename):
            msg = (
                f"Filename {filename!r} too short: need {width} chars "
                f"at position {pos} for '{field_name}', "
                f"but only {len(filename) - pos} remain"
            )
            raise ValueError(msg)

        raw = filename[pos : pos + width]
        pos += width

        if field_name == "skip":
            continue
        elif field_name == "hour_letter":
            result["hour_letter"] = raw
        else:
            try:
                result[field_name] = int(raw)
            except ValueError:
                msg = f"Cannot parse '{field_name}' as integer from {raw!r} in {filename!r}"
                raise ValueError(msg) from None

    return result

to_virtual_file(file_path)

Map a physical file to a VirtualFile using this recipe.

Parameters

file_path Path to the physical file.

Returns

VirtualFile The mapped virtual file.

Raises

ValueError If the filename cannot be parsed.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
def to_virtual_file(self, file_path: Path) -> VirtualFile:
    """Map a physical file to a VirtualFile using this recipe.

    Parameters
    ----------
    file_path
        Path to the physical file.

    Returns
    -------
    VirtualFile
        The mapped virtual file.

    Raises
    ------
    ValueError
        If the filename cannot be parsed.
    """
    parsed = self.parse_filename(file_path.name)

    def _require_int(parsed_key: str) -> int:
        value = parsed[parsed_key]
        if isinstance(value, int):
            return value
        msg = (
            f"Recipe '{self.name}': expected integer field '{parsed_key}' "
            f"for {file_path.name!r}, got {value!r}"
        )
        raise ValueError(msg)

    def _require_str(parsed_key: str) -> str:
        value = parsed[parsed_key]
        if isinstance(value, str):
            return value
        msg = (
            f"Recipe '{self.name}': expected string field '{parsed_key}' "
            f"for {file_path.name!r}, got {value!r}"
        )
        raise ValueError(msg)

    # Resolve year
    if "year" in parsed:
        year = _require_int("year")
    elif "yy" in parsed:
        year = resolve_year_from_yy(_require_int("yy"))
    else:
        raise ValueError(
            f"Recipe '{self.name}': no 'year' or 'yy' field "
            f"in parsed result for {file_path.name!r}"
        )

    # Resolve DOY (from doy directly, or from month+day)
    if "doy" in parsed:
        doy = _require_int("doy")
    elif "month" in parsed and "day" in parsed:
        from datetime import date

        doy = date(year, _require_int("month"), _require_int("day")).timetuple().tm_yday
    else:
        raise ValueError(
            f"Recipe '{self.name}': no 'doy' or 'month'+'day' fields "
            f"in parsed result for {file_path.name!r}"
        )

    # Resolve hour
    if "hour" in parsed:
        hour = _require_int("hour")
    elif "hour_letter" in parsed:
        hour = hour_letter_to_int(_require_str("hour_letter"))
    else:
        hour = 0

    # Resolve minute
    minute = _require_int("minute") if "minute" in parsed else 0

    # Daily file: no hour in the name, or the RINEX 2 daily session "0"
    # (hourly sessions are "a"-"x", so one recipe matches both)
    period = self.period
    has_hour = any("hour" in e or "hour_letter" in e for e in self.fields)
    if parsed.get("hour_letter") == "0" or (hour == 0 and minute == 0 and not has_hour):
        period = "01D"

    rx_type = (
        ReceiverType.REFERENCE if self.receiver_type == "reference" else ReceiverType.ACTIVE
    )

    conventional = CanVODFilename(
        site=self.site,
        receiver_type=rx_type,
        receiver_number=self.receiver_number,
        agency=self.agency,
        year=year,
        doy=doy,
        hour=hour,
        minute=minute,
        period=period,
        sampling=self.sampling,
        content=self.content,
        file_type=FileType(self.file_type),
    )

    return VirtualFile(physical_path=file_path, conventional_name=conventional)

matches(filename)

Check if a filename can be parsed by this recipe.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
309
310
311
312
313
314
315
316
317
def matches(self, filename: str) -> bool:
    """Check if a filename can be parsed by this recipe."""
    if len(filename) != self.expected_length:
        return False
    try:
        self.parse_filename(filename)
        return True
    except ValueError:
        return False

to_yaml()

Serialize to YAML string.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
321
322
323
324
325
326
327
328
def to_yaml(self) -> str:
    """Serialize to YAML string."""
    return yaml.dump(
        self.model_dump(mode="json"),
        default_flow_style=False,
        sort_keys=False,
        allow_unicode=True,
    )

save(path)

Write recipe to a YAML file.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
330
331
332
333
def save(self, path: Path) -> None:
    """Write recipe to a YAML file."""
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(self.to_yaml(), encoding="utf-8")

from_yaml(text) classmethod

Load from a YAML string.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
335
336
337
338
339
@classmethod
def from_yaml(cls, text: str) -> NamingRecipe:
    """Load from a YAML string."""
    data = yaml.safe_load(text)
    return cls.model_validate(data)

load(path) classmethod

Load from a YAML file.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe.py
341
342
343
344
345
@classmethod
def load(cls, path: Path) -> NamingRecipe:
    """Load from a YAML file."""
    text = path.read_text(encoding="utf-8")
    return cls.from_yaml(text)

Recipe Files

Where naming recipe files live, and creating them from the template.

Recipes are kept per site in the configuration directory::

<config dir>/recipes/<site>/<name>.yaml

<site> is the site name in the settings file and <name> the receiver's recipe: setting. Recipe files are user data: they are not part of any repository and are only read from this location.

RecipeNotFoundError

Bases: FileNotFoundError

No recipe file exists for a site and recipe name.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe_files.py
20
21
class RecipeNotFoundError(FileNotFoundError):
    """No recipe file exists for a site and recipe name."""

recipe_path(config_dir, site, name)

Path of the recipe name of site, whether it exists or not.

Parameters

config_dir : Path The configuration directory (the one holding the settings file). site : str Site name in the settings file. name : str Recipe name, the receiver's recipe: setting.

Returns

Path <config_dir>/recipes/<site>/<name>.yaml.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe_files.py
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
def recipe_path(config_dir: Path, site: str, name: str) -> Path:
    """Path of the recipe ``name`` of ``site``, whether it exists or not.

    Parameters
    ----------
    config_dir : Path
        The configuration directory (the one holding the settings file).
    site : str
        Site name in the settings file.
    name : str
        Recipe name, the receiver's ``recipe:`` setting.

    Returns
    -------
    Path
        ``<config_dir>/recipes/<site>/<name>.yaml``.
    """
    return Path(config_dir) / "recipes" / site / f"{name}.yaml"

find_recipe(config_dir, site, name)

Path of the existing recipe file name of site.

Parameters

config_dir : Path The configuration directory (the one holding the settings file). site : str Site name in the settings file. name : str Recipe name, the receiver's recipe: setting.

Returns

Path <config_dir>/recipes/<site>/<name>.yaml.

Raises

RecipeNotFoundError If that file does not exist. If the recipe is found directly in <config_dir>/recipes/, the message says where to move it.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe_files.py
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
def find_recipe(config_dir: Path, site: str, name: str) -> Path:
    """Path of the existing recipe file ``name`` of ``site``.

    Parameters
    ----------
    config_dir : Path
        The configuration directory (the one holding the settings file).
    site : str
        Site name in the settings file.
    name : str
        Recipe name, the receiver's ``recipe:`` setting.

    Returns
    -------
    Path
        ``<config_dir>/recipes/<site>/<name>.yaml``.

    Raises
    ------
    RecipeNotFoundError
        If that file does not exist. If the recipe is found directly in
        ``<config_dir>/recipes/``, the message says where to move it.
    """
    path = recipe_path(config_dir, site, name)
    if path.is_file():
        return path
    flat = Path(config_dir) / "recipes" / f"{name}.yaml"
    if flat.is_file():
        msg = (
            f"Recipe '{name}' of site '{site}' must be in a folder named after "
            f"the site. Move {flat} to {path}"
        )
        raise RecipeNotFoundError(msg)
    msg = f"Recipe file not found for '{name}' of site '{site}': {path}"
    raise RecipeNotFoundError(msg)

create_recipe(config_dir, site, name, *, overwrite=False)

Create the recipe name of site from the template.

The new file has name filled in. The receiver identity and the fields list must be edited before the recipe can be loaded.

Parameters

config_dir : Path The configuration directory (the one holding the settings file). site : str Site name in the settings file. name : str Recipe name, the receiver's recipe: setting. overwrite : bool Replace an existing recipe file.

Returns

Path The created file, <config_dir>/recipes/<site>/<name>.yaml.

Raises

FileExistsError If the recipe file exists and overwrite is false.

Source code in packages/canvod-filemap/src/canvod/filemap/recipe_files.py
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
def create_recipe(config_dir: Path, site: str, name: str, *, overwrite: bool = False) -> Path:
    """Create the recipe ``name`` of ``site`` from the template.

    The new file has ``name`` filled in. The receiver identity and the
    ``fields`` list must be edited before the recipe can be loaded.

    Parameters
    ----------
    config_dir : Path
        The configuration directory (the one holding the settings file).
    site : str
        Site name in the settings file.
    name : str
        Recipe name, the receiver's ``recipe:`` setting.
    overwrite : bool
        Replace an existing recipe file.

    Returns
    -------
    Path
        The created file, ``<config_dir>/recipes/<site>/<name>.yaml``.

    Raises
    ------
    FileExistsError
        If the recipe file exists and ``overwrite`` is false.
    """
    path = recipe_path(config_dir, site, name)
    if path.exists() and not overwrite:
        msg = f"Recipe file already exists: {path}"
        raise FileExistsError(msg)
    text = TEMPLATE_PATH.read_text(encoding="utf-8")
    text = text.replace("name: CHANGEME", f"name: {name}", 1)
    path.parent.mkdir(parents=True, exist_ok=True)
    path.write_text(text, encoding="utf-8")
    return path

Mapping

VirtualFile: a physical file paired with its canVOD conventional name.

NamingRecipe.to_virtual_file returns it; the file on disk keeps its name.

VirtualFile dataclass

Physical file mapped to its canVOD conventional name.

Source code in packages/canvod-filemap/src/canvod/filemap/mapping.py
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
@dataclass(frozen=True)
class VirtualFile:
    """Physical file mapped to its canVOD conventional name."""

    physical_path: Path
    conventional_name: CanVODFilename

    @property
    def canonical_str(self) -> str:
        """The conventional filename as a string."""
        return self.conventional_name.name

    def open(self, mode: str = "rb"):
        """Open the physical file."""
        return self.physical_path.open(mode)

canonical_str property

The conventional filename as a string.

open(mode='rb')

Open the physical file.

Source code in packages/canvod-filemap/src/canvod/filemap/mapping.py
26
27
28
def open(self, mode: str = "rb"):
    """Open the physical file."""
    return self.physical_path.open(mode)

Patterns

Helpers for the date and time fields of non-canonical filenames.

hour_letter_to_int(letter)

Convert a RINEX hour letter (a-x or '0') to an integer 0-23.

Raises

ValueError If the letter is not a valid RINEX hour code.

Source code in packages/canvod-filemap/src/canvod/filemap/patterns.py
10
11
12
13
14
15
16
17
18
19
20
21
def hour_letter_to_int(letter: str) -> int:
    """Convert a RINEX hour letter (a-x or '0') to an integer 0-23.

    Raises
    ------
    ValueError
        If the letter is not a valid RINEX hour code.
    """
    try:
        return _HOUR_LETTER_MAP[letter.lower()]
    except KeyError:
        raise ValueError(f"Invalid hour letter: {letter!r}") from None

resolve_year_from_yy(yy)

Expand a 2-digit year to 4 digits (80-99 → 1980-1999, 00-79 → 2000-2079).

Source code in packages/canvod-filemap/src/canvod/filemap/patterns.py
24
25
26
27
28
def resolve_year_from_yy(yy: int) -> int:
    """Expand a 2-digit year to 4 digits (80-99 → 1980-1999, 00-79 → 2000-2079)."""
    if yy >= 80:
        return 1900 + yy
    return 2000 + yy