diff --git a/src/modelskill/comparison/_comparison.py b/src/modelskill/comparison/_comparison.py index aed4cc27e..8838184e5 100644 --- a/src/modelskill/comparison/_comparison.py +++ b/src/modelskill/comparison/_comparison.py @@ -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(): @@ -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 @@ -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 diff --git a/src/modelskill/matching.py b/src/modelskill/matching.py index a514bf217..b7ac4b983 100644 --- a/src/modelskill/matching.py +++ b/src/modelskill/matching.py @@ -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[ @@ -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) diff --git a/src/modelskill/network.py b/src/modelskill/network.py index 071bc01f6..8d8644fe2 100644 --- a/src/modelskill/network.py +++ b/src/modelskill/network.py @@ -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"]) diff --git a/tests/test_comparercollection.py b/tests/test_comparercollection.py index f884ff7f4..bdf4aa807 100644 --- a/tests/test_comparercollection.py +++ b/tests/test_comparercollection.py @@ -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 ======================== diff --git a/tests/test_match.py b/tests/test_match.py index 5664c341b..3324f4788 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -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" diff --git a/tests/test_network.py b/tests/test_network.py index 931860102..6c2c4aa3c 100644 --- a/tests/test_network.py +++ b/tests/test_network.py @@ -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" )