Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
558 changes: 558 additions & 0 deletions notebooks/Network_comparison.ipynb

Large diffs are not rendered by default.

8 changes: 7 additions & 1 deletion src/modelskill/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,13 @@
GridModelResult,
DfsuModelResult,
DummyModelResult,
NetworkModelResult,
)
from .obs import (
observation,
PointObservation,
TrackObservation,
)
from .obs import observation, PointObservation, TrackObservation
from .matching import from_matched, match
from .configuration import from_config
from .settings import options, get_option, set_option, reset_option, load_style
Expand Down Expand Up @@ -90,6 +95,7 @@ def load(filename: Union[str, Path]) -> Comparer | ComparerCollection:
"GridModelResult",
"DfsuModelResult",
"DummyModelResult",
"NetworkModelResult",
"observation",
"PointObservation",
"TrackObservation",
Expand Down
11 changes: 10 additions & 1 deletion src/modelskill/matching.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,13 @@
from .model.dummy import DummyModelResult
from .model.grid import GridModelResult
from .model.track import TrackModelResult
from .obs import Observation, PointObservation, TrackObservation, observation
from .model.network import NetworkModelResult
from .obs import (
Observation,
PointObservation,
TrackObservation,
observation,
)
from .timeseries import TimeSeries
from .types import Period

Expand All @@ -51,6 +57,7 @@
DfsuModelResult,
TrackModelResult,
DummyModelResult,
NetworkModelResult,
]
ObsInputType = Union[
str,
Expand Down Expand Up @@ -452,6 +459,7 @@ def _parse_single_model(
| GridModelResult
| DfsuModelResult
| DummyModelResult
| NetworkModelResult
):
if isinstance(
mod,
Expand Down Expand Up @@ -484,6 +492,7 @@ def _parse_single_model(
GridModelResult,
DfsuModelResult,
DummyModelResult,
NetworkModelResult,
),
)
return mod
2 changes: 2 additions & 0 deletions src/modelskill/model/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
from .dfsu import DfsuModelResult
from .grid import GridModelResult
from .dummy import DummyModelResult
from .network import NetworkModelResult

__all__ = [
"PointModelResult",
Expand All @@ -29,4 +30,5 @@
"GridModelResult",
"model_result",
"DummyModelResult",
"NetworkModelResult",
]
9 changes: 8 additions & 1 deletion src/modelskill/model/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from .track import TrackModelResult
from .dfsu import DfsuModelResult
from .grid import GridModelResult
from .network import NetworkModelResult


from ..types import GeometryType, DataInputType
Expand All @@ -27,7 +28,13 @@ def model_result(
aux_items: Optional[list[int | str]] = None,
gtype: Optional[Literal["point", "track", "unstructured", "grid"]] = None,
**kwargs: Any,
) -> PointModelResult | TrackModelResult | DfsuModelResult | GridModelResult:
) -> (
PointModelResult
| TrackModelResult
| DfsuModelResult
| GridModelResult
| NetworkModelResult
):
"""A factory function for creating an appropriate object based on the data input.

Parameters
Expand Down
62 changes: 62 additions & 0 deletions src/modelskill/model/network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
from __future__ import annotations
from typing import Optional, Sequence, Literal

from mikeio1d import Res1D

from ..timeseries import _parse_network_input
from ..quantity import Quantity
from .point import PointModelResult


class NetworkModelResult(PointModelResult):
"""Model result for a network location.

Construct a NetworkModelResult from a res1d data source.

