Skip to content

Add the inventory patch surface: acquisition_key, reader vocabulary, and enrich - #855

Merged
d-chambers merged 27 commits into
devfrom
inventory-phase-2
Aug 11, 2026
Merged

Add the inventory patch surface: acquisition_key, reader vocabulary, and enrich#855
d-chambers merged 27 commits into
devfrom
inventory-phase-2

Conversation

@d-chambers

@d-chambers d-chambers commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Description

Phase 2 of the DASDAE inventory work, on top of the model layer merged in #843: the patch surface. It gives a patch a single identity the inventory can resolve, makes the readers speak the inventory's own vocabulary, and adds the operation that joins the two.

Patch attrs are reorganized around one identity field. data_source_id holds the whole inventory identity (network.fiber_array.location.acquisition), replacing network, station, instrument_id, and acquisition_id. Component queries are wildcard matches on the composite (spool.select(data_source_id="XX.*")). station is not reused because DAS MiniSEED already spends it on a per-sampling-point identity.

Readers speak one vocabulary, shared with the inventory. constants.INVENTORY_ATTRS lists the observing-system facts a reader may emit; each is a field of the inventory's acquisition, or of its interrogator when dotted (interrogator.serial_number). Those attrs carry fixed units, so readers convert at the parse boundary through the new io.utils.convert_attr_units and the *_units companion attrs are gone. A conformance test holds every shipped reader to the vocabulary; vendor-specific attrs are untouched and listed explicitly.

Storage provenance is spelled source_* everywhere — the get_contents() and index columns path/file_format/file_version become source_path/source_format/source_version, matching PatchSummary — and no reader puts them in patch attrs: they say where the bytes live, and a patch merged from three files has no single answer.

Patch.enrich and Spool.attach_inventory copy inventory metadata onto a patch: the patch's data_source_id and time resolve one acquisition and optical path, the acquisition's channel map places each channel on the path, and the path's tracks project onto it. attrs and coords each take True, a tuple of names, or False; conflicts settles disagreements using chunking's vocabulary; on_missing governs names the inventory does not define.

One model rule changed while implementing it: an acquisition now places its channels one way, the distance_map. The affine start_distance form mapped channel numbers, which no reader produces (36 of the 38 example files carry meters, none carry channels), and a single control point does the same job while extrapolating. The map may now state its points in both input coordinates, and the patch's own coordinate decides which is read.

All breaking changes are described in docs/changelog.qmd.

Reviewed before opening: one Codex pass and three adversarial review passes on the attrs/vocabulary work, plus one Codex and three adversarial passes on the channel-resolution change. Those found (and this PR fixes) a Febus pulse width converted as nanoseconds when the header states meters, a second Silixa code path that skipped its conversion, an attr name that broke every trimmed read of a spool, a hive path that indexed an unusable data_source_id and failed only at load, and a dual-axis map that could silently be read on the wrong coordinate.

Changelog

  • added: Patch.enrich copies DASDAE inventory metadata onto a patch, resolved by acquisition_key and time, adding the acquisition's geometry axes and annotation groups as coordinates in the CRS's own units (x/y in degrees under the default EPSG:4979), with attrs, coords, conflicts, and on_missing controls.
  • added: Spool.attach_inventory, Spool.enrich, and Spool.remove_inventory. Attaching costs nothing per patch; Spool.enrich enriches each patch as it is extracted (~70% overhead on extraction), and a patch the inventory cannot resolve comes out unchanged under on_unresolved ("warn", "ignore", "raise") while one straddling two epochs raises UnresolvedPatchError.
  • changed breaking: patch attrs are reorganized around one identity field, acquisition_key (network.fiber_array.location.acquisition); network, station, instrument_id, and acquisition_id are removed from PatchAttrs, and get_patch_names keys off it — though the default groupby_attrs still lists network and station so archives partitioned by them keep their patches apart.
  • changed breaking: readers emit one attr vocabulary shared with the inventory (dascore.constants.INVENTORY_ATTRS) in fixed units — instrument_id becomes interrogator.serial_number, the *_units companion attrs are gone, and a length-dimensioned pulse is pulse_length; ProdML's acquisition_id and OptoDAS's experiment keep their own spellings, and xml_binary's pulse_width_ns becomes pulse_width in seconds.
  • changed breaking: storage provenance is spelled source_path, source_format, and source_version in get_contents() and the index; no reader puts it in patch attrs.
  • changed breaking: the index version is bumped, so existing indexes must be deleted and rebuilt; patches written by earlier versions replay their stored attrs and will not merge with newly written ones.
  • changed: reading a DASDAE file cached by an earlier version converts its *_units companions into the fixed unit and drops them, so the cached patch matches the same patch read fresh from source — except Febus A1 and Silixa H5 caches, whose unit never reached the file and which must be rewritten.
  • changed: an acquisition places its channels with distance_map only — the affine start_distance form is gone, and a map may be written in both input coordinates at once. An annotation group may not take a reserved name, and interval coverage includes the end of each coverage run.
  • fixed: writing netcdf_cf requires an HDF5-capable engine (h5netcdf or netCDF4) and raises without one, instead of silently writing a NETCDF3 file DASCore's own reader cannot open (#866).

Checklist

I have (if applicable):

  • referenced the GitHub issue this PR closes.
  • documented the new feature with docstrings and/or appropriate doc page.
  • included tests. See testing guidelines.
  • added the "ready_for_review" tag once the PR is ready to be reviewed.

Closes #854.

Summary by CodeRabbit

  • New Features

    • Added inventory enrichment for patches and spools, including metadata, coordinates, geometry, annotations, and channel mapping.
    • Added validated data_source_id metadata, standardized interrogator attributes, and canonical unit conversion across supported formats.
  • Improvements

    • Storage provenance now uses source_path, source_format, and source_version.
    • Updated grouping and selection to use data source IDs and tags.
    • Improved distance mapping and validation for inventory-based channel resolution.
  • Documentation

    • Updated tutorials, recipes, examples, and API notes for inventory workflows and metadata changes.

Patch attrs had no story about who owns which field. Identity was spread
over network/station/instrument_id/acquisition_id, every reader spelled
the observing system's facts its own way, some shipped a companion attr
naming a value's units, and a few put the file path in the patch. None
of that can meet an inventory: two spellings of gauge length are two
attrs, and nothing downstream can reconcile them.

Sort every attr into one of four kinds and let the kind decide the name
and the owner. Identity becomes the single data_source_id field holding
network.fiber_array.location.acquisition; component queries are wildcard
matches on it. station is not reused because DAS MiniSEED already spends
it on a per-sampling-point identity. Observing-system facts take the
inventory's own names, listed in INVENTORY_ATTRS and tested against the
models they mirror, with nested facts dotted (interrogator.serial_number).
Those attrs carry fixed units, so readers convert at the parse boundary
through convert_attr_units and the *_units companions are gone. Storage
provenance keeps the source_ prefix everywhere, including the contents
and index columns, and leaves patch attrs entirely: it says where the
bytes live, and a patch merged from three files has no single answer.

