dlsim4gmns
DLSim4GMNS -- a physics-gated space-time-event freeway simulator on GMNS.
Predict density; let the fundamental diagram give speed and flow, so q = k*v holds by construction and the physics gate clears. One pipeline runs on any GMNS network.
import dlsim4gmns as dl
out = dl.run() # bundled sample corridor
print(out["gate"], out["report"]["checks"])
1"""DLSim4GMNS -- a physics-gated space-time-event freeway simulator on GMNS. 2 3Predict density; let the fundamental diagram give speed and flow, so q = k*v holds by 4construction and the physics gate clears. One pipeline runs on any GMNS network. 5 6 import dlsim4gmns as dl 7 out = dl.run() # bundled sample corridor 8 print(out["gate"], out["report"]["checks"]) 9""" 10from .fd import fd_state, density_from_speed, fd_from_attributes 11from .gmns import read_network, fd_table 12from .simulator import simulate 13from .validator import validate, check_schema, check_state 14from .pipeline import run, sample_network_dir 15from .viz import plot_fd, spacetime_heatmap, network_map, plot_odme_fit # matplotlib lazy 16from .odme import gls_odme # scipy imported lazily (inside) 17from .physics import accumulate, conservation_residual # conservation (gate G2) 18from .transfer import volume_from_speed # speed->volume (Level 4) 19from .dynamics import newell_corridor, newell_merge, corridor_engine # Newell KW dynamic loading 20from .scenario import (load_scenario, run_scenario, recover_demand_scale, # scenario driver 21 topology, merge_engine) 22from .field import (load_corridor_field, field_physics_check, # Level-2 field testbed 23 bottleneck_ranking, speed_matrix, calibrate_fd, fd_prediction_error) 24from .control import Simulator # step-and-observe control interface 25from .calibrate import calibrate_odme # dynamic ODME on the control interface 26 27try: # single source of truth: pyproject 28 from importlib.metadata import version as _pkg_version 29 __version__ = _pkg_version("DLSim4GMNS") 30except Exception: # not installed (running from src) 31 __version__ = "0.1.0" 32 33__all__ = [ 34 "__version__", "fd_state", "density_from_speed", "fd_from_attributes", 35 "read_network", "fd_table", "simulate", "validate", "check_schema", 36 "check_state", "run", "sample_network_dir", 37 "plot_fd", "spacetime_heatmap", "network_map", "plot_odme_fit", "gls_odme", 38 "accumulate", "conservation_residual", "volume_from_speed", 39 "newell_corridor", "newell_merge", "corridor_engine", 40 "load_scenario", "run_scenario", "recover_demand_scale", "topology", "merge_engine", 41 "load_corridor_field", "field_physics_check", "bottleneck_ranking", "speed_matrix", 42 "calibrate_fd", "fd_prediction_error", "Simulator", "calibrate_odme", 43]
13def fd_state(k, params): 14 """Map density k (scalar or array) to (v, q) on the triangular FD. 15 16 params: mapping/Series/row with v_f, q_cap, k_crit, k_jam. 17 free branch k <= k_crit : v = v_f, q = v_f * k 18 congested k > k_crit : q = q_cap*(k_jam-k)/(k_jam-k_crit), v = q/k 19 Returns (v, q) as numpy arrays with v <= v_f and q = k*v. 20 """ 21 k = np.asarray(k, dtype=float) 22 vf = float(params["v_f"]); qc = float(params["q_cap"]) 23 kc = float(params["k_crit"]); kj = float(params["k_jam"]) 24 q = np.where(k <= kc, vf * k, 25 qc * np.maximum(kj - k, 0.0) / max(kj - kc, 1e-9)) 26 v = np.where(k > 1e-9, q / np.maximum(k, 1e-9), vf) 27 return np.minimum(v, vf), q
Map density k (scalar or array) to (v, q) on the triangular FD.
params: mapping/Series/row with v_f, q_cap, k_crit, k_jam. free branch k <= k_crit : v = v_f, q = v_f * k congested k > k_crit : q = q_cap*(k_jam-k)/(k_jam-k_crit), v = q/k Returns (v, q) as numpy arrays with v <= v_f and q = k*v.
30def density_from_speed(v, params): 31 """Invert the congested branch: speed -> density (for speed->volume, Level 4). 32 Under-determined in free flow (v ~= v_f); returns the congested-branch density.""" 33 v = np.asarray(v, dtype=float) 34 qc = float(params["q_cap"]) 35 kc = float(params["k_crit"]); kj = float(params["k_jam"]) 36 return qc * kj / (v * (kj - kc) + qc)
Invert the congested branch: speed -> density (for speed->volume, Level 4). Under-determined in free flow (v ~= v_f); returns the congested-branch density.
39def fd_from_attributes(free_speed_kmh, capacity_vph, lanes): 40 """Derive FD params from OSM/GMNS link attributes (geography-agnostic, Level 4).""" 41 vf = float(free_speed_kmh); qc = float(capacity_vph) 42 return dict(v_f=vf, q_cap=qc, k_crit=qc / max(vf, 1.0), k_jam=float(lanes) * 130.0)
Derive FD params from OSM/GMNS link attributes (geography-agnostic, Level 4).
11def read_network(net_dir: str) -> dict: 12 """Load a GMNS network directory. Tolerates both the competition schema 13 (free_speed_kmh/capacity_vph) and osm2gmns output (free_speed/capacity).""" 14 node = pd.read_csv(os.path.join(net_dir, "node.csv")) 15 link = pd.read_csv(os.path.join(net_dir, "link.csv")) 16 return {"nodes": node, "links": link, "fd": fd_table(link)}
Load a GMNS network directory. Tolerates both the competition schema (free_speed_kmh/capacity_vph) and osm2gmns output (free_speed/capacity).
26def fd_table(links: pd.DataFrame) -> pd.DataFrame: 27 """Per-link triangular FD params from link attributes.""" 28 vf = pd.to_numeric(_col(links, "free_speed_kmh", "free_speed"), errors="coerce").fillna(100.0) 29 cap = pd.to_numeric(_col(links, "capacity_vph", "capacity"), errors="coerce") 30 lanes = pd.to_numeric(_col(links, "lanes"), errors="coerce").fillna(2).clip(lower=1) 31 cap = cap.where(cap > 0, lanes * 1900.0) 32 rows = [fd_from_attributes(v, c, n) for v, c, n in zip(vf, cap, lanes)] 33 fd = pd.DataFrame(rows) 34 fd.insert(0, "link_id", links["link_id"].values) 35 return fd
Per-link triangular FD params from link attributes.
12def simulate(net: dict, tod_step_min: int = 15, peak_load: float = 1.15) -> pd.DataFrame: 13 """Produce a diurnal density-canonical state {speed, flow, density} per (link, tod). 14 Density follows a free-flow floor plus AM/PM Gaussian demand bumps, pushed past 15 k_crit on lower-capacity links (the bottlenecks). q = k*v holds by construction. 16 """ 17 fd = net["fd"].set_index("link_id") 18 lanes = net["links"].set_index("link_id") 19 tods = [f"{h:02d}:{m:02d}" for h in range(24) for m in range(0, 60, tod_step_min)] 20 tmin = np.array([int(t[:2]) * 60 + int(t[3:]) for t in tods]) 21 demand = np.exp(-((tmin - 450) / 90) ** 2) + np.exp(-((tmin - 1020) / 100) ** 2) 22 rows = [] 23 for lid in fd.index: 24 r = fd.loc[lid] 25 ln = float(lanes.loc[lid].get("lanes", 3)) if lid in lanes.index else 3 26 load = peak_load if ln <= 2 else 0.85 27 k = 0.15 * r["k_crit"] + demand * load * r["k_crit"] 28 k = np.minimum(k, r["k_jam"]) 29 v, q = fd_state(k, r) 30 for t, vv, qq, kk in zip(tods, v, q, k): 31 rows.append(dict(link_id=lid, tod=t, timestamp=f"2026-06-15T{t}:00Z", 32 speed=vv, flow=qq, density=float(kk))) 33 return pd.DataFrame(rows)
Produce a diurnal density-canonical state {speed, flow, density} per (link, tod). Density follows a free-flow floor plus AM/PM Gaussian demand bumps, pushed past k_crit on lower-capacity links (the bottlenecks). q = k*v holds by construction.
52def validate(state: pd.DataFrame, fd: pd.DataFrame) -> tuple[bool, dict]: 53 """Run the gate. Returns (gate_passed, report). Gate-first: a failing gate means 54 the accuracy score is zero regardless of RMSE.""" 55 checks = [check_schema(state)] 56 if checks[0]["passed"]: 57 checks.append(check_state(state, fd)) 58 gate = all(c["passed"] for c in checks) 59 return gate, dict(gate_passed=gate, checks=checks)
Run the gate. Returns (gate_passed, report). Gate-first: a failing gate means the accuracy score is zero regardless of RMSE.
20def check_schema(state: pd.DataFrame) -> dict: 21 """G0: required columns present; speed/flow/density finite and non-negative.""" 22 missing = [c for c in REQUIRED if c not in state.columns] 23 if missing: 24 return dict(check="G0_schema", passed=False, detail={"missing": missing}) 25 bad = 0 26 for c in ("speed", "flow", "density"): 27 v = state[c].astype(float) 28 bad += int((v < -1e-6).sum() + v.isna().sum()) 29 return dict(check="G0_schema", passed=bad == 0, detail={"bad_or_missing": bad})
G0: required columns present; speed/flow/density finite and non-negative.
32def check_state(state: pd.DataFrame, fd: pd.DataFrame) -> dict: 33 """G1: q = k*v within tol, and v<=v_f, k<=k_jam, q<=q_cap (FD feasibility).""" 34 m = state.merge(fd, on="link_id", how="left") 35 v = m["speed"].astype(float).values 36 q = m["flow"].astype(float).values 37 k = m["density"].astype(float).values 38 qkv = np.abs(q - k * v) / np.maximum(np.abs(q), 1.0) 39 qkv_ok = _frac_ok(qkv <= QKV_TOL) 40 vf = m.get("v_f", pd.Series(np.full(len(m), 1e9))).astype(float).values 41 kj = m.get("k_jam", pd.Series(np.full(len(m), 1e9))).astype(float).values 42 cap = m.get("q_cap", pd.Series(np.full(len(m), 1e9))).astype(float).values 43 v_ok = _frac_ok(v <= vf * 1.05) 44 k_ok = _frac_ok(k <= kj * 1.05) 45 q_ok = _frac_ok(q <= cap * 1.15) 46 passed = all((1 - x) <= VIOL_FRAC for x in (qkv_ok, v_ok, k_ok, q_ok)) 47 return dict(check="G1_state (q=k*v, FD-feasible)", passed=bool(passed), 48 detail=dict(qkv_ok=round(qkv_ok, 4), v_le_vf=round(v_ok, 4), 49 k_le_kjam=round(k_ok, 4), q_le_cap=round(q_ok, 4)))
G1: q = k*v within tol, and v<=v_f, k<=k_jam, q<=q_cap (FD feasibility).
17def run(net_dir: str | None = None) -> dict: 18 """Run load -> simulate -> gate on a network directory (defaults to the sample).""" 19 net = read_network(net_dir or sample_network_dir()) 20 state = simulate(net) 21 gate, report = validate(state, net["fd"]) 22 return dict(net_dir=net_dir or "sample_corridor", 23 n_links=len(net["links"]), n_cells=len(state), 24 gate=gate, report=report, state=state)
Run load -> simulate -> gate on a network directory (defaults to the sample).
12def sample_network_dir() -> str: 13 """Path to the bundled sample corridor shipped inside the package.""" 14 return os.path.join(os.path.dirname(__file__), "data", "sample_corridor")
Path to the bundled sample corridor shipped inside the package.
22def plot_fd(params, ax=None): 23 """The triangular fundamental diagram: flow q(k) and speed v(k) vs density.""" 24 plt = _mpl() 25 kj = float(params["k_jam"]) 26 k = np.linspace(0, kj, 200) 27 v, q = fd_state(k, params) 28 if ax is None: 29 fig, ax = plt.subplots(figsize=(5, 3.4), dpi=120) 30 else: 31 fig = ax.figure 32 ax.plot(k, q, color="#8c1d40", lw=2, label="flow $q(k)$") 33 ax.axvline(float(params["k_crit"]), ls="--", color="#888", lw=1) 34 ax.set_xlabel("density $k$ (veh/km)"); ax.set_ylabel("flow $q$ (veh/h)") 35 ax.set_title("Fundamental diagram ($q=k\\,v$)") 36 ax2 = ax.twinx(); ax2.plot(k, v, color="#0a6", lw=1.2, alpha=.7, label="speed $v(k)$") 37 ax2.set_ylabel("speed $v$ (km/h)") 38 fig.tight_layout() 39 return fig
The triangular fundamental diagram: flow q(k) and speed v(k) vs density.
42def spacetime_heatmap(state: pd.DataFrame, links: pd.DataFrame | None = None, 43 value: str = "speed", vmax: float | None = None): 44 """Space-time heatmap: link position (y) x time-of-day (x), colored by `value`. 45 Links are ordered by `links` (if given, by row order) else by link_id.""" 46 plt = _mpl() 47 order = list(links["link_id"]) if links is not None else sorted(state["link_id"].unique()) 48 tods = sorted(state["tod"].unique()) 49 pv = state.pivot_table(value, "link_id", "tod", aggfunc="mean").reindex(index=order, columns=tods) 50 fig, ax = plt.subplots(figsize=(7, 3.6), dpi=120) 51 vmx = vmax if vmax is not None else (110 if value == "speed" else float(np.nanmax(pv.values))) 52 im = ax.imshow(pv.values, aspect="auto", cmap=_SPEED_CMAP, vmin=0, vmax=vmx, origin="lower", 53 extent=[0, 24, 0, len(order)]) 54 ax.set_xlabel("hour of day"); ax.set_ylabel("link (upstream $\\to$ downstream)") 55 ax.set_xticks(range(0, 25, 4)); ax.set_title(f"Space-time {value}") 56 fig.colorbar(im, ax=ax, label=value, shrink=.9); fig.tight_layout() 57 return fig
Space-time heatmap: link position (y) x time-of-day (x), colored by value.
Links are ordered by links (if given, by row order) else by link_id.
78def network_map(net: dict, state: pd.DataFrame | None = None, value: str = "speed"): 79 """Network geometry (nodes/links); links colored by mean `value` if a state is given.""" 80 plt = _mpl() 81 nd, lk = net["nodes"], net["links"] 82 pos = {r.node_id: (float(r.x_coord), float(r.y_coord)) for r in nd.itertuples()} 83 col = None 84 if state is not None: 85 col = state.groupby("link_id")[value].mean() 86 fig, ax = plt.subplots(figsize=(6.5, 4), dpi=120) 87 import matplotlib 88 from matplotlib.colors import Normalize 89 norm = Normalize(0, 110); cmap = matplotlib.colormaps[_SPEED_CMAP] 90 for r in lk.itertuples(): 91 a, b = pos.get(r.from_node_id), pos.get(r.to_node_id) 92 if not a or not b: 93 continue 94 c = cmap(norm(col.get(r.link_id, np.nan))) if col is not None else "#0366d6" 95 ax.plot([a[0], b[0]], [a[1], b[1]], "-", color=c, lw=2 + 0.6 * getattr(r, "lanes", 2)) 96 xs = [p[0] for p in pos.values()]; ys = [p[1] for p in pos.values()] 97 ax.scatter(xs, ys, s=8, c="#333", zorder=3) 98 ax.set_xlabel("lon"); ax.set_ylabel("lat"); ax.set_aspect("equal") 99 ax.set_title("Network" + (f" (mean {value})" if col is not None else "")); fig.tight_layout() 100 return fig
Network geometry (nodes/links); links colored by mean value if a state is given.
60def plot_odme_fit(y_obs, y_est, ax=None): 61 """ODME goodness-of-fit: observed vs estimated link counts (the 45-degree line).""" 62 plt = _mpl() 63 y_obs = np.asarray(y_obs, float); y_est = np.asarray(y_est, float) 64 if ax is None: 65 fig, ax = plt.subplots(figsize=(4.2, 4.2), dpi=120) 66 else: 67 fig = ax.figure 68 hi = max(float(y_obs.max()), float(y_est.max())) * 1.05 69 ax.plot([0, hi], [0, hi], "--", color="#888", lw=1) 70 ax.scatter(y_obs, y_est, s=28, color="#8c1d40", alpha=.8) 71 mape = float(np.mean(np.abs(y_est - y_obs)) / max(y_obs.mean(), 1) * 100) 72 ax.set_xlabel("observed link count"); ax.set_ylabel("ODME estimated") 73 ax.set_title(f"ODME fit (link MAPE {mape:.1f}%)"); ax.set_aspect("equal") 74 fig.tight_layout() 75 return fig
ODME goodness-of-fit: observed vs estimated link counts (the 45-degree line).
12def gls_odme(B, v_hat, A_OD=None, q_hat=None, lam=0.05, w=None): 13 """Non-negative, prior-regularised least squares. 14 15 B : (n_sensor_links x P) sensor-link x path incidence 16 v_hat : (n_sensor_links,) observed link counts 17 A_OD : (n_od x P) OD x path incidence (optional prior term) 18 q_hat : (n_od,) OD prior volumes (optional) 19 Returns dict(x=path flow, y=fitted link flow, d=OD, link_mape). 20 """ 21 from scipy.optimize import lsq_linear 22 B = np.asarray(B, float); v_hat = np.asarray(v_hat, float) 23 W = np.ones(len(v_hat)) if w is None else np.asarray(w, float) 24 blocks = [np.sqrt(W)[:, None] * B] 25 targets = [np.sqrt(W) * v_hat] 26 if A_OD is not None and q_hat is not None and lam > 0: 27 A_OD = np.asarray(A_OD, float) 28 blocks.append(np.sqrt(lam) * A_OD) 29 targets.append(np.sqrt(lam) * np.asarray(q_hat, float)) 30 M = np.vstack(blocks); t = np.concatenate(targets) 31 x = lsq_linear(M, t, bounds=(0, np.inf), max_iter=2000).x 32 y = B @ x 33 d = A_OD @ x if A_OD is not None else None 34 mape = float(np.mean(np.abs(y - v_hat)) / max(v_hat.mean(), 1) * 100) 35 return dict(x=x, y=y, d=d, link_mape=mape)
Non-negative, prior-regularised least squares.
B : (n_sensor_links x P) sensor-link x path incidence v_hat : (n_sensor_links,) observed link counts A_OD : (n_od x P) OD x path incidence (optional prior term) q_hat : (n_od,) OD prior volumes (optional) Returns dict(x=path flow, y=fitted link flow, d=OD, link_mape).
13def accumulate(lam, mu, N0=0.0, dt=1.0): 14 """Integrate net flow to the vehicle-number trajectory N(t). 15 For T net-flow steps returns T+1 values: N[0]=N0, N[i+1]=N[i]+dt*(lam[i]-mu[i]).""" 16 lam = np.asarray(lam, float); mu = np.asarray(mu, float) 17 return np.concatenate([[N0], N0 + np.cumsum(dt * (lam - mu))])
Integrate net flow to the vehicle-number trajectory N(t). For T net-flow steps returns T+1 values: N[0]=N0, N[i+1]=N[i]+dt*(lam[i]-mu[i]).
20def conservation_residual(N, lam, mu, dt=1.0): 21 """Mean absolute violation of N(t+dt) - N(t) = dt*(lambda - mu). 0.0 == conserved. 22 N has length len(lam)+1 (the trajectory); lam, mu are the per-step net flows.""" 23 N = np.asarray(N, float); lam = np.asarray(lam, float); mu = np.asarray(mu, float) 24 dN = np.diff(N) 25 expected = dt * (lam - mu) 26 return float(np.mean(np.abs(dN - expected))) if len(dN) else 0.0
Mean absolute violation of N(t+dt) - N(t) = dt*(lambda - mu). 0.0 == conserved. N has length len(lam)+1 (the trajectory); lam, mu are the per-step net flows.
10def volume_from_speed(v, params, cap_drop=0.70): 11 """Regime-aware volume estimate + 1-sigma uncertainty from a speed field. 12 13 congested (v < 0.75 v_f): near capacity, q ~= cap_drop * C (tight sigma) 14 transition (0.75-0.9 v_f): FD congested branch (loose sigma) 15 free (v >= 0.9 v_f): UNDER-DETERMINED -> (nan, inf) (defer to a prior) 16 17 Returns (q_hat, sigma) as arrays; free-flow cells carry (nan, inf) so an ODME step 18 weights them out (weight = 1/sigma^2 = 0) and lets the OD/diurnal prior fill them. 19 """ 20 v = np.asarray(v, float) 21 vf = float(params["v_f"]); qc = float(params["q_cap"]) 22 kc = float(params["k_crit"]); kj = float(params["k_jam"]) 23 r = v / max(vf, 1e-6) 24 q = np.full_like(v, np.nan); sig = np.full_like(v, np.inf) 25 cong = r < 0.75 26 q = np.where(cong, cap_drop * qc, q) 27 sig = np.where(cong, 0.30 * cap_drop * qc, sig) 28 tr = (r >= 0.75) & (r < 0.9) 29 kt = qc * kj / (v * (kj - kc) + qc) # congested-branch density 30 q = np.where(tr, kt * v, q) 31 sig = np.where(tr, 0.60 * np.maximum(kt * v, 1.0), sig) 32 return q, sig # free flow stays (nan, inf)
Regime-aware volume estimate + 1-sigma uncertainty from a speed field.
congested (v < 0.75 v_f): near capacity, q ~= cap_drop * C (tight sigma) transition (0.75-0.9 v_f): FD congested branch (loose sigma) free (v >= 0.9 v_f): UNDER-DETERMINED -> (nan, inf) (defer to a prior)
Returns (q_hat, sigma) as arrays; free-flow cells carry (nan, inf) so an ODME step weights them out (weight = 1/sigma^2 = 0) and lets the OD/diurnal prior fill them.
21def newell_corridor(length, lanes, vf, w, kjam, qmax, inflow, dt_sec: float = 6.0): 22 length = np.ascontiguousarray(length, float) 23 lanes = np.ascontiguousarray(lanes, float) 24 vf = np.ascontiguousarray(vf, float) 25 w = np.ascontiguousarray(w, float) 26 kjam = np.ascontiguousarray(kjam, float) 27 inflow = np.ascontiguousarray(inflow, float) 28 L, T = len(length), len(inflow) 29 qmax = np.ascontiguousarray(np.asarray(qmax, float).reshape(L, T)) 30 dT_h = dt_sec / 3600.0 31 32 # per-link free-flow / backward-wave lags (ticks) and storage (veh) -- exactly the C++ 33 fftt = np.maximum(1, (length / vf / dT_h + 0.5).astype(int)) 34 bwtt = np.maximum(1, (length / w / dT_h + 0.5).astype(int)) 35 storage = kjam * length * lanes 36 37 A = np.zeros((L, T)); D = np.zeros((L, T)) 38 A[0, 1:] = np.cumsum(inflow[1:] * dT_h) # cumulative demand into link 0 39 40 for t in range(1, T): 41 Vt = np.empty(L); capin = np.empty(L) 42 for l in range(L): # Step 2: states at t 43 Vt[l] = A[l, t - fftt[l]] if t - fftt[l] >= 0 else 0.0 # forward wave 44 D_bw = D[l, t - bwtt[l]] if t - bwtt[l] >= 0 else 0.0 45 capin[l] = max(0.0, D_bw + storage[l] - A[l, t - 1]) # backward wave + storage 46 for l in range(L): # Step 3: capacity-constrained transfer 47 capout = min(qmax[l, t] * dT_h, capin[l + 1]) if l < L - 1 else qmax[l, t] * dT_h 48 Dt = min(Vt[l], D[l, t - 1] + capout) 49 D[l, t] = Dt 50 if l < L - 1: 51 A[l + 1, t] = Dt # A(l+1,t) = D(l,t) 52 return A, D
77def newell_merge(length, lanes, vf, w, kjam, qmax, downstream, inflow, dt_sec: float = 6.0, 78 off_down=None, off_frac=None): 79 """Newell KW loading on a freeway-junction topology (merge + diverge). 80 81 Movement-based: each link sends a mainline movement to `downstream[l]` and, if 82 `off_frac[l] > 0`, an off-ramp movement (fraction `off_frac[l]` of its outflow) to 83 `off_down[l]`. A downstream link's inflow capacity is split across the movements entering 84 it **proportional to the # of incoming lanes** (DTALite merge). Movements are 85 independent, so an off-ramp can never block the mainline (off-ramp spillback disabled -- 86 see docs/assumptions.md). `downstream`/`off_down` = -1 means the movement exits the 87 network. Generalizes `newell_corridor` and the pure merge. 88 89 inflow : (L, T) external demand entering each origin link (0 for interior links); unmet 90 demand waits in a per-origin queue off-network. Returns (A, D), each (L, T). 91 """ 92 length = np.asarray(length, float) 93 lanes = np.maximum(np.asarray(lanes, float), 1e-6) # guard the lane-proportional split 94 vf = np.asarray(vf, float); w = np.asarray(w, float); kjam = np.asarray(kjam, float) 95 downstream = np.asarray(downstream, int) 96 L = len(length) 97 off_down = np.full(L, -1, int) if off_down is None else np.asarray(off_down, int) 98 off_frac = np.zeros(L) if off_frac is None else np.asarray(off_frac, float) 99 inflow = np.ascontiguousarray(np.asarray(inflow, float).reshape(L, -1)) 100 T = inflow.shape[1] 101 qmax = np.ascontiguousarray(np.asarray(qmax, float).reshape(L, T)) 102 dT_h = dt_sec / 3600.0 103 104 fftt = np.maximum(1, (length / vf / dT_h + 0.5).astype(int)) 105 bwtt = np.maximum(1, (length / w / dT_h + 0.5).astype(int)) 106 storage = kjam * length * lanes 107 # incoming movements per downstream link: (from_link, is_offramp) 108 in_moves = [[] for _ in range(L)] 109 for l in range(L): 110 if downstream[l] >= 0: 111 in_moves[downstream[l]].append((l, False)) 112 if off_down[l] >= 0 and off_frac[l] > 0: 113 in_moves[off_down[l]].append((l, True)) 114 is_origin = [not in_moves[l] for l in range(L)] 115 116 A = np.zeros((L, T)); D = np.zeros((L, T)); oq = np.zeros(L) 117 for l in range(L): 118 if is_origin[l]: 119 oq[l] += inflow[l, 0] * dT_h # inject t=0 demand (loop starts at t=1) 120 for t in range(1, T): 121 S = np.zeros(L); capin = np.zeros(L) 122 for l in range(L): 123 Vl = A[l, t - fftt[l]] if t - fftt[l] >= 0 else 0.0 124 S[l] = min(max(0.0, Vl - D[l, t - 1]), qmax[l, t] * dT_h) 125 Dbw = D[l, t - bwtt[l]] if t - bwtt[l] >= 0 else 0.0 126 capin[l] = max(0.0, Dbw + storage[l] - A[l, t - 1]) 127 main_dem = S * (1.0 - off_frac); off_dem = S * off_frac 128 alloc = np.zeros(L); arrivals = np.zeros(L) 129 for l in range(L): # movements that exit the network 130 if downstream[l] < 0: 131 alloc[l] += main_dem[l] 132 if off_down[l] < 0 and off_frac[l] > 0: 133 alloc[l] += off_dem[l] 134 for d in range(L): # merge over movements entering d 135 moves = in_moves[d] 136 if not moves: 137 continue 138 dem = [(off_dem[fl] if is_off else main_dem[fl]) for (fl, is_off) in moves] 139 wt = [lanes[fl] for (fl, _) in moves] 140 acc = _cap_split(dem, wt, range(len(moves)), capin[d]) 141 for k, (fl, _) in enumerate(moves): 142 alloc[fl] += acc[k]; arrivals[d] += acc[k] 143 for l in range(L): # admit external demand at origins 144 if is_origin[l]: 145 oq[l] += inflow[l, t] * dT_h 146 a = min(oq[l], capin[l]); arrivals[l] += a; oq[l] -= a 147 for l in range(L): 148 D[l, t] = D[l, t - 1] + alloc[l] 149 A[l, t] = A[l, t - 1] + arrivals[l] 150 return A, D
Newell KW loading on a freeway-junction topology (merge + diverge).
Movement-based: each link sends a mainline movement to downstream[l] and, if
off_frac[l] > 0, an off-ramp movement (fraction off_frac[l] of its outflow) to
off_down[l]. A downstream link's inflow capacity is split across the movements entering
it proportional to the # of incoming lanes (DTALite merge). Movements are
independent, so an off-ramp can never block the mainline (off-ramp spillback disabled --
see docs/assumptions.md). downstream/off_down = -1 means the movement exits the
network. Generalizes newell_corridor and the pure merge.
inflow : (L, T) external demand entering each origin link (0 for interior links); unmet demand waits in a per-origin queue off-network. Returns (A, D), each (L, T).
153def corridor_engine(prefer_native: bool = True): 154 """Return (fn, backend_name): the native corridor loader if built, else pure-Python. 155 Both take the same signature and return identical (A, D).""" 156 if prefer_native: 157 try: 158 from . import native 159 if native.available(): 160 return native.newell_corridor, "native-c++" 161 native.note_unavailable() 162 except Exception: 163 pass 164 return newell_corridor, "python"
Return (fn, backend_name): the native corridor loader if built, else pure-Python. Both take the same signature and return identical (A, D).
30def load_scenario(scn_dir: str) -> dict: 31 """Read a scenario directory (node/link/demand csv + scenario.json).""" 32 net = read_network(scn_dir) 33 demand = pd.read_csv(os.path.join(scn_dir, "demand.csv")) 34 with open(os.path.join(scn_dir, "scenario.json")) as f: 35 cfg = json.load(f) 36 return {"dir": scn_dir, "net": net, "demand": demand, "cfg": cfg}
Read a scenario directory (node/link/demand csv + scenario.json).
142def run_scenario(scn: dict, demand_scale: float = 1.0, bin_min: int = 5, 143 prefer_native: bool = True) -> dict: 144 """Run merge dynamic loading and derive the density-canonical state, queue profile, 145 bottleneck report, and physics gate. Returns a rich result dict.""" 146 p = _build_inputs(scn, demand_scale); topo = p["topo"] 147 engine, backend = merge_engine(prefer_native) 148 A, D = engine(p["length"], p["lanes"], p["vf"], p["w"], p["kjam_per_lane"], 149 p["qmax"], np.asarray(topo["downstream"]), p["inflow"], float(p["tick"]), 150 off_down=p["off_down"], off_frac=p["off_frac"]) 151 A = np.asarray(A); D = np.asarray(D) 152 L, T = A.shape 153 on = A - D 154 dt_h = p["tick"] / 3600.0 155 bin_ticks = max(1, int(bin_min * 60 // p["tick"])) 156 fd = scn["net"]["fd"].set_index("link_id") 157 158 state_rows, queue_rows = [], [] 159 for i, lid in enumerate(topo["lids"]): 160 length_i, vf_i = p["length"][i], p["vf"][i] 161 for b0 in range(0, T - 1, bin_ticks): 162 b1 = min(b0 + bin_ticks, T - 1) 163 hrs = (b1 - b0) * dt_h 164 if hrs <= 0: 165 continue 166 flow = (D[i, b1] - D[i, b0]) / hrs # veh/h discharged in the bin 167 n_avg = float(on[i, b0:b1].mean()) # mean vehicles on link 168 dens = n_avg / max(length_i, 1e-6) # total veh/mi 169 spd = flow / dens if dens > 1e-6 else vf_i 170 if spd > vf_i: # free-flow discretization overshoot: 171 spd, flow = vf_i, dens * vf_i # cap v at v_f, keep q = k*v exact 172 tod = "%02d:%02d" % (int(p["base"] + b0 * dt_h * 60) // 60, 173 int(p["base"] + b0 * dt_h * 60) % 60) 174 tt_min = length_i / max(spd, 1e-6) * 60.0 175 state_rows.append(dict(link_id=lid, tod=tod, speed=spd, flow=flow, 176 density=dens, queue=max(0.0, n_avg), travel_time_min=tt_min)) 177 # congestion episode = the contiguous over-capacity run containing the peak queue 178 thresh = float(fd.loc[lid]["k_crit"]) * length_i # ~free-flow content 179 above = on[i] > thresh 180 onset = clear = None 181 if above.any(): 182 peak = int(on[i].argmax()) 183 s = peak 184 while s > 0 and above[s - 1]: 185 s -= 1 186 e = peak 187 while e < T - 1 and above[e + 1]: 188 e += 1 189 onset, clear = s, (e + 1 if e < T - 1 else None) 190 to_min = lambda t: None if t is None else round(p["base"] + t * dt_h * 60, 1) 191 queue_rows.append(dict(link_id=lid, max_on_link=float(on[i].max()), 192 onset_min=to_min(onset), clear_min=to_min(clear), 193 duration_min=None if (onset is None or clear is None) else round((clear - onset) * dt_h * 60, 1))) 194 195 state = pd.DataFrame(state_rows) 196 gate, report = validate(state[["link_id", "speed", "flow", "density"]], scn["net"]["fd"]) 197 bottleneck = (pd.DataFrame(queue_rows).sort_values("max_on_link", ascending=False) 198 .reset_index(drop=True)) 199 released = float(sum(A[topo["idx"][lid], -1] for lid in topo["origins"])) 200 on_end = float(on[:, -1].sum()) # vehicles still on the network 201 throughput = released - on_end # left via a mainline exit or an off-ramp 202 conserved = on_end < max(5.0, 0.001 * max(released, 1.0)) # network cleared (no residual) 203 return dict(backend=backend, A=A, D=D, on=on, topo=topo, tmin=p["tmin"], 204 inflow=p["inflow"], qmax=p["qmax"], state=state, gate=gate, 205 gate_report=report, bottleneck=bottleneck, conserved=bool(conserved), 206 throughput=throughput, released=released, notes=topo["notes"])
Run merge dynamic loading and derive the density-canonical state, queue profile, bottleneck report, and physics gate. Returns a rich result dict.
209def recover_demand_scale(scn: dict, observed_throughput: float, 210 lo: float = 0.5, hi: float = 1.6, iters: int = 24) -> dict: 211 """Illustrative 1-parameter ODME: recover the demand multiplier whose simulated 212 corridor throughput matches an observed total, by bisection on the monotone map 213 scale -> throughput. The full multi-target ODME is `dlsim4gmns.odme` (FTT/GLS).""" 214 def err(s): 215 return run_scenario(scn, demand_scale=s)["throughput"] - observed_throughput 216 e_lo, e_hi = err(lo), err(hi) 217 if e_lo * e_hi > 0: 218 best = lo if abs(e_lo) < abs(e_hi) else hi 219 return dict(scale=best, matched=False, 220 throughput=observed_throughput + (e_lo if best == lo else e_hi)) 221 for _ in range(iters): 222 mid = 0.5 * (lo + hi) 223 if err(mid) * e_lo > 0: 224 lo = mid 225 else: 226 hi = mid 227 scale = 0.5 * (lo + hi) 228 return dict(scale=scale, matched=True, 229 throughput=run_scenario(scn, demand_scale=scale)["throughput"])
Illustrative 1-parameter ODME: recover the demand multiplier whose simulated
corridor throughput matches an observed total, by bisection on the monotone map
scale -> throughput. The full multi-target ODME is dlsim4gmns.odme (FTT/GLS).
39def topology(net: dict) -> dict: 40 """Infer junction topology from GMNS: downstream[l] = the MAINLINE link l feeds into (the 41 outgoing link with the most lanes at its end node, or -1 at an exit), the origin links (no 42 incoming link), and each origin's zone. Off-ramp branches at a diverge node are activated 43 by declaring them in scenario.json 'diverges' (see docs/assumptions.md).""" 44 links, nodes = net["links"], net["nodes"] 45 lids = list(links["link_id"]) 46 idx = {lid: i for i, lid in enumerate(lids)} 47 frm = dict(zip(links["link_id"], links["from_node_id"])) 48 to = dict(zip(links["link_id"], links["to_node_id"])) 49 lanes_of = dict(zip(links["link_id"], pd.to_numeric( 50 links.get("lanes", pd.Series([2] * len(links))), errors="coerce").fillna(1))) 51 out_by_node, in_nodes, notes = {}, set(), [] 52 for lid in lids: 53 out_by_node.setdefault(frm[lid], []).append(lid) 54 in_nodes.add(to[lid]) 55 downstream = [] 56 for lid in lids: 57 outs = out_by_node.get(to[lid], []) 58 if not outs: 59 downstream.append(-1) 60 continue 61 main = max(outs, key=lambda x: lanes_of.get(x, 1)) # mainline = most lanes 62 downstream.append(idx[main]) 63 if len(outs) > 1: 64 offs = [o for o in outs if o != main] 65 notes.append(f"node {to[lid]}: diverge (mainline={main}, off-ramp(s)={offs}); " 66 f"declare in scenario.json 'diverges' to route off-ramp flow") 67 origins = [lid for lid in lids if frm[lid] not in in_nodes] 68 zone = dict(zip(nodes["node_id"], nodes.get("zone_id", pd.Series([None] * len(nodes))))) 69 origin_zone = {lid: zone.get(frm[lid]) for lid in origins} 70 return dict(lids=lids, idx=idx, downstream=downstream, origins=origins, 71 origin_zone=origin_zone, exits=[i for i, d in enumerate(downstream) if d < 0], 72 notes=notes)
Infer junction topology from GMNS: downstream[l] = the MAINLINE link l feeds into (the outgoing link with the most lanes at its end node, or -1 at an exit), the origin links (no incoming link), and each origin's zone. Off-ramp branches at a diverge node are activated by declaring them in scenario.json 'diverges' (see docs/assumptions.md).
128def merge_engine(prefer_native: bool = True): 129 """Return (fn, backend): the native merge loader if built, else pure-Python. Both take 130 the same signature and return identical (A, D).""" 131 if prefer_native: 132 try: 133 from . import native 134 if native.available() and native.available_merge(): 135 return native.newell_merge, "native-c++" 136 native.note_unavailable() 137 except Exception: 138 pass 139 return newell_merge, "python"
Return (fn, backend): the native merge loader if built, else pure-Python. Both take the same signature and return identical (A, D).
21def load_corridor_field(field_dir: str) -> dict: 22 """Read a field directory: GMNS network + observed 5-min link states.""" 23 net = read_network(field_dir) 24 obs = pd.read_csv(os.path.join(field_dir, "observed_weekday.csv")) 25 return {"dir": field_dir, "net": net, "observed": obs}
Read a field directory: GMNS network + observed 5-min link states.
28def field_physics_check(field: dict) -> tuple[bool, dict]: 29 """Run the physics gate on the OBSERVED field states. Returns (gate, report) with q=k*v 30 residual stats added. 31 32 Honesty note: `q = k*v` holding on PeMS data is **internal consistency**, not a physics 33 validation -- PeMS density is derived as flow/speed, so the identity holds by construction. 34 The genuine model-vs-field validation is `fd_prediction_error` (predict speed from density 35 via a calibrated FD; the triangular shape is a real constraint the data need not satisfy). 36 FD feasibility is checked against the network's posted free_speed; observed free-flow 37 speeds can exceed it (a data-vs-assumption gap surfaced honestly).""" 38 obs = field["observed"] 39 gate, report = validate(obs[["link_id", "speed", "flow", "density"]], field["net"]["fd"]) 40 q, k, v = obs["flow"].values, obs["density"].values, obs["speed"].values 41 qkv = np.abs(q - k * v) / np.maximum(np.abs(q), 1.0) 42 report["qkv_residual_median"] = round(float(np.median(qkv)), 5) 43 report["qkv_frac_within_5pct"] = round(float((qkv <= 0.05).mean()), 4) 44 return gate, report
Run the physics gate on the OBSERVED field states. Returns (gate, report) with q=k*v residual stats added.
Honesty note: q = k*v holding on PeMS data is internal consistency, not a physics
validation -- PeMS density is derived as flow/speed, so the identity holds by construction.
The genuine model-vs-field validation is fd_prediction_error (predict speed from density
via a calibrated FD; the triangular shape is a real constraint the data need not satisfy).
FD feasibility is checked against the network's posted free_speed; observed free-flow
speeds can exceed it (a data-vs-assumption gap surfaced honestly).
47def bottleneck_ranking(field: dict, cong_speed: float | None = None) -> pd.DataFrame: 48 """Per-link observed congestion: min speed, congestion duration, discharge, D/C -- 49 ranked worst-first. A cell is congested below `cong_speed` (default 0.6 x free-flow).""" 50 obs = field["observed"] 51 links = field["net"]["links"].set_index("link_id") 52 fd = field["net"]["fd"].set_index("link_id") 53 rows = [] 54 for lid, g in obs.groupby("link_id"): 55 vf = float(fd.loc[lid]["v_f"]) if lid in fd.index else 105.0 56 cap = float(links.loc[lid]["capacity_vph"]) if lid in links.index else np.nan 57 thr = cong_speed if cong_speed is not None else CONG_SPEED_FRAC * vf 58 cong = g[g["speed"] < thr] 59 peak_flow = float(g["flow"].max()) # realized throughput (~ capacity) 60 rows.append(dict( 61 link_id=lid, min_speed=round(float(g["speed"].min()), 1), 62 max_density=round(float(g["density"].max()), 1), 63 congested_min=len(cong) * STEP_MIN, 64 onset=(cong["tod"].min() if len(cong) else None), 65 clear=(cong["tod"].max() if len(cong) else None), 66 peak_flow_vph=round(peak_flow, 0), 67 utilization=round(peak_flow / cap, 2) if cap > 0 else float("nan"))) 68 return (pd.DataFrame(rows).sort_values(["congested_min", "min_speed"], 69 ascending=[False, True]).reset_index(drop=True))
Per-link observed congestion: min speed, congestion duration, discharge, D/C --
ranked worst-first. A cell is congested below cong_speed (default 0.6 x free-flow).
72def speed_matrix(field: dict) -> pd.DataFrame: 73 """Link x time-of-day speed matrix (for a space-time speed heatmap).""" 74 return field["observed"].pivot_table("speed", "link_id", "tod")
Link x time-of-day speed matrix (for a space-time speed heatmap).
77def calibrate_fd(field: dict) -> pd.DataFrame: 78 """Calibrate a triangular FD (v_f, q_cap, k_crit, k_jam) per link from the observed 79 (density, flow, speed) cloud -- the field-derived FD / QVDF parameters. Also compares 80 the calibrated (achievable) capacity to the network's posted capacity.""" 81 obs = field["observed"]; links = field["net"]["links"].set_index("link_id") 82 rows = [] 83 for lid, g in obs.groupby("link_id"): 84 v, k, q = g["speed"].values, g["density"].values, g["flow"].values 85 vf = float(np.percentile(v, 95)) # free-flow speed 86 qcap = float(np.percentile(q, 98)) # achievable capacity 87 kcrit = qcap / max(vf, 1.0) 88 kjam = float(max(np.percentile(k, 99) * 1.05, kcrit * 4)) 89 posted = float(links.loc[lid]["capacity_vph"]) if lid in links.index else float("nan") 90 rows.append(dict(link_id=lid, v_f=round(vf, 1), q_cap=round(qcap, 0), 91 k_crit=round(kcrit, 1), k_jam=round(kjam, 1), 92 posted_capacity=posted, 93 capacity_ratio=round(qcap / posted, 2) if posted > 0 else float("nan"))) 94 return pd.DataFrame(rows)
Calibrate a triangular FD (v_f, q_cap, k_crit, k_jam) per link from the observed (density, flow, speed) cloud -- the field-derived FD / QVDF parameters. Also compares the calibrated (achievable) capacity to the network's posted capacity.
97def fd_prediction_error(field: dict, fd_cal: pd.DataFrame | None = None) -> dict: 98 """Validate the density-canonical model on field data: predict speed from the OBSERVED 99 density via the calibrated triangular FD and compare to observed speed. Returns the 100 speed MAPE (flow MAPE is identical, since q = k*v). This is the sim-vs-field model check. 101 """ 102 from .fd import fd_state 103 if fd_cal is None: 104 fd_cal = calibrate_fd(field) 105 cal = fd_cal.set_index("link_id"); obs = field["observed"] 106 errs = [] 107 for lid, g in obs.groupby("link_id"): 108 if lid not in cal.index: 109 continue 110 p = cal.loc[lid]; k, v, q = g["density"].values, g["speed"].values, g["flow"].values 111 vp, _ = fd_state(k, dict(v_f=p["v_f"], q_cap=p["q_cap"], k_crit=p["k_crit"], k_jam=p["k_jam"])) 112 m = q > 50 # measure where there is real flow 113 if m.sum(): 114 errs.append(float(np.mean(np.abs(vp[m] - v[m]) / np.maximum(v[m], 1.0)))) 115 errs = np.array(errs) 116 return dict(speed_mape_median=round(float(np.median(errs)), 4), 117 speed_mape_mean=round(float(np.mean(errs)), 4), 118 capacity_ratio_median=round(float(fd_cal["capacity_ratio"].median()), 2), 119 n_links=len(errs))
Validate the density-canonical model on field data: predict speed from the OBSERVED density via the calibrated triangular FD and compare to observed speed. Returns the speed MAPE (flow MAPE is identical, since q = k*v). This is the sim-vs-field model check.
26class Simulator: 27 def __init__(self, scn: dict, demand_scale: float = 1.0): 28 p = _build_inputs(scn, demand_scale) 29 self.topo = p["topo"]; self.tick = int(p["tick"]); self.base = int(p["base"]) 30 self.T = int(p["T"]); self.tmin = p["tmin"]; self.dT_h = self.tick / 3600.0 31 self.length = np.asarray(p["length"], float); self.lanes = np.asarray(p["lanes"], float) 32 self.vf = np.asarray(p["vf"], float) 33 self.downstream = np.asarray(self.topo["downstream"], int) 34 self.off_down = np.asarray(p["off_down"], int); self.off_frac = np.asarray(p["off_frac"], float) 35 self.qmax = np.array(p["qmax"], float) # mutable (control target) 36 self.inflow = np.array(p["inflow"], float) # mutable (control target) 37 L = len(self.length); self.L = L 38 w = np.asarray(p["w"], float); kjam = np.asarray(p["kjam_per_lane"], float) 39 self.fftt = np.maximum(1, (self.length / self.vf / self.dT_h + 0.5).astype(int)) 40 self.bwtt = np.maximum(1, (self.length / w / self.dT_h + 0.5).astype(int)) 41 self.storage = kjam * self.length * self.lanes 42 self.in_moves = [[] for _ in range(L)] 43 for l in range(L): 44 if self.downstream[l] >= 0: 45 self.in_moves[self.downstream[l]].append((l, False)) 46 if self.off_down[l] >= 0 and self.off_frac[l] > 0: 47 self.in_moves[self.off_down[l]].append((l, True)) 48 self.is_origin = [not self.in_moves[l] for l in range(L)] 49 self.A = np.zeros((L, self.T)); self.D = np.zeros((L, self.T)); self.oq = np.zeros(L) 50 for l in range(L): 51 if self.is_origin[l]: 52 self.oq[l] += self.inflow[l, 0] * self.dT_h # inject t=0 demand 53 self.t = 0 54 55 # ---- driving ------------------------------------------------------------------- 56 def _tick(self, t: int): 57 A, D, L, dT = self.A, self.D, self.L, self.dT_h 58 S = np.zeros(L); capin = np.zeros(L) 59 for l in range(L): 60 Vl = A[l, t - self.fftt[l]] if t - self.fftt[l] >= 0 else 0.0 61 S[l] = min(max(0.0, Vl - D[l, t - 1]), self.qmax[l, t] * dT) 62 Dbw = D[l, t - self.bwtt[l]] if t - self.bwtt[l] >= 0 else 0.0 63 capin[l] = max(0.0, Dbw + self.storage[l] - A[l, t - 1]) 64 main_dem = S * (1.0 - self.off_frac); off_dem = S * self.off_frac 65 alloc = np.zeros(L); arr = np.zeros(L) 66 for l in range(L): 67 if self.downstream[l] < 0: 68 alloc[l] += main_dem[l] 69 if self.off_down[l] < 0 and self.off_frac[l] > 0: 70 alloc[l] += off_dem[l] 71 for d in range(L): 72 moves = self.in_moves[d] 73 if not moves: 74 continue 75 dem = [(off_dem[fl] if is_off else main_dem[fl]) for (fl, is_off) in moves] 76 wt = [self.lanes[fl] for (fl, _) in moves] 77 acc = _cap_split(dem, wt, range(len(moves)), capin[d]) 78 for k, (fl, _) in enumerate(moves): 79 alloc[fl] += acc[k]; arr[d] += acc[k] 80 for l in range(L): 81 if self.is_origin[l]: 82 self.oq[l] += self.inflow[l, t] * dT 83 a = min(self.oq[l], capin[l]); arr[l] += a; self.oq[l] -= a 84 for l in range(L): 85 D[l, t] = D[l, t - 1] + alloc[l] 86 A[l, t] = A[l, t - 1] + arr[l] 87 88 def step(self, n: int = 1): 89 """Advance up to n ticks (stops at the horizon). Returns self.""" 90 for _ in range(n): 91 if self.t >= self.T - 1: 92 break 93 self.t += 1 94 self._tick(self.t) 95 return self 96 97 def run(self): 98 """Advance to the horizon. Returns self.""" 99 while not self.done(): 100 self.step() 101 return self 102 103 def done(self) -> bool: 104 return self.t >= self.T - 1 105 106 def now_min(self) -> float: 107 return self.base + self.t * self.dT_h * 60.0 108 109 # ---- observe ------------------------------------------------------------------- 110 def state(self) -> pd.DataFrame: 111 """Per-link instantaneous state at the current tick: q, k, v, queue (q = k*v exact).""" 112 t = max(self.t, 1) 113 on = self.A[:, t] - self.D[:, t] 114 flow = (self.D[:, t] - self.D[:, t - 1]) / self.dT_h 115 dens = on / np.maximum(self.length, 1e-6) 116 spd = np.where(dens > 1e-6, flow / np.maximum(dens, 1e-9), self.vf) 117 over = spd > self.vf 118 spd = np.where(over, self.vf, spd); flow = np.where(over, dens * self.vf, flow) 119 tod = "%02d:%02d" % (int(self.now_min()) // 60, int(self.now_min()) % 60) 120 return pd.DataFrame(dict(link_id=self.topo["lids"], tod=tod, speed=spd, 121 flow=flow, density=dens, queue=np.maximum(on, 0.0))) 122 123 def to_arrays(self): 124 """Cumulative arrival/departure arrays (L x T) filled up to the current tick.""" 125 return self.A, self.D 126 127 # ---- control (inject between ticks) -------------------------------------------- 128 def set_capacity(self, link_id, rate_vph: float): 129 """Set a link's outflow capacity from the current tick onward (e.g. a ramp meter).""" 130 self.qmax[self.topo["idx"][link_id], self.t:] = float(rate_vph) 131 132 def add_incident(self, link_id, capacity_vph: float, start_min: float, end_min: float): 133 """Drop a link's capacity over a wall-clock window (minutes since midnight).""" 134 m = (self.tmin >= start_min) & (self.tmin < end_min) 135 self.qmax[self.topo["idx"][link_id], m] = float(capacity_vph) 136 137 def set_inflow(self, link_id, volume_vph: float): 138 """Set an origin link's demand from the current tick onward.""" 139 self.inflow[self.topo["idx"][link_id], self.t:] = float(volume_vph)
27 def __init__(self, scn: dict, demand_scale: float = 1.0): 28 p = _build_inputs(scn, demand_scale) 29 self.topo = p["topo"]; self.tick = int(p["tick"]); self.base = int(p["base"]) 30 self.T = int(p["T"]); self.tmin = p["tmin"]; self.dT_h = self.tick / 3600.0 31 self.length = np.asarray(p["length"], float); self.lanes = np.asarray(p["lanes"], float) 32 self.vf = np.asarray(p["vf"], float) 33 self.downstream = np.asarray(self.topo["downstream"], int) 34 self.off_down = np.asarray(p["off_down"], int); self.off_frac = np.asarray(p["off_frac"], float) 35 self.qmax = np.array(p["qmax"], float) # mutable (control target) 36 self.inflow = np.array(p["inflow"], float) # mutable (control target) 37 L = len(self.length); self.L = L 38 w = np.asarray(p["w"], float); kjam = np.asarray(p["kjam_per_lane"], float) 39 self.fftt = np.maximum(1, (self.length / self.vf / self.dT_h + 0.5).astype(int)) 40 self.bwtt = np.maximum(1, (self.length / w / self.dT_h + 0.5).astype(int)) 41 self.storage = kjam * self.length * self.lanes 42 self.in_moves = [[] for _ in range(L)] 43 for l in range(L): 44 if self.downstream[l] >= 0: 45 self.in_moves[self.downstream[l]].append((l, False)) 46 if self.off_down[l] >= 0 and self.off_frac[l] > 0: 47 self.in_moves[self.off_down[l]].append((l, True)) 48 self.is_origin = [not self.in_moves[l] for l in range(L)] 49 self.A = np.zeros((L, self.T)); self.D = np.zeros((L, self.T)); self.oq = np.zeros(L) 50 for l in range(L): 51 if self.is_origin[l]: 52 self.oq[l] += self.inflow[l, 0] * self.dT_h # inject t=0 demand 53 self.t = 0
88 def step(self, n: int = 1): 89 """Advance up to n ticks (stops at the horizon). Returns self.""" 90 for _ in range(n): 91 if self.t >= self.T - 1: 92 break 93 self.t += 1 94 self._tick(self.t) 95 return self
Advance up to n ticks (stops at the horizon). Returns self.
97 def run(self): 98 """Advance to the horizon. Returns self.""" 99 while not self.done(): 100 self.step() 101 return self
Advance to the horizon. Returns self.
110 def state(self) -> pd.DataFrame: 111 """Per-link instantaneous state at the current tick: q, k, v, queue (q = k*v exact).""" 112 t = max(self.t, 1) 113 on = self.A[:, t] - self.D[:, t] 114 flow = (self.D[:, t] - self.D[:, t - 1]) / self.dT_h 115 dens = on / np.maximum(self.length, 1e-6) 116 spd = np.where(dens > 1e-6, flow / np.maximum(dens, 1e-9), self.vf) 117 over = spd > self.vf 118 spd = np.where(over, self.vf, spd); flow = np.where(over, dens * self.vf, flow) 119 tod = "%02d:%02d" % (int(self.now_min()) // 60, int(self.now_min()) % 60) 120 return pd.DataFrame(dict(link_id=self.topo["lids"], tod=tod, speed=spd, 121 flow=flow, density=dens, queue=np.maximum(on, 0.0)))
Per-link instantaneous state at the current tick: q, k, v, queue (q = k*v exact).
123 def to_arrays(self): 124 """Cumulative arrival/departure arrays (L x T) filled up to the current tick.""" 125 return self.A, self.D
Cumulative arrival/departure arrays (L x T) filled up to the current tick.
128 def set_capacity(self, link_id, rate_vph: float): 129 """Set a link's outflow capacity from the current tick onward (e.g. a ramp meter).""" 130 self.qmax[self.topo["idx"][link_id], self.t:] = float(rate_vph)
Set a link's outflow capacity from the current tick onward (e.g. a ramp meter).
132 def add_incident(self, link_id, capacity_vph: float, start_min: float, end_min: float): 133 """Drop a link's capacity over a wall-clock window (minutes since midnight).""" 134 m = (self.tmin >= start_min) & (self.tmin < end_min) 135 self.qmax[self.topo["idx"][link_id], m] = float(capacity_vph)
Drop a link's capacity over a wall-clock window (minutes since midnight).
137 def set_inflow(self, link_id, volume_vph: float): 138 """Set an origin link's demand from the current tick onward.""" 139 self.inflow[self.topo["idx"][link_id], self.t:] = float(volume_vph)
Set an origin link's demand from the current tick onward.
17def calibrate_odme(scn: dict, target_counts: dict, iters: int = 15, gain: float = 1.0, 18 lo: float = 0.3, hi: float = 3.0) -> dict: 19 """Recover a per-origin demand multiplier `theta` so simulated counts match observed. 20 21 scn : a loaded scenario (`load_scenario`). 22 target_counts : {link_id: observed total departures over the horizon}. Calibrated 23 origins are those whose own link is a target. 24 Returns dict(theta, rmse_history, sim_counts, converged, hit_bound). `converged` is True 25 when the final count RMSE is below `tol` (default 1% of total observed); `hit_bound` 26 flags origins whose multiplier is pinned at `lo`/`hi` (the estimate is unreliable there). 27 """ 28 topo = topology(scn["net"]); idx = topo["idx"]; zone_of = topo["origin_zone"] 29 theta = {l: 1.0 for l in target_counts if l in zone_of} # per-origin multipliers 30 base = scn["demand"]["volume_vph"].to_numpy(dtype=float).copy() 31 ozone = scn["demand"]["o_zone_id"].to_numpy() 32 tol = 0.01 * max(sum(target_counts.values()), 1.0) 33 hist = []; sim_counts = {} 34 for _ in range(iters): 35 s = copy.deepcopy(scn) 36 vol = base.copy() 37 for l, th in theta.items(): 38 vol[ozone == zone_of[l]] *= th 39 s["demand"] = s["demand"].assign(volume_vph=vol) 40 sim = Simulator(s).run() # the control interface as forward model 41 sim_counts = {l: float(sim.D[idx[l], -1]) for l in target_counts} 42 hist.append(float(np.sqrt(np.mean([(sim_counts[l] - target_counts[l]) ** 2 43 for l in target_counts])))) 44 if hist[-1] < tol: 45 break 46 for l in theta: # multiplicative update toward target 47 if sim_counts[l] > 1: 48 theta[l] = float(np.clip(theta[l] * (target_counts[l] / sim_counts[l]) ** gain, lo, hi)) 49 hit_bound = {l: (th <= lo + 1e-9 or th >= hi - 1e-9) for l, th in theta.items()} 50 return dict(theta=theta, rmse_history=hist, sim_counts=sim_counts, 51 converged=bool(hist and hist[-1] < tol), hit_bound=hit_bound)
Recover a per-origin demand multiplier theta so simulated counts match observed.
scn : a loaded scenario (load_scenario).
target_counts : {link_id: observed total departures over the horizon}. Calibrated
origins are those whose own link is a target.
Returns dict(theta, rmse_history, sim_counts, converged, hit_bound). converged is True
when the final count RMSE is below tol (default 1% of total observed); hit_bound
flags origins whose multiplier is pinned at lo/hi (the estimate is unreliable there).