Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1eee1bd
Accept every file extension mikeio1d can read in from_res1d
jpalm3r Aug 3, 2026
91eba70
Test from_res1d input validation
jpalm3r Aug 3, 2026
792dead
Commit confidential test data folder
jpalm3r Aug 3, 2026
a61ccfd
Return an empty frame for Res1D locations with no quantities
jpalm3r Aug 3, 2026
c625836
Reject Res1D reaches that expose no start/end node
jpalm3r Aug 3, 2026
315158d
Add network result fixtures from mikeio1d
jpalm3r Aug 3, 2026
a2af1f7
Extract the shared mikeio1d loading path
jpalm3r Aug 3, 2026
a7e3979
Refuse the mikeio1d formats modelskill cannot use
jpalm3r Aug 3, 2026
7219e7a
Reject Res1D objects opened with a path
jpalm3r Aug 3, 2026
e7d6845
Add Network.from_mike and Network.from_epanet
jpalm3r Aug 3, 2026
58e6f8d
Remove Network.from_res1d in favour of the per-product constructors
jpalm3r Aug 3, 2026
accb859
Document the per-product network constructors
jpalm3r Aug 3, 2026
dad26d7
Migrating to checkout@v6 (Node20 -> Node24)
jpalm3r Aug 3, 2026
70002ca
Adding NotImplemented error for potential new mikeio1d format.
jpalm3r Aug 3, 2026
9103b14
Make NetworkReach.length optional
jpalm3r Aug 3, 2026
c0dff38
Report mikeio1d's zero reach length as undefined
jpalm3r Aug 3, 2026
2b20922
Test optional and undefined reach length
jpalm3r Aug 3, 2026
a6b5611
Document optional reach length
jpalm3r Aug 3, 2026
6053656
Add a minimal reader for EPANET/SWMM .inp files
jpalm3r Aug 3, 2026
420788b
Vendor the epanet.inp fixture
jpalm3r Aug 3, 2026
dc54d2a
Read EPANET companion files on from_epanet
jpalm3r Aug 3, 2026
65e6dd7
Name the companion file in the .out and .resx refusals
jpalm3r Aug 3, 2026
0e18017
Test the EPANET companion files and the .inp reader
jpalm3r Aug 3, 2026
d864737
Document EPANET companion files in the user guide and ADR-012
jpalm3r Aug 3, 2026
81dfc46
Fixing markdown comment
jpalm3r Aug 5, 2026
0561615
Guarding against unknown reach ids in resx file.
jpalm3r Aug 5, 2026
d569284
Merge branch 'main' into network-constructors-v2
jpalm3r Aug 5, 2026
cc4ceb9
Trim down adr
jpalm3r Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/workflows/full_test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- uses: astral-sh/ruff-action@v2
with:
version: 0.6.2
Expand All @@ -24,7 +24,7 @@ jobs:
pandas-version: ["pandas2", "pandas3"] # TODO: drop pandas2 once 3.x is well-established

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- uses: extractions/setup-just@v3

Expand Down Expand Up @@ -58,7 +58,7 @@ jobs:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6