A conformance test holds every shipped reader to the vocabulary, so a
format added next year cannot quietly reinvent channel_spacing. Extra
attrs are still allowed; the test is a review checkpoint, not a runtime
rule.

Breaking: the removed attrs fields, the reader respellings, the *_units
removal, and the source_* column rename are all in the changelog.
An inventory that a patch cannot be joined to is a document, not
metadata. enrich is that join: the patch's data_source_id and time
resolve one acquisition and optical path, the acquisition's channel map
places each channel on the path axis, and the path's tracks project onto
the patch.

The rules follow from who owns what. Blanket attrs=True copies the
observing-system facts under the inventory's own names and leaves the
data trio alone, since processing maintains those; naming one restores
the as-acquired value. Disagreements go through conflicts, reusing
chunking's flag and vocabulary, so a header contradicting the resolved
acquisition can raise instead of being silently replaced -- usually the
sign that the data_source_id resolved to the wrong place. A patch
straddling an epoch boundary raises rather than picking one side,
because acquisition metadata is scalar per patch.

Which patch coordinate the channel map applies to is the map's to
declare: channel numbers for the affine form and a channel-axis distance
map, interrogator meters for an instrument_distance map. Guessing would
be wrong by a factor of the channel spacing, so a patch without the
declared coordinate raises and says which map form fits it.

Long argument reprs are now truncated in patch history; without that
every enriched patch carried a full inventory dump in its history.
Calling enrich on every extracted patch by hand is the kind of step that
gets forgotten halfway through an analysis, and the spool already knows
which patches it is about to hand out. attach_inventory holds the
inventory and enriches on extraction, so metadata arrives with the data.

The inventory joins the spool's equality state: two spools over the same
rows yield different patches when one is attached, so they are not the
same spool.

This is deliberately only the extraction hook. Resolving the index
against the inventory, subdividing it at epoch boundaries, and selecting
on inventory tracks are phase 3 and are called out as absent in the
method's own documentation.
Four reviews (one Codex, three adversarial) against the three phase-2
commits. Every finding below was reproduced before it was fixed.

Data that was silently wrong:

- Febus A1 states its pulse as a length in meters, which the deleted
  units companion had recorded correctly; converting it as nanoseconds
  made it wrong by eight orders of magnitude. It keeps terra15's
  pulse_length name for the same quantity.
- Silixa's Carina variant reads the same nanosecond header key through a
  second code path, which the conversion missed, so 110 ns became 110
  seconds.
- Neubrex spells the units key in the singular, so its conversion never
  ran and any file stating cm would have passed through as meters.
- A file which declares units that cannot be used has a value of unknown
  scale. Keeping the number passed "5 s" off as 5 meters; the value is
  now dropped with a warning.
- TDMS filtered its parsed header through PatchAttrs.model_fields, which
  no dotted name can be in, so it dropped the interrogator serial it had
  just read.

Failures which only surfaced at load:

- An attr named path, file_format, or file_version stopped being
  reserved when the flat columns were renamed, so it was splatted into
  dc.read beside the row's own value and every trimmed read of that
  spool raised TypeError.
- A hive path stamping an incomplete data_source_id indexed cleanly and
  then failed at every patch access, far from the directory that caused
  it. Refuse it at indexing instead.

Enrichment:

- Projected coordinates now carry the units the CRS and the model state.
- Re-enriching is a refresh, coordinates included: an identical
  coordinate is not a collision, but a contradicting one still is.
- NaN is how a reader spells an unknown number, so it is filled rather
  than treated as a value which disagrees. Previously conflicts="drop"
  deleted the very value enrichment exists to supply.
- sample_rate and spatial_interval are maintained by processing, so
  blanket enrichment no longer restores stale ones; naming one still
  does. A patch decimated in time kept its as-acquired sample rate.
- Coverage runs end where they end: a track interval elsewhere on the
  path no longer changes which channels an earlier interval covers.
- A geometry-less path, an unset or misspelled track field, and a
  multi-valued component field now report through on_missing or raise a
  named error instead of returning silent NaN or a numpy broadcast error.
- A union of spools carries an attached inventory instead of dropping
  it, and two different attachments raise.
An acquisition had two ways to place its channels on the path and a
validator to keep authors from using both. The affine form
(start_distance + spatial_interval) mapped channel numbers, which no
DASCore reader produces: of the 38 example files, 36 carry meters and
none carry channels, most with a spacing that is not one and an origin
that is not zero. So the form that looked simple was the one nobody
could use, and the rule policing the pair existed only because there
were two.

A single control point says everything the affine form said -- an
origin, with spatial_interval as its slope on the channel axis and one
meter of path per interrogator meter on the other -- and it
extrapolates, where a two-point map goes undefined past its ends. So
the affine form and its exclusivity rule are gone.

With one mechanism left, "exactly one input axis" was also the wrong
rule for the map itself. It now states at least one, and may state both,
so one set of control points can be written in whichever coordinates
were measured and serve patches whose axes differ. The patch decides
which axis is read. A patch carrying more than one of them must agree
with the map about all of them: two coordinates which place the same
channel in different places mean the patch contradicts the map, and
picking one would answer a question the data disputes.

An acquisition written against the removed field now says what replaced
it instead of raising pydantic's "extra inputs are not permitted".
@coderabbitai

coderabbitai Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds inventory-based patch enrichment and spool attachment. It consolidates patch identity into data_source_id, standardizes reader metadata and canonical units, and renames storage provenance fields to source_path, source_format, and source_version.

Changes

Inventory and metadata integration

