Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
64 commits
Select commit Hold shift + click to select a range
ea41a57
Including subset method draft
jpalm3r Mar 26, 2026
a80d3a1
Minor refactoring
jpalm3r Mar 26, 2026
bfc2f92
Include network subset in notebook
jpalm3r Mar 26, 2026
569d75e
Include copy of reduce method
jpalm3r Mar 26, 2026
74ccf56
Include basic test for reducing network
jpalm3r Mar 26, 2026
63bdc22
Including test for inplace
jpalm3r Mar 26, 2026
8b08099
Fixing mypy issues
jpalm3r Mar 26, 2026
d30789e
merging latest changes from main
jpalm3r Apr 8, 2026
15db0b3
Adding option to filter out node data
jpalm3r Apr 14, 2026
118052d
Fix populating gridpoints
jpalm3r Apr 15, 2026
b3c24b7
Fix tests
jpalm3r Apr 15, 2026
cbf9add
Simplify new tests
jpalm3r Apr 15, 2026
15ff8b1
Add df test
jpalm3r Apr 15, 2026
6d76486
Small fixes and expanding docs
jpalm3r Apr 15, 2026
76190e8
Including to_dataset cell
jpalm3r Apr 15, 2026
11b26db
removing cell
jpalm3r Apr 15, 2026
851ad7a
refining api for selecting node and reaches subset
jpalm3r Apr 15, 2026
6c6852c
small docstring change
jpalm3r Apr 15, 2026
9f65bc8
solution for lazy matching with networkmodelresult and nodeobservation
jpalm3r Apr 15, 2026
21c54d6
Including tests
jpalm3r Apr 15, 2026
9c33eed
Update docs
jpalm3r Apr 15, 2026
0179ece
update docs
jpalm3r Apr 15, 2026
222948a
Introducing edge observation
jpalm3r Apr 17, 2026
d4ad756
Simplifying and reusing breakpoint logic
jpalm3r Apr 17, 2026
9381daa
add todo
jpalm3r Apr 17, 2026
be9f778
Refactor extract_edge
jpalm3r Apr 20, 2026
05b7290
add tests
jpalm3r Apr 20, 2026
e0e9991
lint fix
jpalm3r Apr 20, 2026
c5b6614
Fix mypy issues
jpalm3r Apr 20, 2026
a71bc26
Fix mypy issues
jpalm3r Apr 20, 2026
714f6cc
removing cast
jpalm3r Apr 20, 2026
be8f35e
Add ignore to bypass mypy issue. Format.
jpalm3r Apr 20, 2026
7e385c4
Including at parameter in NodeObservation
jpalm3r Apr 20, 2026
110323e
Merging latest changes in NetworkObservation api
jpalm3r Apr 20, 2026
a0344ef
Apply tuple breakpoint tolerance when resolving network aliases
Copilot Apr 20, 2026
7fd014b
Make tuple alias lookup deterministic and add tolerance edge-case tests
Copilot Apr 20, 2026
1f47f56
Document deterministic tie-break in tuple alias resolution
Copilot Apr 20, 2026
f16a751
Clarify tolerance units and make tolerance tests self-describing
Copilot Apr 20, 2026
8a99715
Add tie-break coverage for tolerance-based tuple alias matching
Copilot Apr 20, 2026
5068f60
Handle empty network data and optimize subset/alias resolution paths
Copilot Apr 21, 2026
edd87d7
Avoid duplicate mutation in reduce_around when copy is false
Copilot Apr 21, 2026
a120582
Removing alias_map property
jpalm3r Apr 21, 2026
fefe0e8
Remove reduce_around
jpalm3r Apr 21, 2026
5aa25da
Removing coords test
jpalm3r Apr 21, 2026
eaec45c
remove unused imports
jpalm3r Apr 21, 2026
c77e56b
Replace "all" for sentinel value None
jpalm3r Apr 22, 2026
45c4293
Merging latest changes
jpalm3r Apr 22, 2026
e113faf
Fixing mypy issues
jpalm3r Apr 22, 2026
e3e8db4
correct _alias_map call
jpalm3r Apr 22, 2026
4d45a25
Simplifying node extraction
jpalm3r Apr 22, 2026
d61cace
Fix mypy issues
jpalm3r Apr 22, 2026
0c84dda
Update docs
jpalm3r Apr 22, 2026
16e546b
Update src/modelskill/model/network.py
jpalm3r Apr 22, 2026
eca6a0e
Update src/modelskill/obs.py
jpalm3r Apr 22, 2026
e9c9a2b
Update docs/user-guide/network.qmd
jpalm3r Apr 22, 2026
3bf0ed9
Update docs/user-guide/network.qmd
jpalm3r Apr 22, 2026
b2a9f3e
Harden edge extraction and add edge extraction regression tests
Copilot Apr 22, 2026
73e25c7
Add edge extraction guards and regression coverage for selective loading
Copilot Apr 22, 2026
81cf448
Return empty DataFrames for unloaded Res1D node and breakpoint data
Copilot Apr 22, 2026
32b4bbb
Remove find option in skill workflow
jpalm3r Apr 23, 2026
940a6eb
Merge branch 'edge_observations' of https://github.com/DHI/modelskill…
jpalm3r Apr 23, 2026
b293291
Rename edge to reach throughout network extension
jpalm3r Apr 24, 2026
ea43233
Fixing leftover edge mentions
jpalm3r Apr 24, 2026
2f0065e
Merge pull request #642 from DHI/rename-edge-to-reach
jpalm3r Apr 24, 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
2 changes: 2 additions & 0 deletions docs/.gitignore
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
/.quarto/
_sidebar.yml
objects.json

