Skip to content

Speed up from_res1d and add a quantities filter - #685

Draft
jpalm3r wants to merge 19 commits into
mainfrom
network-loading
Draft

Speed up from_res1d and add a quantities filter#685
jpalm3r wants to merge 19 commits into
mainfrom
network-loading

Conversation

@jpalm3r

@jpalm3r jpalm3r commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Speeds up Network.from_res1d and adds the quantity filter requested in #679.

crcoDHI profiled a 7,955-node EPANET-backed file and found that the quantity filter — the literal
request — is the smallest of the available wins. Two cheaper fixes in the same code path matter
more, so all three are here.

Changes

  • Share one empty DataFrame across topology-only locations (fac935e7). Every topology-only
    node and gridpoint allocated its own empty frame, two per reach: 35% of a filtered load on the
    test file and ~5 s on the reported network. _build_dataframe already skips empty frames before
    the concat, so the shared instance never reaches pandas.
  • Read each selected node once (12bb3f2c). _init_node read a node once per reach endpoint,
    but _generate_graph keeps only the first copy. 236 reads for 119 nodes on the test file. The
    per-reach boundary read stays uncached — it is genuinely distinct.
  • quantities parameter on from_res1d (22178858), applied at the read layer so the full
    topology is still built. None = all, str or list = subset, [] = none. Pushing the filter into
    the Res1D constructor instead is faster to open but drops every location lacking the quantity —
    crcoDHI confirmed zero of 8,377 reaches survive. A location carrying none of the requested
    quantities becomes topology-only rather than raising.

Regression tests land first in 57e86117 and d534e096; user guide in 2c663898.

On tests/testdata/network.res1d a filtered load goes from 176 ms to 132 ms under cProfile, with
378 empty-frame allocations down to 1 and 236 node reads down to 119. quantities=None reproduces
the previous behavior exactly.

Caveats

  • The fixture carries exactly one quantity per location, so the tests verify the quantity filter is
    correct but never exercise a location holding two — the only case where reading per quantity
    saves anything. Asked crcoDHI to check against their file.
  • The shared empty frame is a shared object. Nothing mutates a topology-only location's data
    today and a comment says not to, but a caller doing so would touch every such location at once.
    Allocating lazily per instance gives back only half the win.

Merge order

Requires #681 to close first. This branch is stacked on beta_test_found_bugs, so the diff
against main currently includes that PR's ten commits as well. Once #681 merges, this reduces to
the six commits above.

Not in this PR, filed from crcoDHI's profile: topology reuse (#682), batched reads (#683), and the
_get_total_length gap (#684).

🤖 Generated with Claude Code

jpalm3r and others added 16 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
Copilot AI review requested due to automatic review settings July 31, 2026 08:56

Copilot AI 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.

Pull request overview

This PR improves performance and flexibility of Res1D network ingestion by optimizing Network.from_res1d and introducing a quantities filter, while also adding/adjusting regression tests and user-guide documentation. It also includes stacked changes related to clearer timezone mismatch errors in matching and node-geometry save/load support.

Changes:

  • Add quantities filtering to Network.from_res1d, applied at the per-location read layer while preserving full topology.
  • Reduce ingestion overhead by sharing a single empty DataFrame for topology-only locations and caching per-node reads to avoid duplicate work.
  • Expand regression tests and documentation; include stacked fixes for timezone-mismatch error clarity and node-gtype raw data save/load.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_network.py Adds regression tests for datetime index integrity, empty-frame sharing, node read de-duplication, and quantities filtering behavior.
tests/test_match.py Adds a regression test ensuring timezone-awareness mismatch raises a clear ValueError.
tests/test_comparercollection.py Adds a node-geometry round-trip save/load test via ComparerCollection.
src/modelskill/network.py Adds quantities parameter and threads it through Res1D loading; caches node reads; filters empty frames in dataframe build.
src/modelskill/model/adapters/_res1d.py Implements quantity-filtered reads and introduces a shared _EMPTY_DATA to avoid repeated empty-frame allocations.
src/modelskill/matching.py Adds _check_timezone_compatibility() to raise a clearer error before pandas/xarray failures.
src/modelskill/comparison/_comparison.py Extends Comparer.save()/load() raw-data handling to include gtype == "node".
docs/user-guide/network.qmd Documents the new quantities argument and provides an example.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread src/modelskill/matching.py Outdated
Comment on lines +399 to +407
obs_tz = observation.data.time.to_index().tz
for name, mr in raw_mod_data.items():
mr_tz = mr.data.time.to_index().tz
if (obs_tz is None) != (mr_tz is None):
raise ValueError(
f"Timezone mismatch between observation '{observation.name}' "
f"(tz={obs_tz}) and model result '{name}' (tz={mr_tz}). "
"Both must be either timezone-aware or timezone-naive."
)

@jpalm3r jpalm3r Jul 31, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Good catch, and the problem turned out to be bigger than differing timezones: any timezone-aware input broke match(), including when both sides carried the same timezone. TimeSeries.time is pd.DatetimeIndex(self.data.time), which drops the timezone, so _get_global_start_end handed tz-naive bounds to observation.trim() and pandas raised Cannot compare tz-naive and tz-aware datetime-like objects. Preserving the timezone there does not help either — xarray cannot carry a tz-aware time coordinate through dropna/interp (Cannot interpret 'datetime64[ns, UTC]' as a data type).

So rather than tightening the check, timezone-aware time is now converted to UTC and the timezone dropped where the time coordinate is normalised: _normalize_time in timeseries/_timeseries.py, called from _validate_dataset (every geometry) and from _parse_dataset (the from_matched path). Aware input works, two different timezones line up on absolute time, and a warning records the shift. _check_timezone_compatibility is gone.

  • 0ce91ff tests, including an observation in Europe/Copenhagen matched against a UTC model result
  • d61f083 the fix
  • 431c6cc note on timezone handling in the matching user guide

One consequence worth naming: a tz-aware observation combined with a tz-naive model result no longer raises — the naive side is taken to be UTC, with the warning as the only signal.

jpalm3r and others added 3 commits July 31, 2026 16:02
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>
Copilot AI review requested due to automatic review settings July 31, 2026 14:39

Copilot AI 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.

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated no new comments.

Suppressed comments (1)

src/modelskill/model/adapters/_res1d.py:16

  • Using a single module-level empty DataFrame shared by all topology-only nodes/gridpoints changes observable behavior: if any caller mutates network.graph.nodes[...]["data"] in-place, it will affect every topology-only location at once. If this is acceptable, it should be documented more explicitly as part of the public API; otherwise consider storing None for topology-only data in the graph (and only materializing an empty DataFrame on demand) to avoid both allocations and shared-mutation hazards.
# Topology-only nodes and gridpoints all share this frame instead of each
# allocating its own. A large network has two per reach, which profiling showed
# to be the biggest single cost of a filtered load. Never mutate it in place.
_EMPTY_DATA = pd.DataFrame()

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.

2 participants