#!/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()