pm-td — Peeta & Mahmassani’s (1995) time-dependent UE and SO¶
What. pm-td is the methodology paper of simulation-based dynamic traffic
assignment: time-dependent, EXPERIENCED-time system-optimal AND user-
equilibrium route choice, solved by method-of-successive-averages (MSA)
iterations around the repo’s own CTM/LTM loading (playing the role of the
paper’s mesoscopic simulator). One machinery, two objectives — TD-UE assigns
to least EXPERIENCED time, TD-SO to least time-dependent MARGINAL time — this
notebook covers both (pm-td-ue and pm-td-so).
Why it is in the benchmark. It is the first ITERATIVE, simulation-based time-dependent route equilibrium: multi-origin/multi-destination, path-based, FIFO-enforced by the loader, with no closed form. Its headline result — the system optimum strictly beats user equilibrium’s total travel time — is made executable here. See the model compendium (Peeta & Mahmassani 1995) and docs/design/adr-031-peeta-mahmassani.md (P1).
Scope. This notebook solves TD-UE on a symmetric two-route diamond (an exact 50/50 anchor) and both TD-UE/TD-SO on an SO != UE wedge, certifying every result. The MSA solvers are explicitly NON-certified research code — the certifier is the arbiter, never the solver’s own claim.
Canon. [peeta1995system], docs/REFERENCES.md / docs/references.bib.
How this notebook is graded¶
A notebook never claims a number it does not compute in that cell. Every
scored quantity below is recomputed live by the P1 TDTAEvaluator from the
emitted per-path departure plan alone — the harness reruns its OWN per-path
loading and recomputes the experienced-time route-swap residual (tdue_gap)
and the LP-bound gap (so_bound_gap) against a freshly-solved lp-so-dta-
style optimum, never trusting the MSA solver’s self-reported iterate
(README, Certified, not self-reported).
# Setup. `pm-td` is a core model: 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 (
PathLoader,
TDPathFlows,
TDTAEvaluator,
pm_diamond_scenario,
pm_wedge_scenario,
solve_td_so,
solve_td_ue,
viz,
)
pm-td-ue: an exact 50/50 anchor¶
The symmetric two-route diamond: origin 1 to destination 2 by two
byte-identical routes (fast feeder + Q=1 bottleneck each). By symmetry the
exact TD-UE is the 50/50 split every interval; an all-on-one control queues
one bottleneck while its twin sits idle — a hand-computable positive gap.
TDTAScenario is frozen and content-hashed (P2).
diamond = pm_diamond_scenario()
print(f"scenario : {diamond.name}")
print(f"content hash : {diamond.content_hash()[:16]}…")
print(f"paths : {[p.links for p in diamond.paths]}")
print(f"grid : dt={diamond.grid.dt}, n_steps={diamond.grid.n_steps}")
print("task : time-dependent user equilibrium, experienced travel time")
scenario : pm-diamond-ctm
content hash : 1ed2c58061543b1b…
paths : [(0, 1), (2, 3)]
grid : dt=1.0, n_steps=16
task : time-dependent user equilibrium, experienced travel time
ue_flows = solve_td_ue(diamond, iters=40)
evaluator = TDTAEvaluator(diamond)
metrics = evaluator.certify(ue_flows)
print(f"feasible : {metrics['feasible']:.0f}")
print(f"tdue_gap : {metrics['tdue_gap']:.3e}")
print(f"tstt : {metrics['tstt']:.3f}")
assert metrics["feasible"] == 1.0
assert metrics["tdue_gap"] < 1e-6
assert np.isclose(metrics["tstt"], 14.0, atol=1e-2)
# The 50/50 split, recomputed from the emitted departures directly.
per_path = ue_flows.departures.sum(axis=1)
print(f"departures per path : {per_path}")
np.testing.assert_allclose(per_path, [2.0, 2.0], atol=1e-6)
# DISTINCTIVE: an all-on-one control certifies a hand-computable POSITIVE gap
# (0.75) -- built here from the SAME total demand, forced onto path 0 alone.
all_on_one = np.zeros_like(ue_flows.departures)
all_on_one[0] = ue_flows.departures.sum(axis=0)
gap_all_on_one = evaluator.certify(TDPathFlows(diamond.content_hash(), all_on_one))["tdue_gap"]
print(f"all-on-one control tdue_gap : {gap_all_on_one:.4f}")
assert np.isclose(gap_all_on_one, 0.75, atol=1e-3)
feasible : 1
tdue_gap : 0.000e+00
tstt : 14.000
departures per path : [2. 2.]
all-on-one control tdue_gap : 0.7500
pm-td-ue vs pm-td-so: SO strictly beats UE (the paper’s headline)¶
The wedge: a FAST but capacitated route (Q=1 bottleneck) versus a SLOWER
uncongested route. Selfish UE overloads the fast route until its queue delay
matches the slow route’s extra free-flow time; the system optimum diverts more
traffic to the slow route earlier, cutting total queueing.
wedge = pm_wedge_scenario()
print(f"scenario : {wedge.name}")
print(f"content hash : {wedge.content_hash()[:16]}…")
ue_wedge = solve_td_ue(wedge, iters=40)
so_wedge = solve_td_so(wedge, iters=40)
ev_wedge = TDTAEvaluator(wedge)
m_ue = ev_wedge.certify(ue_wedge)
m_so = ev_wedge.certify(so_wedge)
print(f"UE: tdue_gap={m_ue['tdue_gap']:.4f} tstt={m_ue['tstt']:.3f}")
print(f"SO: so_bound_gap={m_so['so_bound_gap']:.3e} tstt={m_so['tstt']:.3f}")
assert m_ue["feasible"] == 1.0 and m_so["feasible"] == 1.0
# MSA is non-certified research code -- the UE's own residual is disclosed,
# not asserted to zero (the certifier, not the solver, is the arbiter).
assert m_ue["tdue_gap"] < 0.15
assert np.isclose(m_ue["tstt"], 24.0, atol=1e-2)
# SO attains its LP-relaxation bound essentially exactly.
assert m_so["so_bound_gap"] < 1e-6
assert np.isclose(m_so["tstt"], 23.0, atol=1e-2)
# The headline: SO is strictly below UE, recomputed as a plain comparison of
# the two independently certified totals.
assert m_so["tstt"] < m_ue["tstt"]
print(f"SO/UE TSTT ratio : {m_so['tstt'] / m_ue['tstt']:.4f} (SO strictly lower)")
scenario : pm-wedge
content hash : 2fa30ab1fb705308…
UE: tdue_gap=0.0909 tstt=24.000
SO: so_bound_gap=0.000e+00 tstt=23.000
SO/UE TSTT ratio : 0.9583 (SO strictly lower)
Visualize¶
Both figures come from tabench.viz — pm-td scenarios use the road
Network (unlike transit-strategy’s separate TransitNetwork), so the
house visualizer applies directly. The plotted flow is each link’s TIME-
AVERAGED throughput, recomputed here by re-running PathLoader on the emitted
departure plan (the same loading the certifier itself performs).
T = float(wedge.grid.edges[-1])
ue_out = PathLoader(wedge, ue_wedge.departures).run()
so_out = PathLoader(wedge, so_wedge.departures).run()
ue_avg = ue_out.n_out[:, -1] / T
so_avg = so_out.n_out[:, -1] / T
display(viz.plot_network_flows(wedge.network, ue_avg))
display(viz.plot_flow_scatter(("UE (selfish)", ue_avg), {"SO (system optimum)": so_avg}))
Takeaways & pointers¶
Certified, not self-reported. Every gap and TSTT above came from
TDTAEvaluator’s own re-loading and re-scoring — the MSA solver’s best- iterate claim was only diffed against it.The MSA solvers are honestly non-certified. UE’s wedge residual (
tdue_gap ~ 0.09) is disclosed, not hidden — a simulation-based MSA has no convergence guarantee (the paper says so); the certifier reports the best iterate, never a convergence claim.Where next. the exit-function / LP twins
merchant-nemhauser·lp-so-dta(the SO boundso_bound_gapscores against); the CTM/LTM loading underneathctm; the lineage in the model compendium.