Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
3 changes: 3 additions & 0 deletions adr/012-network-format-constructors.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ Name constructors after the product that writes the file, and ship one only wher
| `Network.from_mike` | `.res1d`, `.res11` |
| `Network.from_epanet` | `.res`, plus optional `.resx` and `.inp` companions |

`NetworkModelResult` is exempt: it accepts a path and reads it with the constructor its extension is mapped to, since every other model result class already takes a path.

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.
Expand All @@ -37,5 +39,6 @@ Every extension mikeio1d reads is accounted for in one of three module-level tab

- 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.
- `NetworkModelResult(path)` reads the extension table rather than asking the caller, which is the one place the guessing objection above does not bite: the tables map each extension to exactly one product, and the answer is reported in `mr.network`. It picks up an EPANET file's `.resx` and `.inp` siblings for the same reason, since a network built without the `.inp` has no reach lengths at all. Anything needing named companions or selective loading still goes through `Network.from_*`.
- 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.
21 changes: 15 additions & 6 deletions docs/user-guide/network.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -391,13 +391,23 @@ print(ids)

## Skill assessment workflow

### 1. Wrap the Network in a NetworkModelResult
### 1. Wrap the network in a NetworkModelResult

`NetworkModelResult` takes the path to a result file, so an assessment can start from one without loading the `Network` yourself:

```{python}
import modelskill as ms
from modelskill.model.network import NetworkModelResult

mr = NetworkModelResult(network, name="MyModel", item="WaterLevel")
mr = ms.NetworkModelResult(path_to_res1d, name="MyModel", item="WaterLevel")
mr
```

The extension picks the constructor, following the same table as [Building a Network](#building-a-network) above, and an EPANET `.res` also reads the `.resx` and `.inp` that share its folder and stem.

Pass a `Network` when you need to name the companions yourself, or to keep memory down with [selective loading](#selective-loading):

```{python}
mr = ms.NetworkModelResult(network, name="MyModel", item="WaterLevel")
mr
```

Expand Down Expand Up @@ -461,7 +471,7 @@ obs_q
Pass the observation to `ms.match()` exactly as you would a `NodeObservation`. modelskill resolves which breakpoint to use automatically:

```{python}
mr_q = NetworkModelResult(network, name="MyModel", item="Discharge")
mr_q = ms.NetworkModelResult(network, name="MyModel", item="Discharge")
cc_q = ms.match(obs=obs_q, mod=mr_q)
cc_q.skill()
```
Expand All @@ -479,8 +489,7 @@ Pass that database as `db` and modelskill does the lookup for you:
```python
quantity = "Pressure"

network = Network.from_epanet("model.res", quantities=quantity)
network_model = ms.NetworkModelResult(network, item=quantity)
network_model = ms.NetworkModelResult("model.res", item=quantity)

obs = ms.NodeObservation.from_multiple(
data="calibration.dfs0",
Expand Down
9 changes: 6 additions & 3 deletions src/modelskill/model/adapters/_res1d.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,10 @@
import pandas as pd

if TYPE_CHECKING:
from mikeio1d import Res1D
from mikeio1d.result_network import ResultNode, ResultGridPoint, ResultReach

from modelskill.network import _Companion

from modelskill.network import NetworkNode, ReachBreakPoint, NetworkReach


Expand Down Expand Up @@ -155,7 +156,7 @@ def _build_reach_breakpoints(
length: float | None,
quantities: set[str] | None,
populate_gridpoints: bool,
extra: Res1D | None = None,
extra: _Companion | None = None,
) -> list[ReachBreakPoint]:
"""Build a reach's break points from its mikeio1d gridpoints.

Expand Down Expand Up @@ -189,7 +190,9 @@ def _build_reach_breakpoints(
extra_gridpoints = extra.reaches[reach.name].gridpoints

breakpoints: list[ReachBreakPoint] = []
for i, (gp, distances) in enumerate(zip(unique_gridpoints, distances_per_gridpoint)):
for i, (gp, distances) in enumerate(
zip(unique_gridpoints, distances_per_gridpoint)
):
data = _simplify_colnames(gp, quantities) if populate_gridpoints else None
if data is not None and i < len(extra_gridpoints):
data = _merge_extra_quantities(
Expand Down
52 changes: 43 additions & 9 deletions src/modelskill/model/network.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
from __future__ import annotations

from pathlib import Path
from typing import TYPE_CHECKING, Sequence

import numpy as np
Expand Down Expand Up @@ -90,15 +91,17 @@ def _create_new_instance(self, data: xr.Dataset) -> NodeModelResult:
class NetworkModelResult:
"""Model result for network data with time and node dimensions.

Construct a NetworkModelResult from a Network object containing
timeseries data for each node. Users must provide exact node IDs
(integers obtained via ``Network.find()``) when creating observations —
no spatial interpolation is performed.
Construct a NetworkModelResult from a result file or from an already-loaded
Network containing timeseries data for each node. Users must provide exact
node IDs (integers obtained via ``Network.find()``) when creating
observations — no spatial interpolation is performed.

Parameters
----------
data : Network
Network-like object with a ``to_dataset()`` method (e.g. :class:`modelskill.network.Network`).
data : Network, str or Path
Path to a ``.res1d``, ``.res11`` or ``.res`` result file, or a
network-like object with a ``to_dataset()`` method (e.g.
:class:`modelskill.network.Network`).
name : str, optional
The name of the model result,
by default None (will be set to first data variable name)
Expand All @@ -113,23 +116,54 @@ class NetworkModelResult:
Examples
--------
>>> import modelskill as ms
>>> mr = ms.NetworkModelResult("model.res1d", item="WaterLevel")

From a network built by hand, or loaded with arguments of its own:

>>> from modelskill.network import Network
>>> network = Network(reaches) # reaches is a list[NetworkReach]
>>> mr = ms.NetworkModelResult(network, name="MyModel")
>>> obs = ms.NodeObservation(data, node=network.find(node="node_A"))
>>> obs = ms.NodeObservation(data, at=network.find(node="node_A"))
>>> extracted = mr.extract(obs)

Notes
-----
A path is read by the constructor its extension belongs to: ``.res1d`` and
``.res11`` by :meth:`Network.from_mike
<modelskill.network.Network.from_mike>`, ``.res`` by
:meth:`Network.from_epanet <modelskill.network.Network.from_epanet>`. An
EPANET file also picks up the ``.resx`` and ``.inp`` companions that share
its folder and stem, since the ``.inp`` is the only one of the three
carrying reach lengths.

Load the network yourself when you need to name the companions, or to keep
memory down on a large file by reading only the nodes, reaches or
quantities you will score.

See Also
--------
modelskill.network.Network.from_mike : Read a MIKE 1D or MIKE 11 result file.
modelskill.network.Network.from_epanet : Read an EPANET result file.
"""

def __init__(
self,
data: Network,
data: Network | str | Path,
*,
name: str | None = None,
item: str | int | None = None,
quantity: Quantity | None = None,
aux_items: Sequence[int | str] | None = None,
):
self.network = data.copy()
if isinstance(data, (str, Path)):
# Imported here, not at module scope, to keep this module importable
# without the optional network dependencies (ADR-010).
from modelskill.network import _network_from_path

# Freshly built, so nothing else holds a reference to copy away from.
self.network = _network_from_path(data)
else:
self.network = data.copy()

ds = self.network.to_dataset()
sel_items = SelectedItems.parse(
Expand Down
Loading
Loading