- uses: extractions/setup-just@v3

Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -153,4 +153,6 @@ docs/_site/
docs/_extensions/
docs/api/*.qmd

uv.lock
uv.lock

tests/testdata/confidential/*
41 changes: 41 additions & 0 deletions adr/012-network-format-constructors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# ADR-012: One Network Constructor per Modelling Product

**Status**: Draft

**Date**: 2026-08

## Context

`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.

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.

## Decision

Name constructors after the product that writes the file, and ship one only where a committed fixture backs it:

| Constructor | Extensions |
|---|---|
| `Network.from_mike` | `.res1d`, `.res11` |
| `Network.from_epanet` | `.res`, plus optional `.resx` and `.inp` companions |

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.

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.

## Alternatives Considered

**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.

**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.

**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.

**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)).

## Consequences

- 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.
- 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.
- 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.
- 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.
1 change: 1 addition & 0 deletions adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ Each ADR follows this structure:
- [ADR-009](009-factory-pattern.md) - Factory pattern for type detection
- [ADR-010](010-optional-domain-dependencies.md) - Optional dependencies for domain-specific model types (Draft)
- [ADR-011](011-vertical-pre-extracted-columns.md) - VerticalModelResult ingests pre-extracted columns
- [ADR-012](012-network-format-constructors.md) - One Network constructor per modelling product (Draft)

## Contributing

Expand Down
106 changes: 93 additions & 13 deletions docs/user-guide/network.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -132,26 +132,41 @@ Network → NetworkModelResult → match() → Comparer

## Building a Network

You can build a `Network` object by loading it from a supported network format.
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.

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

### Res1D file
| Constructor | Extensions | Product |
|---|---|---|
| `Network.from_mike` | `.res1d`, `.res11` | MIKE 1D, MIKE 11 |
| `Network.from_epanet` | `.res`, plus optional `.resx` and `.inp` | EPANET |

The remaining formats mikeio1d can open cannot be turned into a `Network`, and say so when you try:

| Extension | Why not |
|---|---|
| `.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)). |
| `.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. |
| `.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. |

The quickest way to get a `Network` is from the path to a MIKE 1D result file:
### From a network result file

The quickest way to get a `Network` is from the path to a result file:

```{python}
# | echo: false

path_to_res1d = "../../tests/testdata/network.res1d"
path_to_res11 = "../../tests/testdata/network_cali.res11"
path_to_epanet = "../../tests/testdata/epanet.res"
path_to_sensor_data_1 = "../../tests/testdata/network_sensor_1.csv"
path_to_sensor_data_2 = "../../tests/testdata/network_sensor_2.csv"
```

```{python}
from modelskill.network import Network

network = Network.from_res1d(path_to_res1d)
network = Network.from_mike(path_to_res1d)
network
```

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

res = Res1D(path_to_res1d)
network = Network.from_res1d(res)
network = Network.from_mike(res)
```

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:

```{python}
Network.from_mike(path_to_res11)
```

EPANET results use `from_epanet`:

```{python}
Network.from_epanet(path_to_epanet)
```

#### EPANET companion files

An EPANET run writes more than one file, and the `.res` is not the whole picture:

| File | What it adds |
|---|---|
| `.res` | The network and its main timeseries. Required. |
| `.resx` | Extra results — tank volume and pump energy. Merged onto matching nodes. |
| `.inp` | The model input. The only one of the three carrying reach lengths. |

Pass the companions alongside the result file to get a fuller network:

```{python}
# | echo: false
path_to_epanet_resx = "../../tests/testdata/epanet.resx"
path_to_epanet_inp = "../../tests/testdata/epanet.inp"
```

```{python}
network_epanet = Network.from_epanet(
path_to_epanet,
resx=path_to_epanet_resx,
inp=path_to_epanet_inp,
)
network_epanet
```

`Volume` and `Volume Percentage` come from the `.resx`, and the reach lengths from the `.inp`:

```{python}
sorted(
d["length"]
for *_, d in network_epanet.graph.edges(data=True)
if d["length"] is not None
)
```

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.
::: {.callout-warning}
## EPANET reach geometry is limited

EPANET is a link-node model, and mikeio1d reports no length and a single synthetic gridpoint for each reach. So for an EPANET network:

* 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
* reaches have no breakpoints, so a `ReachObservation` cannot be matched — use `NodeObservation` instead
* `find(reach=..., distance=<number>)` never resolves; only `distance="start"` and `distance="end"` work

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).

Node timeseries, `to_dataframe()`, `to_dataset()`, `find(node=...)` and `recall()` are unaffected.
:::

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.

![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)

#### Selective loading

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

`from_res1d` accepts two optional arguments to restrict what gets loaded:
Both constructors accept the same two optional arguments to restrict what gets loaded:

| Argument | Type | Effect |
|---|---|---|
Expand All @@ -186,7 +264,7 @@ Selective loading only controls **which timeseries are held in memory**. The ful
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=[]`:

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

```{python}
network_subset = Network.from_res1d(
network_subset = Network.from_mike(
path_to_res1d,
nodes=["78", "46"],
reaches=["94l1"],
Expand Down Expand Up @@ -385,7 +463,9 @@ Use `ReachObservation` when your measured quantity is representative of the whol
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`.

`NetworkNode` requires three properties: `id`, `data`, and `boundary`.
`NetworkReach` requires five: `id`, `start`, `end`, `length`, and `breakpoints`.
`NetworkReach` requires four: `id`, `start`, `end`, and `breakpoints`.

`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.


The following is a simple implementation example:
Expand Down Expand Up @@ -452,7 +532,7 @@ class ExampleReach(NetworkReach):
```

::: {.callout-tip}
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.
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.
:::


Expand Down
4 changes: 2 additions & 2 deletions notebooks/Collection_systems_network.ipynb
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"```python\n",
"from modelskill.network import Network\n",
"\n",
"network = Network.from_res1d(\"path/to/results.res1d\")\n",
"network = Network.from_mike(\"path/to/results.res1d\")\n",
"``` \n",
"\n",
"### Custom network format\n",
Expand Down Expand Up @@ -89,7 +89,7 @@
}
],
"source": [
"network = Network.from_res1d(\"../tests/testdata/network.res1d\")\n",
"network = Network.from_mike(\"../tests/testdata/network.res1d\")\n",
"network"
]
},
Expand Down
10 changes: 6 additions & 4 deletions roadmap/features/network-models.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,14 +13,16 @@ This reduces the effort required to produce quality-assured model deliverables a

## What This Enables

- Load MIKE 1D simulation results (Res1D files) as model results

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

## Current Status

In active development. Reading of MIKE 1D result files is already supported via
`Network.from_res1d`, which requires the optional `networks` dependency group
(`pip install modelskill[networks]`). Integration with ModelSkill's validation workflow is underway.
In active development. MIKE 1D, MIKE 11 and EPANET result files can be read today. Integration with ModelSkill's validation workflow is underway.

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.

Loading
Loading