Skip to content

Draw an inventory: its path, its layout, and its epochs - #952

Merged
d-chambers merged 9 commits into
devfrom
inventory-viz-2
Aug 21, 2026
Merged

Draw an inventory: its path, its layout, and its epochs#952
d-chambers merged 9 commits into
devfrom
inventory-viz-2

Conversation

@d-chambers

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

Copy link
Copy Markdown
Contributor

Description

An Inventory describes the observing system behind a fiber archive, and until now it could not draw itself at all: dascore/viz/ held five patch functions and nothing else. The hand-drawn docs/_static/tunnel_deployment.svg in the tunnel recipe exists because the library could not produce it.

This adds three plots on a new Inventory.viz namespace, each looking along one coordinate:

Call Looks along What you choose
inventory.viz.path optical distance tracks= picks the lanes (channels, components, coupling, label-group names); columns= draws a geometry column as a panel beneath; distance=(low, high) windows the axis
inventory.viz.map space x=/y= pick the CRS axes; color= takes distance, a geometry column, a label group, or coupling
inventory.viz.timeline time kind= and color=; time=(start, end) windows the axis

The window arguments are named for the coordinate they select on, the way select already spells it, and either end may be None.

The renderer is the load-bearing part

The three plots sit on dascore/viz/_lanes.py, a dataframe-first interval-lane renderer whose column names are parameters rather than a fixed schema. That is what lets spool.get_contents() be passed straight in later without a rename, and it is why the module is general from day one though it has one caller today. plot_lanes builds its rectangles into one PatchCollection per lane per sub-row, so per-interval assertions still work through get_paths() while a large frame stays a handful of artists. lane_gaps is the gap derivation on its own, since "where are the holes" is worth asking without a figure attached.

Colour follows value_kind — the same function OpticalPath._check_label_groups uses, so the picture cannot disagree with the validator. Strings are categorical and assigned frame-wide, so one value is one colour everywhere in a figure; numbers are continuous, normalized per lane; booleans take the lane's colour with False at low alpha.

Choices worth a second pair of eyes

  • Unsurveyed fiber is a gap, never a bridge. map drops any segment touching a NaN position, so the tunnel's slack coil reads as the break it is.
  • A single-point distance_map draws as a tick. It states an origin, and the inventory records no channel count, so inventing an extent would be a lie in the one place users trust the picture.
  • path does not trim by default. A 1.5 km lead-in takes the figure unless distance= is given; honest by default, and the window is one argument away.
  • map with no path named draws every path which places itself, while path and timeline insist on one. A map of one cable in an inventory of several would be the strange default.
  • The timeline leaves out epochs outside its window rather than clipping them to a sliver at the edge, which would read as an epoch which ended there.
  • An inventory whose epochs state no time still draws, on an axis explicitly labelled as stating none, since that is what most hand-written YAML looks like.

Later, out of scope here: spool availability and gaps, and AnnotationSet viz. Both are consumers of the same renderer.

Follows #938 (the example inventories these plots are tested and documented against) and #937 (which made Inventory a namespace host).

Review

A cross-model sweep (Codex on the CLI, Codex on this PR, and six Claude reviewers running blind to each other) returned 26 correctness findings; all are fixed and pinned by tests. The ones worth knowing about:

  • Container epochs. A path or acquisition stating no time bound defers to its network and fiber array, so filtering on the child alone let a time passed to disambiguate epochs fail to do so, and drew inherited bounds as unbounded.
  • Short gaps were bridged. A uniform sample grid stepped over an unsurveyed stretch narrower than its spacing, drawing a line across fiber nobody placed — the one thing the map promises not to do. Geometry is now sampled at every gap.
  • Unstated values vanished. A numeric colouring left them NaN, which matplotlib draws transparent, so a placed cable disappeared where it stated no value.
  • Labels were measured in points against a box in pixels, so the same figure kept or lost its labels depending on dpi.
  • Diagnostics. Every resolve failure was retold as an ambiguity no time could fix; an address was offered as a way to choose among epochs of one path, which only a time does.

Two of the regression tests written in the first round could not fail — the gap test read only segment endpoints, where a bridge has none, and the palette test used two paths whose labels were byte-identical. Both were rewritten against cases that distinguish, and every fix here was mutation-tested: the fix is reverted, and the test must fail.

The figures also had a styling pass: components take an Okabe-Ito set, categories are drawn from tab20's dark half first so neighbours are not two shades of one hue, in-box labels carry a halo so they survive a dark fill, wide maps get a horizontal colorbar, and constrained layout keeps the furniture on the canvas.

Changelog

  • added: Inventory.viz.path plots an optical path's components, coupling, channels, and label groups against optical distance.
  • added: Inventory.viz.map plots where an inventory's fiber physically goes, breaking the line where no position is stated.
  • added: Inventory.viz.timeline plots when each acquisition and optical path was valid.