Parameters
----------
data : str, Path or mikeio1d.Res1D
filename (.res1d) or object with the data
quantity : str
The name of the model result,
by default None (will be set to file name or item name)
reach : str, optional
Reach id in the network
node : int, optional
Node id in the network
chainage : float, optional
Chainage number in its respective reach
gridpoint : int, optional
Index associated to the gridpoints in the reach
name : Optional[str], optional
The name of the model result,
by default None (will be set to file name or item name)
aux_items : Optional[list[int | str]], optional
Auxiliary items, by default None
"""

def __init__(
self,
data: Res1D | str,
quantity: Optional[str | Quantity] = None,
*,
reach: Optional[str] = None,
node: Optional[int] = None,
chainage: Optional[float] = None,
gridpoint: Optional[int | Literal["start", "end"]] = None,
name: Optional[str] = None,
aux_items: Optional[Sequence[int | str]] = None,
) -> None:
if isinstance(quantity, str):
quantity = Quantity.from_mikeio_eum_name(quantity)

variable = quantity.name if isinstance(quantity, Quantity) else None
data = _parse_network_input(
data,
variable=variable,
reach=reach,
node=node,
chainage=chainage,
gridpoint=gridpoint,
)
super().__init__(data=data, name=name, quantity=quantity, aux_items=aux_items)
3 changes: 2 additions & 1 deletion src/modelskill/timeseries/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
from ._timeseries import TimeSeries
from ._point import _parse_point_input
from ._point import _parse_point_input, _parse_network_input
from ._track import _parse_track_input

__all__ = [
"TimeSeries",
"_parse_point_input",
"_parse_track_input",
"_parse_network_input",
]
81 changes: 79 additions & 2 deletions src/modelskill/timeseries/_point.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,14 @@
from collections.abc import Hashable
from dataclasses import dataclass
from pathlib import Path
from typing import Sequence, get_args, List, Optional
from typing import Literal, Sequence, get_args, List, Optional
import numpy as np
import pandas as pd
import xarray as xr

import warnings
import mikeio
import mikeio1d

from ..types import GeometryType, PointType
from ..quantity import Quantity
Expand Down Expand Up @@ -76,9 +78,14 @@ def _parse_point_input(
stem = Path(data).stem
data = xr.open_dataset(data)
name = name or data.attrs.get("name") or stem
elif suffix == ".res1d":
name = name or Path(data).stem
data = mikeio1d.open(data)

elif isinstance(data, mikeio.Dfs0):
data = data.read() # now mikeio.Dataset

elif isinstance(data, mikeio1d.Res1D):
data = data.read() # now mikeio1d.Res1D
# parse items
if isinstance(data, (mikeio.DataArray, pd.Series, xr.DataArray)):
item_name = data.name if data.name is not None else "PointModelResult"
Expand Down Expand Up @@ -177,3 +184,73 @@ def _parse_point_input(

assert isinstance(ds, xr.Dataset)
return ds


def _parse_network_input(
data: mikeio1d.Res1D | str,
variable: Optional[str] = None,
*,
node: Optional[int] = None,
reach: Optional[str] = None,
chainage: Optional[str | float] = None,
gridpoint: Optional[int | Literal["start", "end"]] = None,
) -> pd.Series:
def variable_name_to_res1d(name: str) -> str:
return name.replace(" ", "").replace("_", "")

if isinstance(data, (str, Path)):
if Path(data).suffix == ".res1d":
data = mikeio1d.open(data)
else:
raise ValueError("Input data must have '.res1d' file extension.")

by_node = node is not None
by_reach = reach is not None
with_chainage = chainage is not None
with_index = gridpoint is not None

if by_node and not by_reach:
location = data.nodes[str(node)]
if with_chainage or with_index:
warnings.warn(
"'chainage' or 'gridpoint' are only relevant when passed with 'reach' but they were passed with 'node', so they will be ignored."
)

elif by_reach and not by_node:
location = data.reaches[reach]
if with_index == with_chainage:
raise ValueError(
"Locations accessed by chainage must be specified either by chainage or by index, not both."
)

if with_index and not with_chainage:
gridpoint = 0 if gridpoint == "start" else gridpoint
gridpoint = -1 if gridpoint == "end" else gridpoint
chainage = location.chainages[gridpoint]

location = location[chainage]

else:
raise ValueError(
"A network location must be specified either by node or by reach."
)

if variable is None:
if len(location.quantities) != 1:
raise ValueError(
f"The network location does not have a unique quantity: {location.quantities}, in such case 'variable' argument cannot be None"
)
res1d_name = location.quantities[0]
else:
# After filtering by node or by reach and chainage, a location will only
# have unique quantities
res1d_name = variable_name_to_res1d(variable)
df = location.to_dataframe()
if df.shape[1] == 1:
colname = df.columns[0]
if res1d_name not in colname:
raise ValueError(f"Column name '{colname}' does not contain '{res1d_name}'")

return df.rename(columns={colname: res1d_name})[res1d_name].copy()
else:
raise ValueError(f"Multiple matching quantites found at location: {df.columns}")
2 changes: 2 additions & 0 deletions src/modelskill/types.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import pandas as pd
import xarray as xr
import mikeio
import mikeio1d


class GeometryType(Enum):
Expand Down Expand Up @@ -85,6 +86,7 @@ def from_string(s: str) -> "GeometryType":
mikeio.DataArray,
xr.Dataset,
xr.DataArray,
mikeio1d.Res1D,
]
TrackType = Union[str, Path, pd.DataFrame, mikeio.Dfs0, mikeio.Dataset, xr.Dataset]

Expand Down
52 changes: 52 additions & 0 deletions tests/model/test_network.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
import pytest
import mikeio1d

import numpy as np
import pandas as pd
import modelskill as ms

parse_network = ms.timeseries._parse_network_input


@pytest.fixture
def res1d_datapath() -> str:
return "tests/testdata/network.res1d"


@pytest.fixture
def res1d_object(res1d_datapath) -> mikeio1d.Res1D:
return mikeio1d.open(res1d_datapath)


def test_read_quantity_by_node(res1d_object):
series = parse_network(res1d_object, variable="Water Level", node=3)
df = res1d_object.read()
assert isinstance(series, pd.Series)
assert series.name == "WaterLevel"
np.testing.assert_allclose(df["WaterLevel:3"].values, series.values)


@pytest.mark.parametrize(
"network_kwargs",
[
dict(gridpoint="end"),
dict(gridpoint=2),
dict(chainage=47.683),
dict(chainage="47.683"),
],
)
def test_read_quantity_by_reach(res1d_object, network_kwargs):
series = parse_network(
res1d_object, variable="Water Level", reach="100l1", **network_kwargs
)
df = res1d_object.read()
assert isinstance(series, pd.Series)
assert series.name == "WaterLevel"
np.testing.assert_allclose(df["WaterLevel:100l1:47.6827"].values, series.values)


def test_node_and_reach_as_arguments(res1d_object):
with pytest.raises(
ValueError, match="Item can only be specified either by node or by reach"
):
parse_network(res1d_object, variable="Water Level", reach="100l1", node=2)
Binary file added tests/testdata/network.res1d
Binary file not shown.
Loading