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}))
../../_images/35fc67c04963aea6b88cf6d3caad706b919d043c340fb640fa1c2c3662a901b7.png ../../_images/383910f58d2b07d9d2c2481581fc17f2f61e35f041bef502cac0bad06b7140fa.png

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. TampereNode is what ctm, ltm, and godunov all 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.