Networks

Network support depends on mikeio1d, which reads the result files and builds the network, and which is not installed by default. Install it alongside modelskill with the network extra:

uv pip install modelskill[network]

or

uv add modelskill[network]

A network is a 1D pipe or river network read as a graph: nodes hold timeseries data (water level at a junction, say), reaches carry the topology and the length between them, and break points along a reach are locations in their own right.

The workflow is the usual four steps, with the network standing in for a grid or a mesh:

result file  →  NetworkModelResult  →  match()  →  Comparer

Where the network comes from

Reading the file and building the graph is mikeio1d’s job, not modelskill’s. Network.open reads MIKE 1D (.res1d), MIKE 11 (.res11) and EPANET (.res) results, finds the EPANET companion files, and says so when a format cannot be turned into a network. See its documentation for the companions, and for resolve, which looks up a location by the name the model gives it.

modelskill takes the file path directly, and opens it for you:

import modelskill as ms

mr = ms.NetworkModelResult(path_to_res1d, name="MyModel", item="WaterLevel")
mr
<NetworkModelResult>: MyModel

A model result reads no timeseries when it is built. Each observation’s location is read when it is matched, and only that location, so scoring a few sensors against a large model reads a few series.

Open the network yourself when you need to name EPANET companion files, or to build model results for several quantities over one network:

from mikeio1d.network import Network

network = Network.open(path_to_res1d)
ms.NetworkModelResult(network, name="MyModel", item="WaterLevel")
<NetworkModelResult>: MyModel

Skill assessment workflow

1. The model result

mr above is the model side of the comparison, for one quantity. It reads a location when an observation is matched there.

2. Create NodeObservations and compute skill

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

The at= argument takes either of two forms, depending on what you have at hand.

NoteMIKE 1D vocabulary vs the modelskill API

In MIKE 1D, “nodes” are the named connection points in the 1D network (manholes, junctions, outfalls) while “reaches” are the pipe or channel segments that connect them. The modelskill API uses NodeObservation more broadly: the at= argument accepts either the node’s name or a (reach_id, position) break point tuple that identifies a specific chainage along a reach. If you only need a reach-level quantity (uniform across the reach), use ReachObservation with reach= instead.

Option A — the node’s name

Pass the node’s name in the model, e.g. the Res1D node name. It is resolved against the network at match time:

obs_1 = ms.NodeObservation(path_to_sensor_data_1, at="98")

cc = ms.match(obs=obs_1, mod=mr)
cc.skill()
n bias rmse urmse mae cc si r2
observation
network_sensor_1 109 0.011133 0.091439 0.090759 0.073117 0.982759 0.000466 0.965167
Note

Resolution happens inside ms.match(). A name the network does not hold raises a ValueError.

Option B — breakpoint by (reach, position) 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, position) tuple. The NetworkModelResult looks up the corresponding breakpoint at match time:

obs_bp = ms.NodeObservation(path_to_sensor_data_2, at=("117l1", 48.7))

cc = ms.match(obs=obs_bp, mod=mr)
cc.skill()
n bias rmse urmse mae cc si r2
observation
network_sensor_2 80 0.004811 0.10659 0.106482 0.084723 0.854313 0.00055 0.725381

The tuple form is resolved during matching, as network.resolve(("117l1", 48.7)) would resolve it.

NoteChainage tolerance

By default a position matches a break point within the tolerance mikeio1d uses to decide two chainages are the same place, which absorbs floating-point rounding between the position you type and the value stored in the network. A measured chainage is rarely that close; pass position_tol= to snap it onto the nearest break point within that distance that carries the item being scored:

obs_bp = ms.NodeObservation(path_to_sensor_data_2, at=("117l1", 48.0), position_tol=1.0)

The comparer keeps the chainage you gave; mr.extract(obs_bp).at gives the break point the model was read at. If no break point falls within the tolerance, a ValueError says the location is not found and points to network.addresses(reach=...), which lists the break points along that reach. One is raised too when the location exists but carries no data for the quantity you are scoring - MIKE 1D stores water level and discharge at alternating grid points along a reach.

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 extracts model data from the breakpoints on the given reach that carry the quantity and hold data for it. When there are several, they must carry equivalent values, and the one with the lowest position is used. The comparer sits on the reach, so cc.at is the reach name; mr_q.extract(obs_q).at gives the break point the model was read at.

obs_q = ms.ReachObservation(path_to_sensor_data_4, reach="116l1", name="Q_116l1")
obs_q
<ReachObservation>: Q_116l1
Location: 116l1
Time: 1994-08-07 16:34:50.260000 - 1994-08-07 18:35:06.983900
Quantity: Undefined

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

mr_q = ms.NetworkModelResult(network, name="MyModel", item="Discharge")
cc_q = ms.match(obs=obs_q, mod=mr_q)
cc_q.skill()
n bias rmse urmse mae cc si r2
observation
Q_116l1 108 0.000019 0.051999 0.051999 0.041013 0.995003 0.131325 0.989936
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, position) tuple instead (see Option B above).

See also