You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
# 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.
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.
136
136
137
-
Currently, the only supported format is `mikeio.Res1D`.
137
+
There is one constructor per product that writes the file:
138
138
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. |
140
151
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:
@@ -161,18 +176,81 @@ or a `mikeio1d.Res1D` that has already been opened:
161
176
from mikeio1d import Res1D
162
177
163
178
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:
`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
+
)
165
229
```
166
230
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.
168
246
169
247

170
248
171
249
#### Selective loading
172
250
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.
174
252
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:
176
254
177
255
| Argument | Type | Effect |
178
256
|---|---|---|
@@ -186,7 +264,7 @@ Selective loading only controls **which timeseries are held in memory**. The ful
186
264
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=[]`:
187
265
188
266
```{python}
189
-
network_subset = Network.from_res1d(
267
+
network_subset = Network.from_mike(
190
268
path_to_res1d,
191
269
nodes=["78", "46"],
192
270
reaches=[],
@@ -197,7 +275,7 @@ network_subset
197
275
If you also need gridpoint data along a particular reach, pass its name (or a list of names):
198
276
199
277
```{python}
200
-
network_subset = Network.from_res1d(
278
+
network_subset = Network.from_mike(
201
279
path_to_res1d,
202
280
nodes=["78", "46"],
203
281
reaches=["94l1"],
@@ -385,7 +463,9 @@ Use `ReachObservation` when your measured quantity is representative of the whol
385
463
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`.
386
464
387
465
`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.
389
469
390
470
391
471
The following is a simple implementation example:
@@ -452,7 +532,7 @@ class ExampleReach(NetworkReach):
452
532
```
453
533
454
534
::: {.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.
Copy file name to clipboardExpand all lines: roadmap/features/network-models.md
+6-4Lines changed: 6 additions & 4 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -13,14 +13,16 @@ This reduces the effort required to produce quality-assured model deliverables a
13
13
14
14
## What This Enables
15
15
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
17
18
- Match network model outputs against point observations at specific nodes or reaches
18
19
- Apply the full suite of ModelSkill metrics and visualisations to network model validation
19
20
- Compare multiple network model scenarios side by side
20
21
- Produce standardised skill assessments for urban drainage, water supply, and river modelling projects
21
22
22
23
## Current Status
23
24
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.
0 commit comments