node-model — Tampere et al.’s (2011) generic first-order node model¶
What. TampereNode resolves ANY merge/diverge junction (multiple incoming,
multiple outgoing links) into per-approach transfer flows from each incoming
link’s sending flow, each outgoing link’s receiving flow, and the mandated
turning fractions — satisfying six node axioms (N1 conservation, N2
non-negativity, N3 supply/demand respect, N4 turn-fraction fidelity, N5
maximal transfer, N6 invariance to inflating an already-unconstrained sending
flow). It generalizes the trivial 1-in/1-out series-node min(s, r) rule
ctm/ltm/godunov silently use elsewhere in this track.
Why it is in the benchmark. Every DNL link model in this benchmark shares
ONE node layer — TampereNode is what turns single-link loading into NETWORK
loading, and it is what makes C8 (turning-fraction fidelity) a certifiable,
gating property rather than a modeling assumption. See the
model compendium (Tampere et al. 2011) and
docs/design/adr-017-node-model.md
(P1).
Scope. This notebook exercises TampereNode.transfer directly on hand-
computable merge/diverge anchors (exact fractions, axiom-checked), then loads a
small diverge network end-to-end through NetworkLoader and certifies C8
turn fidelity via DNLEvaluator.
Canon. [tampere2011generic], docs/REFERENCES.md / docs/references.bib.
How this notebook is graded¶
A notebook never claims a number it does not compute in that cell. The
node-level anchors below are checked against assert_node_axioms (the same
axiom checker the test suite uses) in the cell where they are claimed; the
end-to-end network result is recomputed live by the P1 DNLEvaluator from the
cumulative link curves the loader emitted — no self-report to diff, exactly as
in ctm/ltm/godunov
(README, Certified, not self-reported).
# Setup. `node-model` is core: a plain `pip install -e .` suffices — no
# optional extra, so no guard cell. The inline backend is Agg-based (headless
# CI renders into the notebook); NEVER matplotlib.use("Agg") in-kernel — it
# silently suppresses inline figure capture.
%matplotlib inline
import numpy as np
from tabench import (
CTMLink,
DNLEvaluator,
DynamicDemand,
DynamicScenario,
LinkDynamics,
NetworkLoader,
TampereNode,
TimeGrid,
TurningFractions,
assert_node_axioms,
viz,
)
from tabench.core.scenario import Network
Node anchors: merge and diverge, exact fractions¶
Two hand-computable cases, each checked against the six node axioms via
assert_node_axioms (N1 conservation, N2 non-negativity, N3 supply/demand
respect, N4 turn fidelity, N5 maximal transfer): a capacity-proportional merge
(two equal-sending approaches, an out-link supplying less than both want,
split by out-link capacity share), and a FIFO diverge (one approach splitting
0.6/0.4 into two out-links, one of which can only accept 0.4 — FIFO holds the
WHOLE approach back proportionally, not just the blocked movement).
node = TampereNode()
# Merge: 2 in -> 1 out. Both approaches send 1, out-link supplies 1, capacities
# [2, 1] -> capacity-proportional share (2:1).
s, r, turns, caps = np.array([1.0, 1.0]), np.array([1.0]), np.array([[1.0], [1.0]]), np.array([2.0, 1.0])
q_merge = node.transfer(s, r, turns, caps)
assert_node_axioms(q_merge, s, r, turns, eps=1e-9)
print(f"merge transfer (2:1 capacity share) : {q_merge.ravel()}")
np.testing.assert_allclose(q_merge, [[2 / 3], [1 / 3]], atol=1e-12)
# Diverge: 1 in -> 2 out. Approach wants to split 0.6/0.4; out-link 2 supplies
# only 0.4 -> FIFO throttles the WHOLE approach: only 1 of the 2 units the
# approach is sending transfer, split in the SAME 0.6/0.4 ratio.
s, r, turns, caps = np.array([2.0]), np.array([1e6, 0.4]), np.array([[0.6, 0.4]]), np.array([1.0])
q_div = node.transfer(s, r, turns, caps)
assert_node_axioms(q_div, s, r, turns, eps=1e-9)
print(f"diverge transfer (FIFO holdback) : {q_div.ravel()}")
np.testing.assert_allclose(q_div, [[0.6, 0.4]], atol=1e-12)
merge transfer (2:1 capacity share) : [0.66666667 0.33333333]
diverge transfer (FIFO holdback) : [0.6 0.4]
End-to-end: a diverge network through the loader¶
A small network — origin zone 1 -> interior node 4 -> two destinations (zone 2
direct, zone 3 via node 4) — with a mandated 0.6/0.4 turning split at node 4.
NetworkLoader defaults interior >1-out-link nodes to TampereNode
automatically (no explicit node_models argument needed).
net = Network(
name="node-model-diverge", n_nodes=4, n_zones=3, first_thru_node=4,
init_node=np.array([1, 4, 4]), term_node=np.array([4, 2, 3]),
capacity=np.ones(3), length=np.zeros(3), free_flow_time=np.ones(3),
b=np.zeros(3), power=np.ones(3), toll=np.zeros(3), link_type=np.ones(3, dtype=np.int64),
)
rates = np.zeros((1, 3, 3))
rates[0, 0, 1] = 0.9 # zone 1 -> zone 2
rates[0, 0, 2] = 0.6 # zone 1 -> zone 3
scenario = DynamicScenario(
name="node-model-diverge", network=net,
dynamics=LinkDynamics(
length=np.full(3, 4.0), free_speed=np.full(3, 1.0), wave_speed=np.full(3, 1.0),
jam_density=np.full(3, 4.0), capacity=np.full(3, 2.0),
),
demand=DynamicDemand(breakpoints=np.array([0.0, 10.0]), rates=rates),
grid=TimeGrid(dt=1.0, n_steps=16),
turns=TurningFractions(frac=((4, np.array([[0.6, 0.4]])),)),
)
print(f"scenario : {scenario.name}")
print(f"content hash : {scenario.content_hash()[:16]}…")
print(f"links : {net.n_links} (tail→head: "
+ ", ".join(f"{i}->{j}" for i, j in zip(net.init_node, net.term_node)) + ")")
print("task : deterministic DNL loading (feasibility + conservation + turn fidelity)")
out = NetworkLoader(scenario, CTMLink).run()
print(f"storage at t={scenario.grid.edges[-1]:.0f} : "
f"{(out.n_in[:, -1] - out.n_out[:, -1]).round(3)} (per link)")
scenario : node-model-diverge
content hash : 7b4bf0afdd3362fc…
links : 3 (tail→head: 1->4, 4->2, 4->3)
task : deterministic DNL loading (feasibility + conservation + turn fidelity)
storage at t=16 : [0. 1.8 1.2] (per link)
Certify (P1) — feasibility, conservation, and turn fidelity (C8)¶
C8 recovers the mandated 0.6/0.4 split exactly from the emitted per-link
outflow curves alone (d_in[out_j] == sum_i frac[i, j] * d_out[in_i]) — a
necessary condition on aggregate counts, gating here.
metrics = DNLEvaluator(scenario).evaluate(out)
print(f"dnl_feasible : {metrics['dnl_feasible']:.0f}")
print(f"conservation_residual : {metrics['conservation_residual']:.3e}")
print(f"turn_residual : {metrics['turn_residual']:.3e}")
assert metrics["dnl_feasible"] == 1.0
assert metrics["conservation_residual"] <= 1e-9
assert metrics["turn_residual"] <= 1e-9
# Recompute the mandated split directly from the emitted outflow curves — not
# quoting DNLEvaluator's internal check, redoing it here.
d_out_in = np.diff(out.n_out[0]) # per-step outflow of the approach (link 0)
d_in_a = np.diff(out.n_in[1]) # per-step inflow of out-link A (link 1, -> zone 2)
d_in_b = np.diff(out.n_in[2]) # per-step inflow of out-link B (link 2, -> zone 3)
mandated_a, mandated_b = 0.6 * d_out_in, 0.4 * d_out_in
np.testing.assert_allclose(d_in_a, mandated_a, atol=1e-9)
np.testing.assert_allclose(d_in_b, mandated_b, atol=1e-9)
print("emitted per-step split matches the mandated 0.6/0.4 turning fractions exactly")
dnl_feasible : 1
conservation_residual : 1.776e-15
turn_residual : 4.441e-16
emitted per-step split matches the mandated 0.6/0.4 turning fractions exactly
Visualize¶
Both figures come from tabench.viz, the house visualizer. Left/top: the
certified diverge network coloured by each link’s time-averaged flow. Right/
bottom: the loaded average flow against the mandated 0.6/0.4 split applied to
the approach’s own average flow — the recomputed anchor above, restated as a
flow vector.
T = float(scenario.grid.edges[-1])
avg_flow = out.n_out[:, -1] / T
ref_avg_flow = np.array([avg_flow[0], 0.6 * avg_flow[0], 0.4 * avg_flow[0]])
display(viz.plot_network_flows(net, avg_flow))
display(viz.plot_flow_scatter(("mandated 0.6/0.4 split", ref_avg_flow), {"node-model": avg_flow}))
Takeaways & pointers¶
Certified, not self-reported. DNL link/node models have nothing to self-report — the emitted curves ARE the output, recertified from scratch by
DNLEvaluator.One node layer, every link model.
TampereNodeis whatctm,ltm, andgodunovall delegate junction physics to; none of them contain merge/diverge logic themselves.Where next. the link models it serves:
ctm·ltm·godunov; the lineage in the model compendium.