Skip to content

Commit f434b5b

Browse files
jpalm3rclaude
andcommitted
Accept a result-file path in NetworkModelResult
Constructing a model result from a file meant importing Network, loading it, then passing it in. Hand NetworkModelResult the path instead and it loads the network itself via Network.from_path; the loaded network stays reachable afterwards. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 116d1d9 commit f434b5b

2 files changed

Lines changed: 94 additions & 9 deletions

File tree

src/modelskill/model/network.py

Lines changed: 43 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
from pathlib import Path
34
from typing import TYPE_CHECKING, Sequence
45

56
import numpy as np
@@ -90,15 +91,17 @@ def _create_new_instance(self, data: xr.Dataset) -> NodeModelResult:
9091
class NetworkModelResult:
9192
"""Model result for network data with time and node dimensions.
9293
93-
Construct a NetworkModelResult from a Network object containing
94-
timeseries data for each node. Users must provide exact node IDs
95-
(integers obtained via ``Network.find()``) when creating observations —
96-
no spatial interpolation is performed.
94+
Construct a NetworkModelResult from a result file or from an already-loaded
95+
Network containing timeseries data for each node. Users must provide exact
96+
node IDs (integers obtained via ``Network.find()``) when creating
97+
observations — no spatial interpolation is performed.
9798
9899
Parameters
99100
----------
100-
data : Network
101-
Network-like object with a ``to_dataset()`` method (e.g. :class:`modelskill.network.Network`).
101+
data : Network, str or Path
102+
Path to a ``.res1d``, ``.res11`` or ``.res`` result file, or a
103+
network-like object with a ``to_dataset()`` method (e.g.
104+
:class:`modelskill.network.Network`).
102105
name : str, optional
103106
The name of the model result,
104107
by default None (will be set to first data variable name)
@@ -113,23 +116,54 @@ class NetworkModelResult:
113116
Examples
114117
--------
115118
>>> import modelskill as ms
119+
>>> mr = ms.NetworkModelResult("model.res1d", item="WaterLevel")
120+
121+
From a network built by hand, or loaded with arguments of its own:
122+
116123
>>> from modelskill.network import Network
117124
>>> network = Network(reaches) # reaches is a list[NetworkReach]
118125
>>> mr = ms.NetworkModelResult(network, name="MyModel")
119-
>>> obs = ms.NodeObservation(data, node=network.find(node="node_A"))
126+
>>> obs = ms.NodeObservation(data, at=network.find(node="node_A"))
120127
>>> extracted = mr.extract(obs)
128+
129+
Notes
130+
-----
131+
A path is read by the constructor its extension belongs to: ``.res1d`` and
132+
``.res11`` by :meth:`Network.from_mike
133+
<modelskill.network.Network.from_mike>`, ``.res`` by
134+
:meth:`Network.from_epanet <modelskill.network.Network.from_epanet>`. An
135+
EPANET file also picks up the ``.resx`` and ``.inp`` companions that share
136+
its folder and stem, since the ``.inp`` is the only one of the three
137+
carrying reach lengths.
138+
139+
Load the network yourself when you need to name the companions, or to keep
140+
memory down on a large file by reading only the nodes, reaches or
141+
quantities you will score.
142+
143+
See Also
144+
--------
145+
modelskill.network.Network.from_mike : Read a MIKE 1D or MIKE 11 result file.
146+
modelskill.network.Network.from_epanet : Read an EPANET result file.
121147
"""
122148

123149
def __init__(
124150
self,
125-
data: Network,
151+
data: Network | str | Path,
126152
*,
127153
name: str | None = None,
128154
item: str | int | None = None,
129155
quantity: Quantity | None = None,
130156
aux_items: Sequence[int | str] | None = None,
131157
):
132-
self.network = data.copy()
158+
if isinstance(data, (str, Path)):
159+
# Imported here, not at module scope, to keep this module importable
160+
# without the optional network dependencies (ADR-010).
161+
from modelskill.network import _network_from_path
162+
163+
# Freshly built, so nothing else holds a reference to copy away from.
164+
self.network = _network_from_path(data)
165+
else:
166+
self.network = data.copy()
133167

134168
ds = self.network.to_dataset()
135169
sel_items = SelectedItems.parse(

tests/test_network.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2145,3 +2145,54 @@ def test_a_failure_without_companions_is_left_alone(self, tmp_path, monkeypatch)
21452145
with pytest.raises(ValueError, match="^boom$"):
21462146
_network_from_path(res)
21472147

2148+
2149+
@requires_mikeio1d
2150+
class TestNetworkModelResultFromPath:
2151+
"""A path spares the caller a separate Network import and load."""
2152+
2153+
_RES1D = "./tests/testdata/network.res1d"
2154+
2155+
def test_a_path_gives_the_same_result_as_a_loaded_network(self):
2156+
from_path = NetworkModelResult(self._RES1D, item="WaterLevel")
2157+
from_network = NetworkModelResult(
2158+
Network.from_mike(self._RES1D), item="WaterLevel"
2159+
)
2160+
2161+
assert from_path.name == from_network.name
2162+
assert from_path.quantity == from_network.quantity
2163+
assert np.array_equal(from_path.nodes, from_network.nodes)
2164+
assert from_path.time.equals(from_network.time)
2165+
2166+
def test_a_str_and_a_path_are_interchangeable(self):
2167+
as_str = NetworkModelResult(self._RES1D, item="WaterLevel")
2168+
as_path = NetworkModelResult(Path(self._RES1D), item="WaterLevel")
2169+
2170+
assert np.array_equal(as_str.nodes, as_path.nodes)
2171+
2172+
def test_the_network_is_reachable_afterwards(self):
2173+
mr = NetworkModelResult(self._RES1D, item="WaterLevel")
2174+
2175+
assert isinstance(mr.network, Network)
2176+
assert mr.network.find(node="100") in mr.nodes
2177+
2178+
def test_extract_works_from_a_path_loaded_model(self):
2179+
mr = NetworkModelResult(self._RES1D, item="WaterLevel")
2180+
node = mr.network.find(node="100")
2181+
obs_data = pd.DataFrame({"sensor": np.zeros(len(mr.time))}, index=mr.time)
2182+
2183+
extracted = mr.extract(NodeObservation(obs_data, at=node))
2184+
2185+
assert isinstance(extracted, NodeModelResult)
2186+
assert extracted.node == node
2187+
2188+
def test_an_epanet_path_reads_its_companions(self, tmp_path):
2189+
res = _copy_epanet(tmp_path, ".resx", ".inp")
2190+
2191+
mr = NetworkModelResult(res, item="Head")
2192+
2193+
assert "Volume" in mr.network.quantities
2194+
assert mr.network._reaches["10"].length == pytest.approx(3209.544)
2195+
2196+
def test_an_unreadable_format_is_refused(self):
2197+
with pytest.raises(NotImplementedError, match="Unsupported file extension"):
2198+
NetworkModelResult("./tests/testdata/obs.dfs0")

0 commit comments

Comments
 (0)