From 1eee1bd7f751d2d5f9149c13e874b5ae83be1736 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 10:06:29 +0200 Subject: [PATCH 01/27] Accept every file extension mikeio1d can read in from_res1d Res1D reads nine formats (res1d, res11, res, prf, crf, xrf, out, whr, resx), so the hardcoded .res1d-only guard rejected files that load fine. Ask Res1D for the supported set rather than keeping a second copy of it that drifts as mikeio1d adds formats. Co-Authored-By: Claude Opus 5 --- src/modelskill/network.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 071bc01f6..89cbf6eed 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -361,7 +361,10 @@ def from_res1d( Parameters ---------- res : str, Path or Res1D - Path to a .res1d file, or an already-opened :class:`mikeio1d.Res1D` object. + Path to a network result file, or an already-opened + :class:`mikeio1d.Res1D` object. Any file extension that mikeio1d + can read is accepted (.res1d, .res11, .res, .prf, .crf, .xrf, + .out, .whr, .resx). nodes : str, list of str, or None, optional Controls which nodes have their timeseries data loaded into memory. @@ -419,9 +422,11 @@ def from_res1d( if isinstance(res, (str, Path)): path = Path(res) - if path.suffix.lower() != ".res1d": + supported = _Res1D.get_supported_file_extensions() + if path.suffix.lower() not in supported: raise NotImplementedError( - f"Unsupported file extension '{path.suffix}'. Only .res1d files are supported." + f"Unsupported file extension '{path.suffix}'. " + f"Supported extensions are {sorted(supported)}." ) res = _Res1D(str(path)) elif not isinstance(res, _Res1D): @@ -445,6 +450,7 @@ def from_res1d( list_of_reaches = cls._load_res1d_network(res, nodes_list, reaches_list) return cls(list_of_reaches) + @staticmethod def _load_res1d_network( res: Res1D, @@ -491,7 +497,9 @@ def _generate_alias_map(g: nx.Graph) -> dict[str | tuple[str, float], int]: return {g.nodes[id]["alias"]: id for id in g.nodes()} @staticmethod - def _generate_reaches_dict(reaches: Sequence[NetworkReach]) -> dict[str, NetworkReach]: + def _generate_reaches_dict( + reaches: Sequence[NetworkReach], + ) -> dict[str, NetworkReach]: return {r.id: r for r in reaches} @staticmethod From 91eba70759988335b6e12d61d7ae18af9a14db46 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 10:06:39 +0200 Subject: [PATCH 02/27] Test from_res1d input validation The extension guard and the TypeError branch had no coverage. Co-Authored-By: Claude Opus 5 --- tests/test_network.py | 73 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/tests/test_network.py b/tests/test_network.py index 931860102..411630bfc 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -605,6 +605,79 @@ def test_from_res1d_empty_nodes_and_reaches_keeps_topology_and_empty_outputs(): assert len(ds.data_vars) == 0 +# --------------------------------------------------------------------------- +# from_res1d — input validation +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +@pytest.mark.parametrize( + "suffix", + [ + ".res1d", # MIKE 1D + ".res11", # MIKE 11 + ".res", # EPANET + ".prf", # MOUSE + ".out", # SWMM + ".RES1D", # extension check is case-insensitive + ], +) +def test_from_res1d_accepts_every_extension_mikeio1d_supports(tmp_path, suffix): + """Every extension mikeio1d can read gets past the extension guard. + + The file does not exist, so mikeio1d - not the guard - is what complains. + """ + missing_file = tmp_path / f"network{suffix}" + + with pytest.raises((FileExistsError, FileNotFoundError)): + Network.from_res1d(missing_file) + + +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +def test_from_res1d_rejects_unsupported_extension(): + with pytest.raises(NotImplementedError, match="Unsupported file extension"): + Network.from_res1d("./tests/testdata/obs.dfs0") + + +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +def test_from_res1d_error_lists_supported_extensions(): + from mikeio1d import Res1D + + with pytest.raises(NotImplementedError) as excinfo: + Network.from_res1d("network.nc") + + message = str(excinfo.value) + for extension in Res1D.get_supported_file_extensions(): + assert extension in message + + +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +def test_from_res1d_accepts_open_res1d_object(): + from mikeio1d import Res1D + + res = Res1D("./tests/testdata/network.res1d") + + network = Network.from_res1d(res, nodes=[], reaches=[]) + + assert network.graph.number_of_nodes() == 259 + + +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +def test_from_res1d_rejects_unsupported_type(): + with pytest.raises(TypeError, match="Expected a str, Path or Res1D object"): + Network.from_res1d(42) # type: ignore[arg-type] + + # --------------------------------------------------------------------------- # NodeObservation — alias / breakpoint node forms # --------------------------------------------------------------------------- From 792deaded2b3c55d15695d98c6655e8dd66a2232 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 10:08:59 +0200 Subject: [PATCH 03/27] Commit confidential test data folder --- .gitignore | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index 318f22d3b..b38653822 100644 --- a/.gitignore +++ b/.gitignore @@ -153,4 +153,6 @@ docs/_site/ docs/_extensions/ docs/api/*.qmd -uv.lock \ No newline at end of file +uv.lock + +tests/testdata/confidential/* \ No newline at end of file From a61ccfdd63c9d4bbfbb850012eef90e089a34c70 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:36:59 +0200 Subject: [PATCH 04/27] Return an empty frame for Res1D locations with no quantities MIKE 11 stores its timeseries on reach gridpoints, so its nodes carry no quantities at all and mikeio1d raises "Could not create DataFrame with zero items" when asked for one. Guard on quantities instead, which unblocks .res11 files. Co-Authored-By: Claude Opus 5 --- src/modelskill/model/adapters/_res1d.py | 6 ++++ tests/test_network.py | 43 ++++++++++++++++++++++--- 2 files changed, 45 insertions(+), 4 deletions(-) diff --git a/src/modelskill/model/adapters/_res1d.py b/src/modelskill/model/adapters/_res1d.py index d6d391da0..04d51e31c 100644 --- a/src/modelskill/model/adapters/_res1d.py +++ b/src/modelskill/model/adapters/_res1d.py @@ -13,6 +13,12 @@ def _simplify_colnames(node: ResultNode | ResultGridPoint) -> pd.DataFrame: # We remove suffixes and indexes so the columns contain only the quantity names + # Some formats keep no timeseries at all on some locations - MIKE 11, for instance, + # stores everything on reach gridpoints, leaving the nodes empty. Asking mikeio1d + # for a dataframe there raises, so return an empty one instead. + if not node.quantities: + return pd.DataFrame() + # The columns in a Res1D dataframe follow the convention "Quantity:Location:Sublocation" # where Location refers to the node id or the reach id followed by the chainage. RES1D_NAME_SEP = ":" diff --git a/tests/test_network.py b/tests/test_network.py index 411630bfc..576fd4669 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -14,6 +14,7 @@ NetworkModelResult, NodeModelResult, ) +from modelskill.model.adapters._res1d import _simplify_colnames from modelskill.network import ( Network, BasicNode, @@ -200,7 +201,8 @@ def test_extract_wrong_observation_type(self, sample_network): obs = ms.PointObservation(df, x=0.0, y=0.0) with pytest.raises( - TypeError, match="NetworkModelResult supports NodeObservation and ReachObservation" + TypeError, + match="NetworkModelResult supports NodeObservation and ReachObservation", ): nmr.extract(obs) @@ -473,9 +475,7 @@ def test_extract_reach_observation_non_equivalent_breakpoints_raises(sample_node obs_data = sample_node_data.rename(columns={"WaterLevel": "Discharge"}) obs = ms.ReachObservation(obs_data, reach="113l1", item="Discharge") - with pytest.raises( - ValueError, match="Not all data in breakpoints are equivalent" - ): + with pytest.raises(ValueError, match="Not all data in breakpoints are equivalent"): nmr.extract(obs) @@ -858,3 +858,38 @@ def test_match_with_string_alias(self, sample_network, sample_node_data): comparer = ms.match(obs, nmr) assert comparer.n_points > 0 assert "Network_Model" in comparer.mod_names + + +# --------------------------------------------------------------------------- +# Res1D adapter — no mikeio1d required, the adapter is duck-typed +# --------------------------------------------------------------------------- + + +class _StubLocation: + """Stands in for a mikeio1d ResultNode / ResultGridPoint.""" + + def __init__(self, quantities, df=None): + self.quantities = quantities + self._df = df + + def to_dataframe(self): + if self._df is None: + raise AssertionError("to_dataframe() should not be called") + return self._df + + +class TestSimplifyColnames: + def test_location_without_quantities_gives_empty_frame(self): + """MIKE 11 keeps its data on gridpoints, leaving nodes with no quantities.""" + df = _simplify_colnames(_StubLocation(quantities=[])) + + assert df.empty + assert list(df.columns) == [] + + def test_quantity_columns_are_stripped_of_location_suffix(self): + time = pd.date_range("2020", periods=2, freq="h") + raw = pd.DataFrame({"WaterLevel:node_1": [1.0, 2.0]}, index=time) + + df = _simplify_colnames(_StubLocation(quantities=["WaterLevel"], df=raw)) + + assert list(df.columns) == ["WaterLevel"] From c625836d5d755cc958dd0a20984e972641e7e7d0 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:38:17 +0200 Subject: [PATCH 05/27] Reject Res1D reaches that expose no start/end node mikeio1d returns None for both start_node and end_node on .resx results, which the existing identity checks let through by comparing None to None. The graph build then failed with networkx complaining about a None node key, three layers from the cause. Check explicitly instead. Co-Authored-By: Claude Opus 5 --- src/modelskill/model/adapters/_res1d.py | 8 +++++ tests/test_network.py | 39 ++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 1 deletion(-) diff --git a/src/modelskill/model/adapters/_res1d.py b/src/modelskill/model/adapters/_res1d.py index 04d51e31c..e5bd9d520 100644 --- a/src/modelskill/model/adapters/_res1d.py +++ b/src/modelskill/model/adapters/_res1d.py @@ -93,6 +93,14 @@ def __init__( ): self._id = reach.name + # Must be checked separately: some formats (.resx) report None for both the + # reach and the node, which the identity checks below would let through. + if reach.start_node is None or reach.end_node is None: + raise ValueError( + f"mikeio1d reported no start/end node for reach {reach.name!r}; " + "this result format's topology cannot be represented as a Network." + ) + if start_node.id != reach.start_node: raise ValueError("Incorrect starting node.") if end_node.id != reach.end_node: diff --git a/tests/test_network.py b/tests/test_network.py index 576fd4669..33240ce5a 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -14,7 +14,11 @@ NetworkModelResult, NodeModelResult, ) -from modelskill.model.adapters._res1d import _simplify_colnames +from modelskill.model.adapters._res1d import ( + Res1DNode, + Res1DReach, + _simplify_colnames, +) from modelskill.network import ( Network, BasicNode, @@ -893,3 +897,36 @@ def test_quantity_columns_are_stripped_of_location_suffix(self): df = _simplify_colnames(_StubLocation(quantities=["WaterLevel"], df=raw)) assert list(df.columns) == ["WaterLevel"] + + +class _StubReach: + """Stands in for a mikeio1d ResultReach.""" + + def __init__(self, name="r1", start_node="a", end_node="b", length=100.0): + self.name = name + self.start_node = start_node + self.end_node = end_node + self.length = length + self.gridpoints = [] + + +class TestRes1DReachConnectivity: + """Formats that expose no reach connectivity must fail with a clear message.""" + + @pytest.mark.parametrize("missing", ["start_node", "end_node"]) + def test_missing_node_raises(self, missing): + reach = _StubReach(**{missing: None}) + + with pytest.raises(ValueError, match="no start/end node for reach 'r1'"): + Res1DReach(reach, Res1DNode("a"), Res1DNode("b")) + + def test_both_nodes_missing_raises(self): + """.resx reports None for both, which the identity checks alone would allow.""" + reach = _StubReach(start_node=None, end_node=None) + + with pytest.raises(ValueError, match="no start/end node"): + Res1DReach(reach, Res1DNode(None), Res1DNode(None)) # type: ignore[arg-type] + + def test_mismatched_start_node_still_raises(self): + with pytest.raises(ValueError, match="Incorrect starting node"): + Res1DReach(_StubReach(), Res1DNode("wrong"), Res1DNode("b")) From 315158df77fe7dba943c83ff8969e6c4e5db07a4 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:38:44 +0200 Subject: [PATCH 06/27] Add network result fixtures from mikeio1d Four files copied unchanged from DHI/mikeio1d (MIT, same as modelskill): res11 and epanet.res to cover MIKE 11 and EPANET end to end, resx and swmm.out to assert that the two formats mikeio1d cannot give us reach connectivity for are rejected with a clear message. Co-Authored-By: Claude Opus 5 --- tests/testdata/README.md | 20 ++++++++++++++++++++ tests/testdata/epanet.res | Bin 0 -> 17066 bytes tests/testdata/epanet.resx | Bin 0 -> 1019 bytes tests/testdata/network_cali.res11 | Bin 0 -> 196339 bytes tests/testdata/swmm.out | Bin 0 -> 44166 bytes 5 files changed, 20 insertions(+) create mode 100644 tests/testdata/README.md create mode 100644 tests/testdata/epanet.res create mode 100644 tests/testdata/epanet.resx create mode 100644 tests/testdata/network_cali.res11 create mode 100644 tests/testdata/swmm.out diff --git a/tests/testdata/README.md b/tests/testdata/README.md new file mode 100644 index 000000000..9a96ab41b --- /dev/null +++ b/tests/testdata/README.md @@ -0,0 +1,20 @@ +# Test data provenance + +Most files here were produced for modelskill. The exceptions are listed below. + +## From DHI/mikeio1d + +These network result files come from +[DHI/mikeio1d](https://github.com/DHI/mikeio1d/tree/main/tests/testdata) +(commit `d937466`), copied unchanged. mikeio1d is MIT-licensed, as is modelskill. + +| File | Format | Used for | +|---|---|---| +| `network_cali.res11` | MIKE 11 | `Network.from_mike` coverage for `.res11` | +| `epanet.res` | EPANET | `Network.from_epanet` coverage | +| `epanet.resx` | EPANET (MIKE+) | asserting `.resx` is rejected — mikeio1d exposes no reach connectivity for it | +| `swmm.out` | SWMM | asserting `.out` is rejected — mikeio1d cannot resolve reach start/end nodes for it | + +The last two exist to pin upstream behaviour: if a future mikeio1d exposes +connectivity for those formats, the rejection tests fail, which is when we would want +to add a constructor for them. diff --git a/tests/testdata/epanet.res b/tests/testdata/epanet.res new file mode 100644 index 0000000000000000000000000000000000000000..dbc080f9abe104854cf7a3fd86d0c1c3a17d65b5 GIT binary patch literal 17066 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 literal 0 HcmV?d00001 diff --git a/tests/testdata/epanet.resx b/tests/testdata/epanet.resx new file mode 100644 index 0000000000000000000000000000000000000000..327373e021b5a7d5c4b9a4c206bfaa676c0ab531 GIT binary patch literal 1019 zcmdmNc36&yfq?;pL4Xm6S)nwDFTlsZ&=APTU<6+L82%~T0?RpxFM-SDDINgJX%^lG%aw_(fXg)}K;)8SAA{u%-CPBi zTRj&dw{r6{uv~A?TDY9tcBq*fUV!CacNDzI{W0M_?!D<~dN0n4=8V7dGHOEKimKD!2%+q-fhhFtLR%V4?SfcY45 zHXvq$ z0n5$12Qm{Pr}=81lM2wQW$XK#CiFdV+@z=Av{+o*$?sym(;tr%r+0r5?zRuR?NGBr z(h)|(LI|b~Cf+Ka;$(cZ(<$WsdZ*cEJDiNJMLF(zR^eD?BJSkax!h@k)e5J@_YCd+ d%rmg#(3NyhKjmYucld}c$UG1qL~jOWdjMlf!-@a^ literal 0 HcmV?d00001 diff --git a/tests/testdata/network_cali.res11 b/tests/testdata/network_cali.res11 new file mode 100644 index 0000000000000000000000000000000000000000..ce51c1db39e5ac9d870da363418940a37a7c7e14 GIT binary patch literal 196339 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 `R8}~ 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 If&EkQe-X@BasU7T literal 0 HcmV?d00001 diff --git a/tests/testdata/swmm.out b/tests/testdata/swmm.out new file mode 100644 index 0000000000000000000000000000000000000000..9801df1b2ea44915606afd4d386fdb39dd1202f1 GIT binary patch literal 44166 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 TPsVb4C@Bq79B|KxrGx(myYz30 literal 0 HcmV?d00001 From a2af1f7231d88f1aa59db2f9f8911124e145c281 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:41:20 +0200 Subject: [PATCH 07/27] Extract the shared mikeio1d loading path Moves the version guard, extension check, Res1D construction and nodes/reaches normalisation into _from_mikeio1d, so the per-format constructors added next are each a docstring and one delegating call. No behaviour change: from_res1d passes allowed=None. Co-Authored-By: Claude Opus 5 --- src/modelskill/network.py | 65 ++++++++++++++++++++++++++++++++++----- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 89cbf6eed..4ac3563db 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -413,6 +413,30 @@ def from_res1d( ... ) """ + return cls._from_mikeio1d( + res, nodes=nodes, reaches=reaches, allowed=None, caller="from_res1d" + ) + + @classmethod + def _from_mikeio1d( + cls, + res: str | Path | Res1D, + *, + nodes: str | list[str] | None, + reaches: str | list[str] | None, + allowed: frozenset[str] | None, + caller: str, + ) -> Network: + """Shared implementation behind the public ``from_*`` constructors. + + Parameters + ---------- + allowed : frozenset of str or None + Extensions this constructor accepts, or None to accept everything + mikeio1d can read. + caller : str + Name of the public method, used in error messages. + """ if sys.version_info >= (3, 14): raise NotImplementedError( f"Current version of 'mikeio1d' requires python < 3.14 and {sys.version} is being used." @@ -422,14 +446,12 @@ def from_res1d( if isinstance(res, (str, Path)): path = Path(res) - supported = _Res1D.get_supported_file_extensions() - if path.suffix.lower() not in supported: - raise NotImplementedError( - f"Unsupported file extension '{path.suffix}'. " - f"Supported extensions are {sorted(supported)}." - ) + cls._validate_extension(path.suffix, allowed=allowed, caller=caller) res = _Res1D(str(path)) - elif not isinstance(res, _Res1D): + elif isinstance(res, _Res1D): + suffix = Path(str(res.file_path)).suffix + cls._validate_extension(suffix, allowed=allowed, caller=caller) + else: raise TypeError( f"Expected a str, Path or Res1D object, got {type(res).__name__!r}" ) @@ -451,6 +473,35 @@ def from_res1d( list_of_reaches = cls._load_res1d_network(res, nodes_list, reaches_list) return cls(list_of_reaches) + @staticmethod + def _validate_extension( + suffix: str, *, allowed: frozenset[str] | None, caller: str + ) -> None: + """Check a file extension against mikeio1d and against one constructor. + + Raises + ------ + NotImplementedError + If mikeio1d cannot read the extension at all. + ValueError + If mikeio1d can read it but this constructor does not accept it. + """ + from mikeio1d import Res1D as _Res1D + + extension = suffix.lower() + + supported = _Res1D.get_supported_file_extensions() + if extension not in supported: + raise NotImplementedError( + f"Unsupported file extension '{suffix}'. " + f"Supported extensions are {sorted(supported)}." + ) + + if allowed is not None and extension not in allowed: + raise ValueError( + f"Network.{caller}() reads {sorted(allowed)} files, got '{suffix}'." + ) + @staticmethod def _load_res1d_network( res: Res1D, From a7e3979c94337eca3be9844c5f48d579a84516a1 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:42:53 +0200 Subject: [PATCH 08/27] Refuse the mikeio1d formats modelskill cannot use Four of the nine extensions mikeio1d reads cannot produce a Network. SWMM .out and .resx expose no reach start/end nodes, so there is no topology to rebuild; MOUSE and Water Hammer have no test fixture anywhere, so support cannot be verified. Each now fails with the specific reason instead of an error from inside mikeio1d. The rejection tests use real .resx and .out files, so they start failing if a future mikeio1d exposes connectivity for them. Co-Authored-By: Claude Opus 5 --- src/modelskill/network.py | 47 +++++++++++++++++++++++++++++++++--- tests/test_network.py | 51 +++++++++++++++++++++++++++++++++------ 2 files changed, 86 insertions(+), 12 deletions(-) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 4ac3563db..a3d555d6d 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -30,6 +30,35 @@ from .model.adapters._res1d import Res1DReach +_MIKE_EXTENSIONS = frozenset({".res1d", ".res11"}) +_EPANET_EXTENSIONS = frozenset({".res"}) + +_NO_CONNECTIVITY = ( + "mikeio1d does not expose reach start/end nodes for {product} results, " + "so the network topology cannot be reconstructed." +) +_NO_FIXTURE = ( + "{product} results are not supported yet: modelskill has no test fixture for " + "this format, so support cannot be verified. Please open an issue if you need it." +) + +# extension -> why modelskill will not read it, even though mikeio1d can +_UNSUPPORTED_EXTENSIONS: dict[str, str] = { + ".out": _NO_CONNECTIVITY.format(product="SWMM"), + ".resx": _NO_CONNECTIVITY.format(product=".resx"), + ".prf": _NO_FIXTURE.format(product="MOUSE"), + ".crf": _NO_FIXTURE.format(product="MOUSE"), + ".xrf": _NO_FIXTURE.format(product="MOUSE"), + ".whr": _NO_FIXTURE.format(product="Water Hammer"), +} + +# extension -> the constructor that reads it, for "use X instead" errors +_EXTENSION_CONSTRUCTORS: dict[str, str] = { + **{extension: "from_mike" for extension in _MIKE_EXTENSIONS}, + **{extension: "from_epanet" for extension in _EPANET_EXTENSIONS}, +} + + class NetworkNode(ABC): """Abstract base class for a node in a network. @@ -482,24 +511,34 @@ def _validate_extension( Raises ------ NotImplementedError - If mikeio1d cannot read the extension at all. + If modelskill cannot read the extension, either because mikeio1d + does not support it or because modelskill does not. ValueError - If mikeio1d can read it but this constructor does not accept it. + If another constructor is the one that reads this extension. """ from mikeio1d import Res1D as _Res1D extension = suffix.lower() + # Checked before the supported set below, since these all *are* readable + # by mikeio1d - it is modelskill that cannot use the result. + reason = _UNSUPPORTED_EXTENSIONS.get(extension) + if reason is not None: + raise NotImplementedError(f"Cannot read '{suffix}' files. {reason}") + supported = _Res1D.get_supported_file_extensions() if extension not in supported: + readable = sorted(supported - set(_UNSUPPORTED_EXTENSIONS)) raise NotImplementedError( f"Unsupported file extension '{suffix}'. " - f"Supported extensions are {sorted(supported)}." + f"Supported extensions are {readable}." ) if allowed is not None and extension not in allowed: + constructor = _EXTENSION_CONSTRUCTORS[extension] raise ValueError( - f"Network.{caller}() reads {sorted(allowed)} files, got '{suffix}'." + f"Network.{caller}() reads {sorted(allowed)} files, got '{suffix}'. " + f"Use Network.{constructor}() instead." ) @staticmethod diff --git a/tests/test_network.py b/tests/test_network.py index 33240ce5a..dc7e5814d 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -23,6 +23,9 @@ Network, BasicNode, BasicReach, + _EPANET_EXTENSIONS, + _MIKE_EXTENSIONS, + _UNSUPPORTED_EXTENSIONS, ) from modelskill.obs import NodeObservation from modelskill.quantity import Quantity @@ -623,13 +626,11 @@ def test_from_res1d_empty_nodes_and_reaches_keeps_topology_and_empty_outputs(): ".res1d", # MIKE 1D ".res11", # MIKE 11 ".res", # EPANET - ".prf", # MOUSE - ".out", # SWMM ".RES1D", # extension check is case-insensitive ], ) -def test_from_res1d_accepts_every_extension_mikeio1d_supports(tmp_path, suffix): - """Every extension mikeio1d can read gets past the extension guard. +def test_from_res1d_accepts_readable_extensions(tmp_path, suffix): + """A readable extension gets past the extension guard. The file does not exist, so mikeio1d - not the guard - is what complains. """ @@ -650,15 +651,49 @@ def test_from_res1d_rejects_unsupported_extension(): @pytest.mark.skipif( sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" ) -def test_from_res1d_error_lists_supported_extensions(): - from mikeio1d import Res1D - +def test_from_res1d_error_lists_only_readable_extensions(): with pytest.raises(NotImplementedError) as excinfo: Network.from_res1d("network.nc") message = str(excinfo.value) - for extension in Res1D.get_supported_file_extensions(): + for extension in _MIKE_EXTENSIONS | _EPANET_EXTENSIONS: assert extension in message + for extension in _UNSUPPORTED_EXTENSIONS: + assert extension not in message + + +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +@pytest.mark.parametrize( + "filename", + ["./tests/testdata/epanet.resx", "./tests/testdata/swmm.out"], +) +def test_formats_without_reach_connectivity_are_rejected(filename): + """Real files, so these fail if mikeio1d ever starts exposing connectivity.""" + with pytest.raises(NotImplementedError, match="reach start/end nodes"): + Network.from_res1d(filename) + + +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +@pytest.mark.parametrize("suffix", [".prf", ".crf", ".xrf", ".whr"]) +def test_formats_without_a_fixture_are_rejected(tmp_path, suffix): + with pytest.raises(NotImplementedError, match="no test fixture"): + Network.from_res1d(tmp_path / f"network{suffix}") + + +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +def test_every_mikeio1d_extension_is_accounted_for(): + """A new mikeio1d format must be read or explicitly refused, never ignored.""" + from mikeio1d import Res1D + + accounted_for = _MIKE_EXTENSIONS | _EPANET_EXTENSIONS | set(_UNSUPPORTED_EXTENSIONS) + + assert accounted_for == Res1D.get_supported_file_extensions() @pytest.mark.skipif( From 7219e7a652e505ff634b6de52c6d96429ae8b66e Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:43:57 +0200 Subject: [PATCH 09/27] Reject Res1D objects opened with a path mikeio1d resolves reach topology with str.endswith on Res1D.file_path, so a Res1D built from a Path raises AttributeError from three frames down, blaming an attribute the caller never touched. Say what is wrong up front instead. Re-opening it ourselves would discard whatever filters the caller set on their object. Co-Authored-By: Claude Opus 5 --- src/modelskill/network.py | 20 +++++++++++++++++++- tests/test_network.py | 14 ++++++++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index a3d555d6d..43b371e33 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -59,6 +59,23 @@ } +def _check_file_path_is_str(res: Res1D) -> None: + """Reject a Res1D opened with a path object rather than a string. + + mikeio1d resolves reach topology with ``str.endswith`` on + ``Res1D.file_path``, which raises ``AttributeError`` from deep inside the + load when that attribute is a ``Path``. Fail here instead, where the cause + can be named. + """ + file_path = getattr(res, "file_path", None) + if file_path is not None and not isinstance(file_path, str): + raise TypeError( + f"This Res1D was opened with a {type(file_path).__name__} file_path, " + "which mikeio1d cannot resolve reach topology from. Re-open it as " + "Res1D(str(path)), or pass the path to the constructor directly." + ) + + class NetworkNode(ABC): """Abstract base class for a node in a network. @@ -478,7 +495,8 @@ def _from_mikeio1d( cls._validate_extension(path.suffix, allowed=allowed, caller=caller) res = _Res1D(str(path)) elif isinstance(res, _Res1D): - suffix = Path(str(res.file_path)).suffix + _check_file_path_is_str(res) + suffix = Path(res.file_path).suffix cls._validate_extension(suffix, allowed=allowed, caller=caller) else: raise TypeError( diff --git a/tests/test_network.py b/tests/test_network.py index dc7e5814d..6b2b72f59 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -2,6 +2,7 @@ # ruff: noqa: E402 import sys +from pathlib import Path import pytest pytest.importorskip("networkx") @@ -709,6 +710,19 @@ def test_from_res1d_accepts_open_res1d_object(): assert network.graph.number_of_nodes() == 259 +@pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) +def test_from_res1d_rejects_res1d_opened_with_a_path(): + """mikeio1d calls str.endswith on file_path, so a Path breaks it later on.""" + from mikeio1d import Res1D + + res = Res1D(Path("./tests/testdata/network.res1d")) + + with pytest.raises(TypeError, match="file_path"): + Network.from_res1d(res) + + @pytest.mark.skipif( sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" ) From e7d6845ddac6d3e958b18b5f9a6ce7e44c722b77 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:46:45 +0200 Subject: [PATCH 10/27] Add Network.from_mike and Network.from_epanet Name the constructors after the products that write the files, so the method list is the format list. Each is a docstring and one delegating call; passing a file the other one reads raises a ValueError naming it. from_epanet documents the link-node caveats, and the tests assert them: zero-length edges, no breakpoints, and ReachObservation therefore not being matchable against an EPANET network. Co-Authored-By: Claude Opus 5 --- src/modelskill/network.py | 153 ++++++++++++++++++++++++++++++++++++++ tests/test_network.py | 103 +++++++++++++++++++++++++ 2 files changed, 256 insertions(+) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 43b371e33..879b0b53f 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -394,6 +394,159 @@ def __repr__(self) -> str: ] return "\n".join(out) + @classmethod + def from_mike( + cls, + res: str | Path | Res1D, + *, + nodes: str | list[str] | None = None, + reaches: str | list[str] | None = None, + ) -> Network: + """Create a Network from a MIKE 1D or MIKE 11 result file. + + Parameters + ---------- + res : str, Path or Res1D + Path to a ``.res1d`` or ``.res11`` file, or an already-opened + :class:`mikeio1d.Res1D` object. + nodes : str, list of str, or None, optional + Controls which nodes have their timeseries data loaded into memory. + + * ``None`` *(default)* — data is loaded for every node. + * A single node ID or a list of node IDs — only those nodes get + data; others are topology-only. + * ``[]`` (empty list) — no node data is loaded at all. + + The full network topology is always constructed regardless of this + setting, so ``find()`` and ``recall()`` still work on all nodes. + reaches : str, list of str, or None, optional + Controls which reaches have their intermediate gridpoint data + populated. + + * ``None`` *(default)* — gridpoints are populated for every reach. + * A single reach name or a list of reach names — only those reaches + get gridpoint data; others are topology-only. + * ``[]`` (empty list) — no gridpoint data is loaded at all. + + Returns + ------- + Network + + Raises + ------ + NotImplementedError + If the file extension is not one modelskill can read. + ValueError + If the extension belongs to another constructor, such as EPANET. + + Examples + -------- + Load everything (default behaviour): + + >>> from modelskill.network import Network + >>> network = Network.from_mike("model.res1d") + + Load data only for the two nodes where observations exist, and skip + all intermediate gridpoint data to keep memory usage low: + + >>> network = Network.from_mike( + ... "model.res1d", + ... nodes=["node_a", "node_b"], + ... reaches=[], + ... ) + + Load data for selected nodes and gridpoints for one specific reach: + + >>> network = Network.from_mike( + ... "model.res1d", + ... nodes=["node_a", "node_b"], + ... reaches=["reach_1"], + ... ) + + Notes + ----- + MIKE 11 keeps its timeseries on reach gridpoints rather than on nodes, + so the nodes of a ``.res11`` network carry no data of their own. Pass + ``reaches`` rather than ``nodes`` to control what gets loaded. + + See Also + -------- + from_epanet : Read an EPANET result file. + """ + return cls._from_mikeio1d( + res, + nodes=nodes, + reaches=reaches, + allowed=_MIKE_EXTENSIONS, + caller="from_mike", + ) + + @classmethod + def from_epanet( + cls, + res: str | Path | Res1D, + *, + nodes: str | list[str] | None = None, + reaches: str | list[str] | None = None, + ) -> Network: + """Create a Network from an EPANET result file. + + Parameters + ---------- + res : str, Path or Res1D + Path to a ``.res`` file, or an already-opened + :class:`mikeio1d.Res1D` object. + nodes : str, list of str, or None, optional + Which nodes get their timeseries loaded. See :meth:`from_mike`. + reaches : str, list of str, or None, optional + Which reaches get their gridpoint data loaded. See + :meth:`from_mike`. EPANET results have no intermediate gridpoints, + so this argument has no effect. + + Returns + ------- + Network + + Raises + ------ + NotImplementedError + If the file extension is not one modelskill can read. + ValueError + If the extension belongs to another constructor, such as MIKE. + + Examples + -------- + >>> from modelskill.network import Network + >>> network = Network.from_epanet("model.res") + + Notes + ----- + EPANET is a link-node model, and mikeio1d reports no length and a + single synthetic gridpoint for each of its reaches. As a result: + + * every edge of :attr:`graph` has ``length=0``, so graph algorithms + weighted by length are meaningless + * reaches have no breakpoints, so + :class:`~modelskill.obs.ReachObservation` cannot be matched against + an EPANET network — use :class:`~modelskill.obs.NodeObservation` + * ``find(reach=..., distance=)`` never resolves; only + ``distance="start"`` and ``distance="end"`` work + + Node timeseries, :meth:`to_dataframe`, :meth:`to_dataset`, + ``find(node=...)`` and :meth:`recall` are unaffected. + + See Also + -------- + from_mike : Read a MIKE 1D or MIKE 11 result file. + """ + return cls._from_mikeio1d( + res, + nodes=nodes, + reaches=reaches, + allowed=_EPANET_EXTENSIONS, + caller="from_epanet", + ) + @classmethod def from_res1d( cls, diff --git a/tests/test_network.py b/tests/test_network.py index 6b2b72f59..231d9edb2 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -979,3 +979,106 @@ def test_both_nodes_missing_raises(self): def test_mismatched_start_node_still_raises(self): with pytest.raises(ValueError, match="Incorrect starting node"): Res1DReach(_StubReach(), Res1DNode("wrong"), Res1DNode("b")) + + +# --------------------------------------------------------------------------- +# from_mike / from_epanet +# --------------------------------------------------------------------------- + +requires_mikeio1d = pytest.mark.skipif( + sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" +) + + +@requires_mikeio1d +class TestFromMike: + def test_res1d(self): + network = Network.from_mike("./tests/testdata/network.res1d") + + assert network.graph.number_of_nodes() == 259 + + def test_res11(self): + """MIKE 11 keeps its data on gridpoints, so its nodes are empty.""" + network = Network.from_mike("./tests/testdata/network_cali.res11") + + assert len(network._reaches) == 3 + assert network.graph.number_of_nodes() == 71 + assert set(network.quantities) == {"Discharge", "Water Level"} + assert [r.n_breakpoints for r in network._reaches.values()] == [23, 21, 23] + + def test_res11_reaches_have_real_lengths(self): + network = Network.from_mike("./tests/testdata/network_cali.res11") + + lengths = [d["length"] for *_, d in network.graph.edges(data=True)] + assert all(length > 0 for length in lengths) + + def test_open_res1d_object(self): + from mikeio1d import Res1D + + res = Res1D("./tests/testdata/network.res1d") + + network = Network.from_mike(res, nodes=[], reaches=[]) + + assert network.graph.number_of_nodes() == 259 + + def test_epanet_file_is_redirected(self): + with pytest.raises(ValueError, match=r"Use Network\.from_epanet\(\)"): + Network.from_mike("./tests/testdata/epanet.res") + + def test_unknown_extension(self): + with pytest.raises(NotImplementedError, match="Unsupported file extension"): + Network.from_mike("./tests/testdata/obs.dfs0") + + def test_unsupported_type(self): + with pytest.raises(TypeError, match="Expected a str, Path or Res1D object"): + Network.from_mike(42) # type: ignore[arg-type] + + +@requires_mikeio1d +class TestFromEpanet: + def test_epanet(self): + network = Network.from_epanet("./tests/testdata/epanet.res") + + assert network.graph.number_of_nodes() == 11 + assert len(network._reaches) == 13 + assert set(network.quantities) == { + "Demand", + "Head", + "Pressure", + "WaterQuality", + } + assert not network.to_dataframe().empty + + def test_link_node_reaches_have_no_length_or_breakpoints(self): + """mikeio1d reports neither for a link-node model - documented in the docstring.""" + network = Network.from_epanet("./tests/testdata/epanet.res") + + lengths = [d["length"] for *_, d in network.graph.edges(data=True)] + assert lengths and all(length == 0 for length in lengths) + assert all(r.n_breakpoints == 0 for r in network._reaches.values()) + + def test_reach_observation_cannot_be_matched(self, sample_node_data): + """Follows from having no breakpoints; also documented in the docstring.""" + network = Network.from_epanet("./tests/testdata/epanet.res") + nmr = NetworkModelResult(network, item="Pressure") + obs = ms.ReachObservation(sample_node_data, reach="10", item="WaterLevel") + + with pytest.raises(ValueError, match="breakpoints"): + nmr.extract(obs) + + def test_mike_file_is_redirected(self): + with pytest.raises(ValueError, match=r"Use Network\.from_mike\(\)"): + Network.from_epanet("./tests/testdata/network.res1d") + + def test_open_res1d_object_is_validated(self): + from mikeio1d import Res1D + + res = Res1D("./tests/testdata/network.res1d") + + with pytest.raises(ValueError, match=r"Use Network\.from_mike\(\)"): + Network.from_epanet(res) + + @pytest.mark.parametrize("suffix", [".res", ".RES"]) + def test_extension_is_case_insensitive(self, tmp_path, suffix): + with pytest.raises((FileExistsError, FileNotFoundError)): + Network.from_epanet(tmp_path / f"network{suffix}") From 58e6f8d50fa5dd36e969b690afc7e3f0b0dd41b1 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:55:37 +0200 Subject: [PATCH 11/27] Remove Network.from_res1d in favour of the per-product constructors The name promised one format while the method read nine, which is what prompted the split. Callers move to from_mike, which reads exactly the .res1d files the old name referred to. Removed outright rather than deprecated: it only ever shipped in the 1.4.0a3 alpha, and the network module is opt-in and absent from the API reference. Co-Authored-By: Claude Opus 5 --- notebooks/Collection_systems_network.ipynb | 4 +- src/modelskill/model/network.py | 2 +- src/modelskill/network.py | 80 +--------- tests/notebooks/test_notebooks.py | 2 +- tests/test_network.py | 176 +++++++-------------- 5 files changed, 67 insertions(+), 197 deletions(-) diff --git a/notebooks/Collection_systems_network.ipynb b/notebooks/Collection_systems_network.ipynb index a79d769fe..89e9962e0 100644 --- a/notebooks/Collection_systems_network.ipynb +++ b/notebooks/Collection_systems_network.ipynb @@ -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", @@ -89,7 +89,7 @@ } ], "source": [ - "network = Network.from_res1d(\"../tests/testdata/network.res1d\")\n", + "network = Network.from_mike(\"../tests/testdata/network.res1d\")\n", "network" ] }, diff --git a/src/modelskill/model/network.py b/src/modelskill/model/network.py index 17c62035c..328c1cdea 100644 --- a/src/modelskill/model/network.py +++ b/src/modelskill/model/network.py @@ -197,7 +197,7 @@ def _extract_node(self, observation: NodeObservation) -> NodeModelResult: raise ValueError( f"Node {node_id} exists in the network topology but its timeseries was not loaded. " f"Re-create the NetworkModelResult with the relevant nodes populated, " - f"e.g. Network.from_res1d(nodes=[...])." + f"e.g. Network.from_mike(path, nodes=[...])." ) return NodeModelResult( diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 879b0b53f..c065b2c98 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -547,75 +547,6 @@ def from_epanet( caller="from_epanet", ) - @classmethod - def from_res1d( - cls, - res: str | Path | Res1D, - *, - nodes: str | list[str] | None = None, - reaches: str | list[str] | None = None, - ) -> Network: - """Create a Network from a Res1D file or object. - - Parameters - ---------- - res : str, Path or Res1D - Path to a network result file, or an already-opened - :class:`mikeio1d.Res1D` object. Any file extension that mikeio1d - can read is accepted (.res1d, .res11, .res, .prf, .crf, .xrf, - .out, .whr, .resx). - nodes : str, list of str, or None, optional - Controls which nodes have their timeseries data loaded into memory. - - * ``None`` *(default)* — data is loaded for every node. - * A single node ID or a list of node IDs — only those nodes get - data; others are topology-only. - * ``[]`` (empty list) — no node data is loaded at all. - - The full network topology is always constructed regardless of this - setting, so ``find()`` and ``recall()`` still work on all nodes. - reaches : str, list of str, or None, optional - Controls which reaches have their intermediate gridpoint data - populated. - - * ``None`` *(default)* — gridpoints are populated for every reach. - * A single reach name or a list of reach names — only those reaches - get gridpoint data; others are topology-only. - * ``[]`` (empty list) — no gridpoint data is loaded at all. - - Returns - ------- - Network - - Examples - -------- - Load everything (default behaviour): - - >>> from modelskill.network import Network - >>> network = Network.from_res1d("model.res1d") - - Load data only for the two nodes where observations exist, and skip - all intermediate gridpoint data to keep memory usage low: - - >>> network = Network.from_res1d( - ... "model.res1d", - ... nodes=["node_a", "node_b"], - ... reaches=[], - ... ) - - Load data for selected nodes and gridpoints for one specific reach: - - >>> network = Network.from_res1d( - ... "model.res1d", - ... nodes=["node_a", "node_b"], - ... reaches=["reach_1"], - ... ) - """ - - return cls._from_mikeio1d( - res, nodes=nodes, reaches=reaches, allowed=None, caller="from_res1d" - ) - @classmethod def _from_mikeio1d( cls, @@ -623,16 +554,15 @@ def _from_mikeio1d( *, nodes: str | list[str] | None, reaches: str | list[str] | None, - allowed: frozenset[str] | None, + allowed: frozenset[str], caller: str, ) -> Network: """Shared implementation behind the public ``from_*`` constructors. Parameters ---------- - allowed : frozenset of str or None - Extensions this constructor accepts, or None to accept everything - mikeio1d can read. + allowed : frozenset of str + Extensions this constructor accepts. caller : str Name of the public method, used in error messages. """ @@ -675,7 +605,7 @@ def _from_mikeio1d( @staticmethod def _validate_extension( - suffix: str, *, allowed: frozenset[str] | None, caller: str + suffix: str, *, allowed: frozenset[str], caller: str ) -> None: """Check a file extension against mikeio1d and against one constructor. @@ -705,7 +635,7 @@ def _validate_extension( f"Supported extensions are {readable}." ) - if allowed is not None and extension not in allowed: + if extension not in allowed: constructor = _EXTENSION_CONSTRUCTORS[extension] raise ValueError( f"Network.{caller}() reads {sorted(allowed)} files, got '{suffix}'. " diff --git a/tests/notebooks/test_notebooks.py b/tests/notebooks/test_notebooks.py index e44be8975..8b6fd62dc 100644 --- a/tests/notebooks/test_notebooks.py +++ b/tests/notebooks/test_notebooks.py @@ -8,7 +8,7 @@ _TEST_DIR = os.path.dirname(os.path.abspath(__file__)) PARENT_DIR = os.path.join(_TEST_DIR, "../..") SKIP_LIST = ["Download", "Metocean_track_comparison_global", "Metrics_widget", "Collection_systems_network"] -# We skip Collection_systems_network.ipynb since it uses Network.from_res1d() which uses pythonnet and, currently, it does not support python 3.14 +# We skip Collection_systems_network.ipynb since it uses Network.from_mike() which uses pythonnet and, currently, it does not support python 3.14 def _process_notebook(notebook_filename, notebook_path="notebooks"): diff --git a/tests/test_network.py b/tests/test_network.py index 231d9edb2..fd6d9851b 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -452,7 +452,7 @@ def test_matching_workflow_multiple_nodes(self, sample_network, sample_node_data ) def test_open_res1d(): path_to_file = "./tests/testdata/network.res1d" - network = Network.from_res1d(path_to_file) + network = Network.from_mike(path_to_file) assert network.graph.number_of_nodes() == 259 @@ -461,7 +461,7 @@ def test_open_res1d(): ) def test_extract_reach_observation_happy_path(sample_node_data): path_to_file = "./tests/testdata/network.res1d" - network = Network.from_res1d(path_to_file) + network = Network.from_mike(path_to_file) nmr = NetworkModelResult(network, item="Discharge", name="network_model") obs_data = sample_node_data.rename(columns={"WaterLevel": "Discharge"}) obs = ms.ReachObservation(obs_data, reach="100l1", item="Discharge") @@ -478,7 +478,7 @@ def test_extract_reach_observation_happy_path(sample_node_data): ) def test_extract_reach_observation_non_equivalent_breakpoints_raises(sample_node_data): path_to_file = "./tests/testdata/network.res1d" - network = Network.from_res1d(path_to_file) + network = Network.from_mike(path_to_file) nmr = NetworkModelResult(network, item="Discharge") obs_data = sample_node_data.rename(columns={"WaterLevel": "Discharge"}) obs = ms.ReachObservation(obs_data, reach="113l1", item="Discharge") @@ -494,7 +494,7 @@ def test_extract_reach_observation_with_reaches_not_populated_raises_valueerror( sample_node_data, ): path_to_file = "./tests/testdata/network.res1d" - network = Network.from_res1d(path_to_file, reaches=[]) + network = Network.from_mike(path_to_file, reaches=[]) nmr = NetworkModelResult(network, item="WaterLevel") obs = ms.ReachObservation(sample_node_data, reach="100l1", item="WaterLevel") @@ -509,7 +509,7 @@ def test_extract_reach_observation_breakpoint_node_missing_raises_valueerror( sample_node_data, ): path_to_file = "./tests/testdata/network.res1d" - network = Network.from_res1d(path_to_file) + network = Network.from_mike(path_to_file) nmr = NetworkModelResult(network, item="Discharge") obs_data = sample_node_data.rename(columns={"WaterLevel": "Discharge"}) baseline_obs = ms.ReachObservation(obs_data, reach="100l1", item="Discharge") @@ -530,13 +530,13 @@ def test_extract_reach_observation_breakpoint_node_missing_raises_valueerror( @pytest.mark.skipif( sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" ) -def test_from_res1d_nodes_filter_creates_full_network(): +def test_from_mike_nodes_filter_creates_full_network(): """When nodes is specified, the full network topology is created.""" path_to_file = "./tests/testdata/network.res1d" - full_network = Network.from_res1d(path_to_file) + full_network = Network.from_mike(path_to_file) selected_nodes = ["1", "108"] - partial_network = Network.from_res1d(path_to_file, nodes=selected_nodes) + partial_network = Network.from_mike(path_to_file, nodes=selected_nodes) # Full topology is preserved assert ( @@ -547,12 +547,12 @@ def test_from_res1d_nodes_filter_creates_full_network(): @pytest.mark.skipif( sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" ) -def test_from_res1d_nodes_filter_only_selected_have_data(): +def test_from_mike_nodes_filter_only_selected_have_data(): """When nodes is specified, only selected nodes contain non-empty data.""" path_to_file = "./tests/testdata/network.res1d" selected_nodes = ["1", "108"] - network = Network.from_res1d(path_to_file, nodes=selected_nodes, reaches=[]) + network = Network.from_mike(path_to_file, nodes=selected_nodes, reaches=[]) g = network.graph.copy() n_nodes = network.graph.number_of_nodes() @@ -564,12 +564,12 @@ def test_from_res1d_nodes_filter_only_selected_have_data(): @pytest.mark.skipif( sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" ) -def test_from_res1d_nodes_single_string(): +def test_from_mike_nodes_single_string(): """nodes argument accepts a single string (not just a list).""" path_to_file = "./tests/testdata/network.res1d" - full_network = Network.from_res1d(path_to_file) + full_network = Network.from_mike(path_to_file) - network = Network.from_res1d(path_to_file, nodes="108", reaches=[]) + network = Network.from_mike(path_to_file, nodes="108", reaches=[]) g = network.graph.copy() assert g.number_of_nodes() == full_network.graph.number_of_nodes() @@ -586,7 +586,7 @@ def test_dataframe_from_partial_network(): """nodes argument accepts a single string (not just a list).""" path_to_file = "./tests/testdata/network.res1d" selected_nodes = ["108", "101"] - network = Network.from_res1d(path_to_file, nodes=selected_nodes, reaches=[]) + network = Network.from_mike(path_to_file, nodes=selected_nodes, reaches=[]) nodes_in_df = network.to_dataframe().droplevel(axis=1, level=1).columns assert set(nodes_in_df) == set([network.find(n) for n in selected_nodes]) @@ -595,10 +595,10 @@ def test_dataframe_from_partial_network(): @pytest.mark.skipif( sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" ) -def test_from_res1d_empty_nodes_and_reaches_keeps_topology_and_empty_outputs(): +def test_from_mike_empty_nodes_and_reaches_keeps_topology_and_empty_outputs(): path_to_file = "./tests/testdata/network.res1d" - full_network = Network.from_res1d(path_to_file) - network = Network.from_res1d(path_to_file, nodes=[], reaches=[]) + full_network = Network.from_mike(path_to_file) + network = Network.from_mike(path_to_file, nodes=[], reaches=[]) assert network.graph.number_of_nodes() == full_network.graph.number_of_nodes() @@ -614,121 +614,61 @@ def test_from_res1d_empty_nodes_and_reaches_keeps_topology_and_empty_outputs(): # --------------------------------------------------------------------------- -# from_res1d — input validation +# Which extensions each constructor accepts, and why the rest are refused # --------------------------------------------------------------------------- @pytest.mark.skipif( sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" ) -@pytest.mark.parametrize( - "suffix", - [ - ".res1d", # MIKE 1D - ".res11", # MIKE 11 - ".res", # EPANET - ".RES1D", # extension check is case-insensitive - ], -) -def test_from_res1d_accepts_readable_extensions(tmp_path, suffix): - """A readable extension gets past the extension guard. - - The file does not exist, so mikeio1d - not the guard - is what complains. - """ - missing_file = tmp_path / f"network{suffix}" - - with pytest.raises((FileExistsError, FileNotFoundError)): - Network.from_res1d(missing_file) - - -@pytest.mark.skipif( - sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" -) -def test_from_res1d_rejects_unsupported_extension(): - with pytest.raises(NotImplementedError, match="Unsupported file extension"): - Network.from_res1d("./tests/testdata/obs.dfs0") - - -@pytest.mark.skipif( - sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" -) -def test_from_res1d_error_lists_only_readable_extensions(): - with pytest.raises(NotImplementedError) as excinfo: - Network.from_res1d("network.nc") - - message = str(excinfo.value) - for extension in _MIKE_EXTENSIONS | _EPANET_EXTENSIONS: - assert extension in message - for extension in _UNSUPPORTED_EXTENSIONS: - assert extension not in message - - -@pytest.mark.skipif( - sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" -) -@pytest.mark.parametrize( - "filename", - ["./tests/testdata/epanet.resx", "./tests/testdata/swmm.out"], -) -def test_formats_without_reach_connectivity_are_rejected(filename): - """Real files, so these fail if mikeio1d ever starts exposing connectivity.""" - with pytest.raises(NotImplementedError, match="reach start/end nodes"): - Network.from_res1d(filename) - - -@pytest.mark.skipif( - sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" -) -@pytest.mark.parametrize("suffix", [".prf", ".crf", ".xrf", ".whr"]) -def test_formats_without_a_fixture_are_rejected(tmp_path, suffix): - with pytest.raises(NotImplementedError, match="no test fixture"): - Network.from_res1d(tmp_path / f"network{suffix}") - - -@pytest.mark.skipif( - sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" -) -def test_every_mikeio1d_extension_is_accounted_for(): - """A new mikeio1d format must be read or explicitly refused, never ignored.""" - from mikeio1d import Res1D - - accounted_for = _MIKE_EXTENSIONS | _EPANET_EXTENSIONS | set(_UNSUPPORTED_EXTENSIONS) - - assert accounted_for == Res1D.get_supported_file_extensions() - - -@pytest.mark.skipif( - sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" -) -def test_from_res1d_accepts_open_res1d_object(): - from mikeio1d import Res1D - - res = Res1D("./tests/testdata/network.res1d") +class TestExtensionPolicy: + @pytest.mark.parametrize("suffix", [".res1d", ".res11", ".RES1D"]) + def test_from_mike_accepts_mike_extensions(self, tmp_path, suffix): + """The file does not exist, so mikeio1d - not the guard - is what complains.""" + with pytest.raises((FileExistsError, FileNotFoundError)): + Network.from_mike(tmp_path / f"network{suffix}") - network = Network.from_res1d(res, nodes=[], reaches=[]) + def test_error_lists_only_readable_extensions(self): + with pytest.raises(NotImplementedError) as excinfo: + Network.from_mike("network.nc") - assert network.graph.number_of_nodes() == 259 + message = str(excinfo.value) + for extension in _MIKE_EXTENSIONS | _EPANET_EXTENSIONS: + assert extension in message + for extension in _UNSUPPORTED_EXTENSIONS: + assert extension not in message + @pytest.mark.parametrize( + "filename", ["./tests/testdata/epanet.resx", "./tests/testdata/swmm.out"] + ) + def test_formats_without_reach_connectivity_are_refused(self, filename): + """Real files, so these fail if mikeio1d ever starts exposing connectivity.""" + with pytest.raises(NotImplementedError, match="reach start/end nodes"): + Network.from_mike(filename) + + @pytest.mark.parametrize("suffix", [".prf", ".crf", ".xrf", ".whr"]) + def test_formats_without_a_fixture_are_refused(self, tmp_path, suffix): + with pytest.raises(NotImplementedError, match="no test fixture"): + Network.from_mike(tmp_path / f"network{suffix}") + + def test_every_mikeio1d_extension_is_accounted_for(self): + """A new mikeio1d format must be read or explicitly refused, never ignored.""" + from mikeio1d import Res1D -@pytest.mark.skipif( - sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" -) -def test_from_res1d_rejects_res1d_opened_with_a_path(): - """mikeio1d calls str.endswith on file_path, so a Path breaks it later on.""" - from mikeio1d import Res1D + accounted_for = ( + _MIKE_EXTENSIONS | _EPANET_EXTENSIONS | set(_UNSUPPORTED_EXTENSIONS) + ) - res = Res1D(Path("./tests/testdata/network.res1d")) + assert accounted_for == Res1D.get_supported_file_extensions() - with pytest.raises(TypeError, match="file_path"): - Network.from_res1d(res) + def test_res1d_opened_with_a_path_is_refused(self): + """mikeio1d calls str.endswith on file_path, so a Path breaks it later on.""" + from mikeio1d import Res1D + res = Res1D(Path("./tests/testdata/network.res1d")) -@pytest.mark.skipif( - sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14" -) -def test_from_res1d_rejects_unsupported_type(): - with pytest.raises(TypeError, match="Expected a str, Path or Res1D object"): - Network.from_res1d(42) # type: ignore[arg-type] + with pytest.raises(TypeError, match="file_path"): + Network.from_mike(res) # --------------------------------------------------------------------------- From accb859841d13061f26b4d00dd9923e96734a0c3 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 11:57:14 +0200 Subject: [PATCH 12/27] Document the per-product network constructors The user guide claimed Res1D was the only supported format. Replace that with the constructor table, the reasons the other mikeio1d formats are refused, runnable MIKE 11 and EPANET examples, and a callout for the EPANET link-node caveats. ADR-012 records the naming decision and the rule that a constructor requires a fixture. Co-Authored-By: Claude Opus 5 --- adr/012-network-format-constructors.md | 69 ++++++++++++++++++++++++++ adr/README.md | 1 + docs/user-guide/network.qmd | 60 ++++++++++++++++++---- roadmap/features/network-models.md | 6 ++- 4 files changed, 123 insertions(+), 13 deletions(-) create mode 100644 adr/012-network-format-constructors.md diff --git a/adr/012-network-format-constructors.md b/adr/012-network-format-constructors.md new file mode 100644 index 000000000..2c66147e5 --- /dev/null +++ b/adr/012-network-format-constructors.md @@ -0,0 +1,69 @@ +# ADR-012: One Network Constructor per Modelling Product + +**Status**: Draft + +**Date**: 2026-08 + +## Context + +`Network` is built from result files read through mikeio1d. Its 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. That made the name misleading: it promised one format and read nine. + +Loading each of mikeio1d's own fixtures showed the nine are not interchangeable: + +- `.res1d` and `.res11` produce a full network with real reach lengths and gridpoints. `.res11` initially failed because MIKE 11 keeps its timeseries on reach gridpoints, leaving nodes with no quantities at all — a bug in modelskill's adapter, now fixed. +- `.res` (EPANET) loads, but as a link-node model it reports no reach length and one synthetic gridpoint per reach. +- `.out` (SWMM) and `.resx` expose no reach start/end nodes. There is no topology to rebuild. mikeio1d's own SWMM tests never touch reach connectivity. +- MOUSE and `.whr` have no test fixture anywhere, including in mikeio1d's testdata, so nothing about them can be verified. + +## Decision + +Name constructors after the product that writes the file, and only ship one where a committed fixture backs it: + +| Constructor | Extensions | +|---|---| +| `Network.from_mike` | `.res1d`, `.res11` | +| `Network.from_epanet` | `.res` | + +`from_res1d` is removed. It only ever shipped in the 1.4.0a3 alpha, and the network module is opt-in and absent from the API reference, so a deprecation shim would have added a second name for the tested path without protecting a real caller. + +Every extension mikeio1d can read 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 specific reason. A test asserts the three cover exactly `Res1D.get_supported_file_extensions()`, so a mikeio1d release that adds a tenth format fails CI and forces a decision rather than leaving the format silently unreachable. + +Two supporting rules: + +- **A constructor requires a fixture.** Naming a product in the API is a support claim; it should be backed by a test that builds a `Network` from a real file of that product. MOUSE and Water Hammer are refused today for exactly this reason, and each becomes a six-line addition once a redistributable fixture exists. +- **Degenerate results are documented, not warned about.** EPANET's zero-length reaches and absent breakpoints are stated in the `from_epanet` docstring and the user guide, and asserted in tests. A runtime warning would fire on correct usage and teach users to filter our warnings, and both consequences already raise where they bite. + +## Alternatives Considered + +**One constructor per extension** — `from_res`, `from_out`, `from_whr` say nothing about the product they belong to, and MOUSE would need three identical methods. + +**A generic catch-all (`from_file` or `from_mikeio1d`)** — a second way to do the same thing. With every extension either read or explicitly refused, the catch-all's only remaining job is forward compatibility with formats mikeio1d adds later, which the drift test handles more usefully by demanding a decision. + +**Keep `from_res1d` permissive** — preserves the misleading name, and a constructor that accepts everything cannot tell an EPANET user which method to reach for instead. + +**Ship all five product constructors regardless of coverage** — three of the five would either always fail (SWMM) or be unverifiable, so the method list would stop being a reliable statement of what works. + +## Consequences + +Positive: + +- The method list is the format list; `Network.from_` answers "which formats does this read". +- Refusals name the cause, so the SWMM and `.resx` gaps read as upstream limitations rather than modelskill bugs. +- Passing a file the other constructor handles raises a `ValueError` naming that constructor. +- One private implementation (`Network._from_mikeio1d`) does the version guard, extension validation, `Res1D` construction and node/reach filtering, so a new product constructor is a docstring and one call. + +Negative: + +- MIKE 11 is covered by a fixture but has no field-tested usage behind it yet. +- MOUSE and Water Hammer are refused even though mikeio1d may well read them correctly. This is deliberate: refusing with a reason is recoverable, while a method that silently produces a wrong graph is not. + +## Relationship to ADR-009 + +[ADR-009](009-factory-pattern.md) argues for auto-detecting entry points such as `model_result()` and `observation()` so users need not know the class hierarchy. That is not in tension with this decision. Auto-detection resolves *which class* to build from the shape of the data; these constructors resolve *which product wrote the file*, which is information the call site should state rather than have guessed — particularly when the answer decides whether reach-based matching will work at all. + +## See Also + +- [ADR-010](010-optional-domain-dependencies.md) — why mikeio1d is an optional dependency +- `tests/testdata/README.md` — provenance of the result fixtures diff --git a/adr/README.md b/adr/README.md index eeff80d44..59b6d3bf2 100644 --- a/adr/README.md +++ b/adr/README.md @@ -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 diff --git a/docs/user-guide/network.qmd b/docs/user-guide/network.qmd index dd8ed055d..c70298316 100644 --- a/docs/user-guide/network.qmd +++ b/docs/user-guide/network.qmd @@ -132,18 +132,32 @@ 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` | EPANET | + +The remaining formats mikeio1d can open cannot be turned into a `Network`, and say so when you try: + +| Extension | Why not | +|---|---| +| `.out` (SWMM), `.resx` | mikeio1d does not report reach start/end nodes for these, so there is no topology to rebuild. | +| `.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" ``` @@ -151,7 +165,7 @@ 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 ``` @@ -161,18 +175,42 @@ 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) ``` -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. +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) +``` + +::: {.callout-warning} +## EPANET networks have no reach geometry + +EPANET is a link-node model, and mikeio1d reports no length and a single synthetic gridpoint for each reach. So for an EPANET network: + +* every edge of `network.graph` has `length=0`, which makes graph algorithms weighted by length meaningless +* reaches have no breakpoints, so a `ReachObservation` cannot be matched — use `NodeObservation` instead +* `find(reach=..., distance=)` never resolves; only `distance="start"` and `distance="end"` work + +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 | |---|---|---| @@ -186,7 +224,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=[], @@ -197,7 +235,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"], diff --git a/roadmap/features/network-models.md b/roadmap/features/network-models.md index b4b586662..fc7a7f96c 100644 --- a/roadmap/features/network-models.md +++ b/roadmap/features/network-models.md @@ -13,7 +13,7 @@ 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, reaches, or catchments - Apply the full suite of ModelSkill metrics and visualisations to network model validation - Compare multiple network model scenarios side by side @@ -21,4 +21,6 @@ This reduces the effort required to produce quality-assured model deliverables a ## Current Status -In active development. Reading of MIKE 1D result files is already supported. 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 cannot be supported until mikeio1d exposes reach connectivity for them. From dad26d780dca8ffaf8d87e4ae9720a95c1f4249e Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 13:19:21 +0200 Subject: [PATCH 13/27] Migrating to checkout@v6 (Node20 -> Node24) --- .github/workflows/full_test.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/full_test.yml b/.github/workflows/full_test.yml index 7881909ca..bd62ad2b4 100644 --- a/.github/workflows/full_test.yml +++ b/.github/workflows/full_test.yml @@ -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 @@ -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 @@ -58,7 +58,7 @@ jobs: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - uses: extractions/setup-just@v3 From 70002cabc53181990fdc6a6a4d4990f264d57bfe Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 13:23:25 +0200 Subject: [PATCH 14/27] Adding NotImplemented error for potential new mikeio1d format. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/modelskill/network.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index c065b2c98..f835ead83 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -636,7 +636,13 @@ def _validate_extension( ) if extension not in allowed: - constructor = _EXTENSION_CONSTRUCTORS[extension] + constructor = _EXTENSION_CONSTRUCTORS.get(extension) + if constructor is None: + raise NotImplementedError( + f"File extension '{suffix}' is supported by mikeio1d but is not mapped " + "to a Network constructor in this version of modelskill. " + "Please upgrade modelskill or open an issue." + ) raise ValueError( f"Network.{caller}() reads {sorted(allowed)} files, got '{suffix}'. " f"Use Network.{constructor}() instead." From 9103b1412ae1e8c1ea3f279cc7d502b37ebbf6a0 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 13:56:04 +0200 Subject: [PATCH 15/27] Make NetworkReach.length optional Reach length matters in river and sewer networks but not in link-node water distribution models, where no length exists to supply. Drop the abstractmethod so subclasses may omit it, default BasicReach's argument to None, and guard the one graph edge that needs the total length. --- src/modelskill/network.py | 59 +++++++++++++++++++++++++-------------- 1 file changed, 38 insertions(+), 21 deletions(-) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index f835ead83..730477eff 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -175,7 +175,7 @@ def data(self) -> pd.DataFrame: @property def distance(self) -> float: - """Along-reach distance of this break point (same units as :attr:`NetworkReach.length`).""" + """Along-reach distance of this break point, measured from the start node.""" return self.id[1] @property @@ -192,30 +192,31 @@ class NetworkReach(ABC): a list of :class:`ReachBreakPoint` objects for intermediate chainage locations. - Subclass this to integrate your own network topology. Five properties + Subclass this to integrate your own network topology. Four properties must be implemented: * :attr:`id` - a unique string identifier for the reach. * :attr:`start` - the upstream/start :class:`NetworkNode`. * :attr:`end` - the downstream/end :class:`NetworkNode`. - * :attr:`length` - total reach length (in the units of your coordinate - system). * :attr:`breakpoints` - list of :class:`ReachBreakPoint` instances ordered by increasing distance from the start node (empty list if none). + :attr:`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. + The concrete helper :class:`BasicReach` is provided for the common case where all data is already available in memory. Examples -------- - Minimal subclass: + Minimal subclass, without a length: >>> class MyReach(NetworkReach): - ... def __init__(self, rid, start_node, end_node, length): + ... def __init__(self, rid, start_node, end_node): ... self._id = rid ... self._start = start_node ... self._end = end_node - ... self._length = length ... @property ... def id(self): return self._id ... @property @@ -223,10 +224,17 @@ class NetworkReach(ABC): ... @property ... def end(self): return self._end ... @property - ... def length(self): return self._length - ... @property ... def breakpoints(self): return [] + Add a :attr:`length` property on top of that when the domain has one: + + >>> class MyMeasuredReach(MyReach): + ... def __init__(self, rid, start_node, end_node, length): + ... super().__init__(rid, start_node, end_node) + ... self._length = length + ... @property + ... def length(self): return self._length + See Also -------- BasicReach : Ready-to-use concrete implementation. @@ -254,10 +262,9 @@ def end(self) -> NetworkNode: pass @property - @abstractmethod - def length(self) -> float: - """Total length of this reach in network units.""" - pass + def length(self) -> float | None: + """Total length of this reach in network units, or ``None`` if undefined.""" + return None @property @abstractmethod @@ -324,14 +331,18 @@ class BasicReach(NetworkReach): Start node. end : NetworkNode End node. - length : float - Reach length. + length : float, optional + Reach length, by default None (undefined). breakpoints : list[ReachBreakPoint], optional Intermediate break points, by default empty. Examples -------- >>> reach = BasicReach("reach_1", node_a, node_b, length=250.0) + + Where the domain has no reach length, leave it out: + + >>> reach = BasicReach("pipe_1", node_a, node_b) """ def __init__( @@ -339,7 +350,7 @@ def __init__( id: str, start: NetworkNode, end: NetworkNode, - length: float, + length: float | None = None, breakpoints: list[ReachBreakPoint] | None = None, ) -> None: self._id = id @@ -361,7 +372,7 @@ def end(self) -> NetworkNode: return self._end @property - def length(self) -> float: + def length(self) -> float | None: return self._length @property @@ -790,11 +801,17 @@ def _generate_graph(reaches: Sequence[NetworkReach]) -> nx.Graph: g0.add_node(bp_key, data=bp.data) g0.add_edge(start_key, bp_keys[0], length=reach.breakpoints[0].distance) - g0.add_edge( - bp_keys[-1], - end_key, - length=reach.length - reach.breakpoints[-1].distance, + + # Only the final segment needs the total length. Break point + # distances are known even when the total is not, so a reach + # without a length still gets real lengths on every edge but + # this one. + tail_length = ( + None + if reach.length is None + else reach.length - reach.breakpoints[-1].distance ) + g0.add_edge(bp_keys[-1], end_key, length=tail_length) # 3) Connect consecutive intermediate breakpoints for i in range(reach.n_breakpoints - 1): From c0dff38ac4608d7313fca5fa43642c95452dfcda Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 13:57:36 +0200 Subject: [PATCH 16/27] Report mikeio1d's zero reach length as undefined mikeio1d returns 0 when it cannot read a reach length, which every EPANET reach hits. Surfacing that as a zero-length reach makes length-weighted graph algorithms treat the reach as free; None makes them raise instead. --- src/modelskill/model/adapters/_res1d.py | 10 ++++++++-- tests/test_network.py | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/modelskill/model/adapters/_res1d.py b/src/modelskill/model/adapters/_res1d.py index e5bd9d520..08b8a88d4 100644 --- a/src/modelskill/model/adapters/_res1d.py +++ b/src/modelskill/model/adapters/_res1d.py @@ -112,7 +112,13 @@ def __init__( self._start = start_node self._end = end_node - self._length = reach.length + + # mikeio1d returns 0 when it cannot read a reach length - link-node models + # such as EPANET report this for every reach. Report it as undefined rather + # than as a zero-length reach, which would make length-weighted graph + # algorithms treat the reach as free. The two cases cannot be told apart + # upstream. + self._length = reach.length if reach.length else None self._breakpoints: list[ReachBreakPoint] = [ GridPoint( gridpoint.reach_name, @@ -135,7 +141,7 @@ def end(self) -> Res1DNode: return self._end @property - def length(self) -> float: + def length(self) -> float | None: return self._length @property diff --git a/tests/test_network.py b/tests/test_network.py index fd6d9851b..ac54c40f4 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -994,7 +994,7 @@ def test_link_node_reaches_have_no_length_or_breakpoints(self): network = Network.from_epanet("./tests/testdata/epanet.res") lengths = [d["length"] for *_, d in network.graph.edges(data=True)] - assert lengths and all(length == 0 for length in lengths) + assert lengths and all(length is None for length in lengths) assert all(r.n_breakpoints == 0 for r in network._reaches.values()) def test_reach_observation_cannot_be_matched(self, sample_node_data): From 2b209225e9ece0785fe939691320b373eca3a3f1 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 14:00:19 +0200 Subject: [PATCH 17/27] Test optional and undefined reach length Covers a NetworkReach subclass that omits length, the None edge attribute it produces, break point distances surviving an undefined total, and the mikeio1d zero sentinel becoming None. --- tests/test_network.py | 130 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 130 insertions(+) diff --git a/tests/test_network.py b/tests/test_network.py index ac54c40f4..f8819d669 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -24,6 +24,8 @@ Network, BasicNode, BasicReach, + NetworkReach, + ReachBreakPoint, _EPANET_EXTENSIONS, _MIKE_EXTENSIONS, _UNSUPPORTED_EXTENSIONS, @@ -613,6 +615,119 @@ def test_from_mike_empty_nodes_and_reaches_keeps_topology_and_empty_outputs(): assert len(ds.data_vars) == 0 +# --------------------------------------------------------------------------- +# Optional reach length +# --------------------------------------------------------------------------- + + +class _StubBreakPoint(ReachBreakPoint): + """Minimal concrete ReachBreakPoint for building reaches by hand.""" + + def __init__(self, reach_id, distance, data=None): + self._id = (reach_id, distance) + self._data = pd.DataFrame() if data is None else data + + @property + def id(self): + return self._id + + @property + def data(self): + return self._data + + +def _two_node_pair(): + time = pd.date_range("2020", periods=3, freq="h") + df = pd.DataFrame({"WaterLevel": [1.0, 1.1, 1.2]}, index=time) + return BasicNode("a", df), BasicNode("b", df.copy()) + + +class TestOptionalReachLength: + """Reach length is undefined in some domains, so it must be omittable.""" + + def test_subclass_may_omit_length(self): + class LengthlessReach(NetworkReach): + def __init__(self, id, start, end): + self._id, self._start, self._end = id, start, end + + @property + def id(self): + return self._id + + @property + def start(self): + return self._start + + @property + def end(self): + return self._end + + @property + def breakpoints(self): + return [] + + a, b = _two_node_pair() + reach = LengthlessReach("r1", a, b) + + assert reach.length is None + assert Network([reach]).graph.number_of_nodes() == 2 + + def test_basic_reach_length_defaults_to_none(self): + a, b = _two_node_pair() + + assert BasicReach("r1", a, b).length is None + + def test_edge_length_is_none_when_undefined(self): + a, b = _two_node_pair() + + network = Network([BasicReach("r1", a, b)]) + + assert [d["length"] for *_, d in network.graph.edges(data=True)] == [None] + + def test_breakpoint_distances_survive_an_undefined_length(self): + """Only the final segment needs the total, so the rest keep real lengths.""" + a, b = _two_node_pair() + breakpoints = [_StubBreakPoint("r1", d) for d in (30.0, 70.0)] + + network = Network([BasicReach("r1", a, b, breakpoints=breakpoints)]) + + lengths = sorted( + (d["length"] for *_, d in network.graph.edges(data=True)), + key=lambda v: (v is None, v), + ) + assert lengths == [30.0, 40.0, None] + + def test_length_weighted_algorithms_fail_loudly(self): + """Storing None keeps networkx honest. + + Omitting the attribute instead would let networkx default the weight to + 1, so every call below would return a plausible but meaningless number. + With None, shortest-path treats the edge as hidden and the arithmetic + consumers raise. + """ + import networkx as nx + + a, b = _two_node_pair() + g = Network([BasicReach("r1", a, b)]).graph + + with pytest.raises(nx.NetworkXNoPath): + nx.shortest_path_length(g, 0, 1, weight="length") + + with pytest.raises(TypeError): + g.size(weight="length") + + def test_known_length_is_unchanged(self): + a, b = _two_node_pair() + breakpoints = [_StubBreakPoint("r1", 40.0)] + + network = Network([BasicReach("r1", a, b, 100.0, breakpoints)]) + + assert sorted(d["length"] for *_, d in network.graph.edges(data=True)) == [ + 40.0, + 60.0, + ] + + # --------------------------------------------------------------------------- # Which extensions each constructor accepts, and why the rest are refused # --------------------------------------------------------------------------- @@ -921,6 +1036,21 @@ def test_mismatched_start_node_still_raises(self): Res1DReach(_StubReach(), Res1DNode("wrong"), Res1DNode("b")) +class TestRes1DReachLength: + """mikeio1d returns 0 when it cannot read a length; that is not a real zero.""" + + @pytest.mark.parametrize("reported", [0, 0.0]) + def test_zero_becomes_undefined(self, reported): + reach = Res1DReach(_StubReach(length=reported), Res1DNode("a"), Res1DNode("b")) + + assert reach.length is None + + def test_real_length_passes_through(self): + reach = Res1DReach(_StubReach(length=47.5), Res1DNode("a"), Res1DNode("b")) + + assert reach.length == 47.5 + + # --------------------------------------------------------------------------- # from_mike / from_epanet # --------------------------------------------------------------------------- From a6b5611f05049c3353ad485737a927b1a5d5f945 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 14:06:40 +0200 Subject: [PATCH 18/27] Document optional reach length Records why an unreadable length is surfaced as None rather than 0, and why the edge attribute is kept rather than omitted. --- adr/012-network-format-constructors.md | 5 +++-- docs/user-guide/network.qmd | 8 +++++--- src/modelskill/network.py | 4 ++-- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/adr/012-network-format-constructors.md b/adr/012-network-format-constructors.md index 2c66147e5..dfeca593e 100644 --- a/adr/012-network-format-constructors.md +++ b/adr/012-network-format-constructors.md @@ -13,7 +13,7 @@ modelskill's constructor was named `from_res1d`, and its extension guard was bri Loading each of mikeio1d's own fixtures showed the nine are not interchangeable: - `.res1d` and `.res11` produce a full network with real reach lengths and gridpoints. `.res11` initially failed because MIKE 11 keeps its timeseries on reach gridpoints, leaving nodes with no quantities at all — a bug in modelskill's adapter, now fixed. -- `.res` (EPANET) loads, but as a link-node model it reports no reach length and one synthetic gridpoint per reach. +- `.res` (EPANET) loads, but as a link-node model it reports no reach length and one synthetic gridpoint per reach. mikeio1d signals the missing length by returning `0`, which is indistinguishable from a genuine zero. - `.out` (SWMM) and `.resx` expose no reach start/end nodes. There is no topology to rebuild. mikeio1d's own SWMM tests never touch reach connectivity. - MOUSE and `.whr` have no test fixture anywhere, including in mikeio1d's testdata, so nothing about them can be verified. @@ -33,7 +33,8 @@ Every extension mikeio1d can read is accounted for in one of three module-level Two supporting rules: - **A constructor requires a fixture.** Naming a product in the API is a support claim; it should be backed by a test that builds a `Network` from a real file of that product. MOUSE and Water Hammer are refused today for exactly this reason, and each becomes a six-line addition once a redistributable fixture exists. -- **Degenerate results are documented, not warned about.** EPANET's zero-length reaches and absent breakpoints are stated in the `from_epanet` docstring and the user guide, and asserted in tests. A runtime warning would fire on correct usage and teach users to filter our warnings, and both consequences already raise where they bite. +- **Degenerate results are documented, not warned about.** EPANET's undefined reach lengths and absent breakpoints are stated in the `from_epanet` docstring and the user guide, and asserted in tests. A runtime warning would fire on correct usage and teach users to filter our warnings, and both consequences already raise where they bite. +- **An unreadable reach length is undefined, not zero.** `NetworkReach.length` is optional and defaults to `None`, and the adapter maps mikeio1d's `0` sentinel onto it. Reporting `0` would assert that an EPANET pipe has no extent, which is false — the length exists, mikeio1d just cannot read it — and it makes a length-weighted graph algorithm treat the reach as free to traverse. With `None`, `networkx` fails instead: shortest-path treats the edge as unreachable and weight-summing calls raise `TypeError`. Omitting the edge attribute altogether was rejected for the opposite reason, since `networkx` then defaults the weight to `1`. Nothing inside modelskill reads the length, so this only affects `Network.graph`; matching and extraction work from break point distances. ## Alternatives Considered diff --git a/docs/user-guide/network.qmd b/docs/user-guide/network.qmd index c70298316..3699f0fc1 100644 --- a/docs/user-guide/network.qmd +++ b/docs/user-guide/network.qmd @@ -195,7 +195,7 @@ Network.from_epanet(path_to_epanet) EPANET is a link-node model, and mikeio1d reports no length and a single synthetic gridpoint for each reach. So for an EPANET network: -* every edge of `network.graph` has `length=0`, which makes graph algorithms weighted by length meaningless +* 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` * reaches have no breakpoints, so a `ReachObservation` cannot be matched — use `NodeObservation` instead * `find(reach=..., distance=)` never resolves; only `distance="start"` and `distance="end"` work @@ -423,7 +423,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: @@ -490,7 +492,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. ::: diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 730477eff..0feec806f 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -535,8 +535,8 @@ def from_epanet( EPANET is a link-node model, and mikeio1d reports no length and a single synthetic gridpoint for each of its reaches. As a result: - * every edge of :attr:`graph` has ``length=0``, so graph algorithms - weighted by length are meaningless + * every edge of :attr:`graph` has ``length=None``, so a length-weighted + graph algorithm fails rather than returning a meaningless number * reaches have no breakpoints, so :class:`~modelskill.obs.ReachObservation` cannot be matched against an EPANET network — use :class:`~modelskill.obs.NodeObservation` From 605365675d95f5672a24848f9cc1fe88bd77997f Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 15:23:12 +0200 Subject: [PATCH 19/27] Add a minimal reader for EPANET/SWMM .inp files mikeio1d reads only the binary result formats, so the companion input file has to be parsed here. Both products share one layout, so the section reader is generic and only the [PIPES] interpretation is EPANET-specific. --- src/modelskill/model/adapters/_inp.py | 109 ++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) create mode 100644 src/modelskill/model/adapters/_inp.py diff --git a/src/modelskill/model/adapters/_inp.py b/src/modelskill/model/adapters/_inp.py new file mode 100644 index 000000000..329b2c92a --- /dev/null +++ b/src/modelskill/model/adapters/_inp.py @@ -0,0 +1,109 @@ +"""Minimal reader for EPANET and SWMM ``.inp`` input files. + +mikeio1d reads only the binary result formats, so the ``.inp`` that accompanies a +result file has to be parsed here. Both products use the same layout: bracketed +section headers, ``;``-prefixed comments (including the ``;;Name Node1 ...`` +column headers the products write), whitespace-delimited data rows, and blank +lines to ignore. + +Only the sections modelskill needs are interpreted; everything else is kept as +raw fields for a caller to use, or ignored. +""" + +from __future__ import annotations + +from pathlib import Path + + +def read_sections(path: str | Path) -> dict[str, list[list[str]]]: + """Parse an ``.inp`` file into its sections. + + Parameters + ---------- + path : str or Path + Path to an EPANET or SWMM ``.inp`` file. + + Returns + ------- + dict[str, list[list[str]]] + Section name (upper case, without brackets) mapped to its data rows, + each row split into whitespace-delimited fields. Comment-only and blank + lines are dropped, as is any trailing comment on a data row. + + Examples + -------- + >>> sections = read_sections("model.inp") # doctest: +SKIP + >>> sections["PIPES"][0] # doctest: +SKIP + ['10', '10', '11', '3209.544', '304.8', '100', '0', 'Open'] + """ + sections: dict[str, list[list[str]]] = {} + current: list[list[str]] | None = None + + with open(path, "r", encoding="utf-8", errors="replace") as f: + for line in f: + # A comment can trail a data row, so strip it before anything else. + line = line.split(";", 1)[0].strip() + if not line: + continue + + if line.startswith("["): + name = line.strip("[]").strip().upper() + current = sections.setdefault(name, []) + continue + + if current is not None: + current.append(line.split()) + + return sections + + +def read_pipe_lengths(path: str | Path) -> dict[str, float]: + """Read reach lengths from the ``[PIPES]`` section of an EPANET ``.inp``. + + Parameters + ---------- + path : str or Path + Path to an EPANET ``.inp`` file. + + Returns + ------- + dict[str, float] + Pipe ID mapped to its length. Pumps and valves are links too, but carry + no length, so they are absent from the result rather than present with a + placeholder. + + Raises + ------ + ValueError + If the file has no ``[PIPES]`` section, or a row there has too few + fields to read a length from. + + Notes + ----- + ``[PIPES]`` rows are ``ID Node1 Node2 Length Diameter Roughness ...``, so the + length is the fourth field. The units are whatever the model declares in + ``[OPTIONS]``; no conversion is applied. + """ + sections = read_sections(path) + + try: + rows = sections["PIPES"] + except KeyError: + raise ValueError( + f"'{path}' has no [PIPES] section, so it does not look like an " + "EPANET input file. Available sections: " + f"{sorted(sections)}." + ) + + _ID, _LENGTH = 0, 3 + lengths: dict[str, float] = {} + for row in rows: + if len(row) <= _LENGTH: + raise ValueError( + f"Cannot read a pipe length from [PIPES] row {' '.join(row)!r} " + f"in '{path}': expected at least {_LENGTH + 1} fields " + f"(ID, Node1, Node2, Length), got {len(row)}." + ) + lengths[row[_ID]] = float(row[_LENGTH]) + + return lengths From 420788b4dcddc850ed63ee230e1bd10fb0a189fa Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 15:23:39 +0200 Subject: [PATCH 20/27] Vendor the epanet.inp fixture Copied from mikeio1d at the same commit as the other vendored fixtures. Carries the pipe lengths the .res file does not. --- tests/testdata/README.md | 17 ++- tests/testdata/epanet.inp | 226 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 tests/testdata/epanet.inp diff --git a/tests/testdata/README.md b/tests/testdata/README.md index 9a96ab41b..a6f4a71c6 100644 --- a/tests/testdata/README.md +++ b/tests/testdata/README.md @@ -4,7 +4,7 @@ Most files here were produced for modelskill. The exceptions are listed below. ## From DHI/mikeio1d -These network result files come from +These network files come from [DHI/mikeio1d](https://github.com/DHI/mikeio1d/tree/main/tests/testdata) (commit `d937466`), copied unchanged. mikeio1d is MIT-licensed, as is modelskill. @@ -12,9 +12,14 @@ These network result files come from |---|---|---| | `network_cali.res11` | MIKE 11 | `Network.from_mike` coverage for `.res11` | | `epanet.res` | EPANET | `Network.from_epanet` coverage | -| `epanet.resx` | EPANET (MIKE+) | asserting `.resx` is rejected — mikeio1d exposes no reach connectivity for it | -| `swmm.out` | SWMM | asserting `.out` is rejected — mikeio1d cannot resolve reach start/end nodes for it | +| `epanet.resx` | EPANET (MIKE+) | the `resx=` companion — extra node quantities merged onto the `.res` network | +| `epanet.inp` | EPANET input | the `inp=` companion — real pipe lengths, which the `.res` does not carry | +| `swmm.out` | SWMM | asserting `.out` is refused — its reach connectivity lives in a companion `.inp` we do not read yet (#689) | -The last two exist to pin upstream behaviour: if a future mikeio1d exposes -connectivity for those formats, the rejection tests fail, which is when we would want -to add a constructor for them. +`epanet.resx` and `epanet.inp` pair with `epanet.res`: same run, same IDs. The +`.resx` node and reach IDs are a strict subset of the `.res` ones, and the `.inp` +`[PIPES]` IDs cover every `.res` reach except the pump. + +`swmm.out` is kept without its `.inp` on purpose. It pins the refusal, so the test +fails the day we add SWMM support or a future mikeio1d starts reporting reach +connectivity for it. diff --git a/tests/testdata/epanet.inp b/tests/testdata/epanet.inp new file mode 100644 index 000000000..2b42e3a37 --- /dev/null +++ b/tests/testdata/epanet.inp @@ -0,0 +1,226 @@ +;***************************************************** +;* Generated from MIKE+ * +;***************************************************** + +[TITLE] + +[JUNCTIONS] +;------------------------------------------------------ +;ID Elevation Demand Pattern +;------------------------------------------------------ +10 216.408000 +11 216.408000 +12 213.360000 +13 211.836000 +21 213.360000 +22 211.836000 +23 210.312000 +31 213.360000 +32 216.408000 + +[RESERVOIRS] +;------------------------------------------------------ +;ID Head Pattern +;------------------------------------------------------ +9 243.840000 + +[TANKS] +;--------------------------------------------------------------------------------------------------------- +;ID Elev. Init. Min. Max. Diam. MinVol VolCurve Overflow +; Level Level Level +;--------------------------------------------------------------------------------------------------------- +2 259.080000 36.576000 30.480000 45.720000 15.392400 0.000000 * No + +[PIPES] +;----------------------------------------------------------------- +;ID Head Tail Length Diam Rough. Minor CV +; Node Node +;----------------------------------------------------------------- +10 10 11 3209.544000 457.200000 100.000000 0.000000 +11 11 12 1609.344000 355.600000 100.000000 0.000000 +110 2 12 60.960000 457.200000 100.000000 0.000000 +111 11 21 1609.344000 254.000000 100.000000 0.000000 +112 12 22 1609.344000 304.800000 100.000000 0.000000 +113 13 23 1609.344000 203.200000 100.000000 0.000000 +12 12 13 1609.344000 254.000000 100.000000 0.000000 +121 21 31 1609.344000 203.200000 100.000000 0.000000 +122 22 32 1609.344000 152.400000 100.000000 0.000000 +21 21 22 1609.344000 254.000000 100.000000 0.000000 +22 22 23 1609.344000 304.800000 100.000000 0.000000 +31 31 32 1609.344000 152.400000 100.000000 0.000000 + +[VALVES] +;------------------------------------------------------ +; ID Head Tail Diam Type Setting (Losscoef) +; Node Node +;------------------------------------------------------ + +[PUMPS] +;------------------------------------------------------------------ +;ID Head Tail Properties +; Node Node +;------------------------------------------------------------------ +9 9 10 HEAD 1 + +[VSD_PUMPS] +;------------------------------------------------------------------------------------- +;Pump Node Setpoint Setpoint SetpointType Speed Speed ControlType +;ID ID Value Curve 0/1(pressure/HGL) min max 0/1(downstream node, any node) +;------------------------------------------------------------------------------------- + +[EMITTERS] +;------------------------------------------------------ +; Node Flow Coeff. +; ID +;------------------------------------------------------ + +[DEMANDS] +;------------------------------------------------------------------ +; NodeID Demand Pattern +;------------------------------------------------------------------ +10 0.000000 ;BASE +11 9.463530 ;BASE +12 9.463530 ;BASE +13 6.309020 ;BASE +21 9.463530 ;BASE +22 12.618039 ;BASE +23 9.463530 ;BASE +31 6.309020 ;BASE +32 6.309020 ;BASE + +[PATTERNS] +;ID Multipliers +1 1.000000 +1 1.200000 +1 1.400000 +1 1.600000 +1 1.400000 +1 1.200000 +1 1.000000 +1 0.800000 +1 0.600000 +1 0.400000 +1 0.600000 +1 0.800000 + +[STATUS] +;ID Status/Setting + +[CURVES] +;ID X-Value Y-Value +1 94.635295 76.200000 + +[CONTROLS] +LINK 9 OPEN IF NODE 2 BELOW 33.528000 +LINK 9 CLOSED IF NODE 2 ABOVE 42.672000 + +[RULES] + +[MIXING] +;Tank Model +2 MIXED +9 MIXED + +[QUALITY] +;------------------------------------------------------------------ +;Nodes Initial +;ID quality +;------------------------------------------------------------------ +10 0.500000 +11 0.500000 +12 0.500000 +13 0.500000 +21 0.500000 +22 0.500000 +23 0.500000 +31 0.500000 +32 0.500000 +2 1.000000 +9 1.000000 + +[SOURCES] +;----------------------------------------------- +;NODEID SRCTYPE STRENGTH PATTERN +;----------------------------------------------- + +[REACTIONS] +GLOBAL BULK -0.500000 +GLOBAL WALL -1.000000 +GLOBAL NewBulk 0.000000 0.000000 +ORDER BULK 1.000000 +ORDER WALL 1 +ROUGHNESS CORRELATION 0.000000 + +[ENERGY] +GLOBAL PRICE 0 +GLOBAL EFFIC 75 +DEMAND CHARGE 0 + +[TIMES] +Duration 24:0:0 +Hydraulic Timestep 1:0:0 +Quality Timestep 0:5:0 +Pattern Timestep 2:0:0 +Pattern Start 0:0:0 +Report Timestep 1:0:0 +Report Start 0:0:0 +Start Date 2022:10:13 +Start ClockTime 0:00:00 +STATISTIC NONE + +[REPORT] +;------------------------------------------------------ +STATUS FULL +SUMMARY YES +MESSAGES YES +ENERGY YES +NODES NONE +LINKS NONE + +[OPTIONS] +UNITS LPS +DIFFUSIVITY 1.000000 +HEADLOSS H-W +SPECIFIC GRAVITY 1.000000 +VISCOSITY 1.000000 +TRIALS 40 +TOLERANCE 0.010000 +ACCURACY 0.001000 +Quality NONE +PATTERN 1 +EMITTER EXPONENT 0.500000 +CHECKFREQ 2 +MAXCHECK 10.000000 +DAMPLIMIT 0.000000 +DEMAND MULTIPLIER 1.000000 +UNBALANCED CONTINUE 10 + +[TURBINES] +;------------------------------------------------------ +; ID +;------------------------------------------------------ + +[COORDINATES] +;------------------------------------------------------ +;Node X-coord Y-coord +;ID +;------------------------------------------------------ +10 6.096000 21.336000 +11 9.144000 21.336000 +12 15.240000 21.336000 +13 21.336000 21.336000 +21 9.144000 12.192000 +22 15.240000 12.192000 +23 21.336000 12.192000 +31 9.144000 3.048000 +32 15.240000 3.048000 +2 15.240000 27.432000 +9 3.048000 21.336000 + +[VERTICES] +;------------------------------------------------------ +;Link X-coord Y-coord +;ID +;------------------------------------------------------ + +[END] From dc54d2a0fe820c00c8a71243f9deeaf597261cff Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 15:27:27 +0200 Subject: [PATCH 21/27] Read EPANET companion files on from_epanet An EPANET run writes the network and main timeseries to .res, extra results to .resx, and the model itself to .inp - which is the only one of the three that carries reach lengths. Accept both companions as keyword arguments. resx= merges node quantities only. Its reach-level quantities sit on single-gridpoint reaches with no breakpoint to live on (#680). Planned as two commits, but the signature and loader plumbing are shared, so splitting would have left a half-built argument list in between. --- src/modelskill/model/adapters/_res1d.py | 52 +++++++-- src/modelskill/network.py | 133 +++++++++++++++++++++++- 2 files changed, 174 insertions(+), 11 deletions(-) diff --git a/src/modelskill/model/adapters/_res1d.py b/src/modelskill/model/adapters/_res1d.py index 08b8a88d4..567f0d498 100644 --- a/src/modelskill/model/adapters/_res1d.py +++ b/src/modelskill/model/adapters/_res1d.py @@ -39,6 +39,44 @@ def _simplify_colnames(node: ResultNode | ResultGridPoint) -> pd.DataFrame: return df.rename(columns=renamer_dict).copy() +def _merge_extra_quantities( + base: pd.DataFrame, extra: pd.DataFrame, *, node_id: str +) -> pd.DataFrame: + """Append a companion file's quantities to a node's frame as extra columns. + + Parameters + ---------- + base : pd.DataFrame + The node's frame from the main result file. + extra : pd.DataFrame + The same node's frame from the companion file, sharing its time index. + node_id : str + Node ID, used in error messages. + + Returns + ------- + pd.DataFrame + + Raises + ------ + ValueError + If a quantity appears in both frames. Concatenating would give the node + two columns of the same name, which is the state ``_simplify_colnames`` + already refuses. + """ + if extra.empty: + return base + + overlapping = base.columns.intersection(extra.columns) + if len(overlapping) > 0: + raise ValueError( + f"Node {node_id!r} already has {sorted(overlapping)} in the main " + "result file, so the companion file's copy cannot be merged in." + ) + + return pd.concat([base, extra], axis=1) + + class Res1DNode(NetworkNode): def __init__( self, @@ -90,6 +128,7 @@ def __init__( end_node: Res1DNode, *, populate_gridpoints: bool = True, + length: float | None = None, ): self._id = reach.name @@ -113,12 +152,13 @@ def __init__( self._start = start_node self._end = end_node - # mikeio1d returns 0 when it cannot read a reach length - link-node models - # such as EPANET report this for every reach. Report it as undefined rather - # than as a zero-length reach, which would make length-weighted graph - # algorithms treat the reach as free. The two cases cannot be told apart - # upstream. - self._length = reach.length if reach.length else None + # A length read from a companion input file wins, since mikeio1d has none + # to offer for the formats that need one. Otherwise: mikeio1d returns 0 + # when it cannot read a reach length - link-node models such as EPANET + # report this for every reach. Report it as undefined rather than as a + # zero-length reach, which would make length-weighted graph algorithms + # treat the reach as free. The two cases cannot be told apart upstream. + self._length = length if length is not None else (reach.length or None) self._breakpoints: list[ReachBreakPoint] = [ GridPoint( gridpoint.reach_name, diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 0feec806f..bc0bcaf41 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -497,16 +497,32 @@ def from_epanet( cls, res: str | Path | Res1D, *, + resx: str | Path | Res1D | None = None, + inp: str | Path | None = None, nodes: str | list[str] | None = None, reaches: str | list[str] | None = None, ) -> Network: - """Create a Network from an EPANET result file. + """Create a Network from an EPANET result file and its companions. + + An EPANET run writes up to three files that modelskill can use. The + ``.res`` holds the network and its main timeseries; the optional + ``.resx`` holds extra results; and the optional ``.inp`` is the input + file, which is the only one of the three carrying reach lengths. Parameters ---------- res : str, Path or Res1D Path to a ``.res`` file, or an already-opened :class:`mikeio1d.Res1D` object. + resx : str, Path, Res1D or None, optional + Companion ``.resx`` file from the same run. Its extra node + quantities (tank ``Volume`` and ``Volume Percentage``) are merged + onto the matching nodes. By default None, and those quantities are + simply absent. + inp : str, Path or None, optional + EPANET ``.inp`` input file for the same model, read for its + ``[PIPES]`` lengths. By default None, and reach lengths are + undefined. nodes : str, list of str, or None, optional Which nodes get their timeseries loaded. See :meth:`from_mike`. reaches : str, list of str, or None, optional @@ -523,26 +539,42 @@ def from_epanet( NotImplementedError If the file extension is not one modelskill can read. ValueError - If the extension belongs to another constructor, such as MIKE. + If the extension belongs to another constructor, such as MIKE, if a + companion file has the wrong extension, or if ``resx`` does not come + from the same run as ``res``. Examples -------- >>> from modelskill.network import Network >>> network = Network.from_epanet("model.res") + With both companions, for real edge lengths and the extra quantities: + + >>> network = Network.from_epanet( + ... "model.res", + ... resx="model.resx", + ... inp="model.inp", + ... ) + Notes ----- EPANET is a link-node model, and mikeio1d reports no length and a single synthetic gridpoint for each of its reaches. As a result: - * every edge of :attr:`graph` has ``length=None``, so a length-weighted - graph algorithm fails rather than returning a meaningless number + * without ``inp``, every edge of :attr:`graph` has ``length=None``, so a + length-weighted graph algorithm fails rather than returning a + meaningless number. Pumps and valves keep ``length=None`` even with + ``inp``, since ``[PIPES]`` is the only section carrying lengths * reaches have no breakpoints, so :class:`~modelskill.obs.ReachObservation` cannot be matched against an EPANET network — use :class:`~modelskill.obs.NodeObservation` * ``find(reach=..., distance=)`` 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 issue #680. + Node timeseries, :meth:`to_dataframe`, :meth:`to_dataset`, ``find(node=...)`` and :meth:`recall` are unaffected. @@ -556,6 +588,8 @@ def from_epanet( reaches=reaches, allowed=_EPANET_EXTENSIONS, caller="from_epanet", + resx=resx, + inp=inp, ) @classmethod @@ -567,6 +601,8 @@ def _from_mikeio1d( reaches: str | list[str] | None, allowed: frozenset[str], caller: str, + resx: str | Path | Res1D | None = None, + inp: str | Path | None = None, ) -> Network: """Shared implementation behind the public ``from_*`` constructors. @@ -576,6 +612,10 @@ def _from_mikeio1d( Extensions this constructor accepts. caller : str Name of the public method, used in error messages. + resx : str, Path, Res1D or None, optional + Companion result file whose node quantities are merged in. + inp : str, Path or None, optional + Companion input file read for reach lengths. """ if sys.version_info >= (3, 14): raise NotImplementedError( @@ -611,9 +651,80 @@ def _from_mikeio1d( else: reaches_list = list(reaches) - list_of_reaches = cls._load_res1d_network(res, nodes_list, reaches_list) + extra = None if resx is None else cls._open_companion_result(res, resx) + lengths = None if inp is None else cls._read_companion_lengths(inp) + + list_of_reaches = cls._load_res1d_network( + res, nodes_list, reaches_list, extra=extra, lengths=lengths + ) return cls(list_of_reaches) + @staticmethod + def _read_companion_lengths(inp: str | Path) -> dict[str, float]: + """Read reach lengths from a companion ``.inp`` input file.""" + from modelskill.model.adapters._inp import read_pipe_lengths + + path = Path(inp) + if path.suffix.lower() != ".inp": + raise ValueError( + f"Expected an EPANET '.inp' input file, got '{path.suffix}'. " + "This argument reads reach lengths from the model input, not " + "from a result file." + ) + return read_pipe_lengths(path) + + @staticmethod + def _open_companion_result(res: Res1D, resx: str | Path | Res1D) -> Res1D: + """Open and validate a companion ``.resx`` result file. + + Raises + ------ + ValueError + If the extension is not ``.resx``, or if the file does not come from + the same run as ``res``. + """ + from mikeio1d import Res1D as _Res1D + + if isinstance(resx, (str, Path)): + path = Path(resx) + if path.suffix.lower() != ".resx": + raise ValueError( + f"Expected an EPANET '.resx' companion file, got '{path.suffix}'." + ) + extra = _Res1D(str(path)) + elif isinstance(resx, _Res1D): + _check_file_path_is_str(resx) + if Path(resx.file_path).suffix.lower() != ".resx": + raise ValueError( + "Expected an EPANET '.resx' companion file, got " + f"'{Path(resx.file_path).suffix}'." + ) + extra = resx + else: + raise TypeError( + f"Expected a str, Path or Res1D object, got {type(resx).__name__!r}" + ) + + # Merging two different runs would line up silently and produce a network + # that is wrong in a way no later error would reveal. + if not res.time_index.equals(extra.time_index): + raise ValueError( + "The '.resx' companion does not share a time axis with the " + "'.res' file, so the two are not from the same run. Got " + f"{len(extra.time_index)} steps ending {extra.end_time} against " + f"{len(res.time_index)} ending {res.end_time}." + ) + + unknown = set(extra.nodes) - set(res.nodes) + if unknown: + raise ValueError( + f"The '.resx' companion holds nodes {sorted(unknown)} that are " + "absent from the '.res' network, so the two files do not describe " + "the same model." + ) + + return extra + @staticmethod def _validate_extension( suffix: str, *, allowed: frozenset[str], caller: str @@ -664,15 +775,20 @@ def _load_res1d_network( res: Res1D, nodes: list[str], reaches: list[str], + *, + extra: Res1D | None = None, + lengths: dict[str, float] | None = None, ) -> list[Res1DReach]: from modelskill.model.adapters._res1d import ( Res1DReach, Res1DNode, + _merge_extra_quantities, _simplify_colnames, ) nodes_set = set(nodes) reaches_set = set(reaches) + lengths = lengths or {} # In order to work with bigger files, we might want to select a subset of nodes and avoid # potential memory issues. For this reason, we create this intermediate step that populates @@ -684,6 +800,12 @@ def _init_node(reach: ResultReach, is_end: bool) -> Res1DNode: if id in nodes_set: node = res.nodes[id] df = _simplify_colnames(node) + # Merged here rather than up front so selective loading still + # decides what is held in memory. + if extra is not None and id in extra.nodes: + df = _merge_extra_quantities( + df, _simplify_colnames(extra.nodes[id]), node_id=id + ) overlapping_gridpoint = reach.gridpoints[gpt_idx] boundary = _simplify_colnames(overlapping_gridpoint) return Res1DNode(id, data=df, boundary={reach.name: boundary}) @@ -696,6 +818,7 @@ def _init_node(reach: ResultReach, is_end: bool) -> Res1DNode: _init_node(reach, False), _init_node(reach, True), populate_gridpoints=reach.name in reaches_set, + length=lengths.get(reach.name), ) for reach in res.reaches.values() ] From 65e6dd73ecff31d3ca4f0c2de1a05cf4bb1cbb3d Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 15:28:59 +0200 Subject: [PATCH 22/27] Name the companion file in the .out and .resx refusals The old message blamed mikeio1d for not exposing reach start/end nodes. The real reason is that neither file carries its own topology: SWMM's lives in the companion .inp, and .resx describes a network defined in its sibling .res. The .resx message now names the argument that reads it. --- src/modelskill/network.py | 22 ++++++++++++++++------ tests/test_network.py | 18 +++++++++++------- 2 files changed, 27 insertions(+), 13 deletions(-) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index bc0bcaf41..3aff11f48 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -33,19 +33,29 @@ _MIKE_EXTENSIONS = frozenset({".res1d", ".res11"}) _EPANET_EXTENSIONS = frozenset({".res"}) -_NO_CONNECTIVITY = ( - "mikeio1d does not expose reach start/end nodes for {product} results, " - "so the network topology cannot be reconstructed." -) _NO_FIXTURE = ( "{product} results are not supported yet: modelskill has no test fixture for " "this format, so support cannot be verified. Please open an issue if you need it." ) +# A result file that holds timeseries but no topology of its own. The connectivity +# is in a companion file we do not parse yet. +_TOPOLOGY_IN_COMPANION_FILE = ( + "SWMM '.out' files carry no reach connectivity of their own - it lives in the " + "companion '.inp' input file, which modelskill does not read yet. Tracked in " + "https://github.com/DHI/modelskill/issues/689." +) +# A companion result file: readable, but it describes a network defined elsewhere. +_COMPANION_RESULT_FILE = ( + "'.resx' holds extra EPANET results (tank volume, pump energy) for a network " + "defined in the sibling '.res' file, so it has no topology of its own. Read the " + "'.res' file and pass this one alongside it: " + "Network.from_epanet(res, resx=...)." +) # extension -> why modelskill will not read it, even though mikeio1d can _UNSUPPORTED_EXTENSIONS: dict[str, str] = { - ".out": _NO_CONNECTIVITY.format(product="SWMM"), - ".resx": _NO_CONNECTIVITY.format(product=".resx"), + ".out": _TOPOLOGY_IN_COMPANION_FILE, + ".resx": _COMPANION_RESULT_FILE, ".prf": _NO_FIXTURE.format(product="MOUSE"), ".crf": _NO_FIXTURE.format(product="MOUSE"), ".xrf": _NO_FIXTURE.format(product="MOUSE"), diff --git a/tests/test_network.py b/tests/test_network.py index f8819d669..f97fae76d 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -753,13 +753,17 @@ def test_error_lists_only_readable_extensions(self): for extension in _UNSUPPORTED_EXTENSIONS: assert extension not in message - @pytest.mark.parametrize( - "filename", ["./tests/testdata/epanet.resx", "./tests/testdata/swmm.out"] - ) - def test_formats_without_reach_connectivity_are_refused(self, filename): - """Real files, so these fail if mikeio1d ever starts exposing connectivity.""" - with pytest.raises(NotImplementedError, match="reach start/end nodes"): - Network.from_mike(filename) + def test_swmm_refusal_names_the_companion_inp(self): + """A real file, so this fails the day SWMM support lands.""" + with pytest.raises(NotImplementedError, match=r"companion '\.inp'"): + Network.from_mike("./tests/testdata/swmm.out") + + def test_resx_refusal_points_at_the_resx_argument(self): + """'.resx' is a companion, so the message must name what to do instead.""" + with pytest.raises( + NotImplementedError, match=r"from_epanet\(res, resx=\.\.\.\)" + ): + Network.from_mike("./tests/testdata/epanet.resx") @pytest.mark.parametrize("suffix", [".prf", ".crf", ".xrf", ".whr"]) def test_formats_without_a_fixture_are_refused(self, tmp_path, suffix): From 0e180170addd63690f5a9155624a717cf45196d0 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 15:34:26 +0200 Subject: [PATCH 23/27] Test the EPANET companion files and the .inp reader Covers real pipe lengths from inp=, the pump keeping None, merged node quantities from resx=, both together, and each refusal path. The two guards against mismatched files need monkeypatching, since the committed fixtures are a matching pair by construction. --- tests/test_network.py | 227 +++++++++++++++++++++++++++++++++++++++++- 1 file changed, 226 insertions(+), 1 deletion(-) diff --git a/tests/test_network.py b/tests/test_network.py index f97fae76d..150f043ba 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -15,6 +15,7 @@ NetworkModelResult, NodeModelResult, ) +from modelskill.model.adapters._inp import read_pipe_lengths, read_sections from modelskill.model.adapters._res1d import ( Res1DNode, Res1DReach, @@ -1124,7 +1125,7 @@ def test_epanet(self): assert not network.to_dataframe().empty def test_link_node_reaches_have_no_length_or_breakpoints(self): - """mikeio1d reports neither for a link-node model - documented in the docstring.""" + """Without inp=, mikeio1d reports neither - documented in the docstring.""" network = Network.from_epanet("./tests/testdata/epanet.res") lengths = [d["length"] for *_, d in network.graph.edges(data=True)] @@ -1156,3 +1157,227 @@ def test_open_res1d_object_is_validated(self): def test_extension_is_case_insensitive(self, tmp_path, suffix): with pytest.raises((FileExistsError, FileNotFoundError)): Network.from_epanet(tmp_path / f"network{suffix}") + + +# --------------------------------------------------------------------------- +# EPANET companion files: .inp for reach lengths, .resx for extra quantities +# --------------------------------------------------------------------------- + +_EPANET_RES = "./tests/testdata/epanet.res" +_EPANET_RESX = "./tests/testdata/epanet.resx" +_EPANET_INP = "./tests/testdata/epanet.inp" + +# The 12 [PIPES] entries; reach "9" is the pump, which carries no length. +_PUMP_REACH = "9" + + +@requires_mikeio1d +class TestEpanetCompanionInp: + """`.inp` is the only one of the three files carrying reach lengths.""" + + def test_pipe_reaches_get_real_lengths(self): + network = Network.from_epanet(_EPANET_RES, inp=_EPANET_INP) + + lengths = {r.id: r.length for r in network._reaches.values()} + assert lengths["10"] == pytest.approx(3209.544) + assert lengths["110"] == pytest.approx(60.96) + + def test_pump_reach_stays_undefined(self): + """[PIPES] is the only section with lengths, so pumps keep None.""" + network = Network.from_epanet(_EPANET_RES, inp=_EPANET_INP) + + lengths = {r.id: r.length for r in network._reaches.values()} + assert lengths[_PUMP_REACH] is None + assert sum(v is None for v in lengths.values()) == 1 + + def test_graph_edges_carry_the_lengths(self): + network = Network.from_epanet(_EPANET_RES, inp=_EPANET_INP) + + lengths = [d["length"] for *_, d in network.graph.edges(data=True)] + assert sum(v is not None for v in lengths) == 12 + + def test_node_ids_overlapping_reach_ids_are_not_confused(self): + """Most IDs here name both a node and a reach, e.g. '9', '10', '21'.""" + network = Network.from_epanet(_EPANET_RES, inp=_EPANET_INP) + + assert set(network._reaches) & set(network._alias_map) # they do overlap + # Reach "10" is 3209.544 long; node "10" is untouched by the length map. + assert network._reaches["10"].length == pytest.approx(3209.544) + node_10 = network.find(node="10") + assert "Head" in network.to_dataframe()[node_10].columns + + def test_wrong_suffix_is_refused(self): + with pytest.raises(ValueError, match=r"Expected an EPANET '\.inp'"): + Network.from_epanet(_EPANET_RES, inp=_EPANET_RESX) + + def test_file_without_a_pipes_section_is_refused(self, tmp_path): + other = tmp_path / "not-epanet.inp" + other.write_text("[JUNCTIONS]\n;;Name\n9 1000\n") + + with pytest.raises(ValueError, match=r"no \[PIPES\] section"): + Network.from_epanet(_EPANET_RES, inp=other) + + +@requires_mikeio1d +class TestEpanetCompanionResx: + """`.resx` holds extra results for the network defined in the sibling `.res`.""" + + def test_extra_node_quantities_are_merged(self): + network = Network.from_epanet(_EPANET_RES, resx=_EPANET_RESX) + + assert set(network.quantities) == { + "Demand", + "Head", + "Pressure", + "WaterQuality", + "Volume", + "Volume Percentage", + } + + def test_only_the_nodes_present_in_the_resx_gain_them(self): + """The .resx covers the tank and the reservoir, not all eleven nodes.""" + network = Network.from_epanet(_EPANET_RES, resx=_EPANET_RESX) + df = network.to_dataframe() + + with_volume = { + node + for node in df.columns.get_level_values("node").unique() + if "Volume" in df[node].columns + } + # Node IDs are re-indexed to integers, so recall the original labels. + assert {network.recall(node)["node"] for node in with_volume} == {"2", "9"} + + def test_values_come_through(self): + network = Network.from_epanet(_EPANET_RES, resx=_EPANET_RESX) + + reservoir = network.find(node="9") + volume = network.to_dataframe()[(reservoir, "Volume Percentage")] + assert len(volume) == 25 + assert volume.notna().all() + + def test_selective_loading_still_governs_what_is_read(self): + network = Network.from_epanet(_EPANET_RES, resx=_EPANET_RESX, nodes=["2"]) + + df = network.to_dataframe() + tank = network.find(node="2") + assert set(df.columns.get_level_values("node").unique()) == {tank} + assert "Volume" in df[tank].columns + + def test_both_companions_together(self): + network = Network.from_epanet(_EPANET_RES, resx=_EPANET_RESX, inp=_EPANET_INP) + + assert "Volume" in network.quantities + assert network._reaches["10"].length == pytest.approx(3209.544) + + def test_an_open_res1d_object_is_accepted(self): + from mikeio1d import Res1D + + network = Network.from_epanet(_EPANET_RES, resx=Res1D(_EPANET_RESX)) + + assert "Volume" in network.quantities + + def test_wrong_suffix_is_refused(self): + with pytest.raises(ValueError, match=r"Expected an EPANET '\.resx'"): + Network.from_epanet(_EPANET_RES, resx=_EPANET_RES) + + def test_a_result_file_of_another_format_is_refused(self): + from mikeio1d import Res1D + + other = Res1D("./tests/testdata/network.res1d") + + with pytest.raises(ValueError, match=r"Expected an EPANET '\.resx'"): + Network.from_epanet(_EPANET_RES, resx=other) + + def test_a_companion_from_another_run_is_refused(self, monkeypatch): + """Merging two runs would line up silently and give a wrong network.""" + from mikeio1d import Res1D + + res = Res1D(_EPANET_RES) + resx = Res1D(_EPANET_RESX) + shifted = resx.time_index + pd.Timedelta("1D") + + # Both objects share the Res1D class, so shift only this one instance. + original = type(resx).time_index.fget + monkeypatch.setattr( + type(resx), + "time_index", + property(lambda self: shifted if self is resx else original(self)), + ) + + with pytest.raises(ValueError, match="does not share a time axis"): + Network.from_epanet(res, resx=resx) + + def test_a_companion_naming_an_unknown_node_is_refused(self, monkeypatch): + """A node the .res has never heard of means these are different models.""" + from mikeio1d import Res1D + + res = Res1D(_EPANET_RES) + resx = Res1D(_EPANET_RESX) + strangers = dict(resx.nodes) | {"not_in_the_res": None} + + original = type(resx).nodes.fget + monkeypatch.setattr( + type(resx), + "nodes", + property(lambda self: strangers if self is resx else original(self)), + ) + + with pytest.raises(ValueError, match="not_in_the_res"): + Network.from_epanet(res, resx=resx) + + def test_unsupported_type_is_refused(self): + with pytest.raises(TypeError, match="Expected a str, Path or Res1D object"): + Network.from_epanet(_EPANET_RES, resx=42) # type: ignore[arg-type] + + +class TestReadInp: + """Minimal .inp reader - see modelskill/model/adapters/_inp.py.""" + + def _write(self, tmp_path, text): + path = tmp_path / "model.inp" + path.write_text(text) + return path + + def test_sections_are_keyed_without_brackets_and_upper_cased(self, tmp_path): + path = self._write(tmp_path, "[Pipes]\n1 a b 10\n[TANKS]\n2 5\n") + + assert set(read_sections(path)) == {"PIPES", "TANKS"} + + def test_comment_and_blank_lines_are_dropped(self, tmp_path): + path = self._write( + tmp_path, + ";a leading banner\n\n[PIPES]\n" + ";;ID Node1 Node2 Length\n" + ";;-- ----- ----- ------\n" + "1 a b 10\n\n", + ) + + assert read_sections(path) == {"PIPES": [["1", "a", "b", "10"]]} + + def test_trailing_comment_is_stripped_from_a_data_row(self, tmp_path): + path = self._write(tmp_path, "[PIPES]\n1 a b 10 ; the short one\n") + + assert read_sections(path)["PIPES"] == [["1", "a", "b", "10"]] + + def test_rows_before_any_section_are_ignored(self, tmp_path): + path = self._write(tmp_path, "stray row\n[PIPES]\n1 a b 10\n") + + assert read_sections(path) == {"PIPES": [["1", "a", "b", "10"]]} + + def test_lengths_are_read_from_the_fourth_field(self, tmp_path): + path = self._write(tmp_path, "[PIPES]\n1 a b 10.5 300 100\n") + + assert read_pipe_lengths(path) == {"1": 10.5} + + def test_a_short_row_raises_rather_than_dropping_a_length(self, tmp_path): + path = self._write(tmp_path, "[PIPES]\n1 a b\n") + + with pytest.raises(ValueError, match="Cannot read a pipe length"): + read_pipe_lengths(path) + + def test_a_repeated_section_header_accumulates(self, tmp_path): + path = self._write( + tmp_path, "[PIPES]\n1 a b 10\n[TANKS]\n2 5\n[PIPES]\n3 c d 20\n" + ) + + assert read_pipe_lengths(path) == {"1": 10.0, "3": 20.0} From d864737f879b88ba5547b0e991e175e4d1233de3 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Mon, 3 Aug 2026 15:38:11 +0200 Subject: [PATCH 24/27] Document EPANET companion files in the user guide and ADR-012 Adds the companion-file table and a worked example, corrects the refused-format table, and records two supporting rules: companions are constructor arguments rather than constructors, and a refusal names the file that would lift it. --- adr/012-network-format-constructors.md | 19 ++++++---- docs/user-guide/network.qmd | 48 +++++++++++++++++++++++--- 2 files changed, 57 insertions(+), 10 deletions(-) diff --git a/adr/012-network-format-constructors.md b/adr/012-network-format-constructors.md index dfeca593e..7e1785027 100644 --- a/adr/012-network-format-constructors.md +++ b/adr/012-network-format-constructors.md @@ -14,7 +14,7 @@ Loading each of mikeio1d's own fixtures showed the nine are not interchangeable: - `.res1d` and `.res11` produce a full network with real reach lengths and gridpoints. `.res11` initially failed because MIKE 11 keeps its timeseries on reach gridpoints, leaving nodes with no quantities at all — a bug in modelskill's adapter, now fixed. - `.res` (EPANET) loads, but as a link-node model it reports no reach length and one synthetic gridpoint per reach. mikeio1d signals the missing length by returning `0`, which is indistinguishable from a genuine zero. -- `.out` (SWMM) and `.resx` expose no reach start/end nodes. There is no topology to rebuild. mikeio1d's own SWMM tests never touch reach connectivity. +- `.out` (SWMM) and `.resx` expose no reach start/end nodes. Looking closer, this is not an upstream API gap: the raw `StartNodeIndex` is `-1` on every reach, node coordinates are `nan`, and there are no chainages, so the connectivity is absent from the files themselves. It lives in a *companion* file — SWMM's `.inp` input file, and, for `.resx`, the sibling `.res` that defines the network the `.resx` adds results to. - MOUSE and `.whr` have no test fixture anywhere, including in mikeio1d's testdata, so nothing about them can be verified. ## Decision @@ -24,15 +24,17 @@ Name constructors after the product that writes the file, and only ship one wher | Constructor | Extensions | |---|---| | `Network.from_mike` | `.res1d`, `.res11` | -| `Network.from_epanet` | `.res` | +| `Network.from_epanet` | `.res`, plus optional `.resx` and `.inp` companions | `from_res1d` is removed. It only ever shipped in the 1.4.0a3 alpha, and the network module is opt-in and absent from the API reference, so a deprecation shim would have added a second name for the tested path without protecting a real caller. Every extension mikeio1d can read 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 specific reason. A test asserts the three cover exactly `Res1D.get_supported_file_extensions()`, so a mikeio1d release that adds a tenth format fails CI and forces a decision rather than leaving the format silently unreachable. -Two supporting rules: +Four supporting rules: +- **A constructor takes its product's companion files as arguments, not as separate constructors.** A product may write several files for one run, and the result file is not always the whole picture. `from_epanet(res, resx=..., inp=...)` reflects that: the `.resx` adds node quantities and the `.inp` adds the reach lengths that no result file carries. The alternative — a `from_resx()` — would have been wrong, since a companion file describes a network defined elsewhere and cannot stand on its own. A companion is validated against the main file (same time axis, no unknown IDs) rather than merged on trust, because two runs would line up silently and produce a network that nothing downstream would flag. - **A constructor requires a fixture.** Naming a product in the API is a support claim; it should be backed by a test that builds a `Network` from a real file of that product. MOUSE and Water Hammer are refused today for exactly this reason, and each becomes a six-line addition once a redistributable fixture exists. +- **A refusal names the file that would lift it.** "mikeio1d does not expose reach start/end nodes" was accurate about our code path but wrong about the cause, and it left users with nothing to do. The `.out` refusal now names the companion `.inp` and the issue tracking it; the `.resx` refusal names `from_epanet(res, resx=...)`, which is a next action rather than a dead end. - **Degenerate results are documented, not warned about.** EPANET's undefined reach lengths and absent breakpoints are stated in the `from_epanet` docstring and the user guide, and asserted in tests. A runtime warning would fire on correct usage and teach users to filter our warnings, and both consequences already raise where they bite. - **An unreadable reach length is undefined, not zero.** `NetworkReach.length` is optional and defaults to `None`, and the adapter maps mikeio1d's `0` sentinel onto it. Reporting `0` would assert that an EPANET pipe has no extent, which is false — the length exists, mikeio1d just cannot read it — and it makes a length-weighted graph algorithm treat the reach as free to traverse. With `None`, `networkx` fails instead: shortest-path treats the edge as unreachable and weight-summing calls raise `TypeError`. Omitting the edge attribute altogether was rejected for the opposite reason, since `networkx` then defaults the weight to `1`. Nothing inside modelskill reads the length, so this only affects `Network.graph`; matching and extraction work from break point distances. @@ -44,21 +46,26 @@ Two supporting rules: **Keep `from_res1d` permissive** — preserves the misleading name, and a constructor that accepts everything cannot tell an EPANET user which method to reach for instead. -**Ship all five product constructors regardless of coverage** — three of the five would either always fail (SWMM) or be unverifiable, so the method list would stop being a reliable statement of what works. +**Ship all five product constructors regardless of coverage** — MOUSE and Water Hammer would be unverifiable, so the method list would stop being a reliable statement of what works. SWMM is a different case: its `.inp` does carry the missing topology and a fixture for it exists upstream, so it is deferred rather than impossible ([#689](https://github.com/DHI/modelskill/issues/689)). + +**A `from_resx()` constructor, or a caller-supplied edge list** — both were considered while working out what `.resx` and `.out` needed. A `from_resx()` cannot work, because a companion file describes a network defined elsewhere. An edge-list argument for supplying topology by hand was redundant: `NetworkReach` is already an abstract base class, so that path exists without new API. ## Consequences Positive: - The method list is the format list; `Network.from_` answers "which formats does this read". -- Refusals name the cause, so the SWMM and `.resx` gaps read as upstream limitations rather than modelskill bugs. +- Refusals name the file that would lift them, so a user has somewhere to go. - Passing a file the other constructor handles raises a `ValueError` naming that constructor. -- One private implementation (`Network._from_mikeio1d`) does the version guard, extension validation, `Res1D` construction and node/reach filtering, so a new product constructor is a docstring and one call. +- One private implementation (`Network._from_mikeio1d`) does the version guard, extension validation, `Res1D` construction, node/reach filtering and companion-file handling, so a new product constructor is a docstring and one call. +- EPANET networks get real edge lengths and two more node quantities, and the `.inp` reader added for it (`model/adapters/_inp.py`) is the same one SWMM support will need, since the two products share the `.inp` layout. Negative: - MIKE 11 is covered by a fixture but has no field-tested usage behind it yet. - MOUSE and Water Hammer are refused even though mikeio1d may well read them correctly. This is deliberate: refusing with a reason is recoverable, while a method that silently produces a wrong graph is not. +- The `.inp` reader is ours to maintain. mikeio1d does not read `.inp` at all, and pulling in `wntr` or `swmmio` for two sections each would weigh more than the parser does (ADR-010). The cost is that an unusual `.inp` dialect is our bug to fix. +- `.resx` merges node quantities only. Its reach-level quantities need a data location on single-gridpoint reaches, which is #680. ## Relationship to ADR-009 diff --git a/docs/user-guide/network.qmd b/docs/user-guide/network.qmd index 3699f0fc1..56df21b1b 100644 --- a/docs/user-guide/network.qmd +++ b/docs/user-guide/network.qmd @@ -139,13 +139,14 @@ There is one constructor per product that writes the file: | Constructor | Extensions | Product | |---|---|---| | `Network.from_mike` | `.res1d`, `.res11` | MIKE 1D, MIKE 11 | -| `Network.from_epanet` | `.res` | EPANET | +| `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), `.resx` | mikeio1d does not report reach start/end nodes for these, so there is no topology to rebuild. | +| `.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. | ### From a network result file @@ -190,15 +191,54 @@ EPANET results use `from_epanet`: 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 +) +``` + ::: {.callout-warning} -## EPANET networks have no reach geometry +## 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: -* 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` +* 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`. 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=)` 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. ::: From 81dfc464598bd52c9414b55af9f8a4b9a4827aee Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Wed, 5 Aug 2026 08:47:41 +0200 Subject: [PATCH 25/27] Fixing markdown comment Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- roadmap/features/network-models.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/roadmap/features/network-models.md b/roadmap/features/network-models.md index fc7a7f96c..b8bfad2ad 100644 --- a/roadmap/features/network-models.md +++ b/roadmap/features/network-models.md @@ -23,4 +23,4 @@ This reduces the effort required to produce quality-assured model deliverables a 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 cannot be supported until mikeio1d exposes reach connectivity for them. +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. From 0561615f391e6d2d508c20006fdcb71aaab73753 Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Wed, 5 Aug 2026 08:49:05 +0200 Subject: [PATCH 26/27] Guarding against unknown reach ids in resx file. Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/modelskill/network.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 3aff11f48..8e065445e 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -725,10 +725,18 @@ def _open_companion_result(res: Res1D, resx: str | Path | Res1D) -> Res1D: f"{len(res.time_index)} ending {res.end_time}." ) - unknown = set(extra.nodes) - set(res.nodes) - if unknown: + unknown_nodes = set(extra.nodes) - set(res.nodes) + if unknown_nodes: raise ValueError( - f"The '.resx' companion holds nodes {sorted(unknown)} that are " + f"The '.resx' companion holds nodes {sorted(unknown_nodes)} that are " + "absent from the '.res' network, so the two files do not describe " + "the same model." + ) + + unknown_reaches = set(extra.reaches) - set(res.reaches) + if unknown_reaches: + raise ValueError( + f"The '.resx' companion holds reaches {sorted(unknown_reaches)} that are " "absent from the '.res' network, so the two files do not describe " "the same model." ) From cc4ceb9901bf7abbb5f84e78b93ae9d850407a9a Mon Sep 17 00:00:00 2001 From: jpalm3r Date: Wed, 5 Aug 2026 10:17:48 +0200 Subject: [PATCH 27/27] Trim down adr --- adr/012-network-format-constructors.md | 62 ++++++-------------------- docs/user-guide/network.qmd | 2 +- 2 files changed, 14 insertions(+), 50 deletions(-) diff --git a/adr/012-network-format-constructors.md b/adr/012-network-format-constructors.md index 7e1785027..324959247 100644 --- a/adr/012-network-format-constructors.md +++ b/adr/012-network-format-constructors.md @@ -6,72 +6,36 @@ ## Context -`Network` is built from result files read through mikeio1d. Its 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. +`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. -modelskill's constructor was named `from_res1d`, and its extension guard was briefly widened to accept everything mikeio1d could read. That made the name misleading: it promised one format and read nine. - -Loading each of mikeio1d's own fixtures showed the nine are not interchangeable: - -- `.res1d` and `.res11` produce a full network with real reach lengths and gridpoints. `.res11` initially failed because MIKE 11 keeps its timeseries on reach gridpoints, leaving nodes with no quantities at all — a bug in modelskill's adapter, now fixed. -- `.res` (EPANET) loads, but as a link-node model it reports no reach length and one synthetic gridpoint per reach. mikeio1d signals the missing length by returning `0`, which is indistinguishable from a genuine zero. -- `.out` (SWMM) and `.resx` expose no reach start/end nodes. Looking closer, this is not an upstream API gap: the raw `StartNodeIndex` is `-1` on every reach, node coordinates are `nan`, and there are no chainages, so the connectivity is absent from the files themselves. It lives in a *companion* file — SWMM's `.inp` input file, and, for `.resx`, the sibling `.res` that defines the network the `.resx` adds results to. -- MOUSE and `.whr` have no test fixture anywhere, including in mikeio1d's testdata, so nothing about them can be verified. +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 only ship one where a committed fixture backs it: +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 | -`from_res1d` is removed. It only ever shipped in the 1.4.0a3 alpha, and the network module is opt-in and absent from the API reference, so a deprecation shim would have added a second name for the tested path without protecting a real caller. - -Every extension mikeio1d can read 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 specific reason. A test asserts the three cover exactly `Res1D.get_supported_file_extensions()`, so a mikeio1d release that adds a tenth format fails CI and forces a decision rather than leaving the format silently unreachable. +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. -Four supporting rules: - -- **A constructor takes its product's companion files as arguments, not as separate constructors.** A product may write several files for one run, and the result file is not always the whole picture. `from_epanet(res, resx=..., inp=...)` reflects that: the `.resx` adds node quantities and the `.inp` adds the reach lengths that no result file carries. The alternative — a `from_resx()` — would have been wrong, since a companion file describes a network defined elsewhere and cannot stand on its own. A companion is validated against the main file (same time axis, no unknown IDs) rather than merged on trust, because two runs would line up silently and produce a network that nothing downstream would flag. -- **A constructor requires a fixture.** Naming a product in the API is a support claim; it should be backed by a test that builds a `Network` from a real file of that product. MOUSE and Water Hammer are refused today for exactly this reason, and each becomes a six-line addition once a redistributable fixture exists. -- **A refusal names the file that would lift it.** "mikeio1d does not expose reach start/end nodes" was accurate about our code path but wrong about the cause, and it left users with nothing to do. The `.out` refusal now names the companion `.inp` and the issue tracking it; the `.resx` refusal names `from_epanet(res, resx=...)`, which is a next action rather than a dead end. -- **Degenerate results are documented, not warned about.** EPANET's undefined reach lengths and absent breakpoints are stated in the `from_epanet` docstring and the user guide, and asserted in tests. A runtime warning would fire on correct usage and teach users to filter our warnings, and both consequences already raise where they bite. -- **An unreadable reach length is undefined, not zero.** `NetworkReach.length` is optional and defaults to `None`, and the adapter maps mikeio1d's `0` sentinel onto it. Reporting `0` would assert that an EPANET pipe has no extent, which is false — the length exists, mikeio1d just cannot read it — and it makes a length-weighted graph algorithm treat the reach as free to traverse. With `None`, `networkx` fails instead: shortest-path treats the edge as unreachable and weight-summing calls raise `TypeError`. Omitting the edge attribute altogether was rejected for the opposite reason, since `networkx` then defaults the weight to `1`. Nothing inside modelskill reads the length, so this only affects `Network.graph`; matching and extraction work from break point distances. +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`, `from_whr` say nothing about the product they belong to, and MOUSE would need three identical methods. - -**A generic catch-all (`from_file` or `from_mikeio1d`)** — a second way to do the same thing. With every extension either read or explicitly refused, the catch-all's only remaining job is forward compatibility with formats mikeio1d adds later, which the drift test handles more usefully by demanding a decision. +**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. -**Keep `from_res1d` permissive** — preserves the misleading name, and a constructor that accepts everything cannot tell an EPANET user which method to reach for instead. +**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. -**Ship all five product constructors regardless of coverage** — MOUSE and Water Hammer would be unverifiable, so the method list would stop being a reliable statement of what works. SWMM is a different case: its `.inp` does carry the missing topology and a fixture for it exists upstream, so it is deferred rather than impossible ([#689](https://github.com/DHI/modelskill/issues/689)). +**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. -**A `from_resx()` constructor, or a caller-supplied edge list** — both were considered while working out what `.resx` and `.out` needed. A `from_resx()` cannot work, because a companion file describes a network defined elsewhere. An edge-list argument for supplying topology by hand was redundant: `NetworkReach` is already an abstract base class, so that path exists without new API. +**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 -Positive: - -- The method list is the format list; `Network.from_` answers "which formats does this read". -- Refusals name the file that would lift them, so a user has somewhere to go. -- Passing a file the other constructor handles raises a `ValueError` naming that constructor. -- One private implementation (`Network._from_mikeio1d`) does the version guard, extension validation, `Res1D` construction, node/reach filtering and companion-file handling, so a new product constructor is a docstring and one call. -- EPANET networks get real edge lengths and two more node quantities, and the `.inp` reader added for it (`model/adapters/_inp.py`) is the same one SWMM support will need, since the two products share the `.inp` layout. - -Negative: - -- MIKE 11 is covered by a fixture but has no field-tested usage behind it yet. -- MOUSE and Water Hammer are refused even though mikeio1d may well read them correctly. This is deliberate: refusing with a reason is recoverable, while a method that silently produces a wrong graph is not. -- The `.inp` reader is ours to maintain. mikeio1d does not read `.inp` at all, and pulling in `wntr` or `swmmio` for two sections each would weigh more than the parser does (ADR-010). The cost is that an unusual `.inp` dialect is our bug to fix. -- `.resx` merges node quantities only. Its reach-level quantities need a data location on single-gridpoint reaches, which is #680. - -## Relationship to ADR-009 - -[ADR-009](009-factory-pattern.md) argues for auto-detecting entry points such as `model_result()` and `observation()` so users need not know the class hierarchy. That is not in tension with this decision. Auto-detection resolves *which class* to build from the shape of the data; these constructors resolve *which product wrote the file*, which is information the call site should state rather than have guessed — particularly when the answer decides whether reach-based matching will work at all. - -## See Also - -- [ADR-010](010-optional-domain-dependencies.md) — why mikeio1d is an optional dependency -- `tests/testdata/README.md` — provenance of the result fixtures +- 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. +- 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 56df21b1b..a769d9c16 100644 --- a/docs/user-guide/network.qmd +++ b/docs/user-guide/network.qmd @@ -233,7 +233,7 @@ sorted( 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`. With `inp=`, only pumps and valves stay `None`, since `[PIPES]` is the one section carrying lengths +* 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=)` never resolves; only `distance="start"` and `distance="end"` work