Spaces:
Runtime error
Runtime error
| #!/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() | |