From 3623b4c9997c8dd0a764d2e3ec937c124afaff2c Mon Sep 17 00:00:00 2001 From: legend5teve <2659618982@qq.com> Date: Thu, 5 Mar 2026 16:05:42 -0700 Subject: [PATCH 1/8] refactor: Change scenario prompts in agents/scenarios.py --- agentevac/agents/scenarios.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/agentevac/agents/scenarios.py b/agentevac/agents/scenarios.py index eb8772d..41fe2a2 100644 --- a/agentevac/agents/scenarios.py +++ b/agentevac/agents/scenarios.py @@ -228,14 +228,24 @@ def scenario_prompt_suffix(mode: str) -> str: if cfg["mode"] == "no_notice": return ( "This is a no-notice wildfire scenario: do not assume official route instructions exist. " - "Rely mainly on subjective_information, inbox messages, and your own caution." + "Rely mainly on subjective_information, inbox messages, and your own caution. " + "Do NOT invent official instructions. Base decisions on environmental cues (smoke/flames/visibility), " + "your current hazard or forecast inputs if provided, and peer-to-peer messages. Seek credible info when available " + ", and choose conservative actions if uncertain." ) if cfg["mode"] == "alert_guided": return ( "This is an alert-guided scenario: official alerts describe the fire, but they do not prescribe a route. " - "Use forecast and hazard cues, but make your own navigation choice." + # "Use forecast and hazard cues, but make your own navigation choice." + "but do not prescribe a specific route. Do NOT invent route guidance. Use the provided official alert content, " + "hazard and forecast cues (if provided), and local road conditions to choose when, where and how to evacuate." + ) return ( "This is an advice-guided scenario: official alerts include route-oriented guidance. " - "You may use advisories, briefings, and expected utility as formal support." + "You may use advisories, briefings, and expected utility as formal support. " + # "ADVICE-GUIDED scenario: officials issue an evacuation *order* (leave immediately) and include route-oriented guidance (may be high-level and may change)." + "Default to following designated routes/instructions unless they are blocked, unsafe " + "or extremely congested; if deviating, state why and pick the safest feasible alternative. Stay responsive to updates." + ) From df2f05649156028aab1c0494692b1487fd94cac8 Mon Sep 17 00:00:00 2001 From: legend5teve <2659618982@qq.com> Date: Thu, 5 Mar 2026 22:03:25 -0700 Subject: [PATCH 2/8] chore: update the suggested routes and destination for Lytton Changes to be committed: modified: agentevac/simulation/main.py modified: agentevac/simulation/spawn_events.py modified: agentevac/utils/replay.py modified: sumo/Repaired.netecfg modified: sumo/Repaired.sumocfg --- agentevac/simulation/main.py | 326 ++++-------------- agentevac/simulation/spawn_events.py | 480 ++++++++++++++------------- agentevac/utils/replay.py | 82 ++++- sumo/Repaired.netecfg | 2 +- sumo/Repaired.sumocfg | 2 +- 5 files changed, 370 insertions(+), 522 deletions(-) diff --git a/agentevac/simulation/main.py b/agentevac/simulation/main.py index 3c5f7b8..72c9d5b 100644 --- a/agentevac/simulation/main.py +++ b/agentevac/simulation/main.py @@ -68,6 +68,7 @@ from agentevac.agents.departure_model import should_depart_now from agentevac.agents.routing_utility import annotate_menu_with_expected_utility from agentevac.analysis.metrics import RunMetricsCollector +from agentevac.simulation.spawn_events import SPAWN_EVENTS from agentevac.utils.forecast_layer import ( build_fire_forecast, estimate_edge_forecast_risk, @@ -118,13 +119,34 @@ # Preset routes (Situation 1) - only needed if CONTROL_MODE="route" ROUTE_LIBRARY = [ - # {"name": "route_0", "edges": ["edgeA", "edgeB", "edgeC"]}, + {"name": "route_0", "edges": ["-479435809#1", + "-479435809#0", + "-479435812#0", + "-479435806", + "-30689314#10", + "-30689314#9", + "-30689314#8", + "-30689314#7", + "-30689314#6", + "-30689314#5", + "-30689314#4", + "-30689314#1", + "-30689314#0", + "-479505716#1", + "-479505717", + "-479505352", + "-479505354#2", + "-479505354#1", + "-479505354#0", + "-42047741#0", + "E#S1" + ]}, ] # Preset destinations (Situation 2) DESTINATION_LIBRARY = [ {"name": "shelter_0", "edge": "-42006543#0"}, - {"name": "shelter_1", "edge": "-42047741#0"}, + {"name": "shelter_1", "edge": "E#S1"}, {"name": "shelter_2", "edge": "42044784#5"}, ] @@ -1036,266 +1058,6 @@ def cleanup(self, active_vehicle_ids: List[str]): self._poi_by_vehicle.pop(vid, None) self._last_label.pop(vid, None) -C_RED = (255, 0, 0, 255) # Red, Green, Blue, Alpha -C_ORANGE = (255, 125, 0, 255) -C_YELLOW = (255, 255, 0, 255) -C_SPRING = (125, 255, 0, 255) -C_GREEN = (0, 255, 0, 255) -C_CYAN = (0, 255, 255, 255) -C_OCEAN = (0, 125, 255, 255) -C_BLUE = (0, 0, 255, 255) -C_VIOLET = (125, 0, 255, 255) -C_MAGENTA = (255, 0, 255, 255) -# ---- Scenario spawn events (time in seconds) ---- -SPAWN_EVENTS = [ - # vehicle id, spawn edge, dest edge (initial), depart time, lane, pos, speed, (color) - ("veh1_1", "42006672", "-42047741#0", 0.0, "first", "10", "max", C_RED), - ("veh1_2", "42006672", "-42047741#0", 5.0, "first", "10", "max", C_BLUE), - ("veh1_3", "42006672", "-42047741#0", 10.0, "first", "10", "max", C_GREEN), - - ("veh2_1", "42006514#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), - ("veh2_2", "42006514#4", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - - ("veh3_1", "-42006515", "-42047741#0", 0.0, "first", "20", "max", C_RED), - ("veh3_2", "-42006515", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - - ("veh4_1", "42006515", "-42047741#0", 0.0, "first", "20", "max", C_RED), - ("veh4_2", "42006515", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - - ("veh5_1", "42006565", "-42047741#0", 0.0, "first", "20", "max", C_RED), - ("veh5_2", "42006565", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - - # ("veh6_1", "-42006513#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh6_2", "-42006513#0", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh6_3", "-42006513#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh6_4", "-42006513#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh6_5", "-42006513#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh7_1", "42006504#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh7_2", "42006504#1", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh7_3", "42006504#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh8_1", "42006513#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh8_2", "42006513#0", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh8_3", "42006513#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh8_4", "42006513#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh8_5", "42006513#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh9_1", "-42006719#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh9_2", "-42006719#1", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh9_3", "-42006719#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh9_4", "-42006719#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh9_5", "-42006719#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh9_6", "-42006719#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh9_7", "-42006719#1", "-42047741#0", 30.0, "first", "20", "max", C_YELLOW), - # - # ("veh10_1", "42006513#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh10_2", "42006513#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh10_3", "42006513#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh10_4", "42006513#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh10_5", "42006513#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh10_6", "42006513#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # - # ("veh11_1", "-42006513#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh11_2", "-42006513#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh11_3", "-42006513#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh11_4", "-42006513#2", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh11_5", "-42006513#2", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh11_6", "-42006513#2", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh11_7", "-42006513#2", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # ("veh11_8", "-42006513#2", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), - # ("veh11_9", "-42006513#2", "-42047741#0", 40.0, "first", "20", "max", C_MAGENTA), - # - # ("veh12_1", "30689314#5", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh12_2", "30689314#5", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh12_3", "30689314#5", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh12_4", "30689314#5", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh13_1", "-30689314#5", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh13_2", "-30689314#5", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh13_3", "-30689314#5", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh13_4", "-30689314#5", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh13_5", "-30689314#5", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh13_6", "-30689314#5", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # - # ("veh14_1", "42006513#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh14_2", "42006513#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh14_3", "42006513#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh14_4", "42006513#2", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh14_5", "42006513#2", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh14_6", "42006513#2", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh14_7", "42006513#2", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # ("veh14_8", "42006513#2", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), - # ("veh14_9", "42006513#2", "-42047741#0", 40.0, "first", "20", "max", C_MAGENTA), - # - # ("veh15_1", "-30689314#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh15_2", "-30689314#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh15_3", "-30689314#4", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh15_4", "-30689314#4", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh15_5", "-30689314#4", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh15_6", "-30689314#4", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh15_7", "-30689314#4", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # - # ("veh16_1", "-42006513#3", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh16_2", "-42006513#3", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh16_3", "-42006513#3", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh16_4", "-42006513#3", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh16_5", "-42006513#3", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh17_1", "42006513#3", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh17_2", "42006513#3", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh17_3", "42006513#3", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh18_1", "42006734#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh18_2", "42006734#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh18_3", "42006734#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh18_4", "42006734#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh18_5", "42006734#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh18_6", "42006734#0", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh18_7", "42006734#0", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # ("veh18_8", "42006734#0", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), - # - # ("veh19_1", "-42006513#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh19_2", "-42006513#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh19_3", "-42006513#4", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh20_1", "42006513#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh20_2", "42006513#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # - # ("veh21_1", "30689314#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh21_2", "30689314#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh21_3", "30689314#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh21_4", "30689314#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh22_1", "-30689314#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh22_2", "-30689314#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh22_3", "-30689314#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh22_4", "-30689314#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh23_1", "42006734#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh23_2", "42006734#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh23_3", "42006734#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh23_4", "42006734#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh24_1", "42006713#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh24_2", "42006713#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh24_3", "42006713#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh25_1", "42006701#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh25_2", "42006701#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh25_3", "42006701#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh25_4", "42006701#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh25_5", "42006701#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh26_1", "479505716#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh26_2", "479505716#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh26_3", "479505716#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh26_4", "479505716#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh27_1", "-479505716#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh27_2", "-479505716#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh27_3", "-479505716#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh27_4", "-479505716#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh28_1", "42006734#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh28_2", "42006734#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh28_3", "42006734#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh29_1", "42006734#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh29_2", "42006734#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # - # ("veh30_1", "-42006522#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh30_2", "-42006522#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh30_3", "-42006522#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh31_1", "42006522#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh31_2", "42006522#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # - # ("veh32_1", "42006636#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh32_2", "42006636#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh32_3", "42006636#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh32_4", "42006636#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh32_5", "42006636#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh33_1", "-966804140", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh33_2", "-966804140", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh33_3", "-966804140", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh34_1", "42006708", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh34_2", "42006708", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh34_3", "42006708", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh35_1", "479505354#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh35_2", "479505354#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh35_3", "479505354#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh36_1", "-42006660", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh36_2", "-42006660", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh36_3", "-42006660", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh36_4", "-42006660", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh36_5", "-42006660", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh37_1", "42006589", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh37_2", "42006589", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh37_3", "42006589", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh38_1", "42006572", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh38_2", "42006572", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # - # ("veh39_1", "42006733", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh39_2", "42006733", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh39_3", "42006733", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh40_1", "42006506", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh40_2", "42006506", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh40_3", "42006506", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh40_4", "42006506", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh40_5", "42006506", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh41_1", "-42006549#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh41_2", "-42006549#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh41_3", "-42006549#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh41_4", "-42006549#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh41_5", "-42006549#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh41_6", "-42006549#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh41_7", "-42006549#1", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # ("veh41_8", "-42006549#1", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), - # - # ("veh42_1", "-42006552#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh42_2", "-42006552#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh42_3", "-42006552#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh42_4", "-42006552#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh42_5", "-42006552#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh43_1", "42006552#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh43_2", "42006552#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh43_3", "42006552#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh43_4", "42006552#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh43_5", "42006552#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh44_1", "-42006552#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh44_2", "-42006552#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh44_3", "-42006552#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh44_4", "-42006552#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh44_5", "-42006552#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh45_1", "-42006706#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh45_2", "-42006706#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh45_3", "-42006706#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh45_4", "-42006706#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh45_5", "-42006706#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh46_1", "42006706#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh46_2", "42006706#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh46_3", "42006706#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh46_4", "42006706#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh46_5", "42006706#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh46_6", "42006706#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh46_7", "42006706#1", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # - # ("veh47_1", "42006592", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh47_2", "42006592", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), -] spawned = set() @@ -1334,6 +1096,9 @@ def cleanup(self, active_vehicle_ids: List[str]): id_label_max=OVERLAY_ID_LABEL_MAX, ) print(f"[REPLAY] mode={RUN_MODE} path={replay.path}") +if RUN_MODE == "replay": + departure_source = "recorded_departure_events" if replay.has_departure_schedule() else "spawn_events_fallback" + print(f"[REPLAY_DEPARTURES] source={departure_source}") if replay.dialog_path: print(f"[DIALOG] path={replay.dialog_path}") if replay.dialog_csv_path: @@ -1887,15 +1652,20 @@ def process_pending_departures(step_idx: int): on decision ticks (multiples of ``decision_period_steps``); all other steps return immediately after checking whether any vehicle's scheduled depart time has passed. - For each not-yet-spawned vehicle whose depart time has been reached: + In record mode, all spawn events become eligible from simulation time 0 so the + actual release time is governed by the departure model rather than the static + ``t0`` values in ``SPAWN_EVENTS``. + + For each not-yet-spawned vehicle whose release gate has been reached: 1. Samples a noisy/delayed environment signal for the spawn edge. 2. Builds a social signal (empty inbox for pre-departure agents). 3. Updates the Bayesian belief distribution. 4. Evaluates the three-clause departure decision rule. 5. If departing, adds the vehicle to the SUMO simulation via TraCI. - In replay mode, vehicles are added immediately when their depart time is reached - without running the departure decision logic. + In replay mode, vehicles are added when the recorded ``departure_release`` event + for that vehicle is encountered. If the replay log predates departure-event + logging, the function falls back to the static ``SPAWN_EVENTS`` schedule. Args: step_idx: The current SUMO simulation step index. @@ -1922,12 +1692,19 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: for (vid, from_edge, to_edge, t0, dLane, dPos, dSpeed, dColor) in SPAWN_EVENTS: if vid in spawned: continue - if sim_t < t0: - continue if RUN_MODE == "replay": - should_release = True - release_reason = "replay_schedule" + departure_rec = replay.departure_record_for_step(step_idx, vid) + if departure_rec is not None: + should_release = True + release_reason = str(departure_rec.get("reason") or "replay_recorded_departure") + else: + if replay.has_departure_schedule(): + continue + if sim_t < t0: + continue + should_release = True + release_reason = "replay_schedule_fallback" agent_state = ensure_agent_state( vid, sim_t, @@ -1939,6 +1716,9 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: default_lambda_t=DEFAULT_LAMBDA_T, ) else: + effective_t0 = 0.0 + if sim_t < effective_t0: + continue if not evaluate_departures: continue @@ -2106,6 +1886,14 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: traci.vehicle.setColor(vid, dColor) spawned.add(vid) agent_state.has_departed = True + replay.record_departure_release( + step=step_idx, + sim_t_s=sim_t, + veh_id=vid, + from_edge=from_edge, + to_edge=to_edge, + reason=release_reason, + ) metrics.record_departure(vid, sim_t, release_reason) print(f"[DEPART] {vid}: released from {from_edge} via {release_reason}") if EVENTS_ENABLED: diff --git a/agentevac/simulation/spawn_events.py b/agentevac/simulation/spawn_events.py index ec5bd1b..4d2debd 100644 --- a/agentevac/simulation/spawn_events.py +++ b/agentevac/simulation/spawn_events.py @@ -16,17 +16,27 @@ - ``lane`` : SUMO departure lane specifier (e.g., ``"first"``). - ``pos`` : Departure position on the edge in metres. - ``speed`` : Departure speed (``"max"`` uses the lane speed limit). - - ``color`` : RGBA color constant defined in ``agentevac/simulation/main.py`` - (e.g., ``C_RED``, ``C_BLUE``). + - ``color`` : RGBA color tuple used for the initial SUMO vehicle color. -Active groups (veh1–veh5): 12 vehicles across 5 spawn locations; the baseline +Active groups (veh1-veh5): 12 vehicles across 5 spawn locations; the baseline scenario for development and testing. -Commented-out groups (veh6–veh47): Additional spawn locations across the road network. +Commented-out groups (veh6-veh47): Additional spawn locations across the road network. Disabled to keep the active agent count manageable. Re-enable individual groups to test denser evacuation scenarios. """ +C_RED = (255, 0, 0, 255) +C_ORANGE = (255, 125, 0, 255) +C_YELLOW = (255, 255, 0, 255) +C_SPRING = (125, 255, 0, 255) +C_GREEN = (0, 255, 0, 255) +C_CYAN = (0, 255, 255, 255) +C_OCEAN = (0, 125, 255, 255) +C_BLUE = (0, 0, 255, 255) +C_VIOLET = (125, 0, 255, 255) +C_MAGENTA = (255, 0, 255, 255) + SPAWN_EVENTS = [ # vehicle id, spawn edge, dest edge (initial), depart time, lane, pos, speed, (color) ("veh1_1", "42006672", "-42047741#0", 0.0, "first", "10", "max", C_RED), @@ -45,234 +55,234 @@ ("veh5_1", "42006565", "-42047741#0", 0.0, "first", "20", "max", C_RED), ("veh5_2", "42006565", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh6_1", "-42006513#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh6_2", "-42006513#0", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh6_3", "-42006513#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh6_4", "-42006513#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh6_5", "-42006513#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh7_1", "42006504#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh7_2", "42006504#1", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh7_3", "42006504#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh8_1", "42006513#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh8_2", "42006513#0", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh8_3", "42006513#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh8_4", "42006513#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh8_5", "42006513#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh9_1", "-42006719#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh9_2", "-42006719#1", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), - # ("veh9_3", "-42006719#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh9_4", "-42006719#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh9_5", "-42006719#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh9_6", "-42006719#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh9_7", "-42006719#1", "-42047741#0", 30.0, "first", "20", "max", C_YELLOW), - # - # ("veh10_1", "42006513#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh10_2", "42006513#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh10_3", "42006513#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh10_4", "42006513#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh10_5", "42006513#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh10_6", "42006513#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # - # ("veh11_1", "-42006513#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh11_2", "-42006513#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh11_3", "-42006513#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh11_4", "-42006513#2", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh11_5", "-42006513#2", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh11_6", "-42006513#2", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh11_7", "-42006513#2", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # ("veh11_8", "-42006513#2", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), - # ("veh11_9", "-42006513#2", "-42047741#0", 40.0, "first", "20", "max", C_MAGENTA), - # - # ("veh12_1", "30689314#5", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh12_2", "30689314#5", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh12_3", "30689314#5", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh12_4", "30689314#5", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh13_1", "-30689314#5", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh13_2", "-30689314#5", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh13_3", "-30689314#5", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh13_4", "-30689314#5", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh13_5", "-30689314#5", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh13_6", "-30689314#5", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # - # ("veh14_1", "42006513#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh14_2", "42006513#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh14_3", "42006513#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh14_4", "42006513#2", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh14_5", "42006513#2", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh14_6", "42006513#2", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh14_7", "42006513#2", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # ("veh14_8", "42006513#2", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), - # ("veh14_9", "42006513#2", "-42047741#0", 40.0, "first", "20", "max", C_MAGENTA), - # - # ("veh15_1", "-30689314#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh15_2", "-30689314#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh15_3", "-30689314#4", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh15_4", "-30689314#4", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh15_5", "-30689314#4", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh15_6", "-30689314#4", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh15_7", "-30689314#4", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # - # ("veh16_1", "-42006513#3", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh16_2", "-42006513#3", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh16_3", "-42006513#3", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh16_4", "-42006513#3", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh16_5", "-42006513#3", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh17_1", "42006513#3", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh17_2", "42006513#3", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh17_3", "42006513#3", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh18_1", "42006734#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh18_2", "42006734#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh18_3", "42006734#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh18_4", "42006734#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh18_5", "42006734#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh18_6", "42006734#0", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh18_7", "42006734#0", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # ("veh18_8", "42006734#0", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), - # - # ("veh19_1", "-42006513#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh19_2", "-42006513#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh19_3", "-42006513#4", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh20_1", "42006513#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh20_2", "42006513#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # - # ("veh21_1", "30689314#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh21_2", "30689314#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh21_3", "30689314#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh21_4", "30689314#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh22_1", "-30689314#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh22_2", "-30689314#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh22_3", "-30689314#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh22_4", "-30689314#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh23_1", "42006734#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh23_2", "42006734#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh23_3", "42006734#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh23_4", "42006734#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh24_1", "42006713#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh24_2", "42006713#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh24_3", "42006713#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh25_1", "42006701#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh25_2", "42006701#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh25_3", "42006701#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh25_4", "42006701#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh25_5", "42006701#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh26_1", "479505716#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh26_2", "479505716#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh26_3", "479505716#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh26_4", "479505716#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh27_1", "-479505716#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh27_2", "-479505716#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh27_3", "-479505716#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh27_4", "-479505716#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # - # ("veh28_1", "42006734#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh28_2", "42006734#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh28_3", "42006734#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh29_1", "42006734#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh29_2", "42006734#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # - # ("veh30_1", "-42006522#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh30_2", "-42006522#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh30_3", "-42006522#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh31_1", "42006522#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh31_2", "42006522#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # - # ("veh32_1", "42006636#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh32_2", "42006636#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh32_3", "42006636#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh32_4", "42006636#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh32_5", "42006636#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh33_1", "-966804140", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh33_2", "-966804140", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh33_3", "-966804140", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh34_1", "42006708", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh34_2", "42006708", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh34_3", "42006708", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh35_1", "479505354#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh35_2", "479505354#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh35_3", "479505354#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh36_1", "-42006660", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh36_2", "-42006660", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh36_3", "-42006660", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh36_4", "-42006660", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh36_5", "-42006660", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh37_1", "42006589", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh37_2", "42006589", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh37_3", "42006589", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh38_1", "42006572", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh38_2", "42006572", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # - # ("veh39_1", "42006733", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh39_2", "42006733", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh39_3", "42006733", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # - # ("veh40_1", "42006506", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh40_2", "42006506", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh40_3", "42006506", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh40_4", "42006506", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh40_5", "42006506", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh41_1", "-42006549#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh41_2", "-42006549#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh41_3", "-42006549#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh41_4", "-42006549#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh41_5", "-42006549#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh41_6", "-42006549#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh41_7", "-42006549#1", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # ("veh41_8", "-42006549#1", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), - # - # ("veh42_1", "-42006552#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh42_2", "-42006552#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh42_3", "-42006552#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh42_4", "-42006552#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh42_5", "-42006552#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh43_1", "42006552#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh43_2", "42006552#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh43_3", "42006552#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh43_4", "42006552#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh43_5", "42006552#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh44_1", "-42006552#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh44_2", "-42006552#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh44_3", "-42006552#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh44_4", "-42006552#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh44_5", "-42006552#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh45_1", "-42006706#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh45_2", "-42006706#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh45_3", "-42006706#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh45_4", "-42006706#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh45_5", "-42006706#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # - # ("veh46_1", "42006706#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh46_2", "42006706#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), - # ("veh46_3", "42006706#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), - # ("veh46_4", "42006706#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), - # ("veh46_5", "42006706#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), - # ("veh46_6", "42006706#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), - # ("veh46_7", "42006706#1", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), - # - # ("veh47_1", "42006592", "-42047741#0", 0.0, "first", "20", "max", C_RED), - # ("veh47_2", "42006592", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), -] \ No newline at end of file + ("veh6_1", "-42006513#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh6_2", "-42006513#0", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), + ("veh6_3", "-42006513#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh6_4", "-42006513#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh6_5", "-42006513#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh7_1", "42006504#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh7_2", "42006504#1", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), + ("veh7_3", "42006504#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh8_1", "42006513#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh8_2", "42006513#0", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), + ("veh8_3", "42006513#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh8_4", "42006513#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh8_5", "42006513#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh9_1", "-42006719#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh9_2", "-42006719#1", "-42047741#0", 5.0, "first", "20", "max", C_BLUE), + ("veh9_3", "-42006719#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh9_4", "-42006719#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh9_5", "-42006719#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh9_6", "-42006719#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + ("veh9_7", "-42006719#1", "-42047741#0", 30.0, "first", "20", "max", C_YELLOW), + + ("veh10_1", "42006513#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh10_2", "42006513#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh10_3", "42006513#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh10_4", "42006513#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh10_5", "42006513#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh10_6", "42006513#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + + ("veh11_1", "-42006513#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh11_2", "-42006513#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh11_3", "-42006513#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh11_4", "-42006513#2", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh11_5", "-42006513#2", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh11_6", "-42006513#2", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + ("veh11_7", "-42006513#2", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), + ("veh11_8", "-42006513#2", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), + ("veh11_9", "-42006513#2", "-42047741#0", 40.0, "first", "20", "max", C_MAGENTA), + + ("veh12_1", "30689314#5", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh12_2", "30689314#5", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh12_3", "30689314#5", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh12_4", "30689314#5", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + + ("veh13_1", "-30689314#5", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh13_2", "-30689314#5", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh13_3", "-30689314#5", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh13_4", "-30689314#5", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh13_5", "-30689314#5", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh13_6", "-30689314#5", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + + ("veh14_1", "42006513#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh14_2", "42006513#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh14_3", "42006513#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh14_4", "42006513#2", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh14_5", "42006513#2", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh14_6", "42006513#2", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + ("veh14_7", "42006513#2", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), + ("veh14_8", "42006513#2", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), + ("veh14_9", "42006513#2", "-42047741#0", 40.0, "first", "20", "max", C_MAGENTA), + + ("veh15_1", "-30689314#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh15_2", "-30689314#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh15_3", "-30689314#4", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh15_4", "-30689314#4", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh15_5", "-30689314#4", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh15_6", "-30689314#4", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + ("veh15_7", "-30689314#4", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), + + ("veh16_1", "-42006513#3", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh16_2", "-42006513#3", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh16_3", "-42006513#3", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh16_4", "-42006513#3", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh16_5", "-42006513#3", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh17_1", "42006513#3", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh17_2", "42006513#3", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh17_3", "42006513#3", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh18_1", "42006734#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh18_2", "42006734#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh18_3", "42006734#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh18_4", "42006734#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh18_5", "42006734#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh18_6", "42006734#0", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + ("veh18_7", "42006734#0", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), + ("veh18_8", "42006734#0", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), + + ("veh19_1", "-42006513#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh19_2", "-42006513#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh19_3", "-42006513#4", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh20_1", "42006513#4", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh20_2", "42006513#4", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + + ("veh21_1", "30689314#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh21_2", "30689314#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh21_3", "30689314#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh21_4", "30689314#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + + ("veh22_1", "-30689314#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh22_2", "-30689314#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh22_3", "-30689314#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh22_4", "-30689314#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + + ("veh23_1", "42006734#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh23_2", "42006734#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh23_3", "42006734#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh23_4", "42006734#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + + ("veh24_1", "42006713#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh24_2", "42006713#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh24_3", "42006713#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh25_1", "42006701#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh25_2", "42006701#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh25_3", "42006701#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh25_4", "42006701#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh25_5", "42006701#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh26_1", "479505716#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh26_2", "479505716#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh26_3", "479505716#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh26_4", "479505716#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + + ("veh27_1", "-479505716#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh27_2", "-479505716#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh27_3", "-479505716#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh27_4", "-479505716#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + + ("veh28_1", "42006734#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh28_2", "42006734#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh28_3", "42006734#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh29_1", "42006734#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh29_2", "42006734#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + + ("veh30_1", "-42006522#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh30_2", "-42006522#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh30_3", "-42006522#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh31_1", "42006522#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh31_2", "42006522#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + + ("veh32_1", "42006636#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh32_2", "42006636#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh32_3", "42006636#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh32_4", "42006636#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh32_5", "42006636#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh33_1", "-966804140", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh33_2", "-966804140", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh33_3", "-966804140", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh34_1", "42006708", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh34_2", "42006708", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh34_3", "42006708", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh35_1", "479505354#2", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh35_2", "479505354#2", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh35_3", "479505354#2", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh36_1", "-42006660", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh36_2", "-42006660", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh36_3", "-42006660", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh36_4", "-42006660", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh36_5", "-42006660", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh37_1", "42006589", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh37_2", "42006589", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh37_3", "42006589", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh38_1", "42006572", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh38_2", "42006572", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + + ("veh39_1", "42006733", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh39_2", "42006733", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh39_3", "42006733", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + + ("veh40_1", "42006506", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh40_2", "42006506", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh40_3", "42006506", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh40_4", "42006506", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh40_5", "42006506", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh41_1", "-42006549#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh41_2", "-42006549#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh41_3", "-42006549#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh41_4", "-42006549#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh41_5", "-42006549#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh41_6", "-42006549#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + ("veh41_7", "-42006549#1", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), + ("veh41_8", "-42006549#1", "-42047741#0", 35.0, "first", "20", "max", C_VIOLET), + + ("veh42_1", "-42006552#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh42_2", "-42006552#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh42_3", "-42006552#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh42_4", "-42006552#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh42_5", "-42006552#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh43_1", "42006552#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh43_2", "42006552#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh43_3", "42006552#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh43_4", "42006552#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh43_5", "42006552#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh44_1", "-42006552#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh44_2", "-42006552#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh44_3", "-42006552#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh44_4", "-42006552#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh44_5", "-42006552#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh45_1", "-42006706#0", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh45_2", "-42006706#0", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh45_3", "-42006706#0", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh45_4", "-42006706#0", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh45_5", "-42006706#0", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + + ("veh46_1", "42006706#1", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh46_2", "42006706#1", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), + ("veh46_3", "42006706#1", "-42047741#0", 10.0, "first", "20", "max", C_GREEN), + ("veh46_4", "42006706#1", "-42047741#0", 15.0, "first", "20", "max", C_ORANGE), + ("veh46_5", "42006706#1", "-42047741#0", 20.0, "first", "20", "max", C_SPRING), + ("veh46_6", "42006706#1", "-42047741#0", 25.0, "first", "20", "max", C_CYAN), + ("veh46_7", "42006706#1", "-42047741#0", 30.0, "first", "20", "max", C_OCEAN), + + ("veh47_1", "42006592", "-42047741#0", 0.0, "first", "20", "max", C_RED), + ("veh47_2", "42006592", "-42047741#0", 5.0, "first", "20", "max", C_YELLOW), +] diff --git a/agentevac/utils/replay.py b/agentevac/utils/replay.py index 73cdf8d..1b4edaf 100644 --- a/agentevac/utils/replay.py +++ b/agentevac/utils/replay.py @@ -1,11 +1,13 @@ -"""Record and replay LLM-driven route decisions for deterministic re-runs. +"""Record and replay LLM-driven actions for deterministic re-runs. This module provides ``RouteReplay``, a class that operates in one of two modes: -**record** — During a live simulation run, every LLM-applied route change is logged +**record** — During a live simulation run, every replay-relevant action is logged to a JSONL file (one JSON record per line) along with agent cognition snapshots - and LLM dialog transcripts. Only ``route_change`` events are used for replay; - cognition and dialog events are write-only metadata for research/debugging. + and LLM dialog transcripts. Replay currently consumes: + - ``departure_release`` events for vehicle release timing + - ``route_change`` events for route application + Cognition and dialog events are write-only metadata for research/debugging. Three output files are created: - ``routes_.jsonl`` — Replayable route-change schedule. @@ -13,6 +15,7 @@ - ``routes_.dialogs.csv`` — Machine-readable LLM dialog table. **replay** — Loads a previously recorded JSONL file and, on each simulation step, + releases vehicles according to the recorded ``departure_release`` schedule and applies the scheduled ``route_change`` events to the matching vehicle via ``traci.vehicle.setRoute()``. This allows exact behavioural reproduction without making any OpenAI API calls. @@ -46,7 +49,8 @@ def __init__(self, mode: str, path: str): self._dialog_fh = None self._dialog_csv_fh = None self._dialog_csv_writer = None - self._schedule = {} # step_idx -> veh_id -> record + self._schedule = {} # step_idx -> veh_id -> route_change record + self._departure_schedule = {} # step_idx -> veh_id -> departure_release record if self.mode == "record": self.path = self._build_record_path(path) @@ -75,7 +79,7 @@ def __init__(self, mode: str, path: str): self._dialog_csv_writer.writeheader() self._dialog_csv_fh.flush() elif self.mode == "replay": - self._schedule = self._load_schedule(self.path) + self._schedule, self._departure_schedule = self._load_schedule(self.path) else: raise ValueError(f"Unknown RUN_MODE={mode}. Use 'record' or 'replay'.") @@ -108,32 +112,35 @@ def close(self): @staticmethod def _load_schedule(path: str): - """Load and index ``route_change`` events from a JSONL file by step index. + """Load replayable events from a JSONL file by step index. - Non-``route_change`` events (cognition, metrics snapshots, dialogs) are - silently ignored so replay only reproduces the route-assignment actions. + Replay currently consumes ``route_change`` and ``departure_release`` events. + All other events (cognition, metrics snapshots, dialogs) are silently ignored. Args: path: Path to the recorded JSONL file. Returns: - Dict mapping ``step_idx`` → {``veh_id`` → record dict}. + Tuple of dicts: + - ``route_schedule``: ``step_idx`` → {``veh_id`` → route-change record} + - ``departure_schedule``: ``step_idx`` → {``veh_id`` → departure record} """ - schedule = {} + route_schedule = {} + departure_schedule = {} with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() if not line: continue rec = json.loads(line) - # Only route-change events are replayable actions. event = rec.get("event", "route_change") - if event != "route_change": - continue step = int(rec["step"]) vid = rec["veh_id"] - schedule.setdefault(step, {})[vid] = rec - return schedule + if event == "route_change": + route_schedule.setdefault(step, {})[vid] = rec + elif event == "departure_release": + departure_schedule.setdefault(step, {})[vid] = rec + return route_schedule, departure_schedule @staticmethod def _build_record_path(base_path: str) -> str: @@ -239,6 +246,39 @@ def record_route_change( } self._write_jsonl(rec) + def record_departure_release( + self, + step: int, + sim_t_s: float, + veh_id: str, + from_edge: str, + to_edge: str, + reason: Optional[str] = None, + ): + """Log one vehicle release event to the JSONL file. + + This is replayable metadata used to reproduce the actual departure timing of + each vehicle in replay mode. + + Args: + step: SUMO simulation step index. + sim_t_s: Simulation time in seconds. + veh_id: Vehicle ID. + from_edge: Spawn edge used to initialize the route. + to_edge: Initial destination edge used to initialize the route. + reason: Optional departure reason. + """ + rec = { + "event": "departure_release", + "step": int(step), + "time_s": float(sim_t_s), + "veh_id": str(veh_id), + "from_edge": str(from_edge), + "to_edge": str(to_edge), + "reason": reason, + } + self._write_jsonl(rec) + def record_agent_cognition( self, step: int, @@ -271,6 +311,16 @@ def record_agent_cognition( } self._write_jsonl(rec) + def departure_record_for_step(self, step: int, veh_id: str) -> Optional[Dict[str, Any]]: + """Return the recorded departure-release event for one vehicle at one step.""" + if self.mode != "replay": + return None + return self._departure_schedule.get(int(step), {}).get(str(veh_id)) + + def has_departure_schedule(self) -> bool: + """Whether the loaded replay log contains explicit departure-release events.""" + return bool(self._departure_schedule) + def record_metric_snapshot( self, step: int, diff --git a/sumo/Repaired.netecfg b/sumo/Repaired.netecfg index 0365f81..125ab4b 100644 --- a/sumo/Repaired.netecfg +++ b/sumo/Repaired.netecfg @@ -1,6 +1,6 @@ - diff --git a/sumo/Repaired.sumocfg b/sumo/Repaired.sumocfg index 0360b58..636540a 100644 --- a/sumo/Repaired.sumocfg +++ b/sumo/Repaired.sumocfg @@ -1,6 +1,6 @@ - From 477b0dec20a793f354e6daeaf10d9e9479d20a1c Mon Sep 17 00:00:00 2001 From: legend5teve <2659618982@qq.com> Date: Thu, 5 Mar 2026 22:16:47 -0700 Subject: [PATCH 3/8] fix: update replay.py to fix key error Module updated: agentevac/utils/replay.py - Fixed RouteReplay._load_schedule(...) so it only reads step and veh_id for replayable events: - route_change - departure_release - Non-replayable events like agent_cognition and metrics_snapshot are now ignored without touching veh_id. Cause - The loader was accessing rec["veh_id"] before checking the event type. - metrics_snapshot records do not have veh_id, so replay loading crashed with KeyError. Verification 1. python3 -m py_compile agentevac/utils/replay.py passed. 2. Reproduced the failing case with a small local script: - one route_change - one agent_cognition - one metrics_snapshot - replay load now succeeds and only indexes the route-change step. --- agentevac/utils/replay.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/agentevac/utils/replay.py b/agentevac/utils/replay.py index 1b4edaf..c860604 100644 --- a/agentevac/utils/replay.py +++ b/agentevac/utils/replay.py @@ -134,11 +134,13 @@ def _load_schedule(path: str): continue rec = json.loads(line) event = rec.get("event", "route_change") - step = int(rec["step"]) - vid = rec["veh_id"] if event == "route_change": + step = int(rec["step"]) + vid = rec["veh_id"] route_schedule.setdefault(step, {})[vid] = rec elif event == "departure_release": + step = int(rec["step"]) + vid = rec["veh_id"] departure_schedule.setdefault(step, {})[vid] = rec return route_schedule, departure_schedule From 0f4ac33f9a118059399aee899f5bdac5f2e2ed6c Mon Sep 17 00:00:00 2001 From: legend5teve <2659618982@qq.com> Date: Mon, 9 Mar 2026 15:52:01 -0600 Subject: [PATCH 4/8] feat: optimize the visualization module for plotting statistic results and agent communication --- README.md | 43 +++++ pyproject.toml | 3 + scripts/_plot_common.py | 106 ++++++++++++ scripts/plot_agent_communication.py | 230 ++++++++++++++++++++++++++ scripts/plot_all_run_artifacts.py | 167 +++++++++++++++++++ scripts/plot_departure_timeline.py | 150 +++++++++++++++++ scripts/plot_experiment_comparison.py | 216 ++++++++++++++++++++++++ scripts/plot_run_metrics.py | 125 ++++++++++++++ 8 files changed, 1040 insertions(+) create mode 100644 scripts/_plot_common.py create mode 100644 scripts/plot_agent_communication.py create mode 100644 scripts/plot_all_run_artifacts.py create mode 100644 scripts/plot_departure_timeline.py create mode 100644 scripts/plot_experiment_comparison.py create mode 100644 scripts/plot_run_metrics.py diff --git a/README.md b/README.md index 40ffa64..e5db941 100644 --- a/README.md +++ b/README.md @@ -95,3 +95,46 @@ agentevac-study \ ``` This runs a grid search over information noise, delay, and trust parameters and fits results against a reference metrics file. + +## Plotting Completed Runs + +Install the plotting dependency: + +```bash +pip install -e .[plot] +``` + +Generate figures for the latest run: + +```bash +python3 scripts/plot_all_run_artifacts.py +``` + +Generate figures for a specific run ID: + +```bash +python3 scripts/plot_all_run_artifacts.py --run-id 20260309_030340 +``` + +Useful individual plotting commands: + +```bash +# 2x2 dashboard for one run_metrics_*.json +python3 scripts/plot_run_metrics.py --metrics outputs/run_metrics_20260309_030340.json + +# Departures, messages, system observations, and route changes over time +python3 scripts/plot_departure_timeline.py \ + --events outputs/events_20260309_030340.jsonl \ + --replay outputs/llm_routes_20260309_030340.jsonl + +# Messaging and dialog activity +python3 scripts/plot_agent_communication.py \ + --events outputs/events_20260309_030340.jsonl \ + --dialogs outputs/llm_routes_20260309_030340.dialogs.csv + +# Compare multiple completed runs or sweep outputs +python3 scripts/plot_experiment_comparison.py \ + --results-json outputs/experiments/experiment_results.json +``` + +By default, plots are saved under `outputs/figures/` or next to the selected input file. diff --git a/pyproject.toml b/pyproject.toml index ac40f89..a33c091 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,9 @@ dev = [ "mkdocs-material", "build", ] +plot = [ + "matplotlib>=3.8", +] [project.scripts] # Calibration / sweep tools expose a proper main() and work as CLI scripts. diff --git a/scripts/_plot_common.py b/scripts/_plot_common.py new file mode 100644 index 0000000..9f837ae --- /dev/null +++ b/scripts/_plot_common.py @@ -0,0 +1,106 @@ +"""Shared helpers for plotting completed simulation artifacts.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path +from typing import Any, Iterable, List + + +def newest_file(pattern: str) -> Path: + """Return the newest file matching ``pattern``. + + Raises: + FileNotFoundError: If no matching files exist. + """ + matches = sorted(Path().glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True) + if not matches: + raise FileNotFoundError(f"No files match pattern: {pattern}") + return matches[0] + + +def resolve_input(path_arg: str | None, pattern: str) -> Path: + """Resolve an explicit input path or fall back to the newest matching file.""" + if path_arg: + path = Path(path_arg) + if not path.exists(): + raise FileNotFoundError(f"Input file does not exist: {path}") + return path + return newest_file(pattern) + + +def load_json(path: Path) -> Any: + """Load a JSON document from ``path``.""" + with path.open("r", encoding="utf-8") as fh: + return json.load(fh) + + +def load_jsonl(path: Path) -> List[dict[str, Any]]: + """Load JSON Lines from ``path`` into a list of dicts.""" + rows: List[dict[str, Any]] = [] + with path.open("r", encoding="utf-8") as fh: + for line in fh: + text = line.strip() + if not text: + continue + rows.append(json.loads(text)) + return rows + + +def ensure_output_path( + input_path: Path, + output_arg: str | None, + *, + suffix: str, +) -> Path: + """Resolve output path and ensure its parent directory exists.""" + if output_arg: + out = Path(output_arg) + else: + out = input_path.with_suffix("") + out = out.with_name(f"{out.name}.{suffix}.png") + out.parent.mkdir(parents=True, exist_ok=True) + return out + + +def top_items(mapping: dict[str, float], limit: int) -> list[tuple[str, float]]: + """Return up to ``limit`` items sorted by descending value then key.""" + items = sorted(mapping.items(), key=lambda item: (-item[1], item[0])) + return items[: max(1, int(limit))] + + +def bin_counts( + times_s: Iterable[float], + *, + bin_s: float, +) -> list[tuple[float, int]]: + """Bin event times into fixed-width buckets. + + Returns: + List of ``(bin_start_s, count)`` tuples in ascending order. + """ + counts: dict[float, int] = {} + width = max(float(bin_s), 1e-9) + for t in times_s: + bucket = width * int(float(t) // width) + counts[bucket] = counts.get(bucket, 0) + 1 + return sorted(counts.items(), key=lambda item: item[0]) + + +def require_matplotlib(): + """Import matplotlib lazily with a useful error message.""" + # Constrain thread-hungry numeric backends before importing matplotlib/numpy. + os.environ.setdefault("MPLBACKEND", "Agg") + os.environ.setdefault("OMP_NUM_THREADS", "1") + os.environ.setdefault("OPENBLAS_NUM_THREADS", "1") + os.environ.setdefault("MKL_NUM_THREADS", "1") + os.environ.setdefault("NUMEXPR_NUM_THREADS", "1") + try: + import matplotlib.pyplot as plt + except ImportError as exc: + raise SystemExit( + "matplotlib is required for plotting. Install it with " + "`pip install -e .[plot]` or `pip install matplotlib`." + ) from exc + return plt diff --git a/scripts/plot_agent_communication.py b/scripts/plot_agent_communication.py new file mode 100644 index 0000000..6474639 --- /dev/null +++ b/scripts/plot_agent_communication.py @@ -0,0 +1,230 @@ +#!/usr/bin/env python3 +"""Visualize agent-to-agent messaging and LLM dialog volume for one run.""" + +from __future__ import annotations + +import argparse +import csv +from pathlib import Path +from typing import Any + +try: + from scripts._plot_common import ensure_output_path, load_jsonl, require_matplotlib, resolve_input, top_items +except ModuleNotFoundError: + from _plot_common import ensure_output_path, load_jsonl, require_matplotlib, resolve_input, top_items + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Visualize messaging and dialog activity from events JSONL and dialogs CSV." + ) + parser.add_argument( + "--events", + help="Path to an events_*.jsonl file. Defaults to the newest outputs/events_*.jsonl.", + ) + parser.add_argument( + "--dialogs", + help="Path to a *.dialogs.csv file. Defaults to the newest outputs/*.dialogs.csv.", + ) + parser.add_argument( + "--out", + help="Output PNG path. Defaults to .communication.png.", + ) + parser.add_argument( + "--show", + action="store_true", + help="Open the figure window in addition to saving the PNG.", + ) + parser.add_argument( + "--top-n", + type=int, + default=15, + help="Maximum number of bars to draw in sender/recipient charts (default: 15).", + ) + return parser.parse_args() + + +def _load_dialog_rows(path: Path) -> list[dict[str, str]]: + with path.open("r", encoding="utf-8", newline="") as fh: + return list(csv.DictReader(fh)) + + +def _draw_bar(ax, items: list[tuple[str, float]], title: str, ylabel: str, color: str) -> None: + if not items: + ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11) + ax.set_title(title) + ax.set_axis_off() + return + labels = [k for k, _ in items] + values = [v for _, v in items] + ax.bar(range(len(values)), values, color=color) + ax.set_xticks(range(len(labels))) + ax.set_xticklabels(labels, rotation=60, ha="right", fontsize=8) + ax.set_title(title) + ax.set_ylabel(ylabel) + + +def _round_value(rec: dict[str, Any]) -> int | None: + for key in ("delivery_round", "deliver_round", "sent_round", "round"): + value = rec.get(key) + if value is None: + continue + try: + return int(value) + except (TypeError, ValueError): + continue + return None + + +def _plot_round_series(ax, event_rows: list[dict[str, Any]]) -> None: + series = { + "queued": {}, + "delivered": {}, + "llm": {}, + "predeparture": {}, + } + for rec in event_rows: + event = rec.get("event") + round_idx = _round_value(rec) + if round_idx is None: + continue + if event == "message_queued": + series["queued"][round_idx] = series["queued"].get(round_idx, 0) + 1 + elif event == "message_delivered": + series["delivered"][round_idx] = series["delivered"].get(round_idx, 0) + 1 + elif event == "llm_decision": + series["llm"][round_idx] = series["llm"].get(round_idx, 0) + 1 + elif event == "predeparture_llm_decision": + series["predeparture"][round_idx] = series["predeparture"].get(round_idx, 0) + 1 + + plotted = False + colors = { + "queued": "#4C78A8", + "delivered": "#54A24B", + "llm": "#F58518", + "predeparture": "#E45756", + } + for name, mapping in series.items(): + if not mapping: + continue + xs = sorted(mapping.keys()) + ys = [mapping[x] for x in xs] + ax.plot(xs, ys, marker="o", linewidth=1.8, label=name, color=colors[name]) + plotted = True + + if not plotted: + ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11) + ax.set_axis_off() + return + ax.set_title("Message and Decision Volume by Round") + ax.set_xlabel("Decision Round") + ax.set_ylabel("Event Count") + ax.legend() + + +def _plot_dialog_modes(ax, dialog_rows: list[dict[str, str]]) -> None: + counts: dict[str, int] = {} + response_lengths: dict[str, list[int]] = {} + for row in dialog_rows: + mode = str(row.get("control_mode") or "unknown") + counts[mode] = counts.get(mode, 0) + 1 + response_text = row.get("response_text") or "" + response_lengths.setdefault(mode, []).append(len(response_text)) + + labels = sorted(counts.keys()) + if not labels: + ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11) + ax.set_axis_off() + return + + xs = list(range(len(labels))) + count_vals = [counts[label] for label in labels] + avg_lens = [ + (sum(response_lengths[label]) / float(len(response_lengths[label]))) + if response_lengths[label] else 0.0 + for label in labels + ] + + ax.bar(xs, count_vals, color="#72B7B2", label="dialogs") + ax.set_xticks(xs) + ax.set_xticklabels(labels, rotation=20, ha="right") + ax.set_title("Dialog Volume and Avg Response Length") + ax.set_ylabel("Dialog Count") + + ax2 = ax.twinx() + ax2.plot(xs, avg_lens, color="#B279A2", marker="o", linewidth=1.8, label="avg response chars") + ax2.set_ylabel("Average Response Length (chars)") + + +def plot_agent_communication( + *, + events_path: Path, + dialogs_path: Path, + out_path: Path, + show: bool, + top_n: int, +) -> None: + plt = require_matplotlib() + event_rows = load_jsonl(events_path) + dialog_rows = _load_dialog_rows(dialogs_path) + + sender_counts: dict[str, int] = {} + recipient_counts: dict[str, int] = {} + for rec in event_rows: + event = rec.get("event") + if event == "message_queued": + sender = str(rec.get("from_id") or "unknown") + sender_counts[sender] = sender_counts.get(sender, 0) + 1 + elif event == "message_delivered": + recipient = str(rec.get("to_id") or "unknown") + recipient_counts[recipient] = recipient_counts.get(recipient, 0) + 1 + + fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + fig.suptitle( + f"AgentEvac Communication Analysis\n{events_path.name} | {dialogs_path.name}", + fontsize=14, + ) + + _draw_bar( + axes[0, 0], + top_items({k: float(v) for k, v in sender_counts.items()}, top_n), + f"Top Message Senders (top {top_n})", + "Queued Messages", + "#4C78A8", + ) + _draw_bar( + axes[0, 1], + top_items({k: float(v) for k, v in recipient_counts.items()}, top_n), + f"Top Message Recipients (top {top_n})", + "Delivered Messages", + "#54A24B", + ) + _plot_round_series(axes[1, 0], event_rows) + _plot_dialog_modes(axes[1, 1], dialog_rows) + + fig.tight_layout(rect=(0, 0, 1, 0.95)) + fig.savefig(out_path, dpi=160, bbox_inches="tight") + print(f"[PLOT] events={events_path}") + print(f"[PLOT] dialogs={dialogs_path}") + print(f"[PLOT] output={out_path}") + if show: + plt.show() + plt.close(fig) + + +def main() -> None: + args = _parse_args() + events_path = resolve_input(args.events, "outputs/events_*.jsonl") + dialogs_path = resolve_input(args.dialogs, "outputs/*.dialogs.csv") + out_path = ensure_output_path(events_path, args.out, suffix="communication") + plot_agent_communication( + events_path=events_path, + dialogs_path=dialogs_path, + out_path=out_path, + show=args.show, + top_n=args.top_n, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/plot_all_run_artifacts.py b/scripts/plot_all_run_artifacts.py new file mode 100644 index 0000000..68ad3ad --- /dev/null +++ b/scripts/plot_all_run_artifacts.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Generate all standard figures for one completed AgentEvac run.""" + +from __future__ import annotations + +import argparse +import re +from pathlib import Path + +try: + from scripts._plot_common import newest_file + from scripts.plot_agent_communication import plot_agent_communication + from scripts.plot_departure_timeline import plot_timeline + from scripts.plot_experiment_comparison import load_cases, plot_experiment_comparison + from scripts.plot_run_metrics import plot_metrics_dashboard +except ModuleNotFoundError: + from _plot_common import newest_file + from plot_agent_communication import plot_agent_communication + from plot_departure_timeline import plot_timeline + from plot_experiment_comparison import load_cases, plot_experiment_comparison + from plot_run_metrics import plot_metrics_dashboard + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Generate the standard dashboard, timeline, comparison, and communication plots for one run." + ) + parser.add_argument("--run-id", help="Timestamp token such as 20260309_030340.") + parser.add_argument("--metrics", help="Explicit run_metrics JSON path.") + parser.add_argument("--events", help="Explicit events JSONL path.") + parser.add_argument("--replay", help="Explicit llm_routes JSONL path.") + parser.add_argument("--dialogs", help="Explicit dialogs CSV path.") + parser.add_argument( + "--results-json", + help="Optional experiment_results.json to also generate the multi-run comparison figure.", + ) + parser.add_argument( + "--out-dir", + help="Output directory. Defaults to outputs/figures//.", + ) + parser.add_argument("--show", action="store_true", help="Show figures interactively as they are generated.") + parser.add_argument("--top-n", type=int, default=15, help="Top-N bars for agent-level charts.") + parser.add_argument("--bin-s", type=float, default=30.0, help="Time-bin width in seconds for timeline counts.") + return parser.parse_args() + + +def _maybe_path(path_arg: str | None) -> Path | None: + if not path_arg: + return None + path = Path(path_arg) + if not path.exists(): + raise SystemExit(f"Input file does not exist: {path}") + return path + + +def _resolve_run_id(args: argparse.Namespace) -> str: + if args.run_id: + return str(args.run_id) + for path_arg in (args.events, args.metrics, args.replay, args.dialogs): + if path_arg: + match = re.search(r"(\d{8}_\d{6})", Path(path_arg).name) + if match: + return match.group(1) + newest = newest_file("outputs/events_*.jsonl") + stem = newest.stem + return stem.replace("events_", "", 1) + + +def _resolve_paths(args: argparse.Namespace, run_id: str) -> dict[str, Path | None]: + metrics = _maybe_path(args.metrics) + events = _maybe_path(args.events) + replay = _maybe_path(args.replay) + dialogs = _maybe_path(args.dialogs) + + if metrics is None: + candidate = Path(f"outputs/run_metrics_{run_id}.json") + metrics = candidate if candidate.exists() else newest_file("outputs/run_metrics_*.json") + if events is None: + candidate = Path(f"outputs/events_{run_id}.jsonl") + events = candidate if candidate.exists() else newest_file("outputs/events_*.jsonl") + if replay is None: + candidate = Path(f"outputs/llm_routes_{run_id}.jsonl") + replay = candidate if candidate.exists() else None + if dialogs is None: + candidate = Path(f"outputs/llm_routes_{run_id}.dialogs.csv") + dialogs = candidate if candidate.exists() else newest_file("outputs/*.dialogs.csv") + + return { + "metrics": metrics, + "events": events, + "replay": replay, + "dialogs": dialogs, + } + + +def main() -> None: + args = _parse_args() + run_id = _resolve_run_id(args) + paths = _resolve_paths(args, run_id) + + out_dir = Path(args.out_dir) if args.out_dir else Path("outputs/figures") / run_id + out_dir.mkdir(parents=True, exist_ok=True) + + metrics_path = paths["metrics"] + events_path = paths["events"] + replay_path = paths["replay"] + dialogs_path = paths["dialogs"] + assert metrics_path is not None + assert events_path is not None + assert dialogs_path is not None + + plot_metrics_dashboard( + metrics_path, + out_path=out_dir / "run_metrics.dashboard.png", + show=args.show, + top_n=args.top_n, + ) + plot_timeline( + events_path, + replay_path=replay_path, + out_path=out_dir / "run_timeline.png", + show=args.show, + bin_s=args.bin_s, + ) + plot_agent_communication( + events_path=events_path, + dialogs_path=dialogs_path, + out_path=out_dir / "agent_communication.png", + show=args.show, + top_n=args.top_n, + ) + comparison_source: Path | None = None + if args.results_json: + results_path = Path(args.results_json) + if not results_path.exists(): + raise SystemExit(f"Results JSON does not exist: {results_path}") + comparison_rows, comparison_source = load_cases(results_path, "outputs/run_metrics_*.json") + plot_experiment_comparison( + comparison_rows, + source_path=comparison_source, + out_path=out_dir / "experiment_comparison.png", + show=args.show, + ) + else: + metrics_matches = sorted(Path().glob("outputs/run_metrics_*.json")) + if len(metrics_matches) > 1: + comparison_rows, comparison_source = load_cases(None, "outputs/run_metrics_*.json") + plot_experiment_comparison( + comparison_rows, + source_path=comparison_source, + out_path=out_dir / "experiment_comparison.png", + show=args.show, + ) + + print(f"[PLOT] run_id={run_id}") + print(f"[PLOT] figures_dir={out_dir}") + print(f"[PLOT] metrics={metrics_path}") + print(f"[PLOT] events={events_path}") + if replay_path: + print(f"[PLOT] replay={replay_path}") + print(f"[PLOT] dialogs={dialogs_path}") + if comparison_source: + print(f"[PLOT] comparison_source={comparison_source}") + + +if __name__ == "__main__": + main() diff --git a/scripts/plot_departure_timeline.py b/scripts/plot_departure_timeline.py new file mode 100644 index 0000000..5e8ab67 --- /dev/null +++ b/scripts/plot_departure_timeline.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""Plot departure and communication timelines from completed simulation logs.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +try: + from scripts._plot_common import ( + bin_counts, + ensure_output_path, + load_jsonl, + require_matplotlib, + resolve_input, + ) +except ModuleNotFoundError: + from _plot_common import ( + bin_counts, + ensure_output_path, + load_jsonl, + require_matplotlib, + resolve_input, + ) + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Visualize departures, messages, and route changes over time." + ) + parser.add_argument( + "--events", + help="Path to an events_*.jsonl file. Defaults to the newest outputs/events_*.jsonl.", + ) + parser.add_argument( + "--replay", + help="Optional llm_routes_*.jsonl replay log for route-change counts.", + ) + parser.add_argument( + "--out", + help="Output PNG path. Defaults to .timeline.png.", + ) + parser.add_argument( + "--show", + action="store_true", + help="Open the figure window in addition to saving the PNG.", + ) + parser.add_argument( + "--bin-s", + type=float, + default=30.0, + help="Time-bin width in seconds for event counts (default: 30).", + ) + return parser.parse_args() + + +def _extract_times(rows: list[dict], event_type: str) -> list[float]: + out = [] + for rec in rows: + if rec.get("event") == event_type and rec.get("time_s") is not None: + out.append(float(rec["time_s"])) + return sorted(out) + + +def _plot_cumulative(ax, times: list[float], title: str, color: str) -> None: + if not times: + ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11) + ax.set_title(title) + ax.set_axis_off() + return + y = list(range(1, len(times) + 1)) + ax.step(times, y, where="post", color=color, linewidth=2) + ax.scatter(times, y, color=color, s=16) + ax.set_title(title) + ax.set_xlabel("Simulation Time (s)") + ax.set_ylabel("Cumulative Count") + + +def _plot_binned(ax, series: list[tuple[str, list[float], str]], *, bin_s: float) -> None: + plotted = False + for label, times, color in series: + binned = bin_counts(times, bin_s=bin_s) + if not binned: + continue + xs = [x for x, _ in binned] + ys = [y for _, y in binned] + ax.plot(xs, ys, marker="o", linewidth=1.8, label=label, color=color) + plotted = True + if not plotted: + ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11) + ax.set_axis_off() + return + ax.set_title(f"Event Volume per {int(bin_s) if float(bin_s).is_integer() else bin_s}s Bin") + ax.set_xlabel("Simulation Time (s)") + ax.set_ylabel("Event Count") + ax.legend() + + +def plot_timeline(events_path: Path, *, replay_path: Path | None, out_path: Path, show: bool, bin_s: float) -> None: + plt = require_matplotlib() + event_rows = load_jsonl(events_path) + replay_rows = load_jsonl(replay_path) if replay_path else [] + + departure_times = _extract_times(event_rows, "departure_release") + message_times = _extract_times(event_rows, "message_delivered") + _extract_times(event_rows, "message_queued") + observation_times = _extract_times(event_rows, "system_observation_generated") + llm_times = _extract_times(event_rows, "llm_decision") + _extract_times(event_rows, "predeparture_llm_decision") + route_change_times = _extract_times(replay_rows, "route_change") + + fig, axes = plt.subplots(2, 1, figsize=(14, 9)) + fig.suptitle( + f"AgentEvac Timeline\n{events_path.name}" + (f" | replay={replay_path.name}" if replay_path else ""), + fontsize=14, + ) + + _plot_cumulative(axes[0], departure_times, "Cumulative Departures", "#E45756") + _plot_binned( + axes[1], + [ + ("Messages", sorted(message_times), "#4C78A8"), + ("System observations", sorted(observation_times), "#54A24B"), + ("LLM decisions", sorted(llm_times), "#F58518"), + ("Route changes", sorted(route_change_times), "#B279A2"), + ], + bin_s=bin_s, + ) + + fig.tight_layout(rect=(0, 0, 1, 0.95)) + fig.savefig(out_path, dpi=160, bbox_inches="tight") + print(f"[PLOT] events={events_path}") + if replay_path: + print(f"[PLOT] replay={replay_path}") + print(f"[PLOT] output={out_path}") + if show: + plt.show() + plt.close(fig) + + +def main() -> None: + args = _parse_args() + events_path = resolve_input(args.events, "outputs/events_*.jsonl") + replay_path = Path(args.replay) if args.replay else None + if replay_path and not replay_path.exists(): + raise SystemExit(f"Replay file does not exist: {replay_path}") + out_path = ensure_output_path(events_path, args.out, suffix="timeline") + plot_timeline(events_path, replay_path=replay_path, out_path=out_path, show=args.show, bin_s=args.bin_s) + + +if __name__ == "__main__": + main() diff --git a/scripts/plot_experiment_comparison.py b/scripts/plot_experiment_comparison.py new file mode 100644 index 0000000..e63ba50 --- /dev/null +++ b/scripts/plot_experiment_comparison.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Compare multiple completed runs from an experiment sweep or metrics glob.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +try: + from scripts._plot_common import ensure_output_path, load_json, require_matplotlib +except ModuleNotFoundError: + from _plot_common import ensure_output_path, load_json, require_matplotlib + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Compare multiple AgentEvac runs from experiment_results.json or a metrics glob." + ) + parser.add_argument( + "--results-json", + help="Path to experiment_results.json from agentevac.analysis.experiments.", + ) + parser.add_argument( + "--metrics-glob", + default="outputs/run_metrics_*.json", + help="Glob of metrics JSON files used if --results-json is omitted " + "(default: outputs/run_metrics_*.json).", + ) + parser.add_argument( + "--out", + help="Output PNG path. Defaults to .comparison.png or outputs/metrics_comparison.png.", + ) + parser.add_argument( + "--show", + action="store_true", + help="Open the figure window in addition to saving the PNG.", + ) + return parser.parse_args() + + +def _safe_float(value: Any, default: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return default + + +def _metrics_row(metrics: dict[str, Any]) -> dict[str, float]: + return { + "departure_variability": _safe_float(metrics.get("departure_time_variability")), + "route_entropy": _safe_float(metrics.get("route_choice_entropy")), + "hazard_exposure": _safe_float(metrics.get("average_hazard_exposure", {}).get("global_average")), + "avg_travel_time": _safe_float(metrics.get("average_travel_time", {}).get("average")), + "arrived_agents": _safe_float(metrics.get("arrived_agents")), + "departed_agents": _safe_float(metrics.get("departed_agents")), + } + + +def load_cases(results_json: Path | None, metrics_glob: str) -> tuple[list[dict[str, Any]], Path]: + rows: list[dict[str, Any]] = [] + if results_json is not None: + payload = load_json(results_json) + if not isinstance(payload, list): + raise SystemExit(f"Expected a list in {results_json}") + for item in payload: + metrics_path = item.get("metrics_path") + if not metrics_path: + continue + path = Path(str(metrics_path)) + if not path.exists(): + continue + metrics = load_json(path) + case = item.get("case") or {} + row = { + "label": str(item.get("case_id") or path.stem), + "scenario": str(case.get("scenario", "unknown")), + "info_sigma": _safe_float(case.get("info_sigma")), + "info_delay_s": _safe_float(case.get("info_delay_s")), + "theta_trust": _safe_float(case.get("theta_trust")), + "metrics_path": str(path), + } + row.update(_metrics_row(metrics)) + rows.append(row) + return rows, results_json + + matches = sorted(Path().glob(metrics_glob)) + if not matches: + raise SystemExit(f"No metrics files match pattern: {metrics_glob}") + for path in matches: + metrics = load_json(path) + row = { + "label": path.stem, + "scenario": "unknown", + "info_sigma": 0.0, + "info_delay_s": 0.0, + "theta_trust": 0.0, + "metrics_path": str(path), + } + row.update(_metrics_row(metrics)) + rows.append(row) + return rows, matches[-1] + + +def _scatter_by_scenario(ax, rows: list[dict[str, Any]]) -> None: + scenario_colors = { + "no_notice": "#E45756", + "alert_guided": "#F58518", + "advice_guided": "#4C78A8", + "unknown": "#777777", + } + seen = set() + for row in rows: + scenario = str(row.get("scenario", "unknown")) + label = scenario if scenario not in seen else None + seen.add(scenario) + size = max(30.0, 20.0 + 20.0 * row.get("theta_trust", 0.0)) + ax.scatter( + row["hazard_exposure"], + row["avg_travel_time"], + s=size, + color=scenario_colors.get(scenario, "#777777"), + alpha=0.85, + label=label, + ) + ax.set_title("Hazard Exposure vs Travel Time") + ax.set_xlabel("Global Hazard Exposure") + ax.set_ylabel("Average Travel Time (s)") + if seen: + ax.legend() + + +def _line_vs_sigma(ax, rows: list[dict[str, Any]]) -> None: + by_scenario: dict[str, list[dict[str, Any]]] = {} + for row in rows: + by_scenario.setdefault(str(row.get("scenario", "unknown")), []).append(row) + if not by_scenario: + ax.text(0.5, 0.5, "No data", ha="center", va="center") + ax.set_axis_off() + return + for scenario, scenario_rows in sorted(by_scenario.items()): + ordered = sorted(scenario_rows, key=lambda item: item.get("info_sigma", 0.0)) + xs = [r.get("info_sigma", 0.0) for r in ordered] + ys = [r.get("route_entropy", 0.0) for r in ordered] + ax.plot(xs, ys, marker="o", linewidth=1.8, label=scenario) + ax.set_title("Route Entropy vs Info Sigma") + ax.set_xlabel("INFO_SIGMA") + ax.set_ylabel("Route Choice Entropy") + ax.legend() + + +def _bar_mean_by_scenario(ax, rows: list[dict[str, Any]], field: str, title: str, ylabel: str, color: str) -> None: + groups: dict[str, list[float]] = {} + for row in rows: + groups.setdefault(str(row.get("scenario", "unknown")), []).append(float(row.get(field, 0.0))) + labels = sorted(groups.keys()) + if not labels: + ax.text(0.5, 0.5, "No data", ha="center", va="center") + ax.set_axis_off() + return + means = [sum(groups[label]) / float(len(groups[label])) for label in labels] + ax.bar(range(len(labels)), means, color=color) + ax.set_xticks(range(len(labels))) + ax.set_xticklabels(labels, rotation=20, ha="right") + ax.set_title(title) + ax.set_ylabel(ylabel) + + +def plot_experiment_comparison(rows: list[dict[str, Any]], *, source_path: Path, out_path: Path, show: bool) -> None: + plt = require_matplotlib() + fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + fig.suptitle( + f"AgentEvac Experiment Comparison\n{source_path.name} | runs={len(rows)}", + fontsize=14, + ) + + _scatter_by_scenario(axes[0, 0], rows) + _line_vs_sigma(axes[0, 1], rows) + _bar_mean_by_scenario( + axes[1, 0], + rows, + field="avg_travel_time", + title="Mean Travel Time by Scenario", + ylabel="Average Travel Time (s)", + color="#4C78A8", + ) + _bar_mean_by_scenario( + axes[1, 1], + rows, + field="hazard_exposure", + title="Mean Hazard Exposure by Scenario", + ylabel="Global Hazard Exposure", + color="#E45756", + ) + + fig.tight_layout(rect=(0, 0, 1, 0.95)) + fig.savefig(out_path, dpi=160, bbox_inches="tight") + print(f"[PLOT] source={source_path}") + print(f"[PLOT] output={out_path}") + if show: + plt.show() + plt.close(fig) + + +def main() -> None: + args = _parse_args() + results_path = Path(args.results_json) if args.results_json else None + if results_path and not results_path.exists(): + raise SystemExit(f"Results JSON does not exist: {results_path}") + rows, source_path = load_cases(results_path, args.metrics_glob) + out_path = ensure_output_path(source_path, args.out, suffix="comparison") + plot_experiment_comparison(rows, source_path=source_path, out_path=out_path, show=args.show) + + +if __name__ == "__main__": + main() diff --git a/scripts/plot_run_metrics.py b/scripts/plot_run_metrics.py new file mode 100644 index 0000000..d09638e --- /dev/null +++ b/scripts/plot_run_metrics.py @@ -0,0 +1,125 @@ +#!/usr/bin/env python3 +"""Plot a compact dashboard for one completed simulation metrics JSON.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +try: + from scripts._plot_common import ensure_output_path, load_json, require_matplotlib, resolve_input, top_items +except ModuleNotFoundError: + from _plot_common import ensure_output_path, load_json, require_matplotlib, resolve_input, top_items + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Visualize one run_metrics_*.json file as a 2x2 dashboard." + ) + parser.add_argument( + "--metrics", + help="Path to a metrics JSON file. Defaults to the newest outputs/run_metrics_*.json.", + ) + parser.add_argument( + "--out", + help="Output PNG path. Defaults to .dashboard.png.", + ) + parser.add_argument( + "--show", + action="store_true", + help="Open the figure window in addition to saving the PNG.", + ) + parser.add_argument( + "--top-n", + type=int, + default=20, + help="Maximum number of per-agent bars to draw in each panel (default: 20).", + ) + return parser.parse_args() + + +def _draw_or_empty(ax, items: list[tuple[str, float]], title: str, ylabel: str, color: str, *, highest_first: bool = True): + if not items: + ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11) + ax.set_title(title) + ax.set_axis_off() + return + labels = [k for k, _ in items] + values = [v for _, v in items] + if not highest_first: + labels = list(reversed(labels)) + values = list(reversed(values)) + ax.bar(range(len(values)), values, color=color) + ax.set_xticks(range(len(labels))) + ax.set_xticklabels(labels, rotation=60, ha="right", fontsize=8) + ax.set_title(title) + ax.set_ylabel(ylabel) + + +def plot_metrics_dashboard(metrics_path: Path, *, out_path: Path, show: bool, top_n: int) -> None: + plt = require_matplotlib() + metrics = load_json(metrics_path) + + kpis = { + "Departure variance": float(metrics.get("departure_time_variability", 0.0)), + "Route entropy": float(metrics.get("route_choice_entropy", 0.0)), + "Hazard exposure": float(metrics.get("average_hazard_exposure", {}).get("global_average", 0.0)), + "Avg travel time": float(metrics.get("average_travel_time", {}).get("average", 0.0)), + } + exposure = metrics.get("average_hazard_exposure", {}).get("per_agent_average", {}) or {} + travel = metrics.get("average_travel_time", {}).get("per_agent", {}) or {} + instability = metrics.get("decision_instability", {}).get("per_agent_changes", {}) or {} + + fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + fig.suptitle( + f"AgentEvac Run Metrics\n{metrics_path.name} | mode={metrics.get('run_mode', 'unknown')} " + f"| departed={metrics.get('departed_agents', 0)} | arrived={metrics.get('arrived_agents', 0)}", + fontsize=14, + ) + + axes[0, 0].bar(range(len(kpis)), list(kpis.values()), color=["#4C78A8", "#F58518", "#E45756", "#54A24B"]) + axes[0, 0].set_xticks(range(len(kpis))) + axes[0, 0].set_xticklabels(list(kpis.keys()), rotation=20, ha="right") + axes[0, 0].set_title("Run KPI Summary") + axes[0, 0].set_ylabel("Value") + + _draw_or_empty( + axes[0, 1], + top_items(travel, top_n), + f"Per-Agent Travel Time (top {top_n})", + "Seconds", + "#4C78A8", + ) + _draw_or_empty( + axes[1, 0], + top_items(exposure, top_n), + f"Per-Agent Hazard Exposure (top {top_n})", + "Average Risk Score", + "#E45756", + ) + _draw_or_empty( + axes[1, 1], + top_items({k: float(v) for k, v in instability.items()}, top_n), + f"Per-Agent Decision Instability (top {top_n})", + "Choice Changes", + "#72B7B2", + ) + + fig.tight_layout(rect=(0, 0, 1, 0.95)) + fig.savefig(out_path, dpi=160, bbox_inches="tight") + print(f"[PLOT] metrics={metrics_path}") + print(f"[PLOT] output={out_path}") + if show: + plt.show() + plt.close(fig) + + +def main() -> None: + args = _parse_args() + metrics_path = resolve_input(args.metrics, "outputs/run_metrics_*.json") + out_path = ensure_output_path(metrics_path, args.out, suffix="dashboard") + plot_metrics_dashboard(metrics_path, out_path=out_path, show=args.show, top_n=args.top_n) + + +if __name__ == "__main__": + main() From 4f3172f14d13e0c3005f538b50918c02d15ca692 Mon Sep 17 00:00:00 2001 From: legend5teve <2659618982@qq.com> Date: Tue, 10 Mar 2026 18:33:13 -0600 Subject: [PATCH 5/8] feat: implement timeline analysis for evacuation in scripts/plot_agent_round_timeline.py --- README.md | 9 + scripts/plot_agent_round_timeline.py | 286 +++++++++++++++++++++++++++ scripts/plot_all_run_artifacts.py | 11 ++ 3 files changed, 306 insertions(+) create mode 100644 scripts/plot_agent_round_timeline.py diff --git a/README.md b/README.md index e5db941..1aec29e 100644 --- a/README.md +++ b/README.md @@ -132,6 +132,15 @@ python3 scripts/plot_agent_communication.py \ --events outputs/events_20260309_030340.jsonl \ --dialogs outputs/llm_routes_20260309_030340.dialogs.csv +# One-row-per-agent round timeline with departure, arrival, and route-change highlights +python3 scripts/plot_agent_round_timeline.py --run-id 20260309_030340 + +# Or pass explicit files +python3 scripts/plot_agent_round_timeline.py \ + --events outputs/events_20260309_030340.jsonl \ + --replay outputs/llm_routes_20260309_030340.jsonl \ + --metrics outputs/run_metrics_20260309_030340.json + # Compare multiple completed runs or sweep outputs python3 scripts/plot_experiment_comparison.py \ --results-json outputs/experiments/experiment_results.json diff --git a/scripts/plot_agent_round_timeline.py b/scripts/plot_agent_round_timeline.py new file mode 100644 index 0000000..43e55d4 --- /dev/null +++ b/scripts/plot_agent_round_timeline.py @@ -0,0 +1,286 @@ +#!/usr/bin/env python3 +"""Plot a round-based agent timeline with departure, arrival, and route-change overlays.""" + +from __future__ import annotations + +import argparse +from pathlib import Path +from typing import Any + +try: + from scripts._plot_common import load_json, load_jsonl, require_matplotlib, resolve_input +except ModuleNotFoundError: + from _plot_common import load_json, load_jsonl, require_matplotlib, resolve_input + + +def _parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Plot one row per agent from departure round to arrival round, " + "with route-change rounds highlighted." + ) + parser.add_argument( + "--run-id", + help="Timestamp token such as 20260309_030340. Used to resolve matching outputs files.", + ) + parser.add_argument( + "--events", + help="Path to an events_*.jsonl file. Defaults to the newest outputs/events_*.jsonl.", + ) + parser.add_argument( + "--replay", + help="Path to an llm_routes_*.jsonl file. Defaults to the newest outputs/llm_routes_*.jsonl.", + ) + parser.add_argument( + "--metrics", + help="Path to a run_metrics_*.json file. Defaults to the newest outputs/run_metrics_*.json.", + ) + parser.add_argument( + "--out", + help="Output PNG path. Defaults to .round_timeline.png.", + ) + parser.add_argument( + "--show", + action="store_true", + help="Open the figure window in addition to saving the PNG.", + ) + parser.add_argument( + "--include-no-departure", + action="store_true", + help="Also show agents without a departure_release event, starting from their first route change.", + ) + return parser.parse_args() + + +def _round_table(event_rows: list[dict[str, Any]]) -> list[tuple[int, float]]: + rounds = [] + for rec in event_rows: + if rec.get("event") != "decision_round_start": + continue + if rec.get("round") is None or rec.get("sim_t_s") is None: + continue + rounds.append((int(rec["round"]), float(rec["sim_t_s"]))) + rounds = sorted(set(rounds), key=lambda item: item[0]) + if not rounds: + raise SystemExit("No decision_round_start events found; cannot build round timeline.") + return rounds + + +def _round_for_time(t: float, rounds: list[tuple[int, float]]) -> int: + """Return the latest decision round whose time is <= ``t``.""" + selected = rounds[0][0] + for round_idx, round_t in rounds: + if round_t <= float(t) + 1e-9: + selected = round_idx + else: + break + return selected + + +def _departure_times(event_rows: list[dict[str, Any]]) -> dict[str, float]: + out: dict[str, float] = {} + for rec in event_rows: + if rec.get("event") != "departure_release": + continue + vid = rec.get("veh_id") + sim_t = rec.get("sim_t_s") + if vid is None or sim_t is None: + continue + out.setdefault(str(vid), float(sim_t)) + return out + + +def _route_change_times(replay_rows: list[dict[str, Any]]) -> dict[str, list[float]]: + out: dict[str, list[float]] = {} + for rec in replay_rows: + if rec.get("event") != "route_change": + continue + vid = rec.get("veh_id") + sim_t = rec.get("time_s") + if vid is None or sim_t is None: + continue + out.setdefault(str(vid), []).append(float(sim_t)) + for vid in out: + out[vid] = sorted(set(out[vid])) + return out + + +def _timeline_rows( + event_rows: list[dict[str, Any]], + replay_rows: list[dict[str, Any]], + metrics: dict[str, Any], + *, + include_no_departure: bool, +) -> tuple[list[dict[str, Any]], int]: + rounds = _round_table(event_rows) + final_round = rounds[-1][0] + departures = _departure_times(event_rows) + route_changes = _route_change_times(replay_rows) + travel_times = metrics.get("average_travel_time", {}).get("per_agent", {}) or {} + + all_agent_ids = set(departures.keys()) + if include_no_departure: + all_agent_ids.update(route_changes.keys()) + + rows: list[dict[str, Any]] = [] + for vid in sorted(all_agent_ids): + depart_time = departures.get(vid) + change_times = route_changes.get(vid, []) + + if depart_time is None: + if not include_no_departure or not change_times: + continue + start_round = _round_for_time(change_times[0], rounds) + status = "no_departure_event" + else: + start_round = _round_for_time(depart_time, rounds) + status = "completed" if vid in travel_times else "incomplete" + + if vid in travel_times and depart_time is not None: + arrival_time = float(depart_time) + float(travel_times[vid]) + end_round = _round_for_time(arrival_time, rounds) + status = "completed" + else: + end_round = final_round + + end_round = max(end_round, start_round) + change_rounds = sorted({_round_for_time(t, rounds) for t in change_times if _round_for_time(t, rounds) >= start_round}) + + rows.append({ + "veh_id": vid, + "start_round": start_round, + "end_round": end_round, + "change_rounds": change_rounds, + "status": status, + }) + + rows.sort(key=lambda row: (row["start_round"], row["veh_id"])) + return rows, final_round + + +def plot_agent_round_timeline( + *, + events_path: Path, + replay_path: Path, + metrics_path: Path, + out_path: Path, + show: bool, + include_no_departure: bool, +) -> None: + plt = require_matplotlib() + from matplotlib.patches import Patch + + event_rows = load_jsonl(events_path) + replay_rows = load_jsonl(replay_path) + metrics = load_json(metrics_path) + timeline_rows, final_round = _timeline_rows( + event_rows, + replay_rows, + metrics, + include_no_departure=include_no_departure, + ) + if not timeline_rows: + raise SystemExit("No agent timeline rows could be constructed from the provided artifacts.") + + fig_h = max(6.0, 0.32 * len(timeline_rows) + 2.0) + fig, ax = plt.subplots(figsize=(14, fig_h)) + fig.suptitle( + f"Agent Round Timeline\n{events_path.name} | {replay_path.name} | {metrics_path.name}", + fontsize=14, + ) + + yticks = [] + ylabels = [] + base_colors = { + "completed": "#4C78A8", + "incomplete": "#999999", + "no_departure_event": "#BBBBBB", + } + + for idx, row in enumerate(timeline_rows): + y = idx + yticks.append(y) + ylabels.append(row["veh_id"]) + start = float(row["start_round"]) - 0.5 + width = float(row["end_round"] - row["start_round"] + 1) + color = base_colors.get(row["status"], "#4C78A8") + hatch = "//" if row["status"] != "completed" else None + ax.broken_barh( + [(start, width)], + (y - 0.35, 0.7), + facecolors=color, + edgecolors="black", + linewidth=0.4, + hatch=hatch, + alpha=0.9, + ) + change_segments = [(float(round_idx) - 0.5, 1.0) for round_idx in row["change_rounds"]] + if change_segments: + ax.broken_barh( + change_segments, + (y - 0.35, 0.7), + facecolors="#F58518", + edgecolors="#C04B00", + linewidth=0.4, + ) + + ax.set_xlim(0.5, final_round + 0.5) + ax.set_ylim(-1, len(timeline_rows)) + ax.set_xlabel("Decision Round") + ax.set_ylabel("Agent") + ax.set_yticks(yticks) + ax.set_yticklabels(ylabels, fontsize=8) + ax.grid(axis="x", linestyle=":", alpha=0.4) + + ax.legend( + handles=[ + Patch(facecolor="#4C78A8", edgecolor="black", label="Active interval"), + Patch(facecolor="#F58518", edgecolor="#C04B00", label="Route/destination change round"), + Patch(facecolor="#999999", edgecolor="black", hatch="//", label="Still active at run end / inferred"), + ], + loc="upper right", + ) + + fig.tight_layout(rect=(0, 0, 1, 0.97)) + fig.savefig(out_path, dpi=160, bbox_inches="tight") + print(f"[PLOT] events={events_path}") + print(f"[PLOT] replay={replay_path}") + print(f"[PLOT] metrics={metrics_path}") + print(f"[PLOT] output={out_path}") + if show: + plt.show() + plt.close(fig) + + +def main() -> None: + args = _parse_args() + if args.run_id: + run_id = str(args.run_id) + events_default = f"outputs/events_{run_id}.jsonl" + replay_default = f"outputs/llm_routes_{run_id}.jsonl" + metrics_default = f"outputs/run_metrics_{run_id}.json" + else: + events_default = "outputs/events_*.jsonl" + replay_default = "outputs/llm_routes_*.jsonl" + metrics_default = "outputs/run_metrics_*.json" + + events_path = resolve_input(args.events, events_default) + replay_path = resolve_input(args.replay, replay_default) + metrics_path = resolve_input(args.metrics, metrics_default) + out_path = ( + Path(args.out) + if args.out + else events_path.with_suffix("").with_name(f"{events_path.with_suffix('').name}.round_timeline.png") + ) + out_path.parent.mkdir(parents=True, exist_ok=True) + plot_agent_round_timeline( + events_path=events_path, + replay_path=replay_path, + metrics_path=metrics_path, + out_path=out_path, + show=args.show, + include_no_departure=args.include_no_departure, + ) + + +if __name__ == "__main__": + main() diff --git a/scripts/plot_all_run_artifacts.py b/scripts/plot_all_run_artifacts.py index 68ad3ad..3418529 100644 --- a/scripts/plot_all_run_artifacts.py +++ b/scripts/plot_all_run_artifacts.py @@ -10,12 +10,14 @@ try: from scripts._plot_common import newest_file from scripts.plot_agent_communication import plot_agent_communication + from scripts.plot_agent_round_timeline import plot_agent_round_timeline from scripts.plot_departure_timeline import plot_timeline from scripts.plot_experiment_comparison import load_cases, plot_experiment_comparison from scripts.plot_run_metrics import plot_metrics_dashboard except ModuleNotFoundError: from _plot_common import newest_file from plot_agent_communication import plot_agent_communication + from plot_agent_round_timeline import plot_agent_round_timeline from plot_departure_timeline import plot_timeline from plot_experiment_comparison import load_cases, plot_experiment_comparison from plot_run_metrics import plot_metrics_dashboard @@ -129,6 +131,15 @@ def main() -> None: show=args.show, top_n=args.top_n, ) + if replay_path is not None: + plot_agent_round_timeline( + events_path=events_path, + replay_path=replay_path, + metrics_path=metrics_path, + out_path=out_dir / "agent_round_timeline.png", + show=args.show, + include_no_departure=False, + ) comparison_source: Path | None = None if args.results_json: results_path = Path(args.results_json) From 1c7ff719b6de22f70b7ddad64f669f7ec8ca7363 Mon Sep 17 00:00:00 2001 From: legend5teve <2659618982@qq.com> Date: Tue, 10 Mar 2026 22:27:30 -0600 Subject: [PATCH 6/8] chore: add test cases to cover newly added features; update doc strings for documentation --- scripts/__init__.py | 1 + scripts/plot_agent_round_timeline.py | 12 ++++ scripts/plot_all_run_artifacts.py | 5 ++ tests/test_plot_agent_round_timeline.py | 75 +++++++++++++++++++++++++ tests/test_plot_all_run_artifacts.py | 72 ++++++++++++++++++++++++ 5 files changed, 165 insertions(+) create mode 100644 scripts/__init__.py create mode 100644 tests/test_plot_agent_round_timeline.py create mode 100644 tests/test_plot_all_run_artifacts.py diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..7646535 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1 @@ +"""Plotting and utility scripts for post-run analysis.""" diff --git a/scripts/plot_agent_round_timeline.py b/scripts/plot_agent_round_timeline.py index 43e55d4..d758c75 100644 --- a/scripts/plot_agent_round_timeline.py +++ b/scripts/plot_agent_round_timeline.py @@ -14,6 +14,7 @@ def _parse_args() -> argparse.Namespace: + """Parse CLI arguments for the round-timeline plot.""" parser = argparse.ArgumentParser( description="Plot one row per agent from departure round to arrival round, " "with route-change rounds highlighted." @@ -52,6 +53,7 @@ def _parse_args() -> argparse.Namespace: def _round_table(event_rows: list[dict[str, Any]]) -> list[tuple[int, float]]: + """Extract and sort the `(round, sim_t_s)` table from event rows.""" rounds = [] for rec in event_rows: if rec.get("event") != "decision_round_start": @@ -77,6 +79,7 @@ def _round_for_time(t: float, rounds: list[tuple[int, float]]) -> int: def _departure_times(event_rows: list[dict[str, Any]]) -> dict[str, float]: + """Collect the first recorded departure time for each agent.""" out: dict[str, float] = {} for rec in event_rows: if rec.get("event") != "departure_release": @@ -90,6 +93,7 @@ def _departure_times(event_rows: list[dict[str, Any]]) -> dict[str, float]: def _route_change_times(replay_rows: list[dict[str, Any]]) -> dict[str, list[float]]: + """Collect route-change timestamps per agent from the replay log.""" out: dict[str, list[float]] = {} for rec in replay_rows: if rec.get("event") != "route_change": @@ -111,6 +115,12 @@ def _timeline_rows( *, include_no_departure: bool, ) -> tuple[list[dict[str, Any]], int]: + """Build per-agent timeline rows from departures, travel times, and route changes. + + Returns: + A tuple `(rows, final_round)` where `rows` contains one dict per agent with + `start_round`, `end_round`, `change_rounds`, and a `status` label. + """ rounds = _round_table(event_rows) final_round = rounds[-1][0] departures = _departure_times(event_rows) @@ -166,6 +176,7 @@ def plot_agent_round_timeline( show: bool, include_no_departure: bool, ) -> None: + """Render the round-based agent timeline figure and save it to disk.""" plt = require_matplotlib() from matplotlib.patches import Patch @@ -252,6 +263,7 @@ def plot_agent_round_timeline( def main() -> None: + """CLI entry point for generating the round-timeline plot.""" args = _parse_args() if args.run_id: run_id = str(args.run_id) diff --git a/scripts/plot_all_run_artifacts.py b/scripts/plot_all_run_artifacts.py index 3418529..e6003ef 100644 --- a/scripts/plot_all_run_artifacts.py +++ b/scripts/plot_all_run_artifacts.py @@ -24,6 +24,7 @@ def _parse_args() -> argparse.Namespace: + """Parse CLI arguments for the aggregate plotting wrapper.""" parser = argparse.ArgumentParser( description="Generate the standard dashboard, timeline, comparison, and communication plots for one run." ) @@ -47,6 +48,7 @@ def _parse_args() -> argparse.Namespace: def _maybe_path(path_arg: str | None) -> Path | None: + """Validate an optional explicit file path and return it as a `Path`.""" if not path_arg: return None path = Path(path_arg) @@ -56,6 +58,7 @@ def _maybe_path(path_arg: str | None) -> Path | None: def _resolve_run_id(args: argparse.Namespace) -> str: + """Resolve the run ID from CLI args or the newest events file.""" if args.run_id: return str(args.run_id) for path_arg in (args.events, args.metrics, args.replay, args.dialogs): @@ -69,6 +72,7 @@ def _resolve_run_id(args: argparse.Namespace) -> str: def _resolve_paths(args: argparse.Namespace, run_id: str) -> dict[str, Path | None]: + """Resolve all input artifact paths for one run.""" metrics = _maybe_path(args.metrics) events = _maybe_path(args.events) replay = _maybe_path(args.replay) @@ -96,6 +100,7 @@ def _resolve_paths(args: argparse.Namespace, run_id: str) -> dict[str, Path | No def main() -> None: + """CLI entry point for generating the standard set of run figures.""" args = _parse_args() run_id = _resolve_run_id(args) paths = _resolve_paths(args, run_id) diff --git a/tests/test_plot_agent_round_timeline.py b/tests/test_plot_agent_round_timeline.py new file mode 100644 index 0000000..ff1b02f --- /dev/null +++ b/tests/test_plot_agent_round_timeline.py @@ -0,0 +1,75 @@ +"""Unit tests for scripts.plot_agent_round_timeline.""" + +from scripts.plot_agent_round_timeline import ( + _round_for_time, + _round_table, + _timeline_rows, +) + + +class TestRoundTable: + def test_extracts_and_sorts_rounds(self): + rows = [ + {"event": "decision_round_start", "round": 2, "sim_t_s": 20.0}, + {"event": "ignored", "round": 99, "sim_t_s": 99.0}, + {"event": "decision_round_start", "round": 1, "sim_t_s": 10.0}, + ] + assert _round_table(rows) == [(1, 10.0), (2, 20.0)] + + +class TestRoundForTime: + def test_maps_to_latest_round_not_exceeding_time(self): + rounds = [(1, 10.0), (2, 20.0), (3, 30.0)] + assert _round_for_time(9.0, rounds) == 1 + assert _round_for_time(20.0, rounds) == 2 + assert _round_for_time(29.9, rounds) == 2 + assert _round_for_time(31.0, rounds) == 3 + + +class TestTimelineRows: + def _event_rows(self): + return [ + {"event": "decision_round_start", "round": 1, "sim_t_s": 10.0}, + {"event": "decision_round_start", "round": 2, "sim_t_s": 20.0}, + {"event": "decision_round_start", "round": 3, "sim_t_s": 30.0}, + {"event": "decision_round_start", "round": 4, "sim_t_s": 40.0}, + {"event": "departure_release", "veh_id": "veh_a", "sim_t_s": 20.0}, + {"event": "departure_release", "veh_id": "veh_b", "sim_t_s": 20.0}, + ] + + def test_completed_agent_uses_departure_plus_travel_time(self): + rows, final_round = _timeline_rows( + self._event_rows(), + [{"event": "route_change", "veh_id": "veh_a", "time_s": 30.0}], + {"average_travel_time": {"per_agent": {"veh_a": 15.0}}}, + include_no_departure=False, + ) + by_id = {row["veh_id"]: row for row in rows} + assert final_round == 4 + assert by_id["veh_a"]["start_round"] == 2 + assert by_id["veh_a"]["end_round"] == 3 + assert by_id["veh_a"]["change_rounds"] == [3] + assert by_id["veh_a"]["status"] == "completed" + + def test_incomplete_agent_extends_to_final_round(self): + rows, _ = _timeline_rows( + self._event_rows(), + [], + {"average_travel_time": {"per_agent": {}}}, + include_no_departure=False, + ) + by_id = {row["veh_id"]: row for row in rows} + assert by_id["veh_a"]["end_round"] == 4 + assert by_id["veh_a"]["status"] == "incomplete" + + def test_include_no_departure_uses_first_route_change_round(self): + rows, _ = _timeline_rows( + self._event_rows(), + [{"event": "route_change", "veh_id": "veh_c", "time_s": 30.0}], + {"average_travel_time": {"per_agent": {}}}, + include_no_departure=True, + ) + by_id = {row["veh_id"]: row for row in rows} + assert by_id["veh_c"]["start_round"] == 3 + assert by_id["veh_c"]["end_round"] == 4 + assert by_id["veh_c"]["status"] == "no_departure_event" diff --git a/tests/test_plot_all_run_artifacts.py b/tests/test_plot_all_run_artifacts.py new file mode 100644 index 0000000..b5c0f0b --- /dev/null +++ b/tests/test_plot_all_run_artifacts.py @@ -0,0 +1,72 @@ +"""Unit tests for scripts.plot_all_run_artifacts.""" + +from argparse import Namespace +from pathlib import Path + +from scripts.plot_all_run_artifacts import _resolve_paths, _resolve_run_id + + +class TestResolveRunId: + def test_prefers_explicit_run_id(self): + args = Namespace( + run_id="20260309_030340", + events=None, + metrics=None, + replay=None, + dialogs=None, + ) + assert _resolve_run_id(args) == "20260309_030340" + + def test_extracts_run_id_from_explicit_path(self): + args = Namespace( + run_id=None, + events="outputs/events_20260309_030340.jsonl", + metrics=None, + replay=None, + dialogs=None, + ) + assert _resolve_run_id(args) == "20260309_030340" + + +class TestResolvePaths: + def test_prefers_matching_run_id_files(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + out = Path("outputs") + out.mkdir() + (out / "run_metrics_20260309_030340.json").write_text("{}", encoding="utf-8") + (out / "events_20260309_030340.jsonl").write_text("", encoding="utf-8") + (out / "llm_routes_20260309_030340.jsonl").write_text("", encoding="utf-8") + (out / "llm_routes_20260309_030340.dialogs.csv").write_text( + "step,time_s,veh_id,control_mode,model,system_prompt,user_prompt,response_text,parsed_json,error\n", + encoding="utf-8", + ) + args = Namespace( + metrics=None, + events=None, + replay=None, + dialogs=None, + ) + paths = _resolve_paths(args, "20260309_030340") + assert paths["metrics"] == out / "run_metrics_20260309_030340.json" + assert paths["events"] == out / "events_20260309_030340.jsonl" + assert paths["replay"] == out / "llm_routes_20260309_030340.jsonl" + assert paths["dialogs"] == out / "llm_routes_20260309_030340.dialogs.csv" + + def test_missing_replay_returns_none(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + out = Path("outputs") + out.mkdir() + (out / "run_metrics_20260309_030340.json").write_text("{}", encoding="utf-8") + (out / "events_20260309_030340.jsonl").write_text("", encoding="utf-8") + (out / "llm_routes_20260309_030340.dialogs.csv").write_text( + "step,time_s,veh_id,control_mode,model,system_prompt,user_prompt,response_text,parsed_json,error\n", + encoding="utf-8", + ) + args = Namespace( + metrics=None, + events=None, + replay=None, + dialogs=None, + ) + paths = _resolve_paths(args, "20260309_030340") + assert paths["replay"] is None From 44bbd6800fff38a139a0c20271e44b2b980f8b55 Mon Sep 17 00:00:00 2001 From: legend5teve <2659618982@qq.com> Date: Wed, 11 Mar 2026 12:36:01 -0600 Subject: [PATCH 7/8] chore: update plotting scales according to actual KPI scales --- scripts/plot_run_metrics.py | 85 +++++++++++++++++++++++------ tests/test_plot_run_metrics.py | 97 ++++++++++++++++++++++++++++++++++ 2 files changed, 166 insertions(+), 16 deletions(-) create mode 100644 tests/test_plot_run_metrics.py diff --git a/scripts/plot_run_metrics.py b/scripts/plot_run_metrics.py index d09638e..531e9f8 100644 --- a/scripts/plot_run_metrics.py +++ b/scripts/plot_run_metrics.py @@ -13,6 +13,7 @@ def _parse_args() -> argparse.Namespace: + """Parse CLI arguments for the run-metrics dashboard.""" parser = argparse.ArgumentParser( description="Visualize one run_metrics_*.json file as a 2x2 dashboard." ) @@ -39,6 +40,7 @@ def _parse_args() -> argparse.Namespace: def _draw_or_empty(ax, items: list[tuple[str, float]], title: str, ylabel: str, color: str, *, highest_first: bool = True): + """Draw a bar panel, or a centered placeholder if no rows are available.""" if not items: ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11) ax.set_title(title) @@ -56,49 +58,99 @@ def _draw_or_empty(ax, items: list[tuple[str, float]], title: str, ylabel: str, ax.set_ylabel(ylabel) +def _kpi_specs(metrics: dict) -> list[dict[str, object]]: + """Build the four top-level KPI descriptors used in the dashboard header panel.""" + return [ + { + "title": "Departure variance", + "value": float(metrics.get("departure_time_variability", 0.0)), + "ylabel": "Seconds^2", + "color": "#4C78A8", + "fmt": "{:.3f}", + }, + { + "title": "Route entropy", + "value": float(metrics.get("route_choice_entropy", 0.0)), + "ylabel": "Entropy (nats)", + "color": "#F58518", + "fmt": "{:.3f}", + }, + { + "title": "Hazard exposure", + "value": float(metrics.get("average_hazard_exposure", {}).get("global_average", 0.0)), + "ylabel": "Average risk score", + "color": "#E45756", + "fmt": "{:.3f}", + }, + { + "title": "Avg travel time", + "value": float(metrics.get("average_travel_time", {}).get("average", 0.0)), + "ylabel": "Seconds", + "color": "#54A24B", + "fmt": "{:.2f}", + }, + ] + + +def _plot_kpi_grid(fig, slot, metrics: dict) -> None: + """Render the KPI summary as four mini subplots with independent y scales.""" + kpi_grid = slot.subgridspec(2, 2, wspace=0.35, hspace=0.45) + for idx, spec in enumerate(_kpi_specs(metrics)): + ax = fig.add_subplot(kpi_grid[idx // 2, idx % 2]) + value = float(spec["value"]) + ymax = max(1.0, value * 1.15) if value >= 0.0 else max(1.0, abs(value) * 1.15) + ax.bar([0], [value], color=str(spec["color"]), width=0.5) + ax.set_title(str(spec["title"]), fontsize=10) + ax.set_ylabel(str(spec["ylabel"]), fontsize=9) + ax.set_xticks([]) + ax.set_ylim(min(0.0, value * 1.1), ymax) + ax.grid(axis="y", linestyle=":", alpha=0.35) + label = str(spec["fmt"]).format(value) + text_y = value if value > 0.0 else ymax * 0.04 + va = "bottom" + if value < 0.0: + text_y = value + va = "top" + ax.text(0, text_y, label, ha="center", va=va, fontsize=10) + + def plot_metrics_dashboard(metrics_path: Path, *, out_path: Path, show: bool, top_n: int) -> None: + """Render the run-metrics dashboard and save it to ``out_path``.""" plt = require_matplotlib() metrics = load_json(metrics_path) - - kpis = { - "Departure variance": float(metrics.get("departure_time_variability", 0.0)), - "Route entropy": float(metrics.get("route_choice_entropy", 0.0)), - "Hazard exposure": float(metrics.get("average_hazard_exposure", {}).get("global_average", 0.0)), - "Avg travel time": float(metrics.get("average_travel_time", {}).get("average", 0.0)), - } exposure = metrics.get("average_hazard_exposure", {}).get("per_agent_average", {}) or {} travel = metrics.get("average_travel_time", {}).get("per_agent", {}) or {} instability = metrics.get("decision_instability", {}).get("per_agent_changes", {}) or {} - fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + fig = plt.figure(figsize=(14, 10)) + grid = fig.add_gridspec(2, 2, wspace=0.28, hspace=0.3) fig.suptitle( f"AgentEvac Run Metrics\n{metrics_path.name} | mode={metrics.get('run_mode', 'unknown')} " f"| departed={metrics.get('departed_agents', 0)} | arrived={metrics.get('arrived_agents', 0)}", fontsize=14, ) - axes[0, 0].bar(range(len(kpis)), list(kpis.values()), color=["#4C78A8", "#F58518", "#E45756", "#54A24B"]) - axes[0, 0].set_xticks(range(len(kpis))) - axes[0, 0].set_xticklabels(list(kpis.keys()), rotation=20, ha="right") - axes[0, 0].set_title("Run KPI Summary") - axes[0, 0].set_ylabel("Value") + _plot_kpi_grid(fig, grid[0, 0], metrics) + ax_travel = fig.add_subplot(grid[0, 1]) + ax_exposure = fig.add_subplot(grid[1, 0]) + ax_instability = fig.add_subplot(grid[1, 1]) _draw_or_empty( - axes[0, 1], + ax_travel, top_items(travel, top_n), f"Per-Agent Travel Time (top {top_n})", "Seconds", "#4C78A8", ) _draw_or_empty( - axes[1, 0], + ax_exposure, top_items(exposure, top_n), f"Per-Agent Hazard Exposure (top {top_n})", "Average Risk Score", "#E45756", ) _draw_or_empty( - axes[1, 1], + ax_instability, top_items({k: float(v) for k, v in instability.items()}, top_n), f"Per-Agent Decision Instability (top {top_n})", "Choice Changes", @@ -115,6 +167,7 @@ def plot_metrics_dashboard(metrics_path: Path, *, out_path: Path, show: bool, to def main() -> None: + """CLI entry point for the run-metrics dashboard.""" args = _parse_args() metrics_path = resolve_input(args.metrics, "outputs/run_metrics_*.json") out_path = ensure_output_path(metrics_path, args.out, suffix="dashboard") diff --git a/tests/test_plot_run_metrics.py b/tests/test_plot_run_metrics.py new file mode 100644 index 0000000..0f950e6 --- /dev/null +++ b/tests/test_plot_run_metrics.py @@ -0,0 +1,97 @@ +"""Unit tests for scripts.plot_run_metrics.""" + +from scripts.plot_run_metrics import _kpi_specs, _plot_kpi_grid + + +class TestKpiSpecs: + def test_extracts_expected_values(self): + metrics = { + "departure_time_variability": 59.2975, + "route_choice_entropy": 0.686473, + "average_hazard_exposure": {"global_average": 0.0}, + "average_travel_time": {"average": 599.4855}, + } + specs = _kpi_specs(metrics) + values = {str(item["title"]): float(item["value"]) for item in specs} + assert values["Departure variance"] == 59.2975 + assert values["Route entropy"] == 0.686473 + assert values["Hazard exposure"] == 0.0 + assert values["Avg travel time"] == 599.4855 + + def test_missing_fields_default_to_zero(self): + specs = _kpi_specs({}) + assert all(float(item["value"]) == 0.0 for item in specs) + + +class TestPlotMetricsDashboard: + class _FakeAxis: + def __init__(self): + self.ylabel = None + self.ylim = None + self.title = None + self.text_calls = [] + + def bar(self, *args, **kwargs): + return None + + def set_title(self, value, **kwargs): + self.title = value + + def set_ylabel(self, value, **kwargs): + self.ylabel = value + + def set_xticks(self, *args, **kwargs): + return None + + def set_ylim(self, *args, **kwargs): + self.ylim = args + + def grid(self, *args, **kwargs): + return None + + def text(self, *args, **kwargs): + self.text_calls.append((args, kwargs)) + + class _FakeSubGrid: + def __getitem__(self, key): + return key + + class _FakeSlot: + def subgridspec(self, *args, **kwargs): + return TestPlotMetricsDashboard._FakeSubGrid() + + class _FakeFigure: + def __init__(self): + self.axes = [] + + def add_subplot(self, _slot): + ax = TestPlotMetricsDashboard._FakeAxis() + self.axes.append(ax) + return ax + + def test_plot_kpi_grid_creates_four_separate_panels(self): + metrics = { + "departure_time_variability": 59.2975, + "route_choice_entropy": 0.686473, + "average_hazard_exposure": {"global_average": 0.0}, + "average_travel_time": {"average": 599.4855}, + } + fig = self._FakeFigure() + slot = self._FakeSlot() + + _plot_kpi_grid(fig, slot, metrics) + + assert len(fig.axes) == 4 + assert [ax.title for ax in fig.axes] == [ + "Departure variance", + "Route entropy", + "Hazard exposure", + "Avg travel time", + ] + assert [ax.ylabel for ax in fig.axes] == [ + "Seconds^2", + "Entropy (nats)", + "Average risk score", + "Seconds", + ] + assert all(ax.ylim is not None for ax in fig.axes) From a1f935fce418d717bf8aa8dfbaa5844498119475 Mon Sep 17 00:00:00 2001 From: legend5teve <2659618982@qq.com> Date: Wed, 11 Mar 2026 14:02:46 -0600 Subject: [PATCH 8/8] feat: log run parameters for plotting modules --- agentevac/simulation/main.py | 67 ++++++++++++++++++++ agentevac/utils/run_parameters.py | 79 ++++++++++++++++++++++++ scripts/_plot_common.py | 15 +++++ scripts/plot_agent_communication.py | 55 ++++++++++++++++- scripts/plot_all_run_artifacts.py | 13 +++- scripts/plot_experiment_comparison.py | 38 +++++++++--- scripts/plot_run_metrics.py | 73 ++++++++++++++++++++-- tests/test_plot_agent_communication.py | 26 ++++++++ tests/test_plot_all_run_artifacts.py | 7 +++ tests/test_plot_experiment_comparison.py | 50 +++++++++++++++ tests/test_plot_run_metrics.py | 29 ++++++++- tests/test_run_parameters.py | 48 ++++++++++++++ 12 files changed, 480 insertions(+), 20 deletions(-) create mode 100644 agentevac/utils/run_parameters.py create mode 100644 tests/test_plot_agent_communication.py create mode 100644 tests/test_plot_experiment_comparison.py create mode 100644 tests/test_run_parameters.py diff --git a/agentevac/simulation/main.py b/agentevac/simulation/main.py index 9569b3b..64131aa 100644 --- a/agentevac/simulation/main.py +++ b/agentevac/simulation/main.py @@ -92,6 +92,7 @@ summarize_neighborhood_observation, compute_social_departure_pressure, ) +from agentevac.utils.run_parameters import write_run_parameter_log from agentevac.utils.replay import RouteReplay # ---- OpenAI (LLM control) ---- @@ -249,6 +250,10 @@ def _parse_cli_args() -> argparse.Namespace: "--metrics-log-path", help="Override METRICS_LOG_PATH env var (timestamp is appended).", ) + parser.add_argument( + "--params-log-path", + help="Override PARAMS_LOG_PATH env var (companion run suffix is preserved).", + ) parser.add_argument("--overlay-max-label-chars", type=int, help="Max overlay label characters.") parser.add_argument("--overlay-poi-layer", type=int, help="POI layer for overlays.") parser.add_argument("--overlay-poi-offset-m", type=float, help="POI offset in meters.") @@ -321,6 +326,7 @@ def _float_from_env_or_cli(cli_value: Optional[float], env_key: str, default: fl if CLI_ARGS.metrics is not None: METRICS_ENABLED = (CLI_ARGS.metrics == "on") METRICS_LOG_PATH = CLI_ARGS.metrics_log_path or os.getenv("METRICS_LOG_PATH", "outputs/run_metrics.json") +PARAMS_LOG_PATH = CLI_ARGS.params_log_path or os.getenv("PARAMS_LOG_PATH", "outputs/run_params.json") WEB_DASHBOARD_ENABLED = _parse_bool(os.getenv("WEB_DASHBOARD_ENABLED", "0"), False) if CLI_ARGS.web_dashboard is not None: WEB_DASHBOARD_ENABLED = (CLI_ARGS.web_dashboard == "on") @@ -1311,6 +1317,61 @@ def cleanup(self, active_vehicle_ids: List[str]): } +def _run_parameter_payload() -> Dict[str, Any]: + """Build the persisted run-parameter snapshot used by post-run plotting tools.""" + return { + "run_mode": RUN_MODE, + "scenario": SCENARIO_MODE, + "sumo_binary": SUMO_BINARY, + "messaging_controls": { + "enabled": MESSAGING_ENABLED, + "max_message_chars": MAX_MESSAGE_CHARS, + "max_inbox_messages": MAX_INBOX_MESSAGES, + "max_sends_per_agent_per_round": MAX_SENDS_PER_AGENT_PER_ROUND, + "max_broadcasts_per_round": MAX_BROADCASTS_PER_ROUND, + "ttl_rounds": TTL_ROUNDS, + }, + "driver_briefing_thresholds": { + "margin_very_close_m": MARGIN_VERY_CLOSE_M, + "margin_near_m": MARGIN_NEAR_M, + "margin_buffered_m": MARGIN_BUFFERED_M, + "risk_density_low": RISK_DENSITY_LOW, + "risk_density_medium": RISK_DENSITY_MEDIUM, + "risk_density_high": RISK_DENSITY_HIGH, + "delay_fast_ratio": DELAY_FAST_RATIO, + "delay_moderate_ratio": DELAY_MODERATE_RATIO, + "delay_heavy_ratio": DELAY_HEAVY_RATIO, + "caution_min_margin_m": CAUTION_MIN_MARGIN_M, + "recommended_min_margin_m": RECOMMENDED_MIN_MARGIN_M, + }, + "cognition": { + "info_sigma": INFO_SIGMA, + "info_delay_s": INFO_DELAY_S, + "social_signal_max_messages": SOCIAL_SIGNAL_MAX_MESSAGES, + "theta_trust": DEFAULT_THETA_TRUST, + "belief_inertia": BELIEF_INERTIA, + }, + "departure": { + "theta_r": DEFAULT_THETA_R, + "theta_u": DEFAULT_THETA_U, + "gamma": DEFAULT_GAMMA, + }, + "utility": { + "lambda_e": DEFAULT_LAMBDA_E, + "lambda_t": DEFAULT_LAMBDA_T, + }, + "neighbor_observation": { + "scope": NEIGHBOR_SCOPE, + "window_s": DEFAULT_NEIGHBOR_WINDOW_S, + "social_recent_weight": DEFAULT_SOCIAL_RECENT_WEIGHT, + "social_total_weight": DEFAULT_SOCIAL_TOTAL_WEIGHT, + "social_trigger": DEFAULT_SOCIAL_TRIGGER, + "social_min_danger": DEFAULT_SOCIAL_MIN_DANGER, + "max_system_observations": MAX_SYSTEM_OBSERVATIONS, + }, + } + + # ========================= # Step 4: Define SUMO configuration # ========================= @@ -1330,6 +1391,11 @@ def cleanup(self, active_vehicle_ids: List[str]): replay = RouteReplay(RUN_MODE, REPLAY_LOG_PATH) events = LiveEventStream(EVENTS_ENABLED, EVENTS_LOG_PATH, EVENTS_STDOUT) metrics = RunMetricsCollector(METRICS_ENABLED, METRICS_LOG_PATH, RUN_MODE) +params_log_path = write_run_parameter_log( + PARAMS_LOG_PATH, + _run_parameter_payload(), + reference_path=metrics.path or events.path or replay.path, +) dashboard = WebDashboard( enabled=WEB_DASHBOARD_ENABLED, host=WEB_DASHBOARD_HOST, @@ -1357,6 +1423,7 @@ def cleanup(self, active_vehicle_ids: List[str]): print(f"[EVENTS] enabled={EVENTS_ENABLED} path={events.path} stdout={EVENTS_STDOUT}") if metrics.path: print(f"[METRICS] enabled={METRICS_ENABLED} path={metrics.path}") +print(f"[RUN_PARAMS] path={params_log_path}") print( f"[WEB_DASHBOARD] enabled={dashboard.enabled} host={WEB_DASHBOARD_HOST} " f"port={WEB_DASHBOARD_PORT} max_events={WEB_DASHBOARD_MAX_EVENTS}" diff --git a/agentevac/utils/run_parameters.py b/agentevac/utils/run_parameters.py new file mode 100644 index 0000000..b7801de --- /dev/null +++ b/agentevac/utils/run_parameters.py @@ -0,0 +1,79 @@ +"""Helpers for recording and locating per-run parameter snapshots.""" + +from __future__ import annotations + +import json +import time +from pathlib import Path +from typing import Any, Mapping, Optional + +_REFERENCE_PREFIXES = ( + "run_params_", + "run_metrics_", + "metrics_", + "events_", + "llm_routes_", + "routes_", +) + + +def reference_suffix(reference_path: str | Path) -> str: + """Return the variable suffix portion of a run artifact filename. + + Examples: + ``run_metrics_20260311_012202.json`` -> ``20260311_012202`` + ``metrics_sigma-40_20260311_012202.json`` -> ``sigma-40_20260311_012202`` + """ + stem = Path(reference_path).stem + for prefix in _REFERENCE_PREFIXES: + if stem.startswith(prefix): + suffix = stem[len(prefix):] + if suffix: + return suffix + return stem + + +def build_parameter_log_path(base_path: str, *, reference_path: Optional[str | Path] = None) -> str: + """Build a parameter-log path, preserving a companion artifact suffix when possible.""" + base = Path(base_path) + ext = base.suffix or ".json" + stem = base.stem if base.suffix else base.name + + if reference_path: + suffix = reference_suffix(reference_path) + candidate = base.with_name(f"{stem}_{suffix}{ext}") + idx = 1 + while candidate.exists(): + candidate = base.with_name(f"{stem}_{suffix}_{idx:02d}{ext}") + idx += 1 + return str(candidate) + + ts = time.strftime("%Y%m%d_%H%M%S") + candidate = base.with_name(f"{stem}_{ts}{ext}") + idx = 1 + while candidate.exists(): + candidate = base.with_name(f"{stem}_{ts}_{idx:02d}{ext}") + idx += 1 + return str(candidate) + + +def write_run_parameter_log( + base_path: str, + payload: Mapping[str, Any], + *, + reference_path: Optional[str | Path] = None, +) -> str: + """Write one JSON parameter snapshot to disk and return its path.""" + target = Path(build_parameter_log_path(base_path, reference_path=reference_path)) + target.parent.mkdir(parents=True, exist_ok=True) + with target.open("w", encoding="utf-8") as fh: + json.dump(dict(payload), fh, ensure_ascii=False, indent=2, sort_keys=True) + fh.write("\n") + return str(target) + + +def companion_parameter_path(reference_path: str | Path, *, base_name: str = "run_params") -> Path: + """Derive the expected companion parameter-log path for a run artifact.""" + ref = Path(reference_path) + suffix = reference_suffix(ref) + return ref.with_name(f"{base_name}_{suffix}.json") diff --git a/scripts/_plot_common.py b/scripts/_plot_common.py index 9f837ae..df2a2f4 100644 --- a/scripts/_plot_common.py +++ b/scripts/_plot_common.py @@ -7,6 +7,8 @@ from pathlib import Path from typing import Any, Iterable, List +from agentevac.utils.run_parameters import companion_parameter_path + def newest_file(pattern: str) -> Path: """Return the newest file matching ``pattern``. @@ -30,6 +32,19 @@ def resolve_input(path_arg: str | None, pattern: str) -> Path: return newest_file(pattern) +def resolve_optional_run_params(path_arg: str | None, reference_path: Path | None) -> Path | None: + """Resolve an explicit or companion run-parameter log path if available.""" + if path_arg: + path = Path(path_arg) + if not path.exists(): + raise FileNotFoundError(f"Input file does not exist: {path}") + return path + if reference_path is None: + return None + candidate = companion_parameter_path(reference_path) + return candidate if candidate.exists() else None + + def load_json(path: Path) -> Any: """Load a JSON document from ``path``.""" with path.open("r", encoding="utf-8") as fh: diff --git a/scripts/plot_agent_communication.py b/scripts/plot_agent_communication.py index 6474639..1932cf1 100644 --- a/scripts/plot_agent_communication.py +++ b/scripts/plot_agent_communication.py @@ -9,9 +9,25 @@ from typing import Any try: - from scripts._plot_common import ensure_output_path, load_jsonl, require_matplotlib, resolve_input, top_items + from scripts._plot_common import ( + ensure_output_path, + load_json, + load_jsonl, + require_matplotlib, + resolve_input, + resolve_optional_run_params, + top_items, + ) except ModuleNotFoundError: - from _plot_common import ensure_output_path, load_jsonl, require_matplotlib, resolve_input, top_items + from _plot_common import ( + ensure_output_path, + load_json, + load_jsonl, + require_matplotlib, + resolve_input, + resolve_optional_run_params, + top_items, + ) def _parse_args() -> argparse.Namespace: @@ -26,6 +42,10 @@ def _parse_args() -> argparse.Namespace: "--dialogs", help="Path to a *.dialogs.csv file. Defaults to the newest outputs/*.dialogs.csv.", ) + parser.add_argument( + "--params", + help="Optional companion run_params JSON path. Defaults to the matching run_params_.json when present.", + ) parser.add_argument( "--out", help="Output PNG path. Defaults to .communication.png.", @@ -156,6 +176,24 @@ def _plot_dialog_modes(ax, dialog_rows: list[dict[str, str]]) -> None: ax2.set_ylabel("Average Response Length (chars)") +def _messaging_summary(params: dict | None) -> str | None: + """Format messaging anti-bloat controls for the dashboard footer.""" + if not params: + return None + messaging = params.get("messaging_controls") or {} + if not messaging: + return None + return ( + "Messaging controls: " + f"enabled={messaging.get('enabled', '?')} " + f"max_chars={messaging.get('max_message_chars', '?')} " + f"max_inbox={messaging.get('max_inbox_messages', '?')} " + f"max_sends={messaging.get('max_sends_per_agent_per_round', '?')} " + f"max_broadcasts={messaging.get('max_broadcasts_per_round', '?')} " + f"ttl_rounds={messaging.get('ttl_rounds', '?')}" + ) + + def plot_agent_communication( *, events_path: Path, @@ -163,10 +201,12 @@ def plot_agent_communication( out_path: Path, show: bool, top_n: int, + params_path: Path | None = None, ) -> None: plt = require_matplotlib() event_rows = load_jsonl(events_path) dialog_rows = _load_dialog_rows(dialogs_path) + params = load_json(params_path) if params_path else None sender_counts: dict[str, int] = {} recipient_counts: dict[str, int] = {} @@ -202,10 +242,17 @@ def plot_agent_communication( _plot_round_series(axes[1, 0], event_rows) _plot_dialog_modes(axes[1, 1], dialog_rows) - fig.tight_layout(rect=(0, 0, 1, 0.95)) + footer = _messaging_summary(params) + rect_bottom = 0.04 if footer else 0.0 + if footer: + fig.text(0.02, 0.012, footer, ha="left", va="bottom", fontsize=8) + + fig.tight_layout(rect=(0, rect_bottom, 1, 0.95)) fig.savefig(out_path, dpi=160, bbox_inches="tight") print(f"[PLOT] events={events_path}") print(f"[PLOT] dialogs={dialogs_path}") + if params_path: + print(f"[PLOT] params={params_path}") print(f"[PLOT] output={out_path}") if show: plt.show() @@ -216,6 +263,7 @@ def main() -> None: args = _parse_args() events_path = resolve_input(args.events, "outputs/events_*.jsonl") dialogs_path = resolve_input(args.dialogs, "outputs/*.dialogs.csv") + params_path = resolve_optional_run_params(args.params, events_path) out_path = ensure_output_path(events_path, args.out, suffix="communication") plot_agent_communication( events_path=events_path, @@ -223,6 +271,7 @@ def main() -> None: out_path=out_path, show=args.show, top_n=args.top_n, + params_path=params_path, ) diff --git a/scripts/plot_all_run_artifacts.py b/scripts/plot_all_run_artifacts.py index e6003ef..f9da937 100644 --- a/scripts/plot_all_run_artifacts.py +++ b/scripts/plot_all_run_artifacts.py @@ -33,6 +33,7 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--events", help="Explicit events JSONL path.") parser.add_argument("--replay", help="Explicit llm_routes JSONL path.") parser.add_argument("--dialogs", help="Explicit dialogs CSV path.") + parser.add_argument("--params", help="Explicit run_params JSON path.") parser.add_argument( "--results-json", help="Optional experiment_results.json to also generate the multi-run comparison figure.", @@ -61,7 +62,7 @@ def _resolve_run_id(args: argparse.Namespace) -> str: """Resolve the run ID from CLI args or the newest events file.""" if args.run_id: return str(args.run_id) - for path_arg in (args.events, args.metrics, args.replay, args.dialogs): + for path_arg in (args.events, args.metrics, args.replay, args.dialogs, args.params): if path_arg: match = re.search(r"(\d{8}_\d{6})", Path(path_arg).name) if match: @@ -77,6 +78,7 @@ def _resolve_paths(args: argparse.Namespace, run_id: str) -> dict[str, Path | No events = _maybe_path(args.events) replay = _maybe_path(args.replay) dialogs = _maybe_path(args.dialogs) + params = _maybe_path(args.params) if metrics is None: candidate = Path(f"outputs/run_metrics_{run_id}.json") @@ -90,12 +92,16 @@ def _resolve_paths(args: argparse.Namespace, run_id: str) -> dict[str, Path | No if dialogs is None: candidate = Path(f"outputs/llm_routes_{run_id}.dialogs.csv") dialogs = candidate if candidate.exists() else newest_file("outputs/*.dialogs.csv") + if params is None: + candidate = Path(f"outputs/run_params_{run_id}.json") + params = candidate if candidate.exists() else None return { "metrics": metrics, "events": events, "replay": replay, "dialogs": dialogs, + "params": params, } @@ -112,6 +118,7 @@ def main() -> None: events_path = paths["events"] replay_path = paths["replay"] dialogs_path = paths["dialogs"] + params_path = paths["params"] assert metrics_path is not None assert events_path is not None assert dialogs_path is not None @@ -121,6 +128,7 @@ def main() -> None: out_path=out_dir / "run_metrics.dashboard.png", show=args.show, top_n=args.top_n, + params_path=params_path, ) plot_timeline( events_path, @@ -135,6 +143,7 @@ def main() -> None: out_path=out_dir / "agent_communication.png", show=args.show, top_n=args.top_n, + params_path=params_path, ) if replay_path is not None: plot_agent_round_timeline( @@ -175,6 +184,8 @@ def main() -> None: if replay_path: print(f"[PLOT] replay={replay_path}") print(f"[PLOT] dialogs={dialogs_path}") + if params_path: + print(f"[PLOT] params={params_path}") if comparison_source: print(f"[PLOT] comparison_source={comparison_source}") diff --git a/scripts/plot_experiment_comparison.py b/scripts/plot_experiment_comparison.py index e63ba50..c66f795 100644 --- a/scripts/plot_experiment_comparison.py +++ b/scripts/plot_experiment_comparison.py @@ -8,9 +8,9 @@ from typing import Any try: - from scripts._plot_common import ensure_output_path, load_json, require_matplotlib + from scripts._plot_common import ensure_output_path, load_json, require_matplotlib, resolve_optional_run_params except ModuleNotFoundError: - from _plot_common import ensure_output_path, load_json, require_matplotlib + from _plot_common import ensure_output_path, load_json, require_matplotlib, resolve_optional_run_params def _parse_args() -> argparse.Namespace: @@ -57,6 +57,22 @@ def _metrics_row(metrics: dict[str, Any]) -> dict[str, float]: } +def _param_metadata(path: Path) -> dict[str, Any]: + """Load companion run parameters for plots that only have KPI JSON files.""" + params_path = resolve_optional_run_params(None, path) + if params_path is None: + return {} + payload = load_json(params_path) + cognition = payload.get("cognition") or {} + return { + "scenario": str(payload.get("scenario", "unknown")), + "info_sigma": _safe_float(cognition.get("info_sigma")), + "info_delay_s": _safe_float(cognition.get("info_delay_s")), + "theta_trust": _safe_float(cognition.get("theta_trust")), + "params_path": str(params_path), + } + + def load_cases(results_json: Path | None, metrics_glob: str) -> tuple[list[dict[str, Any]], Path]: rows: list[dict[str, Any]] = [] if results_json is not None: @@ -72,12 +88,13 @@ def load_cases(results_json: Path | None, metrics_glob: str) -> tuple[list[dict[ continue metrics = load_json(path) case = item.get("case") or {} + params_meta = _param_metadata(path) row = { "label": str(item.get("case_id") or path.stem), - "scenario": str(case.get("scenario", "unknown")), - "info_sigma": _safe_float(case.get("info_sigma")), - "info_delay_s": _safe_float(case.get("info_delay_s")), - "theta_trust": _safe_float(case.get("theta_trust")), + "scenario": str(case.get("scenario", params_meta.get("scenario", "unknown"))), + "info_sigma": _safe_float(case.get("info_sigma", params_meta.get("info_sigma"))), + "info_delay_s": _safe_float(case.get("info_delay_s", params_meta.get("info_delay_s"))), + "theta_trust": _safe_float(case.get("theta_trust", params_meta.get("theta_trust"))), "metrics_path": str(path), } row.update(_metrics_row(metrics)) @@ -89,12 +106,13 @@ def load_cases(results_json: Path | None, metrics_glob: str) -> tuple[list[dict[ raise SystemExit(f"No metrics files match pattern: {metrics_glob}") for path in matches: metrics = load_json(path) + params_meta = _param_metadata(path) row = { "label": path.stem, - "scenario": "unknown", - "info_sigma": 0.0, - "info_delay_s": 0.0, - "theta_trust": 0.0, + "scenario": str(params_meta.get("scenario", "unknown")), + "info_sigma": _safe_float(params_meta.get("info_sigma")), + "info_delay_s": _safe_float(params_meta.get("info_delay_s")), + "theta_trust": _safe_float(params_meta.get("theta_trust")), "metrics_path": str(path), } row.update(_metrics_row(metrics)) diff --git a/scripts/plot_run_metrics.py b/scripts/plot_run_metrics.py index 531e9f8..8982bdb 100644 --- a/scripts/plot_run_metrics.py +++ b/scripts/plot_run_metrics.py @@ -7,9 +7,23 @@ from pathlib import Path try: - from scripts._plot_common import ensure_output_path, load_json, require_matplotlib, resolve_input, top_items + from scripts._plot_common import ( + ensure_output_path, + load_json, + require_matplotlib, + resolve_input, + resolve_optional_run_params, + top_items, + ) except ModuleNotFoundError: - from _plot_common import ensure_output_path, load_json, require_matplotlib, resolve_input, top_items + from _plot_common import ( + ensure_output_path, + load_json, + require_matplotlib, + resolve_input, + resolve_optional_run_params, + top_items, + ) def _parse_args() -> argparse.Namespace: @@ -21,6 +35,10 @@ def _parse_args() -> argparse.Namespace: "--metrics", help="Path to a metrics JSON file. Defaults to the newest outputs/run_metrics_*.json.", ) + parser.add_argument( + "--params", + help="Optional companion run_params JSON path. Defaults to the matching run_params_.json when present.", + ) parser.add_argument( "--out", help="Output PNG path. Defaults to .dashboard.png.", @@ -114,10 +132,41 @@ def _plot_kpi_grid(fig, slot, metrics: dict) -> None: ax.text(0, text_y, label, ha="center", va=va, fontsize=10) -def plot_metrics_dashboard(metrics_path: Path, *, out_path: Path, show: bool, top_n: int) -> None: +def _briefing_summary(params: dict | None) -> str | None: + """Format driver-briefing thresholds for the dashboard footer.""" + if not params: + return None + briefing = params.get("driver_briefing_thresholds") or {} + if not briefing: + return None + return ( + "Briefing thresholds: " + f"margin_m={briefing.get('margin_very_close_m', '?')}/" + f"{briefing.get('margin_near_m', '?')}/" + f"{briefing.get('margin_buffered_m', '?')} " + f"risk_density={briefing.get('risk_density_low', '?')}/" + f"{briefing.get('risk_density_medium', '?')}/" + f"{briefing.get('risk_density_high', '?')} " + f"delay_ratio={briefing.get('delay_fast_ratio', '?')}/" + f"{briefing.get('delay_moderate_ratio', '?')}/" + f"{briefing.get('delay_heavy_ratio', '?')} " + f"advisory_margin_m={briefing.get('caution_min_margin_m', '?')}/" + f"{briefing.get('recommended_min_margin_m', '?')}" + ) + + +def plot_metrics_dashboard( + metrics_path: Path, + *, + out_path: Path, + show: bool, + top_n: int, + params_path: Path | None = None, +) -> None: """Render the run-metrics dashboard and save it to ``out_path``.""" plt = require_matplotlib() metrics = load_json(metrics_path) + params = load_json(params_path) if params_path else None exposure = metrics.get("average_hazard_exposure", {}).get("per_agent_average", {}) or {} travel = metrics.get("average_travel_time", {}).get("per_agent", {}) or {} instability = metrics.get("decision_instability", {}).get("per_agent_changes", {}) or {} @@ -157,9 +206,16 @@ def plot_metrics_dashboard(metrics_path: Path, *, out_path: Path, show: bool, to "#72B7B2", ) - fig.tight_layout(rect=(0, 0, 1, 0.95)) + footer = _briefing_summary(params) + rect_bottom = 0.04 if footer else 0.0 + if footer: + fig.text(0.02, 0.012, footer, ha="left", va="bottom", fontsize=8) + + fig.tight_layout(rect=(0, rect_bottom, 1, 0.95)) fig.savefig(out_path, dpi=160, bbox_inches="tight") print(f"[PLOT] metrics={metrics_path}") + if params_path: + print(f"[PLOT] params={params_path}") print(f"[PLOT] output={out_path}") if show: plt.show() @@ -170,8 +226,15 @@ def main() -> None: """CLI entry point for the run-metrics dashboard.""" args = _parse_args() metrics_path = resolve_input(args.metrics, "outputs/run_metrics_*.json") + params_path = resolve_optional_run_params(args.params, metrics_path) out_path = ensure_output_path(metrics_path, args.out, suffix="dashboard") - plot_metrics_dashboard(metrics_path, out_path=out_path, show=args.show, top_n=args.top_n) + plot_metrics_dashboard( + metrics_path, + out_path=out_path, + show=args.show, + top_n=args.top_n, + params_path=params_path, + ) if __name__ == "__main__": diff --git a/tests/test_plot_agent_communication.py b/tests/test_plot_agent_communication.py new file mode 100644 index 0000000..4716545 --- /dev/null +++ b/tests/test_plot_agent_communication.py @@ -0,0 +1,26 @@ +"""Unit tests for scripts.plot_agent_communication.""" + +from scripts.plot_agent_communication import _messaging_summary + + +class TestMessagingSummary: + def test_formats_messaging_controls(self): + summary = _messaging_summary( + { + "messaging_controls": { + "enabled": True, + "max_message_chars": 400, + "max_inbox_messages": 20, + "max_sends_per_agent_per_round": 3, + "max_broadcasts_per_round": 20, + "ttl_rounds": 10, + } + } + ) + assert summary is not None + assert "Messaging controls:" in summary + assert "max_chars=400" in summary + assert "ttl_rounds=10" in summary + + def test_returns_none_without_messaging_payload(self): + assert _messaging_summary({}) is None diff --git a/tests/test_plot_all_run_artifacts.py b/tests/test_plot_all_run_artifacts.py index b5c0f0b..70085f4 100644 --- a/tests/test_plot_all_run_artifacts.py +++ b/tests/test_plot_all_run_artifacts.py @@ -14,6 +14,7 @@ def test_prefers_explicit_run_id(self): metrics=None, replay=None, dialogs=None, + params=None, ) assert _resolve_run_id(args) == "20260309_030340" @@ -24,6 +25,7 @@ def test_extracts_run_id_from_explicit_path(self): metrics=None, replay=None, dialogs=None, + params=None, ) assert _resolve_run_id(args) == "20260309_030340" @@ -40,17 +42,20 @@ def test_prefers_matching_run_id_files(self, tmp_path, monkeypatch): "step,time_s,veh_id,control_mode,model,system_prompt,user_prompt,response_text,parsed_json,error\n", encoding="utf-8", ) + (out / "run_params_20260309_030340.json").write_text("{}", encoding="utf-8") args = Namespace( metrics=None, events=None, replay=None, dialogs=None, + params=None, ) paths = _resolve_paths(args, "20260309_030340") assert paths["metrics"] == out / "run_metrics_20260309_030340.json" assert paths["events"] == out / "events_20260309_030340.jsonl" assert paths["replay"] == out / "llm_routes_20260309_030340.jsonl" assert paths["dialogs"] == out / "llm_routes_20260309_030340.dialogs.csv" + assert paths["params"] == out / "run_params_20260309_030340.json" def test_missing_replay_returns_none(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) @@ -67,6 +72,8 @@ def test_missing_replay_returns_none(self, tmp_path, monkeypatch): events=None, replay=None, dialogs=None, + params=None, ) paths = _resolve_paths(args, "20260309_030340") assert paths["replay"] is None + assert paths["params"] is None diff --git a/tests/test_plot_experiment_comparison.py b/tests/test_plot_experiment_comparison.py new file mode 100644 index 0000000..04e1dcc --- /dev/null +++ b/tests/test_plot_experiment_comparison.py @@ -0,0 +1,50 @@ +"""Unit tests for scripts.plot_experiment_comparison.""" + +import json +from pathlib import Path + +from scripts.plot_experiment_comparison import load_cases + + +class TestLoadCases: + def test_metrics_glob_uses_companion_run_params(self, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + out = Path("outputs") + out.mkdir() + metrics_path = out / "run_metrics_20260311_012202.json" + params_path = out / "run_params_20260311_012202.json" + metrics_path.write_text( + json.dumps( + { + "departure_time_variability": 12.0, + "route_choice_entropy": 0.5, + "average_hazard_exposure": {"global_average": 0.1}, + "average_travel_time": {"average": 42.0}, + "arrived_agents": 3, + "departed_agents": 4, + } + ), + encoding="utf-8", + ) + params_path.write_text( + json.dumps( + { + "scenario": "alert_guided", + "cognition": { + "info_sigma": 40.0, + "info_delay_s": 5.0, + "theta_trust": 0.7, + }, + } + ), + encoding="utf-8", + ) + + rows, source_path = load_cases(None, "outputs/run_metrics_*.json") + + assert source_path == metrics_path + assert len(rows) == 1 + assert rows[0]["scenario"] == "alert_guided" + assert rows[0]["info_sigma"] == 40.0 + assert rows[0]["info_delay_s"] == 5.0 + assert rows[0]["theta_trust"] == 0.7 diff --git a/tests/test_plot_run_metrics.py b/tests/test_plot_run_metrics.py index 0f950e6..fb5e21b 100644 --- a/tests/test_plot_run_metrics.py +++ b/tests/test_plot_run_metrics.py @@ -1,6 +1,6 @@ """Unit tests for scripts.plot_run_metrics.""" -from scripts.plot_run_metrics import _kpi_specs, _plot_kpi_grid +from scripts.plot_run_metrics import _briefing_summary, _kpi_specs, _plot_kpi_grid class TestKpiSpecs: @@ -95,3 +95,30 @@ def test_plot_kpi_grid_creates_four_separate_panels(self): "Seconds", ] assert all(ax.ylim is not None for ax in fig.axes) + + +class TestBriefingSummary: + def test_formats_driver_briefing_thresholds(self): + summary = _briefing_summary( + { + "driver_briefing_thresholds": { + "margin_very_close_m": 100.0, + "margin_near_m": 300.0, + "margin_buffered_m": 700.0, + "risk_density_low": 0.12, + "risk_density_medium": 0.35, + "risk_density_high": 0.70, + "delay_fast_ratio": 1.1, + "delay_moderate_ratio": 1.3, + "delay_heavy_ratio": 1.6, + "caution_min_margin_m": 100.0, + "recommended_min_margin_m": 300.0, + } + } + ) + assert summary is not None + assert "Briefing thresholds:" in summary + assert "margin_m=100.0/300.0/700.0" in summary + + def test_returns_none_without_briefing_payload(self): + assert _briefing_summary({}) is None diff --git a/tests/test_run_parameters.py b/tests/test_run_parameters.py new file mode 100644 index 0000000..bdac106 --- /dev/null +++ b/tests/test_run_parameters.py @@ -0,0 +1,48 @@ +"""Unit tests for agentevac.utils.run_parameters.""" + +from pathlib import Path + +from agentevac.utils.run_parameters import ( + build_parameter_log_path, + companion_parameter_path, + reference_suffix, + write_run_parameter_log, +) + + +class TestReferenceSuffix: + def test_strips_known_metric_prefix(self): + assert reference_suffix("outputs/run_metrics_20260311_012202.json") == "20260311_012202" + + def test_preserves_case_id_prefixes(self): + assert ( + reference_suffix("outputs/experiments/metrics_sigma-40_delay-0_20260311_012202.json") + == "sigma-40_delay-0_20260311_012202" + ) + + +class TestBuildParameterLogPath: + def test_uses_reference_suffix_for_companion_names(self, tmp_path): + path = build_parameter_log_path( + str(tmp_path / "run_params.json"), + reference_path=tmp_path / "run_metrics_20260311_012202.json", + ) + assert Path(path).name == "run_params_20260311_012202.json" + + +class TestWriteRunParameterLog: + def test_writes_json_using_reference_suffix(self, tmp_path): + target = write_run_parameter_log( + str(tmp_path / "run_params.json"), + {"scenario": "advice_guided"}, + reference_path=tmp_path / "events_20260311_012202.jsonl", + ) + path = Path(target) + assert path.name == "run_params_20260311_012202.json" + assert path.read_text(encoding="utf-8").strip().startswith("{") + + +class TestCompanionParameterPath: + def test_matches_metrics_artifact_suffix(self): + candidate = companion_parameter_path(Path("outputs/run_metrics_20260311_012202.json")) + assert candidate == Path("outputs/run_params_20260311_012202.json")