From 7de73d95ba3d08494b6319b4f56935a8d0bb766d Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Tue, 11 Aug 2026 10:45:56 +0200 Subject: [PATCH 1/3] Load a network from a result-file path, reconciling companion names Adds Network.from_path, which picks the right constructor from the file extension and, for EPANET, finds the .resx and .inp companions sitting beside the .res file rather than making the caller name them. Companion locations are also re-keyed by the main file's spelling. mikeio1d can hand back a mis-decoded name when the two files were written with different text encodings, which left a node unmatched even though both files described it. Names that already match, or that no re-reading explains, are left alone, so a companion from a genuinely different model still fails to match rather than being forced onto the wrong location. Co-Authored-By: Claude Opus 5 --- src/modelskill/model/adapters/_res1d.py | 9 +- src/modelskill/network/__init__.py | 177 +++++++++++++++++++++++- tests/test_network.py | 172 +++++++++++++++++++++++ 3 files changed, 350 insertions(+), 8 deletions(-) diff --git a/src/modelskill/model/adapters/_res1d.py b/src/modelskill/model/adapters/_res1d.py index 22d8f9077..c34eb73dd 100644 --- a/src/modelskill/model/adapters/_res1d.py +++ b/src/modelskill/model/adapters/_res1d.py @@ -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 @@ -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. @@ -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( diff --git a/src/modelskill/network/__init__.py b/src/modelskill/network/__init__.py index a185e51ad..3e31714bc 100644 --- a/src/modelskill/network/__init__.py +++ b/src/modelskill/network/__init__.py @@ -74,6 +74,81 @@ } +# The encodings a companion file's text is worth re-reading as. mikeio1d hands +# back '.res' names decoded as UTF-8 but '.resx' names decoded with the Windows +# ANSI codepage, so a name holding a non-ASCII character arrives spelled two +# ways from one model. cp1252 is that codepage on a Western-European Windows. +_COMPANION_ENCODINGS = ("cp1252", "latin-1") + + +def _repair_mis_decoded(name: str) -> list[str]: + """Re-read a name as UTF-8, undoing a single-byte decoding of those bytes. + + Parameters + ---------- + name : str + A location name as the companion file reported it. + + Returns + ------- + list of str + The candidate spellings, which is empty when no encoding round-trips. + A caller must check a candidate against the main file before using it: + the encoding that produced the name is a guess. + """ + candidates = [] + for encoding in _COMPANION_ENCODINGS: + try: + repaired = name.encode(encoding).decode("utf-8") + except (UnicodeEncodeError, UnicodeDecodeError): + continue + if repaired != name and repaired not in candidates: + candidates.append(repaired) + return candidates + + +def _rekey_by_main_file(locations: Any, known: Any) -> dict[str, Any]: + """Key a companion file's locations by their names in the main result file. + + A name that already matches, or that no re-reading reconciles, keeps the + spelling it came with — so a companion from a genuinely different model + still holds names the main file does not, and validation still catches it. + + Parameters + ---------- + locations : mapping of str to location + The companion file's nodes or reaches. + known : container of str + The main file's names for the same kind of location. + + Returns + ------- + dict of str to location + """ + rekeyed = {} + for name in locations: + key = name + if name not in known: + key = next( + (c for c in _repair_mis_decoded(name) if c in known), + name, + ) + rekeyed[key] = locations[name] + return rekeyed + + +class _Companion: + """A companion result file, keyed by the main file's location names. + + Stands in for the ``Res1D`` it wraps everywhere the loader reaches into a + companion, so a node or reach is found under one spelling of its name. + """ + + def __init__(self, res: Res1D, extra: Res1D) -> None: + self.nodes = _rekey_by_main_file(extra.nodes, res.nodes) + self.reaches = _rekey_by_main_file(extra.reaches, res.reaches) + + def _check_file_path_is_str(res: Res1D) -> None: """Reject a Res1D opened with a path object rather than a string. @@ -727,9 +802,14 @@ def _read_companion_lengths(inp: str | Path) -> dict[str, float]: return read_pipe_lengths(path) @staticmethod - def _open_companion_result(res: Res1D, resx: str | Path | Res1D) -> Res1D: + def _open_companion_result(res: Res1D, resx: str | Path | Res1D) -> _Companion: """Open and validate a companion ``.resx`` result file. + Returns + ------- + _Companion + The companion's locations, keyed by their names in ``res``. + Raises ------ ValueError @@ -768,7 +848,9 @@ def _open_companion_result(res: Res1D, resx: str | Path | Res1D) -> Res1D: f"{len(res.time_index)} ending {res.end_time}." ) - unknown_nodes = set(extra.nodes) - set(res.nodes) + companion = _Companion(res, extra) + + unknown_nodes = set(companion.nodes) - set(res.nodes) if unknown_nodes: raise ValueError( f"The '.resx' companion holds nodes {sorted(unknown_nodes)} that are " @@ -776,7 +858,7 @@ def _open_companion_result(res: Res1D, resx: str | Path | Res1D) -> Res1D: "the same model." ) - unknown_reaches = set(extra.reaches) - set(res.reaches) + unknown_reaches = set(companion.reaches) - set(res.reaches) if unknown_reaches: raise ValueError( f"The '.resx' companion holds reaches {sorted(unknown_reaches)} that are " @@ -784,7 +866,7 @@ def _open_companion_result(res: Res1D, resx: str | Path | Res1D) -> Res1D: "the same model." ) - return extra + return companion @staticmethod def _validate_extension( @@ -837,7 +919,7 @@ def _load_res1d_network( nodes: list[str], reaches: list[str], *, - extra: Res1D | None = None, + extra: _Companion | None = None, lengths: dict[str, float] | None = None, quantities: set[str] | None = None, ) -> list[Res1DReach]: @@ -1284,6 +1366,91 @@ def copy(self) -> "Network": return deepcopy(self) +def _find_epanet_companions(res: Path) -> tuple[Path | None, Path | None]: + """Find the ``.resx`` and ``.inp`` files sitting beside an EPANET ``.res``. + + A companion is recognised by sharing the result file's directory and stem. + Either may be missing, in which case ``None`` takes its place. + + Parameters + ---------- + res : Path + Path to an EPANET ``.res`` result file. + + Returns + ------- + tuple of (Path or None, Path or None) + The ``.resx`` and ``.inp`` companions, in that order. + """ + + def sibling(suffix: str) -> Path | None: + # Upper case too, since only Windows matches suffixes case-insensitively. + for spelling in (suffix, suffix.upper()): + candidate = res.with_suffix(spelling) + if candidate.is_file(): + return candidate + return None + + return sibling(".resx"), sibling(".inp") + + +def _network_from_path(path: str | Path) -> Network: + """Build a Network from a result file, picking the constructor by extension. + + Backs ``NetworkModelResult(path)``. The extension names the product that + wrote the file - ``.res`` is EPANET's and nobody else's - so the mapping in + ``_EXTENSION_CONSTRUCTORS`` decides which constructor runs. An EPANET file + also gets its ``.resx`` and ``.inp`` companions read when they sit beside it, + since without the ``.inp`` no reach has a length and reach matching cannot + work. + + Parameters + ---------- + path : str or Path + Path to a ``.res1d``, ``.res11`` or ``.res`` result file. + + Returns + ------- + Network + + Raises + ------ + NotImplementedError + If the file extension is not one modelskill can read. + """ + file = Path(path) + extension = file.suffix.lower() + # Every mapped extension is allowed here, so _validate_extension's + # "use the other constructor" arm cannot fire; the default only keeps the + # caller name sensible for extensions it refuses outright. + constructor = _EXTENSION_CONSTRUCTORS.get(extension, "from_mike") + Network._validate_extension( + file.suffix, + allowed=_MIKE_EXTENSIONS | _EPANET_EXTENSIONS, + caller=constructor, + ) + + if constructor == "from_mike": + return Network.from_mike(file) + + resx, inp = _find_epanet_companions(file) + try: + return Network.from_epanet(file, resx=resx, inp=inp) + except ValueError as err: + found = [companion for companion in (resx, inp) if companion is not None] + if not found: + raise + # The companions were never asked for, so name them: otherwise the error + # points at files the caller did not know were being read. + names = ", ".join(f"'{companion.name}'" for companion in found) + raise ValueError( + f"Failed to build a network from '{file.name}': {err}\n" + f"Companion files read alongside it, because they share its folder: " + f"{names}. Use Network.from_epanet(r'{file}') to read the result file " + "on its own, or pass the companions you want explicitly." + ) from err + + def _make_basic_network(node_ids, time, data, quantity="WaterLevel"): nodes = [ BasicNode(nid, pd.DataFrame({quantity: data[:, i]}, index=time)) diff --git a/tests/test_network.py b/tests/test_network.py index 4e98a9c56..27d9215c7 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -1,8 +1,10 @@ """Test network models and observations""" # ruff: noqa: E402 +import shutil import sys from pathlib import Path +from types import SimpleNamespace import pytest pytest.importorskip("networkx") @@ -31,6 +33,11 @@ _EPANET_EXTENSIONS, _MIKE_EXTENSIONS, _UNSUPPORTED_EXTENSIONS, + _Companion, + _find_epanet_companions, + _network_from_path, + _repair_mis_decoded, + _rekey_by_main_file, ) from modelskill.obs import NodeObservation, ReachObservation from modelskill.quantity import Quantity @@ -1922,6 +1929,51 @@ def test_unsupported_type_is_refused(self): Network.from_epanet(_EPANET_RES, resx=42) # type: ignore[arg-type] +class TestCompanionNameEncoding: + """mikeio1d reads '.res' names as UTF-8 and '.resx' names as CP1252. + + A node called 'ØST' in one file is 'ØST' in the other, so the same model + looks like two - and the four Danish tank names in a real MIKE+ EPANET + model were the ones that surfaced it. + """ + + def test_a_mis_decoded_name_is_recovered(self): + assert _repair_mis_decoded("ØST") == ["ØST"] + assert _repair_mis_decoded("Vandværk_Vest") == ["Vandværk_Vest"] + + def test_an_ascii_name_has_nothing_to_recover(self): + assert _repair_mis_decoded("Junction_1") == [] + + def test_a_name_that_no_encoding_explains_is_left_alone(self): + """'ØST' is already correct: its bytes are not valid UTF-8 on their own.""" + assert _repair_mis_decoded("ØST") == [] + + def test_a_companion_location_is_keyed_by_the_main_files_name(self): + rekeyed = _rekey_by_main_file({"ØST": "data"}, {"ØST", "Junction_1"}) + + assert rekeyed == {"ØST": "data"} + + def test_a_matching_name_is_untouched(self): + rekeyed = _rekey_by_main_file({"Junction_1": "data"}, {"Junction_1"}) + + assert rekeyed == {"Junction_1": "data"} + + def test_a_name_from_another_model_keeps_its_own_spelling(self): + """Otherwise a genuinely different companion would slip past validation.""" + rekeyed = _rekey_by_main_file({"ØST": "data"}, {"Junction_1"}) + + assert rekeyed == {"ØST": "data"} + + def test_a_companion_rekeys_both_nodes_and_reaches(self): + res = SimpleNamespace(nodes={"ØST": 1}, reaches={"Vandværk_Vest": 2}) + extra = SimpleNamespace(nodes={"ØST": 3}, reaches={"Vandværk_Vest": 4}) + + companion = _Companion(res, extra) + + assert companion.nodes == {"ØST": 3} + assert companion.reaches == {"Vandværk_Vest": 4} + + class TestReadInp: """Minimal .inp reader - see modelskill/model/adapters/_inp.py.""" @@ -1973,3 +2025,123 @@ def test_a_repeated_section_header_accumulates(self, tmp_path): ) assert read_pipe_lengths(path) == {"1": 10.0, "3": 20.0} + + +# --------------------------------------------------------------------------- +# Building a Network from a bare path +# --------------------------------------------------------------------------- + + +def _copy_epanet(tmp_path, *suffixes): + """Copy the EPANET fixture set into tmp_path, keeping only some companions.""" + tmp_path = Path(tmp_path) + tmp_path.mkdir(parents=True, exist_ok=True) + for suffix in (".res", *suffixes): + shutil.copy(f"./tests/testdata/epanet{suffix}", tmp_path / f"model{suffix}") + return tmp_path / "model.res" + + +class TestFindEpanetCompanions: + """A companion is the file sharing the result file's folder and stem.""" + + def test_both_companions_are_found(self, tmp_path): + res = _copy_epanet(tmp_path, ".resx", ".inp") + + assert _find_epanet_companions(res) == ( + tmp_path / "model.resx", + tmp_path / "model.inp", + ) + + def test_a_missing_companion_is_none(self, tmp_path): + res = _copy_epanet(tmp_path, ".inp") + + assert _find_epanet_companions(res) == (None, tmp_path / "model.inp") + + def test_a_lone_result_file_has_neither(self, tmp_path): + res = _copy_epanet(tmp_path) + + assert _find_epanet_companions(res) == (None, None) + + def test_a_differently_named_sibling_is_not_a_companion(self, tmp_path): + res = _copy_epanet(tmp_path) + shutil.copy("./tests/testdata/epanet.inp", tmp_path / "other.inp") + + assert _find_epanet_companions(res) == (None, None) + + +@requires_mikeio1d +class TestNetworkFromPath: + """The extension names the product, so it picks the constructor.""" + + def test_res1d_goes_to_from_mike(self): + network = _network_from_path("./tests/testdata/network.res1d") + + assert network.graph.number_of_nodes() == 259 + 2 * 118 + + def test_res11_goes_to_from_mike(self): + network = _network_from_path(Path("./tests/testdata/network_cali.res11")) + + assert set(network.quantities) == {"Discharge", "Water Level"} + + def test_res_goes_to_from_epanet_with_both_companions(self, tmp_path): + res = _copy_epanet(tmp_path, ".resx", ".inp") + + network = _network_from_path(res) + + # Lengths come from the .inp; Volume comes from the .resx. + assert network._reaches["10"].length == pytest.approx(3209.544) + assert "Volume" in network.quantities + + def test_res_without_companions_still_loads(self, tmp_path): + res = _copy_epanet(tmp_path) + + network = _network_from_path(res) + + assert network._reaches["10"].length is None + assert "Volume" not in network.quantities + + def test_each_companion_is_found_on_its_own(self, tmp_path): + with_inp = _network_from_path(_copy_epanet(tmp_path / "a", ".inp")) + with_resx = _network_from_path(_copy_epanet(tmp_path / "b", ".resx")) + + assert with_inp._reaches["10"].length == pytest.approx(3209.544) + assert "Volume" not in with_inp.quantities + assert with_resx._reaches["10"].length is None + assert "Volume" in with_resx.quantities + + def test_an_unreadable_format_keeps_its_reason(self): + with pytest.raises(NotImplementedError, match="companion '.inp' input file"): + _network_from_path("./tests/testdata/swmm.out") + + def test_an_unknown_extension_is_refused(self): + with pytest.raises(NotImplementedError, match="Unsupported file extension"): + _network_from_path("./tests/testdata/obs.dfs0") + + def test_a_failing_companion_names_the_file_it_picked_up( + self, tmp_path, monkeypatch + ): + """A companion the caller never asked for must be named when it fails.""" + res = _copy_epanet(tmp_path, ".resx") + monkeypatch.setattr( + Network, + "_open_companion_result", + staticmethod(lambda *a, **kw: (_ for _ in ()).throw(ValueError("boom"))), + ) + + with pytest.raises(ValueError, match="model.resx") as excinfo: + _network_from_path(res) + + assert "boom" in str(excinfo.value) + assert "Network.from_epanet" in str(excinfo.value) + + def test_a_failure_without_companions_is_left_alone(self, tmp_path, monkeypatch): + res = _copy_epanet(tmp_path) + monkeypatch.setattr( + Network, + "_load_res1d_network", + staticmethod(lambda *a, **kw: (_ for _ in ()).throw(ValueError("boom"))), + ) + + with pytest.raises(ValueError, match="^boom$"): + _network_from_path(res) + From 2114718d58cc00156f5b630645fee2dd552722aa Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Tue, 11 Aug 2026 10:46:21 +0200 Subject: [PATCH 2/3] Accept a result-file path in NetworkModelResult Constructing a model result from a file meant importing Network, loading it, then passing it in. Hand NetworkModelResult the path instead and it loads the network itself via Network.from_path; the loaded network stays reachable afterwards. Co-Authored-By: Claude Opus 5 --- src/modelskill/model/network.py | 52 +++++++++++++++++++++++++++------ tests/test_network.py | 51 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 9 deletions(-) diff --git a/src/modelskill/model/network.py b/src/modelskill/model/network.py index 5c752df9b..5f5285dc3 100644 --- a/src/modelskill/model/network.py +++ b/src/modelskill/model/network.py @@ -1,5 +1,6 @@ from __future__ import annotations +from pathlib import Path from typing import TYPE_CHECKING, Sequence import numpy as np @@ -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) @@ -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 + `, ``.res`` by + :meth:`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( diff --git a/tests/test_network.py b/tests/test_network.py index 27d9215c7..5c67b65c7 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -2145,3 +2145,54 @@ def test_a_failure_without_companions_is_left_alone(self, tmp_path, monkeypatch) with pytest.raises(ValueError, match="^boom$"): _network_from_path(res) + +@requires_mikeio1d +class TestNetworkModelResultFromPath: + """A path spares the caller a separate Network import and load.""" + + _RES1D = "./tests/testdata/network.res1d" + + def test_a_path_gives_the_same_result_as_a_loaded_network(self): + from_path = NetworkModelResult(self._RES1D, item="WaterLevel") + from_network = NetworkModelResult( + Network.from_mike(self._RES1D), item="WaterLevel" + ) + + assert from_path.name == from_network.name + assert from_path.quantity == from_network.quantity + assert np.array_equal(from_path.nodes, from_network.nodes) + assert from_path.time.equals(from_network.time) + + def test_a_str_and_a_path_are_interchangeable(self): + as_str = NetworkModelResult(self._RES1D, item="WaterLevel") + as_path = NetworkModelResult(Path(self._RES1D), item="WaterLevel") + + assert np.array_equal(as_str.nodes, as_path.nodes) + + def test_the_network_is_reachable_afterwards(self): + mr = NetworkModelResult(self._RES1D, item="WaterLevel") + + assert isinstance(mr.network, Network) + assert mr.network.find(node="100") in mr.nodes + + def test_extract_works_from_a_path_loaded_model(self): + mr = NetworkModelResult(self._RES1D, item="WaterLevel") + node = mr.network.find(node="100") + obs_data = pd.DataFrame({"sensor": np.zeros(len(mr.time))}, index=mr.time) + + extracted = mr.extract(NodeObservation(obs_data, at=node)) + + assert isinstance(extracted, NodeModelResult) + assert extracted.node == node + + def test_an_epanet_path_reads_its_companions(self, tmp_path): + res = _copy_epanet(tmp_path, ".resx", ".inp") + + mr = NetworkModelResult(res, item="Head") + + assert "Volume" in mr.network.quantities + assert mr.network._reaches["10"].length == pytest.approx(3209.544) + + def test_an_unreadable_format_is_refused(self): + with pytest.raises(NotImplementedError, match="Unsupported file extension"): + NetworkModelResult("./tests/testdata/obs.dfs0") From 1510e8103295dbc8b73d7448b88427684c3a98ca Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Tue, 11 Aug 2026 10:46:21 +0200 Subject: [PATCH 3/3] Document loading a network model result from a path Co-Authored-By: Claude Opus 5 --- adr/012-network-format-constructors.md | 3 +++ docs/user-guide/network.qmd | 21 +++++++++++++++------ 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/adr/012-network-format-constructors.md b/adr/012-network-format-constructors.md index 324959247..a6eff7ce8 100644 --- a/adr/012-network-format-constructors.md +++ b/adr/012-network-format-constructors.md @@ -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. @@ -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_` 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. diff --git a/docs/user-guide/network.qmd b/docs/user-guide/network.qmd index e744d7095..ab39a3583 100644 --- a/docs/user-guide/network.qmd +++ b/docs/user-guide/network.qmd @@ -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 ``` @@ -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() ``` @@ -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",