Checklist

I have:

  • filled in the Changelog section above (see docs/contributing/general_guidelines.qmd).

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.

…e map

path() no longer draws the CRS position axes as line panels; columns=
names the non-position geometry columns worth a panel, and tracks=
selects which lanes are drawn and in what order. Inventory.viz hosts
path, map and timeline.
distance= and time= replace distance_limits/time_limits, matching the
coordinate names selection already uses; either end may be None. The
timeline leaves out epochs which fall outside its window rather than
clipping them to a sliver at the edge.

Adds tests for the renderer and the plots, and documents them in the
inventory tutorial, the visualization page, and the tunnel recipe.
Six fixes from review.

A numeric coloring left unstated fiber as NaN, which matplotlib draws
transparent, so a placed cable vanished where it stated no value. It
now takes the grey categorical coloring already uses, with a legend
entry beside the colorbar.

Numeric lanes are normalized per lane, but shared one colorbar, so two
lanes were described by one scale only one of them had. Each numeric
lane now carries its own labelled bar.

The map rebuilt its categorical palette per path, so one value could be
two colors in one figure and the legend named only the first. The
palette is now the figure's.

The timeline kept an epoch which only touched its window, against the
half-open semantics epochs are stored with, and plot_lanes advertised
datetime bounds but left ordinal ticks on the axis.

Selecting a path said an address would disambiguate epochs, which only
a time does, and an ambiguous acquisition key leaked
InvalidInventoryError out of Inventory.resolve.
@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@d-chambers, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 17 minutes

Limit details: You’ve used all 2 included reviews currently available.

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

Wait for the limit to reset, then comment @coderabbitai review or push new commits to the PR.

An organization admin can change what happens after included review limits in Billing.

How do review limits work?

CodeRabbit enforces per-developer PR review limits within each organization.

For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: d345ea83-852b-4925-8f38-877ea6157010

📥 Commits

Reviewing files that changed from the base of the PR and between 4bc33ba and e960d01.

📒 Files selected for processing (9)
  • dascore/viz/__init__.py
  • dascore/viz/_lanes.py
  • dascore/viz/inventory.py
  • docs/recipes/tunnel_inventory.qmd
  • docs/tutorial/inventory.qmd
  • docs/tutorial/visualization.qmd
  • pyproject.toml
  • tests/test_viz/test_inventory_viz.py
  • tests/test_viz/test_lanes.py

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.

@d-chambers

Copy link
Copy Markdown
Contributor Author

@codex review

@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: 8222a81b61

ℹ️ 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/viz/inventory.py Outdated
Comment on lines +76 to +78
candidates = found
if time is not None:
candidates = [x for x in found if x[3].is_effective_at(time)]

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 Filter paths through their containing epochs

When a Network or FiberArray has multiple epochs and its child paths leave their bounds unset, those child bounds defer to the container (_containment_errors explicitly defines this). Filtering only with path.is_effective_at(time) therefore retains paths from inactive container epochs, causing path(..., time=...) to remain ambiguous or select invalid metadata. The same path-only filtering is repeated in map_path's no-selector branch, where obsolete and current layouts can be drawn together; filter the network and array epochs as Inventory.resolve does.

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 in 3c610f5. Reproduced first: two networks with successive epochs whose child paths leave their bounds unset made path(time=...) report ambiguity, i.e. a time passed to disambiguate could not. _effective_at now intersects the network, array and path epochs, and map_path's no-selector branch uses the same helper (it needed _iter_paths' network, which it had been discarding). test_containers_decide_which_epoch pins it; its mutant — filtering on the child alone — fails.

Comment thread dascore/viz/inventory.py Outdated
Comment on lines +738 to +739
"start": one.start_time,
"end": one.end_time,

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 Clamp timeline rows to their container epochs

For checked inventories where a path or acquisition omits a time bound under a bounded FiberArray or Network, the omitted bound inherits the container's bound rather than remaining infinite. Copying only the child's times here makes timeline() hatch the row as unbounded and show it outside the period in which resolution can reach it; compute each displayed interval from the intersection of the network, array, and child epochs. The acquisition rows below have the same problem.

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 in 3c610f5. _effective_epoch intersects the network, array and child bounds, so a row is drawn over the period resolution can actually reach it, and an inherited bound is no longer hatched as unbounded. The same test covers the timeline half: a path with unset bounds under bounded containers used to make timeline() claim no epoch stated a time at all.

