From e95fa87103403a56b37524025e370a824dd7c183 Mon Sep 17 00:00:00 2001 From: Henrik Andersson Date: Fri, 17 Oct 2025 16:44:33 +0200 Subject: [PATCH 1/5] Expose temporal interpolation method --- src/modelskill/matching.py | 89 ++++++++++++++++++++++++++++++++++- src/modelskill/model/point.py | 20 +++++++- tests/test_match.py | 23 +++++++++ 3 files changed, 128 insertions(+), 4 deletions(-) diff --git a/src/modelskill/matching.py b/src/modelskill/matching.py index a3e580f7e..e3d0750bf 100644 --- a/src/modelskill/matching.py +++ b/src/modelskill/matching.py @@ -172,6 +172,22 @@ def match( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, + temporal_method: Literal[ + "akima", + "barycentric", + "cubic", + "krogh", + "linear", + "makima", + "nearest", + "pchip", + "polynomial", + "quadratic", + "quintic", + "slinear", + "spline", + "zero", + ] = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> Comparer: ... @@ -186,6 +202,22 @@ def match( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, + temporal_method: Literal[ + "akima", + "barycentric", + "cubic", + "krogh", + "linear", + "makima", + "nearest", + "pchip", + "polynomial", + "quadratic", + "quintic", + "slinear", + "spline", + "zero", + ] = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> ComparerCollection: ... @@ -199,6 +231,22 @@ def match( gtype=None, max_model_gap=None, spatial_method: Optional[str] = None, + temporal_method: Literal[ + "akima", + "barycentric", + "cubic", + "krogh", + "linear", + "makima", + "nearest", + "pchip", + "polynomial", + "quadratic", + "quintic", + "slinear", + "spline", + "zero", + ] = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ): """Match observation and model result data in space and time @@ -256,6 +304,7 @@ def match( gtype=gtype, max_model_gap=max_model_gap, spatial_method=spatial_method, + temporal_method=temporal_method, obs_no_overlap=obs_no_overlap, ) @@ -308,6 +357,22 @@ def _match_single_obs( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, + temporal_method: Literal[ + "akima", + "barycentric", + "cubic", + "krogh", + "linear", + "makima", + "nearest", + "pchip", + "polynomial", + "quadratic", + "quintic", + "slinear", + "spline", + "zero", + ] = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> Optional[Comparer]: observation = _parse_single_obs(obs, obs_item, gtype=gtype) @@ -324,7 +389,10 @@ def _match_single_obs( raw_mod_data = { m.name: ( - m.extract(observation, spatial_method=spatial_method) + m.extract( + observation, + spatial_method=spatial_method, + ) if isinstance(m, (DfsuModelResult, GridModelResult, DummyModelResult)) else m ) @@ -336,6 +404,7 @@ def _match_single_obs( raw_mod_data=raw_mod_data, max_model_gap=max_model_gap, obs_no_overlap=obs_no_overlap, + temporal_method=temporal_method, ) if matched_data is None: return None @@ -361,6 +430,22 @@ def match_space_time( raw_mod_data: Mapping[str, Alignable], max_model_gap: float | None = None, obs_no_overlap: Literal["ignore", "error", "warn"] = "error", + temporal_method: Literal[ + "akima", + "barycentric", + "cubic", + "krogh", + "linear", + "makima", + "nearest", + "pchip", + "polynomial", + "quadratic", + "quintic", + "slinear", + "spline", + "zero", + ] = "linear", ) -> Optional[xr.Dataset]: """Match observation with one or more model results in time domain. @@ -399,7 +484,7 @@ def match_space_time( for mr in raw_mod_data.values(): # TODO is `align` the correct name for this operation? - aligned = mr.align(observation, max_gap=max_model_gap) + aligned = mr.align(observation, max_gap=max_model_gap, method=temporal_method) if overlapping := set(aligned.filter_by_attrs(kind="aux").data_vars) & set( observation.data.filter_by_attrs(kind="aux").data_vars diff --git a/src/modelskill/model/point.py b/src/modelskill/model/point.py index 799bfdd53..233d6b9ee 100644 --- a/src/modelskill/model/point.py +++ b/src/modelskill/model/point.py @@ -1,5 +1,5 @@ from __future__ import annotations -from typing import Optional, Sequence, Any +from typing import Optional, Sequence, Any, Literal import numpy as np import xarray as xr @@ -98,12 +98,28 @@ def align( observation: Observation, *, max_gap: float | None = None, + method: Literal[ + "akima", + "barycentric", + "cubic", + "krogh", + "linear", + "makima", + "nearest", + "pchip", + "polynomial", + "quadratic", + "quintic", + "slinear", + "spline", + "zero", + ] = "linear", **kwargs: Any, ) -> xr.Dataset: new_time = observation.time dati = self.data.dropna("time").interp( - time=new_time, assume_sorted=True, **kwargs + time=new_time, assume_sorted=True, method=method, **kwargs ) pmr = PointModelResult(dati) diff --git a/tests/test_match.py b/tests/test_match.py index 45b12e7ae..d78c5fd25 100644 --- a/tests/test_match.py +++ b/tests/test_match.py @@ -616,3 +616,26 @@ def test_multiple_models_same_name(tmp_path: Path) -> None: with pytest.raises(ValueError, match="HKZN_local_2017_DutchCoast"): ms.match(obs, [mr1, mr2]) + + +def test_directional_data_use_nearest_temporal_interpolation(): + mod = ms.PointModelResult( + name="mod", + data=pd.Series( + [359, 5], index=pd.date_range("2023-01-01", periods=2, freq="3H") + ), + ) + + obs = ms.PointObservation( + name="obs", + data=pd.Series( + np.zeros(5), index=pd.date_range("2023-01-01", periods=5, freq="1H") + ), + ) + + cmp = ms.match( + obs=obs, + mod=mod, + temporal_method="nearest", + ) + assert cmp.data["mod"].values[1] == pytest.approx(359.0) From 4a5d37a082c152fac67ad4668e351bf18c4a9c8f Mon Sep 17 00:00:00 2001 From: Henrik Andersson Date: Fri, 17 Oct 2025 17:02:12 +0200 Subject: [PATCH 2/5] Define type once --- src/modelskill/matching.py | 96 ++++++----------------------------- src/modelskill/model/point.py | 21 ++------ src/modelskill/types.py | 19 ++++++- 3 files changed, 36 insertions(+), 100 deletions(-) diff --git a/src/modelskill/matching.py b/src/modelskill/matching.py index e3d0750bf..96579c6f7 100644 --- a/src/modelskill/matching.py +++ b/src/modelskill/matching.py @@ -31,7 +31,7 @@ from .model.track import TrackModelResult from .obs import Observation, PointObservation, TrackObservation, observation from .timeseries import TimeSeries -from .types import Period +from .types import Period, InterpMethod TimeDeltaTypes = Union[float, int, np.timedelta64, pd.Timedelta, timedelta] IdxOrNameTypes = Optional[Union[int, str]] @@ -172,22 +172,7 @@ def match( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, - temporal_method: Literal[ - "akima", - "barycentric", - "cubic", - "krogh", - "linear", - "makima", - "nearest", - "pchip", - "polynomial", - "quadratic", - "quintic", - "slinear", - "spline", - "zero", - ] = "linear", + temporal_method: InterpMethod | str = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> Comparer: ... @@ -202,22 +187,7 @@ def match( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, - temporal_method: Literal[ - "akima", - "barycentric", - "cubic", - "krogh", - "linear", - "makima", - "nearest", - "pchip", - "polynomial", - "quadratic", - "quintic", - "slinear", - "spline", - "zero", - ] = "linear", + temporal_method: InterpMethod | str = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> ComparerCollection: ... @@ -231,22 +201,7 @@ def match( gtype=None, max_model_gap=None, spatial_method: Optional[str] = None, - temporal_method: Literal[ - "akima", - "barycentric", - "cubic", - "krogh", - "linear", - "makima", - "nearest", - "pchip", - "polynomial", - "quadratic", - "quintic", - "slinear", - "spline", - "zero", - ] = "linear", + temporal_method: InterpMethod | str = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ): """Match observation and model result data in space and time @@ -281,6 +236,11 @@ def match( 'inverse_distance' (with 5 nearest points), by default "inverse_distance". - For GridModelResult, passed to xarray.interp() as method argument, by default 'linear'. + temporal_method : InterpMethod | str, optional + Temporal interpolation method passed to xarray.interp(), by default 'linear' + Valid options are: "akima", "barycentric", "cubic", "krogh", "linear", + "makima", "nearest", "pchip", "polynomial", "quadratic", + "quintic", "slinear", "spline", "zero". obs_no_overlap: str, optional How to handle observations with no overlap with model results. One of: 'ignore', 'error', 'warn', by default 'error'. @@ -357,22 +317,7 @@ def _match_single_obs( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, - temporal_method: Literal[ - "akima", - "barycentric", - "cubic", - "krogh", - "linear", - "makima", - "nearest", - "pchip", - "polynomial", - "quadratic", - "quintic", - "slinear", - "spline", - "zero", - ] = "linear", + temporal_method: InterpMethod | str = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> Optional[Comparer]: observation = _parse_single_obs(obs, obs_item, gtype=gtype) @@ -430,22 +375,7 @@ def match_space_time( raw_mod_data: Mapping[str, Alignable], max_model_gap: float | None = None, obs_no_overlap: Literal["ignore", "error", "warn"] = "error", - temporal_method: Literal[ - "akima", - "barycentric", - "cubic", - "krogh", - "linear", - "makima", - "nearest", - "pchip", - "polynomial", - "quadratic", - "quintic", - "slinear", - "spline", - "zero", - ] = "linear", + temporal_method: InterpMethod | str = "linear", ) -> Optional[xr.Dataset]: """Match observation with one or more model results in time domain. @@ -465,6 +395,10 @@ def match_space_time( max_model_gap : Optional[TimeDeltaTypes], optional In case of non-equidistant model results (e.g. event data), max_model_gap can be given e.g. as seconds, by default None + obs_no_overlap : Literal['ignore', 'error', 'warn'], optional + How to handle observations with no overlap with model results. One of: 'ignore', 'error', 'warn', by default 'error'. + temporal_method : InterpMethod | str, optional + Temporal interpolation method passed to xarray.interp(), by default 'linear' Returns ------- diff --git a/src/modelskill/model/point.py b/src/modelskill/model/point.py index 233d6b9ee..7dbc2599b 100644 --- a/src/modelskill/model/point.py +++ b/src/modelskill/model/point.py @@ -1,12 +1,12 @@ from __future__ import annotations -from typing import Optional, Sequence, Any, Literal +from typing import Optional, Sequence, Any import numpy as np import xarray as xr import pandas as pd from ..obs import Observation -from ..types import PointType +from ..types import PointType, InterpMethod from ..quantity import Quantity from ..timeseries import TimeSeries, _parse_point_input from ._base import Alignable @@ -98,22 +98,7 @@ def align( observation: Observation, *, max_gap: float | None = None, - method: Literal[ - "akima", - "barycentric", - "cubic", - "krogh", - "linear", - "makima", - "nearest", - "pchip", - "polynomial", - "quadratic", - "quintic", - "slinear", - "spline", - "zero", - ] = "linear", + method: InterpMethod | str = "linear", **kwargs: Any, ) -> xr.Dataset: new_time = observation.time diff --git a/src/modelskill/types.py b/src/modelskill/types.py index 0ba3dcd63..e9ba0818e 100644 --- a/src/modelskill/types.py +++ b/src/modelskill/types.py @@ -1,11 +1,28 @@ from enum import Enum from pathlib import Path -from typing import Union, List, Optional +from typing import Union, List, Optional, Literal from dataclasses import dataclass import pandas as pd import xarray as xr import mikeio +InterpMethod = Literal[ + "akima", + "barycentric", + "cubic", + "krogh", + "linear", + "makima", + "nearest", + "pchip", + "polynomial", + "quadratic", + "quintic", + "slinear", + "spline", + "zero", +] + class GeometryType(Enum): """Geometry type (gtype) of data""" From 8123181557143b4bda5e504bfc7728de87a51035 Mon Sep 17 00:00:00 2001 From: Henrik Andersson Date: Tue, 28 Oct 2025 13:58:36 +0100 Subject: [PATCH 3/5] Stick with plain str --- src/modelskill/matching.py | 16 ++++++++-------- src/modelskill/model/point.py | 4 ++-- src/modelskill/types.py | 19 +------------------ 3 files changed, 11 insertions(+), 28 deletions(-) diff --git a/src/modelskill/matching.py b/src/modelskill/matching.py index 96579c6f7..3ed2e03f4 100644 --- a/src/modelskill/matching.py +++ b/src/modelskill/matching.py @@ -31,7 +31,7 @@ from .model.track import TrackModelResult from .obs import Observation, PointObservation, TrackObservation, observation from .timeseries import TimeSeries -from .types import Period, InterpMethod +from .types import Period TimeDeltaTypes = Union[float, int, np.timedelta64, pd.Timedelta, timedelta] IdxOrNameTypes = Optional[Union[int, str]] @@ -172,7 +172,7 @@ def match( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, - temporal_method: InterpMethod | str = "linear", + temporal_method: str = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> Comparer: ... @@ -187,7 +187,7 @@ def match( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, - temporal_method: InterpMethod | str = "linear", + temporal_method: str = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> ComparerCollection: ... @@ -201,7 +201,7 @@ def match( gtype=None, max_model_gap=None, spatial_method: Optional[str] = None, - temporal_method: InterpMethod | str = "linear", + temporal_method: str = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ): """Match observation and model result data in space and time @@ -236,7 +236,7 @@ def match( 'inverse_distance' (with 5 nearest points), by default "inverse_distance". - For GridModelResult, passed to xarray.interp() as method argument, by default 'linear'. - temporal_method : InterpMethod | str, optional + temporal_method : str, optional Temporal interpolation method passed to xarray.interp(), by default 'linear' Valid options are: "akima", "barycentric", "cubic", "krogh", "linear", "makima", "nearest", "pchip", "polynomial", "quadratic", @@ -317,7 +317,7 @@ def _match_single_obs( gtype: Optional[GeometryTypes] = None, max_model_gap: Optional[float] = None, spatial_method: Optional[str] = None, - temporal_method: InterpMethod | str = "linear", + temporal_method: str = "linear", obs_no_overlap: Literal["ignore", "error", "warn"] = "error", ) -> Optional[Comparer]: observation = _parse_single_obs(obs, obs_item, gtype=gtype) @@ -375,7 +375,7 @@ def match_space_time( raw_mod_data: Mapping[str, Alignable], max_model_gap: float | None = None, obs_no_overlap: Literal["ignore", "error", "warn"] = "error", - temporal_method: InterpMethod | str = "linear", + temporal_method: str = "linear", ) -> Optional[xr.Dataset]: """Match observation with one or more model results in time domain. @@ -397,7 +397,7 @@ def match_space_time( max_model_gap can be given e.g. as seconds, by default None obs_no_overlap : Literal['ignore', 'error', 'warn'], optional How to handle observations with no overlap with model results. One of: 'ignore', 'error', 'warn', by default 'error'. - temporal_method : InterpMethod | str, optional + temporal_method : str, optional Temporal interpolation method passed to xarray.interp(), by default 'linear' Returns diff --git a/src/modelskill/model/point.py b/src/modelskill/model/point.py index 7dbc2599b..511e958c9 100644 --- a/src/modelskill/model/point.py +++ b/src/modelskill/model/point.py @@ -6,7 +6,7 @@ import pandas as pd from ..obs import Observation -from ..types import PointType, InterpMethod +from ..types import PointType from ..quantity import Quantity from ..timeseries import TimeSeries, _parse_point_input from ._base import Alignable @@ -98,7 +98,7 @@ def align( observation: Observation, *, max_gap: float | None = None, - method: InterpMethod | str = "linear", + method: str = "linear", **kwargs: Any, ) -> xr.Dataset: new_time = observation.time diff --git a/src/modelskill/types.py b/src/modelskill/types.py index e9ba0818e..0ba3dcd63 100644 --- a/src/modelskill/types.py +++ b/src/modelskill/types.py @@ -1,28 +1,11 @@ from enum import Enum from pathlib import Path -from typing import Union, List, Optional, Literal +from typing import Union, List, Optional from dataclasses import dataclass import pandas as pd import xarray as xr import mikeio -InterpMethod = Literal[ - "akima", - "barycentric", - "cubic", - "krogh", - "linear", - "makima", - "nearest", - "pchip", - "polynomial", - "quadratic", - "quintic", - "slinear", - "spline", - "zero", -] - class GeometryType(Enum): """Geometry type (gtype) of data""" From 2a0c9b2ff88e8addd848c4e029dcafc35bcde3ec Mon Sep 17 00:00:00 2001 From: Henrik Andersson Date: Tue, 28 Oct 2025 14:28:41 +0100 Subject: [PATCH 4/5] Ignore interpolation type --- src/modelskill/model/point.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/modelskill/model/point.py b/src/modelskill/model/point.py index 511e958c9..5f4089ac9 100644 --- a/src/modelskill/model/point.py +++ b/src/modelskill/model/point.py @@ -104,7 +104,10 @@ def align( new_time = observation.time dati = self.data.dropna("time").interp( - time=new_time, assume_sorted=True, method=method, **kwargs + time=new_time, + assume_sorted=True, + method=method, # type: ignore + **kwargs, ) pmr = PointModelResult(dati) From 10f03e61f859fcc6c0edce2b03a31db3abc82897 Mon Sep 17 00:00:00 2001 From: Henrik Andersson Date: Thu, 22 Jan 2026 17:38:06 +0100 Subject: [PATCH 5/5] Remove docstring from private method --- src/modelskill/matching.py | 32 +++----------------------------- 1 file changed, 3 insertions(+), 29 deletions(-) diff --git a/src/modelskill/matching.py b/src/modelskill/matching.py index 2eedaca28..7c1c6550b 100644 --- a/src/modelskill/matching.py +++ b/src/modelskill/matching.py @@ -401,34 +401,6 @@ def _match_space_time( obs_no_overlap: Literal["ignore", "error", "warn"], temporal_method: str = "linear", ) -> Optional[xr.Dataset]: - """Match observation with one or more model results in time domain. - - and return as xr.Dataset in the format used by modelskill.Comparer - - Will interpolate model results to observation time. - - Note: assumes that observation and model data are already matched in space. - But positions of track observations will be checked. - - Parameters - ---------- - observation : Observation - Observation to be matched - raw_mod_data : Mapping[str, Alignable] - Mapping of model results ready for interpolation - max_model_gap : Optional[TimeDeltaTypes], optional - In case of non-equidistant model results (e.g. event data), - max_model_gap can be given e.g. as seconds, by default None - obs_no_overlap : Literal['ignore', 'error', 'warn'], optional - How to handle observations with no overlap with model results. One of: 'ignore', 'error', 'warn', by default 'error'. - temporal_method : str, optional - Temporal interpolation method passed to xarray.interp(), by default 'linear' - - Returns - ------- - xr.Dataset or None - Matched data in the format used by modelskill.Comparer - """ idxs = [m.time for m in raw_mod_data.values()] period = _get_global_start_end(idxs) @@ -447,7 +419,9 @@ def _match_space_time( observation, spatial_tolerance=spatial_tolerance ) case PointModelResult() as pmr, PointObservation(): - aligned = pmr.align(observation, max_gap=max_model_gap) + aligned = pmr.align( + observation, max_gap=max_model_gap, method=temporal_method + ) case _: raise TypeError( f"Matching not implemented for model type {type(mr)} and observation type {type(observation)}"