Speed up from_* and add a quantities filter - #685
Conversation
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
There was a problem hiding this comment.
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
quantitiesfiltering toNetwork.from_res1d, applied at the per-location read layer while preserving full topology. - Reduce ingestion overhead by sharing a single empty
DataFramefor 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.
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>
There was a problem hiding this comment.
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 storingNonefor 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()
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.
431c6cc to
815b762
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 22 out of 27 changed files in this pull request and generated no new comments.
Suppressed comments (1)
src/modelskill/model/adapters/_res1d.py:28
_simplify_colnames()returns a newpd.DataFrame()whennode.quantitiesis empty. These locations are effectively topology-only too (e.g. MIKE 11 nodes), so this defeats the shared empty-frame optimization described just above (_EMPTY_DATA) and can reintroduce many empty-frame allocations on formats with quantity-less locations.
# Some formats keep no timeseries at all on some locations - MIKE 11, for instance,
# stores everything on reach gridpoints, leaving the nodes empty. Asking mikeio1d
# for a dataframe there raises, so return an empty one instead.
if not node.quantities:
return pd.DataFrame()
…nto network-loading
|
Ran a
About a 1.8x speedup, and the drop in node reads lines up with the per-reach-endpoint caching fix. |
ecomodeller
left a comment
There was a problem hiding this comment.
Scope note: this review covers only the timezone commit (d61f083). I haven't reviewed the network loading work itself yet — the request for changes is about the commit riding along, not the substance of the PR.
The timezone commit belongs in its own PR
d61f0835 ("Convert timezone-aware time to UTC-naive on construction") is unrelated to network loading — nothing in this PR produces timezone-aware time, and the tz tests exercise the generic point and from_matched paths. It's also a user-visible behaviour change: input that previously raised now silently succeeds with shifted data. Please revert it here and take it separately.
Three objections, in case they help shape the follow-up.
1. Two concerns under one name. _normalize_time_to_ns was a mechanical pandas 3.0 resolution shim with no semantics. Renaming it to _normalize_time and folding in timezone handling merges a compat workaround with a domain decision. If both survive, they belong in separate functions called in sequence.
2. The warning isn't a safeguard. In practice nobody reads warnings, so a warnings.warn next to a silent time shift ships the shift while feeling covered. Either the behaviour is acceptable with no notice at all, or it should raise.
3. The conversion asserts something the user never said. ModelSkill deliberately has no notion of timezones, as it has none of CRS — that is the user's to track, and it's a single line of pandas as preprocessing. A timezone-aware timestamp is unambiguous, so re-expressing it as UTC is lossless and fine in principle. The mixed case is not: converting an aware observation to UTC to pair it with a naive model asserts that the naive side is UTC, about data the user never annotated. That is the one combination that cannot be read unambiguously — and exactly the one the deleted _check_timezone_compatibility rejected.
On the deleted guard
The commit message argues the guard "could only reject the aware/naive combination and let differing timezones through to the same bare pandas TypeError". That's a fair complaint about the implementation, not about the rule.
Worth correcting one implied premise, though: the guard was not dead code. xarray does retain a timezone-aware coordinate —
>>> ds.time.dtype
dtype('<M8[ns, Europe/Copenhagen]')
>>> ds.time.to_index().tz
<DstTzInfo 'Europe/Copenhagen' LMT+0:50:00 STD>— so _check_timezone_compatibility read the timezone correctly and fired as intended.
What is true is that interp rejects any tz-aware coordinate, not just mismatched ones:
TypeError: Cannot interpret 'datetime64[ns, Europe/Copenhagen]' as a data type
So supporting two aware series in different timezones does require converting to UTC-naive somewhere. Construction is the wrong place, because it cannot see the other side — which is precisely why this commit had to swallow the mixed case along with the rest. The principled home is match time: check compatibility first, then convert the all-aware case together.
Suggested rule
Be generous with input that can be read unambiguously, strict about output. Concretely: accept aware, accept naive, accept two different timezones; raise on aware mixed with naive, with a message naming the one-line pandas fix. No conversion at construction, no warning.
That's a design change worth its own review, so: revert here, and the rest of the PR isn't blocked on settling it.
…h guard ecomodeller's review on #685 (pullrequestreview-4874549554) flagged this work as unrelated to network loading and a user-visible behaviour change that should be designed and reviewed on its own: input that previously raised now silently succeeds with shifted data. Restores _check_timezone_compatibility in matching.py (deleted by the commit being reverted) and reverts _normalize_time back to _normalize_time_to_ns, dropping the UTC conversion and warning. Tests and docs revert alongside. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
cProfile inflates a Network.from_epanet load severalfold and unevenly, so the single profiled pass could not answer how long a load actually takes. Time repeated loads instead and make the profile pass opt-in. The first load in a process pays a one-off mikeio1d/.NET start-up cost of several seconds. That is identical across revisions, so it is run as a discarded warm-up and reported separately as "cold". Arguments are only forwarded to from_epanet when given, so the same script runs against revisions that predate one of its parameters - which is what lets it time main and a feature branch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Answers whether the loading changes on this branch are worth their complexity, by timing the same file under main, under this branch, and under this branch's quantities filter - with and without the .resx/.inp companions. Each case is a subprocess, so no module-level cache carries between them, and the baseline revision is selected with PYTHONPATH rather than a second virtual environment. That keeps mikeio1d and networkx pinned to the same builds, so only modelskill's own code differs. The table is followed by node and reach counts, the quantities each case actually loaded, and the within-case spread - without those, a difference in what was loaded or a noisy machine would read as a speedup. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The label was hardcoded to "res+resx+inp" even when only one of the two was passed. Real data forced the distinction: a .resx that fails validation still leaves the .inp worth timing on its own. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three loads in one process gave a 2.85x spread within a single case on a real 41 MB network - the accumulated memory swamped the differences being measured. One load per fresh process removes that, and matches what a user actually pays. Case order reverses on alternate rounds so a machine that drifts over the run biases the first and last case equally. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Wall-clock on the real 41 MB network stayed noisy enough that adding a 6.7 MB .inp read came out faster than not reading it - an impossibility, and a sign the machine, not the code, was setting the numbers. Windows Defender rescans the result file on every fresh process, and that lands in the wall clock. CPU time counts only the loading process, so a scan or any other busy process is excluded. It leads the table as the figure that reflects modelskill's own code; wall-clock stays, because it is what a user waits. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Measured against a large real networkLoaded a 41 MB EPANET Work per loadWall-clock on the test machine varied up to 4x for byte-identical work, so the headline figures are cProfile call counts, which are exactly reproducible.
The counts match what the changes claim to do, down to the last call:
Passing an Wall-clockFrom the least noisy run, minimum of three loads: 24.2 s on One thing to know about
|
Speeds up
Network.from_*and adds the quantity filter requested in #679.Closes #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
fac935e7). Every topology-onlynode 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_dataframealready skips empty frames beforethe concat, so the shared instance never reaches pandas.
12bb3f2c)._init_noderead a node once per reach endpoint,but
_generate_graphkeeps only the first copy. 236 reads for 119 nodes on the test file. Theper-reach boundary read stays uncached — it is genuinely distinct.
quantitiesparameter onfrom_*(22178858), applied at the read layer so the fulltopology is still built.
None= all, str or list = subset,[]= none. Pushing the filter intothe
Res1Dconstructor 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
57e86117andd534e096; user guide in2c663898.On
tests/testdata/network.res1da filtered load goes from 176 ms to 132 ms under cProfile, with378 empty-frame allocations down to 1 and 236 node reads down to 119.
quantities=Nonereproducesthe previous behavior exactly.
Caveats
correct but never exercise a location holding two — the only case where reading per quantity
saves anything. Asked crcoDHI to check against their file.
datatoday 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 diffagainst
maincurrently includes that PR's commits as well.Not in this PR, filed from crcoDHI's profile: topology reuse (#682), batched reads (#683), and the
_get_total_lengthgap (#684).🤖 Generated with Claude Code