Skip to content

Locate network observations with a MIKE+ database - #698

Draft
jpalm3r wants to merge 3 commits into
epanet-link-quantitiesfrom
read-sqlite
Draft

Locate network observations with a MIKE+ database#698
jpalm3r wants to merge 3 commits into
epanet-link-quantitiesfrom
read-sqlite

Conversation

@jpalm3r

@jpalm3r jpalm3r commented Aug 10, 2026

Copy link
Copy Markdown
Collaborator

Building a NodeObservation today means knowing which node each sensor sits on, which usually ends up as a hand-written SQL query in a notebook. A MIKE+ project already records that: m_Measurement says which file and item each measured timeseries lives in, m_Station says where in the network it belongs.

This adds a db argument to NodeObservation.from_multiple and ReachObservation.from_multiple that does the lookup.

quantity = "Pressure"

network = Network.from_epanet("model.res", quantities=quantity)
network_model = ms.NetworkModelResult(network, item=quantity)
obs = ms.NodeObservation.from_multiple(
    data="calibration.dfs0", db="model.sqlite", quantity=quantity
)
cc = ms.match(obs, network_model)

The mapping is keyed by item, not by location

One dfs0 item maps to exactly one location, but a location can carry several items — two pressure transmitters either side of a check valve, three flow meters on one pipe. So the lookup returns one row per item and one observation is created per row. The existing nodes={location: item} dict cannot express this, since a location can only appear once as a key; it still works, and its docstring now says what it cannot do.

Which class you get is decided by the database

m_Station.locationtype says whether a station sits on a node or a link, and it is load-bearing: some locationid values match both a node alias and a reach id, so trying node-first-then-reach would silently resolve the wrong one. Junctions and tanks become NodeObservation, links become ReachObservation, and a link carrying a chainage becomes a NodeObservation at that breakpoint. Asking one class for a quantity the database places on the other raises and names the class you want.

Contained behind a fixed contract

network/_mikeplus.py returns a fixed set of columns — item_name, name, location, kind, quantity. Table names, the join, the locationtype codes and the resitemname encoding stay inside that module, so a change to the database layout is a change to one file. obs.py never sees a MIKE+ concept.

network.py becomes network/__init__.py to make room for it. Pure move — every import in the repo is from modelskill.network import ... and is unaffected.

Other changes

  • ReachObservation.from_multiple, mirroring the NodeObservation classmethod, which did not exist before.
  • from_multiple(nodes=...) keys now accept aliases and (reach_id, distance) breakpoints. NodeObservation.at already took all three; only from_multiple advertised integers.
  • Bug fix: Network.to_dataset() keyed its data variables by quantity name but never wrote it to the DataArray attrs, so NetworkModelResult reported Quantity.undefined() and skill tables labelled the model column with the observation's quantity. res1d and EPANET files carry a name without a unit, and Quantity.from_cf_attrs needs both, so it falls back to the name alone.

Notes for review

  • Quantity comes from the database, not the data. Calibration dfs0 files routinely carry EUM type Undefined for every item, so the database is the only reliable source for the name; the unit still comes from the data.
  • Items the database cannot place raise by default, separating the two causes — a station that exists but has no measurement registered for the file, and an item not in the database at all. on_missing="skip" builds from the rest.
  • Tests run against a synthetic MIKE+ database built in a fixture, so nothing depends on a real project file.
  • match() still does not compare an observation's quantity with the model's; that is a wider change, tracked in Quantity.is_compatible reports undefined quantities as incompatible #697.

Verified end to end against a real EPANET model and its MIKE+ database: 30 observations, 30 comparers.

@jpalm3r
jpalm3r requested a review from ecomodeller as a code owner August 10, 2026 11:28
Comment thread src/modelskill/obs.py
on_missing: Literal["raise", "skip"] = "raise",
aux_items: list[int | str] | None = None,
attrs: dict | None = None,
) -> list[NodeObservation]: ...
Comment thread src/modelskill/obs.py
quantity: Quantity | None = None,
aux_items: list[int | str] | None = None,
attrs: dict | None = None,
) -> list[ReachObservation]: ...
Comment thread src/modelskill/obs.py
on_missing: Literal["raise", "skip"] = "raise",
aux_items: list[int | str] | None = None,
attrs: dict | None = None,
) -> list[ReachObservation]: ...
@jpalm3r
jpalm3r marked this pull request as draft August 10, 2026 11:30
@jpalm3r
jpalm3r force-pushed the read-sqlite branch 2 times, most recently from db99d3c to 29aa481 Compare August 10, 2026 13:50
jpalm3r and others added 3 commits August 11, 2026 10:40
The MIKE+ database reader lands next and does not belong in the same file
as the Network class. Move network.py to network/__init__.py unchanged so
the split that follows is a pure addition.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A network result file identifies nodes and reaches by ID, but observations
are usually recorded against real-world station names held in the MIKE+
setup database. Read that sqlite database to resolve a station to the node
or reach it sits on, so observations can be placed without hand-mapping
every ID.

Dataset variables also gain a long_name attribute, so a quantity keeps its
label once it reaches xarray.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant