Skip to content

Commit db3b094

Browse files
authored
Merge pull request #691 from DHI/network-constructors-v2
Per-product Network constructors, with EPANET companion files (replica of #687)
2 parents cf29efe + cc4ceb9 commit db3b094

19 files changed

Lines changed: 1587 additions & 82 deletions

File tree

.github/workflows/full_test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ jobs:
1010
lint:
1111
runs-on: ubuntu-latest
1212
steps:
13-
- uses: actions/checkout@v4
13+
- uses: actions/checkout@v6
1414
- uses: astral-sh/ruff-action@v2
1515
with:
1616
version: 0.6.2
@@ -24,7 +24,7 @@ jobs:
2424
pandas-version: ["pandas2", "pandas3"] # TODO: drop pandas2 once 3.x is well-established
2525

2626
steps:
27-
- uses: actions/checkout@v4
27+
- uses: actions/checkout@v6
2828

2929
- uses: extractions/setup-just@v3
3030

@@ -58,7 +58,7 @@ jobs:
5858
runs-on: ubuntu-latest
5959

6060
steps:
61-
- uses: actions/checkout@v4
61+
- uses: actions/checkout@v6
6262

6363
- uses: extractions/setup-just@v3
6464

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -153,4 +153,6 @@ docs/_site/
153153
docs/_extensions/
154154
docs/api/*.qmd
155155

156-
uv.lock
156+
uv.lock
157+
158+
tests/testdata/confidential/*
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
# ADR-012: One Network Constructor per Modelling Product
2+
3+
**Status**: Draft
4+
5+
**Date**: 2026-08
6+
7+
## Context
8+
9+
`Network` is built from result files read through mikeio1d, whose single `Res1D` class opens nine extensions across five products — MIKE 1D (`.res1d`), MIKE 11 (`.res11`), MOUSE (`.prf`, `.crf`, `.xrf`), EPANET (`.res`), SWMM (`.out`), Water Hammer (`.whr`), and `.resx`, which is shared by the last three. There is no per-format reader and no per-format constructor argument, so from mikeio1d's side all nine look alike. modelskill's constructor was named `from_res1d`, and its extension guard was briefly widened to accept everything mikeio1d could read — making the name promise one format while reading nine.
10+
11+
Loading mikeio1d's own fixtures showed the nine are not interchangeable. `.res1d` and `.res11` give a full network with real reach lengths and gridpoints. EPANET's `.res` loads, but as a link-node model it reports no reach length and one synthetic gridpoint per reach, so reach-based matching cannot work. `.out` (SWMM) and `.resx` carry no reach connectivity at all — it lives in a companion file: SWMM's `.inp`, and for `.resx` the sibling `.res` that defines the network the results are added to. MOUSE and `.whr` have no test fixture anywhere, upstream included, so nothing about them can be verified.
12+
13+
## Decision
14+
15+
Name constructors after the product that writes the file, and ship one only where a committed fixture backs it:
16+
17+
| Constructor | Extensions |
18+
|---|---|
19+
| `Network.from_mike` | `.res1d`, `.res11` |
20+
| `Network.from_epanet` | `.res`, plus optional `.resx` and `.inp` companions |
21+
22+
A product's companion files are arguments rather than constructors of their own. A companion describes a network defined elsewhere and cannot stand alone, so `from_epanet(res, resx=..., inp=...)` and not a `from_resx()`. Each companion is validated against the main file — same time axis, no unknown IDs — because two unrelated runs would otherwise merge silently.
23+
24+
Every extension mikeio1d reads is accounted for in one of three module-level tables in `network.py`: readable by `from_mike`, readable by `from_epanet`, or refused with a reason that names the file or method which would lift it. A test asserts the tables cover exactly `Res1D.get_supported_file_extensions()`, so a mikeio1d release adding a tenth format fails CI instead of leaving that format silently unreachable. `from_res1d` is removed without a deprecation shim: it shipped only in the 1.4.0a3 alpha, and the network module is opt-in and absent from the API reference.
25+
26+
## Alternatives Considered
27+
28+
**One constructor per extension** - `from_res`, `from_out` and `from_whr` say nothing about the product they belong to, and MOUSE would need three identical methods.
29+
30+
**A generic catch-all (`from_file`, `from_mikeio1d`)** - a second way to do the same thing. With every extension either read or explicitly refused, its only remaining job is forward compatibility, which the coverage test handles more usefully by demanding a decision.
31+
32+
**Auto-detect the product, as ADR-009 does elsewhere** - factories such as `model_result()` resolve *which class* to build from the shape of the data. Here the question is *which product wrote the file*, which the call site should state rather than have guessed, since the answer decides whether reach-based matching works at all.
33+
34+
**Ship all five product constructors** - MOUSE and Water Hammer would be unverifiable, so the method list would stop being a reliable statement of what works. SWMM is deferred rather than impossible, since its `.inp` does carry the missing topology ([#689](https://github.com/DHI/modelskill/issues/689)).
35+
36+
## Consequences
37+
38+
- The method list is the format list: `Network.from_<TAB>` answers "which formats does this read", and passing a file the other constructor handles raises a `ValueError` naming that constructor.
39+
- EPANET's degenerate geometry is stated in the `from_epanet` docstring and the user guide and asserted in tests, rather than warned about at runtime. A warning would fire on correct usage, and both consequences already raise where they bite.
40+
- MOUSE and Water Hammer are refused even though mikeio1d may well read them correctly. Refusing with a reason is recoverable; a method that silently builds a wrong graph is not. Each becomes a six-line addition once a redistributable fixture exists.
41+
- The `.inp` reader (`model/adapters/_inp.py`) is ours to maintain, since mikeio1d does not read `.inp` and pulling in `wntr` or `swmmio` for two sections would weigh more than the parser does (ADR-010). SWMM support will reuse it, as the two products share the layout.

adr/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ Each ADR follows this structure:
3030
- [ADR-009](009-factory-pattern.md) - Factory pattern for type detection
3131
- [ADR-010](010-optional-domain-dependencies.md) - Optional dependencies for domain-specific model types (Draft)
3232
- [ADR-011](011-vertical-pre-extracted-columns.md) - VerticalModelResult ingests pre-extracted columns
33+
- [ADR-012](012-network-format-constructors.md) - One Network constructor per modelling product (Draft)
3334

3435
## Contributing
3536

docs/user-guide/network.qmd

Lines changed: 93 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -132,26 +132,41 @@ Network → NetworkModelResult → match() → Comparer
132132

133133
## Building a Network
134134

135-
You can build a `Network` object by loading it from a supported network format.
135+
You can build a `Network` object by loading it from a supported network result file. Reading these files relies on [mikeio1d](https://github.com/DHI/mikeio1d), so install the `networks` dependency group first.
136136

137-
Currently, the only supported format is `mikeio.Res1D`.
137+
There is one constructor per product that writes the file:
138138

139-
### Res1D file
139+
| Constructor | Extensions | Product |
140+
|---|---|---|
141+
| `Network.from_mike` | `.res1d`, `.res11` | MIKE 1D, MIKE 11 |
142+
| `Network.from_epanet` | `.res`, plus optional `.resx` and `.inp` | EPANET |
143+
144+
The remaining formats mikeio1d can open cannot be turned into a `Network`, and say so when you try:
145+
146+
| Extension | Why not |
147+
|---|---|
148+
| `.out` (SWMM) | The reach connectivity is not in the `.out` at all — it lives in the companion `.inp` input file, which modelskill does not read yet ([#689](https://github.com/DHI/modelskill/issues/689)). |
149+
| `.resx` | Not a network on its own. It holds extra results for the network defined in the sibling `.res`, so pass it as `from_epanet(res, resx=...)` instead. |
150+
| `.prf`, `.crf`, `.xrf` (MOUSE), `.whr` (Water Hammer) | No test fixture exists for these formats, so support cannot be verified. [Open an issue](https://github.com/DHI/modelskill/issues) if you need one. |
140151

141-
The quickest way to get a `Network` is from the path to a MIKE 1D result file:
152+
### From a network result file
153+
154+
The quickest way to get a `Network` is from the path to a result file:
142155

143156
```{python}
144157
# | echo: false
145158
146159
path_to_res1d = "../../tests/testdata/network.res1d"
160+
path_to_res11 = "../../tests/testdata/network_cali.res11"
161+
path_to_epanet = "../../tests/testdata/epanet.res"
147162
path_to_sensor_data_1 = "../../tests/testdata/network_sensor_1.csv"
148163
path_to_sensor_data_2 = "../../tests/testdata/network_sensor_2.csv"
149164
```
150165

151166
```{python}
152167
from modelskill.network import Network
153168
154-
network = Network.from_res1d(path_to_res1d)
169+
network = Network.from_mike(path_to_res1d)
155170
network
156171
```
157172

@@ -161,18 +176,81 @@ or a `mikeio1d.Res1D` that has already been opened:
161176
from mikeio1d import Res1D
162177

163178
res = Res1D(path_to_res1d)
164-
network = Network.from_res1d(res)
179+
network = Network.from_mike(res)
180+
```
181+
182+
MIKE 11 files work the same way. Note that MIKE 11 keeps its timeseries on reach gridpoints rather than on nodes, so the nodes of such a network carry no data of their own:
183+
184+
```{python}
185+
Network.from_mike(path_to_res11)
186+
```
187+
188+
EPANET results use `from_epanet`:
189+
190+
```{python}
191+
Network.from_epanet(path_to_epanet)
192+
```
193+
194+
#### EPANET companion files
195+
196+
An EPANET run writes more than one file, and the `.res` is not the whole picture:
197+
198+
| File | What it adds |
199+
|---|---|
200+
| `.res` | The network and its main timeseries. Required. |
201+
| `.resx` | Extra results — tank volume and pump energy. Merged onto matching nodes. |
202+
| `.inp` | The model input. The only one of the three carrying reach lengths. |
203+
204+
Pass the companions alongside the result file to get a fuller network:
205+
206+
```{python}
207+
# | echo: false
208+
path_to_epanet_resx = "../../tests/testdata/epanet.resx"
209+
path_to_epanet_inp = "../../tests/testdata/epanet.inp"
210+
```
211+
212+
```{python}
213+
network_epanet = Network.from_epanet(
214+
path_to_epanet,
215+
resx=path_to_epanet_resx,
216+
inp=path_to_epanet_inp,
217+
)
218+
network_epanet
219+
```
220+
221+
`Volume` and `Volume Percentage` come from the `.resx`, and the reach lengths from the `.inp`:
222+
223+
```{python}
224+
sorted(
225+
d["length"]
226+
for *_, d in network_epanet.graph.edges(data=True)
227+
if d["length"] is not None
228+
)
165229
```
166230

167-
A `Res1D` network contains multiple levels that are unified into a generic network structure as depicted in the image below. The image introduces concepts like _find_, _recall_ and _boundary_ which are explained in the following sections.
231+
::: {.callout-warning}
232+
## EPANET reach geometry is limited
233+
234+
EPANET is a link-node model, and mikeio1d reports no length and a single synthetic gridpoint for each reach. So for an EPANET network:
235+
236+
* without `inp=`, every edge of `network.graph` has `length=None`. A length-weighted `networkx` call then fails rather than returning a meaningless number — shortest-path treats the edge as unreachable, and anything that sums the weights raises `TypeError`. The attribute is always present, since `networkx` defaults a missing weight to `1`. With `inp=`, only pumps and valves stay `None`, since `[PIPES]` is the one section carrying lengths
237+
* reaches have no breakpoints, so a `ReachObservation` cannot be matched — use `NodeObservation` instead
238+
* `find(reach=..., distance=<number>)` never resolves; only `distance="start"` and `distance="end"` work
239+
240+
For the same reason, `resx=` merges node quantities only. Its reach-level quantities — pump energy, efficiency and costs — have no breakpoint to live on, which is tracked in [#680](https://github.com/DHI/modelskill/issues/680).
241+
242+
Node timeseries, `to_dataframe()`, `to_dataset()`, `find(node=...)` and `recall()` are unaffected.
243+
:::
244+
245+
A MIKE 1D network contains multiple levels that are unified into a generic network structure as depicted in the image below. The image introduces concepts like _find_, _recall_ and _boundary_ which are explained in the following sections.
168246

169247
![How a Res1D file maps to a Network object. Reaches and nodes are re-indexed as integers; boundary nodes expose `find()`/`recall()` round-trip lookups.](../images/res1d_network_mapping.png)
170248

171249
#### Selective loading
172250

173-
Large Res1D files can contain thousands of nodes and gridpoints. Loading all of that data into memory is slow and may cause memory issues — especially when you only need the timeseries at a handful of nodes where observations exist.
251+
Large result files can contain thousands of nodes and gridpoints. Loading all of that data into memory is slow and may cause memory issues — especially when you only need the timeseries at a handful of nodes where observations exist.
174252

175-
`from_res1d` accepts two optional arguments to restrict what gets loaded:
253+
Both constructors accept the same two optional arguments to restrict what gets loaded:
176254

177255
| Argument | Type | Effect |
178256
|---|---|---|
@@ -186,7 +264,7 @@ Selective loading only controls **which timeseries are held in memory**. The ful
186264
The most memory-efficient setup — useful when you only care about specific junction nodes — is to pass the node IDs you need and skip all intermediate gridpoints with `reaches=[]`:
187265

188266
```{python}
189-
network_subset = Network.from_res1d(
267+
network_subset = Network.from_mike(
190268
path_to_res1d,
191269
nodes=["78", "46"],
192270
reaches=[],
@@ -197,7 +275,7 @@ network_subset
197275
If you also need gridpoint data along a particular reach, pass its name (or a list of names):
198276

199277
```{python}
200-
network_subset = Network.from_res1d(
278+
network_subset = Network.from_mike(
201279
path_to_res1d,
202280
nodes=["78", "46"],
203281
reaches=["94l1"],
@@ -385,7 +463,9 @@ Use `ReachObservation` when your measured quantity is representative of the whol
385463
In case you have your network data in a format that is not included in [Building a Network](#building-a-network), you can assemble a `Network` object by subclassing the abstract base classes `NetworkNode` and `NetworkReach`.
386464

387465
`NetworkNode` requires three properties: `id`, `data`, and `boundary`.
388-
`NetworkReach` requires five: `id`, `start`, `end`, `length`, and `breakpoints`.
466+
`NetworkReach` requires four: `id`, `start`, `end`, and `breakpoints`.
467+
468+
`NetworkReach.length` is optional and defaults to `None`. Reach length matters in some domains (rivers, sewer networks) and not in others (link-node water distribution models), so override it only where a length exists. Where it is left undefined, the reach contributes an edge with `length=None` to `network.graph`, which keeps length-weighted graph algorithms from quietly treating the reach as free. Nothing else in modelskill reads the length — matching and extraction work from break point distances alone.
389469

390470

391471
The following is a simple implementation example:
@@ -452,7 +532,7 @@ class ExampleReach(NetworkReach):
452532
```
453533

454534
::: {.callout-tip}
455-
The three abstract properties that **every** `NetworkNode` subclass must implement are `id`, `data` and `boundary`. If `boundary` is not relevant for your use case, define the property to return an empty dictionary, as in the example above. Similarly, a `NetworkReach` with no intermediate points can return an empty `breakpoints` list.
535+
The three abstract properties that **every** `NetworkNode` subclass must implement are `id`, `data` and `boundary`. If `boundary` is not relevant for your use case, define the property to return an empty dictionary, as in the example above. Similarly, a `NetworkReach` with no intermediate points can return an empty `breakpoints` list, and one with no meaningful length can leave the `length` property out altogether.
456536
:::
457537

458538

notebooks/Collection_systems_network.ipynb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@
3737
"```python\n",
3838
"from modelskill.network import Network\n",
3939
"\n",
40-
"network = Network.from_res1d(\"path/to/results.res1d\")\n",
40+
"network = Network.from_mike(\"path/to/results.res1d\")\n",
4141
"``` \n",
4242
"\n",
4343
"### Custom network format\n",
@@ -89,7 +89,7 @@
8989
}
9090
],
9191
"source": [
92-
"network = Network.from_res1d(\"../tests/testdata/network.res1d\")\n",
92+
"network = Network.from_mike(\"../tests/testdata/network.res1d\")\n",
9393
"network"
9494
]
9595
},

roadmap/features/network-models.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,14 +13,16 @@ This reduces the effort required to produce quality-assured model deliverables a
1313

1414
## What This Enables
1515

16-
- Load MIKE 1D simulation results (Res1D files) as model results
16+
17+
- Load MIKE 1D, MIKE 11 and EPANET simulation results as model results
1718
- Match network model outputs against point observations at specific nodes or reaches
1819
- Apply the full suite of ModelSkill metrics and visualisations to network model validation
1920
- Compare multiple network model scenarios side by side
2021
- Produce standardised skill assessments for urban drainage, water supply, and river modelling projects
2122

2223
## Current Status
2324

24-
In active development. Reading of MIKE 1D result files is already supported via
25-
`Network.from_res1d`, which requires the optional `networks` dependency group
26-
(`pip install modelskill[networks]`). Integration with ModelSkill's validation workflow is underway.
25+
In active development. MIKE 1D, MIKE 11 and EPANET result files can be read today. Integration with ModelSkill's validation workflow is underway.
26+
27+
MOUSE and Water Hammer results are not read yet: no shareable result file exists for either format, so support cannot be verified. SWMM results are not read yet: the reach connectivity lives in the companion '.inp' input file, which modelskill does not read yet.
28+

0 commit comments

Comments
 (0)