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", 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/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/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..5c67b65c7 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,174 @@ 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) + + +@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")