Skip to content

Commit 6e7dd97

Browse files
verheemvisr
andauthored
Implicit-listen-edge-follow-up (#2969)
fixes #2952 --------- Co-authored-by: Martijn Visser <mgvisser@gmail.com>
1 parent 9af4c4f commit 6e7dd97

5 files changed

Lines changed: 190 additions & 26 deletions

File tree

docs/reference/usage.qmd

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,9 @@ There are currently 4 possible link types:
321321
The LevelDemand and FlowDemand nodes use control links to indicate which nodes it will assign demands to.
322322
3. "listen": The listen links define which nodes are listened to by control nodes.
323323
They point from the listened node to the control node.
324-
The control node tables define listening behavior (`listen_node_id`, variable, weight, look-ahead), and the Python API automatically adds missing `listen` links when writing a model.
324+
The control node tables define listening behavior (`listen_node_id`, variable, weight, look-ahead).
325+
Listen links are **automatically created** when a control link is added via `model.link.add`: the Python API reads the `listen_node_id` columns from the control node's tables and adds any missing listen links immediately.
326+
If you want custom geometries, listen links can also be added manually with `model.link.add`, where `from_node` is a listenable node (e.g. a Basin) and `to_node` is a control node (e.g. DiscreteControl or PidControl). The link type is automatically inferred as `"listen"` from the node types.
325327
4. "observation": Observation links point from an Observation node to the node being observed. These links are not used in the simulation core, but allow attaching time series data for reference, validation, or visualization.
326328

327329
column | type | restriction

python/ribasim/ribasim/geometry/link.py

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import warnings
22
from pathlib import Path
3+
from typing import TYPE_CHECKING, cast
34

45
import matplotlib.pyplot as plt
56
import numpy as np
@@ -26,6 +27,9 @@
2627

2728
from .base import _GeoBaseSchema
2829

30+
if TYPE_CHECKING:
31+
from ribasim.model import Model
32+
2933
__all__ = ("LinkTable",)
3034

3135
SPATIALCONTROLNODETYPES = {
@@ -195,6 +199,72 @@ def add(
195199
)
196200
self._used_link_ids.add(link_id)
197201

202+
# When a control link is added from a listen-capable control node,
203+
# automatically add listen links based on listen_node_id in its tables.
204+
if link_type == "control" and from_node.node_type in LISTENCONTROLNODETYPES:
205+
self._add_listen_links_for_control_node(from_node)
206+
207+
def _collect_listen_node_ids(self, control_node: NodeData) -> set[int]:
208+
"""Return the set of ``listen_node_id`` values for *control_node*.
209+
210+
Reads the relevant tables from the parent model based on the control
211+
node type. Returns an empty set when the parent model is unavailable
212+
or the tables are empty.
213+
"""
214+
model = cast("Model | None", self._parent)
215+
if model is None:
216+
return set()
217+
218+
tables: list[pd.DataFrame | None] = []
219+
if control_node.node_type == "PidControl":
220+
tables = [model.pid_control.static.df, model.pid_control.time.df]
221+
elif control_node.node_type == "DiscreteControl":
222+
tables = [model.discrete_control.variable.df]
223+
elif control_node.node_type == "ContinuousControl":
224+
tables = [model.continuous_control.variable.df]
225+
226+
listen_node_ids: set[int] = set()
227+
for table in tables:
228+
if table is None:
229+
continue
230+
mask = table["node_id"] == control_node.node_id
231+
listen_node_ids.update(
232+
int(x) for x in table.loc[mask, "listen_node_id"].unique()
233+
)
234+
return listen_node_ids
235+
236+
def _add_listen_links_for_control_node(self, control_node: NodeData) -> None:
237+
"""Add listen links for *control_node* based on its ``listen_node_id`` columns.
238+
239+
Skips any listen links that already exist in the link table.
240+
"""
241+
model = cast("Model | None", self._parent)
242+
if model is None:
243+
return
244+
245+
listen_node_ids = self._collect_listen_node_ids(control_node)
246+
if not listen_node_ids:
247+
return
248+
249+
assert self.df is not None
250+
assert model.node.df is not None
251+
for listen_node_id in sorted(listen_node_ids):
252+
already_exists = (
253+
(self.df["from_node_id"] == listen_node_id)
254+
& (self.df["to_node_id"] == control_node.node_id)
255+
& (self.df["link_type"] == "listen")
256+
).any()
257+
if already_exists:
258+
continue
259+
260+
row = model.node.df.loc[listen_node_id]
261+
listened_node = NodeData(
262+
node_id=listen_node_id,
263+
node_type=row["node_type"],
264+
geometry=row["geometry"],
265+
)
266+
self.add(listened_node, control_node)
267+
198268
def _remove_link_id(self, link_id: NonNegativeInt):
199269
if self.df is not None and link_id in self.df.index:
200270
# Remove from node table

python/ribasim/ribasim/model.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -400,7 +400,7 @@ def write(
400400
external : bool, optional
401401
Write the NetCDF input files. Default is True.
402402
"""
403-
self._ensure_listen_links()
403+
self.ensure_listen_links()
404404

405405
if self.use_validation:
406406
self._validate_model()
@@ -455,7 +455,7 @@ def _collect_listen_link_pairs(self) -> pd.DataFrame:
455455

456456
return _concat(parts).drop_duplicates()
457457

458-
def _ensure_listen_links(self) -> None:
458+
def ensure_listen_links(self) -> None:
459459
"""Add any missing listen links inferred from control-node tables.
460460
461461
Compares the pairs from :meth:`_collect_listen_link_pairs` against links
@@ -766,7 +766,7 @@ def plot(
766766
_, ax = plt.subplots()
767767
ax.axis("off")
768768

769-
self._ensure_listen_links()
769+
self.ensure_listen_links()
770770
node = self.node
771771
self.link.plot(ax=ax, zorder=2)
772772
node.plot(ax=ax, zorder=3)

python/ribasim/tests/test_link.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,13 +57,16 @@ def test_node_data():
5757

5858

5959
def test_listen_link_type_inference(discrete_control_of_pid_control):
60+
"""Listen links are auto-added when a control link is created."""
6061
model = discrete_control_of_pid_control
61-
model.link.add(model.basin[3], model.pid_control[6])
6262
assert model.link.df is not None
63-
added_link = model.link.df.iloc[-1]
64-
assert added_link["from_node_id"] == 3
65-
assert added_link["to_node_id"] == 6
66-
assert added_link["link_type"] == "listen"
63+
listen_links = model.link.df.loc[model.link.df["link_type"] == "listen"]
64+
# Basin(3)->PidControl(6) was auto-added when PidControl(6)->Outlet(2) control link was created
65+
basin_to_pid = listen_links.loc[
66+
(listen_links["from_node_id"] == 3) & (listen_links["to_node_id"] == 6)
67+
]
68+
assert len(basin_to_pid) == 1
69+
assert basin_to_pid.iloc[0]["link_type"] == "listen"
6770

6871

6972
@pytest.mark.parametrize(
@@ -95,12 +98,11 @@ def test_validate_link_rejects_excess_inneighbors(basic):
9598

9699

97100
def test_listen_link_allows_reverse_control(discrete_control_of_pid_control):
98-
"""A listen link should be allowed even when a control link exists in the opposite direction."""
101+
"""A listen link is auto-added even when a control link exists in the opposite direction."""
99102
model = discrete_control_of_pid_control
100-
# pid_control[6] already has a control link *to* its controlled node;
101-
# adding a listen link from basin to pid_control should not trigger the
102-
# "opposite link already exists" error.
103-
model.link.add(model.basin[3], model.pid_control[6])
103+
# pid_control #6 has a control link *to* outlet #2;
104+
# the listen link from basin #3 to pid_control #6 was auto-added
105+
# without triggering the "opposite link already exists" error.
104106
assert model.link.df is not None
105107
listen_links = model.link.df.loc[model.link.df["link_type"] == "listen"]
106108
assert not listen_links.empty

python/ribasim/tests/test_model.py

Lines changed: 102 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
from ribasim.geometry.node import NodeData
1717
from ribasim.input_base import esc_id
1818
from ribasim.model import Model
19-
from ribasim.nodes import basin
19+
from ribasim.nodes import basin, pid_control
2020
from ribasim_testmodels import (
2121
basic_model,
2222
outlet_model,
@@ -102,21 +102,65 @@ def test_plot(basic, discrete_control_of_pid_control):
102102
discrete_control_of_pid_control.plot()
103103

104104

105+
def test_control_link_adds_listen_links(discrete_control_of_pid_control):
106+
"""Adding a control link automatically creates listen links.
107+
108+
When ``link.add`` creates a control link from a listen-capable control
109+
node, it reads the ``listen_node_id`` columns from the control node's
110+
tables and adds the corresponding listen links right away.
111+
"""
112+
model = discrete_control_of_pid_control
113+
114+
assert model.link.df is not None
115+
listen_mask = model.link.df["link_type"] == "listen"
116+
control_mask = model.link.df["link_type"] == "control"
117+
118+
# Remove all listen and control links
119+
for link_id in model.link.df.index[listen_mask | control_mask].tolist():
120+
model.link._remove_link_id(link_id)
121+
122+
assert model.link.df.loc[model.link.df["link_type"] == "listen"].empty
123+
assert model.link.df.loc[model.link.df["link_type"] == "control"].empty
124+
125+
# Manually add control links back
126+
model.link.add(model.pid_control[6], model.outlet[2])
127+
model.link.add(model.discrete_control[7], model.pid_control[6])
128+
129+
# Assert that listen links are now present (auto-added when control links are added)
130+
listen_links = model.link.df.loc[model.link.df["link_type"] == "listen"]
131+
assert len(listen_links) == 2
132+
assert set(
133+
zip(listen_links["from_node_id"], listen_links["to_node_id"], strict=False)
134+
) == {
135+
(3, 6),
136+
(1, 7),
137+
}
138+
139+
105140
def test_write_adds_missing_listen_links(discrete_control_of_pid_control, tmp_path):
141+
"""Removing auto-added listen links and writing restores them."""
106142
model = discrete_control_of_pid_control
107143

108144
assert model.link.df is not None
145+
listen_mask = model.link.df["link_type"] == "listen"
146+
assert listen_mask.sum() == 2 # auto-added by link.add
147+
148+
# Remove the auto-added listen links
149+
for link_id in model.link.df.index[listen_mask].tolist():
150+
model.link._remove_link_id(link_id)
151+
109152
assert model.link.df.loc[model.link.df["link_type"] == "listen"].empty
110153

111-
model.write(tmp_path / "model_with_listen_links/ribasim.toml")
154+
# write() calls ensure_listen_links(), which re-adds them
155+
model.write(tmp_path / "restored_listen_links/ribasim.toml")
112156

113-
with connect(
114-
tmp_path / "model_with_listen_links/input/database.gpkg"
115-
) as connection:
157+
with connect(tmp_path / "restored_listen_links/input/database.gpkg") as connection:
116158
query = "select from_node_id, to_node_id from Link where link_type = 'listen'"
117159
df = pd.read_sql_query(query, connection)
118160

119-
assert not df.empty
161+
assert len(df) == 2
162+
written_pairs = set(zip(df["from_node_id"], df["to_node_id"], strict=False))
163+
assert written_pairs == {(3, 6), (1, 7)}
120164

121165

122166
def test_collect_listen_link_pairs_with_control(discrete_control_of_pid_control):
@@ -134,15 +178,61 @@ def test_collect_listen_link_pairs_without_control(basic):
134178

135179

136180
def test_ensure_listen_links_idempotent(discrete_control_of_pid_control):
137-
"""Calling _ensure_listen_links twice should not duplicate links."""
181+
"""Calling ensure_listen_links on a model whose listen links are already present.
182+
183+
(auto-added by link.add) should not duplicate links.
184+
"""
138185
model = discrete_control_of_pid_control
139-
model._ensure_listen_links()
140186
assert model.link.df is not None
141-
count_after_first = len(model.link.df)
187+
count_before = len(model.link.df)
188+
189+
model.ensure_listen_links()
190+
assert len(model.link.df) == count_before
191+
192+
model.ensure_listen_links()
193+
assert len(model.link.df) == count_before
194+
195+
196+
def test_manually_add_listen_link(trivial):
197+
"""Listen links can be added explicitly via link.add().
198+
199+
When ``link.add`` is called with a listenable node (e.g. Basin) pointing
200+
to a control node (e.g. PidControl), the link type is automatically
201+
inferred as ``"listen"`` and the link appears in the link DataFrame.
202+
This is useful when listen links are needed *before* the control link
203+
is added, or for nodes not referenced by ``listen_node_id``.
204+
"""
205+
model = trivial
206+
207+
# Add a PidControl node that listens to Basin(6)
208+
model.pid_control.add(
209+
Node(100, Point(400, 300)),
210+
[
211+
pid_control.Static(
212+
listen_node_id=6,
213+
target=[0.5],
214+
proportional=1e-2,
215+
integral=1e-8,
216+
derivative=-1e-1,
217+
)
218+
],
219+
)
220+
221+
assert model.link.df is not None
222+
n_links_before = len(model.link.df)
223+
224+
# Explicitly add a listen link from Basin(6) -> PidControl(100)
225+
model.link.add(model.basin[6], model.pid_control[100])
226+
227+
listen_links = model.link.df.loc[model.link.df["link_type"] == "listen"]
228+
assert len(listen_links) == 1
229+
assert listen_links.iloc[0]["from_node_id"] == 6
230+
assert listen_links.iloc[0]["to_node_id"] == 100
231+
assert len(model.link.df) == n_links_before + 1
142232

143-
model._ensure_listen_links()
144-
count_after_second = len(model.link.df)
145-
assert count_after_first == count_after_second
233+
# ensure_listen_links does not duplicate the manually added listen link
234+
model.ensure_listen_links()
235+
assert len(model.link.df.loc[model.link.df["link_type"] == "listen"]) == 1
146236

147237

148238
def test_write_adds_fid_in_tables(basic, tmp_path):

0 commit comments

Comments
 (0)