Skip to content
Open
12 changes: 9 additions & 3 deletions src/modelskill/comparison/_comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -1361,7 +1361,7 @@ def save(self, filename: Union[str, Path]) -> None:
# https://docs.xarray.dev/en/stable/user-guide/io.html#groups

# There is no need to save raw data for track data, since it is identical to the matched data
if self.gtype == "point":
if self.gtype in ("point", "node"):
ds = self.data.copy() # copy needed to avoid modifying self.data

for key, ts_mod in self.raw_mod_data.items():
Expand Down Expand Up @@ -1396,7 +1396,7 @@ def load(filename: Union[str, Path]) -> "Comparer":
# FIXME: consider during Phase3
return Comparer(matched_data=data)

if data.gtype == "point":
if data.gtype in ("point", "node"):
raw_mod_data: Dict[
str,
PointModelResult
Expand All @@ -1412,7 +1412,13 @@ def load(filename: Union[str, Path]) -> "Comparer":
ds = data[[var_name]].rename(
{"_time_raw_" + new_key: "time", var_name: new_key}
)
ts = PointModelResult(data=ds, name=new_key)
ts: PointModelResult | NodeModelResult
if data.gtype == "node":
ts = NodeModelResult(
data=ds, node=int(ds.coords["node"].item()), name=new_key
)
else:
ts = PointModelResult(data=ds, name=new_key)

raw_mod_data[new_key] = ts

Expand Down
20 changes: 20 additions & 0 deletions src/modelskill/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,24 @@ def _get_global_start_end(idxs: Iterable[pd.DatetimeIndex]) -> Period:
return Period(start=min(starts), end=max(ends))


def _check_timezone_compatibility(
observation: Observation,
raw_mod_data: Mapping[
str,
PointModelResult | TrackModelResult | VerticalModelResult | NodeModelResult,
],
) -> None:
obs_tz = observation.data.time.to_index().tz
for name, mr in raw_mod_data.items():
mr_tz = mr.data.time.to_index().tz
if (obs_tz is None) != (mr_tz is None):
raise ValueError(
f"Timezone mismatch between observation '{observation.name}' "
f"(tz={obs_tz}) and model result '{name}' (tz={mr_tz}). "
"Both must be either timezone-aware or timezone-naive."
)


def _match_space_time(
observation: Observation,
raw_mod_data: Mapping[
Expand All @@ -399,6 +417,8 @@ def _match_space_time(
spatial_tolerance: float,
obs_no_overlap: Literal["ignore", "error", "warn"],
) -> xr.Dataset | None:
_check_timezone_compatibility(observation, raw_mod_data)

idxs = [m.time for m in raw_mod_data.values()]
period = _get_global_start_end(idxs)

Expand Down
4 changes: 3 additions & 1 deletion src/modelskill/network.py
Original file line number Diff line number Diff line change
Expand Up @@ -497,7 +497,9 @@ def _generate_reaches_dict(reaches: Sequence[NetworkReach]) -> dict[str, Network
@staticmethod
def _build_dataframe(g: nx.Graph) -> pd.DataFrame:
data_in_nodes = {
k: v["data"] for k, v in g.nodes.items() if v["data"] is not None
k: v["data"]
for k, v in g.nodes.items()
if v["data"] is not None and not v["data"].empty
}
if len(data_in_nodes) == 0:
columns = pd.MultiIndex.from_arrays([[], []], names=["node", "quantity"])
Expand Down
41 changes: 41 additions & 0 deletions tests/test_comparercollection.py
Original file line number Diff line number Diff line change
Expand Up @@ -451,6 +451,47 @@ def test_save_and_load_preserves_raw_model_data(cc, tmp_path):
assert cc2["fake point obs"].raw_mod_data["m1"].name == "m1"


@pytest.fixture
def node_comparer() -> modelskill.comparison.Comparer:
"""A comparer built by matching a NodeObservation against a NetworkModelResult (node gtype)."""
pytest.importorskip("networkx")
from modelskill.model.network import NetworkModelResult
from modelskill.network import Network, BasicNode, BasicReach
from modelskill.obs import NodeObservation

time = pd.date_range("2019-01-01", periods=6, freq="D")
node_a_data = pd.DataFrame(
{"WaterLevel": [1.0, 2.0, 3.0, 4.0, 5.0, 6.0]}, index=time
)
node_b_data = pd.DataFrame(
{"WaterLevel": [1.1, 2.2, 3.3, 4.4, 5.5, 6.6]}, index=time
)
reach = BasicReach(
"r1", BasicNode("123", node_a_data), BasicNode("456", node_b_data), length=100.0
)
network = Network([reach])

nmr = NetworkModelResult(network, name="Network_Model")
node_id = network.find(node="123")
obs = NodeObservation(node_a_data, at=node_id, name="Node_123_Obs")

return ms.match(obs, nmr)


def test_save_and_load_round_trips_node_gtype_raw_data(node_comparer, tmp_path):
"""Node-gtype comparers must survive a save()/load() round trip like point comparers do."""
cc = ms.ComparerCollection([node_comparer])
fn = tmp_path / "test_cc_node.msk"
cc.save(fn)

cc2 = ms.load(fn)

assert cc2[0].gtype == "node"
assert len(cc2[0].raw_mod_data["Network_Model"]) == len(
node_comparer.raw_mod_data["Network_Model"]
)


# ======================== plotting ========================


Expand Down
20 changes: 20 additions & 0 deletions tests/test_match.py
Original file line number Diff line number Diff line change
Expand Up @@ -936,6 +936,26 @@ def test_match_obs_model_pos_args_wrong_order_helpful_error_message():
ms.match(mr, obs)


def test_match_raises_clear_error_on_tz_aware_vs_naive_mismatch():
"""Mismatched tz-awareness must raise a clear ModelSkill error, not a bare pandas TypeError."""
mr = ms.PointModelResult(
data=pd.Series(
[1.0, 2.0, 3.0], index=pd.date_range("2020-01-01", periods=3, freq="h")
),
name="Model",
)
obs = ms.PointObservation(
data=pd.Series(
[1.0, 2.0, 3.0],
index=pd.date_range("2020-01-01", periods=3, freq="h", tz="UTC"),
),
name="Station",
)

with pytest.raises(ValueError, match="[Tt]imezone"):
ms.match(obs, mr)


def test_multiple_models_same_name(tmp_path: Path) -> None:
obs = ms.PointObservation(
"tests/testdata/SW/HKNA_Hm0.dfs0", item=0, x=4.2420, y=52.6887, name="HKNA"
Expand Down
19 changes: 19 additions & 0 deletions tests/test_network.py
Original file line number Diff line number Diff line change
Expand Up @@ -584,6 +584,25 @@ def test_dataframe_from_partial_network():
assert set(nodes_in_df) == set([network.find(n) for n in selected_nodes])


@pytest.mark.skipif(
sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14"
)
def test_nodes_filtered_network_keeps_datetime_index():
"""Topology-only nodes must not degrade the time index to object dtype.

A nodes-filtered network keeps the full topology, storing empty data for
the unselected nodes. Concatenating those empty (RangeIndex) frames must
not corrupt the DatetimeIndex, otherwise ms.match() later fails with
"time must be datetime".
"""
path_to_file = "./tests/testdata/network.res1d"
network = Network.from_res1d(path_to_file, nodes=["108", "101"], reaches=[])

assert isinstance(network._df.index, pd.DatetimeIndex)
assert network._df.index.dtype == "datetime64[ns]"
assert network.to_dataset()["time"].dtype == np.dtype("datetime64[ns]")


@pytest.mark.skipif(
sys.version_info >= (3, 14), reason="mikeio1d requires Python < 3.14"
)
Expand Down
Loading