Layer / File(s) Summary
Contracts, enrichment, and inventory resolution
dascore/core/attrs.py, dascore/core/inventory.py, dascore/proc/inventory.py, dascore/core/patch.py, dascore/core/spool.py
Patch identity uses validated data_source_id. Inventory enrichment resolves attributes and coordinates. Spools can attach inventories and enrich retrieved patches.
Reader metadata and canonical units
dascore/io/*, dascore/io/utils.py
Readers use shared inventory names, nested interrogator.* fields, canonical units, and centralized unit conversion. Legacy provenance and unit fields are removed.
Source provenance and indexing
dascore/io/index/*, dascore/core/summary.py, dascore/utils/chunk_plan.py
Storage metadata uses source_path, source_format, and source_version. Index ingestion, planning, resolution, and schema handling use the renamed fields.
Validation, tests, examples, and documentation
tests/*, dascore/examples.py, docs/*, benchmarks/*
Tests, examples, benchmarks, and documentation cover the updated identity, inventory, reader, provenance, selection, grouping, and enrichment contracts.

Possibly related PRs

Suggested labels: documentation, IO, proc, patch, spool

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR renames acquisition_id to experiment_id but does not retain the deprecated acquisition_id input alias requested by issue #854. Add a deprecated acquisition_id input alias that maps to experiment_id, and update mappings, configuration, and documentation to preserve compatibility.
Out of Scope Changes check ⚠️ Warning The PR includes broad enrichment, reader, indexing, provenance, and distance-map changes beyond the acquisition_id naming issue [#854]. Split unrelated inventory-surface, reader, indexing, provenance, and distance-map work into separately linked pull requests, or link issues defining those objectives.
✅ Passed checks (3 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 98.31% which is sufficient. The required threshold is 80.00%.
Title check ✅ Passed The title clearly summarizes the main changes: the inventory patch surface, reader vocabulary, and enrichment support.
Description check ✅ Passed The description explains the feature, documents breaking changes, links issue #854, and includes the required checklist and tests.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch inventory-phase-2

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

Reviews of the two-axis relaxation. A map stating both axes describes
one interrogator, which samples at a fixed spacing, so axes implying a
spacing that varies along the fiber now raise: they describe no
instrument, and reading such a map on one axis contradicts reading it on
the other.

The axis argument was an unvalidated getattr, so axis="distance" -- the
name of the patch coordinate the instrument axis is read from, and so
the likeliest wrong value -- silently interpolated distance onto
distance and returned the input unchanged.

An axis the map cannot be read on (a channel axis with no spacing) no
longer vetoes the axes that can, and the failure that results names
every reason rather than the first. When two coordinates have different
lengths the message says so instead of reporting two equal numbers as a
disagreement, and the agreement tolerance is absolute: a relative one
widens to a whole channel tens of kilometers down the fiber, which is
exactly where a disagreement matters.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2eb7f151ea

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread dascore/core/spool.py Outdated
"inventory to the combined spool instead."
)
raise InvalidSpoolError(msg)
return attached[0]

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Do not apply one operand's inventory to the entire union

When only one operand has an attached inventory, returning that attachment causes the combined spool's _maybe_enrich path to apply it to patches from both operands. If the unattached operand contains another data source, accessing those patches will either fail resolution or silently enrich them with unrelated metadata; the union must either retain attachment state per operand or reject mixed attached/unattached nonempty spools.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Kept deliberately, and the hazard is now handled elsewhere. A union carries an inventory attached to either operand, which is the rule we want: attaching the same inventory to the other operand must not turn a working union into an error. The failure you describe is real but is not union-specific -- dc.spool([resolvable, unresolvable]).enrich(inv) fails identically -- so it was fixed at the root: Spool.enrich now takes on_unresolved (warn by default, plus raise/ignore), and a patch the inventory does not describe comes out unenriched rather than raising. Deciding membership stays with the not-yet-implemented prune_to_inventory (#857).

Note: the identity attr was renamed data_source_id -> acquisition_key after this review, so line numbers and some names have moved.

Comment thread dascore/core/spool.py Outdated
Comment on lines +682 to +683
new._inventory = inventory
new._enrich_kwargs = dict(kwargs) if enrich else None

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve inventory attachments across single-file updates

When an inventory is attached to a root created by Spool.from_file, calling update() is permitted but its single-file branch reconstructs the result with self.from_file(...), restoring _inventory and _enrich_kwargs to their class defaults. The returned spool therefore stops enriching patches after a rescan, unlike directory and in-memory roots; the attachment state needs to be copied to the refreshed file spool.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. update()'s single-file branch rebuilt through the from_file classmethod, which constructs a spool from the file alone; the refreshed spool now inherits _inventory, _enrich_kwargs, and _on_unresolved. The directory-backed branch copy-constructs, which is what hid it. Covered by TestSplitReviewFindings::test_update_keeps_the_inventory.

Note: the identity attr was renamed data_source_id -> acquisition_key after this review, so line numbers and some names have moved.

Comment thread dascore/proc/inventory.py Outdated
Comment on lines +320 to +321
for name, _, distances in resolved[1:]:
if not _distances_agree(first[2], distances):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject map coordinates attached to different dimensions

When a patch carries both map-supported coordinates on different one-dimensional dimensions, this loop discards each later dimension and compares only the mapped value arrays. If the dimensions happen to have equal lengths and values, enrichment accepts the ambiguous patch and attaches every projected geometry or annotation coordinate to the first dimension, potentially labeling the wrong axis; all resolved map coordinates should be required to share the same dimension.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. All resolved axes must now share one dimension, raising and naming both dimensions otherwise. That also made the length check below it unreachable -- two coordinates on the same dimension always have the same length -- so the dead branch was removed rather than left as unreachable code.

Note: the identity attr was renamed data_source_id -> acquisition_key after this review, so line numbers and some names have moved.

Comment thread dascore/core/inventory.py
Comment on lines +717 to +720
axis = self.axes[0] if axis is None else axis
out = getattr(self, axis, None)
if out is None:
msg = f"This DistanceMap is not written in {axis!r}; it has {self.axes}."

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Validate requested axes against DistanceMap.axes

When a caller passes an existing non-input attribute such as axis="distance", getattr returns the map's target-distance control points, so this check accepts them as an input axis. map_to_distance can then silently use the output distances as its source coordinate and return an incorrect identity-like calibration; reject every explicit axis that is not present in self.axes before reading the attribute.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Already handled, in two layers. source_values first rejects any name that is not a DistanceMap input axis (so axis="distance" raises rather than returning the target control points), then rejects an input axis this particular map is not written in, naming self.axes. Verified: axis="distance" and axis="nope" give "is not a DistanceMap input axis", and axis="channel" on an instrument_distance-only map gives "This DistanceMap is not written in 'channel'; it has ('instrument_distance',)".

Note: the identity attr was renamed data_source_id -> acquisition_key after this review, so line numbers and some names have moved.

Comment thread dascore/proc/inventory.py Outdated
Comment on lines +361 to +366
if isinstance(value, tuple | list):
msg = (
f"Cannot project the multi-valued {value!r} onto channels; "
"a coordinate holds one value per channel."
)
raise PatchError(msg)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject mapping-valued track fields before projection

When a qualified request targets a populated mapping field such as coupling.extra_fields, the value bypasses this multi-value guard because only tuples and lists are recognized. _annotation_kind then classifies the dict as numeric and the final float conversion raises a raw TypeError, even though extra_fields is a legitimate field on every inventory track model; mapping and other non-scalar values should be rejected with the same PatchError as the already-handled multi-valued fields.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed. The guard caught only tuples and lists, so a mapping reached the numeric branch and raised a bare TypeError out of float() that on_missing could not intercept. It now rejects any sized non-string value, which covers extra_fields -- a real field on every track model, so this was a reachable request rather than a contrived one.

Note: the identity attr was renamed data_source_id -> acquisition_key after this review, so line numbers and some names have moved.

@coderabbitai coderabbitai Bot added documentation Improvements or additions to documentation IO Work for reading/writing different formats patch related to Patch class proc Related to processing module spool related to Spool class labels Aug 10, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 14

🧹 Nitpick comments (4)
dascore/core/attrs.py (1)

88-99: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider rejecting the removed identity keys explicitly.

extra="allow" means PatchAttrs(network="XX", station="S1") still constructs. The values become inert extra attrs, and grouping, merging, and naming ignore them silently. Acquisition._reject_start_distance in dascore/core/inventory.py gives the analogous removal an explicit migration message. Adding the same treatment here would surface stale reader and user code at construction time.

♻️ Proposed migration guard
+_REMOVED_IDENTITY_FIELDS = ("network", "station", "instrument_id", "acquisition_id")
+
+
 class PatchAttrs(DascoreBaseModel):
     `@model_validator`(mode="before")
     `@classmethod`
     def reject_coordinate_attributes(cls, data: Any) -> Any:
         """Reject nested coord payloads and ignore structural dims input."""
         if not isinstance(data, Mapping):
             return data
         data = dict(data)
         if "coords" in data and not isinstance(data["coords"], str):
             msg = "PatchAttrs no longer accepts coordinate metadata. Received: coords."
             raise ValueError(msg)
+        if removed := sorted(set(data) & set(_REMOVED_IDENTITY_FIELDS)):
+            msg = (
+                f"PatchAttrs no longer accepts {removed}; these are now spelled "
+                "as one data_source_id: network.fiber_array.location.acquisition."
+            )
+            raise ValueError(msg)
         data.pop("dims", None)
         return data
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/core/attrs.py` around lines 88 - 99, Update
PatchAttrs.reject_coordinate_attributes to explicitly reject the removed
identity keys network and station, raising a clear migration error when either
is present before extra attributes are accepted. Preserve the existing coords
validation and dims removal behavior, and follow
Acquisition._reject_start_distance’s migration-message pattern.
dascore/proc/inventory.py (1)

470-480: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the redundant optical_distances parameter.

The only call site passes distances for both parameters, so the two names describe one array. The split signature suggests they can differ, which invites a future caller to pass mismatched arrays into the "distance" branch.

♻️ Proposed refactor
-def _get_coord_values(inventory, path, name, distances, optical_distances):
+def _get_coord_values(inventory, path, name, distances):
     """Return the values of one requested coordinate, or None if undefined."""
     if name == "distance":
         # Optical path distance is in meters, as every path length is.
-        return get_coord(data=optical_distances, units="m")
+        return get_coord(data=distances, units="m")
-        values = _get_coord_values(inventory, path, name, distances, distances)
+        values = _get_coord_values(inventory, path, name, distances)

Also applies to: 524-524

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/proc/inventory.py` around lines 470 - 480, Remove the redundant
optical_distances parameter from _get_coord_values and use distances directly in
the "distance" branch. Update the function’s call site to pass only distances,
preserving all other coordinate-resolution behavior.
dascore/io/prodml/utils.py (1)

347-347: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider accepting acquisition_id as a deprecated input alias.

The writer now reads only experiment_id. A patch that still carries acquisition_id gets a fresh uuid4() as its AcquisitionId, so the recorded campaign identity is lost without any message. Issue #854 recommended keeping acquisition_id as a deprecated alias with a warning.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/io/prodml/utils.py` at line 347, Update the acquisition ID handling
around the `"AcquisitionId"` attribute so it first uses `experiment_id`, falls
back to the deprecated `acquisition_id` alias when needed, and only generates a
new UUID when neither is provided. Emit a deprecation warning whenever
`acquisition_id` is used, while preserving the existing string conversion.
tests/test_proc/test_proc_inventory.py (1)

646-657: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Drop the and guard; call enrich directly.

with_far.networks is always a non-empty tuple here, so with_far.networks and patch.enrich(...) always evaluates to the enrich result. The guard adds no coverage and hides the intent of the assertion.

♻️ Proposed simplification
         near = patch.enrich(inventory, attrs=False, coords=("coupling.medium",))
-        both = with_far.networks and patch.enrich(
-            with_far, attrs=False, coords=("coupling.medium",)
-        )
+        both = patch.enrich(with_far, attrs=False, coords=("coupling.medium",))
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_proc/test_proc_inventory.py` around lines 646 - 657, Remove the
redundant with_far.networks and guard in test_endpoint_belongs_to_its_own_run
and call patch.enrich(with_far, attrs=False, coords=("coupling.medium",))
directly when assigning both, preserving the existing assertions.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dascore/core/spool.py`:
- Around line 80-102: Update _combine_inventories so a union with an inventory
attached to only one operand is rejected rather than returning that inventory
and enrichment arguments. Preserve the existing rejection for two differing
attachments, and only return inventory data when both operands carry the same
attachment.
- Around line 681-684: Update the spool-copy logic around __class__(self) so
refreshed spools preserve the source spool’s _inventory and _enrich_kwargs
attachment state, including when single-file update() rebuilds via from_file.
Copy both fields to the returned spool before returning it, while retaining the
existing enrich-based kwargs behavior where applicable.

In `@dascore/io/index/schema.py`:
- Around line 286-288: Remove the duplicated "source_path" and "source_format"
members from the set near "source_version" in the schema definition, leaving
only the new "source_version" entry while preserving the existing members.

In `@dascore/proc/inventory.py`:
- Around line 171-174: Update the data-state attribute lookup loop in
_get_system_attrs to call getattr with a None default, matching the existing
_get_system_attrs readers and allowing missing Acquisition fields to continue
through the on_missing path instead of raising AttributeError.
- Line 377: Update the zip call constructing spans to pass strict=True,
preserving the existing filtering and tuple construction while ensuring
intervals and values length mismatches raise instead of truncating.
- Around line 442-452: Update _get_geometry_coord to resolve the CRS axis index
defensively, treating an unknown coordinate label as a missing value instead of
allowing crs.axis_index(label) to raise InvalidInventoryError. Return None for
labels outside the CRS so the existing values-is-None handling can apply the
caller’s on_missing behavior.

In `@docs/recipes/real_time_proc.qmd`:
- Around line 112-113: Update the output-path construction around source_path
and output_path to extract only the source file’s basename, then append a
patch-specific suffix using patch_num so multiple patches cannot collide. Join
this sanitized patch filename with output_data_dir, ensuring absolute or nested
source paths never escape the output directory.

In `@tests/test_core/test_spool.py`:
- Around line 468-473: Require both selection examples to verify non-empty
results: in tests/test_core/test_spool.py lines 468-473, update
test_select_data_source_id to assert len(out) > 0 before iterating; in
docs/notes/spool_selection.qmd lines 31-32, add assert len(selected) > 0 before
the existing all(...) assertion.

In `@tests/test_io/test_common_io.py`:
- Around line 707-713: Update test_no_storage_provenance_attrs to include
source_path, source_format, and source_version in the prohibited PatchAttrs
field set, while preserving the existing rejection of path, file_format, and
file_version.

In `@tests/test_io/test_febus/test_febusbsl.py`:
- Around line 81-84: Update test_read_attrs_omit_storage_provenance to include
source_path, source_format, and source_version in the forbidden attribute-name
set, while preserving the existing assertions for path, file_format, and
file_version.

In `@tests/test_io/test_febus/test_febusg1.py`:
- Around line 178-182: Update test_read_attrs_omit_storage_provenance in
tests/test_io/test_febus/test_febusg1.py (lines 178-182) to assert absence of
source_path, source_format, and source_version alongside the legacy names;
update the corresponding SOR scan reader test in
tests/test_io/test_sr4731/test_sr4731.py (lines 183-185) with the same source_*
absence assertion.

In `@tests/test_io/test_xml_binary/test_xml_binary.py`:
- Around line 295-305: Update test_read_omits_provenance and
test_scan_omits_provenance to also reject source_path, source_format, and
source_version in the attribute-name assertions, preserving the existing checks
for the legacy provenance fields.

In `@tests/test_utils/test_patch_utils.py`:
- Line 872: Replace the "/tmp/real_file.h5" test value in the source_path
assignment with a neutral absolute non-memory path, preserving the existing test
behavior and the "memory://registry/patch" value.

---

Nitpick comments:
In `@dascore/core/attrs.py`:
- Around line 88-99: Update PatchAttrs.reject_coordinate_attributes to
explicitly reject the removed identity keys network and station, raising a clear
migration error when either is present before extra attributes are accepted.
Preserve the existing coords validation and dims removal behavior, and follow
Acquisition._reject_start_distance’s migration-message pattern.

In `@dascore/io/prodml/utils.py`:
- Line 347: Update the acquisition ID handling around the `"AcquisitionId"`
attribute so it first uses `experiment_id`, falls back to the deprecated
`acquisition_id` alias when needed, and only generates a new UUID when neither
is provided. Emit a deprecation warning whenever `acquisition_id` is used, while
preserving the existing string conversion.

In `@dascore/proc/inventory.py`:
- Around line 470-480: Remove the redundant optical_distances parameter from
_get_coord_values and use distances directly in the "distance" branch. Update
the function’s call site to pass only distances, preserving all other
coordinate-resolution behavior.

In `@tests/test_proc/test_proc_inventory.py`:
- Around line 646-657: Remove the redundant with_far.networks and guard in
test_endpoint_belongs_to_its_own_run and call patch.enrich(with_far,
attrs=False, coords=("coupling.medium",)) directly when assigning both,
preserving the existing assertions.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 219f551e-782a-44ba-b61a-cd59412ec4ae

📥 Commits

Reviewing files that changed from the base of the PR and between c97545e and 320a19d.

📒 Files selected for processing (90)
  • benchmarks/test_spool_benchmarks.py
  • dascore/config.py
  • dascore/constants.py
  • dascore/core/attrs.py
  • dascore/core/inventory.py
  • dascore/core/patch.py
  • dascore/core/spool.py
  • dascore/core/summary.py
  • dascore/examples.py
  • dascore/io/ap_sensing/utils.py
  • dascore/io/dasdae/core.py
  • dascore/io/dashdf5/core.py
  • dascore/io/febus/a1utils.py
  • dascore/io/febus/core.py
  • dascore/io/febus/g1utils.py
  • dascore/io/febus/t1utils.py
  • dascore/io/gdr/core.py
  • dascore/io/gdr/utils_das.py
  • dascore/io/h5simple/core.py
  • dascore/io/h5simple/utils.py
  • dascore/io/index/backend.py
  • dascore/io/index/catalog.py
  • dascore/io/index/ingest.py
  • dascore/io/index/planned.py
  • dascore/io/index/schema.py
  • dascore/io/neubrex/core.py
  • dascore/io/neubrex/utils_das.py
  • dascore/io/odh4/core.py
  • dascore/io/optodas/core.py
  • dascore/io/optodas/utils.py
  • dascore/io/prodml/core.py
  • dascore/io/prodml/utils.py
  • dascore/io/segy/core.py
  • dascore/io/segy/utils.py
  • dascore/io/silixah5/core.py
  • dascore/io/silixah5/utils.py
  • dascore/io/sintela/core.py
  • dascore/io/sintela/protobuf_utils.py
  • dascore/io/sr4731/core.py
  • dascore/io/sr4731/utils.py
  • dascore/io/tdms/utils.py
  • dascore/io/terra15/utils.py
  • dascore/io/utils.py
  • dascore/io/xml_binary/core.py
  • dascore/io/xml_binary/utils.py
  • dascore/proc/__init__.py
  • dascore/proc/inventory.py
  • dascore/utils/chunk_plan.py
  • dascore/utils/misc.py
  • dascore/utils/patch.py
  • dascore/utils/paths.py
  • docs/changelog.qmd
  • docs/contributing/new_format.qmd
  • docs/notes/spool_chunking.qmd
  • docs/notes/spool_index.qmd
  • docs/notes/spool_selection.qmd
  • docs/recipes/real_time_proc.qmd
  • docs/tutorial/file_io.qmd
  • docs/tutorial/patch.qmd
  • docs/tutorial/spool.qmd
  • tests/test_core/test_attrs.py
  • tests/test_core/test_directory_spool.py
  • tests/test_core/test_file_spool.py
  • tests/test_core/test_inventory.py
  • tests/test_core/test_patch_chunk.py
  • tests/test_core/test_spool.py
  • tests/test_io/test_common_io.py
  • tests/test_io/test_dasdae/test_dasdae.py
  • tests/test_io/test_febus/test_febusbsl.py
  • tests/test_io/test_febus/test_febusg1.py
  • tests/test_io/test_index/test_catalog.py
  • tests/test_io/test_index/test_db_dirspool.py
  • tests/test_io/test_index/test_heterogeneity_stress.py
  • tests/test_io/test_index/test_hive_attrs.py
  • tests/test_io/test_index/test_index_contract.py
  • tests/test_io/test_index/test_index_edge_cases.py
  • tests/test_io/test_index/test_plan.py
  • tests/test_io/test_index/test_planned.py
  • tests/test_io/test_index/test_union.py
  • tests/test_io/test_indexer.py
  • tests/test_io/test_io_core.py
  • tests/test_io/test_pickle/test_pickle.py
  • tests/test_io/test_prodml/test_prodml_write.py
  • tests/test_io/test_sr4731/test_sr4731.py
  • tests/test_io/test_xml_binary/test_xml_binary.py
  • tests/test_proc/test_proc_inventory.py
  • tests/test_utils/test_attrs_utils.py
  • tests/test_utils/test_chunk.py
  • tests/test_utils/test_config.py
  • tests/test_utils/test_patch_utils.py
💤 Files with no reviewable changes (6)
  • dascore/io/gdr/core.py
  • dascore/io/odh4/core.py
  • dascore/io/sintela/core.py
  • dascore/io/neubrex/core.py
  • dascore/io/silixah5/core.py
  • dascore/io/sr4731/core.py

Comment thread dascore/core/spool.py Outdated
Comment thread dascore/core/spool.py
Comment thread dascore/io/index/schema.py Outdated
Comment thread dascore/proc/inventory.py
Comment thread dascore/proc/inventory.py Outdated
Comment thread tests/test_io/test_febus/test_febusbsl.py Outdated
Comment thread tests/test_io/test_febus/test_febusg1.py Outdated
Comment thread tests/test_io/test_indexer.py
Comment thread tests/test_io/test_xml_binary/test_xml_binary.py Outdated
Comment thread tests/test_utils/test_patch_utils.py Outdated
A review pass that ran real workflows against dev and against this
branch, plus the first CI run.

Upgrading:

- Default patch names now contain dots, because data_source_id does, and
  get_patch_names stripped everything after the first dot when reading a
  name back off a path. Writing patches under their own names and
  re-indexing collapsed every patch of one source onto one truncated,
  colliding name. It strips the extension only.
- An archive partitioned by network=/station= hive keys stopped being
  partitioned when those left the default groupby_attrs, so chunking
  merged patches from different places -- silently discarding one of
  them under conflict="drop". They stay in the default: a name missing
  from a spool is ignored, grouping too finely only leaves patches
  unmerged, and grouping too coarsely loses data.
- A patch written before the units were fixed still carries the
  companion attr naming its own, so the ProdML writer honors it instead
  of stamping seconds on a value in nanoseconds.
- The index columns kept their names while their vocabulary and units
  changed, which no version check could catch. The index version is
  bumped, so a stale index is rebuilt rather than mixing the two.
- A spool carrying an inventory did not equal its own pickle:
  deep_equality_check walks a model's fields, where an unset NaT time
  never equals itself. Inventories compare with == in __eq__ instead.

CI:

- The lint hook wants the future import wherever annotations appear.
- The docs link validator resolves callables, not module constants.
- NetCDF hands back the attrs stored in the file, like DASDAE, so the
  file rather than the reader chooses the names; the vocabulary test
  exempts both and lists MiniSEED's own reader-chosen names.
Two review passes looking for helpers that do not earn their keep and
for rules stated more than once.

The half-open coverage rule with the run end included was written twice,
in coordinates_at for geometry and in _fill_from_intervals for the
tracks. It is one rule, so it is one function, interval_masks, which the
model owns and enrichment uses. The "paired, finite, strictly
increasing" control-point check was written three times across
DistanceMap and Geometry; it is now one check with the object's name
passed in.

enrich carried its own _values_equal which returned False for any array
attr, because bool(array == array) raises and it caught that as a
disagreement. The models' own comparison already handles arrays, nulls,
mappings and sequences, so it is promoted to public and used instead.
The dotted-name owner lookup is one helper rather than two spellings,
and the on_missing values derive from the signature's own Literal
instead of being written twice.

convert_attr_units swaps a parameter no reader used for one two readers
need: a from_units default for headers whose units live in the key name,
which is what silixa's private converter was doing.

Gone entirely: a segy wrapper whose body no longer read the header it
named, an sr4731 extras chain whose last caller this branch removed, a
prodml wrapper restating its own loop, a febus zero-argument function
returning a constant, and a bool() cast around one numpy call.
Splatting a plain dict widens the Literal-typed data_type and
data_category to str; from_dict validates the same mapping without
claiming to. The prodml measure is checked for absence before float(),
which cannot take None.
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown

✅ Documentation built:
👉 Download
Note: You must be logged in to github and a DASDAE member to access the link.

The reader emits the interrogator's facts under their dotted names; the
test still asked for the vendor spellings, which only CI could see
because the protobuf reader needs an optional dependency.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@dascore/core/inventory.py`:
- Line 1070: Update the zip call in the geometry/mask iteration to use
strict=True, enforcing that self.geometry and masks contain exactly one
corresponding entry per interval as guaranteed by interval_masks.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 87bc1b7f-cf63-46bf-9c00-db42a1e228fe

📥 Commits

Reviewing files that changed from the base of the PR and between 320a19d and 35ade20.

📒 Files selected for processing (24)
  • dascore/config.py
  • dascore/constants.py
  • dascore/core/inventory.py
  • dascore/core/spool.py
  • dascore/io/febus/t1utils.py
  • dascore/io/index/schema.py
  • dascore/io/prodml/utils.py
  • dascore/io/segy/core.py
  • dascore/io/segy/utils.py
  • dascore/io/silixah5/utils.py
  • dascore/io/sr4731/utils.py
  • dascore/io/utils.py
  • dascore/proc/inventory.py
  • dascore/utils/models.py
  • dascore/utils/patch.py
  • docs/changelog.qmd
  • docs/contributing/new_format.qmd
  • tests/test_core/test_inventory.py
  • tests/test_io/test_common_io.py
  • tests/test_io/test_io_core.py
  • tests/test_io/test_sintela/test_protobuf.py
  • tests/test_proc/test_proc_inventory.py
  • tests/test_utils/test_config.py
  • tests/test_utils/test_patch_utils.py
💤 Files with no reviewable changes (1)
  • dascore/io/segy/utils.py
🚧 Files skipped from review as they are similar to previous changes (13)
  • docs/changelog.qmd
  • dascore/io/index/schema.py
  • dascore/io/utils.py
  • dascore/io/febus/t1utils.py
  • tests/test_utils/test_patch_utils.py
  • docs/contributing/new_format.qmd
  • tests/test_io/test_io_core.py
  • dascore/constants.py
  • dascore/utils/patch.py
  • dascore/io/prodml/utils.py
  • dascore/core/spool.py
  • dascore/io/silixah5/utils.py
  • tests/test_core/test_inventory.py

Comment thread dascore/core/inventory.py Outdated
@codecov

codecov Bot commented Aug 10, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (1f4542c) to head (ca3eca4).

Additional details and impacted files
@@            Coverage Diff             @@
##               dev      #855    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          176       177     +1     
  Lines        19501     20017   +516     
==========================================
+ Hits         19501     20017   +516     
Flag Coverage Δ
network 47.00% <28.96%> (-0.74%) ⬇️
unittests 100.00% <100.00%> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Febus' own reference library declares PulseWidth with unit "m"
(febus_optics_lib 1.4.2, plugins/plugins_das_febus.py), which settles it:
the value is a length, not a time, and keeps terra15's pulse_length name.

The legacy group attrs say they are legacy, in the comment and in the
config field's own description. And the reason sample_rate and
spatial_interval sit out of blanket enrichment is stated as what it is:
nothing should be redundant between a patch's coordinates and its attrs,
and those two are what the time and distance coordinates already say.
Attaching an inventory and using it were one operation, so a spool could
not carry an inventory without paying to enrich every patch it yielded.
Enrichment is real per-patch work — roughly a 70% overhead on extracting
a patch from disk — and a spool being selected on rather than read should
not pay it.

attach_inventory now only carries the inventory. Spool.enrich is what
uses it, holding its arguments and enriching each patch as it comes out,
and Spool.remove_inventory drops both. Attaching clears enrichment set up
from a previous inventory: swapping the source underneath a configured
enrichment would silently rewrite every patch's metadata.

A union carries the inventory and the enrichment independently, so
attaching the same inventory to the other operand can no longer turn a
working union into an error. Enrich arguments are bound against
Patch.enrich's signature when they are given, so a misspelled one fails
there rather than on some patch pulled much later, and two spools which
enrich identically compare equal however the arguments were spelled.

Patch.enrich no longer writes a nested update_coords history entry, which
pasted a rendered repr of every added coordinate into the history of every
enriched patch and cost 24% of its runtime.

Selecting on inventory-defined fields still is not supported; the query
error now says so instead of denying the field exists (#857).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (2)
tests/test_proc/test_proc_inventory.py (1)

951-954: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Silence the pickle rule on this round-trip assertion.

Ruff reports S301 on line 954. The payload comes from pickle.dumps in the same statement, so the data is trusted. If S301 is enforced in CI, add a targeted suppression to keep the run clean.

🔧 Proposed change
-        assert pickle.loads(pickle.dumps(spool)) == spool
+        assert pickle.loads(pickle.dumps(spool)) == spool  # noqa: S301
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/test_proc/test_proc_inventory.py` around lines 951 - 954, Suppress Ruff
S301 only on the pickle round-trip assertion in
test_attached_spool_survives_pickle, using the repository’s targeted inline-noqa
convention so the trusted pickle.loads(pickle.dumps(spool)) call no longer fails
linting without disabling the rule elsewhere.

Source: Linters/SAST tools

dascore/core/spool.py (1)

574-575: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Annotate _inventory for consistency.

_enrich_kwargs carries a type annotation. _inventory does not. Add one so both attachment fields declare their type.

♻️ Proposed change
-    _inventory = None
+    _inventory: Inventory | None = None
     _enrich_kwargs: dict | None = None
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@dascore/core/spool.py` around lines 574 - 575, Annotate the class-level
`_inventory` field alongside `_enrich_kwargs` with its appropriate type,
preserving its existing `None` initialization and matching the type declaration
style used for the attachment fields.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@dascore/core/spool.py`:
- Around line 574-575: Annotate the class-level `_inventory` field alongside
`_enrich_kwargs` with its appropriate type, preserving its existing `None`
initialization and matching the type declaration style used for the attachment
fields.

In `@tests/test_proc/test_proc_inventory.py`:
- Around line 951-954: Suppress Ruff S301 only on the pickle round-trip
assertion in test_attached_spool_survives_pickle, using the repository’s
targeted inline-noqa convention so the trusted pickle.loads(pickle.dumps(spool))
call no longer fails linting without disabling the rule elsewhere.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: cf647bd9-aefc-49c4-8b83-69610b92391d

📥 Commits

Reviewing files that changed from the base of the PR and between 72add5e and 30f70ba.

📒 Files selected for processing (4)
  • dascore/core/spool.py
  • dascore/proc/inventory.py
  • docs/changelog.qmd
  • tests/test_proc/test_proc_inventory.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • docs/changelog.qmd
  • dascore/proc/inventory.py

Enriching a spool raised as soon as it reached a patch the inventory had
no entry for, so an inventory covering part of an archive could not be
used on it at all. It now leaves such a patch as it found it, under an
on_unresolved policy of warn, ignore, or raise.

Enrich decides metadata; deciding membership stays prune_to_inventory's
job, which is what keeps enrichment lazy: nothing resolves until a patch
is pulled, so len(spool) and get_contents() keep their meaning. A patch
straddling two epochs is described twice rather than not at all and still
raises, since the answer there is to subdivide it; UnresolvedPatchError
tells the two conditions apart.

Also addresses the PR review comments: requires the map's axes to share
one dimension rather than picking the first (which made the length check
below it unreachable), drops two duplicate reserved column names, adds
strict= to two zips, and checks the renamed storage provenance names in
the reader tests through a new STORAGE_PROVENANCE_ATTRS constant. The
real-time recipe no longer joins an absolute source_path onto the output
directory, which resolved to the input file and overwrote it.
_resolve_context converted every InvalidInventoryError from resolve into
UnresolvedPatchError, so on_unresolved could wave through an id which is
not four dotted parts. A patch's own id is validated when its attrs are
built, so only the explicit argument can be malformed, and that is the
caller getting it wrong rather than the inventory being silent about a
patch it does not describe.
The guard caught tuples and lists, so a mapping reached the numeric
branch and raised a bare TypeError out of float() which on_missing could
not intercept. Every track model carries an extra_fields mapping, so
asking for one was a real request rather than a contrived one.
The field resolves a patch against an inventory: it is a structured
lookup path, network.fiber_array.location.acquisition, validated to
exactly four dotted codes. Naming it with an _id suffix promised the
opposite of what it holds, since _id elsewhere is free to be an opaque
token and usually a UUID.

acquisition_key says what it is without claiming to be an acquisition
(it resolves with time to a sequence of them) or to be one code
(Acquisition.code holds only the last part). It also reduces vocabulary:
the entity was already called an acquisition everywhere else in the
model, the spec, ProdML, and DAS-RCN.

This frees acquisition_id for what ProdML means by AcquisitionId, which
every example file confirms is a UUID for the acquisition run, so the
reader stops renaming it. Since the value identifies a run rather than
an inventory entry, it can cross-check a resolution rather than drive
one. OptoDAS keeps its own header name too: experiment rather than
experiment_id. Readers now have a stated rule to stay as close to the
file's own spelling as the vocabulary allows.

The index version was already bumped this release and nothing has been
released, so the column rename needs no second bump.

Carried along, because they touch the same lines: the on_unresolved
warning no longer names a patch, so it stays one warning instead of one
per distinct key on exactly the partly-covered archive the default
exists to serve; and a test pins update_coords to declaring no
requirements, which is what makes enrich's raw_function bypass legal.
The rename skipped benchmarks/, where a hive-path test kept using the old
name and passed anyway: the old spelling is now an unvalidated extra, so
the benchmark had stopped exercising the field it was meant to cover.

The collapsed on_unresolved warning silenced every later spool in a
session, since a constant message registers once per module. It now
dedupes per spool, which is the granularity that means anything.

Removes an invented history from the contributor docs: neither of the
renames it held up as a cautionary tale ever shipped. Corrects the
changelog's claim that index columns kept their names, in the release
which renamed two sets of them, and stops it filing two vendor extras
inside the canonical vocabulary list.

The rest is mechanical-rename fallout: six "a acquisition_key", eight
stale "data source id" phrases including the validator's own docstring,
two stale test class names, and the spec still explaining a data_ prefix
on a name which no longer has one.
Dev's shared FiberIO scan/read scaffolding (#859) touched most of the
readers this branch had already rewritten for the attr vocabulary, so
several files needed both changes rather than either.

Resolutions worth noting: io/utils.py keeps both sides' helpers, since
each added different functions to the same region; prodml/core.py and
dashdf5/core.py take dev's deletion of the unused ProdMLPatchAttrs, the
real classes living in prodml/utils.py where this branch's changes
already applied; febus/t1utils.py uses dev's make_scan_payload with this
branch's _T1_ATTRS, which never carried the provenance dev's version
strips; sr4731/utils.py gains dev's extras plumbing on top of this
branch's interrogator.* naming; and h5simple keeps dev's structure minus
the two lines putting file_format and file_version back into patch
attrs, which this branch removed and which the vocabulary conformance
test caught immediately.
on_missing="nan" fills NaN for floats but None for strings, so the name
described half the cases it covered. "null" is true across dtypes.

Also stops Spool.enrich's kwargs documentation listing the argument names
it forwards. The list could drift from Patch.enrich, which documents them
and whose signature the names are actually read from.
Two of these made a patch unsaveable rather than merely odd.

A track or annotation covering only part of a patch filled the uncovered
channels of a string coordinate with None, leaving an object array of str
and None which could not be written, chunked, or sorted. The shipped
example inventory reproduces it through plain enrich, since blanket
coords copy every annotation group. A coordinate carries one dtype, and a
string array has no null, so absence is now the empty string; the
boolean and numeric kinds already had markers.

closed_fiber_loop is the first bool DASCore routinely puts in patch
attrs, and netCDF has no boolean attribute type, so every enriched patch
failed to write as netcdf_cf. Bools are stored as the integer flags CF
uses for exactly this.

The merge also resurrected sr4731's extras plumbing, which this branch
had deleted along with the _get_extras that fed it storage provenance.
Nothing calls it, but out.update(extras or {}) ran last and would have
overwritten the canonical interrogator.* names.

Inventory.resolve validated acquisition keys itself rather than through
the shared validator, so a trailing space read as "resolves to 0
acquisitions" instead of naming the malformed key -- the distinction
97e0b02 drew for enrich, missing from the public entry point.

Also: h5simple excludes storage provenance and the format discriminator
when copying root attrs, so scan and read agree and neither leaks a
file-declared file_version; Spool.enrich stops referring to
prune_to_inventory in the present tense; and max_lens drops the three
provenance entries left behind by the source_* rename.
AP Sensing reported the wrong device. The reader took its serial from the
DAQ group, which describes the digitizer card inside the interrogator, so
patches carried a uint32 card serial as interrogator.serial_number while
the file's own Interrogator/SerialNumber sat unmapped. On dev the key was
misspelled and never reached a canonical field, so renaming it is what
promoted the wrong value; Interrogator/Model is now read too.

Re-enriching a selection had stopped being a refresh. A string
coordinate's width is fixed by the longest value present, so a patch
sliced down to the short values keeps the wider dtype, and _coords_equal
compared dtypes exactly. It now compares the kind, which still tells a
float coordinate from a string one.

A track field every interval leaves at its empty default now reads as
missing rather than yielding a coordinate blank throughout, and a CRS
label this inventory does not define does the same instead of raising an
error on_missing cannot intercept. Both now behave like every other name
the inventory has no answer for.

Also: Inventory.resolve rejects the empty key rather than failing to
unpack it; the shared validator enforces the same length PatchAttrs does,
so a key too long to store cannot read as merely unknown; an annotation
value may not be the empty string, which a string coordinate spends on
uncovered channels; the netCDF bool coercion covers arrays and sequences
and no longer touches the dataset attrs, which are always empty.

Two findings are recorded rather than fixed. Spool.select reads a bare
numeric range as canonical SI while Patch.select reads it in the
coordinate's own units, so enriched geometry in degrees selects
differently through a spool than through a patch (#863) -- it predates
this work and changing it would change indexed-query semantics for every
non-SI coordinate. And DASDAE caches written before this release keep
their unconverted values and *_units companions, which nothing migrates.
d-chambers and others added 2 commits August 11, 2026 16:56
Released DASDAE caches store *_units companions (gauge_length, pulse_width,
pulse_rate) or spell the unit into the key (pulse_width_ns). Reading now
spends that unit statement at the parse boundary like every reader does:
values convert to the fixed units, a length-dimensioned pulse becomes
pulse_length, and coordinate-named keys are left to coord reconstruction.
Febus A1 and Silixa H5 caches never wrote their companions (the released
writer excluded unset model defaults), so they keep the rewrite warning.

The netcdf bool-attr tests now pass file_format explicitly like the rest
of their module; the conda CI env writes NETCDF3 via the scipy backend,
which bare-path format detection cannot sniff.
Without netCDF4 or h5netcdf, xarray's to_netcdf falls back to scipy and
writes NETCDF3 classic, which this module's HDF5-based reader and format
detection cannot open — the write succeeded and every later read failed
(#866). Writing now raises MissingOptionalDependencyError with an install
hint instead, after input validation so shape errors still come first.
The bool-attr round-trip tests gate on _require_xarray_netcdf_engine()
like the rest of the module; NETCDF3 read support is tracked in #867.
The test fakes optional_import and the whole xarray surface, but the new
engine guard probes installed packages directly, so in an environment
without netCDF4/h5netcdf it fired inside a test whose point is that
nothing real is touched.
Dev's protobuf scan work (#865) landed after this branch renamed the
vendor identification attrs, so its trailing-META test asserted the
pre-rename spellings; the reader itself already emits the dotted names.
@d-chambers d-chambers changed the title Add the inventory patch surface: data_source_id, reader vocabulary, and enrich Add the inventory patch surface: acquisition_key, reader vocabulary, and enrich Aug 11, 2026
@d-chambers
d-chambers merged commit 541b25a into dev Aug 11, 2026
40 of 41 checks passed
@d-chambers
d-chambers deleted the inventory-phase-2 branch August 11, 2026 18:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation IO Work for reading/writing different formats patch related to Patch class proc Related to processing module spool related to Spool class

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant