diff --git a/agentevac/agents/belief_model.py b/agentevac/agents/belief_model.py index dec45c1..47aa931 100644 --- a/agentevac/agents/belief_model.py +++ b/agentevac/agents/belief_model.py @@ -68,11 +68,11 @@ def categorize_hazard_state(signal: Dict[str, Any]) -> Dict[str, float]: If neither is present (e.g., no fire active), returns a uniform prior. Threshold rationale: - - ≤ 0 m : fire has reached / overtaken the edge → near-certain danger. - - ≤ 100 m : fire front is on the street block → high danger. - - ≤ 300 m : fire within roughly two city blocks → risky (watch closely). - - ≤ 700 m : fire is a few blocks away → elevated but manageable. - - > 700 m : fire is well clear of the route → predominantly safe. + - ≤ 0 m : fire has reached / overtaken the edge → near-certain danger. + - ≤ 1200 m : within ember-attack range → high danger. + - ≤ 2500 m : smoke / radiant-heat proximity → risky (watch closely). + - ≤ 5000 m : fire visible but with buffer → elevated but manageable. + - > 5000 m : fire is well clear of the route → predominantly safe. Args: signal: Environment signal dict, typically from ``information_model.sample_environment_signal``. @@ -89,11 +89,11 @@ def categorize_hazard_state(signal: Dict[str, Any]) -> Dict[str, float]: margin_f = float(margin) if margin_f <= 0.0: return {"p_safe": 0.02, "p_risky": 0.08, "p_danger": 0.90} - if margin_f <= 100.0: + if margin_f <= 1200.0: return {"p_safe": 0.05, "p_risky": 0.20, "p_danger": 0.75} - if margin_f <= 300.0: + if margin_f <= 2500.0: return {"p_safe": 0.15, "p_risky": 0.55, "p_danger": 0.30} - if margin_f <= 700.0: + if margin_f <= 5000.0: return {"p_safe": 0.35, "p_risky": 0.50, "p_danger": 0.15} return {"p_safe": 0.75, "p_risky": 0.20, "p_danger": 0.05} diff --git a/agentevac/agents/information_model.py b/agentevac/agents/information_model.py index fbf4421..7ac575c 100644 --- a/agentevac/agents/information_model.py +++ b/agentevac/agents/information_model.py @@ -29,13 +29,13 @@ def _state_from_margin(margin_m: Optional[float]) -> str: margin_m: Distance in metres from the nearest fire edge; ``None`` if unknown. Returns: - One of "danger" (≤ 100 m), "risky" (≤ 300 m), "safe" (> 300 m), or "unknown". + One of "danger" (≤ 1200 m), "risky" (≤ 2500 m), "safe" (> 2500 m), or "unknown". """ if margin_m is None: return "unknown" - if margin_m <= 100.0: + if margin_m <= 1200.0: return "danger" - if margin_m <= 300.0: + if margin_m <= 2500.0: return "risky" return "safe" diff --git a/agentevac/agents/routing_utility.py b/agentevac/agents/routing_utility.py index 1f581f9..04c8f44 100644 --- a/agentevac/agents/routing_utility.py +++ b/agentevac/agents/routing_utility.py @@ -63,12 +63,12 @@ def _effective_margin_penalty(min_margin_m: Any) -> float: model uncertainty: even routes with no fire currently nearby may become risky. Thresholds: - - ∞ (no fire detected) → 0.25 (nominal baseline) - - ≤ 0 m (inside fire) → 5.0 (highest risk) - - ≤ 100 m (very close) → 3.0 - - ≤ 300 m (near) → 1.5 - - ≤ 700 m (buffered) → 0.6 - - > 700 m (clear) → 0.15 (lowest non-zero risk) + - ∞ (no fire detected) → 0.25 (nominal baseline) + - ≤ 0 m (inside fire) → 5.0 (highest risk) + - ≤ 1200 m (very close) → 3.0 + - ≤ 2500 m (near) → 1.5 + - ≤ 5000 m (buffered) → 0.6 + - > 5000 m (clear) → 0.15 (lowest non-zero risk) Args: min_margin_m: Minimum fire margin in metres along the route; may be ``None`` @@ -82,11 +82,11 @@ def _effective_margin_penalty(min_margin_m: Any) -> float: return 0.25 if margin <= 0.0: return 5.0 - if margin <= 100.0: + if margin <= 1200.0: return 3.0 - if margin <= 300.0: + if margin <= 2500.0: return 1.5 - if margin <= 700.0: + if margin <= 5000.0: return 0.6 return 0.15 @@ -123,16 +123,26 @@ def _observation_based_exposure( Used in the ``no_notice`` scenario where agents have only their own noisy observation of the current edge. Without per-route fire metrics (risk_sum, blocked_edges, min_margin_m), exposure is derived from the agent's general - belief state scaled by route length: + belief state scaled by estimated travel duration: hazard_level = 0.3 * p_risky + 0.7 * p_danger + 0.4 * perceived_risk - length_factor = len_edges * 0.15 + length_factor = travel_time_minutes * 0.3 (or len_edges * 0.15 fallback) exposure = hazard_level * length_factor + uncertainty_penalty Longer routes are penalised more because a longer route means more time - spent driving through a potentially hazardous environment. The coefficients - prioritise ``p_danger`` (0.7) over ``p_risky`` (0.3) to maintain consistency - with the severity weighting in ``_expected_exposure``. + spent driving through a potentially hazardous environment. Travel time + (from SUMO ``findRoute``) is preferred over edge count because edge + lengths vary widely; a 2 km highway segment should count more than a + 50 m residential street. The coefficients prioritise ``p_danger`` (0.7) + over ``p_risky`` (0.3) to maintain consistency with the severity + weighting in ``_expected_exposure``. + + When ``visual_blocked_edges`` or ``visual_min_margin_m`` keys are present on + the menu item, a **visual fire observation penalty** is added. This models a + no-notice agent who can see fire on the first few edges ahead of their current + position. The penalty uses the same weights as ``_expected_exposure`` + (``blocked_edges * 8.0`` + margin lookup) so that a visually blocked route + gets a large score increase, prompting the agent to switch shelters. Args: menu_item: A destination or route dict. @@ -148,14 +158,40 @@ def _observation_based_exposure( confidence = _num(psychology.get("confidence"), 0.0) hazard_level = 0.3 * p_risky + 0.7 * p_danger + 0.4 * perceived_risk - len_edges = _num( - menu_item.get("len_edges", menu_item.get("len_edges_fastest_path")), - 1.0, - ) - length_factor = len_edges * 0.15 + travel_time_s = menu_item.get("travel_time_s_fastest_path") + if travel_time_s is not None: + length_factor = _num(travel_time_s, 60.0) / 60.0 * 0.3 + else: + len_edges = _num( + menu_item.get("len_edges", menu_item.get("len_edges_fastest_path")), + 1.0, + ) + length_factor = len_edges * 0.15 uncertainty_penalty = max(0.0, 1.0 - confidence) * 0.75 - return hazard_level * length_factor + uncertainty_penalty + # Visual fire observation penalty: present only for the agent's current + # destination when fire is detected on the first few route-head edges. + visual_penalty = 0.0 + if "visual_blocked_edges" in menu_item: + visual_blocked = _num(menu_item.get("visual_blocked_edges"), 0.0) + visual_min_margin = menu_item.get("visual_min_margin_m") + visual_penalty = visual_blocked * 8.0 + if visual_min_margin is not None: + visual_penalty += _effective_margin_penalty(visual_min_margin) + + # Proximity fire perception penalty: present on ALL reachable destinations + # when the agent is within perception range of a fire. Uses the same + # weights as _expected_exposure (blocked * 8.0 + margin_penalty) so that + # routes passing near visible fires are strongly deprioritised. + proximity_penalty = 0.0 + if "proximity_blocked_edges" in menu_item: + prox_blocked = _num(menu_item.get("proximity_blocked_edges"), 0.0) + prox_min_margin = menu_item.get("proximity_min_margin_m") + proximity_penalty = prox_blocked * 8.0 + if prox_min_margin is not None: + proximity_penalty += _effective_margin_penalty(prox_min_margin) + + return hazard_level * length_factor + uncertainty_penalty + visual_penalty + proximity_penalty def _expected_exposure( diff --git a/agentevac/agents/scenarios.py b/agentevac/agents/scenarios.py index a92cc62..6f0b0b1 100644 --- a/agentevac/agents/scenarios.py +++ b/agentevac/agents/scenarios.py @@ -194,6 +194,8 @@ def filter_menu_for_scenario( for item in menu: out = dict(item) + # Strip internal fields that should never reach the LLM prompt. + out.pop("_fastest_path_edges", None) if not cfg["official_route_guidance_visible"]: # Remove advisory labels and authority source produced by the operator briefing logic. out.pop("advisory", None) @@ -210,6 +212,12 @@ def filter_menu_for_scenario( "idx", "name", "dest_edge", "reachable", "note", "travel_time_s_fastest_path", "len_edges_fastest_path", "expected_utility", "utility_components", + # Visual fire observation fields (agent can see fire on + # the first few edges of their current route). + "visual_blocked_edges", "visual_min_margin_m", + # Proximity fire perception fields (agent is close enough + # to a fire to assess its impact on each candidate route). + "proximity_blocked_edges", "proximity_min_margin_m", } else: keep_keys = { @@ -238,11 +246,10 @@ def scenario_prompt_suffix(mode: str) -> str: cfg = load_scenario_config(mode) if cfg["mode"] == "no_notice": return ( - "This is a no-notice wildfire scenario: do not assume official route instructions exist. " - "Rely mainly on your_observation, 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." + "This is a no-notice wildfire scenario: no official warnings or route instructions exist yet. " + "Do NOT invent official instructions. " + "Base decisions on your_observation.environment_signal, inbox messages, " + "and neighborhood_observation. Choose conservative actions if uncertain." ) if cfg["mode"] == "alert_guided": return ( diff --git a/agentevac/analysis/experiments.py b/agentevac/analysis/experiments.py index 3ab4c4a..4021306 100644 --- a/agentevac/analysis/experiments.py +++ b/agentevac/analysis/experiments.py @@ -165,6 +165,7 @@ def run_experiment_case( sumo_binary: str = "sumo", run_mode: str = "record", timeout_s: Optional[float] = None, + sumo_seed: Optional[int] = None, ) -> Dict[str, Any]: """Execute one parameter-grid case by spawning a simulation subprocess. @@ -228,6 +229,8 @@ def run_experiment_case( "DEFAULT_THETA_TRUST": str(float(case_cfg["theta_trust"])), "SUMO_BINARY": str(sumo_binary), }) + if sumo_seed is not None: + env["SUMO_SEED"] = str(int(sumo_seed)) if "DEFAULT_LAMBDA_E" in case_cfg: env["DEFAULT_LAMBDA_E"] = str(float(case_cfg["DEFAULT_LAMBDA_E"])) if "DEFAULT_LAMBDA_T" in case_cfg: @@ -285,6 +288,7 @@ def run_parameter_sweep( sumo_binary: str = "sumo", run_mode: str = "record", timeout_s: Optional[float] = None, + sumo_seed: Optional[int] = None, ) -> List[Dict[str, Any]]: """Run all cases in the experiment grid sequentially. @@ -317,6 +321,7 @@ def run_parameter_sweep( sumo_binary=sumo_binary, run_mode=run_mode, timeout_s=timeout_s, + sumo_seed=sumo_seed, ) ) return results @@ -405,6 +410,8 @@ def _parse_args() -> argparse.Namespace: parser.add_argument("--trust-values", default="0.5") parser.add_argument("--scenario-values", default="advice_guided") parser.add_argument("--messaging", choices=["on", "off"], default="on") + parser.add_argument("--sumo-seed", type=int, default=None, + help="SUMO random seed (integer). Overrides SUMO_SEED env var.") return parser.parse_args() @@ -427,6 +434,7 @@ def main() -> int: sumo_binary=args.sumo_binary, run_mode=args.run_mode, timeout_s=args.timeout_s, + sumo_seed=args.sumo_seed, ) exported = export_experiment_results(results, output_dir=args.output_dir) print(f"[EXPERIMENTS] cases={len(results)}") diff --git a/agentevac/analysis/metrics.py b/agentevac/analysis/metrics.py index f29545a..853e4df 100644 --- a/agentevac/analysis/metrics.py +++ b/agentevac/analysis/metrics.py @@ -81,6 +81,8 @@ def __init__(self, enabled: bool, base_path: str, run_mode: str): self._conflict_by_agent_sum: Dict[str, float] = {} self._conflict_by_agent_count: Dict[str, int] = {} + self._agent_profiles: Dict[str, Dict[str, float]] = {} + @staticmethod def _timestamped_path(base_path: str) -> str: """Generate a unique timestamped output path by appending ``YYYYMMDD_HHMMSS``. @@ -212,6 +214,39 @@ def record_decision_snapshot( if state.get("control_mode") == "destination": self._final_destination_by_agent[agent_id] = str(choice_name) + def record_agent_profile(self, agent_id: str, profile: Dict[str, float]) -> None: + """Record the sampled profile parameters for an agent. + + Only the first call per agent_id is stored (profiles are immutable). + + Args: + agent_id: Vehicle ID. + profile: Dict of psychological parameter values (theta_trust, theta_r, etc.). + """ + if not self.enabled: + return + if agent_id not in self._agent_profiles: + self._agent_profiles[agent_id] = dict(profile) + + def export_agent_profiles(self) -> Optional[str]: + """Write per-agent profiles to a JSON file alongside the metrics file. + + Returns: + The path of the written file, or ``None`` if metrics are disabled or no path is set. + """ + if not self.enabled or not self.path: + return None + if not self._agent_profiles: + return None + base = Path(self.path) + profiles_path = base.with_name(base.stem.replace("run_metrics", "agent_profiles", 1) + base.suffix) + if str(profiles_path) == self.path: + profiles_path = base.with_name(base.stem + "_profiles" + base.suffix) + with open(profiles_path, "w", encoding="utf-8") as fh: + json.dump(self._agent_profiles, fh, ensure_ascii=False, indent=2, sort_keys=True) + fh.write("\n") + return str(profiles_path) + def record_exposure_sample( self, agent_id: str, @@ -439,11 +474,12 @@ def export_run_metrics(self, path: Optional[str] = None) -> Optional[str]: return target def close(self) -> Optional[str]: - """Flush and export metrics; typically called at simulation end. + """Flush and export metrics and agent profiles; typically called at simulation end. Returns: The path of the written metrics file, or ``None``. """ if not self.enabled: return None + self.export_agent_profiles() return self.export_run_metrics() diff --git a/agentevac/simulation/main.py b/agentevac/simulation/main.py index 016ec40..2afab9d 100644 --- a/agentevac/simulation/main.py +++ b/agentevac/simulation/main.py @@ -53,7 +53,7 @@ from pathlib import Path from collections import deque from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer -from typing import Dict, List, Tuple, Any, Optional +from typing import Dict, List, Set, Tuple, Any, Optional from urllib.parse import urlparse, unquote from agentevac.agents.agent_state import ( AGENT_STATES, @@ -129,7 +129,7 @@ # OpenAI model + decision cadence OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") -DECISION_PERIOD_S = float(os.getenv("DECISION_PERIOD_S", "30.0")) # LLM may change decisions each period; default=5.0 +DECISION_PERIOD_S = float(os.getenv("DECISION_PERIOD_S", "60.0")) # LLM may change decisions each period; (simu sec.) # Preset routes (Situation 1) - only needed if CONTROL_MODE="route" ROUTE_LIBRARY = [ @@ -159,9 +159,9 @@ # Preset destinations (Situation 2) DESTINATION_LIBRARY = [ - {"name": "shelter_0", "edge": "-42006543#0"}, + {"name": "shelter_0", "edge": "E#S0"}, {"name": "shelter_1", "edge": "E#S1"}, - {"name": "shelter_2", "edge": "42044784#5"}, + {"name": "shelter_2", "edge": "E#S2"}, ] @@ -358,6 +358,8 @@ def _float_from_env_or_cli(cli_value: Optional[float], env_key: str, default: fl AGENT_HISTORY_ROUNDS = int(os.getenv("AGENT_HISTORY_ROUNDS", "8")) FIRE_TREND_EPS_M = float(os.getenv("FIRE_TREND_EPS_M", "20.0")) AGENT_HISTORY_ROUTE_HEAD_EDGES = int(os.getenv("AGENT_HISTORY_ROUTE_HEAD_EDGES", "5")) +VISUAL_LOOKAHEAD_EDGES = int(os.getenv("VISUAL_LOOKAHEAD_EDGES", "3")) +FIRE_PERCEPTION_RANGE_M = float(os.getenv("FIRE_PERCEPTION_RANGE_M", "1200")) INFO_SIGMA = float(os.getenv("INFO_SIGMA", "40.0")) DIST_REF_M = float(os.getenv("DIST_REF_M", "500.0")) INFO_DELAY_S = float(os.getenv("INFO_DELAY_S", "0.0")) @@ -422,9 +424,9 @@ def _agent_profile(agent_id: str) -> Dict[str, float]: DEFAULT_SOCIAL_MIN_DANGER = float(os.getenv("DEFAULT_SOCIAL_MIN_DANGER", "0.15")) MAX_SYSTEM_OBSERVATIONS = int(os.getenv("MAX_SYSTEM_OBSERVATIONS", "16")) # Driver-briefing threshold config -MARGIN_VERY_CLOSE_M = _float_from_env_or_cli(CLI_ARGS.margin_very_close_m, "MARGIN_VERY_CLOSE_M", 100.0) -MARGIN_NEAR_M = _float_from_env_or_cli(CLI_ARGS.margin_near_m, "MARGIN_NEAR_M", 300.0) -MARGIN_BUFFERED_M = _float_from_env_or_cli(CLI_ARGS.margin_buffered_m, "MARGIN_BUFFERED_M", 700.0) +MARGIN_VERY_CLOSE_M = _float_from_env_or_cli(CLI_ARGS.margin_very_close_m, "MARGIN_VERY_CLOSE_M", 1200.0) +MARGIN_NEAR_M = _float_from_env_or_cli(CLI_ARGS.margin_near_m, "MARGIN_NEAR_M", 2500.0) +MARGIN_BUFFERED_M = _float_from_env_or_cli(CLI_ARGS.margin_buffered_m, "MARGIN_BUFFERED_M", 5000.0) RISK_DENSITY_HIGH = _float_from_env_or_cli(CLI_ARGS.risk_density_high, "RISK_DENSITY_HIGH", 0.70) RISK_DENSITY_MEDIUM = _float_from_env_or_cli(CLI_ARGS.risk_density_medium, "RISK_DENSITY_MEDIUM", 0.35) RISK_DENSITY_LOW = _float_from_env_or_cli(CLI_ARGS.risk_density_low, "RISK_DENSITY_LOW", 0.12) @@ -432,13 +434,13 @@ def _agent_profile(agent_id: str) -> Dict[str, float]: DELAY_MODERATE_RATIO = _float_from_env_or_cli(CLI_ARGS.delay_moderate_ratio, "DELAY_MODERATE_RATIO", 1.30) DELAY_HEAVY_RATIO = _float_from_env_or_cli(CLI_ARGS.delay_heavy_ratio, "DELAY_HEAVY_RATIO", 1.60) RECOMMENDED_MIN_MARGIN_M = _float_from_env_or_cli( - CLI_ARGS.recommended_min_margin_m, "RECOMMENDED_MIN_MARGIN_M", 300.0 + CLI_ARGS.recommended_min_margin_m, "RECOMMENDED_MIN_MARGIN_M", 2500.0 ) CAUTION_MIN_MARGIN_M = _float_from_env_or_cli( - CLI_ARGS.caution_min_margin_m, "CAUTION_MIN_MARGIN_M", 100.0 + CLI_ARGS.caution_min_margin_m, "CAUTION_MIN_MARGIN_M", 1200.0 ) SIM_END_TIME_S = _float_from_env_or_cli( - CLI_ARGS.sim_end_time, "SIM_END_TIME_S", 1200.0 + CLI_ARGS.sim_end_time, "SIM_END_TIME_S", 14400.0 ) if not (0.0 <= MARGIN_VERY_CLOSE_M <= MARGIN_NEAR_M <= MARGIN_BUFFERED_M): @@ -525,29 +527,28 @@ def _agent_profile(agent_id: str) -> Dict[str, float]: # NEW_FIRE_EVENTS: fires that ignite mid-simulation (within the forecast horizon). # Coordinates are in SUMO network metres; match against the loaded .net.xml. FIRE_SOURCES = [ - # {"id": "F0", "t0": 0.0, "x": 9000.0, "y": 9000.0, "r0": 3000.0, "growth_m_per_s": 0.20}, - # {"id": "F0_1", "t0": 0.0, "x": 9000.0, "y": 27000.0, "r0": 3000.0, "growth_m_per_s": 0.20}, -{"id": "F0", "t0": 0.0, "x": 22000.0, "y": 9000.0, "r0": 3000.0, "growth_m_per_s": 0.02}, - {"id": "F0_1", "t0": 0.0, "x": 24000.0, "y": 6000.0, "r0": 3000.0, "growth_m_per_s": 0.02}, + {"id": "F0", "t0": 0.0, "x": 16805.0, "y": 9380.0, "r0": 500.0, "growth_m_per_s": 0.02}, + {"id": "F0_1", "t0": 0.0, "x": 20000.0, "y": 8800.0, "r0": 800.0, "growth_m_per_s": 0.02}, + {"id": "F0_2", "t0": 0.0, "x": 20600.0, "y": 10500.0, "r0": 800.0, "growth_m_per_s": 0.02}, + {"id": "F0_3", "t0": 0.0, "x": 16500.0, "y": 11500.0, "r0": 800.0, "growth_m_per_s": 0.02}, + {"id": "F0_4", "t0": 0.0, "x": 16200.0, "y": 13000.0, "r0": 800.0, "growth_m_per_s": 0.02}, + {"id": "F0_5", "t0": 0.0, "x": 18342.0, "y": 9487.0, "r0": 1200.0, "growth_m_per_s": 0.02}, + {"id": "F0_6", "t0": 0.0, "x": 16350.0, "y": 8905.0, "r0": 500.0, "growth_m_per_s": 0.02}, +{"id": "F0_7", "t0": 0.0, "x": 17002.0, "y": 15791.0, "r0": 1500.0, "growth_m_per_s": 0.02}, ] NEW_FIRE_EVENTS = [ - # {"id": "F1", "t0": 100.0, "x": 5000.0, "y": 4500.0, "r0": 2000.0, "growth_m_per_s": 0.30}, - # {"id": "F0_2", "t0": 50.0, "x": 15000.0, "y": 21000.0, "r0": 3000.0, "growth_m_per_s": 0.20}, - # {"id": "F0_3", "t0": 75.0, "x": 15000.0, "y": 15000.0, "r0": 3000.0, "growth_m_per_s": 0.20}, - {"id": "F1_4", "t0": 90.0, "x": 20000.0, "y": 6000.0, "r0": 3000.0, "growth_m_per_s": 0.02}, - {"id": "F1", "t0": 150.0, "x": 20000.0, "y": 12000.0, "r0": 2000.0, "growth_m_per_s": 0.02}, - {"id": "F1_2", "t0": 210.0, "x": 18000.0, "y": 14000.0, "r0": 3000.0, "growth_m_per_s": 0.02}, - {"id": "F1_3", "t0": 270.0, "x": 15000.0, "y": 18000.0, "r0": 3000.0, "growth_m_per_s": 0.02}, + # {"id": "F1_1", "t0": 80.0, "x": 14600.0, "y": 15800.0, "r0": 800.0, "growth_m_per_s": 0.02}, + ] # Risk model params: # FIRE_WARNING_BUFFER_M : extra buffer added to fire radius when classifying edges as blocked. # RISK_DECAY_M : exponential decay length scale for edge risk score = exp(-margin/RISK_DECAY_M). -FIRE_WARNING_BUFFER_M = 100.0 -RISK_DECAY_M = 80.0 +FIRE_WARNING_BUFFER_M = 1200.0 +RISK_DECAY_M = 960.0 # ---- Fire visualization in SUMO-GUI (Shapes) ---- FIRE_DRAW_ENABLED = True @@ -1565,6 +1566,10 @@ def _run_parameter_payload() -> Dict[str, Any]: MAX_CONCURRENT_LLM = int(os.environ.get("MAX_CONCURRENT_LLM", "20")) veh_last_choice: Dict[str, int] = {} decision_round_counter = 0 +_edge_trace: Dict[str, List[str]] = {} # veh_id -> ordered edges traversed +_edge_trace_last: Dict[str, str] = {} # veh_id -> last recorded edge +_edge_trace_written: Set[str] = set() # vehicles whose traces have been flushed +_replay_trace_applied: Set[str] = set() # vehicles whose replay traces have been set agent_round_history: Dict[str, deque] = {} agent_live_status: Dict[str, Dict[str, Any]] = {} @@ -1813,8 +1818,15 @@ def queue_outbox(self, sender: str, outbox: Optional[List[OutboxMessage]]): raise RuntimeError("ROUTE_LIBRARY is empty but CONTROL_MODE='route'. Fill ROUTE_LIBRARY.") DecisionModel = create_model( "RouteDecision", + situation_summary=(str, Field(..., description=( + "In 1-2 sentences, describe what you believe is happening around you " + "and what concerns you most right now." + ))), choice_index=(conint(ge=-1, le=len(ROUTE_LIBRARY) - 1), Field(..., description="-1 means KEEP")), - reason=(str, Field(..., description="Short reason")), + reason=(str, Field(..., description=( + "One sentence explaining the primary factor that drove your choice " + "(e.g., which signal, advisory, or risk factor was decisive)." + ))), conflict_assessment=( Optional[str], Field( @@ -1842,8 +1854,15 @@ def queue_outbox(self, sender: str, outbox: Optional[List[OutboxMessage]]): raise RuntimeError("DESTINATION_LIBRARY is empty but CONTROL_MODE='destination'. Fill DESTINATION_LIBRARY.") DecisionModel = create_model( "DestinationDecision", + situation_summary=(str, Field(..., description=( + "In 1-2 sentences, describe what you believe is happening around you " + "and what concerns you most right now." + ))), choice_index=(conint(ge=-1, le=len(DESTINATION_LIBRARY) - 1), Field(..., description="-1 means KEEP")), - reason=(str, Field(..., description="Short reason")), + reason=(str, Field(..., description=( + "One sentence explaining the primary factor that drove your choice " + "(e.g., which signal, advisory, or risk factor was decisive)." + ))), conflict_assessment=( Optional[str], Field( @@ -1868,8 +1887,21 @@ def queue_outbox(self, sender: str, outbox: Optional[List[OutboxMessage]]): class PreDepartureDecisionModel(BaseModel): + situation_summary: str = Field( + ..., + description=( + "In 1-2 sentences, describe what you believe is happening around you " + "and what concerns you most right now." + ), + ) action: str = Field(..., description="Use exactly 'depart' or 'wait'.") - reason: str = Field(..., description="Short reason for departing now or continuing to stay.") + reason: str = Field( + ..., + description=( + "One sentence explaining the primary factor that drove your choice " + "(e.g., which signal, advisory, or risk factor was decisive)." + ), + ) conflict_assessment: Optional[str] = Field( default=None, description=( @@ -2083,6 +2115,35 @@ def _build_conflict_description( return {"sources_agree": False, "description": desc} +def _visible_fires( + agent_pos: Tuple[float, float], + fires: List[Tuple[float, float, float]], + perception_range_m: float, +) -> List[Tuple[float, float, float]]: + """Return the subset of *fires* whose perimeter is within *perception_range_m* of *agent_pos*. + + Each fire is a growing circle ``(x, y, radius)``. An agent perceives a fire when:: + + sqrt((ax - fx)^2 + (ay - fy)^2) - fr <= perception_range_m + + Args: + agent_pos: ``(x, y)`` from ``traci.vehicle.getPosition()``. + fires: List of ``(x, y, r)`` tuples representing active fire circles. + perception_range_m: Maximum distance (metres) from the fire perimeter at + which the agent can visually assess the fire. + + Returns: + Filtered list of ``(x, y, r)`` tuples — same format as *fires*. + """ + ax, ay = float(agent_pos[0]), float(agent_pos[1]) + visible: List[Tuple[float, float, float]] = [] + for fx, fy, fr in fires: + dist_to_perimeter = math.hypot(ax - fx, ay - fy) - fr + if dist_to_perimeter <= perception_range_m: + visible.append((fx, fy, fr)) + return visible + + def _edge_margin_from_risk(edge_id: str, edge_risk_fn) -> Optional[float]: if not edge_id or edge_id.startswith(":"): return None @@ -2503,8 +2564,25 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: sim_t, neighborhood_observation=neighborhood_observation, ) - prompt_system_observation_updates = [dict(item) for item in system_observation_updates] - prompt_neighborhood_observation = dict(neighborhood_observation) + # --- Filter env/forecast/neighborhood for the active scenario regime --- + _pd_forecast_payload = { + "summary": dict(forecast_summary), + "current_edge": dict(edge_forecast), + "route_head": dict(route_forecast), + "briefing": forecast_briefing, + } + prompt_env_signal, prompt_forecast = apply_scenario_to_signals( + SCENARIO_MODE, env_signal, _pd_forecast_payload, + ) + if SCENARIO_CONFIG.get("neighborhood_observation_visible", True): + prompt_system_observation_updates = [dict(item) for item in system_observation_updates] + prompt_neighborhood_observation = dict(neighborhood_observation) + else: + prompt_system_observation_updates = [] + prompt_neighborhood_observation = { + "available": False, + "summary": "Neighborhood observation is not available in this scenario.", + } conflict_info = _build_conflict_description( belief_state.get("env_belief", {}), social_signal, @@ -2518,16 +2596,18 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: "spawn_edge": from_edge, "candidate_destination_edge": to_edge, "has_departed": False, + "risk_tolerance": { + "theta_r": round(float(agent_state.profile["theta_r"]), 4), + "description": ( + "theta_r is this agent's personal risk threshold on a 0\u20131 scale. " + "The agent should depart only when perceived danger (combined_belief.p_danger) " + "exceeds theta_r. Higher theta_r means greater tolerance for risk and a longer wait." + ), + }, }, "your_observation": { - "environment_signal": dict(env_signal), + "environment_signal": prompt_env_signal, "env_belief": belief_state.get("env_belief", {}), - "interpretation": ( - f"Based on what you can observe, your estimated hazard distribution is: " - f"safe={round(float(belief_state.get('env_belief', {}).get('p_safe', 0.33)), 2)}, " - f"risky={round(float(belief_state.get('env_belief', {}).get('p_risky', 0.33)), 2)}, " - f"danger={round(float(belief_state.get('env_belief', {}).get('p_danger', 0.33)), 2)}." - ), }, "neighbor_assessment": { "social_signal": dict(social_signal), @@ -2539,17 +2619,11 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: "p_risky": round(float(belief_state["p_risky"]), 4), "p_danger": round(float(belief_state["p_danger"]), 4), "signal_conflict": round(float(belief_state.get("signal_conflict", 0.0)), 4), - "note": ( - "This is a mathematical combination of your observation and neighbor messages. " - "Your own judgment may differ." - ), }, "uncertainty": { - "entropy": round(float(belief_state["entropy"]), 4), "entropy_norm": round(float(belief_state["entropy_norm"]), 4), "bucket": belief_state["uncertainty_bucket"], }, - "psychology": dict(agent_state.psychology), "inbox_order": "chronological_oldest_first", "inbox": predeparture_inbox, "system_observation_updates_order": "chronological_oldest_first", @@ -2560,25 +2634,32 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: "title": SCENARIO_CONFIG["title"], "description": SCENARIO_CONFIG["description"], }, - "forecast": { - "summary": dict(forecast_summary), - "current_edge": dict(edge_forecast), - "route_head": dict(route_forecast), - "briefing": forecast_briefing, - }, + "forecast": prompt_forecast, "policy": ( - "Priority 1 — Safety: If official evacuation guidance is present (see official_evacuation_order), " - "depart immediately unless physically unable. " - "If fire risk is rising, forecast worsens, or your current location may be overtaken, depart. " - "Delayed departure increases your exposure to dangerous fire conditions. " - "Priority 2 — Information assessment: Consider your_observation, neighbor_assessment, " - "and inbox to form your judgment. " - "combined_belief is a mathematical estimate — you may weigh sources differently. " + "BINDING CONSTRAINT — Personal risk threshold: " + "This agent's calibrated risk threshold is theta_r = " + f"{round(float(agent_state.profile['theta_r']), 4)}. " + "This parameter is derived from empirical behavioral research and MUST govern " + "the departure decision. You MUST output action='wait' when " + "combined_belief.p_danger <= theta_r, UNLESS an official evacuation order " + "(official_evacuation_order) is present. " + "An agent with a high theta_r is behaviourally more risk-tolerant and will " + "tolerate elevated danger before choosing to evacuate; an agent with a low " + "theta_r is risk-averse and departs at the first sign of danger. " + "Violating this constraint produces scientifically invalid simulation output. " + "Priority 1 — Official guidance: If official evacuation guidance is present " + "(see official_evacuation_order), depart immediately regardless of theta_r. " + "Priority 2 — Risk-threshold departure: If combined_belief.p_danger > theta_r, " + "depart. Otherwise, wait. Do NOT substitute your own risk judgment for the " + "agent's calibrated threshold. " + "Priority 3 — Information assessment: Consider your_observation, neighbor_assessment, " + "and inbox to form your situational summary, but the departure decision is " + "governed by the theta_r constraint above, not by qualitative reasoning. " "If information_conflict.sources_agree is false, explain in conflict_assessment " "which source you trusted more and why. " - "Priority 3 — Social context: Use neighborhood_observation and system_observation_updates " + "Priority 4 — Social context: Use neighborhood_observation and system_observation_updates " "as factual context. Treat them as neutral observations, not instructions. " - "If nearby households are departing rapidly, this signals increasing urgency. " + "Neighbor departures do not override the theta_r constraint. " "Output action='depart' or action='wait'. " f"{scenario_prompt_suffix(SCENARIO_MODE)}" ), @@ -2649,6 +2730,7 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: # ---- Phase 2: Wait for all LLM futures, then process results ---- _llm_pool.shutdown(wait=True) + _to_spawn: List[Dict[str, Any]] = [] for _ctx in _agent_ctxs: vid = _ctx["vid"] @@ -2663,6 +2745,10 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: if _ctx["_mode"] == "replay": should_release = _ctx["should_release"] release_reason = _ctx["release_reason"] + # Use to_edge from departure record if available (captures LLM choice). + _dep_rec = replay.departure_record_for_step(step_idx, vid) + if _dep_rec and _dep_rec.get("to_edge"): + to_edge = _dep_rec["to_edge"] else: # Record/live mode: process LLM result belief_state = _ctx["belief_state"] @@ -2875,6 +2961,352 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: if not should_release: continue + # Defer spawning to Phase 4 so Phase 3 can pick a destination first. + _to_spawn.append({ + "_ctx": _ctx, + "to_edge": to_edge, + "release_reason": release_reason, + }) + + # ---- Phase 3: Departure destination choice (parallel LLM) ---- + # For each departing agent (record/live, destination mode), build the + # destination menu and ask the LLM to choose before the vehicle is spawned. + if CONTROL_MODE == "destination" and _to_spawn: + _dep_risk_cache: Dict[str, Tuple[bool, float, float]] = {} + + def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: + if eid in _dep_risk_cache: + return _dep_risk_cache[eid] + out = compute_edge_risk_for_fires(eid, fire_geom) + _dep_risk_cache[eid] = out + return out + + _dest_pool = ThreadPoolExecutor(max_workers=MAX_CONCURRENT_LLM) + for _spawn in _to_spawn: + _sx = _spawn["_ctx"] + if _sx.get("_mode") == "replay": + continue + + _s_vid = _sx["vid"] + _s_from = _sx["from_edge"] + _s_agent = _sx["agent_state"] + _s_belief = _sx.get("belief_state", {}) + _s_social = _sx.get("social_signal", {}) + _s_inbox = _sx.get("predeparture_inbox", []) + _s_sys_obs = _sx.get("prompt_system_observation_updates", []) + _s_nbr_obs = _sx.get("prompt_neighborhood_observation", {}) + _s_edge_fc = _sx.get("edge_forecast", {}) + _s_route_fc = _sx.get("route_forecast", {}) + _s_fc_brief = _sx.get("forecast_briefing", "") + _s_conflict = _sx.get("conflict_info", {}) + _s_margin = _sx.get("spawn_margin_m") + _s_env_sig = _sx.get("env_signal", {}) + + # Build destination menu. + _d_menu: List[Dict[str, Any]] = [] + _d_reachable: List[int] = [] + for idx, dest in enumerate(DESTINATION_LIBRARY): + dest_edge = dest["edge"] + try: + stage = traci.simulation.findRoute( + _s_from, dest_edge, + vType="DEFAULT_VEHTYPE", + depart=sim_t, + routingMode=0, + ) + cand_edges = list(stage.edges) if hasattr(stage, "edges") else [] + cand_tt = float(stage.travelTime) if getattr(stage, "travelTime", None) is not None else None + except Exception: + cand_edges = [] + cand_tt = None + + if not cand_edges: + _d_menu.append({ + "idx": idx, + "name": dest["name"], + "dest_edge": dest_edge, + "reachable": False, + "note": "No directed path from current edge.", + "advisory": "Unavailable", + "briefing": "Unavailable: no directed path from current position.", + "reasons": ["No directed path from spawn edge."], + }) + continue + + _d_reachable.append(idx) + blocked_cnt = 0 + risk_sum = 0.0 + min_margin = float("inf") + for e in cand_edges: + b, r, m = _dep_edge_risk(e) + blocked_cnt += int(b) + risk_sum += r + if m < min_margin: + min_margin = m + _d_menu.append({ + "idx": idx, + "name": dest["name"], + "dest_edge": dest_edge, + "reachable": True, + "blocked_edges_on_fastest_path": blocked_cnt, + "risk_sum_on_fastest_path": round(risk_sum, 4), + "min_margin_m_on_fastest_path": None if not math.isfinite(min_margin) else round(min_margin, 2), + "travel_time_s_fastest_path": None if cand_tt is None else round(cand_tt, 2), + "len_edges_fastest_path": len(cand_edges), + }) + + if not _d_reachable: + continue + + # Annotate with briefings and utility scores. + _reachable_times = [ + item.get("travel_time_s_fastest_path") + for item in _d_menu + if item.get("reachable") and item.get("travel_time_s_fastest_path") is not None + ] + _baseline_tt = min(_reachable_times) if _reachable_times else None + for item in _d_menu: + if not item.get("reachable"): + continue + info = build_driver_briefing( + blocked_edges=int(item.get("blocked_edges_on_fastest_path", 0)), + risk_sum=float(item.get("risk_sum_on_fastest_path", 0.0)), + min_margin_m=item.get("min_margin_m_on_fastest_path"), + len_edges=int(item.get("len_edges_fastest_path", 0)), + travel_time_s=item.get("travel_time_s_fastest_path"), + baseline_time_s=_baseline_tt, + ) + item.update(info) + annotate_menu_with_expected_utility( + _d_menu, + mode="destination", + belief=_s_belief, + psychology=_s_agent.psychology, + profile=_s_agent.profile, + scenario=SCENARIO_MODE, + ) + _prompt_dest_menu = filter_menu_for_scenario( + SCENARIO_MODE, _d_menu, control_mode="destination", + ) + + # Build forecast prompt filtered by scenario. + _, _prompt_fc = apply_scenario_to_signals(SCENARIO_MODE, {}, { + "summary": dict(forecast_summary), + "current_edge": dict(_s_edge_fc), + "route_head": dict(_s_route_fc), + "briefing": str(_s_fc_brief or ""), + }) + + # Policy strings (same logic as process_vehicles). + _util_basis = { + "no_notice": ( + "expected_utility is available for all options; higher (less negative) is better. " + "Scores reflect your general hazard perception and route length — " + "you have no route-specific fire data. " + ), + "alert_guided": ( + "expected_utility is available for all options; higher (less negative) is better. " + "Scores incorporate current fire positions along each route. " + ), + "advice_guided": ( + "Use expected_utility as the main safety-efficiency tradeoff score; higher is better. " + ), + } + _util_pol = _util_basis.get(SCENARIO_MODE, _util_basis["advice_guided"]) + _guid_pol = ( + "The Emergency Operations Center has assessed each option. " + "Follow options with advisory='Recommended'; fall back to 'Use with caution' only if no recommended option is reachable. " + "Avoid options marked 'Avoid for now' unless all alternatives are blocked. " + if SCENARIO_CONFIG["official_route_guidance_visible"] + else "No official route recommendation is available in this scenario; infer safety from the visible route facts and your subjective information. " + ) + _fc_pol = ( + "Use forecast.briefing and forecast.route_head to avoid options that may worsen within the forecast horizon. " + if SCENARIO_CONFIG["forecast_visible"] + else "No official forecast is available in this scenario. " + ) + + _dep_env = { + "time_s": round(sim_t, 2), + "decision_round": int(decision_round_counter), + "vehicle": { + "id": _s_vid, + "veh_type": "DEFAULT_VEHTYPE", + "current_edge": _s_from, + "current_route_head": [_s_from], + }, + "agent_self_history_order": "chronological_oldest_first", + "agent_self_history": [], + "fire_proximity": { + "current_edge_margin_m": _round_or_none(_s_margin, 2), + "route_head_min_margin_m": _round_or_none(_s_margin, 2), + "trend_vs_last_round": "stable", + "is_getting_closer_to_fire": False, + }, + "your_observation": { + "environment_signal": dict(_s_env_sig), + "env_belief": _s_belief.get("env_belief", {}), + }, + "neighbor_assessment": { + "social_signal": dict(_s_social), + "social_belief": _s_belief.get("social_belief", {}), + }, + "information_conflict": _s_conflict, + "combined_belief": { + "p_safe": round(float(_s_belief.get("p_safe", 0.5)), 4), + "p_risky": round(float(_s_belief.get("p_risky", 0.3)), 4), + "p_danger": round(float(_s_belief.get("p_danger", 0.2)), 4), + "signal_conflict": round(float(_s_belief.get("signal_conflict", 0.0)), 4), + }, + "uncertainty": { + "entropy_norm": round(float(_s_belief.get("entropy_norm", 0.5)), 4), + "bucket": _s_belief.get("uncertainty_bucket", "Medium"), + }, + "system_observation_updates_order": "chronological_oldest_first", + "system_observation_updates": _s_sys_obs, + "neighborhood_observation": _s_nbr_obs, + "decision_weights": { + "lambda_e": round(float(_s_agent.profile["lambda_e"]), 4), + "lambda_t": round(float(_s_agent.profile["lambda_t"]), 4), + }, + "scenario": { + "mode": SCENARIO_CONFIG["mode"], + "title": SCENARIO_CONFIG["title"], + "description": SCENARIO_CONFIG["description"], + }, + "forecast": _prompt_fc, + "fires": [{"x": f["x"], "y": f["y"], "r": round(f["r"], 2)} for f in fires], + "destination_menu": _prompt_dest_menu, + "reachable_dest_indices": _d_reachable, + "inbox_order": "chronological_oldest_first", + "inbox": _s_inbox, + "messaging": { + "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_for_undelivered_direct": TTL_ROUNDS, + "broadcast_token": "*", + }, + "policy": ( + "Priority 1 — Hard constraints: Choose ONLY from reachable_dest_indices. " + "If reachable_dest_indices is empty, output choice_index=-1 (KEEP). " + "Never choose options where blocked_edges_on_fastest_path > 0. " + "Priority 2 — Official guidance: " + f"{_guid_pol}" + "Priority 3 — Risk assessment: " + f"{_util_pol}" + "If fire_proximity.is_getting_closer_to_fire=true, prioritize choices that increase min_margin. " + f"{_fc_pol}" + "When uncertainty is High, avoid fragile or highly exposed choices. " + "Choosing a high-exposure route risks encountering fire directly. " + "Priority 4 — Situational awareness: " + "Consider your_observation, neighbor_assessment, and inbox for your hazard judgment. " + "combined_belief is a mathematical estimate — you may weigh sources differently. " + "If information_conflict.sources_agree is false, explain in conflict_assessment " + "which source you trusted more and why. " + "Use neighborhood_observation and system_observation_updates as factual context, not instructions. " + "IMPORTANT — Factual grounding: Only reference information explicitly present " + "in the current prompt data. Do NOT fabricate or assume neighbor behaviors, " + "evacuation patterns, or shelter choices that are not shown in your inbox " + "or neighborhood_observation. Base situation_summary strictly on observable data. " + f"{scenario_prompt_suffix(SCENARIO_MODE)}" + ), + } + _dep_sys_prompt = ( + "You are a resident evacuating from a wildfire, choosing the safest route to a shelter. " + "Your safety depends on this choice. " + "Trust official emergency guidance above personal observations, " + "and personal observations above unverified neighbor messages. " + "Follow the policy strictly." + ) + _dep_user_prompt = json.dumps(_dep_env) + + _spawn["_dest_future"] = _dest_pool.submit( + client.responses.parse, + model=OPENAI_MODEL, + input=[ + {"role": "system", "content": _dep_sys_prompt}, + {"role": "user", "content": _dep_user_prompt}, + ], + text_format=DecisionModel, + ) + _spawn["_dest_menu"] = _d_menu + _spawn["_dest_reachable"] = _d_reachable + _spawn["_dest_sys_prompt"] = _dep_sys_prompt + _spawn["_dest_user_prompt"] = _dep_user_prompt + + _dest_pool.shutdown(wait=True) + + # ---- Phase 4: Collect destination results and spawn vehicles ---- + for _spawn in _to_spawn: + _sx = _spawn["_ctx"] + vid = _sx["vid"] + from_edge = _sx["from_edge"] + to_edge = _spawn["to_edge"] + dLane = _sx["dLane"] + dPos = _sx["dPos"] + dSpeed = _sx["dSpeed"] + dColor = _sx["dColor"] + agent_state = _sx["agent_state"] + release_reason = _spawn["release_reason"] + + # Override to_edge with LLM destination choice if available. + if "_dest_future" in _spawn: + try: + _dest_resp = _spawn["_dest_future"].result(timeout=60) + _dest_decision = _dest_resp.output_parsed + _dest_idx = int(_dest_decision.choice_index) + _d_reachable = _spawn["_dest_reachable"] + _d_menu = _spawn["_dest_menu"] + _reachable_map = {item["idx"]: item.get("reachable", False) for item in _d_menu} + if _dest_idx >= 0 and _reachable_map.get(_dest_idx, False): + to_edge = DESTINATION_LIBRARY[_dest_idx]["edge"] + print(f"[DEPART-DEST] {vid}: LLM chose {DESTINATION_LIBRARY[_dest_idx]['name']} (edge={to_edge})") + elif _d_reachable: + _dest_idx = sorted( + _d_reachable, + key=lambda i: -float(next(x for x in _d_menu if x["idx"] == i).get("expected_utility", -10**9)), + )[0] + to_edge = DESTINATION_LIBRARY[_dest_idx]["edge"] + print(f"[DEPART-DEST] {vid}: fallback to best utility {DESTINATION_LIBRARY[_dest_idx]['name']}") + replay.record_llm_dialog( + step=step_idx, sim_t_s=sim_t, veh_id=vid, + control_mode="departure_destination", model=OPENAI_MODEL, + system_prompt=_spawn.get("_dest_sys_prompt", ""), + user_prompt=_spawn.get("_dest_user_prompt", ""), + response_text=getattr(_dest_resp, "output_text", None), + parsed=_dest_decision.model_dump() if hasattr(_dest_decision, "model_dump") else None, + error=None, + ) + # Record in metrics so departure destination counts in destination_choice_share. + _dest_selected = next((x for x in _d_menu if x.get("idx") == _dest_idx), None) + metrics.record_decision_snapshot( + agent_id=vid, + sim_t_s=float(sim_t), + decision_round=int(decision_round_counter), + state={ + "control_mode": CONTROL_MODE, + "action_status": "departure_destination_choice", + "selected_option": { + "name": DESTINATION_LIBRARY[_dest_idx]["name"], + "dest_edge": DESTINATION_LIBRARY[_dest_idx]["edge"], + } if 0 <= _dest_idx < len(DESTINATION_LIBRARY) else {}, + }, + choice_idx=_dest_idx, + action_status="departure_destination_choice", + ) + except Exception as _dest_err: + print(f"[WARN] Departure destination choice failed for {vid}: {_dest_err}") + replay.record_llm_dialog( + step=step_idx, sim_t_s=sim_t, veh_id=vid, + control_mode="departure_destination", model=OPENAI_MODEL, + system_prompt=_spawn.get("_dest_sys_prompt", ""), + user_prompt=_spawn.get("_dest_user_prompt", ""), + response_text=None, parsed=None, error=str(_dest_err), + ) + try: rid = f"r_{vid}" traci.route.add(rid, [from_edge, to_edge]) @@ -3042,14 +3474,37 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: print(f"t={sim_t_s:.2f}s | Vehicle ID: {vehicle}, Position: {position}, Angle: {angle}") print(f"Vehicle info of {vehicle}, RouteLen: {len(rinfo)}, Roadid: {roadid}") + # --- Edge-trace recording (every step, both modes) --- + if roadid and not roadid.startswith(":"): + if _edge_trace_last.get(vehicle) != roadid: + _edge_trace_last[vehicle] = roadid + _edge_trace.setdefault(vehicle, []).append(roadid) + + # --- Edge-trace replay: apply recorded trace on first sight --- + if RUN_MODE == "replay" and vehicle not in _replay_trace_applied: + trace = replay.get_edge_trace(vehicle) + if trace and roadid and not roadid.startswith(":"): + try: + if roadid in trace: + remaining = trace[trace.index(roadid):] + traci.vehicle.setRoute(vehicle, remaining) + _replay_trace_applied.add(vehicle) + except traci.TraCIException: + pass + if not do_decide: return decision_round_counter += 1 decision_round = decision_round_counter - # Decide for a subset (optional throttle) - to_control = vehicles_list[:MAX_VEHICLES_PER_DECISION] + # Decide for a subset (round-robin throttle so every agent eventually gets a turn) + _n_veh = len(vehicles_list) + if _n_veh <= MAX_VEHICLES_PER_DECISION: + to_control = vehicles_list + else: + _rr_offset = ((decision_round - 1) * MAX_VEHICLES_PER_DECISION) % _n_veh + to_control = (vehicles_list[_rr_offset:] + vehicles_list[:_rr_offset])[:MAX_VEHICLES_PER_DECISION] pending_agent_ids = [str(vid) for (vid, *_rest) in SPAWN_EVENTS if vid not in spawned] if EVENTS_ENABLED: events.emit( @@ -3091,8 +3546,24 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: prev_margin_m = None if history_recent: prev_margin_m = history_recent[-1].get("current_edge_margin_m") - current_edge_margin_m = _edge_margin_from_risk(roadid, edge_risk) - route_head_min_margin_m = _route_head_min_margin(rinfo, edge_risk) + # --- Fire perception gating for no_notice --- + # In no_notice mode, agents can only perceive fires within + # FIRE_PERCEPTION_RANGE_M of their position. Margins are computed + # from visible fires only; if none are in range the agent receives + # None → observed_state="unknown" (genuine uncertainty). + if SCENARIO_MODE == "no_notice": + _visible = _visible_fires(position, fire_geom, FIRE_PERCEPTION_RANGE_M) + if _visible: + def _vis_edge_risk(eid, _vf=_visible): + return compute_edge_risk_for_fires(eid, _vf) + current_edge_margin_m = _edge_margin_from_risk(roadid, _vis_edge_risk) + route_head_min_margin_m = _route_head_min_margin(rinfo, _vis_edge_risk) + else: + current_edge_margin_m = None + route_head_min_margin_m = None + else: + current_edge_margin_m = _edge_margin_from_risk(roadid, edge_risk) + route_head_min_margin_m = _route_head_min_margin(rinfo, edge_risk) fire_trend_vs_last_round = _fire_trend(prev_margin_m, current_edge_margin_m, FIRE_TREND_EPS_M) inbox_for_vehicle = messaging.get_inbox(vehicle) if MESSAGING_ENABLED else [] if EVENTS_ENABLED: @@ -3374,6 +3845,7 @@ def record_agent_memory( "min_margin_m_on_fastest_path": None if not math.isfinite(min_margin) else round(min_margin, 2), "travel_time_s_fastest_path": None if cand_tt is None else round(cand_tt, 2), "len_edges_fastest_path": len(cand_edges), + "_fastest_path_edges": cand_edges, }) # If nothing reachable, KEEP @@ -3408,6 +3880,62 @@ def record_agent_memory( baseline_time_s=baseline_time_s, ) item.update(info) + + # --- Visual fire observation for no_notice mode --- + # En-route agents can see fire on the first few edges ahead of + # their current position. This adds a penalty to the CURRENT + # destination's menu item so _observation_based_exposure picks + # it up, making the agent more likely to switch shelters. + if SCENARIO_MODE == "no_notice": + _cur_dest_idx = veh_last_choice.get(vehicle) + if _cur_dest_idx is not None and _cur_dest_idx >= 0: + try: + _rp = rinfo.index(roadid) + _ahead = rinfo[_rp + 1:] + except ValueError: + _ahead = [] + _head = _ahead[:VISUAL_LOOKAHEAD_EDGES] + if _head: + _vb = 0 + _vm = float("inf") + for _he in _head: + _hb, _hr, _hm = edge_risk(_he) + _vb += int(_hb) + if _hm < _vm: + _vm = _hm + for item in menu: + if item.get("idx") == _cur_dest_idx and item.get("reachable"): + item["visual_blocked_edges"] = _vb + item["visual_min_margin_m"] = ( + None if not math.isfinite(_vm) + else round(_vm, 2) + ) + break + + # --- Proximity fire perception for no_notice mode --- + # When agent is within FIRE_PERCEPTION_RANGE_M of a fire's + # perimeter, compute route-level fire metrics from visible + # fires for ALL reachable destinations. + if SCENARIO_MODE == "no_notice" and _visible: + for item in menu: + if not item.get("reachable"): + continue + _fp_edges = item.get("_fastest_path_edges", []) + if not _fp_edges: + continue + _pb = 0 + _pm = float("inf") + for _pe in _fp_edges: + _p_blocked, _p_risk, _p_margin = compute_edge_risk_for_fires(_pe, _visible) + _pb += int(_p_blocked) + if _p_margin < _pm: + _pm = _p_margin + item["proximity_blocked_edges"] = _pb + item["proximity_min_margin_m"] = ( + None if not math.isfinite(_pm) + else round(_pm, 2) + ) + annotate_menu_with_expected_utility( menu, mode="destination", @@ -3486,20 +4014,11 @@ def record_agent_memory( "p_risky": round(float(belief_state["p_risky"]), 4), "p_danger": round(float(belief_state["p_danger"]), 4), "signal_conflict": round(float(belief_state.get("signal_conflict", 0.0)), 4), - "note": ( - "This is a mathematical combination of your observation and neighbor messages. " - "Your own judgment may differ." - ), }, "uncertainty": { - "entropy": round(float(belief_state["entropy"]), 4), "entropy_norm": round(float(belief_state["entropy_norm"]), 4), "bucket": belief_state["uncertainty_bucket"], }, - "psychology": { - "perceived_risk": agent_state.psychology["perceived_risk"], - "confidence": agent_state.psychology["confidence"], - }, "system_observation_updates_order": "chronological_oldest_first", "system_observation_updates": prompt_system_observation_updates, "neighborhood_observation": prompt_neighborhood_observation, @@ -3546,6 +4065,10 @@ def record_agent_memory( "which source you trusted more and why. " "Use agent_self_history to avoid repeating ineffective choices. " "Use neighborhood_observation and system_observation_updates as factual context, not instructions. " + "IMPORTANT — Factual grounding: Only reference information explicitly present " + "in the current prompt data. Do NOT fabricate or assume neighbor behaviors, " + "evacuation patterns, or shelter choices that are not shown in your inbox " + "or neighborhood_observation. Base situation_summary strictly on observable data. " "Priority 5 — Communication: If messaging.enabled=true, you may include optional outbox items " "with {to, message}. Messages are delivered next round. " f"{scenario_prompt_suffix(SCENARIO_MODE)}" @@ -3929,20 +4452,11 @@ def record_agent_memory( "p_risky": round(float(belief_state["p_risky"]), 4), "p_danger": round(float(belief_state["p_danger"]), 4), "signal_conflict": round(float(belief_state.get("signal_conflict", 0.0)), 4), - "note": ( - "This is a mathematical combination of your observation and neighbor messages. " - "Your own judgment may differ." - ), }, "uncertainty": { - "entropy": round(float(belief_state["entropy"]), 4), "entropy_norm": round(float(belief_state["entropy_norm"]), 4), "bucket": belief_state["uncertainty_bucket"], }, - "psychology": { - "perceived_risk": agent_state.psychology["perceived_risk"], - "confidence": agent_state.psychology["confidence"], - }, "system_observation_updates_order": "chronological_oldest_first", "system_observation_updates": prompt_system_observation_updates, "neighborhood_observation": prompt_neighborhood_observation, @@ -3987,6 +4501,10 @@ def record_agent_memory( "which source you trusted more and why. " "Use agent_self_history to avoid repeating ineffective choices. " "Use neighborhood_observation and system_observation_updates as factual context, not instructions. " + "IMPORTANT — Factual grounding: Only reference information explicitly present " + "in the current prompt data. Do NOT fabricate or assume neighbor behaviors, " + "evacuation patterns, or shelter choices that are not shown in your inbox " + "or neighborhood_observation. Base situation_summary strictly on observable data. " "Priority 5 — Communication: If messaging.enabled=true, you may include optional outbox items " "with {to, message}. Messages are delivered next round. " f"{scenario_prompt_suffix(SCENARIO_MODE)}" @@ -4353,6 +4871,9 @@ def update_fire_shapes(sim_t_s: float): arrived_vehicle_ids = list(traci.simulation.getArrivedIDList()) for vid in arrived_vehicle_ids: metrics.record_arrival(vid, sim_t) + if vid in _edge_trace and vid not in _edge_trace_written: + replay.record_edge_trace(vid, _edge_trace[vid]) + _edge_trace_written.add(vid) if vid in agent_live_status: agent_live_status[vid]["active"] = False agent_live_status[vid]["last_seen_sim_t_s"] = _round_or_none(sim_t, 2) @@ -4401,6 +4922,14 @@ def update_fire_shapes(sim_t_s: float): ) finally: + # Flush edge traces for vehicles that never arrived (still en route or stuck). + try: + for _vid, _trace in _edge_trace.items(): + if _vid not in _edge_trace_written: + replay.record_edge_trace(_vid, _trace) + _edge_trace_written.add(_vid) + except Exception: + pass try: replay.record_metric_snapshot( step=step_idx, @@ -4419,6 +4948,8 @@ def update_fire_shapes(sim_t_s: float): except Exception: pass try: + for _aid, _astate in AGENT_STATES.items(): + metrics.record_agent_profile(_aid, _astate.profile) metrics_path = metrics.close() if metrics_path: print(f"[METRICS] summary_path={metrics_path}") diff --git a/agentevac/simulation/spawn_events.py b/agentevac/simulation/spawn_events.py index 4d2debd..29d7356 100644 --- a/agentevac/simulation/spawn_events.py +++ b/agentevac/simulation/spawn_events.py @@ -39,250 +39,250 @@ 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), + ("veh1_1", "42006672", "E#S2", 0.0, "first", "10", "max", C_RED), + ("veh1_2", "42006672", "E#S2", 5.0, "first", "10", "max", C_BLUE), + ("veh1_3", "42006672", "E#S2", 10.0, "first", "10", "max", C_GREEN), + + ("veh2_1", "42006514#4", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh2_2", "42006514#4", "E#S2", 5.0, "first", "20", "max", C_BLUE), + + ("veh3_1", "-42006515", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh3_2", "-42006515", "E#S2", 5.0, "first", "20", "max", C_BLUE), + + ("veh4_1", "42006515", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh4_2", "42006515", "E#S2", 5.0, "first", "20", "max", C_BLUE), + + ("veh5_1", "42006565", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh5_2", "42006565", "E#S2", 5.0, "first", "20", "max", C_BLUE), + + ("veh6_1", "-42006513#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh6_2", "-42006513#0", "E#S2", 5.0, "first", "20", "max", C_BLUE), + ("veh6_3", "-42006513#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh6_4", "-42006513#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh6_5", "-42006513#0", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh7_1", "42006504#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh7_2", "42006504#1", "E#S2", 5.0, "first", "20", "max", C_BLUE), + ("veh7_3", "42006504#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh8_1", "42006513#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh8_2", "42006513#0", "E#S2", 5.0, "first", "20", "max", C_BLUE), + ("veh8_3", "42006513#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh8_4", "42006513#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh8_5", "42006513#0", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh9_1", "-42006719#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh9_2", "-42006719#1", "E#S2", 5.0, "first", "20", "max", C_BLUE), + ("veh9_3", "-42006719#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh9_4", "-42006719#1", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh9_5", "-42006719#1", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh9_6", "-42006719#1", "E#S2", 25.0, "first", "20", "max", C_CYAN), + ("veh9_7", "-42006719#1", "E#S2", 30.0, "first", "20", "max", C_YELLOW), + + ("veh10_1", "42006513#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh10_2", "42006513#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh10_3", "42006513#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh10_4", "42006513#1", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh10_5", "42006513#1", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh10_6", "42006513#1", "E#S2", 25.0, "first", "20", "max", C_CYAN), + + ("veh11_1", "-42006513#2", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh11_2", "-42006513#2", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh11_3", "-42006513#2", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh11_4", "-42006513#2", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh11_5", "-42006513#2", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh11_6", "-42006513#2", "E#S2", 25.0, "first", "20", "max", C_CYAN), + ("veh11_7", "-42006513#2", "E#S2", 30.0, "first", "20", "max", C_OCEAN), + ("veh11_8", "-42006513#2", "E#S2", 35.0, "first", "20", "max", C_VIOLET), + ("veh11_9", "-42006513#2", "E#S2", 40.0, "first", "20", "max", C_MAGENTA), + + ("veh12_1", "30689314#5", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh12_2", "30689314#5", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh12_3", "30689314#5", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh12_4", "30689314#5", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + + ("veh13_1", "-30689314#5", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh13_2", "-30689314#5", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh13_3", "-30689314#5", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh13_4", "-30689314#5", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh13_5", "-30689314#5", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh13_6", "-30689314#5", "E#S2", 25.0, "first", "20", "max", C_CYAN), + + ("veh14_1", "42006513#2", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh14_2", "42006513#2", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh14_3", "42006513#2", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh14_4", "42006513#2", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh14_5", "42006513#2", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh14_6", "42006513#2", "E#S2", 25.0, "first", "20", "max", C_CYAN), + ("veh14_7", "42006513#2", "E#S2", 30.0, "first", "20", "max", C_OCEAN), + ("veh14_8", "42006513#2", "E#S2", 35.0, "first", "20", "max", C_VIOLET), + ("veh14_9", "42006513#2", "E#S2", 40.0, "first", "20", "max", C_MAGENTA), + + ("veh15_1", "-30689314#4", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh15_2", "-30689314#4", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh15_3", "-30689314#4", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh15_4", "-30689314#4", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh15_5", "-30689314#4", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh15_6", "-30689314#4", "E#S2", 25.0, "first", "20", "max", C_CYAN), + ("veh15_7", "-30689314#4", "E#S2", 30.0, "first", "20", "max", C_OCEAN), + + ("veh16_1", "-42006513#3", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh16_2", "-42006513#3", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh16_3", "-42006513#3", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh16_4", "-42006513#3", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh16_5", "-42006513#3", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh17_1", "42006513#3", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh17_2", "42006513#3", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh17_3", "42006513#3", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh18_1", "42006734#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh18_2", "42006734#0", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh18_3", "42006734#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh18_4", "42006734#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh18_5", "42006734#0", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh18_6", "42006734#0", "E#S2", 25.0, "first", "20", "max", C_CYAN), + ("veh18_7", "42006734#0", "E#S2", 30.0, "first", "20", "max", C_OCEAN), + ("veh18_8", "42006734#0", "E#S2", 35.0, "first", "20", "max", C_VIOLET), + + ("veh19_1", "-42006513#4", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh19_2", "-42006513#4", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh19_3", "-42006513#4", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh20_1", "42006513#4", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh20_2", "42006513#4", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + + ("veh21_1", "30689314#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh21_2", "30689314#0", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh21_3", "30689314#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh21_4", "30689314#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + + ("veh22_1", "-30689314#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh22_2", "-30689314#0", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh22_3", "-30689314#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh22_4", "-30689314#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + + ("veh23_1", "42006734#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh23_2", "42006734#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh23_3", "42006734#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh23_4", "42006734#1", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + + ("veh24_1", "42006713#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh24_2", "42006713#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh24_3", "42006713#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh25_1", "42006701#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh25_2", "42006701#0", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh25_3", "42006701#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh25_4", "42006701#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh25_5", "42006701#0", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh26_1", "479505716#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh26_2", "479505716#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh26_3", "479505716#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh26_4", "479505716#1", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + + ("veh27_1", "-479505716#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh27_2", "-479505716#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh27_3", "-479505716#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh27_4", "-479505716#1", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + + ("veh28_1", "42006734#2", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh28_2", "42006734#2", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh28_3", "42006734#2", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh29_1", "42006734#2", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh29_2", "42006734#2", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + + ("veh30_1", "-42006522#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh30_2", "-42006522#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh30_3", "-42006522#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh31_1", "42006522#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh31_2", "42006522#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + + ("veh32_1", "42006636#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh32_2", "42006636#0", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh32_3", "42006636#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh32_4", "42006636#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh32_5", "42006636#0", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh33_1", "-966804140", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh33_2", "-966804140", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh33_3", "-966804140", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh34_1", "42006708", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh34_2", "42006708", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh34_3", "42006708", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh35_1", "479505354#2", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh35_2", "479505354#2", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh35_3", "479505354#2", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh36_1", "-42006660", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh36_2", "-42006660", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh36_3", "-42006660", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh36_4", "-42006660", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh36_5", "-42006660", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh37_1", "42006589", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh37_2", "42006589", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh37_3", "42006589", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh38_1", "42006572", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh38_2", "42006572", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + + ("veh39_1", "42006733", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh39_2", "42006733", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh39_3", "42006733", "E#S2", 10.0, "first", "20", "max", C_GREEN), + + ("veh40_1", "42006506", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh40_2", "42006506", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh40_3", "42006506", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh40_4", "42006506", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh40_5", "42006506", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh41_1", "-42006549#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh41_2", "-42006549#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh41_3", "-42006549#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh41_4", "-42006549#1", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh41_5", "-42006549#1", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh41_6", "-42006549#1", "E#S2", 25.0, "first", "20", "max", C_CYAN), + ("veh41_7", "-42006549#1", "E#S2", 30.0, "first", "20", "max", C_OCEAN), + ("veh41_8", "-42006549#1", "E#S2", 35.0, "first", "20", "max", C_VIOLET), + + ("veh42_1", "-42006552#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh42_2", "-42006552#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh42_3", "-42006552#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh42_4", "-42006552#1", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh42_5", "-42006552#1", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh43_1", "42006552#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh43_2", "42006552#0", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh43_3", "42006552#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh43_4", "42006552#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh43_5", "42006552#0", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh44_1", "-42006552#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh44_2", "-42006552#0", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh44_3", "-42006552#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh44_4", "-42006552#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh44_5", "-42006552#0", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh45_1", "-42006706#0", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh45_2", "-42006706#0", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh45_3", "-42006706#0", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh45_4", "-42006706#0", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh45_5", "-42006706#0", "E#S2", 20.0, "first", "20", "max", C_SPRING), + + ("veh46_1", "42006706#1", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh46_2", "42006706#1", "E#S2", 5.0, "first", "20", "max", C_YELLOW), + ("veh46_3", "42006706#1", "E#S2", 10.0, "first", "20", "max", C_GREEN), + ("veh46_4", "42006706#1", "E#S2", 15.0, "first", "20", "max", C_ORANGE), + ("veh46_5", "42006706#1", "E#S2", 20.0, "first", "20", "max", C_SPRING), + ("veh46_6", "42006706#1", "E#S2", 25.0, "first", "20", "max", C_CYAN), + ("veh46_7", "42006706#1", "E#S2", 30.0, "first", "20", "max", C_OCEAN), + + ("veh47_1", "42006592", "E#S2", 0.0, "first", "20", "max", C_RED), + ("veh47_2", "42006592", "E#S2", 5.0, "first", "20", "max", C_YELLOW), ] diff --git a/agentevac/utils/forecast_layer.py b/agentevac/utils/forecast_layer.py index 49a2cda..9498d2e 100644 --- a/agentevac/utils/forecast_layer.py +++ b/agentevac/utils/forecast_layer.py @@ -52,12 +52,12 @@ def _margin_band(margin_m: Optional[float]) -> str: readable description of how close a fire is to a road edge. Thresholds: - - ``None`` : "unknown" - - ≤ 0 m : "inside_predicted_fire" (fire has overtaken the edge) - - ≤ 100 m : "very_close" - - ≤ 300 m : "near" - - ≤ 700 m : "buffered" - - > 700 m : "clear" + - ``None`` : "unknown" + - ≤ 0 m : "inside_predicted_fire" (fire has overtaken the edge) + - ≤ 1200 m : "very_close" + - ≤ 2500 m : "near" + - ≤ 5000 m : "buffered" + - > 5000 m : "clear" Args: margin_m: Minimum distance (metres) from fire edge to road edge. @@ -69,11 +69,11 @@ def _margin_band(margin_m: Optional[float]) -> str: return "unknown" if margin_m <= 0.0: return "inside_predicted_fire" - if margin_m <= 100.0: + if margin_m <= 1200.0: return "very_close" - if margin_m <= 300.0: + if margin_m <= 2500.0: return "near" - if margin_m <= 700.0: + if margin_m <= 5000.0: return "buffered" return "clear" @@ -264,21 +264,22 @@ def render_forecast_briefing( uncertainty = str(belief.get("uncertainty_bucket") or "High") if blocked_edges > 0: - route_clause = f"{blocked_edges} route-head segment(s) may be blocked" + blocked_word = "segment" if blocked_edges == 1 else "segments" + route_clause = f"route ahead has {blocked_edges} blocked {blocked_word}" else: - route_clause = f"route head looks {route_band}" + route_clause = f"route ahead looks {route_band}" if edge_forecast.get("blocked"): - edge_clause = "your current edge may be overtaken" + edge_clause = "your current location may be overtaken by fire" else: - edge_clause = f"your current edge looks {edge_band}" + edge_clause = f"your current location looks {edge_band}" - # Select a tone word based on belief danger probability and uncertainty. + # Select a tone based on belief danger probability and uncertainty. if p_danger >= 0.5: - tone = "Forecast suggests the threat is building" + tone = "Conditions are worsening" elif uncertainty == "High": - tone = "Forecast is uncertain, but conditions may tighten" + tone = "Conditions are uncertain and may tighten" else: - tone = "Forecast suggests a manageable window" + tone = "Conditions appear manageable for now" - return f"{tone} within {horizon_s}s: {edge_clause}, and {route_clause}." + return f"{tone} (forecast horizon {horizon_s}s): {edge_clause}, and {route_clause}." diff --git a/agentevac/utils/replay.py b/agentevac/utils/replay.py index 98a6812..157ac78 100644 --- a/agentevac/utils/replay.py +++ b/agentevac/utils/replay.py @@ -51,6 +51,7 @@ def __init__(self, mode: str, path: str): self._dialog_csv_writer = None self._schedule = {} # step_idx -> veh_id -> route_change record self._departure_schedule = {} # step_idx -> veh_id -> departure_release record + self._edge_traces: Dict[str, List[str]] = {} # veh_id -> ordered edge list if self.mode == "record": self.path = self._build_record_path(path) @@ -79,7 +80,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._departure_schedule = self._load_schedule(self.path) + self._schedule, self._departure_schedule, self._edge_traces = self._load_schedule(self.path) else: raise ValueError(f"Unknown RUN_MODE={mode}. Use 'record' or 'replay'.") @@ -114,8 +115,9 @@ def close(self): def _load_schedule(path: str): """Load replayable events from a JSONL file by step index. - Replay currently consumes ``route_change`` and ``departure_release`` events. - All other events (cognition, metrics snapshots, dialogs) are silently ignored. + Replay consumes ``route_change``, ``departure_release``, and ``edge_trace`` + events. All other events (cognition, metrics snapshots, dialogs) are silently + ignored. Args: path: Path to the recorded JSONL file. @@ -124,9 +126,11 @@ def _load_schedule(path: str): Tuple of dicts: - ``route_schedule``: ``step_idx`` → {``veh_id`` → route-change record} - ``departure_schedule``: ``step_idx`` → {``veh_id`` → departure record} + - ``edge_traces``: ``veh_id`` → ordered list of edges traversed """ route_schedule = {} departure_schedule = {} + edge_traces: Dict[str, List[str]] = {} with open(path, "r", encoding="utf-8") as f: for line in f: line = line.strip() @@ -142,7 +146,10 @@ def _load_schedule(path: str): step = int(rec["step"]) vid = rec["veh_id"] departure_schedule.setdefault(step, {})[vid] = rec - return route_schedule, departure_schedule + elif event == "edge_trace": + vid = rec["veh_id"] + edge_traces[vid] = list(rec.get("edges") or []) + return route_schedule, departure_schedule, edge_traces @staticmethod def _build_record_path(base_path: str) -> str: @@ -323,6 +330,31 @@ def has_departure_schedule(self) -> bool: """Whether the loaded replay log contains explicit departure-release events.""" return bool(self._departure_schedule) + def record_edge_trace(self, veh_id: str, edges: List[str]) -> None: + """Log the complete sequence of edges a vehicle actually traversed. + + Written once per vehicle (at arrival or simulation end). In replay mode, + the edge trace is loaded by ``_load_schedule`` and used to set the vehicle's + route to the exact edge sequence from the original run. + + Args: + veh_id: Vehicle ID. + edges: Ordered list of SUMO edge IDs the vehicle crossed. + """ + self._write_jsonl({ + "event": "edge_trace", + "veh_id": str(veh_id), + "edges": list(edges), + }) + + def get_edge_trace(self, veh_id: str) -> Optional[List[str]]: + """Return the recorded edge trace for a vehicle, or ``None``.""" + return self._edge_traces.get(str(veh_id)) + + def has_edge_traces(self) -> bool: + """Whether the loaded replay log contains edge-trace events.""" + return bool(self._edge_traces) + def record_metric_snapshot( self, step: int, diff --git a/scripts/run_rq1_info_quality.sh b/scripts/run_rq1_info_quality.sh index f57992c..f15c7c4 100755 --- a/scripts/run_rq1_info_quality.sh +++ b/scripts/run_rq1_info_quality.sh @@ -31,15 +31,14 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" -SEEDS=(12345 12346 12347 12348 12349) - -for seed in "${SEEDS[@]}"; do +for seed in 12345 12346 12347 12348 12349; do echo "============================================" echo "[RQ1] seed=${seed}" echo "============================================" - SUMO_SEED="$seed" python3 -m agentevac.analysis.experiments \ + python3 -m agentevac.analysis.experiments \ --output-dir "outputs/rq1/info_quality_seed_${seed}" \ --sumo-binary sumo \ + --sumo-seed "$seed" \ --sigma-values 0,20,40,80 \ --delay-values 0,15,30,60 \ --trust-values 0.5 \ diff --git a/scripts/run_rq2_social_trust.sh b/scripts/run_rq2_social_trust.sh index 6f82895..afca6a0 100755 --- a/scripts/run_rq2_social_trust.sh +++ b/scripts/run_rq2_social_trust.sh @@ -38,16 +38,15 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" -SEEDS=(12345 12346 12347 12348 12349) - for messaging in on off; do - for seed in "${SEEDS[@]}"; do + for seed in 12345 12346 12347 12348 12349; do echo "============================================" echo "[RQ2] messaging=${messaging} seed=${seed}" echo "============================================" - SUMO_SEED="$seed" python3 -m agentevac.analysis.experiments \ + python3 -m agentevac.analysis.experiments \ --output-dir "outputs/rq2/social_trust_msg_${messaging}_seed_${seed}" \ --sumo-binary sumo \ + --sumo-seed "$seed" \ --sigma-values 40 \ --delay-values 30 \ --trust-values 0.0,0.25,0.5,0.75,1.0 \ diff --git a/scripts/run_rq3_pareto.sh b/scripts/run_rq3_pareto.sh index 375e1c7..9db8748 100755 --- a/scripts/run_rq3_pareto.sh +++ b/scripts/run_rq3_pareto.sh @@ -38,15 +38,14 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" -SEEDS=(12345 12346 12347 12348 12349) - -for seed in "${SEEDS[@]}"; do +for seed in 12345 12346 12347 12348 12349; do echo "============================================" echo "[RQ3] seed=${seed}" echo "============================================" - SUMO_SEED="$seed" python3 -m agentevac.analysis.experiments \ + python3 -m agentevac.analysis.experiments \ --output-dir "outputs/rq3/pareto_seed_${seed}" \ --sumo-binary sumo \ + --sumo-seed "$seed" \ --sigma-values 20,40,80 \ --delay-values 0,30,60 \ --trust-values 0.25,0.5,0.75 \ diff --git a/scripts/run_rq4_heterogeneity.sh b/scripts/run_rq4_heterogeneity.sh index a923262..35ce6f9 100755 --- a/scripts/run_rq4_heterogeneity.sh +++ b/scripts/run_rq4_heterogeneity.sh @@ -45,39 +45,35 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)" cd "$ROOT_DIR" -SEEDS=(12345 12346 12347 12348 12349 12350 12351 12352 12353 12354) - -# Spread level definitions: (label, theta_trust, theta_r, theta_u, gamma, lambda_e, lambda_t) -SPREAD_LABELS=( "none" "low" "moderate" "high" ) -SPREAD_TRUST=( "0.0" "0.05" "0.12" "0.20" ) -SPREAD_TR=( "0.0" "0.03" "0.08" "0.15" ) -SPREAD_TU=( "0.0" "0.03" "0.08" "0.15" ) -SPREAD_GAMMA=( "0.0" "0.001" "0.003" "0.005") -SPREAD_LE=( "0.0" "0.15" "0.4" "0.8" ) -SPREAD_LT=( "0.0" "0.03" "0.08" "0.15" ) - -for i in "${!SPREAD_LABELS[@]}"; do - spread="${SPREAD_LABELS[$i]}" - for seed in "${SEEDS[@]}"; do +# Run each spread level as a separate block to avoid bash indexed arrays. +run_spread() { + local spread="$1" + local s_trust="$2" s_tr="$3" s_tu="$4" s_gamma="$5" s_le="$6" s_lt="$7" + for seed in 12345 12346 12347 12348 12349 12350 12351 12352 12353 12354; do echo "============================================" echo "[RQ4] spread=${spread} seed=${seed}" echo "============================================" - SUMO_SEED="$seed" \ - THETA_TRUST_SPREAD="${SPREAD_TRUST[$i]}" \ - THETA_R_SPREAD="${SPREAD_TR[$i]}" \ - THETA_U_SPREAD="${SPREAD_TU[$i]}" \ - GAMMA_SPREAD="${SPREAD_GAMMA[$i]}" \ - LAMBDA_E_SPREAD="${SPREAD_LE[$i]}" \ - LAMBDA_T_SPREAD="${SPREAD_LT[$i]}" \ + THETA_TRUST_SPREAD="$s_trust" \ + THETA_R_SPREAD="$s_tr" \ + THETA_U_SPREAD="$s_tu" \ + GAMMA_SPREAD="$s_gamma" \ + LAMBDA_E_SPREAD="$s_le" \ + LAMBDA_T_SPREAD="$s_lt" \ python3 -m agentevac.analysis.experiments \ --output-dir "outputs/rq4/heterogeneity_spread_${spread}_seed_${seed}" \ --sumo-binary sumo \ + --sumo-seed "$seed" \ --sigma-values 40 \ --delay-values 30 \ --trust-values 0.5 \ --scenario-values no_notice,alert_guided,advice_guided \ --messaging on done -done +} + +run_spread none 0.0 0.0 0.0 0.0 0.0 0.0 +run_spread low 0.05 0.03 0.03 0.001 0.15 0.03 +run_spread moderate 0.12 0.08 0.08 0.003 0.4 0.08 +run_spread high 0.20 0.15 0.15 0.005 0.8 0.15 echo "[RQ4] All conditions complete." diff --git a/sumo/Repaired.net.xml b/sumo/Repaired.net.xml index 55e28ab..7ed8640 100644 --- a/sumo/Repaired.net.xml +++ b/sumo/Repaired.net.xml @@ -1,6 +1,6 @@ - diff --git a/sumo/Repaired.sumocfg b/sumo/Repaired.sumocfg index 65c0438..83a24a1 100644 --- a/sumo/Repaired.sumocfg +++ b/sumo/Repaired.sumocfg @@ -1,6 +1,6 @@ - diff --git a/tests/test_belief_model.py b/tests/test_belief_model.py index 673a9b5..026f88c 100644 --- a/tests/test_belief_model.py +++ b/tests/test_belief_model.py @@ -18,7 +18,7 @@ class TestCategorizeHazardState: def test_safe_margin_gives_high_p_safe(self): - result = categorize_hazard_state({"observed_margin_m": 800.0}) + result = categorize_hazard_state({"observed_margin_m": 8000.0}) assert result["p_safe"] > result["p_danger"] def test_danger_margin_gives_high_p_danger(self): @@ -32,7 +32,7 @@ def test_probabilities_sum_to_one(self): assert abs(total - 1.0) < 1e-9, f"margin={m}: sum={total}" def test_falls_back_to_base_margin_m(self): - result = categorize_hazard_state({"base_margin_m": 800.0}) + result = categorize_hazard_state({"base_margin_m": 8000.0}) assert result["p_safe"] > result["p_danger"] def test_missing_margin_returns_uniform(self): @@ -42,7 +42,7 @@ def test_missing_margin_returns_uniform(self): def test_closer_margin_implies_higher_danger(self): close = categorize_hazard_state({"observed_margin_m": 50.0}) - far = categorize_hazard_state({"observed_margin_m": 800.0}) + far = categorize_hazard_state({"observed_margin_m": 8000.0}) assert close["p_danger"] > far["p_danger"] @@ -151,7 +151,7 @@ def _prev_belief(self): return {"p_safe": 1 / 3, "p_risky": 1 / 3, "p_danger": 1 / 3} def _safe_env(self): - return {"observed_margin_m": 900.0} + return {"observed_margin_m": 8000.0} def _danger_env(self): return {"observed_margin_m": 0.0} diff --git a/tests/test_forecast_layer.py b/tests/test_forecast_layer.py index ae86b69..f7c607b 100644 --- a/tests/test_forecast_layer.py +++ b/tests/test_forecast_layer.py @@ -12,7 +12,7 @@ def _safe_edge_risk_fn(edge_id): """Always-safe: not blocked, zero risk, large margin.""" - return (False, 0.0, 1000.0) + return (False, 0.0, 8000.0) def _dangerous_edge_risk_fn(edge_id): @@ -96,11 +96,11 @@ def test_very_close_band(self): assert result["band"] == "very_close" def test_near_band(self): - result = estimate_edge_forecast_risk("e", lambda _: (False, 0.05, 200.0)) + result = estimate_edge_forecast_risk("e", lambda _: (False, 0.05, 1800.0)) assert result["band"] == "near" def test_buffered_band(self): - result = estimate_edge_forecast_risk("e", lambda _: (False, 0.01, 500.0)) + result = estimate_edge_forecast_risk("e", lambda _: (False, 0.01, 3500.0)) assert result["band"] == "buffered" def test_edge_id_forwarded_to_risk_fn(self): @@ -187,14 +187,14 @@ def test_high_danger_uses_threat_tone(self): "v1", self._forecast(), self._belief_high_danger(), self._edge_safe(), self._route_safe() ) - assert "building" in result or "threat" in result.lower() + assert "worsening" in result.lower() def test_high_uncertainty_low_danger_uses_uncertain_tone(self): result = render_forecast_briefing( "v1", self._forecast(), self._belief_uncertain(), self._edge_safe(), self._route_safe() ) - assert "uncertain" in result.lower() or "tighten" in result.lower() + assert "uncertain" in result.lower() def test_blocked_current_edge_mentions_overtaken(self): result = render_forecast_briefing( diff --git a/tests/test_information_model.py b/tests/test_information_model.py index 400a5f4..90443bc 100644 --- a/tests/test_information_model.py +++ b/tests/test_information_model.py @@ -45,7 +45,7 @@ def test_observed_state_danger_when_margin_small(self): assert sig["observed_state"] == "danger" def test_observed_state_safe_when_margin_large(self): - sig = inject_signal_noise({"base_margin_m": 1000.0}, sigma_info=0.0) + sig = inject_signal_noise({"base_margin_m": 5000.0}, sigma_info=0.0) assert sig["observed_state"] == "safe" def test_output_is_shallow_copy(self): diff --git a/tests/test_plot_run_metrics.py b/tests/test_plot_run_metrics.py index fb5e21b..e857f8e 100644 --- a/tests/test_plot_run_metrics.py +++ b/tests/test_plot_run_metrics.py @@ -102,23 +102,23 @@ 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, + "margin_very_close_m": 1200.0, + "margin_near_m": 2500.0, + "margin_buffered_m": 5000.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, + "caution_min_margin_m": 1200.0, + "recommended_min_margin_m": 2500.0, } } ) assert summary is not None assert "Briefing thresholds:" in summary - assert "margin_m=100.0/300.0/700.0" in summary + assert "margin_m=1200.0/2500.0/5000.0" in summary def test_returns_none_without_briefing_payload(self): assert _briefing_summary({}) is None diff --git a/tests/test_routing_utility.py b/tests/test_routing_utility.py index ecbdb96..2134a2b 100644 --- a/tests/test_routing_utility.py +++ b/tests/test_routing_utility.py @@ -115,7 +115,7 @@ def _make_menu(self, n=3, reachable=True): "name": f"shelter_{i}", "risk_sum": float(i), "blocked_edges": 0, - "min_margin_m": 500.0, + "min_margin_m": 6000.0, "travel_time_s_fastest_path": 300.0, "reachable": reachable, } @@ -164,7 +164,7 @@ def test_route_mode_scores_all_items(self): def test_higher_risk_gets_lower_utility(self): menu = [ {"idx": 0, "name": "safe", "risk_sum": 0.0, "blocked_edges": 0, - "min_margin_m": 1000.0, "travel_time_s_fastest_path": 300.0, "reachable": True}, + "min_margin_m": 8000.0, "travel_time_s_fastest_path": 300.0, "reachable": True}, {"idx": 1, "name": "risky", "risk_sum": 5.0, "blocked_edges": 2, "min_margin_m": 10.0, "travel_time_s_fastest_path": 300.0, "reachable": True}, ] @@ -222,6 +222,112 @@ def test_uses_len_edges_fastest_path_fallback(self): _observation_based_exposure(item_b, belief, psych) ) + def test_travel_time_preferred_over_edge_count(self): + belief = _neutral_belief() + psych = _psychology() + # Same edge count, different travel times → different exposure. + fast = {"len_edges": 10, "travel_time_s_fastest_path": 240.0} + slow = {"len_edges": 10, "travel_time_s_fastest_path": 1500.0} + assert _observation_based_exposure(slow, belief, psych) > _observation_based_exposure(fast, belief, psych) + + def test_travel_time_ignores_edge_count(self): + belief = _neutral_belief() + psych = _psychology() + # Different edge counts but same travel time → same exposure. + item_a = {"len_edges": 5, "travel_time_s_fastest_path": 600.0} + item_b = {"len_edges": 50, "travel_time_s_fastest_path": 600.0} + assert _observation_based_exposure(item_a, belief, psych) == pytest.approx( + _observation_based_exposure(item_b, belief, psych) + ) + + def test_longer_travel_time_gives_more_exposure(self): + belief = {"p_safe": 0.1, "p_risky": 0.3, "p_danger": 0.6} + psych = {"perceived_risk": 0.5, "confidence": 0.5} + short = _observation_based_exposure({"travel_time_s_fastest_path": 240.0}, belief, psych) + long = _observation_based_exposure({"travel_time_s_fastest_path": 1500.0}, belief, psych) + assert long > short + + def test_edge_count_fallback_when_no_travel_time(self): + belief = _neutral_belief() + psych = _psychology() + # Without travel_time_s_fastest_path, falls back to len_edges. + item = {"len_edges": 10} + exposure = _observation_based_exposure(item, belief, psych) + # length_factor = 10 * 0.15 = 1.5 + assert exposure > 0.0 + + +class TestVisualFireObservationPenalty: + """Tests for the visual fire observation penalty in _observation_based_exposure.""" + + def _base_item(self): + return {"len_edges": 5} + + def test_no_visual_fields_gives_zero_visual_penalty(self): + item = self._base_item() + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(item, belief, psych) + # Without visual fields, exposure is purely belief-based. + item2 = {**self._base_item(), "visual_blocked_edges": 0} + with_visual = _observation_based_exposure(item2, belief, psych) + # No blocked edges and no margin → no margin penalty added, so equal. + assert base == pytest.approx(with_visual) + + def test_visual_blocked_edges_adds_heavy_penalty(self): + belief = _neutral_belief() + psych = _psychology() + no_block = _observation_based_exposure(self._base_item(), belief, psych) + blocked = _observation_based_exposure( + {**self._base_item(), "visual_blocked_edges": 2, "visual_min_margin_m": 0.0}, + belief, psych, + ) + # 2 * 8.0 + margin_penalty(0) = 16 + 5.0 = 21.0 extra + assert blocked > no_block + 20.0 + + def test_visual_close_margin_adds_penalty(self): + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(self._base_item(), belief, psych) + close_fire = _observation_based_exposure( + {**self._base_item(), "visual_blocked_edges": 0, "visual_min_margin_m": 500.0}, + belief, psych, + ) + # margin 500 < 1200 → margin_penalty = 3.0 + assert close_fire == pytest.approx(base + 3.0) + + def test_visual_far_margin_adds_small_penalty(self): + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(self._base_item(), belief, psych) + far_fire = _observation_based_exposure( + {**self._base_item(), "visual_blocked_edges": 0, "visual_min_margin_m": 8000.0}, + belief, psych, + ) + # margin 8000 > 5000 → margin_penalty = 0.15 + assert far_fire == pytest.approx(base + 0.15) + + def test_visual_none_margin_no_margin_penalty(self): + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(self._base_item(), belief, psych) + no_fire = _observation_based_exposure( + {**self._base_item(), "visual_blocked_edges": 0, "visual_min_margin_m": None}, + belief, psych, + ) + # No blocked edges and margin is None → no extra penalty. + assert no_fire == pytest.approx(base) + + def test_visual_penalty_only_affects_item_with_fields(self): + """Items without visual fields should not be affected.""" + belief = _neutral_belief() + psych = _psychology() + item_current = {**self._base_item(), "visual_blocked_edges": 2, "visual_min_margin_m": 0.0} + item_other = self._base_item() + e_current = _observation_based_exposure(item_current, belief, psych) + e_other = _observation_based_exposure(item_other, belief, psych) + assert e_current > e_other + class TestAnnotateMenuScenarioParam: """Tests that the scenario parameter selects the correct exposure function.""" @@ -286,3 +392,89 @@ def test_default_scenario_is_advice_guided(self): assert menu_default[0]["expected_utility"] == pytest.approx( menu_explicit[0]["expected_utility"] ) + + +class TestProximityFirePerceptionPenalty: + """Tests for the proximity fire perception penalty in _observation_based_exposure.""" + + def _base_item(self): + return {"len_edges": 5} + + def test_no_proximity_fields_gives_no_penalty(self): + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(self._base_item(), belief, psych) + # Without proximity fields, no extra penalty. + item2 = {**self._base_item(), "proximity_blocked_edges": 0} + with_prox = _observation_based_exposure(item2, belief, psych) + assert base == pytest.approx(with_prox) + + def test_proximity_blocked_edges_adds_heavy_penalty(self): + belief = _neutral_belief() + psych = _psychology() + no_block = _observation_based_exposure(self._base_item(), belief, psych) + blocked = _observation_based_exposure( + {**self._base_item(), "proximity_blocked_edges": 3, "proximity_min_margin_m": 0.0}, + belief, psych, + ) + # 3 * 8.0 + margin_penalty(0) = 24.0 + 5.0 = 29.0 extra + assert blocked > no_block + 28.0 + + def test_proximity_close_margin_adds_penalty(self): + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(self._base_item(), belief, psych) + close_fire = _observation_based_exposure( + {**self._base_item(), "proximity_blocked_edges": 0, "proximity_min_margin_m": 800.0}, + belief, psych, + ) + # margin 800 <= 1200 → margin_penalty = 3.0 + assert close_fire == pytest.approx(base + 3.0) + + def test_proximity_far_margin_adds_small_penalty(self): + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(self._base_item(), belief, psych) + far = _observation_based_exposure( + {**self._base_item(), "proximity_blocked_edges": 0, "proximity_min_margin_m": 6000.0}, + belief, psych, + ) + # margin 6000 > 5000 → margin_penalty = 0.15 + assert far == pytest.approx(base + 0.15) + + def test_proximity_none_margin_no_margin_penalty(self): + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(self._base_item(), belief, psych) + no_margin = _observation_based_exposure( + {**self._base_item(), "proximity_blocked_edges": 0, "proximity_min_margin_m": None}, + belief, psych, + ) + assert no_margin == pytest.approx(base) + + def test_proximity_applies_to_all_items(self): + """Proximity data should affect any item that has the fields, unlike visual + which is limited to the current destination only.""" + belief = _neutral_belief() + psych = _psychology() + item_safe = {**self._base_item(), "proximity_blocked_edges": 0, "proximity_min_margin_m": 8000.0} + item_dangerous = {**self._base_item(), "proximity_blocked_edges": 2, "proximity_min_margin_m": 0.0} + e_safe = _observation_based_exposure(item_safe, belief, psych) + e_dangerous = _observation_based_exposure(item_dangerous, belief, psych) + assert e_dangerous > e_safe + + def test_proximity_and_visual_penalties_stack(self): + """When both visual and proximity fields are present, both penalties apply.""" + belief = _neutral_belief() + psych = _psychology() + base = _observation_based_exposure(self._base_item(), belief, psych) + both = _observation_based_exposure( + { + **self._base_item(), + "visual_blocked_edges": 1, "visual_min_margin_m": 0.0, + "proximity_blocked_edges": 1, "proximity_min_margin_m": 0.0, + }, + belief, psych, + ) + # visual: 1*8 + 5.0 = 13.0; proximity: 1*8 + 5.0 = 13.0; total extra = 26.0 + assert both == pytest.approx(base + 26.0)