**/*.quarto_ipynb
2 changes: 2 additions & 0 deletions docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ website:
- api/PointObservation.qmd
- api/TrackObservation.qmd
- api/NodeObservation.qmd
- api/ReachObservation.qmd
- section: "Model Result"
href: api/model.qmd
contents:
Expand Down Expand Up @@ -170,6 +171,7 @@ quartodoc:
- PointObservation
- TrackObservation
- NodeObservation
- ReachObservation
- title: Model Result
desc: ""
contents:
Expand Down
176 changes: 137 additions & 39 deletions docs/user-guide/network.qmd
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ uv add modelskill[networks]
import pandas as pd
import numpy as np
from typing import Any
from modelskill.network import Network, NetworkNode, NetworkEdge, EdgeBreakPoint
from modelskill.network import Network, NetworkNode, NetworkReach, ReachBreakPoint


class ExampleNode(NetworkNode):
Expand All @@ -51,18 +51,18 @@ class ExampleNode(NetworkNode):
return {}


class ExampleEdge(NetworkEdge):
"""Edge connecting two nodes with a given length."""
class ExampleReach(NetworkReach):
"""Reach connecting two nodes with a given length."""

def __init__(
self,
edge_id: str,
reach_id: str,
start: NetworkNode,
end: NetworkNode,
length: float,
breakpoints: list | None = None,
):
self._id = edge_id
self._id = reach_id
self._start = start
self._end = end
self._length = length
Expand All @@ -89,9 +89,9 @@ class ExampleEdge(NetworkEdge):
return self._breakpoints


class ExampleBreakPoint(EdgeBreakPoint):
def __init__(self, edge_id: str, distance: float, data: pd.DataFrame):
self._id = (edge_id, distance)
class ExampleBreakPoint(ReachBreakPoint):
def __init__(self, reach_id: str, distance: float, data: pd.DataFrame):
self._id = (reach_id, distance)
self._data = data

@property
Expand All @@ -117,12 +117,12 @@ node_s2 = ExampleNode("sensor_2", df2)
node_s3 = ExampleNode("sensor_3", df3)

bp = ExampleBreakPoint("r1", 200.0, df4)
edge1 = ExampleEdge("r1", node_s1, node_s2, length=500.0, breakpoints=[bp])
edge2 = ExampleEdge("r2", node_s2, node_s3, length=300.0)
network = Network(edges=[edge1, edge2])
reach1 = ExampleReach("r1", node_s1, node_s2, length=500.0, breakpoints=[bp])
reach2 = ExampleReach("r2", node_s2, node_s3, length=300.0)
network = Network(reaches=[reach1, reach2])
```

A **Network** represents a 1D pipe or river network as a directed graph: nodes hold timeseries data (e.g. water level at a junction) and edges carry the topology and reach length between them. Break points along an edge (e.g. cross-section chainages) are supported as observation locations too.
A **Network** represents a 1D pipe or river network as a directed graph: nodes hold timeseries data (e.g. water level at a junction) and reaches carry the topology and reach length between them. Break points along a reach (e.g. cross-section chainages) are supported as observation locations too.

The typical workflow is:

Expand Down Expand Up @@ -168,6 +168,48 @@ A `Res1D` network contains multiple levels that are unified into a generic netwo

![How a Res1D file maps to a Network object. Reaches and nodes are re-indexed as integers; boundary nodes expose `find()`/`recall()` round-trip lookups.](../images/res1d_network_mapping.png)

#### Selective loading

Large Res1D files can contain thousands of nodes and gridpoints. Loading all of that data into memory is slow and may cause memory issues — especially when you only need the timeseries at a handful of nodes where observations exist.

`from_res1d` accepts two optional arguments to restrict what gets loaded:

| Argument | Type | Effect |
|---|---|---|
| `nodes` | `None` \| `str` \| `list[str]` | Control which nodes have timeseries data loaded. `None` (default) loads all nodes; `[]` skips all node data; a name or list loads only those nodes. |
| `reaches` | `None` \| `str` \| `list[str]` | Control which reaches have intermediate gridpoint data populated. `None` (default) loads everything; `[]` skips all gridpoints; a name or list of names loads only those reaches. |

::: {.callout-note}
Selective loading only controls **which timeseries are held in memory**. The full network topology (nodes, reaches, lengths) is always constructed so that `find()`, `recall()`, and graph algorithms still work on the complete network.
:::

The most memory-efficient setup — useful when you only care about specific junction nodes — is to pass the node IDs you need and skip all intermediate gridpoints with `reaches=[]`:

```{python}
network_subset = Network.from_res1d(
path_to_res1d,
nodes=["78", "46"],
reaches=[],
)
network_subset
```

If you also need gridpoint data along a particular reach, pass its name (or a list of names):

```{python}
network_subset = Network.from_res1d(
path_to_res1d,
nodes=["78", "46"],
reaches=["94l1"],
)
network_subset
```

When only some nodes are loaded, `to_dataframe()` and `to_dataset()` only contain columns for those nodes — the rest are graph-connected but data-free:

```{python}
network_subset.to_dataframe(sel="WaterLevel").head()
```

## Inspecting the Network

Expand Down Expand Up @@ -219,6 +261,10 @@ network.to_dataframe(sel="WaterLevel").head()

After construction, nodes are re-labelled as integers. Use `find()` to go from original coordinates to the integer ID and `recall()` to go back.

::: {.callout-tip}
When creating `NodeObservation` objects for skill assessment you generally do **not** need to call `find()`. You can pass the original string ID as `node=`, and `NetworkModelResult` will resolve it for you during matching. For breakpoints, use `at=(reach, distance)` rather than `node=`. See [Skill assessment workflow](#skill-assessment-workflow) for details.
:::

```{python}
# Look up a named node by its original id
node_id = network.find(node="117")
Expand All @@ -230,7 +276,7 @@ print(network.recall(node_id))

```{python}
# Look up a break point by reach + chainage
bp_id = network.find(edge="94l1", distance=21.285)
bp_id = network.find(reach="94l1", distance=21.285)
print(f"Break point (94l1, 21.285) → integer id {bp_id}")
print(network.recall(bp_id))
```
Expand All @@ -242,12 +288,12 @@ print(ids)
```

```{python}
# Edge lookup
ids = network.find(edge="58l1", distance="start")
# Reach lookup
ids = network.find(reach="58l1", distance="start")
print(ids)
ids = network.find(edge="58l1", distance=[51.456, 77.185])
ids = network.find(reach="58l1", distance=[51.456, 77.185])
print(ids)
ids = network.find(edge="58l1", distance=["start", 77.185])
ids = network.find(reach="58l1", distance=["start", 77.185])
print(ids)
```

Expand All @@ -267,23 +313,74 @@ mr

`NodeObservation` accepts a file path directly; the observation name is taken from the filename.

The `node=` argument can be specified in three ways, depending on what information you have at hand.

#### Option A — original string alias

Pass the original node identifier from the source format (e.g. the Res1D node name) as a plain string. The `NetworkModelResult` resolves it to the correct integer ID at match time, so you do not need to call `network.find()` yourself:

```{python}
obs_1 = ms.NodeObservation(path_to_sensor_data_1, node=network.find(node="78"))
obs_2 = ms.NodeObservation(path_to_sensor_data_2, node=network.find(node="46"))
obs_1 = ms.NodeObservation(path_to_sensor_data_1, node="78")
obs_2 = ms.NodeObservation(path_to_sensor_data_2, node="46")

cc = ms.match(obs=[obs_1, obs_2], mod=mr)
cc.skill()
```

::: {.callout-note}
Resolution happens inside `ms.match()`. If the string is not found in the network's alias map a `ValueError` is raised with a clear message indicating which alias could not be resolved.
:::

#### Option B — breakpoint by `(reach, distance)` tuple

When your observation sits at a chainage along a reach rather than at a named junction node, you can use the `at` argument and pass a `(reach_id, distance)` tuple. The `NetworkModelResult` looks up the corresponding breakpoint at match time:

```python
obs_bp = ms.NodeObservation(path_to_sensor_data_1, at=("94l1", 21.285))

cc = ms.match(obs=obs_bp, mod=mr)
cc.skill()
```

The tuple form is equivalent to calling `network.find(reach="94l1", distance=21.285)` beforehand and is resolved during matching.

::: {.callout-note}
## Chainage tolerance

Breakpoint distances are matched with a tolerance of **1 × 10⁻³** (i.e. ±0.001 in whatever distance units the network uses). This means that small floating-point discrepancies between the distance you type and the value stored in the network are handled gracefully. If no breakpoint falls within that tolerance a `ValueError` is raised.
:::

### 3. Using ReachObservation for reach-uniform quantities

Some physical quantities — such as **discharge in a pipe** — are conceptually uniform across the whole reach, even though the model stores values at individual breakpoints along the reach. `ReachObservation` lets you associate a timeseries with a named reach without having to identify a specific breakpoint.

When matched against a `NetworkModelResult`, modelskill automatically extracts model data from an arbitrary breakpoint that belongs to the given reach and verifies that all breakpoints on the reach carry equivalent values.

```{python}
obs_q = ms.ReachObservation(path_to_sensor_data_1, reach="94l1", name="Q_94l1")
obs_q
```

Pass the observation to `ms.match()` exactly as you would a `NodeObservation`. modelskill resolves which breakpoint to use automatically:

```{python}
mr_q = NetworkModelResult(network, name="MyModel", item="Discharge")
cc_q = ms.match(obs=obs_q, mod=mr_q)
cc_q.skill()
```

::: {.callout-note}
Use `ReachObservation` when your measured quantity is representative of the whole reach (e.g. discharge, which is constant along a reach in steady flow). If you need to compare a quantity that varies spatially along the reach (e.g. water level at a specific chainage), use a `NodeObservation` with a `(reach, distance)` tuple instead (see [Option B](#option-b-breakpoint-by-reach-distance-tuple) above).
:::

## Development

### Custom network formats

In case you have your network data in a format that is not included in [Building a Network](#building-a-network), you can assemble a `Network` object by subclassing the abstract base classes `NetworkNode` and `NetworkEdge`.
In case you have your network data in a format that is not included in [Building a Network](#building-a-network), you can assemble a `Network` object by subclassing the abstract base classes `NetworkNode` and `NetworkReach`.

`NetworkNode` requires three properties: `id`, `data`, and `boundary`.
`NetworkEdge` requires five: `id`, `start`, `end`, `length`, and `breakpoints`.
`NetworkReach` requires five: `id`, `start`, `end`, `length`, and `breakpoints`.


The following is a simple implementation example:
Expand All @@ -292,7 +389,7 @@ The following is a simple implementation example:
import pandas as pd
import numpy as np
from typing import Any
from modelskill.network import NetworkNode, NetworkEdge, Network
from modelskill.network import NetworkNode, NetworkReach, Network


class ExampleNode(NetworkNode):
Expand All @@ -315,14 +412,14 @@ class ExampleNode(NetworkNode):
return {}


class ExampleEdge(NetworkEdge):
"""Edge connecting two nodes with a given length."""
class ExampleReach(NetworkReach):
"""Reach connecting two nodes with a given length."""

def __init__(
self, edge_id: str, start: NetworkNode, end: NetworkNode, length: float,
self, reach_id: str, start: NetworkNode, end: NetworkNode, length: float,
breakpoints: list | None = None,
):
self._id = edge_id
self._id = reach_id
self._start = start
self._end = end
self._length = length
Expand Down Expand Up @@ -350,7 +447,7 @@ class ExampleEdge(NetworkEdge):
```

::: {.callout-tip}
The three abstract properties that **every** `NetworkNode` subclass must implement are `id`, `data` and `boundary`. If `boundary` is not relevant for your use case, define the property to return an empty dictionary, as in the example above. Similarly, a `NetworkEdge` with no intermediate points can return an empty `breakpoints` list.
The three abstract properties that **every** `NetworkNode` subclass must implement are `id`, `data` and `boundary`. If `boundary` is not relevant for your use case, define the property to return an empty dictionary, as in the example above. Similarly, a `NetworkReach` with no intermediate points can return an empty `breakpoints` list.
:::


Expand All @@ -362,24 +459,24 @@ node_s1 = ExampleNode("sensor_1", df1)
node_s2 = ExampleNode("sensor_2", df2)
node_s3 = ExampleNode("sensor_3", df3)

edge1 = ExampleEdge("r1", node_s1, node_s2, length=500.0)
edge2 = ExampleEdge("r2", node_s2, node_s3, length=300.0)
reach1 = ExampleReach("r1", node_s1, node_s2, length=500.0)
reach2 = ExampleReach("r2", node_s2, node_s3, length=300.0)

network = Network(edges=[edge1, edge2])
network = Network(reaches=[reach1, reach2])
network
```

### Adding break points along a reach

Break points represent intermediate chainage locations on a reach (e.g. cross-sections). Subclass `EdgeBreakPoint` the same way — implement `id` (a `(edge_id, distance)` tuple) and `data`:
Break points represent intermediate chainage locations on a reach (e.g. cross-sections). Subclass `ReachBreakPoint` the same way — implement `id` (a `(reach_id, distance)` tuple) and `data`:

```python
from modelskill.network import EdgeBreakPoint
from modelskill.network import ReachBreakPoint


class ExampleBreakPoint(EdgeBreakPoint):
def __init__(self, edge_id: str, distance: float, data: pd.DataFrame):
self._id = (edge_id, distance)
class ExampleBreakPoint(ReachBreakPoint):
def __init__(self, reach_id: str, distance: float, data: pd.DataFrame):
self._id = (reach_id, distance)
self._data = data

@property
Expand All @@ -393,13 +490,14 @@ class ExampleBreakPoint(EdgeBreakPoint):

# df4 is a DataFrame object that has been loaded in memory
bp = ExampleBreakPoint("r1", 200.0, df4)
edge1 = ExampleEdge("r1", node_s1, node_s2, length=500.0, breakpoints=[bp])
edge2 = ExampleEdge("r2", node_s2, node_s3, length=300.0)
network = Network(edges=[edge1, edge2])
reach1 = ExampleReach("r1", node_s1, node_s2, length=500.0, breakpoints=[bp])
reach2 = ExampleReach("r2", node_s2, node_s3, length=300.0)
network = Network(reaches=[reach1, reach2])
```


## See also

* [API reference — NetworkModelResult](../api/NetworkModelResult.qmd)
* [API reference — NodeObservation](../api/NodeObservation.qmd)
* [API reference — NodeObservation](../api/NodeObservation.qmd)
* [API reference — ReachObservation](../api/ReachObservation.qmd)
Loading
Loading