From a3c91b11732e87667d4ef6362846e4fa98ceb032 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Mon, 17 Aug 2026 13:10:05 +0200 Subject: [PATCH 1/5] Call an inventory's interval notes labels, not annotations Two things in DASCore were called annotations and are not the same thing: these describe the fiber, while an annotation set describes the data recorded through it. One word for both would have been read as one concept the moment the annotation store landed beside it. OpticalPathAnnotation -> OpticalPathLabel OpticalPath.annotations -> OpticalPath.labels annotations.csv -> labels.csv AnnotationValue -> LabelValue "annotation group" -> "label group", in prose and in errors A hard rename with no fallback: a stored inventory declaring object_type: OpticalPathAnnotation, or holding an annotations.csv, no longer loads as one. dev is pre-release and this is the window. A table whose stem names no attribute is now skipped rather than refused. The refusal read a crew's own spreadsheet as a typo, and an entity directory is somewhere a crew keeps working files; a stem which names a real attribute this format does not read as a table still raises, because that one did claim to be a track. It also means an annotations.csv left behind by this rename sits there harmlessly. Type annotations keep the word throughout. --- dascore/constants.py | 4 +- dascore/core/_spool_inventory.py | 12 +- dascore/core/inventory.py | 80 +++++++------ dascore/core/inventory_loader.py | 33 +++--- dascore/core/spool.py | 4 +- dascore/examples.py | 14 +-- dascore/proc/inventory.py | 4 +- dascore/utils/intervals.py | 2 +- docs/notes/inventory_attachment.qmd | 2 +- docs/recipes/tunnel_inventory.qmd | 18 +-- docs/tutorial/inventory.qmd | 12 +- tests/test_core/test_inventory.py | 140 +++++++++++------------ tests/test_core/test_inventory_loader.py | 48 ++++---- tests/test_core/test_spool_inventory.py | 44 +++---- tests/test_inventory_diagrams.py | 4 +- tests/test_proc/test_proc_inventory.py | 34 +++--- 16 files changed, 229 insertions(+), 226 deletions(-) diff --git a/dascore/constants.py b/dascore/constants.py index b4e71be45..84de9820a 100644 --- a/dascore/constants.py +++ b/dascore/constants.py @@ -278,10 +278,10 @@ def map(self, fn: Callable, iterable: Iterable, /) -> Iterable: enrich_coords_description = """ coords - True (the default) to add the geometry axes and annotation groups of + True (the default) to add the geometry axes and label groups of the resolved optical path, a tuple of names to add exactly those, or False to add none. Names may be `distance` for optical distance, a - coordinate label the inventory's CRS defines, an annotation group, or + coordinate label the inventory's CRS defines, a label group, or a qualified track field such as `coupling.medium`. """.strip() diff --git a/dascore/core/_spool_inventory.py b/dascore/core/_spool_inventory.py index 242749dc1..d3e3d6183 100644 --- a/dascore/core/_spool_inventory.py +++ b/dascore/core/_spool_inventory.py @@ -405,7 +405,7 @@ def check_stampable(name: str, rows: pd.DataFrame) -> None: """ Refuse a stamp which would overwrite the plan's own bookkeeping. - An annotation group may be named anything the inventory does not + An label group may be named anything the inventory does not reserve, and the stamp is assigned onto the outputs — so a group called `output_id` would replace the column binding each output to its members, and one called `time_min` an envelope. Overwriting a @@ -620,9 +620,9 @@ def _fill_from_intervals(distances, intervals, values, kind): return np.asarray(["" if x is None else x for x in out], dtype=str) -def _get_annotation_coord(path, group, distances): - """Return the coordinate values of one annotation group.""" - items = [x for x in path.annotations if x.group == group] +def _get_label_coord(path, group, distances): + """Return the coordinate values of one label group.""" + items = [x for x in path.labels if x.group == group] if not items: return None kind = value_kind(items[0].value) @@ -661,7 +661,7 @@ def _get_geometry_coord(inventory, path, label, distances): crs = inventory.coordinate_reference_system # A label this CRS does not define is a name the inventory has no answer # for, which is on_missing's business rather than an error of its own -- - # a named annotation group the inventory lacks already behaves that way. + # a named label group the inventory lacks already behaves that way. try: index = crs.axis_index(label) except InvalidInventoryError: @@ -708,7 +708,7 @@ def get_coord_values(inventory, path, name, distances): return coord if (coord := _get_geometry_column_coord(path, name, distances)) is not None: return coord - return _get_annotation_coord(path, name, distances) + return _get_label_coord(path, name, distances) # --- epoch resolution over index rows --------------------------------- diff --git a/dascore/core/inventory.py b/dascore/core/inventory.py index 82ab5fbe8..854a66c4f 100644 --- a/dascore/core/inventory.py +++ b/dascore/core/inventory.py @@ -3,7 +3,7 @@ The inventory extends the StationXML concept with first-class support for fiber-optic arrays. It describes the physical optical path (fiber, connectors, -splices), the geometry, coupling, and annotation tracks along optical +splices), the geometry, coupling, and label tracks along optical distance, and the interrogator configurations (acquisitions) that produced patches. Patches carry a ``acquisition_key`` (``network.fiber_array.location.acquisition``) which, together with time, @@ -114,15 +114,13 @@ ] -def _annotation_value(value): - """Normalize an annotation value so its Python type survives validation.""" +def _label_value(value): + """Normalize an label value so its Python type survives validation.""" return normalize_value(value, error=InvalidInventoryError) -# The value kind decides an annotation group's shape, so it must be exact. -AnnotationValue = Annotated[ - str | bool | int | float, BeforeValidator(_annotation_value) -] +# The value kind decides an label group's shape, so it must be exact. +LabelValue = Annotated[str | bool | int | float, BeforeValidator(_label_value)] def _object_type_tag(name: str): @@ -697,9 +695,9 @@ def _wanted(field: str, info) -> bool: return not include or field in include -class OpticalPathAnnotation(_IntervalModel): +class OpticalPathLabel(_IntervalModel): """ - Key/value annotation attached to an interval of an optical path. + Key/value label attached to an interval of an optical path. ``group`` names the variable and ``value`` is its state over the interval, so a bare flag is simply a boolean value. String and numeric @@ -709,21 +707,21 @@ class OpticalPathAnnotation(_IntervalModel): """ group: str = Field(default="", description="Name of the annotated variable.") - value: AnnotationValue = Field( + value: LabelValue = Field( default=True, description="Value of the variable over this interval." ) @field_validator("value") @classmethod def _reject_empty_string(cls, value): - """An annotation whose value is empty states nothing. + """An label whose value is empty states nothing. It would also be indistinguishable from an uncovered channel, since a string coordinate spells absence as the empty string. """ if isinstance(value, str) and not value: msg = ( - "An annotation value may not be the empty string; it would " + "An label value may not be the empty string; it would " "state nothing and would read as an uncovered channel." ) raise ValueError(msg) @@ -1148,11 +1146,11 @@ def _track_identity_fields() -> Mapping[str, str]: return MappingProxyType(out) -# Names an annotation group may not take: a group becomes a patch coordinate +# Names an label group may not take: a group becomes a patch coordinate # at enrichment, where it would shadow one of these. RESERVED_GROUP_NAMES = frozenset( {"time", "distance", "channel", "instrument_distance"} - | {"optical_components", "geometry", "coupling", "annotations"} + | {"optical_components", "geometry", "coupling", "labels"} | set(VALID_COORDINATE_LABELS) ) @@ -1177,7 +1175,7 @@ class OpticalPath(TimeRangedModel): Optical components tile ``[start_distance, start_distance + optical length)``. Geometry and coupling are function tracks (partial coverage, - no overlap); annotations overlap as their group's value kind allows. No + no overlap); labels overlap as their group's value kind allows. No more than one path per ``(FiberArray, location_code)`` is valid at a time. """ @@ -1208,8 +1206,8 @@ class OpticalPath(TimeRangedModel): coupling: tuple[CouplingCondition, ...] = Field( default=(), description="Coupling conditions on this path." ) - annotations: tuple[OpticalPathAnnotation, ...] = Field( - default=(), description="Annotations on this path." + labels: tuple[OpticalPathLabel, ...] = Field( + default=(), description="Labels on this path." ) measurements: tuple[OpticalMeasurement | str, ...] = Field( default=(), @@ -1296,18 +1294,18 @@ def check(self, tolerance: float = 1e-9) -> Self: Check track rules for this path. Checks that geometry and coupling stay within path bounds and do not - overlap (partial coverage is legal), and that annotations stay within + overlap (partial coverage is legal), and that labels stay within bounds. Component tiling is inherent to the cumulative layout. """ errors = [] start, end = self.start_distance, self.end_distance geo_spans = [seg.interval for seg in self.geometry] coup_spans = [c.interval for c in self.coupling] - anno_spans = [a.interval for a in self.annotations] + label_spans = [a.interval for a in self.labels] for name, spans in ( ("geometry", geo_spans), ("coupling", coup_spans), - ("annotations", anno_spans), + ("labels", label_spans), ): for lo, hi in spans: if lo < start - tolerance or hi > end + tolerance: @@ -1322,7 +1320,7 @@ def check(self, tolerance: float = 1e-9) -> Self: f"{overlap[1]}; coupling is a function track." ) errors.extend(self._check_geometry_columns()) - errors.extend(self._check_annotation_groups()) + errors.extend(self._check_label_groups()) if errors: msg = "Optical path validation failed:\n" + "\n".join(errors) raise InvalidInventoryError(msg) @@ -1346,7 +1344,7 @@ def _check_geometry_columns(self) -> list[str]: spans.setdefault(name, []).append(segment.interval) if name in segment.units: units.setdefault(name, set()).add(segment.units[name]) - groups = {x.group for x in self.annotations if x.group} + groups = {x.group for x in self.labels if x.group} errors = [ f"Geometry column {name!r} is a reserved name; a column becomes " "a coordinate and cannot shadow a structural coordinate or a " @@ -1360,7 +1358,7 @@ def _check_geometry_columns(self) -> list[str]: for name in sorted(x for x in spans if "." in x) ] errors += [ - f"{name!r} is both a geometry column and an annotation group; " + f"{name!r} is both a geometry column and an label group; " "one name is one coordinate." for name in sorted(set(spans) & groups) ] @@ -1388,15 +1386,15 @@ def _check_geometry_columns(self) -> list[str]: ) return errors - def _check_annotation_groups(self) -> list[str]: - """Check that each annotation group holds one kind of value.""" + def _check_label_groups(self) -> list[str]: + """Check that each label group holds one kind of value.""" groups: dict[str, list] = {} - for annotation in self.annotations: - groups.setdefault(annotation.group, []).append(annotation) + for label in self.labels: + groups.setdefault(label.group, []).append(label) errors = [] for group in sorted(set(groups) & RESERVED_GROUP_NAMES): errors.append( - f"Annotation group {group!r} is a reserved name; a group " + f"Label group {group!r} is a reserved name; a group " "becomes a coordinate and cannot shadow a structural " "coordinate, a typed track, or a coordinate label." ) @@ -1404,7 +1402,7 @@ def _check_annotation_groups(self) -> list[str]: kinds = {value_kind(x.value) for x in items} if len(kinds) > 1: errors.append( - f"Annotation group {group!r} mixes {sorted(kinds)} values; " + f"Label group {group!r} mixes {sorted(kinds)} values; " "a group holds one kind of value." ) continue @@ -1414,7 +1412,7 @@ def _check_annotation_groups(self) -> list[str]: if overlap is not None: errors.append( f"Overlapping intervals {overlap[0]} and {overlap[1]} in " - f"annotation group {group!r}; only boolean groups, which " + f"label group {group!r}; only boolean groups, which " "state membership, may overlap." ) return errors @@ -1461,14 +1459,14 @@ def select(self, *, distance: tuple[float | None, float | None]) -> Self: geometry.append(seg.new(distance=tuple(new_dist), coordinates=new_coords)) outer = self.end_distance coupling = clip_intervals(self.coupling, lo, hi, outer) - annotations = clip_intervals(self.annotations, lo, hi, outer) + labels = clip_intervals(self.labels, lo, hi, outer) return self.model_copy( update={ "start_distance": lo, "optical_components": tuple(components), "geometry": tuple(geometry), "coupling": tuple(coupling), - "annotations": tuple(annotations), + "labels": tuple(labels), } ) @@ -1519,8 +1517,8 @@ def flip_item(item): (flip_item(c) for c in self.coupling), key=lambda c: c.start_distance, ) - annotations = sorted( - (flip_item(a) for a in self.annotations), + labels = sorted( + (flip_item(a) for a in self.labels), key=lambda a: a.start_distance, ) return self.model_copy( @@ -1528,7 +1526,7 @@ def flip_item(item): "optical_components": tuple(reversed(self.optical_components)), "geometry": tuple(geometry), "coupling": tuple(coupling), - "annotations": tuple(annotations), + "labels": tuple(labels), } ) @@ -1572,7 +1570,7 @@ def shift_item(item): return item.model_copy(update=update) coupling = tuple(shift_item(c) for c in other.coupling) - annotations = tuple(shift_item(a) for a in other.annotations) + labels = tuple(shift_item(a) for a in other.labels) return self.model_copy( update={ "optical_components": ( @@ -1581,7 +1579,7 @@ def shift_item(item): ), "geometry": (*self.geometry, *geometry), "coupling": (*self.coupling, *coupling), - "annotations": (*self.annotations, *annotations), + "labels": (*self.labels, *labels), "measurements": (*self.measurements, *other.measurements), } ) @@ -2172,7 +2170,7 @@ def _coord_names(self) -> tuple[str, ...]: Return the per-channel names this inventory's paths could define. The CRS's axes and optical distance come from the model, while the - annotation groups and the tracks which are actually described come + label groups and the tracks which are actually described come from the paths themselves: an inventory with no coupling track has no coupling to select on. """ @@ -2186,7 +2184,7 @@ def _coord_names(self) -> tuple[str, ...]: crs = self.coordinate_reference_system columns: dict[str, None] = {} for path in self._optical_paths(): - groups.update(dict.fromkeys(x.group for x in path.annotations if x.group)) + groups.update(dict.fromkeys(x.group for x in path.labels if x.group)) # The axes are listed above whatever any path states; what a # path adds is the columns which are not positions. for segment in path.geometry: @@ -2367,7 +2365,7 @@ def replace(self, old, new) -> Self: retroactively. ``old`` is matched by equality at any addressable level: networks, stations, channels, fiber arrays, acquisitions, optical paths, and path track items (components, geometry, coupling, - annotations); pooled resources are addressed by their resource_id. + labels); pooled resources are addressed by their resource_id. An ``old`` matching more than one item is ambiguous and raises. Singletons such as the CRS or a distance map are corrected with ``new()`` on their parent. ``new`` must be the same type as ``old``. @@ -2430,7 +2428,7 @@ def swap(items): "optical_components": swap(path.optical_components), "geometry": swap(path.geometry), "coupling": swap(path.coupling), - "annotations": swap(path.annotations), + "labels": swap(path.labels), } ) ) diff --git a/dascore/core/inventory_loader.py b/dascore/core/inventory_loader.py index 15af9fd35..80f770f30 100644 --- a/dascore/core/inventory_loader.py +++ b/dascore/core/inventory_loader.py @@ -489,7 +489,7 @@ class _Table(NamedTuple): _TABLES: Mapping[str, _Table] = { "optical_components": _Table(order="sequence", places=True), "coupling": _Table(), - "annotations": _Table(), + "labels": _Table(), "geometry": _Table( points=True, group="segment", order="distance", columns="coordinates" ), @@ -770,10 +770,15 @@ def _merge_tables(data: dict, entity: Path, model, crs, attrs: Path) -> None: """ Fill the attributes an entity directory's tables state. - A table is matched to the model purely by name, so a stem which names - no attribute of the declared type is a typo rather than a new track, - and an attribute stated both inline and as a table is one fact spelled - twice. + A table is matched to the model purely by name, and an attribute stated + both inline and as a table is one fact spelled twice. + + A stem naming no attribute at all is left where it lies. An entity + directory is somewhere a crew keeps its own working files, and this + format has no claim on a spreadsheet which never said it was one -- + the same indifference the loader shows a photo or a field note. A stem + which names a real attribute this format does not read as a table is a + different matter, and still raises: that one did say it was one. """ for child in sorted(entity.iterdir()): if child.name.startswith(".") or child.is_dir(): @@ -782,11 +787,7 @@ def _merge_tables(data: dict, entity: Path, model, crs, attrs: Path) -> None: continue stem = _table_stem(child) if stem not in model.model_fields: - msg = ( - f"{_quote(child)} names no attribute of {model.__name__}, which " - f"{_quote(attrs)} declares." - ) - raise InvalidInventoryError(msg) + continue if (table := _TABLES.get(stem)) is None: # Not "is not row-shaped": Station.channels is as row-shaped as # anything here and still has no table, so saying that would be @@ -833,8 +834,8 @@ def _read_track_table(path: Path, table: _Table, stem: str, crs): frame, units = _geometry_columns(frame, crs, path) if not table.points: rows = _object_rows(frame, table, path) - if stem == "annotations": - _parse_annotations(rows, path) + if stem == "labels": + _parse_labels(rows, path) return rows built = _point_rows(frame, table, path, units) # A single object rather than a collection: the table has no grouping @@ -856,7 +857,7 @@ def _geometry_columns(frame: pd.DataFrame, crs, path: Path): may carry its units in parentheses. The axes are all stated or none are, since guessing the missing one is not a reader's job. Text is refused: a value which varies along the fiber without being a number - is what annotations are for. + is what labels are for. """ def is_axis(name: str) -> bool: @@ -910,16 +911,16 @@ def is_axis(name: str) -> bool: f"{_quote(path)} states {frame.loc[bad, column].iloc[0]!r} in " f"column {column!r}, which is not a number. A geometry column " "is numeric; text which varies along the fiber belongs in " - "annotations.csv." + "labels.csv." ) raise InvalidInventoryError(msg) frame[column] = values return frame, units -def _parse_annotations(rows: list[dict], path: Path) -> None: +def _parse_labels(rows: list[dict], path: Path) -> None: """ - Read each annotation's value as its own text states it, in place. + Read each label's value as its own text states it, in place. A group's kind is decided by its values, and the model makes the kind decide the group's shape, so a group which mixes kinds would be two diff --git a/dascore/core/spool.py b/dascore/core/spool.py index b4e5bd412..2683b47e0 100644 --- a/dascore/core/spool.py +++ b/dascore/core/spool.py @@ -617,7 +617,7 @@ def _channel_selectors( A name which is also an attr resolves to the attr, as bare names always do, so only one the caller put in `_coords` is read as the - coordinate — an annotation group may share an acquisition field's + coordinate — a label group may share an acquisition field's name, and selecting on the field must keep working. A name the index already uses for a coordinate keeps that meaning outright: `distance` is the patch's own axis whether or not an inventory @@ -1011,7 +1011,7 @@ def expand_by( """ Expand the spool into one patch per value of an inventory coordinate. - Most often an annotation group. Every kind of group expands: a + Most often a label group. Every kind of group expands: a categorical one by each of its strings, a membership group into the channels it includes and those it does not, and a numeric one by each distinct measurement. Intervals of one group may overlap, diff --git a/dascore/examples.py b/dascore/examples.py index 3b113c112..e1936a6a7 100644 --- a/dascore/examples.py +++ b/dascore/examples.py @@ -25,7 +25,7 @@ Inventory, Network, OpticalPath, - OpticalPathAnnotation, + OpticalPathLabel, ) from dascore.exceptions import UnknownExampleError from dascore.utils.downloader import fetch @@ -773,7 +773,7 @@ def inventory_patch_pair(): The patch is the random DAS example carrying the acquisition key of the inventory's one acquisition. That acquisition places its 300 channels on an optical path through a measured two-point distance map, so the path's - geometry, coupling, and annotations project onto the patch. Used by the + geometry, coupling, and labels project onto the patch. Used by the enrich documentation and tests. """ patch = random_patch(acquisition_key="DAS.R2D1..RAW") @@ -822,16 +822,14 @@ def inventory_patch_pair(): medium="soil", ), ), - annotations=( - OpticalPathAnnotation( + labels=( + OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="zone", value="north" ), - OpticalPathAnnotation( + OpticalPathLabel( start_distance=200.0, end_distance=400.0, group="zone", value="south" ), - OpticalPathAnnotation( - start_distance=150.0, end_distance=300.0, group="noisy" - ), + OpticalPathLabel(start_distance=150.0, end_distance=300.0, group="noisy"), ), ) inventory = Inventory( diff --git a/dascore/proc/inventory.py b/dascore/proc/inventory.py index 3a3e2ab05..d56037a52 100644 --- a/dascore/proc/inventory.py +++ b/dascore/proc/inventory.py @@ -326,7 +326,7 @@ def _get_blanket_coord_names(inventory, path) -> list[str]: """ Return the coordinate names a blanket request copies. - The geometry columns and the annotation groups: what the path says about + The geometry columns and the label groups: what the path says about each channel. Optical distance and the typed-track fields are asked for by name, since they restate what the patch's own axis and the inventory already record. @@ -338,7 +338,7 @@ def _get_blanket_coord_names(inventory, path) -> list[str]: axes = {x for segment in path.geometry for x in axis_columns(segment, crs)} out = ["x", "y", "z"][: len(labels)] if axes else [] out += [x for x in path.geometry_columns() if x not in axes] - seen = dict.fromkeys(x.group for x in path.annotations) + seen = dict.fromkeys(x.group for x in path.labels) return out + [x for x in seen if x] diff --git a/dascore/utils/intervals.py b/dascore/utils/intervals.py index 010b36873..1e40187b5 100644 --- a/dascore/utils/intervals.py +++ b/dascore/utils/intervals.py @@ -183,6 +183,6 @@ def normalize_value(value, error: type[Exception] = ParameterError): if isinstance(value, np.generic): value = value.item() if isinstance(value, float) and not np.isfinite(value): - msg = f"Annotation value must be finite; got {value}." + msg = f"A value must be finite; got {value}." raise error(msg) return value diff --git a/docs/notes/inventory_attachment.qmd b/docs/notes/inventory_attachment.qmd index 33a936bf3..f5b3a5220 100644 --- a/docs/notes/inventory_attachment.qmd +++ b/docs/notes/inventory_attachment.qmd @@ -64,7 +64,7 @@ start = spool.get_contents()["time_min"].min() assert len(spool.select(time=(start, None))) == 1 assert reference._inventory is None -# `zone` is an annotation group; nothing but the inventory knows it. +# `zone` is an label group; nothing but the inventory knows it. _ = spool.select(zone="north") assert reference._inventory is not None ``` diff --git a/docs/recipes/tunnel_inventory.qmd b/docs/recipes/tunnel_inventory.qmd index 41155c4e3..7d3acec56 100644 --- a/docs/recipes/tunnel_inventory.qmd +++ b/docs/recipes/tunnel_inventory.qmd @@ -327,12 +327,12 @@ The coil is `coiled` rather than `trench`, which is the whole reason that value # Everything else -Annotations are for what the model has no field for. Each group becomes a coordinate on the patches, named after the group, so pick names you would want to select on later. Here `section` says what kind of installation a channel belongs to, and `borehole` numbers the holes. +Labels are for what the model has no field for. Each group becomes a coordinate on the patches, named after the group, so pick names you would want to select on later. Here `section` says what kind of installation a channel belongs to, and `borehole` numbers the holes. ```{python} #| code-fold: true -#| code-summary: "path.00/annotations.csv" -def annotation_table(at, trench_parts): +#| code-summary: "path.00/labels.csv" +def label_table(at, trench_parts): """Which section a channel is in, and which borehole if it is in one.""" rows = [(*at[name], "section", "trench") for name in trench_parts] rows.append((*at["cable coil at C"], "section", "coil")) @@ -345,10 +345,10 @@ def annotation_table(at, trench_parts): ) -annotations = annotation_table(at, TRENCH_PARTS) -annotations.to_csv(root / PATH / "annotations.csv", index=False) +labels = label_table(at, TRENCH_PARTS) +labels.to_csv(root / PATH / "labels.csv", index=False) -annotations +labels ``` A group holds one kind of value: `section` is text in every row and `borehole` is a number in every row. Mixing them would be two tracks sharing a name, and is refused when the directory is read. @@ -392,7 +392,7 @@ names = inventory.get_names() print("coords:", [x for x in names.coords if "." not in x]) ``` -`section` and `borehole` are among the coordinates because the annotations table named them. `x`, `y`, and `z` are there because the CRS declares those axes and the geometry table resolves to them. +`section` and `borehole` are among the coordinates because the labels table named them. `x`, `y`, and `z` are there because the CRS declares those axes and the geometry table resolves to them. # What the data gets out of it @@ -514,8 +514,8 @@ geometry_table(repaired_at, repaired_runs).to_csv( coupling_table(repaired_at, repaired_trench).to_csv( root / EPOCH / "coupling.csv", index=False ) -annotation_table(repaired_at, repaired_trench).to_csv( - root / EPOCH / "annotations.csv", index=False +label_table(repaired_at, repaired_trench).to_csv( + root / EPOCH / "labels.csv", index=False ) repaired = dc.inventory(root) diff --git a/docs/tutorial/inventory.qmd b/docs/tutorial/inventory.qmd index 4da6fae61..99f7ccde6 100644 --- a/docs/tutorial/inventory.qmd +++ b/docs/tutorial/inventory.qmd @@ -33,10 +33,10 @@ flowchart LR OpticalPath -->|optical_components| Components["FiberSegment · Splice · Connector · Terminator"] OpticalPath -->|geometry| Geometry OpticalPath -->|coupling| CouplingCondition - OpticalPath -->|annotations| OpticalPathAnnotation + OpticalPath -->|labels| OpticalPathLabel ``` -The components are the ordered physical pieces the light passes through, and they are what gives the path its length: each one tiles the interval after the last, so the path ends where the final component does. The other three describe intervals of that length — the geometry holds the measured curves along the fiber, of which the columns the coordinate reference system names are position; the coupling says how the fiber is attached to the ground there; and the annotations name anything else worth recording per interval. Each of those covers what it covers, and none of them has to cover the whole path. +The components are the ordered physical pieces the light passes through, and they are what gives the path its length: each one tiles the interval after the last, so the path ends where the final component does. The other three describe intervals of that length — the geometry holds the measured curves along the fiber, of which the columns the coordinate reference system names are position; the coupling says how the fiber is attached to the ground there; and the labels name anything else worth recording per interval. Each of those covers what it covers, and none of them has to cover the whole path. Objects reused in several places — interrogators, cables, enclosures, measurements — are written once under the inventory's `resources` and referred to elsewhere by their `resource_id`. Such a field accepts either the object itself or that string, which is what the dashed edges below mean. @@ -112,7 +112,7 @@ files = { "past the coil,250,1340\n" "past the coil,400,1490\n" ), - "fiber_arrays/DAS.R2D1/path/annotations.csv": ( + "fiber_arrays/DAS.R2D1/path/labels.csv": ( "start_distance,end_distance,group,value\n" "100,250,zone,north\n" "250,400,zone,south\n" @@ -136,7 +136,7 @@ A few rules make that directory readable without a schema in front of you: - **`path` is the one reserved *container* name.** A directory called `path` addresses an optical path rather than serializing an attribute named "path". (`attrs` and `inventory` are reserved as file stems — an entity's own object file, and the envelope — and the top-level directory names above are fixed.) - **Files that participate in no convention are ignored.** Photos, field notes, and deployment logs can live in the inventory directory undisturbed. -Two tracks can hold something that varies along the fiber, and which one to use is decided by what the values are. **Numbers go in `geometry.csv`**, one column per quantity, interpolated between the control points which state them; a column may name its units in its header, `chainage (m)`. **Text and vocabulary go in `annotations.csv`**, which is a set of intervals rather than a curve — a zone name has no meaning between two of them. A column of text in a geometry table is refused, and says so. +Two tracks can hold something that varies along the fiber, and which one to use is decided by what the values are. **Numbers go in `geometry.csv`**, one column per quantity, interpolated between the control points which state them; a column may name its units in its header, `chainage (m)`. **Text and vocabulary go in `labels.csv`**, which is a set of intervals rather than a curve — a zone name has no meaning between two of them. A column of text in a geometry table is refused, and says so. Position is not a separate kind of thing: a column whose name the coordinate reference system declares — or its canonical `x`, `y`, `z` alias — *is* that axis, takes its units from the CRS, and must be stated with all its siblings, since half a position is not a position. A segment which names none of them, like the chainage above, states no position at all and is perfectly valid. The `segment` names themselves are for humans; what a channel gets is the columns. @@ -171,12 +171,12 @@ names = inventory.get_names() print("attrs:", names.attrs[:4], "...") print("coords:", [x for x in names.coords if "." not in x]) -# The annotation groups and the geometry column named in the CSVs. +# The label groups and the geometry column named in the CSVs. assert {"zone", "noisy", "chainage"} <= set(names.coords) assert "gauge_length" in names.attrs ``` -`zone` and `noisy` are there because the annotations CSV named them, and `chainage` because the geometry CSV did: a group and a column each become a coordinate under their own name. `coupling`, `geometry`, and `optical_components` are there for the same reason — a track contributes its name only where a path actually describes it. `distance` is where each channel sits on the optical path, and `x`/`y`/`z` and `longitude`/`latitude`/`elevation` are the two spellings of the axes the inventory's coordinate reference system declares. +`zone` and `noisy` are there because the labels CSV named them, and `chainage` because the geometry CSV did: a group and a column each become a coordinate under their own name. `coupling`, `geometry`, and `optical_components` are there for the same reason — a track contributes its name only where a path actually describes it. `distance` is where each channel sits on the optical path, and `x`/`y`/`z` and `longitude`/`latitude`/`elevation` are the two spellings of the axes the inventory's coordinate reference system declares. Listing a name is not promising a value for it. This example's geometry states chainage and no position, so the spatial names resolve to nothing until some segment states them. diff --git a/tests/test_core/test_inventory.py b/tests/test_core/test_inventory.py index b6e15308a..0ef6f6968 100644 --- a/tests/test_core/test_inventory.py +++ b/tests/test_core/test_inventory.py @@ -49,8 +49,8 @@ def build_inventory() -> inv.Inventory: start_distance=0.0, end_distance=200.0, coupling_type="trench" ), ), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=0.0, end_distance=100.0, group="zone", value="east" ), ), @@ -112,20 +112,20 @@ def build_full_inventory() -> inv.Inventory: depth=1.0, ), ), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="zone", value="north" ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=150.0, end_distance=300.0, group="noisy", value=True ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=150.0, end_distance=300.0, group="quiet", value=False ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="count", value=0 ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=200.0, end_distance=300.0, group="offset", value=0.0 ), ), @@ -186,12 +186,12 @@ class TestGeometryColumns: """A geometry states named numeric columns, of which some are axes.""" @staticmethod - def _inventory(*geometry, crs=None, annotations=()): + def _inventory(*geometry, crs=None, labels=()): """Wrap geometry segments in the smallest inventory holding them.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=1000.0),), geometry=geometry, - annotations=annotations, + labels=labels, ) array = inv.FiberArray(code="L001", optical_paths=(path,)) return inv.Inventory( @@ -289,11 +289,11 @@ def test_a_reserved_column_name(self): def test_a_column_which_is_also_an_annotation_group(self): """One name is one coordinate, whichever track would define it.""" column = inv.Geometry(distance=(0.0, 10.0), coordinates={"zone": (0.0, 1.0)}) - annotation = inv.OpticalPathAnnotation( + label = inv.OpticalPathLabel( start_distance=0.0, end_distance=10.0, group="zone", value=1.0 ) with pytest.raises(InvalidInventoryError, match="one name is one coordinate"): - self._inventory(column, annotations=(annotation,)).check() + self._inventory(column, labels=(label,)).check() def test_units_on_an_axis_are_refused(self): """The CRS states the units of its own axes.""" @@ -540,17 +540,17 @@ def test_geometry_overlap_raises(self): path.check() def test_boolean_annotations_overlap_freely(self): - """Membership annotations overlap, within and across groups.""" + """Membership labels overlap, within and across groups.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=0.0, end_distance=60.0, group="noisy" ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=50.0, end_distance=70.0, group="noisy" ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=40.0, end_distance=80.0, group="repaired" ), ), @@ -561,14 +561,14 @@ def test_valued_annotation_groups_may_not_overlap(self): """A single-valued group cannot claim two values at one distance.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=0.0, end_distance=60.0, group="rock_type", value="granite", ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=50.0, end_distance=70.0, group="rock_type", @@ -583,11 +583,11 @@ def test_annotation_group_holds_one_kind_of_value(self): """Mixing value kinds in one group is a modeling error.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=0.0, end_distance=10.0, group="zone", value="east" ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=20.0, end_distance=30.0, group="zone", value=True ), ), @@ -599,14 +599,14 @@ def test_numeric_annotation_group(self): """Numeric groups are single valued but otherwise ordinary.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=0.0, end_distance=40.0, group="frost_depth", value=1.2, ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=40.0, end_distance=90.0, group="frost_depth", @@ -1017,8 +1017,8 @@ def test_reverse_involution(self, path): def test_reverse_rewrites_all_tracks(self, path): """Reverse rewrites all tracks.""" rev = path.reverse() - # 0-100 annotation on a 0-250 path becomes 150-250. - assert rev.annotations[0].interval == (150.0, 250.0) + # 0-100 label on a 0-250 path becomes 150-250. + assert rev.labels[0].interval == (150.0, 250.0) # 0-200 coupling becomes 50-250. assert rev.coupling[0].interval == (50.0, 250.0) rev.check() @@ -1867,18 +1867,18 @@ def test_point_clamp_inside_span_is_legal(self): assert path.check() is path def test_point_annotation(self): - """Point annotation.""" - anno = inv.OpticalPathAnnotation( + """Point label.""" + label = inv.OpticalPathLabel( start_distance=350.0, end_distance=350.0, group="wellhead" ) - assert anno.interval == (350.0, 350.0) + assert label.interval == (350.0, 350.0) def test_point_markers_survive_select(self): """A clamp inside the clip is not coverage, but it is not nothing.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=50.0, end_distance=50.0, group="clamp" ), ), @@ -1889,33 +1889,33 @@ def test_point_markers_survive_select(self): ), ) kept = path.select(distance=(10.0, 90.0)) - assert [x.interval for x in kept.annotations] == [(50.0, 50.0)] + assert [x.interval for x in kept.labels] == [(50.0, 50.0)] assert [x.interval for x in kept.coupling] == [(25.0, 25.0)] def test_point_markers_outside_the_clip_are_dropped(self): """A marker beyond the requested window does not belong to the piece.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=95.0, end_distance=95.0, group="clamp" ), ), ) - assert path.select(distance=(10.0, 90.0)).annotations == () + assert path.select(distance=(10.0, 90.0)).labels == () def test_point_marker_at_the_outer_endpoint_is_kept(self): """The outermost endpoint of the path is included, as everywhere.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=100.0, end_distance=100.0, group="end_cap" ), ), ) kept = path.select(distance=(10.0, 100.0)) - assert [x.interval for x in kept.annotations] == [(100.0, 100.0)] + assert [x.interval for x in kept.labels] == [(100.0, 100.0)] def test_reversed_interval_rejected(self): """An end before the start is rejected.""" @@ -2256,13 +2256,13 @@ def test_crs_axis_count(self): units=("meter",) * 4, ) - def test_annotation_value_keeps_numpy_type(self): + def test_label_value_keeps_numpy_type(self): """A mask element is a flag, not the number one.""" - annotation = inv.OpticalPathAnnotation( + label = inv.OpticalPathLabel( start_distance=0.0, end_distance=1.0, group="noisy", value=np.bool_(True) ) - assert annotation.value is True - counted = inv.OpticalPathAnnotation( + assert label.value is True + counted = inv.OpticalPathLabel( start_distance=0.0, end_distance=1.0, group="shots", value=np.int64(5) ) assert isinstance(counted.value, int) and not isinstance(counted.value, bool) @@ -2274,10 +2274,10 @@ def test_physical_quantities_must_be_finite(self): with pytest.raises(ValidationError): inv.FiberSegment(optical_length=10.0, loss_db=(0.4, np.inf)) - def test_annotation_value_must_be_finite(self): + def test_label_value_must_be_finite(self): """A non-finite value cannot survive a JSON round trip.""" with pytest.raises(ValidationError, match="must be finite"): - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=0.0, end_distance=1.0, group="g", value=np.inf ) @@ -2294,20 +2294,20 @@ def _sample_inventories() -> dict[str, inv.Inventory]: ) cable = inv.Cable(resource_id="cable-1", name="trunk") segment = inv.FiberSegment(name="run", optical_length=100.0, container=cable) - annotations = ( - inv.OpticalPathAnnotation( + labels = ( + inv.OpticalPathLabel( start_distance=0.0, end_distance=50.0, group="zone", value="east" ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=0.0, end_distance=50.0, group="noisy", value=True ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=0.0, end_distance=50.0, group="masked", value=False ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=0.0, end_distance=50.0, group="shots", value=0 ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=50.0, end_distance=100.0, group="offset", value=0.0 ), ) @@ -2351,7 +2351,7 @@ def array(code, paths=(), **kwargs): inv.OpticalPath( location_code="01", optical_components=(segment,), - annotations=annotations, + labels=labels, ), ), ), @@ -2464,16 +2464,16 @@ def test_round_trip_equals(self, name): inventory = SAMPLE_INVENTORIES[name] assert dc.inventory(inventory.to_yaml()) == inventory - def test_an_annotation_value_of_one_survives(self): + def test_an_label_value_of_one_survives(self): """`1 == True`, and the value's default is True, so it was dropped.""" pytest.importorskip("yaml") path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=0.0, end_distance=10.0, group="hole", value=1 ), - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=20.0, end_distance=30.0, group="hole", value=2 ), ), @@ -2490,27 +2490,27 @@ def test_an_annotation_value_of_one_survives(self): def test_an_annotation_still_names_its_class(self): """Restoring the value must not displace the document's tag.""" - annotation = inv.OpticalPathAnnotation( + label = inv.OpticalPathLabel( start_distance=0.0, end_distance=1.0, group="hole", value=2 ) - dumped = annotation.model_dump(mode="json") - assert dumped["object_type"] == "OpticalPathAnnotation" + dumped = label.model_dump(mode="json") + assert dumped["object_type"] == "OpticalPathLabel" def test_a_deliberately_excluded_value_stays_out(self): """What a caller filtered is not what exclude_defaults dropped.""" - annotation = inv.OpticalPathAnnotation( + label = inv.OpticalPathLabel( start_distance=0.0, end_distance=1.0, group="hole", value=2 ) - assert "value" not in annotation.model_dump(mode="json", exclude={"value"}) - assert "value" not in annotation.model_dump(mode="json", include={"group"}) + assert "value" not in label.model_dump(mode="json", exclude={"value"}) + assert "value" not in label.model_dump(mode="json", include={"group"}) def test_a_flag_annotation_stays_terse(self): """A value which really is the default is still left out.""" pytest.importorskip("yaml") path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), - annotations=( - inv.OpticalPathAnnotation( + labels=( + inv.OpticalPathLabel( start_distance=0.0, end_distance=10.0, group="noisy" ), ), @@ -2594,7 +2594,7 @@ def test_document_states_its_schema_version(self): other = inv.Inventory(schema_version=default + 1) assert dc.inventory(other.to_yaml()).schema_version == default + 1 - def test_empty_annotation_value_is_rejected(self): + def test_empty_label_value_is_rejected(self): """An empty value would have to survive serialization to mean anything. It used to be legal, and this guarded it against being pruned and @@ -2603,7 +2603,7 @@ def test_empty_annotation_value_is_rejected(self): the empty string, so a covered one could not be told apart. """ with pytest.raises(ValidationError, match="may not be the empty string"): - inv.OpticalPathAnnotation( + inv.OpticalPathLabel( start_distance=0.0, end_distance=50.0, group="rock", value="" ) @@ -2746,12 +2746,12 @@ def test_a_declaration_naming_nothing_is_refused(self, monkeypatch): inv._track_identity_fields() def test_coords_hold_the_tracks_and_groups(self, names): - """The path's tracks, their fields, and its annotation groups.""" + """The path's tracks, their fields, and its label groups.""" assert "coupling" in names.coords # bare: the identity field assert "coupling.medium" in names.coords assert "geometry" in names.coords assert "optical_components.fiber_type" in names.coords - assert "zone" in names.coords # the annotation group + assert "zone" in names.coords # the label group def test_coords_hold_both_axis_spellings(self, names): """A CRS axis is selectable as stored and as this CRS reads it.""" @@ -2779,7 +2779,7 @@ def test_coords_omit_absent_tracks(self): """An inventory with no coupling has no coupling to select on.""" base = build_inventory() path = base.networks[0].fiber_arrays[0].optical_paths[0] - bare = base.replace(path, path.new(coupling=(), annotations=())) + bare = base.replace(path, path.new(coupling=(), labels=())) coords = set(bare.get_names().coords) assert not {x for x in coords if x.startswith("coupling")} assert "zone" not in coords diff --git a/tests/test_core/test_inventory_loader.py b/tests/test_core/test_inventory_loader.py index e110d7d82..0b8d477bf 100644 --- a/tests/test_core/test_inventory_loader.py +++ b/tests/test_core/test_inventory_loader.py @@ -1075,7 +1075,7 @@ def test_inventory_models_refuse_unknown_input(self): "0,340,conduit,\n" "340,355,trench,backfilled\n" ), - "fiber_arrays/DAS.L001/path/annotations.csv": ( + "fiber_arrays/DAS.L001/path/labels.csv": ( "start_distance,end_distance,group,value\n" "0,340,rock_type,granite\n" "0,120,noisy,true\n" @@ -1102,7 +1102,7 @@ def test_every_shape_at_once(self, make_inventory): path = one_path(make_inventory({**MINIMAL, **TRACKS})) assert [x.name for x in path.optical_components] == ["fiber 1", "splice 1"] assert [x.coupling_type for x in path.coupling] == ["conduit", "trench"] - assert {x.group for x in path.annotations} == { + assert {x.group for x in path.labels} == { "rock_type", "noisy", "frost_depth", @@ -1132,15 +1132,15 @@ def test_rows_are_read_in_the_order_they_state(self, make_inventory): ], ) def test_a_value_is_read_as_its_text_states(self, make_inventory, text, expected): - """A CSV has no types, so an annotation's value is read by content.""" + """A CSV has no types, so an label's value is read by content.""" files = { **MINIMAL, **TRACKS, - "fiber_arrays/DAS.L001/path/annotations.csv": ( + "fiber_arrays/DAS.L001/path/labels.csv": ( f"start_distance,end_distance,group,value\n0,340,g,{text}\n" ), } - value = one_path(make_inventory(files)).annotations[0].value + value = one_path(make_inventory(files)).labels[0].value assert value == expected and isinstance(value, type(expected)) def test_a_group_holding_two_kinds(self, make_inventory): @@ -1148,7 +1148,7 @@ def test_a_group_holding_two_kinds(self, make_inventory): files = { **MINIMAL, **TRACKS, - "fiber_arrays/DAS.L001/path/annotations.csv": ( + "fiber_arrays/DAS.L001/path/labels.csv": ( "start_distance,end_distance,group,value\n" "0,120,zone,north\n" "120,340,zone,true\n" @@ -1210,7 +1210,7 @@ def test_units_on_an_axis_header(self, make_inventory): make_inventory(files) def test_a_column_of_text_is_refused(self, make_inventory): - """Text along distance is what annotations are for.""" + """Text along distance is what labels are for.""" files = { **MINIMAL, **TRACKS, @@ -1218,7 +1218,7 @@ def test_a_column_of_text_is_refused(self, make_inventory): "segment,distance,zone\nS100,100.0,north\nS100,102.0,south\n" ), } - with pytest.raises(InvalidInventoryError, match=r"annotations\.csv"): + with pytest.raises(InvalidInventoryError, match=r"labels\.csv"): make_inventory(files) def test_a_structural_column_restated_with_units(self, make_inventory): @@ -1340,14 +1340,20 @@ def test_a_single_object_table(self, make_inventory): assert acquisition.channel_to_distance([512])[0] == 500.0 def test_a_stem_naming_no_attribute(self, make_inventory): - """A table is matched to the model by name, so a typo is a typo.""" + """A spreadsheet which never said it was a track is left where it lies. + + An entity directory is somewhere a crew keeps its own working files, + and this format has no claim on one it does not recognise. + """ files = { **MINIMAL, "fiber_arrays/DAS.L001/attrs.yaml": "object_type: FiberArray\n", "fiber_arrays/DAS.L001/geometrys.csv": "segment,distance\nS,0\n", + "fiber_arrays/DAS.L001/crew_notes.csv": "who,when\nderrick,tuesday\n", } - with pytest.raises(InvalidInventoryError, match="names no attribute"): - make_inventory(files) + inventory = make_inventory(files) + (array,) = inventory.networks[0].fiber_arrays + assert array.code == "L001" def test_a_stem_naming_a_field_which_is_not_row_shaped(self, make_inventory): """Only an attribute rows can build may be stated as a table.""" @@ -1641,12 +1647,12 @@ def test_an_annotation_stating_no_value(self, make_inventory): files = { **MINIMAL, **TRACKS, - "fiber_arrays/DAS.L001/path/annotations.csv": ( + "fiber_arrays/DAS.L001/path/labels.csv": ( "start_distance,end_distance,group,value\n0,120,noisy,\n" ), } - annotation = one_path(make_inventory(files)).annotations[0] - assert annotation.value is True + label = one_path(make_inventory(files)).labels[0] + assert label.value is True def test_a_path_restating_a_start_which_disagrees(self, make_inventory): """A path directory's name is a restated address like any other.""" @@ -1730,7 +1736,7 @@ def test_a_cell_which_does_not_pertain_to_its_row(self, make_inventory): DECLARED_BY = { "optical_components": inv.OpticalPath, "coupling": inv.OpticalPath, - "annotations": inv.OpticalPath, + "labels": inv.OpticalPath, "geometry": inv.OpticalPath, "distance_map": inv.Acquisition, } @@ -1946,12 +1952,12 @@ def test_an_int_and_a_float_are_one_kind(self, make_inventory, order): files = { **MINIMAL, **TRACKS, - "fiber_arrays/DAS.L001/path/annotations.csv": ( + "fiber_arrays/DAS.L001/path/labels.csv": ( f"start_distance,end_distance,group,value\n" f"0,120,thickness,{first}\n120,340,thickness,{second}\n" ), } - values = [x.value for x in one_path(make_inventory(files)).annotations] + values = [x.value for x in one_path(make_inventory(files)).labels] assert sorted(values) == [1, 1.5] @pytest.mark.parametrize("text", ["TRUE", "True", " true "]) @@ -1960,22 +1966,22 @@ def test_a_boolean_however_a_spreadsheet_writes_it(self, make_inventory, text): files = { **MINIMAL, **TRACKS, - "fiber_arrays/DAS.L001/path/annotations.csv": ( + "fiber_arrays/DAS.L001/path/labels.csv": ( f"start_distance,end_distance,group,value\n0,120,noisy,{text}\n" ), } - assert one_path(make_inventory(files)).annotations[0].value is True + assert one_path(make_inventory(files)).labels[0].value is True def test_a_decimal_point_keeps_a_value_a_float(self, make_inventory): """1.0 is written as a float and stays one, unlike 1.""" files = { **MINIMAL, **TRACKS, - "fiber_arrays/DAS.L001/path/annotations.csv": ( + "fiber_arrays/DAS.L001/path/labels.csv": ( "start_distance,end_distance,group,value\n0,120,thickness,1.0\n" ), } - value = one_path(make_inventory(files)).annotations[0].value + value = one_path(make_inventory(files)).labels[0].value assert isinstance(value, float) and value == 1.0 def test_an_epoch_ending_exactly_where_the_next_begins(self, make_inventory): diff --git a/tests/test_core/test_spool_inventory.py b/tests/test_core/test_spool_inventory.py index cff5ec554..2dba2744f 100644 --- a/tests/test_core/test_spool_inventory.py +++ b/tests/test_core/test_spool_inventory.py @@ -44,7 +44,7 @@ Network, OpticalMeasurement, OpticalPath, - OpticalPathAnnotation, + OpticalPathLabel, ) from dascore.core.inventory_loader import BLESSED_NAME from dascore.examples import get_example_patch, inventory_patch_pair @@ -1497,7 +1497,7 @@ class TestSharedNames: def test_a_group_may_share_an_attrs_name(self, patch, inventory): """ - An annotation group is free to be named after an acquisition field. + An label group is free to be named after an acquisition field. Bare names resolve to attrs first, so selecting on the field has to keep working; only a caller who asked for `_coords` means the @@ -1507,9 +1507,9 @@ def test_a_group_may_share_an_attrs_name(self, patch, inventory): clash = inventory.replace( path, path.new( - annotations=( - *path.annotations, - OpticalPathAnnotation( + labels=( + *path.labels, + OpticalPathLabel( start_distance=0.0, end_distance=100.0, group="gauge_length", @@ -1866,12 +1866,12 @@ def test_each_piece_enriches_from_its_own_epoch(self, patch, inventory): """ coord = patch.get_coord("time") when = coord.min() + (coord.max() - coord.min()) / 2 - moved = inventory.networks[0].fiber_arrays[0].optical_paths[0].annotations[0] + moved = inventory.networks[0].fiber_arrays[0].optical_paths[0].labels[0] split = _split_epochs( inventory, when, second={ - "annotations": ( + "labels": ( moved.new(value="moved", start_distance=100.0, end_distance=400.0), ) }, @@ -2185,12 +2185,12 @@ def two_zones(inventory): return inventory.replace( path, path.new( - annotations=( - *path.annotations, - OpticalPathAnnotation( + labels=( + *path.labels, + OpticalPathLabel( start_distance=110.0, end_distance=150.0, group="hole", value="a" ), - OpticalPathAnnotation( + OpticalPathLabel( start_distance=300.0, end_distance=340.0, group="hole", value="a" ), ) @@ -2714,9 +2714,9 @@ def test_none_on_a_numeric_group(self, patch, inventory): numeric = inventory.replace( path, path.new( - annotations=( - *path.annotations, - OpticalPathAnnotation( + labels=( + *path.labels, + OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="frost_depth", @@ -2804,10 +2804,10 @@ def uneven_spool(patch, inventory): array = inventory.networks[0].fiber_arrays[0] acquisition, path = array.acquisitions[0], array.optical_paths[0] holes = ( - OpticalPathAnnotation( + OpticalPathLabel( start_distance=110.0, end_distance=150.0, group="hole", value="a" ), - OpticalPathAnnotation( + OpticalPathLabel( start_distance=300.0, end_distance=340.0, group="hole", value="a" ), ) @@ -2816,8 +2816,8 @@ def uneven_spool(patch, inventory): array.new( acquisitions=(acquisition, acquisition.new(location_code="01")), optical_paths=( - path.new(annotations=(*path.annotations, *holes)), - path.new(location_code="01", annotations=(*path.annotations, holes[0])), + path.new(labels=(*path.labels, *holes)), + path.new(location_code="01", labels=(*path.labels, holes[0])), ), ), ) @@ -2914,8 +2914,8 @@ def _float_grid_pair(distance, span): name="main", location_code="", optical_components=(FiberSegment(name="c", optical_length=span + 10),), - annotations=( - OpticalPathAnnotation( + labels=( + OpticalPathLabel( start_distance=span * 0.23, end_distance=span * 0.61, group="zone", @@ -3075,8 +3075,8 @@ def test_a_stamp_cannot_overwrite_the_plan(self, patch, inventory): clash = inventory.replace( path, path.new( - annotations=( - OpticalPathAnnotation( + labels=( + OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="output_id", diff --git a/tests/test_inventory_diagrams.py b/tests/test_inventory_diagrams.py index 65ae173f6..df46c904c 100644 --- a/tests/test_inventory_diagrams.py +++ b/tests/test_inventory_diagrams.py @@ -76,7 +76,7 @@ def _accepted_models(model, field): A reference is a `str` in the same union as a model, which is how the inventory spells "this may be a resource_id instead of the object". """ - annotation = get_type_hints(model)[field] + label = get_type_hints(model)[field] found, referenced = set(), False def _walk(node, in_reference_union): @@ -94,7 +94,7 @@ def _walk(node, in_reference_union): found.add(node) referenced = referenced or in_reference_union - _walk(annotation, False) + _walk(label, False) return found, referenced diff --git a/tests/test_proc/test_proc_inventory.py b/tests/test_proc/test_proc_inventory.py index de5349f6f..3e4226b85 100644 --- a/tests/test_proc/test_proc_inventory.py +++ b/tests/test_proc/test_proc_inventory.py @@ -17,7 +17,7 @@ Network, OpticalMeasurement, OpticalPath, - OpticalPathAnnotation, + OpticalPathLabel, ) from dascore.examples import inventory_patch_pair from dascore.exceptions import ( @@ -441,12 +441,12 @@ def test_membership_group(self, patch, inventory): def test_numeric_group(self, patch, inventory): """A numeric group carries NaN where uncovered.""" - annotations = ( - OpticalPathAnnotation( + labels = ( + OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="frost", value=1.5 ), ) - inv = _replace_path(inventory, annotations=annotations) + inv = _replace_path(inventory, labels=labels) out = patch.enrich(inv, attrs=False, coords=("frost",)) values = out.get_coord("frost").values assert values[0] == 1.5 @@ -525,13 +525,13 @@ def test_blanket_without_geometry(self, patch, inventory): assert "zone" in set(out.coords.coord_map) def test_point_markers_cover_nothing(self, patch, inventory): - """An annotation marking a spot documents it without covering it.""" - annotations = ( - OpticalPathAnnotation( + """An label marking a spot documents it without covering it.""" + labels = ( + OpticalPathLabel( start_distance=150.0, end_distance=150.0, group="zone", value="clamp" ), ) - inv = _replace_path(inventory, annotations=annotations) + inv = _replace_path(inventory, labels=labels) out = patch.enrich(inv, attrs=False, coords=("zone",)) values = out.get_coord("zone").values # Nothing is covered, so every channel takes the empty marker. @@ -701,7 +701,7 @@ def test_empty_coord_request_needs_no_map(self, patch, inventory): def test_blanket_needs_no_map_when_path_is_bare(self, patch, inventory): """A path with nothing to project asks nothing of the map either.""" no_map = _replace_acquisition(inventory, distance_map=None) - bare = _replace_path(no_map, geometry=(), annotations=()) + bare = _replace_path(no_map, geometry=(), labels=()) out = patch.enrich(bare) assert set(out.coords.coord_map) == set(patch.coords.coord_map) @@ -849,23 +849,23 @@ def test_single_point_instrument_map_is_an_offset(self, patch, inventory): def test_reserved_annotation_group_raises(self, inventory): """A group named after a coordinate would shadow it at enrichment.""" path = inventory.networks[0].fiber_arrays[0].optical_paths[0] - annotation = OpticalPathAnnotation( + label = OpticalPathLabel( start_distance=100.0, end_distance=200.0, group="time", value=True ) with pytest.raises(InvalidInventoryError, match="reserved name"): - path.new(annotations=(annotation,)).check() + path.new(labels=(label,)).check() def test_boolean_group_is_a_union(self, patch, inventory): """Membership groups overlap, so any covering true interval wins.""" - annotations = ( - OpticalPathAnnotation( + labels = ( + OpticalPathLabel( start_distance=100.0, end_distance=400.0, group="wet", value=True ), - OpticalPathAnnotation( + OpticalPathLabel( start_distance=200.0, end_distance=300.0, group="wet", value=False ), ) - inv = _replace_path(inventory, annotations=annotations) + inv = _replace_path(inventory, labels=labels) out = patch.enrich(inv, attrs=False, coords=("wet",)) assert out.get_coord("wet").values.all() @@ -1018,10 +1018,10 @@ def test_partly_covered_patch_can_be_written(self, patch, inventory, tmp_path): class TestEmptyIsUnambiguous: """Absence has one spelling, so nothing legitimate can wear it.""" - def test_empty_annotation_value_rejected(self): + def test_empty_label_value_rejected(self): """A group saying nothing would read as an uncovered channel.""" with pytest.raises(ValidationError, match="may not be the empty string"): - OpticalPathAnnotation( + OpticalPathLabel( start_distance=0.0, end_distance=10.0, group="zone", value="" ) From e974dec0ba57278abc2229396ecdb7a6b88d0f9a Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Mon, 17 Aug 2026 14:35:47 +0200 Subject: [PATCH 2/5] Keep catching a mistyped track name Skipping every unrecognised CSV also stopped catching a typo: a geometrys.csv loaded its directory silently without the geometry it holds. The two cases are not alike, and this loader already says which rule tells them apart -- strict about near-misses, indifferent to clean misses. A stem close to an attribute of the declared model now raises and names the one it nearly is; a stem close to nothing is still left where it lies. The cutoff is high on purpose: a loose one would start reading a crew's own filenames as bad spellings of this format's, which is the refusal the skip exists to end. An annotations.csv left behind by the rename is not close to labels, so it stays on the ignored side, which is what it was left alone for. --- dascore/core/inventory_loader.py | 30 +++++++++++++++++++++--- tests/test_core/test_inventory_loader.py | 27 +++++++++++++++++++++ 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/dascore/core/inventory_loader.py b/dascore/core/inventory_loader.py index 80f770f30..d6b9a32f8 100644 --- a/dascore/core/inventory_loader.py +++ b/dascore/core/inventory_loader.py @@ -30,6 +30,7 @@ from __future__ import annotations +import difflib import itertools import json import os @@ -766,6 +767,25 @@ def _table_stem(path: Path) -> str: return path.name[: -len(path.suffix)] +def _refuse_near_miss(stem: str, child: Path, model) -> None: + """ + Refuse a table stem which nearly names an attribute of its model. + + The cutoff is deliberately high. A low one would start reading a + crew's own files as bad spellings of this format's, which is the + refusal this indifference exists to end; the cost of missing a + stranger typo is the old behaviour, one message later. + """ + close = difflib.get_close_matches(stem, model.model_fields, n=1, cutoff=0.8) + if close: + msg = ( + f"{_quote(child)} names no attribute of {model.__name__}. Did you " + f"mean {close[0]}.csv? A file this format does not " + "recognise at all is left where it lies." + ) + raise InvalidInventoryError(msg) + + def _merge_tables(data: dict, entity: Path, model, crs, attrs: Path) -> None: """ Fill the attributes an entity directory's tables state. @@ -773,12 +793,15 @@ def _merge_tables(data: dict, entity: Path, model, crs, attrs: Path) -> None: A table is matched to the model purely by name, and an attribute stated both inline and as a table is one fact spelled twice. - A stem naming no attribute at all is left where it lies. An entity + A stem naming nothing recognisable is left where it lies. An entity directory is somewhere a crew keeps its own working files, and this format has no claim on a spreadsheet which never said it was one -- the same indifference the loader shows a photo or a field note. A stem - which names a real attribute this format does not read as a table is a - different matter, and still raises: that one did say it was one. + which nearly names an attribute is a different matter, and still + raises: `geometrys.csv` said it was a track and got it wrong, and + loading its directory silently without it would lose the data it + holds. Near-misses are strict, clean misses are ignored, which is the + rule the rest of this loader follows. """ for child in sorted(entity.iterdir()): if child.name.startswith(".") or child.is_dir(): @@ -787,6 +810,7 @@ def _merge_tables(data: dict, entity: Path, model, crs, attrs: Path) -> None: continue stem = _table_stem(child) if stem not in model.model_fields: + _refuse_near_miss(stem, child, model) continue if (table := _TABLES.get(stem)) is None: # Not "is not row-shaped": Station.channels is as row-shaped as diff --git a/tests/test_core/test_inventory_loader.py b/tests/test_core/test_inventory_loader.py index 0b8d477bf..71b32c042 100644 --- a/tests/test_core/test_inventory_loader.py +++ b/tests/test_core/test_inventory_loader.py @@ -1355,6 +1355,33 @@ def test_a_stem_naming_no_attribute(self, make_inventory): (array,) = inventory.networks[0].fiber_arrays assert array.code == "L001" + def test_a_stem_nearly_naming_an_attribute(self, make_inventory): + """A near-miss did claim to be a track, so it is not quietly dropped.""" + files = { + **MINIMAL, + "fiber_arrays/DAS.L001/attrs.yaml": "object_type: FiberArray\n", + "fiber_arrays/DAS.L001/path/attrs.yaml": "object_type: OpticalPath\n", + "fiber_arrays/DAS.L001/path/geometrys.csv": "segment,distance\nS,0\n", + } + with pytest.raises(InvalidInventoryError, match="Did you mean geometry"): + make_inventory(files) + + def test_the_superseded_annotations_table_is_left_alone(self, make_inventory): + """The table labels.csv replaced names nothing near it, so a directory + which still holds one loads rather than refusing. + """ + files = { + **MINIMAL, + "fiber_arrays/DAS.L001/attrs.yaml": "object_type: FiberArray\n", + "fiber_arrays/DAS.L001/path/attrs.yaml": "object_type: OpticalPath\n", + "fiber_arrays/DAS.L001/path/annotations.csv": ( + "start_distance,end_distance,group,value\n0,10,zone,north\n" + ), + } + inventory = make_inventory(files) + (path,) = inventory.networks[0].fiber_arrays[0].optical_paths + assert path.labels == () + def test_a_stem_naming_a_field_which_is_not_row_shaped(self, make_inventory): """Only an attribute rows can build may be stated as a table.""" files = { From a0bcfad270b6d23fed728922ef75569146d34e3e Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Mon, 17 Aug 2026 14:52:18 +0200 Subject: [PATCH 3/5] Fix the prose the rename mangled Replacing a noun does not replace the article in front of it: eleven places read "an label", two of them in errors a user is shown. And the enrich parameter docs ended up carrying both senses of the word in one sentence -- a coordinate label the CRS defines beside a label group -- which is the one place the new name genuinely collides with the old meaning of "label" in this format. --- dascore/constants.py | 6 +++--- dascore/core/_spool_inventory.py | 2 +- dascore/core/inventory.py | 12 ++++++------ docs/notes/inventory_attachment.qmd | 2 +- tests/test_core/test_inventory_loader.py | 2 +- tests/test_core/test_spool_inventory.py | 2 +- tests/test_proc/test_proc_inventory.py | 2 +- 7 files changed, 14 insertions(+), 14 deletions(-) diff --git a/dascore/constants.py b/dascore/constants.py index 84de9820a..e4e8c0cbd 100644 --- a/dascore/constants.py +++ b/dascore/constants.py @@ -280,9 +280,9 @@ def map(self, fn: Callable, iterable: Iterable, /) -> Iterable: coords True (the default) to add the geometry axes and label groups of the resolved optical path, a tuple of names to add exactly those, or - False to add none. Names may be `distance` for optical distance, a - coordinate label the inventory's CRS defines, a label group, or - a qualified track field such as `coupling.medium`. + False to add none. Names may be `distance` for optical distance, one + of the axes the inventory's CRS names, a label group, or a qualified + track field such as `coupling.medium`. """.strip() enrich_on_missing_description = """ diff --git a/dascore/core/_spool_inventory.py b/dascore/core/_spool_inventory.py index d3e3d6183..caca602d8 100644 --- a/dascore/core/_spool_inventory.py +++ b/dascore/core/_spool_inventory.py @@ -405,7 +405,7 @@ def check_stampable(name: str, rows: pd.DataFrame) -> None: """ Refuse a stamp which would overwrite the plan's own bookkeeping. - An label group may be named anything the inventory does not + A label group may be named anything the inventory does not reserve, and the stamp is assigned onto the outputs — so a group called `output_id` would replace the column binding each output to its members, and one called `time_min` an envelope. Overwriting a diff --git a/dascore/core/inventory.py b/dascore/core/inventory.py index 854a66c4f..571858e65 100644 --- a/dascore/core/inventory.py +++ b/dascore/core/inventory.py @@ -115,11 +115,11 @@ def _label_value(value): - """Normalize an label value so its Python type survives validation.""" + """Normalize a label value so its Python type survives validation.""" return normalize_value(value, error=InvalidInventoryError) -# The value kind decides an label group's shape, so it must be exact. +# The value kind decides a label group's shape, so it must be exact. LabelValue = Annotated[str | bool | int | float, BeforeValidator(_label_value)] @@ -714,14 +714,14 @@ class OpticalPathLabel(_IntervalModel): @field_validator("value") @classmethod def _reject_empty_string(cls, value): - """An label whose value is empty states nothing. + """A label whose value is empty states nothing. It would also be indistinguishable from an uncovered channel, since a string coordinate spells absence as the empty string. """ if isinstance(value, str) and not value: msg = ( - "An label value may not be the empty string; it would " + "A label value may not be the empty string; it would " "state nothing and would read as an uncovered channel." ) raise ValueError(msg) @@ -1146,7 +1146,7 @@ def _track_identity_fields() -> Mapping[str, str]: return MappingProxyType(out) -# Names an label group may not take: a group becomes a patch coordinate +# Names a label group may not take: a group becomes a patch coordinate # at enrichment, where it would shadow one of these. RESERVED_GROUP_NAMES = frozenset( {"time", "distance", "channel", "instrument_distance"} @@ -1358,7 +1358,7 @@ def _check_geometry_columns(self) -> list[str]: for name in sorted(x for x in spans if "." in x) ] errors += [ - f"{name!r} is both a geometry column and an label group; " + f"{name!r} is both a geometry column and a label group; " "one name is one coordinate." for name in sorted(set(spans) & groups) ] diff --git a/docs/notes/inventory_attachment.qmd b/docs/notes/inventory_attachment.qmd index f5b3a5220..2b6ac3a49 100644 --- a/docs/notes/inventory_attachment.qmd +++ b/docs/notes/inventory_attachment.qmd @@ -64,7 +64,7 @@ start = spool.get_contents()["time_min"].min() assert len(spool.select(time=(start, None))) == 1 assert reference._inventory is None -# `zone` is an label group; nothing but the inventory knows it. +# `zone` is a label group; nothing but the inventory knows it. _ = spool.select(zone="north") assert reference._inventory is not None ``` diff --git a/tests/test_core/test_inventory_loader.py b/tests/test_core/test_inventory_loader.py index 71b32c042..ca4dd0661 100644 --- a/tests/test_core/test_inventory_loader.py +++ b/tests/test_core/test_inventory_loader.py @@ -1132,7 +1132,7 @@ def test_rows_are_read_in_the_order_they_state(self, make_inventory): ], ) def test_a_value_is_read_as_its_text_states(self, make_inventory, text, expected): - """A CSV has no types, so an label's value is read by content.""" + """A CSV has no types, so a label's value is read by content.""" files = { **MINIMAL, **TRACKS, diff --git a/tests/test_core/test_spool_inventory.py b/tests/test_core/test_spool_inventory.py index 2dba2744f..2db8cfad6 100644 --- a/tests/test_core/test_spool_inventory.py +++ b/tests/test_core/test_spool_inventory.py @@ -1497,7 +1497,7 @@ class TestSharedNames: def test_a_group_may_share_an_attrs_name(self, patch, inventory): """ - An label group is free to be named after an acquisition field. + A label group is free to be named after an acquisition field. Bare names resolve to attrs first, so selecting on the field has to keep working; only a caller who asked for `_coords` means the diff --git a/tests/test_proc/test_proc_inventory.py b/tests/test_proc/test_proc_inventory.py index 3e4226b85..1cf508b1d 100644 --- a/tests/test_proc/test_proc_inventory.py +++ b/tests/test_proc/test_proc_inventory.py @@ -525,7 +525,7 @@ def test_blanket_without_geometry(self, patch, inventory): assert "zone" in set(out.coords.coord_map) def test_point_markers_cover_nothing(self, patch, inventory): - """An label marking a spot documents it without covering it.""" + """A label marking a spot documents it without covering it.""" labels = ( OpticalPathLabel( start_distance=150.0, end_distance=150.0, group="zone", value="clamp" From 7be6f88c15dc9895971857b8594cd22674e26f8f Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Mon, 17 Aug 2026 15:11:40 +0200 Subject: [PATCH 4/5] Answer two adversarial reviews of the rename Two blind reviews found holes the green suite did not. The two worst were both in the near-miss rule added a commit ago: - A correctly spelled table one directory too high was a clean miss and vanished: a geometry.csv beside a fiber array loaded fine and lost the geometry, because the rule only consulted the declaring model's own fields. A stem which is one of this format's table names now says so wherever it sits. - Case was folded on the suffix and not on the stem, and difflib scores GEOMETRY against geometry at 0.000 -- no characters in common -- so the one spelling nobody picks for a personal file was the one which disappeared, on the platforms whose filesystems do not fold case themselves. Both halves fold now. A name this format used to read is a third case again, and gets a third message. annotations.csv is not a crew's own file and not a typo: it was written by this format, and only the rename made it unreadable. The document doors already refuse the same fact loudly, so shrugging at it here broke one stored inventory two different ways, silently through the door the data actually arrives through. The rest: - resemblance is measured against the attributes which are tables, so a crew's names.csv no longer hard-fails a load with a suggestion the loader then refuses on its own terms - OpticalPathLabel.group still described "the annotated variable", which get_summary_df and model_json_schema both publish - a Python type annotation in the diagram tests had been renamed to `label`, in the one module where label already meant a mermaid edge label - eleven test names left half-renamed, which made the suite unsearchable for the three senses of the word - the CRS's coordinate_labels bind to a local `labels` a few lines from path.labels in two functions; those locals are now named for the axes they hold And the tests now pin all of it: reverting any of the three guards fails the suite, where before a cutoff of 0.55 passed it. --- dascore/core/inventory.py | 6 +- dascore/core/inventory_loader.py | 68 +++++++++++++++--- dascore/proc/inventory.py | 4 +- tests/test_core/test_inventory.py | 18 ++--- tests/test_core/test_inventory_loader.py | 92 ++++++++++++++++++------ tests/test_inventory_diagrams.py | 4 +- tests/test_proc/test_proc_inventory.py | 2 +- 7 files changed, 147 insertions(+), 47 deletions(-) diff --git a/dascore/core/inventory.py b/dascore/core/inventory.py index 571858e65..ca6800f02 100644 --- a/dascore/core/inventory.py +++ b/dascore/core/inventory.py @@ -706,7 +706,7 @@ class OpticalPathLabel(_IntervalModel): start and end) cover nothing, so they are exempt from that rule. """ - group: str = Field(default="", description="Name of the annotated variable.") + group: str = Field(default="", description="Name of the labelled variable.") value: LabelValue = Field( default=True, description="Value of the variable over this interval." ) @@ -2174,10 +2174,10 @@ def _coord_names(self) -> tuple[str, ...]: from the paths themselves: an inventory with no coupling track has no coupling to select on. """ - labels = self.coordinate_reference_system.coordinate_labels + axes = self.coordinate_reference_system.coordinate_labels # Both spellings of the same axes: the canonical storage names and # whatever this CRS declares they mean. - out = dict.fromkeys(["distance", *("x", "y", "z")[: len(labels)], *labels]) + out = dict.fromkeys(["distance", *("x", "y", "z")[: len(axes)], *axes]) groups: dict[str, None] = {} tracks: dict[str, dict[str, None]] = {} shapes: dict[str, set[str]] = {} diff --git a/dascore/core/inventory_loader.py b/dascore/core/inventory_loader.py index d6b9a32f8..3d449d769 100644 --- a/dascore/core/inventory_loader.py +++ b/dascore/core/inventory_loader.py @@ -499,6 +499,19 @@ class _Table(NamedTuple): # The one column of a point table which is not a field of the object it # builds; components order by it and drop it. +# The one suffix a table takes, and the table stems folded once so the +# near-miss check can match a shouted name without folding them per file. +_CSV_SUFFIX = ".csv" +_TABLES_BY_FOLD = {x.casefold(): x for x in _TABLES} + +# Names this format used to read, and what reads them now. A directory +# written before a rename is not a crew's own file which owes this format +# nothing -- it is this format's own former spelling, and a reader which +# shrugged at it would drop data the author believes is in the inventory. +# The document doors already refuse the same fact, so this is what keeps +# one stored inventory from breaking two different ways. +_RETIRED_TABLES = {"annotations": "labels"} + _SEQUENCE = "sequence" @@ -769,19 +782,58 @@ def _table_stem(path: Path) -> str: def _refuse_near_miss(stem: str, child: Path, model) -> None: """ - Refuse a table stem which nearly names an attribute of its model. - - The cutoff is deliberately high. A low one would start reading a - crew's own files as bad spellings of this format's, which is the - refusal this indifference exists to end; the cost of missing a + Refuse a table stem which claims to be a track and is not one here. + + Three ways of claiming it. A stem this format used to read is the + plainest: it was written by this format, for this format, and only a + rename since made it unreadable, so it is told what to rename itself + to rather than ignored. + + Two further ways. A stem which *is* one of this format's table + names, but not of this model, is the likeliest real mistake there is: + the right file one directory too high, a `geometry.csv` written before + the path directory was split out. It could not have said more plainly + that it is a track, so it is refused rather than dropped. + + A stem which merely resembles one is refused too, and told which name + it nearly is. That comparison is against the attributes which are + actually tables, not every attribute: `names.csv` beside a fiber array + is a crew's own file, and suggesting `name.csv` would send them to a + second refusal saying this format does not read a name as a table. + + Both tests fold case, as the suffix test above already does. A + `GEOMETRY.CSV` shares no characters with `geometry` as far as + `difflib` is concerned, so an unfolded comparison would let the one + spelling nobody picks for a personal file be the one which vanishes -- + and would do it only on the platforms whose filesystems do not fold + case themselves. + + The resemblance cutoff is deliberately high. A low one would start + reading a crew's own files as bad spellings of this format's, which is + the refusal this indifference exists to end; the cost of missing a stranger typo is the old behaviour, one message later. """ - close = difflib.get_close_matches(stem, model.model_fields, n=1, cutoff=0.8) + folded = stem.casefold() + if (now := _RETIRED_TABLES.get(folded)) is not None: + msg = ( + f"{_quote(child)} names {stem}, which this format now reads as " + f"{now}{_CSV_SUFFIX}. Rename it: what it holds are {now}." + ) + raise InvalidInventoryError(msg) + tables = {x.casefold(): x for x in set(model.model_fields) & set(_TABLES)} + if (elsewhere := _TABLES_BY_FOLD.get(folded)) and folded not in tables: + msg = ( + f"{_quote(child)} names the track {elsewhere}, which " + f"{model.__name__} does not have. A track belongs to the entity " + "which describes it; this one may be a directory too high." + ) + raise InvalidInventoryError(msg) + close = difflib.get_close_matches(folded, tables, n=1, cutoff=0.8) if close: msg = ( f"{_quote(child)} names no attribute of {model.__name__}. Did you " - f"mean {close[0]}.csv? A file this format does not " - "recognise at all is left where it lies." + f"mean {tables[close[0]]}{_CSV_SUFFIX}? A file this format does " + "not recognise at all is left where it lies." ) raise InvalidInventoryError(msg) diff --git a/dascore/proc/inventory.py b/dascore/proc/inventory.py index d56037a52..f2cf6b249 100644 --- a/dascore/proc/inventory.py +++ b/dascore/proc/inventory.py @@ -332,11 +332,11 @@ def _get_blanket_coord_names(inventory, path) -> list[str]: already record. """ crs = inventory.coordinate_reference_system - labels = crs.coordinate_labels + axis_names = crs.coordinate_labels # The axes are copied under their canonical names, and only where some # segment actually places the fiber; the rest come under their own. axes = {x for segment in path.geometry for x in axis_columns(segment, crs)} - out = ["x", "y", "z"][: len(labels)] if axes else [] + out = ["x", "y", "z"][: len(axis_names)] if axes else [] out += [x for x in path.geometry_columns() if x not in axes] seen = dict.fromkeys(x.group for x in path.labels) return out + [x for x in seen if x] diff --git a/tests/test_core/test_inventory.py b/tests/test_core/test_inventory.py index 0ef6f6968..f8ef22bb4 100644 --- a/tests/test_core/test_inventory.py +++ b/tests/test_core/test_inventory.py @@ -286,7 +286,7 @@ def test_a_reserved_column_name(self): with pytest.raises(InvalidInventoryError, match="reserved name"): self._inventory(clash).check() - def test_a_column_which_is_also_an_annotation_group(self): + def test_a_column_which_is_also_a_label_group(self): """One name is one coordinate, whichever track would define it.""" column = inv.Geometry(distance=(0.0, 10.0), coordinates={"zone": (0.0, 1.0)}) label = inv.OpticalPathLabel( @@ -539,7 +539,7 @@ def test_geometry_overlap_raises(self): with pytest.raises(InvalidInventoryError, match="Overlapping geometry"): path.check() - def test_boolean_annotations_overlap_freely(self): + def test_boolean_labels_overlap_freely(self): """Membership labels overlap, within and across groups.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), @@ -557,7 +557,7 @@ def test_boolean_annotations_overlap_freely(self): ) assert path.check() is path - def test_valued_annotation_groups_may_not_overlap(self): + def test_valued_label_groups_may_not_overlap(self): """A single-valued group cannot claim two values at one distance.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), @@ -579,7 +579,7 @@ def test_valued_annotation_groups_may_not_overlap(self): with pytest.raises(InvalidInventoryError, match="only boolean groups"): path.check() - def test_annotation_group_holds_one_kind_of_value(self): + def test_label_group_holds_one_kind_of_value(self): """Mixing value kinds in one group is a modeling error.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), @@ -595,7 +595,7 @@ def test_annotation_group_holds_one_kind_of_value(self): with pytest.raises(InvalidInventoryError, match="one kind of value"): path.check() - def test_numeric_annotation_group(self): + def test_numeric_label_group(self): """Numeric groups are single valued but otherwise ordinary.""" path = inv.OpticalPath( optical_components=(inv.FiberSegment(optical_length=100.0),), @@ -1866,7 +1866,7 @@ def test_point_clamp_inside_span_is_legal(self): ) assert path.check() is path - def test_point_annotation(self): + def test_point_label(self): """Point label.""" label = inv.OpticalPathLabel( start_distance=350.0, end_distance=350.0, group="wellhead" @@ -2464,7 +2464,7 @@ def test_round_trip_equals(self, name): inventory = SAMPLE_INVENTORIES[name] assert dc.inventory(inventory.to_yaml()) == inventory - def test_an_label_value_of_one_survives(self): + def test_a_label_value_of_one_survives(self): """`1 == True`, and the value's default is True, so it was dropped.""" pytest.importorskip("yaml") path = inv.OpticalPath( @@ -2488,7 +2488,7 @@ def test_an_label_value_of_one_survives(self): # number and is refused as mixing two kinds. assert dc.inventory(text) == inventory - def test_an_annotation_still_names_its_class(self): + def test_a_label_still_names_its_class(self): """Restoring the value must not displace the document's tag.""" label = inv.OpticalPathLabel( start_distance=0.0, end_distance=1.0, group="hole", value=2 @@ -2504,7 +2504,7 @@ def test_a_deliberately_excluded_value_stays_out(self): assert "value" not in label.model_dump(mode="json", exclude={"value"}) assert "value" not in label.model_dump(mode="json", include={"group"}) - def test_a_flag_annotation_stays_terse(self): + def test_a_flag_label_stays_terse(self): """A value which really is the default is still left out.""" pytest.importorskip("yaml") path = inv.OpticalPath( diff --git a/tests/test_core/test_inventory_loader.py b/tests/test_core/test_inventory_loader.py index ca4dd0661..ee18601b5 100644 --- a/tests/test_core/test_inventory_loader.py +++ b/tests/test_core/test_inventory_loader.py @@ -26,6 +26,16 @@ # A minimal directory which loads: one acquisition names everything above it. +PATH_DIRECTORY = { + "acquisitions/DAS.L001..RAW.yaml": "object_type: Acquisition\ndata_category: DAS\n", + "fiber_arrays/DAS.L001/attrs.yaml": "object_type: FiberArray\n", + "fiber_arrays/DAS.L001/path/attrs.yaml": "object_type: OpticalPath\n", + # A path with length, so a track laid along it is inside the path. + "fiber_arrays/DAS.L001/path/optical_components.csv": ( + "sequence,object_type,optical_length,name\n1,FiberSegment,1000.0,fiber 1\n" + ), +} + MINIMAL = { "acquisitions/DAS.L001..RAW.yaml": "object_type: Acquisition\ndata_category: DAS\n", } @@ -1343,44 +1353,82 @@ def test_a_stem_naming_no_attribute(self, make_inventory): """A spreadsheet which never said it was a track is left where it lies. An entity directory is somewhere a crew keeps its own working files, - and this format has no claim on one it does not recognise. + and this format has no claim on one it does not recognise. The + tables beside it are still read, so the directory is being loaded + rather than merely tolerated. """ files = { - **MINIMAL, - "fiber_arrays/DAS.L001/attrs.yaml": "object_type: FiberArray\n", - "fiber_arrays/DAS.L001/geometrys.csv": "segment,distance\nS,0\n", - "fiber_arrays/DAS.L001/crew_notes.csv": "who,when\nderrick,tuesday\n", + **PATH_DIRECTORY, + "fiber_arrays/DAS.L001/path/crew_notes.csv": "who,when\nderrick,tuesday\n", + "fiber_arrays/DAS.L001/path/components.csv": "a,b\n1,2\n", + "fiber_arrays/DAS.L001/path/geometry.csv": ( + "segment,distance,longitude,latitude,elevation\n" + "S,0,0,0,0\nS,100,1,1,0\n" + ), } inventory = make_inventory(files) - (array,) = inventory.networks[0].fiber_arrays - assert array.code == "L001" + (path,) = inventory.networks[0].fiber_arrays[0].optical_paths + assert len(path.geometry) == 1 - def test_a_stem_nearly_naming_an_attribute(self, make_inventory): + @pytest.mark.parametrize("name", ["geometrys", "couplings", "label"]) + def test_a_stem_nearly_naming_an_attribute(self, make_inventory, name): """A near-miss did claim to be a track, so it is not quietly dropped.""" files = { - **MINIMAL, - "fiber_arrays/DAS.L001/attrs.yaml": "object_type: FiberArray\n", - "fiber_arrays/DAS.L001/path/attrs.yaml": "object_type: OpticalPath\n", - "fiber_arrays/DAS.L001/path/geometrys.csv": "segment,distance\nS,0\n", + **PATH_DIRECTORY, + f"fiber_arrays/DAS.L001/path/{name}.csv": "segment,distance\nS,0\n", + } + with pytest.raises(InvalidInventoryError, match="Did you mean"): + make_inventory(files) + + def test_the_resemblance_cutoff_stays_high(self, make_inventory): + """A crew file is not read as a bad spelling of this format's. + + `components` resembles `optical_components` more than most things + do and is still not it, so this pins the cutoff above that: a rule + loose enough to claim this file would refuse a crew's own work, + which is what the indifference exists to prevent. + """ + files = { + **PATH_DIRECTORY, + "fiber_arrays/DAS.L001/path/components.csv": "a,b\n1,2\n", + } + assert make_inventory(files) is not None + + def test_a_table_of_another_entity(self, make_inventory): + """The right file one directory too high still says it is a track.""" + files = { + **PATH_DIRECTORY, + "fiber_arrays/DAS.L001/geometry.csv": "segment,distance\nS,0\n", + } + with pytest.raises(InvalidInventoryError, match="names the track geometry"): + make_inventory(files) + + @pytest.mark.parametrize("name", ["GEOMETRY", "Geometry"]) + def test_a_shouted_table_name(self, make_inventory, name): + """Case decides nothing here, as it decides nothing for a suffix.""" + files = { + **PATH_DIRECTORY, + f"fiber_arrays/DAS.L001/path/{name}.CSV": "segment,distance\nS,0\n", } with pytest.raises(InvalidInventoryError, match="Did you mean geometry"): make_inventory(files) - def test_the_superseded_annotations_table_is_left_alone(self, make_inventory): - """The table labels.csv replaced names nothing near it, so a directory - which still holds one loads rather than refusing. + def test_the_retired_annotations_table_says_what_to_rename(self, make_inventory): + """A name this format used to read is told what reads it now. + + The document doors already refuse the same fact, so shrugging at it + here would break one stored inventory two different ways -- loudly + as YAML, silently as a directory, and the silent one loses the + labels the author believes are in it. """ files = { - **MINIMAL, - "fiber_arrays/DAS.L001/attrs.yaml": "object_type: FiberArray\n", - "fiber_arrays/DAS.L001/path/attrs.yaml": "object_type: OpticalPath\n", + **PATH_DIRECTORY, "fiber_arrays/DAS.L001/path/annotations.csv": ( "start_distance,end_distance,group,value\n0,10,zone,north\n" ), } - inventory = make_inventory(files) - (path,) = inventory.networks[0].fiber_arrays[0].optical_paths - assert path.labels == () + with pytest.raises(InvalidInventoryError, match="now reads as labels"): + make_inventory(files) def test_a_stem_naming_a_field_which_is_not_row_shaped(self, make_inventory): """Only an attribute rows can build may be stated as a table.""" @@ -1669,7 +1717,7 @@ def test_a_column_no_row_states(self, make_inventory): # Unset, rather than a tuple of nothing, which the model would refuse. assert acquisition.distance_map.instrument_distance is None - def test_an_annotation_stating_no_value(self, make_inventory): + def test_a_label_stating_no_value(self, make_inventory): """A membership group's value is its default, not a parsed cell.""" files = { **MINIMAL, diff --git a/tests/test_inventory_diagrams.py b/tests/test_inventory_diagrams.py index df46c904c..65ae173f6 100644 --- a/tests/test_inventory_diagrams.py +++ b/tests/test_inventory_diagrams.py @@ -76,7 +76,7 @@ def _accepted_models(model, field): A reference is a `str` in the same union as a model, which is how the inventory spells "this may be a resource_id instead of the object". """ - label = get_type_hints(model)[field] + annotation = get_type_hints(model)[field] found, referenced = set(), False def _walk(node, in_reference_union): @@ -94,7 +94,7 @@ def _walk(node, in_reference_union): found.add(node) referenced = referenced or in_reference_union - _walk(label, False) + _walk(annotation, False) return found, referenced diff --git a/tests/test_proc/test_proc_inventory.py b/tests/test_proc/test_proc_inventory.py index 1cf508b1d..990bb57ba 100644 --- a/tests/test_proc/test_proc_inventory.py +++ b/tests/test_proc/test_proc_inventory.py @@ -846,7 +846,7 @@ def test_single_point_instrument_map_is_an_offset(self, patch, inventory): out = renamed.enrich(inv, attrs=False, coords=("distance",)) assert out.get_coord("distance").values[10] == 110.0 - def test_reserved_annotation_group_raises(self, inventory): + def test_reserved_label_group_raises(self, inventory): """A group named after a coordinate would shadow it at enrichment.""" path = inventory.networks[0].fiber_arrays[0].optical_paths[0] label = OpticalPathLabel( From 49bb6b44bc976a415e4bf178f7bfef971afe7136 Mon Sep 17 00:00:00 2001 From: Derrick Chambers Date: Mon, 17 Aug 2026 15:27:21 +0200 Subject: [PATCH 5/5] Say which values belong in a label table, not just which types --- docs/tutorial/inventory.qmd | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/tutorial/inventory.qmd b/docs/tutorial/inventory.qmd index 99f7ccde6..a347e7e70 100644 --- a/docs/tutorial/inventory.qmd +++ b/docs/tutorial/inventory.qmd @@ -136,7 +136,7 @@ A few rules make that directory readable without a schema in front of you: - **`path` is the one reserved *container* name.** A directory called `path` addresses an optical path rather than serializing an attribute named "path". (`attrs` and `inventory` are reserved as file stems — an entity's own object file, and the envelope — and the top-level directory names above are fixed.) - **Files that participate in no convention are ignored.** Photos, field notes, and deployment logs can live in the inventory directory undisturbed. -Two tracks can hold something that varies along the fiber, and which one to use is decided by what the values are. **Numbers go in `geometry.csv`**, one column per quantity, interpolated between the control points which state them; a column may name its units in its header, `chainage (m)`. **Text and vocabulary go in `labels.csv`**, which is a set of intervals rather than a curve — a zone name has no meaning between two of them. A column of text in a geometry table is refused, and says so. +Two tracks can hold something that varies along the fiber, and which one to use is decided by how a value behaves between the points which state it. **A quantity which interpolates goes in `geometry.csv`**, one column per quantity, read as a curve through its control points; a column may name its units in its header, `chainage (m)`. **A value which holds over a stretch and then stops goes in `labels.csv`**, a set of intervals rather than a curve — a zone name has no meaning between two of them, and neither does a borehole number. Labels are usually words, then, but a number which identifies rather than measures is a label too. A column of text in a geometry table is refused, and says so. Position is not a separate kind of thing: a column whose name the coordinate reference system declares — or its canonical `x`, `y`, `z` alias — *is* that axis, takes its units from the CRS, and must be stated with all its siblings, since half a position is not a position. A segment which names none of them, like the chainage above, states no position at all and is perfectly valid. The `segment` names themselves are for humans; what a channel gets is the columns.