Skip to content

Promote reach-end gridpoints to breakpoints, remove boundary - #694

Draft
jpalm3r wants to merge 52 commits into
mainfrom
expose-reach-quantities
Draft

Promote reach-end gridpoints to breakpoints, remove boundary#694
jpalm3r wants to merge 52 commits into
mainfrom
expose-reach-quantities

Conversation

@jpalm3r

@jpalm3r jpalm3r commented Aug 5, 2026

Copy link
Copy Markdown
Collaborator

Important

Stacked on #685 (network-loading) — merge that first. This diff will include #685's changes until then; the description below covers only what this branch adds on top of network-loading.

Summary

Reach-end gridpoint data (e.g. EPANET's Flow/Velocity, or MIKE's reach-end WaterLevel, which is a distinct series from the node's own) was previously held in a boundary dict on NetworkNode that nothing ever read. This PR removes boundary and instead promotes a MIKE reach's first/last gridpoint to an ordinary breakpoint, so that data is reachable through find()/recall()/to_dataframe() like any other break point.

  • Res1DReach now includes a reach's own start/end gridpoint in its breakpoints (previously only the intermediate ones), gated by populate_gridpoints like every other breakpoint — reaches=[] now consistently means "no gridpoint data at all," including these.
  • Since a reach-end breakpoint sits at the same location as its node, _generate_graph connects them with a zero-length edge, tagged boundary=True and clamped to exactly 0.0 (the raw value is a difference between two independently-sourced numbers, so floating-point noise could otherwise leave a tiny negative weight and break shortest-path algorithms).
  • NetworkNode.boundary (and BasicNode/Res1DNode's boundary param) are removed — a breaking change for any existing NetworkNode subclass.
  • Reverts an in-progress, broken rewrite of _build_dataframe (25 failed / 29 errors on this branch beforehand) — it turned out not to be needed, since breakpoints already flow into the dataframe through the same mechanism as any other graph node.
  • Docs and the one notebook referencing boundary are updated accordingly.

Deliberately out of scope: EPANET. Its reaches have at most one synthetic gridpoint belonging to neither end, so the existing gate (len(reach.gridpoints) > 2) already excludes them — EPANET keeps today's behavior (no breakpoints, length=None) unchanged.

Closes #599.

Related to #680, which stays open: this only reaches MIKE's reach-end data. EPANET's single-gridpoint case needs its own placement rule and is left for a follow-up.

Test plan

  • uv run pytest tests/ — 814 passed, 6 skipped
  • uv run ruff check / uv run mypy on all touched files
  • Manually verified boundary-edge tagging and the floating-point clamp against the real network.res1d/network_cali.res11 fixtures

jpalm3r and others added 30 commits July 24, 2026 15:24
Topology-only nodes store an empty DataFrame (not None), so the existing
`is not None` check never dropped them. Concatenating an empty RangeIndex
frame alongside real DatetimeIndex frames degraded the result index to
object dtype, which later failed the "time must be datetime" assertion in
ms.match(). Also skip empty frames so the DatetimeIndex is preserved.

Fixes #676

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ComparerCollection.save()/load() doesn't support the NODE geometry
type: Comparer.load() has no "node" branch and falls into
NotImplementedError("Unknown gtype: node").

Refs gh #677
Comparer.save() only flattened raw_mod_data into the netcdf for
gtype == "point", so node comparers silently dropped their raw model
data on save. Extend the check to also cover "node".

Part of gh #677
load() dispatched "track" / "vertical" / "point" and raised
NotImplementedError for anything else, so a node comparer's netcdf
could never be loaded back. Reconstruct NodeModelResult from the
flattened _raw_* variables, mirroring the existing point branch, using
the node coordinate carried along on each variable.

Fixes gh #677
ms.match() raises a bare pandas TypeError ("Cannot compare tz-naive
and tz-aware datetime-like objects") when the observation and model
result disagree on timezone-awareness, with no ModelSkill-specific
context about what went wrong.

Refs gh #678
No timezone handling existed anywhere in modelskill, so a mismatch
between a tz-aware and tz-naive time index only surfaced as a bare
pandas TypeError deep inside observation.trim(), with no indication
of what went wrong. Validate tz-awareness compatibility between the
observation and each model result before trimming, and raise a
ValueError naming both sides.

Fixes gh #678
mypy inferred ts's type from its first assignment (NodeModelResult),
flagging the later PointModelResult assignment as incompatible.

Part of gh #677
…s earlier

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Two costs profiling exposed in from_res1d on large networks (gh #679):
every topology-only location builds its own empty DataFrame, and a node
shared by several reaches is read once per reach endpoint even though
_generate_graph keeps only the first copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every topology-only node and gridpoint allocated its own empty frame: two
per reach, 35% of a filtered load on the test file and ~5s on a reported
8,377-reach network. _build_dataframe already skips empty frames before
the concat, so the shared instance never reaches pandas.
_load_res1d_network visited a node once for every reach it belongs to, but
_generate_graph keeps only the first copy, so the repeat reads were thrown
away: 236 reads for 119 nodes on the test file, 16,754 for 7,955 on a
reported network. Boundary reads stay per-reach.
gh #679 asks for selective ingestion by quantity. Locations that do not
carry a requested quantity should become topology-only rather than raise,
and the filter should compose with the existing nodes/reaches filters.
Applied at the read layer, so the full topology is still built. Pushing
the filter into the Res1D constructor instead is faster to open but drops
every location that lacks the quantity, which loses the topology entirely
on files where nodes and reaches hold different quantities.

A location that carries none of the requested quantities becomes
topology-only rather than raising.

Closes #679
The tz-awareness guard added in 39c70f7 only covers a mismatch between
an aware and a naive side; matching fails just as hard when both sides
are aware, since the internal time coordinate cannot carry a timezone.
Pin down the intended behaviour instead: convert tz-aware input to UTC
and drop the timezone, so aware input works and two timezones pair up
on absolute time. Replaces the test expecting a ValueError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xarray cannot carry a timezone through operations such as interp or
dropna, and TimeSeries.time drops it silently, so matching failed for
any timezone-aware input - not only when one side was naive and the
other aware. Convert aware time to UTC and drop the timezone where the
time coordinate is normalised, which now happens for every geometry via
_validate_dataset as well as for from_matched. Aware input works, two
timezones pair up on absolute time, and a warning records the shift.

This replaces the tz-awareness guard in _match_space_time, which could
only reject the aware/naive combination and let differing timezones
through to the same bare pandas TypeError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Topology-only nodes store an empty DataFrame (not None), so the existing
`is not None` check never dropped them. Concatenating an empty RangeIndex
frame alongside real DatetimeIndex frames degraded the result index to
object dtype, which later failed the "time must be datetime" assertion in
ms.match(). Also skip empty frames so the DatetimeIndex is preserved.

Fixes #676

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
ComparerCollection.save()/load() doesn't support the NODE geometry
type: Comparer.load() has no "node" branch and falls into
NotImplementedError("Unknown gtype: node").

Refs gh #677
Comparer.save() only flattened raw_mod_data into the netcdf for
gtype == "point", so node comparers silently dropped their raw model
data on save. Extend the check to also cover "node".

Part of gh #677
load() dispatched "track" / "vertical" / "point" and raised
NotImplementedError for anything else, so a node comparer's netcdf
could never be loaded back. Reconstruct NodeModelResult from the
flattened _raw_* variables, mirroring the existing point branch, using
the node coordinate carried along on each variable.

Fixes gh #677
ms.match() raises a bare pandas TypeError ("Cannot compare tz-naive
and tz-aware datetime-like objects") when the observation and model
result disagree on timezone-awareness, with no ModelSkill-specific
context about what went wrong.

Refs gh #678
No timezone handling existed anywhere in modelskill, so a mismatch
between a tz-aware and tz-naive time index only surfaced as a bare
pandas TypeError deep inside observation.trim(), with no indication
of what went wrong. Validate tz-awareness compatibility between the
observation and each model result before trimming, and raise a
ValueError naming both sides.

Fixes gh #678
mypy inferred ts's type from its first assignment (NodeModelResult),
flagging the later PointModelResult assignment as incompatible.

Part of gh #677
…s earlier

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
jpalm3r and others added 22 commits August 4, 2026 15:46
Two costs profiling exposed in from_res1d on large networks (gh #679):
every topology-only location builds its own empty DataFrame, and a node
shared by several reaches is read once per reach endpoint even though
_generate_graph keeps only the first copy.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every topology-only node and gridpoint allocated its own empty frame: two
per reach, 35% of a filtered load on the test file and ~5s on a reported
8,377-reach network. _build_dataframe already skips empty frames before
the concat, so the shared instance never reaches pandas.
_load_res1d_network visited a node once for every reach it belongs to, but
_generate_graph keeps only the first copy, so the repeat reads were thrown
away: 236 reads for 119 nodes on the test file, 16,754 for 7,955 on a
reported network. Boundary reads stay per-reach.
gh #679 asks for selective ingestion by quantity. Locations that do not
carry a requested quantity should become topology-only rather than raise,
and the filter should compose with the existing nodes/reaches filters.
Applied at the read layer, so the full topology is still built. Pushing
the filter into the Res1D constructor instead is faster to open but drops
every location that lacks the quantity, which loses the topology entirely
on files where nodes and reaches hold different quantities.

A location that carries none of the requested quantities becomes
topology-only rather than raising.

Closes #679
The tz-awareness guard added in 39c70f7 only covers a mismatch between
an aware and a naive side; matching fails just as hard when both sides
are aware, since the internal time coordinate cannot carry a timezone.
Pin down the intended behaviour instead: convert tz-aware input to UTC
and drop the timezone, so aware input works and two timezones pair up
on absolute time. Replaces the test expecting a ValueError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
xarray cannot carry a timezone through operations such as interp or
dropna, and TimeSeries.time drops it silently, so matching failed for
any timezone-aware input - not only when one side was naive and the
other aware. Convert aware time to UTC and drop the timezone where the
time coordinate is normalised, which now happens for every geometry via
_validate_dataset as well as for from_matched. Aware input works, two
timezones pair up on absolute time, and a warning records the shift.

This replaces the tz-awareness guard in _match_space_time, which could
only reject the aware/naive combination and let differing timezones
through to the same bare pandas TypeError.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
These two tests (originally 57e8611 on PR #685) called the removed
Network.from_res1d, since #685 forked before #687 renamed it to
from_mike/from_epanet. The rebase applied them without a merge conflict
because the surrounding lines didn't overlap textually, but the calls
were left broken. Rename the test functions too, matching the
test_from_mike_* convention used elsewhere in this file.
9506fca rewrote _build_dataframe to surface reach boundary data via a
3-level column index, but crashed on any node without boundary data
(pd.concat({}, axis=1) on the empty-boundary case), collided reach
labels between a reach's two endpoints, and broke every downstream
consumer of the 2-level column shape (to_dataframe(sel=...), to_dataset(),
quantities). 25 failed, 29 errors on this branch.

Reach quantities will be exposed by promoting reach-end gridpoints to
ordinary breakpoints instead (a graph-construction change, not a
dataframe-shape change), so this rewrite isn't needed to reach that goal.
Res1DReach now includes a reach's first/last gridpoint as ordinary
breakpoints (not just the intermediate ones), so their data is reachable
through the normal breakpoint mechanism instead of the removed boundary
concept. This is what #599 and #680 actually need: reach-end quantities
(e.g. EPANET Flow/Velocity, or MIKE's reach-end WaterLevel, which is a
distinct series from the node's own) become ordinary graph data instead
of being silently discarded.

Endpoint gridpoint loading is now gated by populate_gridpoints like every
other breakpoint, so reaches=[] suppresses it too - one consistent rule
instead of a special case for what used to be boundary data.

Since a reach-end breakpoint is geometrically coincident with its node,
_generate_graph tags that edge boundary=True and clamps its length to an
exact 0.0. The clamp matters because the edge's raw length is the
difference between two independently-sourced values (mikeio1d's gridpoint
chainage and the reach's total length), which are not guaranteed to be
bit-identical - left unclamped, floating-point noise could produce a tiny
negative weight and break shortest-path algorithms that require
non-negative edges. `length` itself stays float | None on every edge,
unchanged, so existing networkx weighted-algorithm behavior (None hides
an edge from Dijkstra, per networkx's `cost is None` check) is untouched.

Deliberately scoped to MIKE (res1d/res11): EPANET's reaches have at most
2 gridpoints, so the existing gate (len(reach.gridpoints) > 2) already
excludes them, leaving today's EPANET behavior (no breakpoints,
length=None) unchanged. EPANET's single synthetic gridpoint belongs to
neither end and needs its own placement rule - that's #680's separate,
still-open ask.
Reach-end data now lives on an ordinary breakpoint (previous commit), so
nothing constructs or reads a node's boundary dict anymore. Drops the
abstract property from NetworkNode, its param/property from BasicNode and
Res1DNode, and the merge-on-revisit logic in _generate_graph.

This is a public API break for any NetworkNode subclass: `boundary` was a
required abstract property. Docs and the one notebook that referenced it
are updated separately.
NetworkNode now requires two properties (id, data), not three - drop
boundary from both example subclasses and the "required properties"
callout. Rewrite the find/recall framing paragraph, which no longer
introduces a separate boundary concept, and add a callout documenting the
new zero-length boundary edges for anyone running weighted networkx
algorithms on network.graph.

res1d_network_mapping.png still shows boundary as a separate concept from
find/recall; regenerating it is out of scope here.
Reword the two markdown cells describing graph nodes and the
junction/breakpoint visualisation, now that boundary is no longer a
graph-node attribute. The plot_network() widthmap used 'boundary' in
g.nodes[node] to tell real topology nodes apart from break points; switch
to the node's alias type instead (str for a real node, (reach, distance)
tuple for a break point), which was already on every graph node and needs
no new attribute.

Notebook is excluded from automated execution (pythonnet/3.14), so this
is a source-only fix; not re-rendered in this environment.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Include boundary in recall

1 participant