goatifi / scripts /build_venues.py
KonoDioDaa's picture
Initial FlowTwin deployment
e7a9f02
Raw
History Blame Contribute Delete
37.3 kB
#!/usr/bin/env python3
"""Generate the venue and scenario JSON files.
Edge lengths are derived from node geometry rather than hand-written, so the
map and the physics can never drift apart. Capacities follow Fruin-style
pedestrian flow: roughly 70 people per minute per metre of effective width for
a corridor in one direction.
Run: python scripts/build_venues.py
"""
from __future__ import annotations
import json
import math
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT / "backend"))
VENUE_DIR = ROOT / "data" / "venues"
SCENARIO_DIR = ROOT / "data" / "scenarios"
#: People per minute, per metre of walkway width, in one direction.
FLOW_PER_METRE_WIDTH = 70.0
def dist(a: tuple[float, float], b: tuple[float, float]) -> float:
return math.hypot(a[0] - b[0], a[1] - b[1])
def path_length(points: list[tuple[float, float]]) -> float:
return sum(dist(points[i], points[i + 1]) for i in range(len(points) - 1))
class VenueBuilder:
def __init__(self, **meta):
self.meta = meta
self.nodes: list[dict] = []
self.edges: list[dict] = []
self.landmarks: list[dict] = []
self.phases: list[dict] = []
self._pos: dict[str, tuple[float, float]] = {}
def node(self, node_id: str, name: str, type_: str, x: float, y: float, **kw) -> str:
self._pos[node_id] = (x, y)
self.nodes.append({"id": node_id, "name": name, "type": type_,
"x": x, "y": y, **kw})
return node_id
def edge(self, edge_id: str, src: str, dst: str, width_m: float,
via: list[tuple[float, float]] | None = None,
kind: str = "corridor", bidirectional: bool = True,
capacity_ppm: float | None = None,
length_scale: float = 1.0) -> str:
via = via or []
pts = [self._pos[src], *via, self._pos[dst]]
length = round(path_length(pts) * length_scale, 1)
cap = capacity_ppm if capacity_ppm is not None else round(width_m * FLOW_PER_METRE_WIDTH)
self.edges.append({
"id": edge_id, "source": src, "target": dst,
"length_m": length, "width_m": width_m,
"capacity_ppm": float(cap), "kind": kind,
"bidirectional": bidirectional,
"via": [list(p) for p in via],
})
return edge_id
def landmark(self, lm_id: str, kind: str, points, label: str = "", closed: bool = True):
self.landmarks.append({"id": lm_id, "kind": kind,
"points": [list(p) for p in points],
"label": label, "closed": closed})
def phase(self, pid: str, name: str, start_s: float, end_s: float | None, description: str = ""):
self.phases.append({"id": pid, "name": name, "start_s": start_s,
"end_s": end_s, "description": description})
def build(self, provenance: dict | None = None) -> dict:
doc = dict(self.meta)
doc.update({
"nodes": self.nodes,
"edges": self.edges,
"landmarks": self.landmarks,
"phases": self.phases,
})
if provenance:
doc["provenance"] = provenance
return doc
# ===========================================================================
# Venue 1 — Circuit Alpha (fictional F1 venue, controlled stress test)
# ===========================================================================
def build_circuit_alpha() -> dict:
v = VenueBuilder(
id="circuit_alpha",
name="Circuit Alpha",
subtitle="Fictional Grand Prix venue · controlled stress test",
kind="fictional",
description=(
"A fictional but realistically proportioned Grand Prix venue used to "
"prove the FlowTwin engine end to end. Four perimeter exits, six "
"spectator zones, a full concourse ring, three concession clusters "
"and two transport interfaces."
),
warning_density=1.8,
critical_density=2.8,
)
# --- decorative circuit geometry ------------------------------------
track_outer = [
(300, 320), (770, 318), (868, 352), (908, 428), (886, 508),
(804, 552), (648, 566), (568, 606), (528, 664), (446, 686),
(362, 654), (302, 584), (262, 486), (246, 396), (300, 320),
]
track_inner = [
(330, 358), (752, 356), (830, 382), (862, 430), (846, 484),
(782, 516), (632, 530), (546, 574), (508, 630), (452, 646),
(390, 620), (342, 566), (306, 482), (292, 404), (330, 358),
]
v.landmark("track_outer", "track", track_outer, "Circuit Alpha")
v.landmark("track_inner", "infield", track_inner, "")
v.landmark("pit_lane", "building", [(330, 300), (700, 299), (700, 316), (330, 317)], "PIT LANE")
v.landmark("start_line", "label", [(500, 300), (500, 320)], "S/F", closed=False)
# --- spectator zones -------------------------------------------------
v.node("GS_MAIN", "Main Grandstand", "grandstand", 512, 222,
area_m2=9200, holding_capacity=13000, short_label="MAIN")
v.node("GS_NORTH", "North Grandstand", "grandstand", 262, 250,
area_m2=5200, holding_capacity=6500, short_label="NORTH")
v.node("GS_TURN1", "Turn 1 Grandstand", "grandstand", 862, 262,
area_m2=5600, holding_capacity=7000, short_label="TURN 1")
v.node("GS_EAST", "East Grandstand", "grandstand", 968, 468,
area_m2=6100, holding_capacity=7500, short_label="EAST")
v.node("GS_SOUTH", "South Grandstand", "grandstand", 612, 686,
area_m2=5800, holding_capacity=7000, short_label="SOUTH")
v.node("GA_WEST", "West General Admission", "general_admission", 160, 470,
area_m2=7400, holding_capacity=8000, short_label="GA WEST")
# --- concourse ring --------------------------------------------------
v.node("CON_NORTH", "North Concourse", "concourse", 380, 150, area_m2=3400, short_label="N CONCOURSE")
v.node("PLAZA_MAIN", "Main Plaza", "concourse", 616, 128, area_m2=5200, short_label="MAIN PLAZA")
v.node("CON_NE", "North-East Concourse", "concourse", 866, 156, area_m2=2900, short_label="NE CONCOURSE")
v.node("CON_EAST", "East Concourse", "concourse", 1074, 386, area_m2=3100, short_label="E CONCOURSE")
v.node("CON_SE", "South-East Concourse", "concourse", 856, 748, area_m2=2800, short_label="SE CONCOURSE")
v.node("CON_SOUTH", "South Concourse", "concourse", 470, 800, area_m2=3000, short_label="S CONCOURSE")
v.node("CON_WEST", "West Concourse", "concourse", 120, 640, area_m2=2700, short_label="W CONCOURSE")
v.node("CON_NW", "North-West Concourse", "concourse", 106, 268, area_m2=2600, short_label="NW CONCOURSE")
# --- concessions -----------------------------------------------------
v.node("CONC_NORTH", "North Fan Zone", "concession", 742, 82, area_m2=1900, short_label="FAN ZONE N")
v.node("CONC_EAST", "East Concessions", "concession", 1136, 244, area_m2=1500, short_label="CONC E")
v.node("CONC_SOUTH", "South Concessions", "concession", 646, 856, area_m2=1600, short_label="CONC S")
# --- entry gates -----------------------------------------------------
v.node("GATE_A", "Gate A", "gate", 236, 64, service_rate_ppm=1400, short_label="GATE A")
v.node("GATE_B", "Gate B", "gate", 1150, 118, service_rate_ppm=1200, short_label="GATE B")
v.node("GATE_C", "Gate C", "gate", 1054, 830, service_rate_ppm=1100, short_label="GATE C")
v.node("GATE_D", "Gate D", "gate", 122, 838, service_rate_ppm=1000, short_label="GATE D")
# --- perimeter exits (throughput constraints, not destinations) ------
v.node("EXIT_A", "Exit A · North", "exit", 352, 60, area_m2=900,
service_rate_ppm=1800, short_label="EXIT A")
v.node("EXIT_B", "Exit B · East", "exit", 1188, 396, area_m2=760,
service_rate_ppm=760, short_label="EXIT B",
note="Primary route to the coach and shuttle interchange.")
v.node("EXIT_C", "Exit C · South", "exit", 900, 856, area_m2=820,
service_rate_ppm=1400, short_label="EXIT C")
v.node("EXIT_D", "Exit D · West", "exit", 58, 726, area_m2=700,
service_rate_ppm=700, short_label="EXIT D")
# --- destinations ----------------------------------------------------
v.node("TRANSPORT_RAIL", "Rail Interchange", "transport", 470, 20,
service_rate_ppm=1500, short_label="RAIL", area_m2=4200)
v.node("TRANSPORT_BUS", "Coach & Shuttle Interchange", "transport", 1320, 470,
service_rate_ppm=1700, short_label="COACH", area_m2=3800)
v.node("PARK_NORTH", "North Car Park", "parking", 118, 44,
service_rate_ppm=1100, short_label="P NORTH", area_m2=5000)
v.node("PARK_SOUTH", "South Car Park", "parking", 700, 900,
service_rate_ppm=1100, short_label="P SOUTH", area_m2=5200)
# --- concourse ring corridors (the eight main pedestrian corridors) --
v.edge("C1_NW_N", "CON_NW", "CON_NORTH", 11.0, via=[(190, 128)], kind="concourse")
v.edge("C2_N_PLAZA", "CON_NORTH", "PLAZA_MAIN", 13.0, kind="concourse")
v.edge("C3_PLAZA_NE", "PLAZA_MAIN", "CON_NE", 11.0, kind="concourse")
v.edge("C4_NE_E", "CON_NE", "CON_EAST", 12.0, via=[(1050, 216)], kind="concourse")
v.edge("C5_E_SE", "CON_EAST", "CON_SE", 11.0, via=[(1044, 636)], kind="concourse")
v.edge("C6_SE_S", "CON_SE", "CON_SOUTH", 10.0, via=[(672, 812)], kind="concourse")
v.edge("C7_S_W", "CON_SOUTH", "CON_WEST", 9.0, via=[(268, 780)], kind="concourse")
v.edge("C8_W_NW", "CON_WEST", "CON_NW", 9.0, via=[(70, 448)], kind="concourse")
# --- grandstand access ramps ----------------------------------------
v.edge("A_MAIN_PLAZA", "GS_MAIN", "PLAZA_MAIN", 16.0, kind="ramp")
v.edge("A_MAIN_NORTH", "GS_MAIN", "CON_NORTH", 12.0, kind="ramp")
v.edge("A_NORTH_CON", "GS_NORTH", "CON_NORTH", 11.0, kind="ramp")
v.edge("A_NORTH_NW", "GS_NORTH", "CON_NW", 10.0, kind="ramp")
v.edge("A_TURN1_NE", "GS_TURN1", "CON_NE", 11.0, kind="ramp")
v.edge("A_TURN1_PLAZA", "GS_TURN1", "PLAZA_MAIN", 9.0, via=[(760, 186)], kind="ramp")
v.edge("A_EAST_CON", "GS_EAST", "CON_EAST", 12.0, kind="ramp")
v.edge("A_EAST_NE", "GS_EAST", "CON_NE", 8.0, via=[(978, 300)], kind="ramp")
v.edge("A_EAST_SE", "GS_EAST", "CON_SE", 7.0, via=[(944, 620)], kind="ramp")
v.edge("A_SOUTH_SE", "GS_SOUTH", "CON_SE", 11.0, kind="ramp")
v.edge("A_SOUTH_S", "GS_SOUTH", "CON_SOUTH", 9.0, kind="ramp")
v.edge("A_GAWEST_W", "GA_WEST", "CON_WEST", 12.0, kind="ramp")
v.edge("A_GAWEST_NW", "GA_WEST", "CON_NW", 10.0, kind="ramp")
# --- concession spurs -------------------------------------------------
v.edge("S_CONC_N", "PLAZA_MAIN", "CONC_NORTH", 6.0, kind="access")
v.edge("S_CONC_N2", "CONC_NORTH", "CON_NE", 6.0, kind="access")
v.edge("S_CONC_E", "CON_EAST", "CONC_EAST", 5.5, kind="access")
v.edge("S_CONC_E2", "CONC_EAST", "CON_NE", 5.5, kind="access")
v.edge("S_CONC_S", "CON_SOUTH", "CONC_SOUTH", 5.5, kind="access")
v.edge("S_CONC_S2", "CONC_SOUTH", "CON_SE", 5.5, kind="access")
# --- exit approaches (where queues form) -----------------------------
v.edge("X_N_EXITA", "CON_NORTH", "EXIT_A", 26.0, kind="gate_link")
v.edge("X_E_EXITB", "CON_EAST", "EXIT_B", 11.0, kind="gate_link")
v.edge("X_SE_EXITC", "CON_SE", "EXIT_C", 21.0, kind="gate_link")
v.edge("X_W_EXITD", "CON_WEST", "EXIT_D", 11.0, kind="gate_link")
# --- entry gate links (used by arrival scenarios) --------------------
v.edge("G_GATEA", "GATE_A", "CON_NORTH", 9.0, kind="gate_link")
v.edge("G_GATEB", "GATE_B", "CON_NE", 8.0, kind="gate_link")
v.edge("G_GATEC", "GATE_C", "CON_SE", 8.0, kind="gate_link")
v.edge("G_GATED", "GATE_D", "CON_WEST", 8.0, kind="gate_link")
# --- transport links --------------------------------------------------
v.edge("T_EXITA_RAIL", "EXIT_A", "TRANSPORT_RAIL", 21.0, kind="transport_link")
v.edge("T_EXITA_PARKN", "EXIT_A", "PARK_NORTH", 12.0, kind="transport_link")
v.edge("T_EXITB_BUS", "EXIT_B", "TRANSPORT_BUS", 16.0, kind="transport_link")
v.edge("T_EXITC_BUS", "EXIT_C", "TRANSPORT_BUS", 12.0,
via=[(1130, 780), (1290, 620)], kind="transport_link")
v.edge("T_EXITC_PARKS", "EXIT_C", "PARK_SOUTH", 12.0, kind="transport_link")
v.edge("T_EXITD_PARKN", "EXIT_D", "PARK_NORTH", 9.0,
via=[(30, 380), (54, 120)], kind="transport_link")
v.edge("T_EXITD_PARKS", "EXIT_D", "PARK_SOUTH", 9.0,
via=[(180, 890), (430, 916)], kind="transport_link")
v.phase("pre_race", "Pre-race", 0, 0, "Spectators seated, network idle.")
v.phase("egress", "Post-race egress", 0, 1500, "Chequered flag: mass departure begins.")
v.phase("dispersal", "Dispersal", 1500, None, "Tail of the crowd clearing the network.")
return v.build()
# ===========================================================================
# Venue 2 — Barcelona 2022 (documented-condition reconstruction)
# ===========================================================================
def build_barcelona_2022() -> dict:
v = VenueBuilder(
id="barcelona_2022",
name="Circuit de Barcelona-Catalunya",
subtitle="2022 Spanish Grand Prix · documented-condition reconstruction",
kind="reconstruction",
description=(
"A simplified spectator and transport network for the 2022 Spanish "
"Grand Prix. Topology, capacity and demand are modelled; the "
"geometry is schematic. This is a counterfactual reconstruction "
"using publicly documented conditions, not a replay of original "
"venue telemetry."
),
warning_density=1.8,
critical_density=2.8,
)
# Schematic circuit outline. Deliberately not a survey-accurate trace:
# the model needs topology, capacity and demand, not architectural fidelity.
track = [
(352, 236), (742, 232), (836, 268), (872, 342), (846, 410),
(762, 442), (690, 470), (700, 528), (654, 576), (566, 590),
(496, 560), (452, 596), (386, 604), (330, 556), (306, 470),
(296, 372), (312, 288), (352, 236),
]
track_inner = [
(378, 272), (726, 268), (802, 296), (828, 344), (808, 388),
(730, 416), (656, 452), (664, 518), (630, 552), (570, 560),
(512, 530), (466, 566), (408, 572), (364, 532), (342, 462),
(334, 376), (348, 306), (378, 272),
]
v.landmark("track_outer", "track", track, "Circuit de Barcelona-Catalunya")
v.landmark("track_inner", "infield", track_inner, "")
v.landmark("pit_lane", "building", [(392, 216), (700, 214), (700, 232), (392, 234)], "PIT LANE")
v.landmark("start_line", "label", [(520, 216), (520, 236)], "S/F", closed=False)
# --- spectator zones (schematic positions of the main stands) --------
v.node("MAIN_GRANDSTAND", "Main Grandstand", "grandstand", 546, 148,
area_m2=11000, holding_capacity=22000, short_label="MAIN")
v.node("TRIBUNA_F", "Tribuna F", "grandstand", 846, 176,
area_m2=6200, holding_capacity=11000, short_label="TRIBUNA F")
v.node("TRIBUNA_G", "Tribuna G", "grandstand", 934, 402,
area_m2=6600, holding_capacity=12000, short_label="TRIBUNA G")
v.node("TRIBUNA_H", "Tribuna H", "grandstand", 640, 664,
area_m2=6800, holding_capacity=12000, short_label="TRIBUNA H")
v.node("GA_STADIUM", "Stadium Section GA", "general_admission", 420, 690,
area_m2=9000, holding_capacity=16000, short_label="GA STADIUM")
v.node("GA_NORTH", "North General Admission", "general_admission", 258, 214,
area_m2=8600, holding_capacity=15000, short_label="GA NORTH")
# --- internal circulation --------------------------------------------
v.node("CONC_MAIN", "Main Concourse", "concourse", 546, 78, area_m2=6400, short_label="MAIN CONCOURSE")
v.node("CONC_NORTH", "North Concourse", "concourse", 254, 92, area_m2=4200, short_label="N CONCOURSE")
v.node("CONC_EAST", "East Concourse", "concourse", 1032, 268, area_m2=4000, short_label="E CONCOURSE")
v.node("CONC_SOUTHEAST", "South-East Concourse", "concourse", 986, 604, area_m2=3600, short_label="SE CONCOURSE")
v.node("CONC_SOUTH", "South Concourse", "concourse", 500, 800, area_m2=4400, short_label="S CONCOURSE")
v.node("CONC_WEST", "West Concourse", "concourse", 152, 470, area_m2=3800, short_label="W CONCOURSE")
v.node("FANZONE", "Fan Zone & Concessions", "concession", 760, 74, area_m2=3000, short_label="FAN ZONE")
# --- perimeter exits ---------------------------------------------------
v.node("EXIT_NORTH", "North Exit", "exit", 400, 34, area_m2=1200,
service_rate_ppm=1900, short_label="EXIT N",
note="Principal pedestrian route towards Montmeló and the rail station.")
v.node("EXIT_EAST", "East Exit", "exit", 1128, 372, area_m2=1000,
service_rate_ppm=1650, short_label="EXIT E",
note="Serves the eastern car parks and coach apron.")
v.node("EXIT_SOUTH", "South Exit", "exit", 700, 872, area_m2=1100,
service_rate_ppm=1350, short_label="EXIT S")
v.node("EXIT_WEST", "West Exit", "exit", 60, 560, area_m2=900,
service_rate_ppm=800, short_label="EXIT W")
# --- transport / parking interfaces -----------------------------------
v.node("RAIL_MONTMELO", "Montmeló Rail Station Approach", "transport", 300, 22,
service_rate_ppm=620, short_label="RAIL MONTMELÓ", area_m2=5200,
note="Modelled as a low-throughput sink: documented reporting "
"describes heavy demand and long delays on this link.")
v.node("COACH_APRON", "Coach & Shuttle Apron", "transport", 1252, 470,
service_rate_ppm=900, short_label="COACH", area_m2=4600)
v.node("PARK_EAST", "East Car Parks", "parking", 1230, 210,
service_rate_ppm=1500, short_label="P EAST", area_m2=9000)
v.node("PARK_SOUTH", "South Car Parks", "parking", 848, 900,
service_rate_ppm=1300, short_label="P SOUTH", area_m2=8600)
v.node("PARK_WEST", "West Car Parks & C-17 Approach", "parking", 44, 760,
service_rate_ppm=900, short_label="P WEST / C-17", area_m2=7800)
# --- internal ring ------------------------------------------------------
v.edge("R1_N_MAIN", "CONC_NORTH", "CONC_MAIN", 17.0, kind="concourse")
v.edge("R2_MAIN_FAN", "CONC_MAIN", "FANZONE", 17.0, kind="concourse")
v.edge("R3_FAN_E", "FANZONE", "CONC_EAST", 13.0, via=[(968, 132)], kind="concourse")
v.edge("R4_E_SE", "CONC_EAST", "CONC_SOUTHEAST", 12.0, via=[(1052, 452)], kind="concourse")
v.edge("R5_SE_S", "CONC_SOUTHEAST", "CONC_SOUTH", 12.0, via=[(760, 782)], kind="concourse")
v.edge("R6_S_W", "CONC_SOUTH", "CONC_WEST", 9.0, via=[(226, 700)], kind="concourse")
v.edge("R7_W_N", "CONC_WEST", "CONC_NORTH", 13.0, via=[(122, 216)], kind="concourse")
# --- stand access -------------------------------------------------------
v.edge("AB_MAIN", "MAIN_GRANDSTAND", "CONC_MAIN", 18.0, kind="ramp")
v.edge("AB_MAIN_N", "MAIN_GRANDSTAND", "CONC_NORTH", 8.0, via=[(390, 108)], kind="ramp")
v.edge("AB_F_FAN", "TRIBUNA_F", "FANZONE", 12.0, kind="ramp")
v.edge("AB_F_E", "TRIBUNA_F", "CONC_EAST", 11.0, via=[(966, 214)], kind="ramp")
v.edge("AB_G_E", "TRIBUNA_G", "CONC_EAST", 11.0, kind="ramp")
v.edge("AB_G_SE", "TRIBUNA_G", "CONC_SOUTHEAST", 11.0, kind="ramp")
v.edge("AB_H_SE", "TRIBUNA_H", "CONC_SOUTHEAST", 8.5, via=[(830, 686)], kind="ramp")
v.edge("AB_H_S", "TRIBUNA_H", "CONC_SOUTH", 11.0, kind="ramp")
v.edge("AB_GAS_S", "GA_STADIUM", "CONC_SOUTH", 14.0, kind="ramp")
v.edge("AB_GAS_W", "GA_STADIUM", "CONC_WEST", 11.0, via=[(240, 606)], kind="ramp")
v.edge("AB_GAN_N", "GA_NORTH", "CONC_NORTH", 14.0, kind="ramp")
v.edge("AB_GAN_W", "GA_NORTH", "CONC_WEST", 12.0, via=[(150, 320)], kind="ramp")
# --- exit approaches ----------------------------------------------------
v.edge("XB_N", "CONC_NORTH", "EXIT_NORTH", 30.0, kind="gate_link")
v.edge("XB_MAIN_N", "CONC_MAIN", "EXIT_NORTH", 18.0, kind="gate_link")
v.edge("XB_E", "CONC_EAST", "EXIT_EAST", 25.0, kind="gate_link")
v.edge("XB_S", "CONC_SOUTH", "EXIT_SOUTH", 21.0, via=[(600, 846)], kind="gate_link")
v.edge("XB_SE_S", "CONC_SOUTHEAST", "EXIT_SOUTH", 12.0, via=[(880, 760)], kind="gate_link")
v.edge("XB_W", "CONC_WEST", "EXIT_WEST", 14.0, kind="gate_link")
# --- external transport links -------------------------------------------
# The rail approach is deliberately narrow: the documented failure in 2022
# was on the transport interface, not inside the circuit.
v.edge("TB_N_RAIL", "EXIT_NORTH", "RAIL_MONTMELO", 12.0, kind="transport_link")
v.edge("TB_N_PARKW", "EXIT_NORTH", "PARK_WEST", 8.0,
via=[(120, 60), (28, 300)], kind="transport_link")
v.edge("TB_E_PARKE", "EXIT_EAST", "PARK_EAST", 16.0, kind="transport_link")
v.edge("TB_E_COACH", "EXIT_EAST", "COACH_APRON", 10.0, kind="transport_link")
v.edge("TB_S_PARKS", "EXIT_SOUTH", "PARK_SOUTH", 14.0, kind="transport_link")
v.edge("TB_S_COACH", "EXIT_SOUTH", "COACH_APRON", 7.5,
via=[(1060, 800), (1230, 640)], kind="transport_link")
v.edge("TB_W_PARKW", "EXIT_WEST", "PARK_WEST", 9.0, kind="transport_link")
v.edge("TB_W_RAIL", "EXIT_WEST", "RAIL_MONTMELO", 5.5,
via=[(24, 250), (110, 40)], kind="transport_link")
v.phase("race", "Race", 0, 0, "Race in progress; network idle.")
v.phase("egress", "Post-race egress", 0, 1800,
"Chequered flag: simultaneous departure towards rail, coach and car parks.")
v.phase("dispersal", "Transport dispersal", 1800, None,
"Residual demand on the external transport interfaces.")
provenance = {
"summary": (
"Documented-condition counterfactual reconstruction of the 2022 "
"Spanish Grand Prix spectator egress."
),
"disclaimer": (
"This is a counterfactual reconstruction using publicly documented "
"event conditions and a synthetic crowd model. It is not a replay of "
"original spectator telemetry, which is not public. Every quantity "
"below is labelled either as a documented fact or as an explicit "
"modelling assumption."
),
"facts": [
{"claim": "Weekend attendance reported as 277,836",
"detail": "Contemporary reporting of the 2022 Spanish Grand Prix weekend.",
"source": "Wikipedia — 2022 Spanish Grand Prix; Autosport",
"applies_to": ["crowd_size"]},
{"claim": "Race-day attendance reported above 120,000",
"detail": "Used to scale the race-day egress population.",
"source": "Contemporary reporting (Autosport / RaceFans)",
"applies_to": ["crowd_size"]},
{"claim": "Severe road traffic and public-transport congestion was reported",
"detail": "Long delays leaving the circuit and heavy demand around the "
"Montmeló transport infrastructure.",
"source": "PlanetF1; RaceFans (26 May 2022)",
"applies_to": ["RAIL_MONTMELO", "PARK_WEST", "COACH_APRON"]},
{"claim": "Long concession queues and reported water shortages",
"detail": "Part of the documented crowd-management pressure on the venue.",
"source": "RaceFans (26 May 2022)",
"applies_to": ["FANZONE"]},
{"claim": "Formula 1 publicly described the situation as not acceptable",
"detail": "F1 told the promoter the fan experience needed to be fixed.",
"source": "Autosport — 'Spanish GP promises to work with F1 on better fan experience'",
"applies_to": []},
{"claim": "Circuit length 4.675 km, 2022 configuration",
"detail": "Used only as a sanity check on venue scale.",
"source": "Formula1.com — Spanish Grand Prix 2022",
"applies_to": []},
],
"assumptions": [
{"claim": "Spectator distribution across stands and general admission",
"detail": "Allocated in proportion to modelled stand areas. Real ticketing "
"splits are not public.",
"basis": "Model assumption"},
{"claim": "Departure-mode split (rail / coach / car parks)",
"detail": "Rail 22%, coach 16%, east parks 26%, south parks 21%, west parks "
"and C-17 approach 15%.",
"basis": "Model assumption informed by reported transport pressure"},
{"claim": "Pedestrian corridor widths and capacities",
"detail": "Set from Fruin-style flow of ~70 people/min per metre of width. "
"Actual corridor dimensions are not public.",
"basis": "Model assumption"},
{"claim": "Rail approach throughput of 620 people/min",
"detail": "A deliberately constrained value chosen to reproduce the "
"documented character of the failure (transport interface "
"saturating), not a measured figure.",
"basis": "Model assumption"},
{"claim": "Release profile over a 40-minute window after the chequered flag",
"detail": "Peaked departure curve. The true departure curve is unknown.",
"basis": "Model assumption"},
{"claim": "Free walking speed 1.34 m/s with 16% dispersion",
"detail": "Standard pedestrian modelling value (Weidmann).",
"basis": "Literature value, not event-specific"},
{"claim": "Schematic venue geometry",
"detail": "Node positions are schematic. Topology and capacity are what the "
"model depends on; architectural fidelity is not attempted.",
"basis": "Model assumption"},
],
}
return v.build(provenance)
# ===========================================================================
# Scenarios
# ===========================================================================
def scenario_circuit_alpha_stress() -> dict:
return {
"id": "circuit_alpha_post_race",
"venue_id": "circuit_alpha",
"order": 1,
"name": "Simulation 1 · F1 Circuit Stress Test",
"headline": "40,000 spectators, simultaneous egress, one exit degraded",
"description": (
"The controlled proof of the engine. A full post-race crowd leaves "
"six spectator zones at once. Two and a half minutes in, Exit B "
"loses half its throughput — a realistic infrastructure failure — "
"and the East Concourse begins to compress."
),
"briefing": [
"40,000 spectators released over an 18-minute peaked departure curve",
"Four perimeter exits, four departure destinations",
"T+240s: Exit B throughput cut by 50% (scripted infrastructure failure)",
"Baseline routing is static shortest-path — no operator intervention",
],
"crowd_size": 40000,
"default_seed": 42193,
"duration_s": 3600,
"phase_label": "Post-race egress",
"release": {"start_s": 15, "ramp_s": 1080, "shape": "peaked"},
"compliance_min": 0.45,
"compliance_max": 0.97,
"demand": [
{"origin": "GS_MAIN", "share": 0.29, "label": "Main Grandstand",
"destinations": {"TRANSPORT_RAIL": 0.36, "TRANSPORT_BUS": 0.34,
"PARK_NORTH": 0.12, "PARK_SOUTH": 0.18}},
{"origin": "GS_NORTH", "share": 0.14, "label": "North Grandstand",
"release_offset_s": 20,
"destinations": {"TRANSPORT_RAIL": 0.38, "TRANSPORT_BUS": 0.16,
"PARK_NORTH": 0.30, "PARK_SOUTH": 0.16}},
{"origin": "GS_TURN1", "share": 0.15, "label": "Turn 1 Grandstand",
"release_offset_s": 35,
"destinations": {"TRANSPORT_RAIL": 0.18, "TRANSPORT_BUS": 0.58,
"PARK_NORTH": 0.06, "PARK_SOUTH": 0.18}},
{"origin": "GS_EAST", "share": 0.17, "label": "East Grandstand",
"release_offset_s": 10,
"destinations": {"TRANSPORT_RAIL": 0.10, "TRANSPORT_BUS": 0.68,
"PARK_NORTH": 0.04, "PARK_SOUTH": 0.18}},
{"origin": "GS_SOUTH", "share": 0.13, "label": "South Grandstand",
"release_offset_s": 40,
"destinations": {"TRANSPORT_RAIL": 0.22, "TRANSPORT_BUS": 0.26,
"PARK_NORTH": 0.12, "PARK_SOUTH": 0.40}},
{"origin": "GA_WEST", "share": 0.12, "label": "West General Admission",
"release_offset_s": 55,
"destinations": {"TRANSPORT_RAIL": 0.30, "TRANSPORT_BUS": 0.14,
"PARK_NORTH": 0.34, "PARK_SOUTH": 0.22}},
],
"timeline": [
{"t_s": 240, "type": "capacity", "scope": "node", "target": "EXIT_B",
"factor": 0.5, "automatic": True, "severity": "critical",
"label": "Exit B throughput reduced by 50%",
"detail": "Scripted infrastructure failure: half the exit lanes at "
"Exit B are taken out of service."},
{"t_s": 15, "type": "phase", "scope": "global", "target": "egress",
"label": "Chequered flag — egress begins", "severity": "info",
"automatic": True},
],
"what_if": {
"crowd_size": 40000,
"exit_b_capacity_pct": 50,
"release_ramp_s": 1080,
"compliance_scale": 1.0,
},
"fallback_id": "circuit_alpha_post_race",
}
def scenario_circuit_alpha_arrival() -> dict:
return {
"id": "circuit_alpha_arrival",
"venue_id": "circuit_alpha",
"order": 3,
"name": "Circuit Alpha · Pre-race Arrival Surge",
"headline": "26,000 spectators arriving through four gates in 25 minutes",
"description": (
"The mirror image of the egress test: demand enters through the "
"gates and converges on the grandstands. Useful for showing that "
"the same engine handles inbound flow."
),
"briefing": [
"26,000 spectators arriving through Gates A–D",
"Gate B is the busiest and the first to saturate",
"Destinations are the six spectator zones",
],
"crowd_size": 26000,
"default_seed": 7717,
"duration_s": 2400,
"phase_label": "Pre-race arrival",
"release": {"start_s": 0, "ramp_s": 900, "shape": "double"},
"demand": [
{"origin": "GATE_A", "share": 0.28, "label": "Gate A",
"destinations": {"GS_MAIN": 0.34, "GS_NORTH": 0.30, "GA_WEST": 0.20,
"GS_TURN1": 0.16}},
{"origin": "GATE_B", "share": 0.32, "label": "Gate B",
"destinations": {"GS_TURN1": 0.34, "GS_EAST": 0.32, "GS_MAIN": 0.24,
"GS_SOUTH": 0.10}},
{"origin": "GATE_C", "share": 0.22, "label": "Gate C",
"destinations": {"GS_SOUTH": 0.40, "GS_EAST": 0.30, "GS_MAIN": 0.18,
"GA_WEST": 0.12}},
{"origin": "GATE_D", "share": 0.18, "label": "Gate D",
"destinations": {"GA_WEST": 0.42, "GS_NORTH": 0.24, "GS_SOUTH": 0.20,
"GS_MAIN": 0.14}},
],
"timeline": [
{"t_s": 300, "type": "capacity", "scope": "node", "target": "GATE_B",
"factor": 0.6, "automatic": True, "severity": "warning",
"label": "Gate B screening throughput drops to 60%",
"detail": "Additional security screening slows admission at Gate B."},
],
"what_if": {"crowd_size": 26000, "release_ramp_s": 900, "compliance_scale": 1.0},
"fallback_id": "circuit_alpha_arrival",
}
def scenario_barcelona_2022() -> dict:
return {
"id": "barcelona_2022_egress",
"venue_id": "barcelona_2022",
"order": 2,
"name": "Simulation 2 · Barcelona 2022 Counterfactual",
"headline": "Race-day scale egress under the documented 2022 conditions",
"description": (
"A documented-condition reconstruction of the post-race egress at "
"the 2022 Spanish Grand Prix. The historical layer is the reported "
"attendance and the reported transport congestion. Everything else "
"— walking speeds, gate splits, corridor capacities, transport "
"demand by minute — is an explicit modelling assumption."
),
"briefing": [
"FACT · 277,836 reported weekend attendance; 120,000+ on race day",
"FACT · Severe road and public-transport congestion was reported",
"FACT · F1 publicly called the situation not acceptable",
"ASSUMPTION · Mode split, corridor capacity and departure curve are modelled",
"This is a counterfactual, not a replay of original telemetry",
],
"crowd_size": 78000,
"default_seed": 20220522,
"duration_s": 6000,
"phase_label": "Post-race egress",
"release": {"start_s": 20, "ramp_s": 2400, "shape": "peaked"},
"compliance_min": 0.40,
"compliance_max": 0.95,
"demand": [
{"origin": "MAIN_GRANDSTAND", "share": 0.22, "label": "Main Grandstand",
"destinations": {"RAIL_MONTMELO": 0.26, "COACH_APRON": 0.16,
"PARK_EAST": 0.22, "PARK_SOUTH": 0.18, "PARK_WEST": 0.18}},
{"origin": "TRIBUNA_F", "share": 0.13, "label": "Tribuna F",
"release_offset_s": 25,
"destinations": {"RAIL_MONTMELO": 0.18, "COACH_APRON": 0.20,
"PARK_EAST": 0.34, "PARK_SOUTH": 0.18, "PARK_WEST": 0.10}},
{"origin": "TRIBUNA_G", "share": 0.14, "label": "Tribuna G",
"release_offset_s": 30,
"destinations": {"RAIL_MONTMELO": 0.14, "COACH_APRON": 0.22,
"PARK_EAST": 0.34, "PARK_SOUTH": 0.22, "PARK_WEST": 0.08}},
{"origin": "TRIBUNA_H", "share": 0.14, "label": "Tribuna H",
"release_offset_s": 35,
"destinations": {"RAIL_MONTMELO": 0.16, "COACH_APRON": 0.16,
"PARK_EAST": 0.20, "PARK_SOUTH": 0.34, "PARK_WEST": 0.14}},
{"origin": "GA_STADIUM", "share": 0.19, "label": "Stadium Section GA",
"release_offset_s": 15,
"destinations": {"RAIL_MONTMELO": 0.24, "COACH_APRON": 0.12,
"PARK_EAST": 0.18, "PARK_SOUTH": 0.24, "PARK_WEST": 0.22}},
{"origin": "GA_NORTH", "share": 0.18, "label": "North General Admission",
"release_offset_s": 10,
"destinations": {"RAIL_MONTMELO": 0.32, "COACH_APRON": 0.10,
"PARK_EAST": 0.18, "PARK_SOUTH": 0.14, "PARK_WEST": 0.26}},
],
"timeline": [
{"t_s": 20, "type": "phase", "scope": "global", "target": "egress",
"label": "Chequered flag — egress begins", "severity": "info",
"automatic": True},
{"t_s": 600, "type": "capacity", "scope": "node", "target": "RAIL_MONTMELO",
"factor": 0.72, "automatic": True, "severity": "critical",
"label": "Rail interchange throughput degrades",
"detail": "ASSUMPTION: models the reported saturation of the Montmeló "
"rail link once departing demand exceeded service capacity."},
],
"what_if": {
"crowd_size": 78000,
"rail_capacity_pct": 100,
"release_ramp_s": 2400,
"compliance_scale": 1.0,
},
"fallback_id": "barcelona_2022_egress",
}
def main() -> None:
VENUE_DIR.mkdir(parents=True, exist_ok=True)
SCENARIO_DIR.mkdir(parents=True, exist_ok=True)
venues = [build_circuit_alpha(), build_barcelona_2022()]
scenarios = [scenario_circuit_alpha_stress(), scenario_circuit_alpha_arrival(),
scenario_barcelona_2022()]
from flowtwin.venue.models import Venue # noqa: E402
from flowtwin.venue.scenario import Scenario # noqa: E402
for doc in venues:
Venue.model_validate(doc) # fail loudly on bad geometry
path = VENUE_DIR / f"{doc['id']}.json"
path.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"venue {doc['id']:<20} nodes={len(doc['nodes']):<3} edges={len(doc['edges'])}")
for doc in scenarios:
Scenario.model_validate(doc)
path = SCENARIO_DIR / f"{doc['id']}.json"
path.write_text(json.dumps(doc, indent=2, ensure_ascii=False), encoding="utf-8")
print(f"scenario {doc['id']:<26} crowd={doc['crowd_size']}")
if __name__ == "__main__":
main()