Comment thread dascore/viz/inventory.py Outdated
Comment on lines +633 to +642
items = [x for x in one.labels if x.group == color]
keys = [x.value for x in items]
if not items:
groups = tuple(dict.fromkeys(x.group for x in one.labels))
msg = (
f"color={color!r} names neither optical distance, a geometry "
f"column ({one.geometry_columns()}), a label group ({groups}), "
"nor 'coupling'."
)
raise ParameterError(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 Treat missing color tracks as unstated

When map_path() draws multiple placed paths and color names a geometry column or label group present on only some of them, processing the first path without that track raises here and aborts the entire map. Partial coverage within a path is already rendered as gray “not stated” fiber, so an entirely unstated path should receive the same treatment; otherwise the documented default of drawing every placed path cannot be combined with ordinary heterogeneous inventory metadata.

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 in 3c610f5. The color name is now validated once against every drawn path, so a typo still raises (with the names the inventory does state), while a path that simply says nothing under a real name draws as "not stated" — the same grey and the same legend entry partial coverage within a path already used. test_a_path_without_the_color_is_unstated covers it.

Comment thread dascore/viz/inventory.py
Comment on lines +52 to +60
try:
context = inventory.resolve(acquisition_key, time)
except InvalidInventoryError as error:
msg = (
f"Acquisition key {acquisition_key!r} names more than one "
"acquisition, which happens where it was reconfigured. Pass a "
"time as well, to say which of its epochs to draw."
)
raise ParameterError(msg) from error

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 acquisition-resolution errors

If acquisition_key is malformed, unknown, resolves to zero networks or acquisitions, or fails at another hierarchy level, Inventory.resolve() raises InvalidInventoryError, but this blanket handler always reports that the key names multiple acquisition epochs and asks for a time. For typos and missing entries, supplying a time cannot help and hides the actual diagnostic from resolve; only translate the specific ambiguity case and preserve or accurately wrap the other resolution failures.

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 in 3c610f5. The handler now keeps what resolve said — "resolves to 0 acquisitions" stays that — and only appends the time hint when no time was given, as advice rather than a diagnosis. test_unknown_acquisition_key_keeps_its_error asserts the preserved count.

Comment thread dascore/viz/inventory.py Outdated
Comment on lines +270 to +275
def path(
inventory,
optical_path=None,
*,
acquisition_key: str | None = None,
time=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.

P1 Badge Add types to the public visualization signatures

The newly exported path, map_path, and timeline APIs leave core parameters such as inventory, optical_path, time, and color untyped even though the repository requires type hints on public functions. Complete these signatures so callers and generated API documentation expose the accepted inventory and selector types consistently. .agents/agents.mdL36-L40

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 in 3c610f5. inventory, optical_path, time, color and aspect are annotated on all three public functions, with Inventory/OpticalPath/timeable_types imported under TYPE_CHECKING so dascore.viz stays lazily importable (pinned by tests/test_imports.py::test_matplotlib_not_imported).

Correctness. A path or acquisition which states no time bound defers to
its network and fiber array, so selection and the timeline now intersect
the three epochs rather than reading the child alone; a time passed to
path() could otherwise fail to disambiguate the epochs it was passed to
choose between. Resolution failures keep what resolve said instead of
being retold as an ambiguity no time can fix. A map colored by a group
only some paths state draws the others as unstated rather than refusing
the figure. The timeline's window takes a missing end, refuses a
reversed one, and refuses a bound which is not a time; the distance
window is resolved before a figure exists, so a refusal leaks none.
Geometry is sampled at every gap, so a gap shorter than the sample
spacing still breaks the line rather than being drawn across.

The renderer. Labels are measured rather than counted, so the same
figure keeps them at any dpi. Gaps are asked of the data, not of the
margin drawn around it. A color which names no colormap is a color. The
palette is the path's, so drawing some tracks colors them as drawing all
of them does, and lanes with pinned colors no longer spend palette slots.

The figures. Components take an Okabe-Ito set, categories are drawn from
tab20's dark half first so neighbours are not two shades of one hue,
in-box labels carry a halo, a wide map gets a horizontal colorbar, and
the constrained layout keeps furniture on the canvas.

Tests. Two regression tests could not fail: the gap test read only
segment endpoints, where a bridge has none, and the palette test used
two paths whose labels were byte-identical. Both are rewritten against
cases which distinguish, and every fix here is pinned by a test whose
mutant dies.
@codecov

codecov Bot commented Aug 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 100.00%. Comparing base (4bc33ba) to head (e960d01).
⚠️ Report is 4 commits behind head on dev.

Additional details and impacted files
@@            Coverage Diff             @@
##               dev      #952    +/-   ##
==========================================
  Coverage   100.00%   100.00%            
==========================================
  Files          195       197     +2     
  Lines        24891     25715   +824     
==========================================
+ Hits         24891     25715   +824     
Flag Coverage Δ
network 43.69% <10.84%> (-0.97%) ⬇️
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.

Importing dascore.viz registers the namespace, so every existing test
would pass with the pyproject entry point deleted; one test now reads
the declaration itself. The palette constants are read through the
module rather than bound at import, so rebinding one moves the lanes
and the column panels together, which is what a module-level palette
is for.
@d-chambers d-chambers added the documentation Improvements or additions to documentation label Aug 20, 2026
lane_gaps and the gaps= drawing branch had no caller here: they were
built for the spool coverage plot, which is its own change. Deriving
what a frame does not cover is not a plotting question either, so when
it returns it belongs beside the other interval helpers in
dascore/utils/intervals.py rather than in the renderer.

pack= stays, since the inventory relies on it to keep overlapping
intervals from drawing on top of each other.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

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

A handful of distinct numbers is a set of categories which happen to be
numbered, so it now takes one color each and a bar which reads at the
values themselves: three boreholes are three blocks, not a ramp through
one and a half. More than a handful is a quantity, and still ramps.

The map's scale also covers only what the projection draws. Seen from
above a borehole is a point, so it was spending three quarters of the
colormap on fiber with no visible length, leaving the trench in one
narrow band. An arrow marks values carried past the end of the scale.

Fiber nobody described reads n/a rather than 'not stated', and the halo
which keeps a label off its own fill is thinner.
@d-chambers d-chambers added the ready_for_review PR is ready for review label Aug 20, 2026
@d-chambers

Copy link
Copy Markdown
Contributor Author

What these plots look like

Everything below is the tunnel deployment from dc.get_example_inventory("tunnel"), drawn by this branch at e960d01f. Each of the three plots looks along one coordinate: path along optical distance, map through space, timeline through time.

inventory.viz.path — what lies along the fiber

inventory.viz.path(time="2024-07-01", distance=(1495, 1780))

path

Every track the path describes becomes a lane. Channels sit on the whole path; splices and connectors are point ticks rather than zero-width boxes; coupling and the two label groups show the trench, the slack coil, and the three boreholes — numbered 3, 2, 1 as the fiber works back through them. distance= is the window, in the same coordinate the data is in.

tracks= picks the lanes, in the order given:

inventory.viz.path(time="2024-10-01", tracks=("coupling", "section", "borehole"), distance=(1495, 1780))

path with tracks

This is the post-repair epoch, and the colours agree with the figure above — trench brown, borehole orange — because the palette is built from the whole path rather than from whichever tracks are drawn.

A geometry column becomes a line panel beneath, sharing the distance axis. On the small example from the inventory tutorial the chainage stands still around 240 m while ten metres of fiber goes by — that is the slack coil:

inventory.viz.path(columns="chainage")

path with a column

Position axes deliberately get no panel; where the fiber is belongs to the map. Without a window the 1.5 km telemetry lead-in takes the figure, which is honest by default:

path unwindowed

inventory.viz.map — where the fiber physically is

inventory.viz.map(x="x", y="z", color="section", time="2024-07-01")

map section

Section view: trench along the floor, three holes to 20 m. The break near x = 122 m is the slack coil. Unplaced fiber is left out rather than bridged — a made-up polyline would be worse than a gap — and a gap narrower than the sample spacing still breaks the line.

x/y choose the axes, because a borehole runs straight down and a fixed plan view would collapse it to a point:

inventory.viz.map(x="x", y="z", time="2024-07-01")   # default colouring: optical distance
inventory.viz.map(time="2024-07-01")                 # the default plan view

map by distance

map plan

The colour scale covers what the projection actually shows. Seen from above the boreholes are points, so counting them would spend three quarters of the colormap on fiber with no visible length and leave the trench in one flat band; the arrow marks values carried past the end of the scale.

color= also takes a label group or "coupling". A few distinct numbers are a set of numbered categories rather than a quantity, so they get one colour each and a bar that reads at the values themselves — three boreholes, no half-boreholes — while fiber that states nothing reads n/a:

inventory.viz.map(x="x", y="z", color="borehole", time="2024-07-01")

map by borehole

inventory.viz.timeline — when each part was valid

inventory.viz.timeline()

timeline

One lane per optical-path lineage and per acquisition. The path lane splits on 1 September, the day the trench cable was repaired; the acquisition's does not, since the interrogator was never reconfigured, so its epoch runs unbounded in both directions and is hatched at each end. An epoch is the intersection of its own bounds with its fiber array's and its network's.


Images live on the pr-952-figures branch so they stay out of this PR's diff; it can be deleted once this lands.

@d-chambers
d-chambers merged commit 61a62ce into dev Aug 21, 2026
32 checks passed
@d-chambers
d-chambers deleted the inventory-viz-2 branch August 21, 2026 08:58
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 ready_for_review PR is ready for review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant