Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
41 commits
Select commit Hold shift + click to select a range
c7edaae
Add notebook for PR ideas
otzi5300 Feb 19, 2026
ca5d4b0
Validate timeseries with geomtype=vertical.
otzi5300 Mar 3, 2026
9f9e8d6
parse input for timeseries with vertical coordinate
otzi5300 Mar 3, 2026
88dc54e
VerticalObservation class
otzi5300 Mar 3, 2026
36e7a65
imports
otzi5300 Mar 3, 2026
32068eb
prepare for verticlObs matching
otzi5300 Mar 3, 2026
41c89b1
test and testdata
otzi5300 Mar 3, 2026
6364075
example notebook updated with current vertical features
otzi5300 Mar 3, 2026
5d79772
remove unused imports
otzi5300 Mar 12, 2026
0e6928c
notes on xarray version and slice on z
otzi5300 Mar 12, 2026
05611e3
fix failed test with xarray <2026
otzi5300 Mar 12, 2026
8a2d924
missing arg
otzi5300 Mar 12, 2026
c3c3e4b
additional comment
otzi5300 Mar 12, 2026
869c9fe
additional verticalobservation tests
otzi5300 Mar 12, 2026
978ba27
explicit types in docstring
otzi5300 Mar 12, 2026
4d8f5d0
Merge pull request #597 from DHI/add_support_for_vertical_obs
otzi5300 Mar 12, 2026
73a354a
formatting
otzi5300 Mar 16, 2026
5437d3d
remove unused code & correct comment
otzi5300 Mar 16, 2026
0e4ef27
Add VerticalModelResult class, imports and tests
otzi5300 Mar 16, 2026
9b240f3
sel_items and item must be set for a dataset
otzi5300 Mar 16, 2026
5c9b35c
support extract from dfsu and test
otzi5300 Mar 16, 2026
dbcecb3
update notebook dev examples
otzi5300 Mar 16, 2026
be5012c
use parametrize for test
otzi5300 Mar 17, 2026
9d7bafc
extra check for roundtrip test
otzi5300 Mar 17, 2026
c585d67
also check verticalModel depth values
otzi5300 Mar 17, 2026
6b214d4
Optional to type | none
otzi5300 Mar 17, 2026
ea4c9e3
Merge pull request #609 from DHI/add_support_for_vertical_mod_cleaned
otzi5300 Mar 17, 2026
8814e2c
initial ideas
otzi5300 Mar 17, 2026
a6048a9
add route for vertical model in factory
otzi5300 Mar 25, 2026
13e4b71
formatting
otzi5300 Mar 25, 2026
8ff1da1
align vertical model to vertical obs
otzi5300 Mar 25, 2026
a13eb84
test for matching
otzi5300 Mar 25, 2026
59cd0f6
correct vertical alignment
otzi5300 Mar 26, 2026
165c267
from_matched logic for vertical
otzi5300 Mar 27, 2026
123fe79
tests and updated notebook
otzi5300 Mar 27, 2026
ba20227
type hints, comments and simplifications
otzi5300 Mar 31, 2026
34eec24
dont pass class attr to class funcs
otzi5300 Mar 31, 2026
e99ef0e
should have been fixed in step 2
otzi5300 Mar 31, 2026
d57ecb2
cleaned notebbok and temporary
otzi5300 Mar 31, 2026
145c06e
fix wrong type hint
otzi5300 Mar 31, 2026
f03cb80
Merge pull request #611 from DHI/vertical_matching
otzi5300 Mar 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
435 changes: 435 additions & 0 deletions notebooks/vertical_skill.ipynb

Large diffs are not rendered by default.

1,768 changes: 1,768 additions & 0 deletions notebooks/vertical_skill_temporary.ipynb

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions src/modelskill/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
from .model import (
PointModelResult,
TrackModelResult,
VerticalModelResult,
GridModelResult,
DfsuModelResult,
DummyModelResult,
Expand All @@ -44,6 +45,7 @@
observation,
PointObservation,
TrackObservation,
VerticalObservation,
)
from .matching import from_matched, match
from .configuration import from_config
Expand Down Expand Up @@ -91,12 +93,14 @@ def load(filename: Union[str, Path]) -> Comparer | ComparerCollection:
"model_result",
"PointModelResult",
"TrackModelResult",
"VerticalModelResult",
"GridModelResult",
"DfsuModelResult",
"DummyModelResult",
"observation",
"PointObservation",
"TrackObservation",
"VerticalObservation",
"TimeSeries",
"match",
"from_matched",
Expand Down
9 changes: 8 additions & 1 deletion src/modelskill/comparison/_comparer_plotter.py
Original file line number Diff line number Diff line change
Expand Up @@ -746,7 +746,14 @@ def taylor(
df = df.rename(columns={"_std_obs": "obs_std", "_std_mod": "std"})

pts = [
TaylorPoint(name=r.model, obs_std=r.obs_std, std=r.std, cc=r.cc, marker=marker, marker_size=marker_size)
TaylorPoint(
name=r.model,
obs_std=r.obs_std,
std=r.std,
cc=r.cc,
marker=marker,
marker_size=marker_size,
)
for r in df.itertuples()
]

Expand Down
86 changes: 66 additions & 20 deletions src/modelskill/comparison/_comparison.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from .. import Quantity
from ..types import GeometryType
from ..obs import PointObservation, TrackObservation
from ..model import PointModelResult, TrackModelResult
from ..model import PointModelResult, TrackModelResult, VerticalModelResult
from ..timeseries._timeseries import _validate_data_var_name
from ._comparer_plotter import ComparerPlotter
from ..metrics import _parse_metric
Expand Down Expand Up @@ -175,6 +175,7 @@ class ItemSelection:
aux: Sequence[str]
x: Optional[str] = None
y: Optional[str] = None
z: Optional[str] = None

def __post_init__(self) -> None:
# check that obs, model and aux are unique, and that they are not overlapping
Expand All @@ -189,6 +190,8 @@ def all(self) -> Sequence[str]:
res.append(self.x)
if self.y is not None:
res.append(self.y)
if self.z is not None:
res.append(self.z)
return res

@staticmethod
Expand All @@ -199,6 +202,7 @@ def parse(
aux_items: Optional[Iterable[str | int]] = None,
x_item: str | int | None = None,
y_item: str | int | None = None,
z_item: str | int | None = None,
) -> ItemSelection:
"""Parse item selection.

Expand All @@ -214,26 +218,30 @@ def parse(
raise ValueError("data must contain at least two items")
obs_name = _get_name(obs_item, items) if obs_item else items[0]

# Check existance of items and convert to names

# Check existence of items and convert to names
if aux_items is not None:
if isinstance(aux_items, (str, int)):
aux_items = [aux_items]
aux_names = [_get_name(a, items) for a in aux_items]
else:
aux_names = []

x_name = _get_name(x_item, items) if x_item is not None else None
y_name = _get_name(y_item, items) if y_item is not None else None
z_name = _get_name(z_item, items) if z_item is not None else None

if mod_items is not None:
if isinstance(mod_items, (str, int)):
mod_items = [mod_items]
mod_names = [_get_name(m, items) for m in mod_items]
else:
# Add remaining items as model items
mod_names = [
item for item in items if item not in aux_names and item != obs_name
item
for item in items
if item not in [x_name, y_name, z_name] + aux_names + [obs_name]
]

x_name = _get_name(x_item, items) if x_item is not None else None
y_name = _get_name(y_item, items) if y_item is not None else None

if len(mod_names) == 0:
raise ValueError("no model items were found! Must be at least one")
if obs_name in mod_names:
Expand All @@ -242,7 +250,7 @@ def parse(
raise ValueError("observation item must not be an auxiliary item")

return ItemSelection(
obs=obs_name, model=mod_names, aux=aux_names, x=x_name, y=y_name
obs=obs_name, model=mod_names, aux=aux_names, x=x_name, y=y_name, z=z_name
)


Expand Down Expand Up @@ -302,18 +310,23 @@ def _matched_data_to_xarray(
z: Optional[float] = None,
x_item: str | int | None = None,
y_item: str | int | None = None,
z_item: str | int | None = None,
quantity: Optional[Quantity] = None,
) -> xr.Dataset:
"""Convert matched data to accepted xarray.Dataset format"""
assert isinstance(df, pd.DataFrame)
cols = list(df.columns)
items = ItemSelection.parse(cols, obs_item, mod_items, aux_items, x_item, y_item)

cols = list(df.columns)
items = ItemSelection.parse(
cols, obs_item, mod_items, aux_items, x_item, y_item, z_item
)
# check that x and x_item is not both specified (same for y and y_item)
if (x is not None) and (x_item is not None):
raise ValueError("x and x_item cannot both be specified")
if (y is not None) and (y_item is not None):
raise ValueError("y and y_item cannot both be specified")
if (z is not None) and (z_item is not None):
raise ValueError("z and z_item cannot both be specified")

if x is not None:
try:
Expand All @@ -329,7 +342,6 @@ def _matched_data_to_xarray(
raise TypeError(
f"y must be scalar, not {type(y)}; if y is a coordinate variable, use y_item"
)

# check that items.obs and items.model are numeric
if not np.issubdtype(df[items.obs].dtype, np.number):
raise ValueError(
Expand Down Expand Up @@ -370,16 +382,21 @@ def _matched_data_to_xarray(
else:
ds.coords["y"] = np.nan

# No z-item so far (relevant for ProfileObservation)
if z is not None:
if z_item is not None:
ds = ds.rename({items.z: "z"}).set_coords("z")
elif z is not None:
ds.coords["z"] = z
elif "z" in ds.data_vars:
ds = ds.set_coords("z")
else:
pass # z is optional, if not provided, we don't add it as a coordinate

if ds.coords["x"].size == 1:
if "z" in ds.coords:
ds.attrs["gtype"] = str(GeometryType.VERTICAL)
elif ds.coords["x"].size == 1:
ds.attrs["gtype"] = str(GeometryType.POINT)
else:
ds.attrs["gtype"] = str(GeometryType.TRACK)
# TODO
# ds.attrs["gtype"] = str(GeometryType.PROFILE)

if quantity is None:
q = Quantity.undefined()
Expand Down Expand Up @@ -444,7 +461,10 @@ class Comparer:
def __init__(
self,
matched_data: xr.Dataset,
raw_mod_data: dict[str, PointModelResult | TrackModelResult] | None = None,
raw_mod_data: dict[
str, PointModelResult | TrackModelResult | VerticalModelResult
]
| None = None,
) -> None:
self.data = _parse_dataset(matched_data)
self.raw_mod_data = (
Expand All @@ -464,7 +484,9 @@ def __init__(
@staticmethod
def from_matched_data(
data: xr.Dataset | pd.DataFrame,
raw_mod_data: Optional[Dict[str, PointModelResult | TrackModelResult]] = None,
raw_mod_data: Optional[
Dict[str, PointModelResult | TrackModelResult | VerticalModelResult]
] = None,
obs_item: str | int | None = None,
mod_items: Optional[Iterable[str | int]] = None,
aux_items: Optional[Iterable[str | int]] = None,
Expand All @@ -475,6 +497,7 @@ def from_matched_data(
z: Optional[float] = None,
x_item: str | int | None = None,
y_item: str | int | None = None,
z_item: str | int | None = None,
quantity: Optional[Quantity] = None,
) -> "Comparer":
"""Initialize from compared data"""
Expand All @@ -491,9 +514,11 @@ def from_matched_data(
z=z,
x_item=x_item,
y_item=y_item,
z_item=z_item,
quantity=quantity,
)
data.attrs["weight"] = weight
# FIXME: Consider changes needed for vertical
return Comparer(matched_data=data, raw_mod_data=raw_mod_data)

def __repr__(self):
Expand Down Expand Up @@ -734,7 +759,9 @@ def _to_observation(self) -> PointObservation | TrackObservation:
else:
raise NotImplementedError(f"Unknown gtype: {self.gtype}")

def _to_model(self) -> list[PointModelResult | TrackModelResult]:
def _to_model(
self,
) -> list[PointModelResult | TrackModelResult | VerticalModelResult]:
mods = list(self.raw_mod_data.values())
return mods

Expand Down Expand Up @@ -1193,6 +1220,7 @@ def to_dataframe(self) -> pd.DataFrame:
"""Convert matched data to pandas DataFrame

Include x, y coordinates only if gtype=track
Include z coordinate only if gtype=vertical

Returns
-------
Expand All @@ -1208,6 +1236,16 @@ def to_dataframe(self) -> pd.DataFrame:
# make sure that x, y cols are first
cols = ["x", "y"] + [c for c in df.columns if c not in ["x", "y"]]
return df[cols]
elif self.gtype == str(GeometryType.VERTICAL):
df = self.data.drop_vars(["x", "y"]).to_dataframe()
# make sure that Observations is first and z is last
cols = (
["Observation"]
+ [c for c in df.columns if c not in ["Observation", "z"]]
+ ["z"]
)
return df[cols]

else:
raise NotImplementedError(f"Unknown gtype: {self.gtype}")

Expand All @@ -1221,6 +1259,8 @@ def save(self, filename: Union[str, Path]) -> None:
"""
ds = self.data

# FIXME: Consider changes needed for vertical

# add self.raw_mod_data to ds with prefix 'raw_' to avoid name conflicts
# an alternative strategy would be to use NetCDF groups
# https://docs.xarray.dev/en/stable/user-guide/io.html#groups
Expand Down Expand Up @@ -1257,8 +1297,14 @@ def load(filename: Union[str, Path]) -> "Comparer":
if data.gtype == "track":
return Comparer(matched_data=data)

if data.gtype == "vertical":
# FIXME: consider during Phase3
return Comparer(matched_data=data)

if data.gtype == "point":
raw_mod_data: Dict[str, PointModelResult | TrackModelResult] = {}
raw_mod_data: Dict[
str, PointModelResult | TrackModelResult | VerticalModelResult
] = {}

for var in data.data_vars:
var_name = str(var)
Expand Down
17 changes: 13 additions & 4 deletions src/modelskill/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@
from .model.dummy import DummyModelResult
from .model.grid import GridModelResult
from .model.track import TrackModelResult
from .obs import Observation, PointObservation, TrackObservation
from .model.vertical import VerticalModelResult
from .obs import Observation, PointObservation, TrackObservation, VerticalObservation
from .timeseries import TimeSeries
from .types import Period

Expand All @@ -40,6 +41,7 @@
GridModelResult,
DfsuModelResult,
TrackModelResult,
VerticalModelResult,
DummyModelResult,
]
MRInputType = Union[
Expand All @@ -56,7 +58,7 @@
TimeSeries,
MRTypes,
]
ObsTypes = Union[PointObservation, TrackObservation]
ObsTypes = Union[PointObservation, TrackObservation, VerticalObservation]
ObsInputType = Union[
str,
Path,
Expand Down Expand Up @@ -85,6 +87,7 @@ def from_matched(
z: Optional[float] = None,
x_item: str | int | None = None,
y_item: str | int | None = None,
z_item: str | int | None = None,
) -> Comparer:
"""Create a Comparer from data that is already matched (aligned).

Expand Down Expand Up @@ -113,6 +116,8 @@ def from_matched(
Name of x item, only relevant for track data
y_item: [str, int], optional
Name of y item, only relevant for track data
z_item: [str, int], optional
Name of z item, only relevant for vertical data for which it must be provided

Returns
-------
Expand Down Expand Up @@ -165,6 +170,7 @@ def from_matched(
z=z,
x_item=x_item,
y_item=y_item,
z_item=z_item,
quantity=quantity,
)

Expand Down Expand Up @@ -351,7 +357,9 @@ def _get_global_start_end(idxs: Iterable[pd.DatetimeIndex]) -> Period:

def _match_space_time(
observation: Observation,
raw_mod_data: Mapping[str, PointModelResult | TrackModelResult],
raw_mod_data: Mapping[
str, PointModelResult | TrackModelResult | VerticalModelResult
],
max_model_gap: float | None,
spatial_tolerance: float,
obs_no_overlap: Literal["ignore", "error", "warn"],
Expand All @@ -375,6 +383,8 @@ def _match_space_time(
)
case PointModelResult() as pmr, PointObservation():
aligned = pmr.align(observation, max_gap=max_model_gap)
case VerticalModelResult() as vmr, VerticalObservation():
aligned = vmr.align(observation)
case _:
raise TypeError(
f"Matching not implemented for model type {type(mr)} and observation type {type(observation)}"
Expand All @@ -386,7 +396,6 @@ def _match_space_time(
raise ValueError(
f"Aux variables are not allowed to have identical names. Choose either aux from obs or model. Overlapping: {overlapping}"
)

for dv in aligned:
data[dv] = aligned[dv]

Expand Down
2 changes: 2 additions & 0 deletions src/modelskill/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,13 +18,15 @@
from .factory import model_result
from .point import PointModelResult
from .track import TrackModelResult
from .vertical import VerticalModelResult
from .dfsu import DfsuModelResult
from .grid import GridModelResult
from .dummy import DummyModelResult

__all__ = [
"PointModelResult",
"TrackModelResult",
"VerticalModelResult",
"DfsuModelResult",
"GridModelResult",
"model_result",
Expand Down
Loading
Loading