diff --git a/.gitignore b/.gitignore index 36e1b0d..5a3ffba 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ venv/ Wildfire_Proposal.pdf .idea/ .venv/ +Research_Proposal_0317.pptx +sumo/halifax.net.xml +sumo/Halifax_buildings.xml diff --git a/agentevac/agents/agent_state.py b/agentevac/agents/agent_state.py index ff7d92b..12c7928 100644 --- a/agentevac/agents/agent_state.py +++ b/agentevac/agents/agent_state.py @@ -47,11 +47,14 @@ class AgentRuntimeState: derived fields (entropy, entropy_norm, uncertainty_bucket). psychology: Scalar summaries derived from the belief (perceived_risk, confidence). signal_history: Bounded list of recent environment signals (noisy margin observations). - Used by the delay model to replay stale observations. social_history: Bounded list of recent social signals derived from inbox messages. decision_history: Bounded list of past decision records (predeparture + routing). Passed to the LLM as ``agent_self_history`` so agents can avoid repeated mistakes. observation_history: Bounded list of system-generated local neighborhood observations. + institutional_history: Bounded list of institutional snapshots (forecast + annotated + menu) pushed each decision round. Used by + ``information_model.apply_institutional_delay`` to serve stale official + information when ``INFO_DELAY_S > 0``. has_departed: True once the vehicle has been added to the SUMO simulation. """ @@ -65,6 +68,7 @@ class AgentRuntimeState: social_history: List[Dict[str, Any]] = field(default_factory=list) decision_history: List[Dict[str, Any]] = field(default_factory=list) observation_history: List[Dict[str, Any]] = field(default_factory=list) + institutional_history: List[Dict[str, Any]] = field(default_factory=list) has_departed: bool = True last_input_hash: Optional[int] = None last_llm_choice_idx: Optional[int] = None @@ -237,9 +241,7 @@ def append_signal_history( ) -> None: """Append an environment signal record to the agent's signal history. - The history is bounded to ``max_items`` entries (default 16). The delay model in - ``information_model.apply_signal_delay`` uses this history to retrieve stale - observations when ``INFO_DELAY_S > 0``. + The history is bounded to ``max_items`` entries (default 16). Args: state: The agent whose history to update. @@ -307,6 +309,26 @@ def append_observation_history( _append_bounded(state.observation_history, observation, max_items) +def append_institutional_history( + state: AgentRuntimeState, + snapshot: Dict[str, Any], + *, + max_items: int = 16, +) -> None: + """Append an institutional snapshot to the agent's institutional history. + + Each snapshot contains the forecast and annotated destination/route menu + produced for that decision round. Used by ``apply_institutional_delay`` + to serve stale official information when ``INFO_DELAY_S > 0``. + + Args: + state: The agent whose history to update. + snapshot: Dict with ``forecast`` and ``annotated_menu`` keys. + max_items: Maximum number of snapshots to retain. + """ + _append_bounded(state.institutional_history, snapshot, max_items) + + def snapshot_agent_state(state: AgentRuntimeState) -> Dict[str, Any]: """Serialize an ``AgentRuntimeState`` to a plain dict for logging or replay. @@ -331,5 +353,6 @@ def snapshot_agent_state(state: AgentRuntimeState) -> Dict[str, Any]: "social_history": [dict(item) for item in state.social_history], "decision_history": [dict(item) for item in state.decision_history], "observation_history": [dict(item) for item in state.observation_history], + "institutional_history": [dict(item) for item in state.institutional_history], "has_departed": bool(state.has_departed), } diff --git a/agentevac/agents/information_model.py b/agentevac/agents/information_model.py index 7ac575c..2372204 100644 --- a/agentevac/agents/information_model.py +++ b/agentevac/agents/information_model.py @@ -1,14 +1,24 @@ -"""Information sensing and social signal processing for evacuation agents. +"""Information sensing, institutional delay, and social signal processing for evacuation agents. -This module handles the two information streams available to each agent each decision round: +This module handles the information streams available to each agent each decision round: **Environmental signals** (``sample_environment_signal``): The agent observes the closest fire margin on its current edge and the minimum margin across the head of its planned route. Gaussian noise (``sigma_info`` metres, std-dev) is injected to model imperfect sensing — e.g., smoke obscuring visibility or GPS - inaccuracy. An optional delay (``INFO_DELAY_S`` seconds, converted to ``delay_rounds``) - is applied by replaying a stale record from the agent's signal history, simulating - delayed emergency broadcasts or slow rumour propagation. + inaccuracy. Environmental signals are always **real-time** — personal observation + has no institutional delay. + +**Institutional delay** (``apply_institutional_delay``): + Official information channels (fire forecasts, route guidance, advisory labels, + expected-utility scores) are subject to ``INFO_DELAY_S`` seconds of institutional + delay. When ``delay_rounds > 0``, the agent receives a stale snapshot from + ``delay_rounds`` decision periods ago. When the agent's history is too short + (i.e., fewer rounds have elapsed than the delay), **no institutional information + is available** — the agent operates as if in a ``no_notice`` regime on that channel. + This models the real-world lag in emergency management information production and + dissemination. In ``no_notice`` mode the institutional channel is already invisible, + so the delay has no additional effect. **Social signals** (``build_social_signal``): Peer messages from the agent's inbox are parsed with a simple keyword-vote approach. @@ -150,6 +160,46 @@ def apply_signal_delay( return out +def apply_institutional_delay( + history: List[Dict[str, Any]], + delay_rounds: int, +) -> Optional[Dict[str, Any]]: + """Resolve the institutional snapshot the agent should see this round. + + Institutional snapshots contain official forecast and annotated menu data + pushed each decision round. When ``delay_rounds > 0``, the agent receives + the snapshot from ``delay_rounds`` periods ago. + + Unlike ``apply_signal_delay``, this function returns ``None`` when the + history is too short — the agent has not yet accumulated enough rounds for + the first delayed report to arrive. The caller should treat ``None`` as + "no institutional information available" and present a ``no_notice``-grade + view to the agent. + + Args: + history: The agent's bounded ``institutional_history`` list (oldest-first). + delay_rounds: Number of decision periods of institutional lag. + When 0, returns ``None`` — caller should use the current (real-time) + snapshot directly. + + Returns: + A stale institutional snapshot dict when available, or ``None`` when + either ``delay_rounds == 0`` (use current) or history is too short + (information not yet available). + """ + delay = max(0, int(delay_rounds)) + if delay <= 0: + # No delay — caller uses the current snapshot directly. + return None + if delay <= len(history): + snapshot = dict(history[-delay]) + snapshot["is_delayed"] = True + snapshot["delay_rounds_applied"] = delay + return snapshot + # History too short — institutional information has not arrived yet. + return None + + def sample_environment_signal( agent_id: str, sim_t_s: float, diff --git a/agentevac/agents/messaging.py b/agentevac/agents/messaging.py new file mode 100644 index 0000000..4fe56cf --- /dev/null +++ b/agentevac/agents/messaging.py @@ -0,0 +1,257 @@ +"""Inter-agent natural-language messaging bus. + +Extracted from ``agentevac.simulation.main`` so that it can be tested +independently of SUMO / TraCI. +""" + +from __future__ import annotations + +from typing import Any, Dict, List, Optional, Tuple + +from pydantic import BaseModel, Field + + +class OutboxMessage(BaseModel): + to: str = Field(..., description="Recipient vehicle ID, or '*' for broadcast to all active agents.") + message: str = Field(..., description="Natural-language message content.") + + +class AgentMessagingBus: + """Inter-agent natural-language messaging bus with delivery-round scheduling. + + Agents include optional outbox items in their LLM response. The bus accepts + these messages at round R and delivers them at round R+1 (one-round latency), + simulating realistic communication delay. + + **Direct messages** (``to`` = specific vehicle ID): + Delivered only to the named recipient if active at delivery time. + Undelivered messages are held for up to ``ttl_rounds`` additional rounds, + then dropped. + + **Broadcasts** (``to`` = ``"*"``, ``"all"``, or ``"broadcast"``): + Fanned out to all round participants known at the time of sending (not of delivery). + This includes both active vehicles and not-yet-departed households participating + in the current decision round. + A global cap (``max_broadcasts_per_round``) limits broadcast flooding. + + Per-agent message caps (``max_sends_per_agent_per_round``) prevent a single + agent from saturating the bus. Inboxes are capped at ``max_inbox_messages`` + entries; older messages are dropped from the front. + + Args: + enabled: If ``False``, all methods are no-ops and inboxes are always empty. + max_message_chars: Maximum length of a single message body (truncated). + max_inbox_messages: Maximum messages retained per agent inbox. + max_sends_per_agent_per_round: Per-agent send cap per decision round. + max_broadcasts_per_round: Global broadcast cap per decision round. + ttl_rounds: Rounds a direct message waits for an offline recipient. + comm_radius_m: Spatial broadcast radius in metres. ``0`` disables + spatial filtering (broadcasts reach all agents). Any positive value + restricts broadcast delivery to agents within this Euclidean distance + of the sender. Direct messages are never filtered. + event_stream: Optional event emitter (must have an ``emit`` method). + """ + + def __init__( + self, + enabled: bool, + max_message_chars: int, + max_inbox_messages: int, + max_sends_per_agent_per_round: int, + max_broadcasts_per_round: int, + ttl_rounds: int, + comm_radius_m: float = 0.0, + event_stream: Any = None, + ): + self.enabled = bool(enabled) + self.max_message_chars = max(1, int(max_message_chars)) + self.max_inbox_messages = max(1, int(max_inbox_messages)) + self.max_sends_per_agent_per_round = max(1, int(max_sends_per_agent_per_round)) + self.max_broadcasts_per_round = max(1, int(max_broadcasts_per_round)) + self.ttl_rounds = max(1, int(ttl_rounds)) + self.comm_radius_m = max(0.0, float(comm_radius_m)) + self._comm_radius_sq = self.comm_radius_m * self.comm_radius_m + + self._pending: List[Dict[str, Any]] = [] + self._inboxes: Dict[str, List[Dict[str, Any]]] = {} + self._active_agents: List[str] = [] + self._positions: Dict[str, Tuple[float, float]] = {} + self._current_round = 0 + self._broadcast_count = 0 + self._sender_sent_count: Dict[str, int] = {} + self._sender_seq: Dict[str, int] = {} + self._events = event_stream + + def _next_sender_seq(self, sender: str) -> int: + nxt = self._sender_seq.get(sender, 0) + 1 + self._sender_seq[sender] = nxt + return nxt + + def _push_inbox(self, recipient: str, msg: Dict[str, Any]): + inbox = self._inboxes.setdefault(recipient, []) + inbox.append({ + "from": msg["from"], + "to": msg["to"], + "message": msg["message"], + "kind": "broadcast" if msg["is_broadcast"] else "direct", + "sent_round": msg["sent_round"], + "delivery_round": msg["deliver_round"], + }) + if len(inbox) > self.max_inbox_messages: + self._inboxes[recipient] = inbox[-self.max_inbox_messages:] + if self._events: + self._events.emit( + "message_delivered", + summary=f"{msg['from']} -> {recipient}", + from_id=msg["from"], + to_id=recipient, + kind="broadcast" if msg["is_broadcast"] else "direct", + sent_round=msg["sent_round"], + delivery_round=msg["deliver_round"], + message=msg["message"], + ) + + def begin_round( + self, + round_idx: int, + participant_agent_ids: List[str], + positions: Optional[Dict[str, Tuple[float, float]]] = None, + ): + """ + Start decision round R: + - deliver messages scheduled for <= R + - reset per-round send counters + - store agent positions for spatial broadcast filtering + Messages generated in round R are delivered at R+1. + """ + if not self.enabled: + return + + self._current_round = int(round_idx) + self._active_agents = list(participant_agent_ids) + self._positions = dict(positions) if positions else {} + participant_set = set(participant_agent_ids) + self._broadcast_count = 0 + self._sender_sent_count = {} + + remaining: List[Dict[str, Any]] = [] + for msg in self._pending: + if msg["deliver_round"] > self._current_round: + remaining.append(msg) + continue + + recipient = msg["to"] + if recipient in participant_set: + self._push_inbox(recipient, msg) + continue + + # Broadcast fanout is only to known round participants at send-time. + if msg["is_broadcast"]: + continue + + # Direct messages may wait for the recipient to appear (TTL-bound). + if self._current_round <= msg["expire_round"]: + remaining.append(msg) + + self._pending = remaining + + def get_inbox(self, agent_id: str) -> List[Dict[str, Any]]: + """Return a copy of the agent's inbox (delivered messages). + + Args: + agent_id: Vehicle ID. + + Returns: + List of message dicts; empty list if messaging is disabled or inbox is empty. + """ + if not self.enabled: + return [] + return list(self._inboxes.get(agent_id, [])) + + def queue_outbox(self, sender: str, outbox: Optional[List[OutboxMessage]]): + """ + Accept sender's outbox for current round R and enqueue for delivery at R+1. + Enforces per-sender and global messaging caps. + """ + if (not self.enabled) or (not outbox): + return + + sender_count = self._sender_sent_count.get(sender, 0) + for raw in outbox: + if sender_count >= self.max_sends_per_agent_per_round: + break + + recipient = str(getattr(raw, "to", "")).strip() + recipient_norm = recipient.lower() + text = str(getattr(raw, "message", "")).strip() + if not recipient or not text: + continue + if len(text) > self.max_message_chars: + text = text[:self.max_message_chars] + + sender_seq = self._next_sender_seq(sender) + + if recipient in {"*", "__all__"} or recipient_norm in {"all", "broadcast"}: + if self._broadcast_count >= self.max_broadcasts_per_round: + continue + self._broadcast_count += 1 + sender_count += 1 + self._sender_sent_count[sender] = sender_count + + # Spatial filtering: if comm_radius_m > 0, only fan out + # to agents within Euclidean range of the sender. + _s_pos = self._positions.get(sender) if self._comm_radius_sq > 0 else None + for target in self._active_agents: + if target == sender: + continue + # --- spatial range check (skip if radius disabled or positions unknown) --- + if _s_pos is not None: + _t_pos = self._positions.get(target) + if _t_pos is not None: + _dx = _s_pos[0] - _t_pos[0] + _dy = _s_pos[1] - _t_pos[1] + if (_dx * _dx + _dy * _dy) > self._comm_radius_sq: + continue + if self._events: + self._events.emit( + "message_queued", + summary=f"{sender} -> {target}", + from_id=sender, + to_id=target, + kind="broadcast", + deliver_round=self._current_round + 1, + message=text, + ) + self._pending.append({ + "from": sender, + "to": target, + "message": text, + "sent_round": self._current_round, + "deliver_round": self._current_round + 1, + "expire_round": self._current_round + 1, + "sender_seq": sender_seq, + "is_broadcast": True, + }) + else: + sender_count += 1 + self._sender_sent_count[sender] = sender_count + if self._events: + self._events.emit( + "message_queued", + summary=f"{sender} -> {recipient}", + from_id=sender, + to_id=recipient, + kind="direct", + deliver_round=self._current_round + 1, + message=text, + ) + self._pending.append({ + "from": sender, + "to": recipient, + "message": text, + "sent_round": self._current_round, + "deliver_round": self._current_round + 1, + "expire_round": self._current_round + self.ttl_rounds, + "sender_seq": sender_seq, + "is_broadcast": False, + }) diff --git a/agentevac/agents/routing_utility.py b/agentevac/agents/routing_utility.py index 04c8f44..6d0f68d 100644 --- a/agentevac/agents/routing_utility.py +++ b/agentevac/agents/routing_utility.py @@ -13,8 +13,10 @@ - ``C(option)`` : travel cost in equivalent minutes (``_travel_cost``). Expected exposure combines four components (in ``alert_guided`` and ``advice_guided``): - 1. **risk_sum** : Sum of edge-level fire risk scores along the route (or fastest path). - Scaled by a severity multiplier that increases with ``p_risky`` and ``p_danger``. + 1. **risk_density** : Average edge-level fire risk (``risk_sum / len_edges``). + Normalising by edge count ensures that the same physical road scores + identically regardless of SUMO edge granularity. The density is scaled + by a severity multiplier that increases with ``p_risky`` and ``p_danger``. 2. **blocked_edges** : Number of edges along the route that are currently inside a fire perimeter. Each blocked edge adds a heavy fixed penalty (8.0) because a blocked edge typically makes the route impassable. @@ -95,8 +97,9 @@ def _travel_cost(menu_item: Dict[str, Any]) -> float: """Estimate travel cost in equivalent minutes for a menu option. Prefers ``travel_time_s_fastest_path`` (converted to minutes) when available. - Falls back to an edge-count heuristic (each edge ≈ 0.25 min) using - ``len_edges`` or ``len_edges_fastest_path``. + Falls back to a route-length heuristic (assuming ~40 km/h average speed) using + ``route_length_m``. If route_length_m is also unavailable, falls back to + ``len_edges`` (each edge ≈ 0.25 min). Args: menu_item: A destination or route dict from the menu library. @@ -107,6 +110,10 @@ def _travel_cost(menu_item: Dict[str, Any]) -> float: travel_time = menu_item.get("travel_time_s_fastest_path") if travel_time is not None: return _num(travel_time, 0.0) / 60.0 + route_length = menu_item.get("route_length_m") + if route_length is not None: + # ~40 km/h average → 667 m/min + return _num(route_length, 0.0) / 667.0 edge_count = menu_item.get("len_edges") if edge_count is None: edge_count = menu_item.get("len_edges_fastest_path") @@ -162,11 +169,16 @@ def _observation_based_exposure( 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 + route_length = menu_item.get("route_length_m") + if route_length is not None: + # Convert metres to equivalent minutes at ~40 km/h, then scale. + length_factor = _num(route_length, 100.0) / 667.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 # Visual fire observation penalty: present only for the agent's current @@ -204,11 +216,21 @@ def _expected_exposure( Formula: severity_scale = 1 + 0.8 * p_risky + 1.6 * p_danger + 0.6 * perceived_risk uncertainty_penalty = max(0, 1 - confidence) * 0.75 - exposure = risk_sum * severity_scale + risk_density = risk_sum / len_edges + exposure = risk_density * severity_scale + blocked_edges * 8.0 + margin_penalty(min_margin_m) + uncertainty_penalty + ``risk_sum`` is the additive accumulation of per-edge fire risk scores. Because + SUMO edge granularity is an artefact of the network file — the same physical road + may be split into 10 or 100 edges — we normalise by the number of edges to obtain + the *average risk per edge* (``risk_density``). This makes the exposure metric + invariant to edge-count differences across routes of identical geometry. The + travel-time cost (via ``lambda_t * travel_cost``) already penalises longer routes + separately, so the risk term only needs to capture *how close to fire the route + passes on average*. + Weight rationale: - ``p_danger`` coefficient (1.6) > ``p_risky`` (0.8): danger is penalised more aggressively because it signals imminent threat. @@ -231,19 +253,24 @@ def _expected_exposure( risk_sum = _num(menu_item.get("risk_sum", menu_item.get("risk_sum_on_fastest_path")), 0.0) blocked_edges = _num(menu_item.get("blocked_edges", menu_item.get("blocked_edges_on_fastest_path")), 0.0) min_margin_m = menu_item.get("min_margin_m", menu_item.get("min_margin_m_on_fastest_path")) + len_edges = _num( + menu_item.get("len_edges", menu_item.get("len_edges_fastest_path")), + 1.0, + ) + risk_density = risk_sum / max(1.0, len_edges) p_risky = _num(belief.get("p_risky"), 1.0 / 3.0) p_danger = _num(belief.get("p_danger"), 1.0 / 3.0) perceived_risk = _num(psychology.get("perceived_risk"), p_danger) confidence = _num(psychology.get("confidence"), 0.0) - # Severity scale: amplifies risk_sum based on how dangerous the agent believes things are. + # Severity scale: amplifies risk based on how dangerous the agent believes things are. severity_scale = 1.0 + (0.8 * p_risky) + (1.6 * p_danger) + (0.6 * perceived_risk) # Uncertainty penalty: less confident agents should avoid options that are already risky. uncertainty_penalty = max(0.0, 1.0 - confidence) * 0.75 return ( - risk_sum * severity_scale + risk_density * severity_scale + (blocked_edges * 8.0) + _effective_margin_penalty(min_margin_m) + uncertainty_penalty diff --git a/agentevac/agents/scenarios.py b/agentevac/agents/scenarios.py index 6f0b0b1..a574037 100644 --- a/agentevac/agents/scenarios.py +++ b/agentevac/agents/scenarios.py @@ -231,6 +231,70 @@ def filter_menu_for_scenario( return prompt_menu +def filter_history_for_scenario( + mode: str, + history: List[Dict[str, Any]], +) -> List[Dict[str, Any]]: + """Strip scenario-inappropriate fields from agent self-history records. + + History records are stored unfiltered for post-hoc analysis, but this + function creates sanitised copies that respect the active information + regime before embedding them in the LLM prompt. + + Leaked fields prevented: + + * ``no_notice``: forecast data, advisory/briefing labels, fire-specific + risk metrics (blocked_edges, risk_sum, min_margin_m), and raw margin + numbers in the environment signal. + * ``alert_guided``: advisory/briefing labels and route-head forecast + detail. + * ``advice_guided``: no filtering (all data visible). + """ + cfg = load_scenario_config(mode) + + if cfg["mode"] == "advice_guided": + return history + + filtered: List[Dict[str, Any]] = [] + for rec in history: + out = dict(rec) # shallow copy — only mutated keys are replaced below + + # --- Forecast --- + if not cfg["forecast_visible"]: + out["forecast"] = {"available": False} + elif not cfg["route_head_forecast_visible"]: + fc = dict(out.get("forecast") or {}) + fc.pop("route_head", None) + out["forecast"] = fc + + # --- Selected option --- + sel = out.get("selected_option") + if sel: + sel = dict(sel) + if not cfg["official_route_guidance_visible"]: + sel.pop("advisory", None) + sel.pop("briefing", None) + if cfg["mode"] == "no_notice": + # Keep only fields plausible from local knowledge. + sel = {k: v for k, v in sel.items() + if k in {"name", "dest_edge", "expected_utility", "travel_time_s"}} + out["selected_option"] = sel + + # --- Environment signal (no_notice: strip raw margin numbers) --- + if cfg["mode"] == "no_notice": + env_sig = (out.get("signals") or {}).get("environment") + if env_sig: + out["signals"] = dict(out.get("signals", {})) + out["signals"]["environment"] = { + "observed_state": env_sig.get("observed_state"), + "is_delayed": env_sig.get("is_delayed", False), + } + + filtered.append(out) + + return filtered + + def scenario_prompt_suffix(mode: str) -> str: """Return an LLM instruction suffix that contextualises the active information regime. diff --git a/agentevac/analysis/analyze_run.py b/agentevac/analysis/analyze_run.py new file mode 100644 index 0000000..b809d3a --- /dev/null +++ b/agentevac/analysis/analyze_run.py @@ -0,0 +1,240 @@ +"""Compact post-run analysis report. + +Usage: + python -m agentevac.analysis.analyze_run + +Examples: + python -m agentevac.analysis.analyze_run 20260325_175136 + python -m agentevac.analysis.analyze_run outputs/run_metrics_20260325_175136.json +""" + +from __future__ import annotations + +import json +import statistics +import sys +from pathlib import Path +from typing import Any, Dict, List, Optional + +OUTPUTS_DIR = Path("outputs") + +_ARTIFACT_PREFIXES = ("run_metrics_", "run_params_", "agent_profiles_", + "events_", "llm_routes_") + + +def _resolve_suffix(arg: str) -> str: + """Turn a CLI argument into a timestamp suffix like '20260325_175136'.""" + p = Path(arg) + if p.exists(): + from agentevac.utils.run_parameters import reference_suffix + return reference_suffix(p) + # Treat as bare suffix + return arg + + +def _load_json(path: Path) -> Optional[Dict[str, Any]]: + if path.exists(): + return json.loads(path.read_text(encoding="utf-8")) + return None + + +def _fmt(val: float, decimals: int = 3) -> str: + return f"{val:.{decimals}f}" + + +def _top_n(d: Dict[str, float], n: int = 3, *, reverse: bool = True) -> List[str]: + """Return top-n agent ids sorted by value (highest first by default).""" + items = sorted(d.items(), key=lambda kv: kv[1], reverse=reverse) + return [f"{k} ({_fmt(v)})" for k, v in items[:n]] + + +def _bottom_n(d: Dict[str, float], n: int = 3) -> List[str]: + return _top_n(d, n, reverse=False) + + +def analyze(suffix: str, outputs_dir: Path = OUTPUTS_DIR) -> str: + """Build a compact text report for the given run suffix.""" + metrics = _load_json(outputs_dir / f"run_metrics_{suffix}.json") + params = _load_json(outputs_dir / f"run_params_{suffix}.json") + profiles = _load_json(outputs_dir / f"agent_profiles_{suffix}.json") + + if metrics is None: + return f"ERROR: run_metrics_{suffix}.json not found in {outputs_dir}" + + lines: List[str] = [] + lines.append(f"=== Run Analysis: {suffix} ===\n") + + # --- Configuration --- + lines.append("Configuration:") + if params: + lines.append(f" Scenario: {params.get('scenario', '?')}") + lines.append(f" Run mode: {params.get('run_mode', '?')}") + lines.append(f" Sim end: {params.get('sim_end_time_s', '?')}s") + lines.append(f" SUMO binary: {params.get('sumo_binary', '?')}") + + fires = params.get("fire_sources", []) + lines.append(f" Fire sources: {len(fires)}") + if fires: + ids = [f.get("id", "?") for f in fires] + lines.append(f" IDs: {', '.join(ids)}") + + cog = params.get("cognition", {}) + lines.append(f" info_sigma: {cog.get('info_sigma', '?')}") + lines.append(f" info_delay: {cog.get('info_delay_s', '?')}s") + lines.append(f" theta_trust: {cog.get('theta_trust', '?')} (default)") + lines.append(f" belief_inertia: {cog.get('belief_inertia', '?')}") + + dep = params.get("departure", {}) + lines.append(f" departure theta_r: {dep.get('theta_r', '?')} (default)") + + util = params.get("utility", {}) + lines.append(f" utility lambda_e: {util.get('lambda_e', '?')}, lambda_t: {util.get('lambda_t', '?')} (default)") + + msg = params.get("messaging_controls", {}) + if msg.get("enabled"): + lines.append(f" Messaging: ON (inbox={msg.get('max_inbox_messages')}, " + f"sends/round={msg.get('max_sends_per_agent_per_round')}, " + f"ttl={msg.get('ttl_rounds')})") + else: + lines.append(" Messaging: OFF") + else: + lines.append(" (run_params file not found)") + + # --- Agents overview --- + departed = metrics.get("departed_agents", 0) + arrived = metrics.get("arrived_agents", 0) + pct = f"{arrived / departed * 100:.0f}%" if departed else "N/A" + lines.append(f"\nAgents: {departed} departed, {arrived} arrived ({pct})") + lines.append(f"Decision snapshots: {metrics.get('decision_snapshot_count', '?')}") + + # --- Destination shares --- + dest = metrics.get("destination_choice_share", {}) + counts = dest.get("counts", {}) + fracs = dest.get("fractions", {}) + if counts: + lines.append("\nDestination Shares:") + for name in sorted(counts, key=lambda k: counts[k], reverse=True): + f = fracs.get(name, 0) + lines.append(f" {name}: {counts[name]:>2} ({f * 100:.1f}%)") + lines.append(f" Route choice entropy: {_fmt(metrics.get('route_choice_entropy', 0), 4)}") + + # --- Travel time --- + tt = metrics.get("average_travel_time", {}) + per_agent_tt = tt.get("per_agent", {}) + if per_agent_tt: + vals = list(per_agent_tt.values()) + lines.append(f"\nTravel Time:") + lines.append(f" Average: {_fmt(tt.get('average', 0), 1)}s") + lines.append(f" Median: {_fmt(statistics.median(vals), 1)}s") + lines.append(f" Std dev: {_fmt(statistics.stdev(vals), 1)}s" if len(vals) > 1 else "") + mn_agent = min(per_agent_tt, key=per_agent_tt.get) + mx_agent = max(per_agent_tt, key=per_agent_tt.get) + lines.append(f" Fastest: {mn_agent} ({_fmt(per_agent_tt[mn_agent], 1)}s)") + lines.append(f" Slowest: {mx_agent} ({_fmt(per_agent_tt[mx_agent], 1)}s)") + lines.append(f" Slowest 3: {', '.join(_top_n(per_agent_tt, 3))}") + + # --- Hazard exposure --- + haz = metrics.get("average_hazard_exposure", {}) + per_agent_haz = haz.get("per_agent_average", {}) + if per_agent_haz: + lines.append(f"\nHazard Exposure:") + lines.append(f" Global average: {_fmt(haz.get('global_average', 0), 4)}") + lines.append(f" Sample count: {haz.get('sample_count', '?')}") + fully_exposed = [k for k, v in per_agent_haz.items() if v >= 1.0] + low_exposure = [k for k, v in per_agent_haz.items() if v < 0.2] + if fully_exposed: + lines.append(f" Fully exposed (>=1.0): {', '.join(fully_exposed)}") + if low_exposure: + lines.append(f" Low exposure (<0.2): {', '.join(_bottom_n({k: per_agent_haz[k] for k in low_exposure}, 5))}") + lines.append(f" Highest 3: {', '.join(_top_n(per_agent_haz, 3))}") + lines.append(f" Lowest 3: {', '.join(_bottom_n(per_agent_haz, 3))}") + + # --- Decision instability --- + inst = metrics.get("decision_instability", {}) + per_agent_inst = inst.get("per_agent_changes", {}) + if per_agent_inst: + lines.append(f"\nDecision Instability:") + lines.append(f" Average changes: {_fmt(inst.get('average_changes', 0), 2)}") + lines.append(f" Max changes: {inst.get('max_changes', 0)}") + stable = [k for k, v in per_agent_inst.items() if v == 0] + lines.append(f" Stable (0 changes): {len(stable)} agents") + unstable = {k: float(v) for k, v in per_agent_inst.items() if v > 0} + if unstable: + lines.append(f" Most unstable: {', '.join(_top_n(unstable, 3))}") + + # --- Signal conflict --- + sc = metrics.get("average_signal_conflict", {}) + if sc: + lines.append(f"\nSignal Conflict:") + lines.append(f" Global average: {_fmt(sc.get('global_average', 0), 4)}") + lines.append(f" Sample count: {sc.get('sample_count', '?')}") + + # --- Departure time variability --- + dtv = metrics.get("departure_time_variability") + if dtv is not None: + lines.append(f"\nDeparture time variability: {_fmt(dtv, 4)}") + + # --- Agent profiles --- + if profiles: + lines.append(f"\nAgent Profiles ({len(profiles)} agents):") + _param_keys = ["theta_r", "lambda_e", "lambda_t", "gamma", "theta_trust"] + for pk in _param_keys: + vals = [p[pk] for p in profiles.values() if pk in p] + if not vals: + continue + lo, hi = min(vals), max(vals) + if lo == hi: + lines.append(f" {pk}: all {_fmt(lo, 4)}") + else: + lines.append(f" {pk}: {_fmt(lo, 4)} — {_fmt(hi, 4)} " + f"(mean {_fmt(statistics.mean(vals), 4)})") + else: + lines.append("\n (agent_profiles file not found)") + + # --- Behavioral flags --- + flags: List[str] = [] + if per_agent_haz: + n_full = len([v for v in per_agent_haz.values() if v >= 1.0]) + if n_full: + flags.append(f"{n_full} agent(s) fully exposed (hazard >= 1.0)") + if per_agent_tt and per_agent_haz: + # Agents with high travel time but low exposure → likely rerouted successfully + med_tt = statistics.median(per_agent_tt.values()) + for vid in per_agent_tt: + t = per_agent_tt[vid] + h = per_agent_haz.get(vid, 0) + if t > med_tt * 2.5 and h < 0.2: + flags.append(f"{vid}: very slow ({_fmt(t, 0)}s) but low exposure " + f"({_fmt(h, 3)}) — likely rerouted to avoid fire") + if t < med_tt * 0.5 and h >= 0.9: + flags.append(f"{vid}: fast ({_fmt(t, 0)}s) but high exposure " + f"({_fmt(h, 3)}) — drove through hazard zone") + if per_agent_inst: + high_inst = [k for k, v in per_agent_inst.items() if v >= 5] + if high_inst: + flags.append(f"Highly indecisive (>=5 changes): {', '.join(high_inst)}") + + if flags: + lines.append("\nBehavioral Flags:") + for f in flags: + lines.append(f" * {f}") + + return "\n".join(lines) + + +def main() -> None: + if len(sys.argv) < 2: + print(__doc__.strip()) + sys.exit(1) + + suffix = _resolve_suffix(sys.argv[1]) + + # Allow overriding outputs dir via second arg + out_dir = Path(sys.argv[2]) if len(sys.argv) > 2 else OUTPUTS_DIR + + report = analyze(suffix, out_dir) + print(report) + + +if __name__ == "__main__": + main() diff --git a/agentevac/analysis/experiments.py b/agentevac/analysis/experiments.py index 4021306..cfa1823 100644 --- a/agentevac/analysis/experiments.py +++ b/agentevac/analysis/experiments.py @@ -166,6 +166,7 @@ def run_experiment_case( run_mode: str = "record", timeout_s: Optional[float] = None, sumo_seed: Optional[int] = None, + map_name: Optional[str] = None, ) -> Dict[str, Any]: """Execute one parameter-grid case by spawning a simulation subprocess. @@ -201,6 +202,7 @@ def run_experiment_case( case_id = str(case_cfg.get("case_id") or _case_id(case_cfg, case_index)) replay_base = out_dir / f"routes_{case_id}.jsonl" metrics_base = out_dir / f"metrics_{case_id}.json" + params_base = out_dir / f"run_params_{case_id}.json" stdout_log = out_dir / f"stdout_{case_id}.log" cmd = [ @@ -216,10 +218,14 @@ def run_experiment_case( "--metrics", "on", "--replay-log-path", str(replay_base), "--metrics-log-path", str(metrics_base), + "--params-log-path", str(params_base), ] messaging_enabled = bool(case_cfg.get("messaging_enabled", True)) cmd.extend(["--messaging", "on" if messaging_enabled else "off"]) + _map = map_name or case_cfg.get("map_name") + if _map: + cmd.extend(["--map", str(_map)]) print(f"[SIM_CLI] case_id={case_id} {_format_cmd(cmd[2:])}") env = os.environ.copy() @@ -289,6 +295,7 @@ def run_parameter_sweep( run_mode: str = "record", timeout_s: Optional[float] = None, sumo_seed: Optional[int] = None, + map_name: Optional[str] = None, ) -> List[Dict[str, Any]]: """Run all cases in the experiment grid sequentially. @@ -303,6 +310,7 @@ def run_parameter_sweep( sumo_binary: SUMO binary name. run_mode: ``"record"`` or ``"replay"``. timeout_s: Per-case subprocess timeout in seconds. + map_name: Map config directory name (e.g., ``"lytton"``). Returns: List of result dicts (one per grid case) from ``run_experiment_case``. @@ -322,6 +330,7 @@ def run_parameter_sweep( run_mode=run_mode, timeout_s=timeout_s, sumo_seed=sumo_seed, + map_name=map_name, ) ) return results @@ -412,6 +421,11 @@ def _parse_args() -> argparse.Namespace: 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.") + parser.add_argument( + "--map", + default=os.getenv("MAP_NAME", "lytton"), + help="Map config directory name under configs/ (default: lytton).", + ) return parser.parse_args() @@ -435,6 +449,7 @@ def main() -> int: run_mode=args.run_mode, timeout_s=args.timeout_s, sumo_seed=args.sumo_seed, + map_name=args.map, ) 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 853e4df..8732365 100644 --- a/agentevac/analysis/metrics.py +++ b/agentevac/analysis/metrics.py @@ -60,6 +60,7 @@ def __init__(self, enabled: bool, base_path: str, run_mode: str): self.path = self._timestamped_path(base_path) Path(self.path).parent.mkdir(parents=True, exist_ok=True) + self.total_agents: int = 0 self._depart_times: Dict[str, float] = {} self._arrival_times: Dict[str, float] = {} self._last_seen_active: Set[str] = set() @@ -70,6 +71,7 @@ def __init__(self, enabled: bool, base_path: str, run_mode: str): self._decision_changes: Dict[str, int] = {} self._last_decision_state: Dict[str, str] = {} self._final_destination_by_agent: Dict[str, str] = {} + self.token_usage: Optional[Dict[str, int]] = None self._exposure_sum = 0.0 self._exposure_count = 0 @@ -444,6 +446,7 @@ def summary(self) -> Dict[str, Any]: "run_mode": self.run_mode, "departed_agents": len(self._depart_times), "arrived_agents": len(self._arrival_times), + "total_agents": self.total_agents, "decision_snapshot_count": self._decision_snapshot_count, "departure_time_variability": round(self.compute_departure_time_variability(), 6), "route_choice_entropy": round(self.compute_route_choice_entropy(), 6), @@ -452,6 +455,7 @@ def summary(self) -> Dict[str, Any]: "average_travel_time": self.compute_average_travel_time(), "average_signal_conflict": self.compute_average_signal_conflict(), "destination_choice_share": self.compute_destination_choice_share(), + **({"token_usage": self.token_usage} if self.token_usage else {}), } def export_run_metrics(self, path: Optional[str] = None) -> Optional[str]: diff --git a/agentevac/analysis/study_runner.py b/agentevac/analysis/study_runner.py index c121f0f..6e25a4a 100644 --- a/agentevac/analysis/study_runner.py +++ b/agentevac/analysis/study_runner.py @@ -94,6 +94,7 @@ def run_study( messaging_enabled: bool = True, weights: Optional[Dict[str, float]] = None, top_k: int = 5, + map_name: Optional[str] = None, ) -> Dict[str, Any]: """Run a complete parameter calibration study: sweep → fit → report. @@ -115,6 +116,7 @@ def run_study( messaging_enabled: Whether inter-agent messaging is active for all cases. weights: Per-metric calibration weight overrides. top_k: Number of top-ranked candidates to include in the calibration report. + map_name: Map config directory name (e.g., ``"lytton"`` or ``"halifax"``). Returns: A study summary dict containing timing information, case counts, file paths, @@ -143,6 +145,7 @@ def run_study( sumo_binary=sumo_binary, run_mode=run_mode, timeout_s=timeout_s, + map_name=map_name, ) finished_experiments_at = time.time() @@ -220,6 +223,7 @@ def _parse_args() -> argparse.Namespace: help="Comma-separated calibration weights, e.g. average_travel_time=2.0,route_choice_entropy=0.5", ) parser.add_argument("--top-k", type=int, default=5) + parser.add_argument("--map", default=None, help="Map config directory name (e.g., 'lytton').") return parser.parse_args() @@ -240,6 +244,7 @@ def main() -> int: messaging_enabled=(args.messaging == "on"), weights=_parse_weights(args.weights), top_k=args.top_k, + map_name=args.map, ) print(f"[STUDY] dir={summary['study_dir']}") print( diff --git a/agentevac/config_loader.py b/agentevac/config_loader.py new file mode 100644 index 0000000..5eccea4 --- /dev/null +++ b/agentevac/config_loader.py @@ -0,0 +1,307 @@ +"""Map configuration loader for AgentEvac. + +Loads network-specific configuration (spawns, fires, destinations, routes) from +JSON files under ``configs//``. This allows switching between maps +(e.g., Lytton → Halifax) with a single ``--map`` CLI flag. + +Expected directory layout:: + + configs// + map.json — net_file, sumo_cfg + spawns.json — vehicle spawn events (detailed or compact format) + fires.json — fire sources and mid-simulation ignition events + destinations.json — destination menu for CONTROL_MODE="destination" + routes.json — route menu for CONTROL_MODE="route" + +Spawn formats +------------- +**Detailed** (list of per-agent dicts):: + + [ + {"veh_id": "veh1_1", "spawn_edge": "42006672", "dest_edge": "E#S2", ...}, + ... + ] + +**Compact** (dict with ``"groups"`` key):: + + { + "groups": [ + {"edge": "42006672", "count": 3}, + {"edge": "42006514#4", "count": 2, "dest_edge": "E#S0"}, + ... + ], + "default_dest_edge": "E#S2" + } + +Compact groups are expanded into the same tuple format by ``expand_spawn_groups``. +""" + +import json +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +# Spawn tuple type alias for readability +SpawnTuple = Tuple[str, str, str, float, str, str, str, Tuple[int, int, int, int]] + +_CONFIGS_DIR = Path(__file__).resolve().parent.parent / "configs" + +# 10-colour palette matching spawn_events.py, cycled for auto-assignment. +_COLOR_PALETTE: List[Tuple[int, int, int, int]] = [ + (255, 0, 0, 255), # red + (0, 0, 255, 255), # blue + (0, 255, 0, 255), # green + (255, 125, 0, 255), # orange + (125, 255, 0, 255), # spring + (0, 255, 255, 255), # cyan + (255, 255, 0, 255), # yellow + (0, 125, 255, 255), # ocean + (125, 0, 255, 255), # violet + (255, 0, 255, 255), # magenta +] + +# Default spacing between agents on the same edge (metres). +_DEFAULT_POS_SPACING_M = 10.0 +# Minimum margin from the end of the edge (metres). +_DEFAULT_POS_MARGIN_M = 5.0 +# Starting position for the first agent on an edge (metres). +_DEFAULT_POS_START_M = 10.0 + + +def load_map_config(map_name: str) -> Dict[str, Any]: + """Load all JSON config files for a given map name. + + Args: + map_name: Directory name under ``configs/`` (e.g., ``"lytton"``). + + Returns: + Dict with keys ``"map"``, ``"spawns"``, ``"fires"``, ``"destinations"``, + ``"routes"`` — each holding the parsed JSON content. Missing optional + files (``routes.json``) default to empty list/dict. + + Raises: + FileNotFoundError: If the map directory does not exist. + """ + map_dir = _CONFIGS_DIR / map_name + if not map_dir.is_dir(): + raise FileNotFoundError( + f"Map config directory not found: {map_dir}. " + f"Available maps: {[p.name for p in _CONFIGS_DIR.iterdir() if p.is_dir()]}" + ) + + cfg: Dict[str, Any] = {} + for name, required in [ + ("map", True), + ("spawns", True), + ("fires", True), + ("destinations", True), + ("routes", False), + ]: + path = map_dir / f"{name}.json" + if path.exists(): + with open(path) as f: + cfg[name] = json.load(f) + elif required: + raise FileNotFoundError(f"Required config file missing: {path}") + else: + cfg[name] = [] + return cfg + + +def spawns_to_tuples( + spawns: List[Dict[str, Any]], +) -> List[SpawnTuple]: + """Convert spawn dicts from JSON into the legacy tuple format used by main.py. + + Args: + spawns: List of spawn event dicts from ``spawns.json``. + + Returns: + List of ``(veh_id, spawn_edge, dest_edge, depart_time, lane, pos, speed, color)`` + tuples matching the format expected by the simulation loop. + """ + result = [] + for s in spawns: + color = tuple(s["color"]) if "color" in s else (255, 0, 0, 255) + result.append(( + str(s["veh_id"]), + str(s["spawn_edge"]), + str(s["dest_edge"]), + float(s["depart_time"]), + str(s.get("lane", "first")), + str(s.get("pos", "20")), + str(s.get("speed", "max")), + color, + )) + return result + + +def expand_spawn_groups( + groups: List[Dict[str, Any]], + default_dest_edge: str, +) -> List[SpawnTuple]: + """Expand compact spawn groups into the full tuple list. + + Each group dict has the form:: + + {"edge": "", "count": , ...} + + Optional per-group overrides: + ``dest_edge`` — override default destination edge. + ``id_prefix`` — override auto-generated ID prefix (default: edge ID). + ``depart_interval`` — seconds between agents (default: 0.0, all simultaneous). + ``lane`` — SUMO departure lane (default: ``"first"``). + ``speed`` — SUMO departure speed (default: ``"max"``). + ``color`` — fixed RGBA for all agents in group (default: palette cycle). + + Agent IDs are ``"_1"``, ``"_2"``, etc. If the same edge + appears in multiple groups, a group suffix ``"_g"`` is appended to avoid + collisions. + + Positions are auto-staggered starting at 10 m with 10 m spacing. Overflow + is handled later by ``validate_spawn_positions`` once edge lengths are known. + + Args: + groups: List of compact group dicts. + default_dest_edge: Fallback destination edge (e.g., first entry in + ``destinations.json``). + + Returns: + List of spawn tuples in the same format as ``spawns_to_tuples``. + """ + result: List[SpawnTuple] = [] + # Track which edge IDs have been seen to detect duplicates. + edge_occurrence: Dict[str, int] = {} + + for group in groups: + edge = str(group["edge"]) + count = int(group["count"]) + if count < 1: + continue + + # Detect duplicate edges across groups. + occurrence = edge_occurrence.get(edge, 0) + edge_occurrence[edge] = occurrence + 1 + needs_group_suffix = occurrence > 0 + + prefix = str(group.get("id_prefix", edge)) + if needs_group_suffix: + prefix = f"{prefix}_g{occurrence + 1}" + + dest = str(group.get("dest_edge", default_dest_edge)) + interval = float(group.get("depart_interval", 0.0)) + lane = str(group.get("lane", "first")) + speed = str(group.get("speed", "max")) + fixed_color = tuple(group["color"]) if "color" in group else None + + for i in range(1, count + 1): + veh_id = f"{prefix}_{i}" + depart_time = interval * (i - 1) + pos = str(_DEFAULT_POS_START_M + _DEFAULT_POS_SPACING_M * (i - 1)) + color = fixed_color or _COLOR_PALETTE[(i - 1) % len(_COLOR_PALETTE)] + result.append((veh_id, edge, dest, depart_time, lane, pos, speed, color)) + + return result + + +def validate_spawn_positions( + spawns: List[SpawnTuple], + edge_lengths: Dict[str, float], +) -> List[SpawnTuple]: + """Clamp spawn positions to stay within edge bounds. + + For any spawn whose ``pos`` exceeds its edge length (minus a safety margin), + the position is clamped so the vehicle fits on the edge. Spawns on edges + not present in ``edge_lengths`` are left unchanged. + + When multiple agents share an edge and would all be clamped to the same + position, they are redistributed evenly across the available edge length. + + Args: + spawns: List of spawn tuples (may contain out-of-bounds positions from + ``expand_spawn_groups``). + edge_lengths: ``{edge_id: length_m}`` dict built from the SUMO network. + + Returns: + New list of spawn tuples with valid positions. + """ + if not edge_lengths: + return list(spawns) + + # Group spawns by edge to handle redistribution. + from collections import defaultdict + edge_indices: Dict[str, List[int]] = defaultdict(list) + for idx, (vid, edge, *_rest) in enumerate(spawns): + edge_indices[edge].append(idx) + + result = list(spawns) + for edge, indices in edge_indices.items(): + length = edge_lengths.get(edge) + if length is None: + continue + + max_pos = max(length - _DEFAULT_POS_MARGIN_M, 1.0) + n = len(indices) + + # Check if any position overflows. + needs_redistribution = False + for idx in indices: + try: + pos_val = float(result[idx][5]) + except (ValueError, IndexError): + continue + if pos_val > max_pos: + needs_redistribution = True + break + + if needs_redistribution: + # Redistribute all agents on this edge evenly. + if n == 1: + positions = [min(_DEFAULT_POS_START_M, max_pos)] + else: + start = min(_DEFAULT_POS_START_M, max_pos * 0.1) + step = (max_pos - start) / max(n - 1, 1) + positions = [start + step * j for j in range(n)] + + for j, idx in enumerate(indices): + old = result[idx] + result[idx] = ( + old[0], old[1], old[2], old[3], + old[4], str(round(positions[j], 1)), old[6], old[7], + ) + + return result + + +def load_spawns( + raw: Any, + destinations: List[Dict[str, Any]], +) -> List[SpawnTuple]: + """Detect spawn format (detailed list or compact groups) and produce tuples. + + Args: + raw: Parsed JSON from ``spawns.json`` — either a list (detailed) or a + dict with a ``"groups"`` key (compact). + destinations: Parsed ``destinations.json`` list (used for default + ``dest_edge`` in compact mode). + + Returns: + List of spawn tuples ready for the simulation loop. + """ + if isinstance(raw, list): + return spawns_to_tuples(raw) + + if isinstance(raw, dict) and "groups" in raw: + default_dest = str(raw.get("default_dest_edge", "")) + if not default_dest and destinations: + default_dest = str(destinations[0].get("edge", "")) + if not default_dest: + raise ValueError( + "Compact spawn format requires either 'default_dest_edge' in " + "spawns.json or at least one entry in destinations.json." + ) + return expand_spawn_groups(raw["groups"], default_dest) + + raise ValueError( + "spawns.json must be either a JSON array (detailed format) or " + "a JSON object with a 'groups' key (compact format)." + ) diff --git a/agentevac/simulation/main.py b/agentevac/simulation/main.py index 2afab9d..e8090ec 100644 --- a/agentevac/simulation/main.py +++ b/agentevac/simulation/main.py @@ -63,18 +63,20 @@ append_social_history, append_decision_history, append_observation_history, + append_institutional_history, snapshot_agent_state, ) from agentevac.agents.information_model import ( sample_environment_signal, apply_signal_delay, + apply_institutional_delay, build_social_signal, ) from agentevac.agents.belief_model import update_agent_belief from agentevac.agents.departure_model import should_depart_now from agentevac.agents.routing_utility import annotate_menu_with_expected_utility from agentevac.analysis.metrics import RunMetricsCollector -from agentevac.simulation.spawn_events import SPAWN_EVENTS +from agentevac.config_loader import load_map_config, load_spawns, validate_spawn_positions from agentevac.utils.forecast_layer import ( build_fire_forecast, estimate_edge_forecast_risk, @@ -85,6 +87,7 @@ SCENARIO_CHOICES, load_scenario_config, apply_scenario_to_signals, + filter_history_for_scenario, filter_menu_for_scenario, scenario_prompt_suffix, ) @@ -94,6 +97,7 @@ summarize_neighborhood_observation, compute_social_departure_pressure, ) +from agentevac.agents.messaging import AgentMessagingBus, OutboxMessage from agentevac.utils.run_parameters import write_run_parameter_log from agentevac.utils.replay import RouteReplay @@ -124,45 +128,18 @@ # "route" -> LLM chooses among preset routes (kept here for completeness) CONTROL_MODE = "destination" -# Your SUMO net file used by the .sumocfg (needed for edge geometry) -NET_FILE = os.getenv("NET_FILE", "sumo/Repaired.net.xml") # override via NET_FILE env var +# NET_FILE, DESTINATION_LIBRARY, ROUTE_LIBRARY, SPAWN_EVENTS, FIRE_SOURCES, +# and NEW_FIRE_EVENTS are loaded from configs// after CLI parsing below. # OpenAI model + decision cadence OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o-mini") DECISION_PERIOD_S = float(os.getenv("DECISION_PERIOD_S", "60.0")) # LLM may change decisions each period; (simu sec.) +MAX_CONCURRENT_LLM = int(os.environ.get("MAX_CONCURRENT_LLM", "50")) -# Preset routes (Situation 1) - only needed if CONTROL_MODE="route" -ROUTE_LIBRARY = [ - {"name": "route_0", "edges": ["-479435809#1", - "-479435809#0", - "-479435812#0", - "-479435806", - "-30689314#10", - "-30689314#9", - "-30689314#8", - "-30689314#7", - "-30689314#6", - "-30689314#5", - "-30689314#4", - "-30689314#1", - "-30689314#0", - "-479505716#1", - "-479505717", - "-479505352", - "-479505354#2", - "-479505354#1", - "-479505354#0", - "-42047741#0", - "E#S1" - ]}, -] - -# Preset destinations (Situation 2) -DESTINATION_LIBRARY = [ - {"name": "shelter_0", "edge": "E#S0"}, - {"name": "shelter_1", "edge": "E#S1"}, - {"name": "shelter_2", "edge": "E#S2"}, -] +# Route and destination libraries are loaded from the map config (configs//), +# populated after CLI parsing below. Declared here so downstream references resolve. +ROUTE_LIBRARY: list = [] +DESTINATION_LIBRARY: list = [] # ========================= @@ -274,6 +251,11 @@ def _parse_cli_args() -> argparse.Namespace: parser.add_argument("--recommended-min-margin-m", type=float, help="Min margin for advisory='Recommended'.") parser.add_argument("--caution-min-margin-m", type=float, help="Min margin for advisory='Use with caution'.") parser.add_argument("--sim-end-time", type=float, help="Simulation end time in seconds (default: 1200).") + parser.add_argument( + "--map", + default=os.getenv("MAP_NAME", "lytton"), + help="Map config directory name under configs/ (default: lytton).", + ) return parser.parse_args() @@ -307,6 +289,19 @@ def _float_from_env_or_cli(cli_value: Optional[float], env_key: str, default: fl CLI_ARGS = _parse_cli_args() _print_cli_flag_snapshot(CLI_ARGS) + +# --- Load map-specific config (spawns, fires, destinations, routes) --- +_MAP_CFG = load_map_config(CLI_ARGS.map) +NET_FILE = os.getenv("NET_FILE", _MAP_CFG["map"]["net_file"]) +DESTINATION_LIBRARY = _MAP_CFG["destinations"] +ROUTE_LIBRARY = _MAP_CFG.get("routes", []) +SPAWN_EVENTS = load_spawns(_MAP_CFG["spawns"], DESTINATION_LIBRARY) +FIRE_SOURCES = _MAP_CFG["fires"]["sources"] +NEW_FIRE_EVENTS = _MAP_CFG["fires"].get("events", []) +print(f"[MAP] name={CLI_ARGS.map} net_file={NET_FILE} " + f"spawns={len(SPAWN_EVENTS)} fires={len(FIRE_SOURCES)}+{len(NEW_FIRE_EVENTS)} " + f"destinations={len(DESTINATION_LIBRARY)} routes={len(ROUTE_LIBRARY)}") + RUN_MODE = (CLI_ARGS.run_mode or os.getenv("RUN_MODE", "record")).lower() # "record" or "replay" SCENARIO_MODE = (CLI_ARGS.scenario or os.getenv("SCENARIO_MODE", "advice_guided")).lower() if SCENARIO_MODE not in SCENARIO_CHOICES: @@ -342,7 +337,7 @@ def _float_from_env_or_cli(cli_value: Optional[float], env_key: str, default: fl if WEB_DASHBOARD_ENABLED and not EVENTS_ENABLED: # Dashboard is event-driven, so force event stream on when dashboard is requested. EVENTS_ENABLED = True -OVERLAYS_ENABLED = _parse_bool(os.getenv("OVERLAYS_ENABLED", "1"), True) +OVERLAYS_ENABLED = _parse_bool(os.getenv("OVERLAYS_ENABLED", "0"), True) if CLI_ARGS.overlays is not None: OVERLAYS_ENABLED = (CLI_ARGS.overlays == "on") OVERLAY_MAX_LABEL_CHARS = int(os.getenv("OVERLAY_MAX_LABEL_CHARS", str(CLI_ARGS.overlay_max_label_chars or 80))) @@ -364,6 +359,7 @@ def _float_from_env_or_cli(cli_value: Optional[float], env_key: str, default: fl DIST_REF_M = float(os.getenv("DIST_REF_M", "500.0")) INFO_DELAY_S = float(os.getenv("INFO_DELAY_S", "0.0")) SOCIAL_SIGNAL_MAX_MESSAGES = int(os.getenv("SOCIAL_SIGNAL_MAX_MESSAGES", "5")) +COMM_RADIUS_M = float(os.getenv("COMM_RADIUS_M", "0")) DEFAULT_THETA_TRUST = float(os.getenv("DEFAULT_THETA_TRUST", "0.5")) BELIEF_INERTIA = float(os.getenv("BELIEF_INERTIA", "0.35")) DEFAULT_THETA_R = float(os.getenv("DEFAULT_THETA_R", "0.45")) @@ -402,8 +398,8 @@ def _float_from_env_or_cli(cli_value: Optional[float], env_key: str, default: fl "theta_r": (0.1, 0.9), "theta_u": (0.05, 0.8), "gamma": (0.98, 1.0), - "lambda_e": (0.0, 5.0), - "lambda_t": (0.0, 2.0), + "lambda_e": (0.0, 100.0), # (0.0, 5.0), + "lambda_t": (0.0, 100.0), # (0.0, 2.0), } @@ -510,7 +506,7 @@ def _agent_profile(agent_id: str) -> Dict[str, float]: if MAX_SYSTEM_OBSERVATIONS < 1: sys.exit("MAX_SYSTEM_OBSERVATIONS must be >= 1.") # Determinism (recommended) -SUMO_SEED = os.getenv("SUMO_SEED", "260313") +SUMO_SEED = os.getenv("SUMO_SEED", "42") os.makedirs(os.path.dirname(REPLAY_LOG_PATH) or ".", exist_ok=True) if RUN_MODE == "replay" and not os.path.exists(REPLAY_LOG_PATH): sys.exit( @@ -523,26 +519,8 @@ def _agent_profile(agent_id: str) -> Dict[str, float]: # FIRE DYNAMICS CONFIG # ========================= # Each fire source is a growing circle: r(t) = r0 + growth_m_per_s * (t - t0). -# FIRE_SOURCES: fires active from t=0. -# 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": 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_1", "t0": 80.0, "x": 14600.0, "y": 15800.0, "r0": 800.0, "growth_m_per_s": 0.02}, - - -] +# FIRE_SOURCES and NEW_FIRE_EVENTS are loaded from configs//fires.json +# after CLI parsing. Coordinates are in SUMO network metres. # Risk model params: # FIRE_WARNING_BUFFER_M : extra buffer added to fire radius when classifying edges as blocked. @@ -1386,9 +1364,17 @@ def _run_parameter_payload() -> Dict[str, Any]: """Build the persisted run-parameter snapshot used by post-run plotting tools.""" return { "run_mode": RUN_MODE, + "map": CLI_ARGS.map, "scenario": SCENARIO_MODE, + "control_mode": CONTROL_MODE, "sim_end_time_s": SIM_END_TIME_S, + "decision_period_s": DECISION_PERIOD_S, + "openai_model": OPENAI_MODEL, + "max_concurrent_llm": MAX_CONCURRENT_LLM, + "sumo_seed": SUMO_SEED, "sumo_binary": SUMO_BINARY, + "net_file": NET_FILE, + "sumo_cfg": os.getenv("SUMO_CFG", _MAP_CFG["map"].get("sumo_cfg", "sumo/Repaired.sumocfg")), "messaging_controls": { "enabled": MESSAGING_ENABLED, "max_message_chars": MAX_MESSAGE_CHARS, @@ -1396,6 +1382,7 @@ def _run_parameter_payload() -> Dict[str, Any]: "max_sends_per_agent_per_round": MAX_SENDS_PER_AGENT_PER_ROUND, "max_broadcasts_per_round": MAX_BROADCASTS_PER_ROUND, "ttl_rounds": TTL_ROUNDS, + "comm_radius_m": COMM_RADIUS_M, }, "driver_briefing_thresholds": { "margin_very_close_m": MARGIN_VERY_CLOSE_M, @@ -1410,6 +1397,21 @@ def _run_parameter_payload() -> Dict[str, Any]: "caution_min_margin_m": CAUTION_MIN_MARGIN_M, "recommended_min_margin_m": RECOMMENDED_MIN_MARGIN_M, }, + "agent_memory": { + "agent_history_rounds": AGENT_HISTORY_ROUNDS, + "fire_trend_eps_m": FIRE_TREND_EPS_M, + "agent_history_route_head_edges": AGENT_HISTORY_ROUTE_HEAD_EDGES, + "visual_lookahead_edges": VISUAL_LOOKAHEAD_EDGES, + "fire_perception_range_m": FIRE_PERCEPTION_RANGE_M, + }, + "forecast": { + "forecast_horizon_s": FORECAST_HORIZON_S, + "forecast_route_head_edges": FORECAST_ROUTE_HEAD_EDGES, + }, + "overlays": { + "enabled": OVERLAYS_ENABLED, + "max_label_chars": OVERLAY_MAX_LABEL_CHARS, + }, "cognition": { "info_sigma": INFO_SIGMA, "dist_ref_m": DIST_REF_M, @@ -1444,6 +1446,8 @@ def _run_parameter_payload() -> Dict[str, Any]: "social_min_danger": DEFAULT_SOCIAL_MIN_DANGER, "max_system_observations": MAX_SYSTEM_OBSERVATIONS, }, + "fire_sources": [dict(f) for f in FIRE_SOURCES], + "fire_events": [dict(f) for f in NEW_FIRE_EVENTS], } @@ -1452,9 +1456,9 @@ def _run_parameter_payload() -> Dict[str, Any]: # ========================= Sumo_config = [ SUMO_BINARY, - "-c", os.getenv("SUMO_CFG", "sumo/Repaired.sumocfg"), + "-c", os.getenv("SUMO_CFG", _MAP_CFG["map"].get("sumo_cfg", "sumo/Repaired.sumocfg")), "--step-length", "0.2", # default: 0.05 - "--delay", "1000", + "--delay", "100", "--lateral-resolution", "0.1", "--seed", str(SUMO_SEED), ] @@ -1466,6 +1470,7 @@ def _run_parameter_payload() -> Dict[str, Any]: replay = RouteReplay(RUN_MODE, REPLAY_LOG_PATH) events = LiveEventStream(EVENTS_ENABLED, EVENTS_LOG_PATH, EVENTS_STDOUT) metrics = RunMetricsCollector(METRICS_ENABLED, METRICS_LOG_PATH, RUN_MODE) +metrics.total_agents = len(SPAWN_EVENTS) params_log_path = write_run_parameter_log( PARAMS_LOG_PATH, _run_parameter_payload(), @@ -1516,7 +1521,7 @@ def _run_parameter_payload() -> Dict[str, Any]: f"[MESSAGING] enabled={MESSAGING_ENABLED} " f"max_chars={MAX_MESSAGE_CHARS} max_inbox={MAX_INBOX_MESSAGES} " f"max_sends={MAX_SENDS_PER_AGENT_PER_ROUND} max_broadcasts={MAX_BROADCASTS_PER_ROUND} " - f"ttl_rounds={TTL_ROUNDS}" + f"ttl_rounds={TTL_ROUNDS} comm_radius_m={COMM_RADIUS_M}" ) print( "[BRIEFING_THRESHOLDS] " @@ -1553,7 +1558,7 @@ def _run_parameter_payload() -> Dict[str, Any]: f"[SCENARIO] mode={SCENARIO_CONFIG['mode']} title={SCENARIO_CONFIG['title']}" ) print( - f"[SUMO] binary={SUMO_BINARY} config={os.getenv('SUMO_CFG', 'sumo/Repaired.sumocfg')}" + f"[SUMO] binary={SUMO_BINARY} config={os.getenv('SUMO_CFG', _MAP_CFG['map'].get('sumo_cfg', 'sumo/Repaired.sumocfg'))}" ) # ========================= @@ -1563,7 +1568,23 @@ def _run_parameter_payload() -> Dict[str, Any]: total_speed = 0 client = OpenAI() # uses OPENAI_API_KEY -MAX_CONCURRENT_LLM = int(os.environ.get("MAX_CONCURRENT_LLM", "20")) + +_token_lock = threading.Lock() +_token_usage = {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0, "llm_calls": 0} + + +def _record_usage(resp): + """Extract token usage from an OpenAI response and accumulate.""" + usage = getattr(resp, "usage", None) + if usage is None: + return + with _token_lock: + _token_usage["input_tokens"] += getattr(usage, "input_tokens", 0) + _token_usage["output_tokens"] += getattr(usage, "output_tokens", 0) + _token_usage["total_tokens"] += getattr(usage, "total_tokens", 0) + _token_usage["llm_calls"] += 1 + + veh_last_choice: Dict[str, int] = {} decision_round_counter = 0 _edge_trace: Dict[str, List[str]] = {} # veh_id -> ordered edges traversed @@ -1583,232 +1604,34 @@ def _run_parameter_payload() -> Dict[str, Any]: ) EDGE_SHAPE: Dict[str, List[Tuple[float, float]]] = {} +EDGE_LENGTH: Dict[str, float] = {} for e in net.getEdges(withInternal=False): lanes = e.getLanes() if not lanes: continue shp = [(float(p[0]), float(p[1])) for p in lanes[0].getShape()] - EDGE_SHAPE[e.getID()] = shp - - -class OutboxMessage(BaseModel): - to: str = Field(..., description="Recipient vehicle ID, or '*' for broadcast to all active agents.") - message: str = Field(..., description="Natural-language message content.") - - -class AgentMessagingBus: - """Inter-agent natural-language messaging bus with delivery-round scheduling. - - Agents include optional outbox items in their LLM response. The bus accepts - these messages at round R and delivers them at round R+1 (one-round latency), - simulating realistic communication delay. - - **Direct messages** (``to`` = specific vehicle ID): - Delivered only to the named recipient if active at delivery time. - Undelivered messages are held for up to ``ttl_rounds`` additional rounds, - then dropped. - - **Broadcasts** (``to`` = ``"*"``, ``"all"``, or ``"broadcast"``): - Fanned out to all round participants known at the time of sending (not of delivery). - This includes both active vehicles and not-yet-departed households participating - in the current decision round. - A global cap (``max_broadcasts_per_round``) limits broadcast flooding. - - Per-agent message caps (``max_sends_per_agent_per_round``) prevent a single - agent from saturating the bus. Inboxes are capped at ``max_inbox_messages`` - entries; older messages are dropped from the front. - - Args: - enabled: If ``False``, all methods are no-ops and inboxes are always empty. - max_message_chars: Maximum length of a single message body (truncated). - max_inbox_messages: Maximum messages retained per agent inbox. - max_sends_per_agent_per_round: Per-agent send cap per decision round. - max_broadcasts_per_round: Global broadcast cap per decision round. - ttl_rounds: Rounds a direct message waits for an offline recipient. - event_stream: Optional ``LiveEventStream`` to emit queue/delivery events. - """ + eid = e.getID() + EDGE_SHAPE[eid] = shp + EDGE_LENGTH[eid] = float(e.getLength()) +_all_lengths = [v for v in EDGE_LENGTH.values() if v > 0] +MEAN_EDGE_LENGTH_M: float = sum(_all_lengths) / len(_all_lengths) if _all_lengths else 100.0 - def __init__( - self, - enabled: bool, - max_message_chars: int, - max_inbox_messages: int, - max_sends_per_agent_per_round: int, - max_broadcasts_per_round: int, - ttl_rounds: int, - event_stream: Optional[LiveEventStream] = None, - ): - self.enabled = bool(enabled) - self.max_message_chars = max(1, int(max_message_chars)) - self.max_inbox_messages = max(1, int(max_inbox_messages)) - self.max_sends_per_agent_per_round = max(1, int(max_sends_per_agent_per_round)) - self.max_broadcasts_per_round = max(1, int(max_broadcasts_per_round)) - self.ttl_rounds = max(1, int(ttl_rounds)) - - self._pending: List[Dict[str, Any]] = [] - self._inboxes: Dict[str, List[Dict[str, Any]]] = {} - self._active_agents: List[str] = [] - self._current_round = 0 - self._broadcast_count = 0 - self._sender_sent_count: Dict[str, int] = {} - self._sender_seq: Dict[str, int] = {} - self._events = event_stream - - def _next_sender_seq(self, sender: str) -> int: - nxt = self._sender_seq.get(sender, 0) + 1 - self._sender_seq[sender] = nxt - return nxt - - def _push_inbox(self, recipient: str, msg: Dict[str, Any]): - inbox = self._inboxes.setdefault(recipient, []) - inbox.append({ - "from": msg["from"], - "to": msg["to"], - "message": msg["message"], - "kind": "broadcast" if msg["is_broadcast"] else "direct", - "sent_round": msg["sent_round"], - "delivery_round": msg["deliver_round"], - }) - if len(inbox) > self.max_inbox_messages: - self._inboxes[recipient] = inbox[-self.max_inbox_messages:] - if self._events: - self._events.emit( - "message_delivered", - summary=f"{msg['from']} -> {recipient}", - from_id=msg["from"], - to_id=recipient, - kind="broadcast" if msg["is_broadcast"] else "direct", - sent_round=msg["sent_round"], - delivery_round=msg["deliver_round"], - message=msg["message"], - ) +# Clamp any spawn positions that exceed edge length (relevant for compact spawn groups). +SPAWN_EVENTS = validate_spawn_positions(SPAWN_EVENTS, EDGE_LENGTH) - def begin_round(self, round_idx: int, participant_agent_ids: List[str]): - """ - Start decision round R: - - deliver messages scheduled for <= R - - reset per-round send counters - Messages generated in round R are delivered at R+1. - """ - if not self.enabled: - return +# Precompute representative (x, y) for each agent's spawn edge. +# Used as position proxy for pre-departure agents in spatial messaging. +SPAWN_EDGE_MIDPOINT: Dict[str, Tuple[float, float]] = {} +for _vid, _edge_id in SPAWN_EDGE_BY_AGENT.items(): + _shp = EDGE_SHAPE.get(_edge_id) + if _shp and len(_shp) >= 2: + _mid = len(_shp) // 2 + SPAWN_EDGE_MIDPOINT[_vid] = _shp[_mid] + elif _shp: + SPAWN_EDGE_MIDPOINT[_vid] = _shp[0] - self._current_round = int(round_idx) - self._active_agents = list(participant_agent_ids) - participant_set = set(participant_agent_ids) - self._broadcast_count = 0 - self._sender_sent_count = {} - remaining: List[Dict[str, Any]] = [] - for msg in self._pending: - if msg["deliver_round"] > self._current_round: - remaining.append(msg) - continue - - recipient = msg["to"] - if recipient in participant_set: - self._push_inbox(recipient, msg) - continue - - # Broadcast fanout is only to known round participants at send-time. - if msg["is_broadcast"]: - continue - - # Direct messages may wait for the recipient to appear (TTL-bound). - if self._current_round <= msg["expire_round"]: - remaining.append(msg) - - self._pending = remaining - - def get_inbox(self, agent_id: str) -> List[Dict[str, Any]]: - """Return a copy of the agent's inbox (delivered messages). - - Args: - agent_id: Vehicle ID. - - Returns: - List of message dicts; empty list if messaging is disabled or inbox is empty. - """ - if not self.enabled: - return [] - return list(self._inboxes.get(agent_id, [])) - - def queue_outbox(self, sender: str, outbox: Optional[List[OutboxMessage]]): - """ - Accept sender's outbox for current round R and enqueue for delivery at R+1. - Enforces per-sender and global messaging caps. - """ - if (not self.enabled) or (not outbox): - return - - sender_count = self._sender_sent_count.get(sender, 0) - for raw in outbox: - if sender_count >= self.max_sends_per_agent_per_round: - break - - recipient = str(getattr(raw, "to", "")).strip() - recipient_norm = recipient.lower() - text = str(getattr(raw, "message", "")).strip() - if not recipient or not text: - continue - if len(text) > self.max_message_chars: - text = text[:self.max_message_chars] - - sender_seq = self._next_sender_seq(sender) - - if recipient in {"*", "__all__"} or recipient_norm in {"all", "broadcast"}: - if self._broadcast_count >= self.max_broadcasts_per_round: - continue - self._broadcast_count += 1 - sender_count += 1 - self._sender_sent_count[sender] = sender_count - - for target in self._active_agents: - if target == sender: - continue - if self._events: - self._events.emit( - "message_queued", - summary=f"{sender} -> {target}", - from_id=sender, - to_id=target, - kind="broadcast", - deliver_round=self._current_round + 1, - message=text, - ) - self._pending.append({ - "from": sender, - "to": target, - "message": text, - "sent_round": self._current_round, - "deliver_round": self._current_round + 1, - "expire_round": self._current_round + 1, - "sender_seq": sender_seq, - "is_broadcast": True, - }) - else: - sender_count += 1 - self._sender_sent_count[sender] = sender_count - if self._events: - self._events.emit( - "message_queued", - summary=f"{sender} -> {recipient}", - from_id=sender, - to_id=recipient, - kind="direct", - deliver_round=self._current_round + 1, - message=text, - ) - self._pending.append({ - "from": sender, - "to": recipient, - "message": text, - "sent_round": self._current_round, - "deliver_round": self._current_round + 1, - "expire_round": self._current_round + self.ttl_rounds, - "sender_seq": sender_seq, - "is_broadcast": False, - }) +# AgentMessagingBus and OutboxMessage are imported from agentevac.agents.messaging. # Structured decision model (allows KEEP = -1) @@ -1918,16 +1741,18 @@ class PreDepartureDecisionModel(BaseModel): max_sends_per_agent_per_round=MAX_SENDS_PER_AGENT_PER_ROUND, max_broadcasts_per_round=MAX_BROADCASTS_PER_ROUND, ttl_rounds=TTL_ROUNDS, + comm_radius_m=COMM_RADIUS_M, event_stream=events if EVENTS_ENABLED else None, ) def build_driver_briefing( - blocked_edges: int, + blocked_edges: float, risk_sum: float, min_margin_m: Optional[float], len_edges: int, travel_time_s: Optional[float] = None, + route_length_m: Optional[float] = None, baseline_time_s: Optional[float] = None, ) -> Dict[str, Any]: """ @@ -1963,7 +1788,15 @@ def build_driver_briefing( proximity_phrase = "clear buffer from fire" proximity_band = "clear" - risk_density = (float(risk_sum) / max(1, int(len_edges))) if len_edges > 0 else 1.0 + # Normalise risk_sum by route length (in units of MEAN_EDGE_LENGTH_M) so + # that the density threshold is independent of edge granularity. + if route_length_m is not None and route_length_m > 0: + _norm = route_length_m / MEAN_EDGE_LENGTH_M + risk_density = float(risk_sum) / max(1e-9, _norm) + elif len_edges > 0: + risk_density = float(risk_sum) / max(1, int(len_edges)) + else: + risk_density = 1.0 if blocked_edges > 0: hazard_band = "critical" elif risk_density >= RISK_DENSITY_HIGH: @@ -2518,11 +2351,10 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: sigma_info=INFO_SIGMA, distance_ref_m=DIST_REF_M, ) - env_signal = apply_signal_delay( - env_signal_now, - agent_state.signal_history, - delay_rounds, - ) + # Env signal is always real-time (noise only, no delay). + env_signal = dict(env_signal_now) + env_signal["is_delayed"] = False + env_signal["delay_rounds_applied"] = 0 predeparture_inbox = messaging.get_inbox(vid) if MESSAGING_ENABLED else [] social_signal = build_social_signal( vid, @@ -2564,13 +2396,31 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: sim_t, neighborhood_observation=neighborhood_observation, ) - # --- Filter env/forecast/neighborhood for the active scenario regime --- + # --- Institutional delay: forecast channel --- _pd_forecast_payload = { "summary": dict(forecast_summary), "current_edge": dict(edge_forecast), "route_head": dict(route_forecast), "briefing": forecast_briefing, } + # Push current institutional snapshot before resolving delay. + append_institutional_history(agent_state, { + "decision_round": int(decision_round_counter), + "forecast": dict(_pd_forecast_payload), + }) + # Resolve what the agent actually sees. + if SCENARIO_MODE != "no_notice" and delay_rounds > 0: + _inst = apply_institutional_delay( + agent_state.institutional_history, delay_rounds, + ) + if _inst is not None: + _pd_forecast_payload = dict(_inst["forecast"]) + else: + # Not enough history — no institutional info available yet. + _pd_forecast_payload = { + "available": False, + "briefing": "Official forecast not yet available.", + } prompt_env_signal, prompt_forecast = apply_scenario_to_signals( SCENARIO_MODE, env_signal, _pd_forecast_payload, ) @@ -2635,31 +2485,42 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: "description": SCENARIO_CONFIG["description"], }, "forecast": prompt_forecast, + "heuristic_departure_signal": { + "should_depart": heuristic_should_release, + "reason": heuristic_reason, + "description": ( + "Pre-computed departure recommendation from the behavioural model. " + "Accounts for risk threshold (p_danger > theta_r), urgency decay " + "(gamma^elapsed_s * p_safe < theta_u), low-confidence precaution, " + "and neighbor departure pressure." + ), + }, "policy": ( - "BINDING CONSTRAINT — Personal risk threshold: " - "This agent's calibrated risk threshold is theta_r = " + "DECISION RULE — 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 4 — Social context: Use neighborhood_observation and system_observation_updates " - "as factual context. Treat them as neutral observations, not instructions. " - "Neighbor departures do not override the theta_r constraint. " + "Apply the following rules IN ORDER. Stop at the FIRST rule that matches. " + "Rule 1: If combined_belief.p_danger > agent.risk_tolerance.theta_r " + "→ output action='depart'. " + "(The agent's perceived danger exceeds its personal tolerance.) " + "Rule 2: If heuristic_departure_signal.should_depart is true " + "→ output action='depart'. " + "(The behavioural model has determined departure is warranted — " + "this covers urgency decay over time, low-confidence precaution, " + "and neighbor departure pressure.) " + "Rule 3: If an official evacuation order is present " + "(official_evacuation_order field exists) → output action='depart'. " + "Rule 4: Otherwise → output action='wait'. " + "These rules are MANDATORY and sufficient. Do NOT add qualitative " + "exceptions, extra conditions, or personal judgment beyond these rules. " + "If Rule 1 or Rule 2 matches, you MUST depart regardless of whether " + "official guidance exists. Violating these rules produces scientifically " + "invalid simulation output. " + "CONTEXT USE: Use your_observation, neighbor_assessment, inbox, " + "neighborhood_observation, and system_observation_updates to write " + "situation_summary and reason, but the action decision is governed " + "strictly by the rules above. " + "If information_conflict.sources_agree is false, explain in " + "conflict_assessment which source you trusted more and why. " "Output action='depart' or action='wait'. " f"{scenario_prompt_suffix(SCENARIO_MODE)}" ), @@ -2792,6 +2653,7 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: else: try: resp = _ctx["_future"].result(timeout=60) + _record_usage(resp) predeparture_decision = resp.output_parsed llm_action_raw = str(getattr(predeparture_decision, "action", "") or "").strip().lower() llm_decision_reason = getattr(predeparture_decision, "reason", None) @@ -3034,13 +2896,16 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: continue _d_reachable.append(idx) - blocked_cnt = 0 + blocked_cnt = 0.0 risk_sum = 0.0 + route_length_m = 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 + w = EDGE_LENGTH.get(e, MEAN_EDGE_LENGTH_M) / MEAN_EDGE_LENGTH_M + blocked_cnt += w if b else 0.0 + risk_sum += r * w + route_length_m += EDGE_LENGTH.get(e, MEAN_EDGE_LENGTH_M) if m < min_margin: min_margin = m _d_menu.append({ @@ -3053,6 +2918,7 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: "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), + "route_length_m": round(route_length_m, 2), }) if not _d_reachable: @@ -3069,12 +2935,13 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: if not item.get("reachable"): continue info = build_driver_briefing( - blocked_edges=int(item.get("blocked_edges_on_fastest_path", 0)), + blocked_edges=float(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, + route_length_m=item.get("route_length_m"), ) item.update(info) annotate_menu_with_expected_utility( @@ -3085,17 +2952,41 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: 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, {}, { + # --- Institutional delay: departure-destination forecast + menu --- + _dep_fc_payload = { "summary": dict(forecast_summary), "current_edge": dict(_s_edge_fc), "route_head": dict(_s_route_fc), "briefing": str(_s_fc_brief or ""), + } + append_institutional_history(_s_agent, { + "decision_round": int(decision_round_counter), + "forecast": dict(_dep_fc_payload), + "annotated_menu": [dict(item) for item in _d_menu], }) + _dep_inst_unavailable = False + if SCENARIO_MODE != "no_notice" and delay_rounds > 0: + _dep_inst = apply_institutional_delay( + _s_agent.institutional_history, delay_rounds, + ) + if _dep_inst is not None: + _dep_fc_payload = dict(_dep_inst["forecast"]) + _d_menu = list(_dep_inst.get("annotated_menu", _d_menu)) + else: + _dep_inst_unavailable = True + + _dep_menu_scenario = "no_notice" if _dep_inst_unavailable else SCENARIO_MODE + _prompt_dest_menu = filter_menu_for_scenario( + _dep_menu_scenario, _d_menu, control_mode="destination", + ) + + # Build forecast prompt filtered by scenario. + _, _prompt_fc = apply_scenario_to_signals( + _dep_menu_scenario, {}, + {"available": False, "briefing": "Official forecast not yet available."} + if _dep_inst_unavailable else _dep_fc_payload, + ) # Policy strings (same logic as process_vehicles). _util_basis = { @@ -3125,6 +3016,28 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: if SCENARIO_CONFIG["forecast_visible"] else "No official forecast is available in this scenario. " ) + _theta_trust = float(_s_agent.profile["theta_trust"]) + if _theta_trust == 0.0: + _trust_pol = ( + "BINDING CONSTRAINT — Social trust: Your theta_trust = 0.0. " + "You have ZERO trust in neighbor messages. " + "IGNORE neighbor_assessment and all inbox messages entirely — " + "base your hazard judgment ONLY on your_observation and official information. " + "Do NOT cite neighbor consensus or inbox content in your reasoning. " + ) + _consider_pol = "Consider ONLY your_observation for your hazard judgment. " + _belief_weigh_pol = "combined_belief already reflects zero social weight and is based solely on your own observations. " + else: + _own_pct = round((1 - _theta_trust) * 100) + _soc_pct = round(_theta_trust * 100) + _trust_pol = ( + f"Social trust calibration: Your theta_trust = {_theta_trust:.4f}. " + f"This means your decision should rely {_own_pct}% on your own observation " + f"and {_soc_pct}% on neighbor messages and inbox. " + "Weight neighbor/inbox information accordingly. " + ) + _consider_pol = "Consider your_observation, neighbor_assessment, and inbox for your hazard judgment. " + _belief_weigh_pol = "combined_belief is a mathematical estimate — you may weigh sources differently. " _dep_env = { "time_s": round(sim_t, 2), @@ -3179,7 +3092,7 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: "destination_menu": _prompt_dest_menu, "reachable_dest_indices": _d_reachable, "inbox_order": "chronological_oldest_first", - "inbox": _s_inbox, + "inbox": _s_inbox if _theta_trust > 0.0 else [], "messaging": { "enabled": MESSAGING_ENABLED, "max_message_chars": MAX_MESSAGE_CHARS, @@ -3187,6 +3100,7 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: "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, + "comm_radius_m": COMM_RADIUS_M, "broadcast_token": "*", }, "policy": ( @@ -3202,8 +3116,9 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: "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. " + f"{_consider_pol}" + f"{_belief_weigh_pol}" + f"{_trust_pol}" "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. " @@ -3256,6 +3171,7 @@ def _dep_edge_risk(eid: str) -> Tuple[bool, float, float]: if "_dest_future" in _spawn: try: _dest_resp = _spawn["_dest_future"].result(timeout=60) + _record_usage(_dest_resp) _dest_decision = _dest_resp.output_parsed _dest_idx = int(_dest_decision.choice_index) _d_reachable = _spawn["_dest_reachable"] @@ -3518,7 +3434,18 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: if MESSAGING_ENABLED: # Deliver pending messages due for this round before asking any agent this round. # Waiting households participate too, so pre-departure prompts can read original peer chat. - messaging.begin_round(decision_round, list(vehicles_list) + pending_agent_ids) + _msg_positions: Dict[str, Tuple[float, float]] = {} + if COMM_RADIUS_M > 0: + for _vid in vehicles_list: + try: + _msg_positions[_vid] = traci.vehicle.getPosition(_vid) + except traci.TraCIException: + pass + for _pid in pending_agent_ids: + if _pid in SPAWN_EDGE_MIDPOINT: + _msg_positions[_pid] = SPAWN_EDGE_MIDPOINT[_pid] + messaging.begin_round(decision_round, list(vehicles_list) + pending_agent_ids, + positions=_msg_positions) if RUN_MODE == "replay": if EVENTS_ENABLED: @@ -3543,6 +3470,7 @@ def forecast_edge_risk(edge_id: str) -> Tuple[bool, float, float]: rinfo = list(traci.vehicle.getRoute(vehicle)) vtype = traci.vehicle.getTypeID(vehicle) history_recent = _history_for_agent(vehicle) + history_for_prompt = filter_history_for_scenario(SCENARIO_MODE, history_recent) prev_margin_m = None if history_recent: prev_margin_m = history_recent[-1].get("current_edge_margin_m") @@ -3604,11 +3532,10 @@ def _vis_edge_risk(eid, _vf=_visible): sigma_info=INFO_SIGMA, distance_ref_m=DIST_REF_M, ) - env_signal = apply_signal_delay( - env_signal_now, - agent_state.signal_history, - delay_rounds, - ) + # Env signal is always real-time (noise only, no delay). + env_signal = dict(env_signal_now) + env_signal["is_delayed"] = False + env_signal["delay_rounds_applied"] = 0 social_signal = build_social_signal( vehicle, inbox_for_vehicle, @@ -3648,6 +3575,7 @@ def _vis_edge_risk(eid, _vf=_visible): "route_head": dict(route_forecast), "briefing": forecast_briefing, } + # Institutional delay is resolved after menu annotation (see below). prompt_env_signal, prompt_forecast = apply_scenario_to_signals( SCENARIO_MODE, env_signal, @@ -3825,13 +3753,16 @@ def record_agent_memory( reachable_indices.append(idx) - blocked_cnt = 0 + blocked_cnt = 0.0 risk_sum = 0.0 + route_length_m = 0.0 min_margin = float("inf") for e in cand_edges: b, r, m = edge_risk(e) - blocked_cnt += int(b) - risk_sum += r + w = EDGE_LENGTH.get(e, MEAN_EDGE_LENGTH_M) / MEAN_EDGE_LENGTH_M + blocked_cnt += w if b else 0.0 + risk_sum += r * w + route_length_m += EDGE_LENGTH.get(e, MEAN_EDGE_LENGTH_M) if m < min_margin: min_margin = m @@ -3845,6 +3776,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), + "route_length_m": round(route_length_m, 2), "_fastest_path_edges": cand_edges, }) @@ -3872,12 +3804,13 @@ def record_agent_memory( if not item.get("reachable"): continue info = build_driver_briefing( - blocked_edges=int(item.get("blocked_edges_on_fastest_path", 0)), + blocked_edges=float(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_time_s, + route_length_m=item.get("route_length_m"), ) item.update(info) @@ -3944,8 +3877,37 @@ def record_agent_memory( profile=agent_state.profile, scenario=SCENARIO_MODE, ) + + # --- Institutional delay: forecast + annotated menu --- + # Push current snapshot before resolving delay. + append_institutional_history(agent_state, { + "decision_round": int(decision_round), + "forecast": dict(scenario_forecast_payload), + "annotated_menu": [dict(item) for item in menu], + }) + # Resolve what the agent actually sees. + _inst_unavailable = False + if SCENARIO_MODE != "no_notice" and delay_rounds > 0: + _inst = apply_institutional_delay( + agent_state.institutional_history, delay_rounds, + ) + if _inst is not None: + # Serve stale forecast and menu from N rounds ago. + scenario_forecast_payload = dict(_inst["forecast"]) + prompt_env_signal, prompt_forecast = apply_scenario_to_signals( + SCENARIO_MODE, env_signal, scenario_forecast_payload, + ) + menu = list(_inst.get("annotated_menu", menu)) + else: + # History too short — no institutional info available yet. + _inst_unavailable = True + prompt_forecast = { + "available": False, + "briefing": "Official forecast not yet available.", + } + prompt_destination_menu = filter_menu_for_scenario( - SCENARIO_MODE, + "no_notice" if _inst_unavailable else SCENARIO_MODE, menu, control_mode="destination", ) @@ -3976,6 +3938,28 @@ def record_agent_memory( if SCENARIO_CONFIG["forecast_visible"] else "No official forecast is available in this scenario. " ) + _theta_trust = float(agent_state.profile["theta_trust"]) + if _theta_trust == 0.0: + trust_policy = ( + "BINDING CONSTRAINT — Social trust: Your theta_trust = 0.0. " + "You have ZERO trust in neighbor messages. " + "IGNORE neighbor_assessment and all inbox messages entirely — " + "base your hazard judgment ONLY on your_observation and official information. " + "Do NOT cite neighbor consensus or inbox content in your reasoning. " + ) + _consider_pol = "Consider ONLY your_observation for your hazard judgment. " + _belief_weigh_pol = "combined_belief already reflects zero social weight and is based solely on your own observations. " + else: + _own_pct = round((1 - _theta_trust) * 100) + _soc_pct = round(_theta_trust * 100) + trust_policy = ( + f"Social trust calibration: Your theta_trust = {_theta_trust:.4f}. " + f"This means your decision should rely {_own_pct}% on your own observation " + f"and {_soc_pct}% on neighbor messages and inbox. " + "Weight neighbor/inbox information accordingly. " + ) + _consider_pol = "Consider your_observation, neighbor_assessment, and inbox for your hazard judgment. " + _belief_weigh_pol = "combined_belief is a mathematical estimate — you may weigh sources differently. " routing_conflict_info = _build_conflict_description( belief_state.get("env_belief", {}), @@ -3993,7 +3977,7 @@ def record_agent_memory( "current_route_head": rinfo[:5], }, "agent_self_history_order": "chronological_oldest_first", - "agent_self_history": history_recent, + "agent_self_history": history_for_prompt, "fire_proximity": { "current_edge_margin_m": current_edge_margin_m, "route_head_min_margin_m": route_head_min_margin_m, @@ -4036,7 +4020,7 @@ def record_agent_memory( "destination_menu": prompt_destination_menu, "reachable_dest_indices": reachable_indices, "inbox_order": "chronological_oldest_first", - "inbox": inbox_for_vehicle, + "inbox": inbox_for_vehicle if _theta_trust > 0.0 else [], "messaging": { "enabled": MESSAGING_ENABLED, "max_message_chars": MAX_MESSAGE_CHARS, @@ -4044,6 +4028,7 @@ def record_agent_memory( "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, + "comm_radius_m": COMM_RADIUS_M, "broadcast_token": "*", }, "policy": ( @@ -4059,8 +4044,9 @@ def record_agent_memory( "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. " + f"{_consider_pol}" + f"{_belief_weigh_pol}" + f"{trust_policy}" "If information_conflict.sources_agree is false, explain in conflict_assessment " "which source you trusted more and why. " "Use agent_self_history to avoid repeating ineffective choices. " @@ -4124,6 +4110,7 @@ def record_agent_memory( ], text_format=DecisionModel, ) + _record_usage(resp) decision = resp.output_parsed choice_idx = int(decision.choice_index) raw_choice_idx = choice_idx @@ -4346,13 +4333,16 @@ def record_agent_memory( menu = [] for idx, rt in enumerate(ROUTE_LIBRARY): edges = list(rt["edges"]) - blocked_cnt = 0 + blocked_cnt = 0.0 risk_sum = 0.0 + route_length_m = 0.0 min_margin = float("inf") for e in edges: b, r, m = edge_risk(e) - blocked_cnt += int(b) - risk_sum += r + w = EDGE_LENGTH.get(e, MEAN_EDGE_LENGTH_M) / MEAN_EDGE_LENGTH_M + blocked_cnt += w if b else 0.0 + risk_sum += r * w + route_length_m += EDGE_LENGTH.get(e, MEAN_EDGE_LENGTH_M) if m < min_margin: min_margin = m menu.append({ @@ -4362,16 +4352,18 @@ def record_agent_memory( "risk_sum": round(risk_sum, 4), "min_margin_m": None if not math.isfinite(min_margin) else round(min_margin, 2), "len_edges": len(edges), + "route_length_m": round(route_length_m, 2), }) for item in menu: info = build_driver_briefing( - blocked_edges=int(item.get("blocked_edges", 0)), + blocked_edges=float(item.get("blocked_edges", 0)), risk_sum=float(item.get("risk_sum", 0.0)), min_margin_m=item.get("min_margin_m"), len_edges=int(item.get("len_edges", 0)), travel_time_s=None, baseline_time_s=None, + route_length_m=item.get("route_length_m"), ) item.update(info) annotate_menu_with_expected_utility( @@ -4382,8 +4374,33 @@ def record_agent_memory( profile=agent_state.profile, scenario=SCENARIO_MODE, ) + + # --- Institutional delay: forecast + annotated menu (route mode) --- + append_institutional_history(agent_state, { + "decision_round": int(decision_round), + "forecast": dict(scenario_forecast_payload), + "annotated_menu": [dict(item) for item in menu], + }) + _inst_unavailable_rt = False + if SCENARIO_MODE != "no_notice" and delay_rounds > 0: + _inst_rt = apply_institutional_delay( + agent_state.institutional_history, delay_rounds, + ) + if _inst_rt is not None: + scenario_forecast_payload = dict(_inst_rt["forecast"]) + prompt_env_signal, prompt_forecast = apply_scenario_to_signals( + SCENARIO_MODE, env_signal, scenario_forecast_payload, + ) + menu = list(_inst_rt.get("annotated_menu", menu)) + else: + _inst_unavailable_rt = True + prompt_forecast = { + "available": False, + "briefing": "Official forecast not yet available.", + } + prompt_route_menu = filter_menu_for_scenario( - SCENARIO_MODE, + "no_notice" if _inst_unavailable_rt else SCENARIO_MODE, menu, control_mode="route", ) @@ -4414,6 +4431,28 @@ def record_agent_memory( if SCENARIO_CONFIG["forecast_visible"] else "No official forecast is available in this scenario. " ) + _theta_trust = float(agent_state.profile["theta_trust"]) + if _theta_trust == 0.0: + trust_policy = ( + "BINDING CONSTRAINT — Social trust: Your theta_trust = 0.0. " + "You have ZERO trust in neighbor messages. " + "IGNORE neighbor_assessment and all inbox messages entirely — " + "base your hazard judgment ONLY on your_observation and official information. " + "Do NOT cite neighbor consensus or inbox content in your reasoning. " + ) + _consider_pol = "Consider ONLY your_observation for your hazard judgment. " + _belief_weigh_pol = "combined_belief already reflects zero social weight and is based solely on your own observations. " + else: + _own_pct = round((1 - _theta_trust) * 100) + _soc_pct = round(_theta_trust * 100) + trust_policy = ( + f"Social trust calibration: Your theta_trust = {_theta_trust:.4f}. " + f"This means your decision should rely {_own_pct}% on your own observation " + f"and {_soc_pct}% on neighbor messages and inbox. " + "Weight neighbor/inbox information accordingly. " + ) + _consider_pol = "Consider your_observation, neighbor_assessment, and inbox for your hazard judgment. " + _belief_weigh_pol = "combined_belief is a mathematical estimate — you may weigh sources differently. " route_conflict_info = _build_conflict_description( belief_state.get("env_belief", {}), @@ -4431,7 +4470,7 @@ def record_agent_memory( "current_route_head": rinfo[:5], }, "agent_self_history_order": "chronological_oldest_first", - "agent_self_history": history_recent, + "agent_self_history": history_for_prompt, "fire_proximity": { "current_edge_margin_m": current_edge_margin_m, "route_head_min_margin_m": route_head_min_margin_m, @@ -4473,7 +4512,7 @@ def record_agent_memory( "fires": [{"x": fire_item["x"], "y": fire_item["y"], "r": round(fire_item["r"], 2)} for fire_item in fires], "route_menu": prompt_route_menu, "inbox_order": "chronological_oldest_first", - "inbox": inbox_for_vehicle, + "inbox": inbox_for_vehicle if _theta_trust > 0.0 else [], "messaging": { "enabled": MESSAGING_ENABLED, "max_message_chars": MAX_MESSAGE_CHARS, @@ -4481,6 +4520,7 @@ def record_agent_memory( "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, + "comm_radius_m": COMM_RADIUS_M, "broadcast_token": "*", }, "policy": ( @@ -4495,8 +4535,9 @@ def record_agent_memory( "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. " + f"{_consider_pol}" + f"{_belief_weigh_pol}" + f"{trust_policy}" "If information_conflict.sources_agree is false, explain in conflict_assessment " "which source you trusted more and why. " "Use agent_self_history to avoid repeating ineffective choices. " @@ -4559,6 +4600,7 @@ def record_agent_memory( ], text_format=DecisionModel, ) + _record_usage(resp) decision = resp.output_parsed choice_idx = int(decision.choice_index) raw_choice_idx = choice_idx @@ -4950,6 +4992,7 @@ def update_fire_shapes(sim_t_s: float): try: for _aid, _astate in AGENT_STATES.items(): metrics.record_agent_profile(_aid, _astate.profile) + metrics.token_usage = dict(_token_usage) 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 29d7356..8c0a6c2 100644 --- a/agentevac/simulation/spawn_events.py +++ b/agentevac/simulation/spawn_events.py @@ -45,7 +45,7 @@ ("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), @@ -70,7 +70,7 @@ ("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), @@ -78,211 +78,211 @@ ("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), + # + # ("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/run_parameters.py b/agentevac/utils/run_parameters.py index b7801de..235baa4 100644 --- a/agentevac/utils/run_parameters.py +++ b/agentevac/utils/run_parameters.py @@ -57,6 +57,54 @@ def build_parameter_log_path(base_path: str, *, reference_path: Optional[str | P return str(candidate) +class _CompactLeafEncoder(json.JSONEncoder): + """JSON encoder that renders dicts of only scalar values on a single line. + + Nested structures are indented normally, but "leaf" dicts (whose values are + all str, int, float, bool, or None) are kept compact so they can be directly + copy-pasted as Python dict literals. + """ + + def __init__(self, **kw): + self._sort_keys = kw.pop("sort_keys", False) + kw.pop("indent", None) # we handle indentation ourselves + super().__init__(**kw) + self._indent = " " + + def encode(self, o): + return self._fmt(o, 0) + + @staticmethod + def _is_leaf_dict(d): + return isinstance(d, dict) and all( + isinstance(v, (str, int, float, bool, type(None))) for v in d.values() + ) + + def _fmt(self, o, level): + ind = self._indent * level + ind1 = self._indent * (level + 1) + if isinstance(o, dict): + if self._is_leaf_dict(o): + keys = sorted(o) if self._sort_keys else list(o) + pairs = ", ".join( + f"{json.dumps(k)}: {json.dumps(o[k], ensure_ascii=False)}" + for k in keys + ) + return "{" + pairs + "}" + keys = sorted(o) if self._sort_keys else list(o) + items = ",\n".join( + f"{ind1}{json.dumps(k)}: {self._fmt(o[k], level + 1)}" + for k in keys + ) + return "{\n" + items + f"\n{ind}}}" + if isinstance(o, list): + if not o: + return "[]" + items = ",\n".join(f"{ind1}{self._fmt(item, level + 1)}" for item in o) + return "[\n" + items + f"\n{ind}]" + return json.dumps(o, ensure_ascii=False) + + def write_run_parameter_log( base_path: str, payload: Mapping[str, Any], @@ -67,7 +115,7 @@ def write_run_parameter_log( target = Path(build_parameter_log_path(base_path, reference_path=reference_path)) target.parent.mkdir(parents=True, exist_ok=True) with target.open("w", encoding="utf-8") as fh: - json.dump(dict(payload), fh, ensure_ascii=False, indent=2, sort_keys=True) + fh.write(_CompactLeafEncoder(sort_keys=True, ensure_ascii=False).encode(dict(payload))) fh.write("\n") return str(target) diff --git a/configs/halifax/destinations.json b/configs/halifax/destinations.json new file mode 100644 index 0000000..28bf8ba --- /dev/null +++ b/configs/halifax/destinations.json @@ -0,0 +1,6 @@ +[ + {"name": "shelter_0", "edge": "70604245"}, + {"name": "shelter_1", "edge": "122683016#1"}, + {"name": "shelter_2", "edge": "-1243586665"}, + {"name": "shelter_3", "edge": "1451309280-AddedOffRampEdge"} +] diff --git a/configs/halifax/fires.json b/configs/halifax/fires.json new file mode 100644 index 0000000..3e71592 --- /dev/null +++ b/configs/halifax/fires.json @@ -0,0 +1,8 @@ +{ + "sources": [ + {"id": "F0", "t0": 0.0, "x": 53510.0, "y": 24500.0, "r0": 1410.0, "growth_m_per_s": 0.02}, + {"id": "F0_1", "t0": 0.0, "x": 52370.0, "y": 21560.0, "r0": 1440.0, "growth_m_per_s": 0.02}, + {"id": "F0_2", "t0": 0.0, "x": 48660.0, "y": 19330.0, "r0": 1640.0, "growth_m_per_s": 0.02} + ], + "events": [] +} diff --git a/configs/halifax/map.json b/configs/halifax/map.json new file mode 100644 index 0000000..336316d --- /dev/null +++ b/configs/halifax/map.json @@ -0,0 +1,4 @@ +{ + "net_file": "sumo/halifax.net.xml", + "sumo_cfg": "sumo/halifax.sumocfg" +} diff --git a/configs/halifax/routes.json b/configs/halifax/routes.json new file mode 100644 index 0000000..ba4432d --- /dev/null +++ b/configs/halifax/routes.json @@ -0,0 +1,7 @@ +[ + { + "name": "route_do_not_use", + "edges": [ + ] + } +] diff --git a/configs/halifax/spawns.json b/configs/halifax/spawns.json new file mode 100644 index 0000000..99e3a9e --- /dev/null +++ b/configs/halifax/spawns.json @@ -0,0 +1,156 @@ +{ + "groups": [ + { + "edge": "-1238930581", + "count": 3 + }, + { + "edge": "-1238930582", + "count": 7 + }, + { + "edge": "-1238930583", + "count": 12 + }, + { + "edge": "-1238933488#1", + "count": 1 + }, + { + "edge": "-1238933490#0", + "count": 7 + }, + { + "edge": "-1238933490#1", + "count": 6 + }, + { + "edge": "-71304551", + "count": 2 + }, + { + "edge": "-71304558", + "count": 4 + }, + { + "edge": "-71304578", + "count": 1 + }, + { + "edge": "-71304581", + "count": 7 + }, + { + "edge": "-71304588#0", + "count": 4 + }, + { + "edge": "-71304588#1", + "count": 7 + }, + { + "edge": "-71305607", + "count": 10 + }, + { + "edge": "-71306176", + "count": 8 + }, + { + "edge": "-71306177", + "count": 6 + }, + { + "edge": "-71306182", + "count": 1 + }, + { + "edge": "-71306185", + "count": 3 + }, + { + "edge": "-71306200", + "count": 5 + }, + { + "edge": "1238930581", + "count": 7 + }, + { + "edge": "1238930582", + "count": 3 + }, + { + "edge": "1238930583", + "count": 4 + }, + { + "edge": "1238933488#0", + "count": 2 + }, + { + "edge": "1238933488#1", + "count": 5 + }, + { + "edge": "1238933489", + "count": 5 + }, + { + "edge": "1238933490#0", + "count": 6 + }, + { + "edge": "1238933490#1", + "count": 6 + }, + { + "edge": "71304551", + "count": 6 + }, + { + "edge": "71304558", + "count": 4 + }, + { + "edge": "71304566", + "count": 1 + }, + { + "edge": "71304578", + "count": 1 + }, + { + "edge": "71304581", + "count": 1 + }, + { + "edge": "71304588#0", + "count": 4 + }, + { + "edge": "71304588#1", + "count": 4 + }, + { + "edge": "71305607", + "count": 9 + }, + { + "edge": "71306177", + "count": 7 + }, + { + "edge": "71306182", + "count": 14 + }, + { + "edge": "71306185", + "count": 3 + }, + { + "edge": "71306200", + "count": 8 + } + ] +} diff --git a/configs/halifax/spawns_compact(sample_only).json b/configs/halifax/spawns_compact(sample_only).json new file mode 100644 index 0000000..c6bc7b0 --- /dev/null +++ b/configs/halifax/spawns_compact(sample_only).json @@ -0,0 +1,13 @@ +{ + "groups": [ + {"edge": "42006672", "count": 3}, + {"edge": "42006514#4", "count": 2}, + {"edge": "-42006515", "count": 2}, + {"edge": "42006515", "count": 2}, + {"edge": "42006565", "count": 2}, + {"edge": "-42006513#0", "count": 5}, + {"edge": "42006504#1", "count": 3}, + {"edge": "42006513#0", "count": 5}, + {"edge": "-42006719#1", "count": 7} + ] +} diff --git a/configs/lytton/destinations.json b/configs/lytton/destinations.json new file mode 100644 index 0000000..8b37b3c --- /dev/null +++ b/configs/lytton/destinations.json @@ -0,0 +1,4 @@ +[ + {"name": "shelter_0", "edge": "E#S0"}, + {"name": "shelter_2", "edge": "E#S2"} +] diff --git a/configs/lytton/fires.json b/configs/lytton/fires.json new file mode 100644 index 0000000..0c512f2 --- /dev/null +++ b/configs/lytton/fires.json @@ -0,0 +1,11 @@ +{ + "sources": [ + {"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_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_8", "t0": 0.0, "x": 16348.0, "y": 6801.0, "r0": 400.0, "growth_m_per_s": 0.02} + ], + "events": [] +} diff --git a/configs/lytton/map.json b/configs/lytton/map.json new file mode 100644 index 0000000..732852f --- /dev/null +++ b/configs/lytton/map.json @@ -0,0 +1,4 @@ +{ + "net_file": "sumo/Repaired.net.xml", + "sumo_cfg": "sumo/Repaired.sumocfg" +} diff --git a/configs/lytton/routes.json b/configs/lytton/routes.json new file mode 100644 index 0000000..16d817a --- /dev/null +++ b/configs/lytton/routes.json @@ -0,0 +1,28 @@ +[ + { + "name": "route_0", + "edges": [ + "-479435809#1", + "-479435809#0", + "-479435812#0", + "-479435806", + "-30689314#10", + "-30689314#9", + "-30689314#8", + "-30689314#7", + "-30689314#6", + "-30689314#5", + "-30689314#4", + "-30689314#1", + "-30689314#0", + "-479505716#1", + "-479505717", + "-479505352", + "-479505354#2", + "-479505354#1", + "-479505354#0", + "-42047741#0", + "E#S1" + ] + } +] diff --git a/configs/lytton/spawns.json b/configs/lytton/spawns.json new file mode 100644 index 0000000..445e284 --- /dev/null +++ b/configs/lytton/spawns.json @@ -0,0 +1,41 @@ +[ + {"veh_id": "veh1_1", "spawn_edge": "42006672", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "10", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh1_2", "spawn_edge": "42006672", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "10", "speed": "max", "color": [0, 0, 255, 255]}, + {"veh_id": "veh1_3", "spawn_edge": "42006672", "dest_edge": "E#S2", "depart_time": 10.0, "lane": "first", "pos": "10", "speed": "max", "color": [0, 255, 0, 255]}, + + {"veh_id": "veh2_1", "spawn_edge": "42006514#4", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh2_2", "spawn_edge": "42006514#4", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 0, 255, 255]}, + + {"veh_id": "veh3_1", "spawn_edge": "-42006515", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh3_2", "spawn_edge": "-42006515", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 0, 255, 255]}, + + {"veh_id": "veh4_1", "spawn_edge": "42006515", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh4_2", "spawn_edge": "42006515", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 0, 255, 255]}, + + {"veh_id": "veh5_1", "spawn_edge": "42006565", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh5_2", "spawn_edge": "42006565", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 0, 255, 255]}, + + {"veh_id": "veh6_1", "spawn_edge": "-42006513#0", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh6_2", "spawn_edge": "-42006513#0", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 0, 255, 255]}, + {"veh_id": "veh6_3", "spawn_edge": "-42006513#0", "dest_edge": "E#S2", "depart_time": 10.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 255, 0, 255]}, + {"veh_id": "veh6_4", "spawn_edge": "-42006513#0", "dest_edge": "E#S2", "depart_time": 15.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 125, 0, 255]}, + {"veh_id": "veh6_5", "spawn_edge": "-42006513#0", "dest_edge": "E#S2", "depart_time": 20.0, "lane": "first", "pos": "20", "speed": "max", "color": [125, 255, 0, 255]}, + + {"veh_id": "veh7_1", "spawn_edge": "42006504#1", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh7_2", "spawn_edge": "42006504#1", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 0, 255, 255]}, + {"veh_id": "veh7_3", "spawn_edge": "42006504#1", "dest_edge": "E#S2", "depart_time": 10.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 255, 0, 255]}, + + {"veh_id": "veh8_1", "spawn_edge": "42006513#0", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh8_2", "spawn_edge": "42006513#0", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 0, 255, 255]}, + {"veh_id": "veh8_3", "spawn_edge": "42006513#0", "dest_edge": "E#S2", "depart_time": 10.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 255, 0, 255]}, + {"veh_id": "veh8_4", "spawn_edge": "42006513#0", "dest_edge": "E#S2", "depart_time": 15.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 125, 0, 255]}, + {"veh_id": "veh8_5", "spawn_edge": "42006513#0", "dest_edge": "E#S2", "depart_time": 20.0, "lane": "first", "pos": "20", "speed": "max", "color": [125, 255, 0, 255]}, + + {"veh_id": "veh9_1", "spawn_edge": "-42006719#1", "dest_edge": "E#S2", "depart_time": 0.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 0, 0, 255]}, + {"veh_id": "veh9_2", "spawn_edge": "-42006719#1", "dest_edge": "E#S2", "depart_time": 5.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 0, 255, 255]}, + {"veh_id": "veh9_3", "spawn_edge": "-42006719#1", "dest_edge": "E#S2", "depart_time": 10.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 255, 0, 255]}, + {"veh_id": "veh9_4", "spawn_edge": "-42006719#1", "dest_edge": "E#S2", "depart_time": 15.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 125, 0, 255]}, + {"veh_id": "veh9_5", "spawn_edge": "-42006719#1", "dest_edge": "E#S2", "depart_time": 20.0, "lane": "first", "pos": "20", "speed": "max", "color": [125, 255, 0, 255]}, + {"veh_id": "veh9_6", "spawn_edge": "-42006719#1", "dest_edge": "E#S2", "depart_time": 25.0, "lane": "first", "pos": "20", "speed": "max", "color": [0, 255, 255, 255]}, + {"veh_id": "veh9_7", "spawn_edge": "-42006719#1", "dest_edge": "E#S2", "depart_time": 30.0, "lane": "first", "pos": "20", "speed": "max", "color": [255, 255, 0, 255]} +] diff --git a/configs/lytton/spawns_compact.json b/configs/lytton/spawns_compact.json new file mode 100644 index 0000000..c6bc7b0 --- /dev/null +++ b/configs/lytton/spawns_compact.json @@ -0,0 +1,13 @@ +{ + "groups": [ + {"edge": "42006672", "count": 3}, + {"edge": "42006514#4", "count": 2}, + {"edge": "-42006515", "count": 2}, + {"edge": "42006515", "count": 2}, + {"edge": "42006565", "count": 2}, + {"edge": "-42006513#0", "count": 5}, + {"edge": "42006504#1", "count": 3}, + {"edge": "42006513#0", "count": 5}, + {"edge": "-42006719#1", "count": 7} + ] +} diff --git a/scripts/generate_spawns_from_buildings.py b/scripts/generate_spawns_from_buildings.py new file mode 100644 index 0000000..716040a --- /dev/null +++ b/scripts/generate_spawns_from_buildings.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +"""Generate spawn configuration from building polygons and a SUMO network. + +For each building polygon, computes the centroid, finds the nearest drivable +edge in the SUMO network, and writes a ``spawns.json`` file in either compact +or detailed format. + +Usage examples:: + + # One agent per building, compact format (default) + python scripts/generate_spawns_from_buildings.py \ + --net sumo/halifax.net.xml \ + --buildings sumo/Halifax_buildings.xml \ + --output configs/halifax/spawns.json + + # 3 agents per building, within a SUMO XY bounding box + python scripts/generate_spawns_from_buildings.py \ + --net sumo/halifax.net.xml \ + --buildings sumo/Halifax_buildings.xml \ + --output configs/halifax/spawns.json \ + --mode per-building --count 3 \ + --bbox "10000,20000,30000,35000" + + # 2 agents per unique edge (deduplicated) + python scripts/generate_spawns_from_buildings.py \ + --net sumo/halifax.net.xml \ + --buildings sumo/Halifax_buildings.xml \ + --output configs/halifax/spawns.json \ + --mode per-edge --count 2 + + # Detailed format output + python scripts/generate_spawns_from_buildings.py \ + --net sumo/halifax.net.xml \ + --buildings sumo/Halifax_buildings.xml \ + --output configs/halifax/spawns.json \ + --format detailed --dest-edge "E#S0" + +Modes:: + + per-building : One group entry per building (buildings sharing an edge are + merged into one group with summed counts). + per-edge : One group entry per unique edge, regardless of how many + buildings map to it. + +The ``--count`` flag sets how many agents spawn per building (``per-building`` +mode) or per unique edge (``per-edge`` mode). Default is 1. +""" + +import argparse +import json +import os +import sys +import xml.etree.ElementTree as ET +from collections import defaultdict +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +# --------------------------------------------------------------------------- +# SUMO_HOME setup +# --------------------------------------------------------------------------- +if "SUMO_HOME" in os.environ: + sys.path.append(os.path.join(os.environ["SUMO_HOME"], "tools")) +else: + # Try common locations + for candidate in ["/usr/share/sumo/tools", "/opt/sumo/tools"]: + if os.path.isdir(candidate): + sys.path.append(candidate) + break + +import sumolib # noqa: E402 (must follow path setup) + +# Edge types that allow passenger vehicles (cars). +_DRIVABLE_TYPES = { + "highway.residential", + "highway.tertiary", + "highway.secondary", + "highway.unclassified", + "highway.primary", + "highway.motorway", + "highway.motorway_link", + "highway.primary_link", + "highway.secondary_link", + "highway.tertiary_link", + "highway.living_street", + "highway.trunk", + "highway.trunk_link", + "highway.service", +} + + +# --------------------------------------------------------------------------- +# Building polygon parsing +# --------------------------------------------------------------------------- + +def parse_buildings(buildings_path: str) -> List[Dict]: + """Parse building polygons and compute centroids. + + Args: + buildings_path: Path to the SUMO ```` XML file containing + ```` elements (e.g., output of ``polyconvert``). + + Returns: + List of dicts with keys ``id``, ``lon``, ``lat`` (centroid coordinates). + """ + buildings = [] + tree = ET.iterparse(buildings_path, events=("end",)) + for _, elem in tree: + if elem.tag != "poly": + continue + poly_id = elem.get("id", "") + shape_str = elem.get("shape", "") + if not shape_str: + elem.clear() + continue + + # Parse shape points (lon,lat pairs separated by spaces) + points = [] + for pair in shape_str.strip().split(): + parts = pair.split(",") + if len(parts) >= 2: + try: + lon, lat = float(parts[0]), float(parts[1]) + points.append((lon, lat)) + except ValueError: + continue + + if not points: + elem.clear() + continue + + # Centroid (average of vertices) + cx = sum(p[0] for p in points) / len(points) + cy = sum(p[1] for p in points) / len(points) + + buildings.append({"id": poly_id, "lon": cx, "lat": cy}) + elem.clear() + + return buildings + + +def filter_buildings_by_bbox( + buildings: List[Dict], + net, + bbox_xy: Tuple[float, float, float, float], +) -> List[Dict]: + """Filter buildings whose SUMO XY centroid falls outside a bounding box. + + Args: + buildings: List of building dicts with ``lon``, ``lat``. + net: sumolib network object (used for lon/lat → XY conversion). + bbox_xy: Bounding box in SUMO XY coordinates: ``(x1, y1, x2, y2)``. + + Returns: + Filtered list of building dicts. + """ + x1, y1, x2, y2 = bbox_xy + x_min, x_max = min(x1, x2), max(x1, x2) + y_min, y_max = min(y1, y2), max(y1, y2) + + result = [] + for b in buildings: + x, y = net.convertLonLat2XY(b["lon"], b["lat"]) + if x_min <= x <= x_max and y_min <= y <= y_max: + result.append(b) + return result + + +# --------------------------------------------------------------------------- +# Nearest-edge lookup (KD-tree accelerated) +# --------------------------------------------------------------------------- + +def _build_edge_index(net, drivable_types): + """Build a KD-tree of drivable edge midpoints for fast nearest-neighbor lookup. + + Returns: + (kdtree, edge_ids, edge_midpoints) where edge_ids[i] corresponds to the + i-th point in the KD-tree. + """ + from scipy.spatial import cKDTree + + edge_ids = [] + midpoints = [] + for e in net.getEdges(withInternal=False): + if e.getType() not in drivable_types: + continue + # Check that at least one lane allows passenger + allows_passenger = False + for lane in e.getLanes(): + if lane.allows("passenger"): + allows_passenger = True + break + if not allows_passenger: + continue + + # Use midpoint of the first lane's shape + shape = e.getLanes()[0].getShape() + if not shape: + continue + mid_idx = len(shape) // 2 + mx, my = float(shape[mid_idx][0]), float(shape[mid_idx][1]) + edge_ids.append(e.getID()) + midpoints.append((mx, my)) + + if not midpoints: + raise RuntimeError("No drivable edges found in the network.") + + import numpy as np + points = np.array(midpoints) + tree = cKDTree(points) + return tree, edge_ids, points + + +def find_nearest_edges( + net, + buildings: List[Dict], + max_distance_m: float, + drivable_types=None, +) -> List[Dict]: + """For each building, find the nearest drivable edge. + + Args: + net: sumolib network object. + buildings: List of building dicts with ``lon``, ``lat``. + max_distance_m: Skip buildings farther than this from any edge. + drivable_types: Set of edge type strings to consider. + + Returns: + List of dicts with ``building_id``, ``edge_id``, ``distance_m``. + """ + import numpy as np + + if drivable_types is None: + drivable_types = _DRIVABLE_TYPES + + print(f"[SPAWNS] Building edge spatial index for {len(buildings)} buildings...") + tree, edge_ids, _ = _build_edge_index(net, drivable_types) + + # Convert all building centroids to SUMO XY + xy_points = [] + for b in buildings: + x, y = net.convertLonLat2XY(b["lon"], b["lat"]) + xy_points.append((x, y)) + + query = np.array(xy_points) + distances, indices = tree.query(query, k=1) + + results = [] + skipped = 0 + for i, b in enumerate(buildings): + dist = float(distances[i]) + if dist > max_distance_m: + skipped += 1 + continue + results.append({ + "building_id": b["id"], + "edge_id": edge_ids[indices[i]], + "distance_m": round(dist, 1), + }) + + print(f"[SPAWNS] Matched {len(results)} buildings to edges " + f"(skipped {skipped} beyond {max_distance_m}m)") + return results + + +# --------------------------------------------------------------------------- +# Spawn generation +# --------------------------------------------------------------------------- + +def generate_spawn_config( + matches: List[Dict], + mode: str, + count: int, + dest_edge: Optional[str], + output_format: str, +): + """Convert building-to-edge matches into spawn configuration. + + Args: + matches: Output of ``find_nearest_edges``. + mode: ``"per-building"`` or ``"per-edge"``. + count: Agents per building (per-building mode) or per edge (per-edge mode). + dest_edge: Default destination edge. If None, omitted from output + (will use destinations.json default at load time). + output_format: ``"compact"`` or ``"detailed"``. + + Returns: + JSON-serializable spawn config (list for detailed, dict for compact). + """ + # Aggregate by edge + edge_building_counts: Dict[str, int] = defaultdict(int) + for m in matches: + edge_building_counts[m["edge_id"]] += 1 + + if mode == "per-building": + # Each building contributes `count` agents → edge total = buildings * count + groups = [] + for edge_id, n_buildings in sorted(edge_building_counts.items()): + total = n_buildings * count + entry = {"edge": edge_id, "count": total} + if dest_edge: + entry["dest_edge"] = dest_edge + groups.append(entry) + elif mode == "per-edge": + # Ignore how many buildings map here; each unique edge gets `count` agents + groups = [] + for edge_id in sorted(edge_building_counts.keys()): + entry = {"edge": edge_id, "count": count} + if dest_edge: + entry["dest_edge"] = dest_edge + groups.append(entry) + else: + raise ValueError(f"Unknown mode: {mode!r}") + + total_agents = sum(g["count"] for g in groups) + total_edges = len(groups) + print(f"[SPAWNS] Mode={mode} count={count} → " + f"{total_agents} agents across {total_edges} edges") + + if output_format == "compact": + result = {"groups": groups} + if dest_edge: + result["default_dest_edge"] = dest_edge + return result + + # Detailed format: expand groups into per-agent dicts + palette = [ + [255, 0, 0, 255], [0, 0, 255, 255], [0, 255, 0, 255], + [255, 125, 0, 255], [125, 255, 0, 255], [0, 255, 255, 255], + [255, 255, 0, 255], [0, 125, 255, 255], [125, 0, 255, 255], + [255, 0, 255, 255], + ] + agents = [] + for g in groups: + edge = g["edge"] + for i in range(1, g["count"] + 1): + agent = { + "veh_id": f"{edge}_{i}", + "spawn_edge": edge, + "dest_edge": dest_edge or "", + "depart_time": 0.0, + "lane": "first", + "pos": str(10.0 + 10.0 * (i - 1)), + "speed": "max", + "color": palette[(i - 1) % len(palette)], + } + agents.append(agent) + return agents + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + +def _parse_bbox(raw: Optional[str]) -> Optional[Tuple[float, float, float, float]]: + if not raw: + return None + parts = [float(x.strip()) for x in raw.split(",")] + if len(parts) != 4: + raise ValueError("--bbox must have 4 comma-separated values: x1,y1,x2,y2") + return (parts[0], parts[1], parts[2], parts[3]) + + +def main(): + parser = argparse.ArgumentParser( + description="Generate spawn configuration from building polygons.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument("--net", required=True, help="Path to SUMO .net.xml file.") + parser.add_argument("--buildings", required=True, help="Path to buildings polygon XML.") + parser.add_argument("--output", required=True, help="Output spawns JSON path.") + parser.add_argument( + "--mode", choices=["per-building", "per-edge"], default="per-building", + help="per-building: count agents per building. per-edge: count agents per unique edge. (default: per-building)", + ) + parser.add_argument( + "--count", type=int, default=1, + help="Agents per building (per-building mode) or per edge (per-edge mode). (default: 1)", + ) + parser.add_argument( + "--format", choices=["compact", "detailed"], default="compact", dest="output_format", + help="Output format. (default: compact)", + ) + parser.add_argument( + "--max-distance", type=float, default=200.0, + help="Skip buildings farther than this (metres) from any drivable edge. (default: 200)", + ) + parser.add_argument( + "--bbox", + help="Bounding box in SUMO XY coordinates: x1,y1,x2,y2. " + "Only buildings whose centroid (converted to XY) falls inside are included. " + "Read the coordinates from the SUMO GUI status bar.", + ) + parser.add_argument( + "--dest-edge", + help="Default destination edge for all agents. If omitted, uses destinations.json at load time.", + ) + parser.add_argument( + "--extra-types", nargs="*", default=[], + help="Additional edge types to consider drivable (e.g., highway.track).", + ) + args = parser.parse_args() + + # 1. Parse buildings + print(f"[SPAWNS] Parsing buildings from {args.buildings}...") + buildings = parse_buildings(args.buildings) + print(f"[SPAWNS] Parsed {len(buildings)} buildings total") + + if not buildings: + print("[SPAWNS] No buildings found. Check building file.") + return 1 + + # 2. Load network + print(f"[SPAWNS] Loading network {args.net}...") + net = sumolib.net.readNet(args.net, withInternal=False) + + # 3. Apply bbox filter in SUMO XY coordinates + bbox = _parse_bbox(args.bbox) + if bbox: + buildings = filter_buildings_by_bbox(buildings, net, bbox) + print(f"[SPAWNS] {len(buildings)} buildings within bbox " + f"x=[{min(bbox[0],bbox[2]):.0f},{max(bbox[0],bbox[2]):.0f}] " + f"y=[{min(bbox[1],bbox[3]):.0f},{max(bbox[1],bbox[3]):.0f}]") + if not buildings: + print("[SPAWNS] No buildings in bbox. Check coordinates in SUMO GUI.") + return 1 + + # 4. Find nearest edges + drivable = set(_DRIVABLE_TYPES) + for extra in args.extra_types: + drivable.add(extra.strip()) + + matches = find_nearest_edges( + net, buildings, + max_distance_m=args.max_distance, + drivable_types=drivable, + ) + + if not matches: + print("[SPAWNS] No buildings matched to edges. Try increasing --max-distance.") + return 1 + + # 5. Generate spawn config + config = generate_spawn_config( + matches, + mode=args.mode, + count=args.count, + dest_edge=args.dest_edge, + output_format=args.output_format, + ) + + # 6. Write output + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + with open(out_path, "w") as f: + json.dump(config, f, indent=2) + f.write("\n") + print(f"[SPAWNS] Written to {out_path}") + return 0 + + +if __name__ == "__main__": + sys.exit(main() or 0) diff --git a/scripts/plot_experiment_comparison.py b/scripts/plot_experiment_comparison.py index c66f795..54f7e94 100644 --- a/scripts/plot_experiment_comparison.py +++ b/scripts/plot_experiment_comparison.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse +import re from pathlib import Path from typing import Any @@ -57,11 +58,41 @@ def _metrics_row(metrics: dict[str, Any]) -> dict[str, float]: } +SCENARIO_ORDER: tuple[str, ...] = ("no_notice", "alert_guided", "advice_guided", "unknown") +SCENARIO_COLORS: dict[str, str] = { + "no_notice": "#E45756", + "alert_guided": "#F58518", + "advice_guided": "#4C78A8", + "unknown": "#777777", +} + + +_FILENAME_PARAM_RE = re.compile( + r"scn-(?P[A-Za-z0-9_]+?)" + r"_sigma-(?P-?\d+(?:\.\d+)?)" + r"_delay-(?P-?\d+(?:\.\d+)?)" + r"_trust-(?P-?\d+(?:\.\d+)?)" +) + + +def _params_from_filename(path: Path) -> dict[str, Any]: + """Recover sweep parameters encoded in a metrics filename.""" + match = _FILENAME_PARAM_RE.search(path.stem) + if not match: + return {} + return { + "scenario": match.group("scenario"), + "info_sigma": _safe_float(match.group("info_sigma")), + "info_delay_s": _safe_float(match.group("info_delay_s")), + "theta_trust": _safe_float(match.group("theta_trust")), + } + + def _param_metadata(path: Path) -> dict[str, Any]: """Load companion run parameters for plots that only have KPI JSON files.""" params_path = resolve_optional_run_params(None, path) if params_path is None: - return {} + return _params_from_filename(path) payload = load_json(params_path) cognition = payload.get("cognition") or {} return { @@ -101,7 +132,10 @@ def load_cases(results_json: Path | None, metrics_glob: str) -> tuple[list[dict[ rows.append(row) return rows, results_json - matches = sorted(Path().glob(metrics_glob)) + matches = [ + path for path in sorted(Path().glob(metrics_glob)) + if not path.stem.endswith("_profiles") + ] if not matches: raise SystemExit(f"No metrics files match pattern: {metrics_glob}") for path in matches: @@ -121,12 +155,6 @@ def load_cases(results_json: Path | None, metrics_glob: str) -> tuple[list[dict[ def _scatter_by_scenario(ax, rows: list[dict[str, Any]]) -> None: - scenario_colors = { - "no_notice": "#E45756", - "alert_guided": "#F58518", - "advice_guided": "#4C78A8", - "unknown": "#777777", - } seen = set() for row in rows: scenario = str(row.get("scenario", "unknown")) @@ -137,7 +165,7 @@ def _scatter_by_scenario(ax, rows: list[dict[str, Any]]) -> None: row["hazard_exposure"], row["avg_travel_time"], s=size, - color=scenario_colors.get(scenario, "#777777"), + color=SCENARIO_COLORS.get(scenario, SCENARIO_COLORS["unknown"]), alpha=0.85, label=label, ) diff --git a/scripts/plot_run_metrics.py b/scripts/plot_run_metrics.py index 8982bdb..dc52063 100644 --- a/scripts/plot_run_metrics.py +++ b/scripts/plot_run_metrics.py @@ -4,7 +4,9 @@ from __future__ import annotations import argparse +import re from pathlib import Path +from typing import Any try: from scripts._plot_common import ( @@ -15,6 +17,11 @@ resolve_optional_run_params, top_items, ) + from scripts.plot_experiment_comparison import ( + SCENARIO_COLORS, + SCENARIO_ORDER, + load_cases, + ) except ModuleNotFoundError: from _plot_common import ( ensure_output_path, @@ -24,6 +31,14 @@ resolve_optional_run_params, top_items, ) + from plot_experiment_comparison import ( + SCENARIO_COLORS, + SCENARIO_ORDER, + load_cases, + ) + + +_CASE_INDEX_RE = re.compile(r"_(\d{3,})_") def _parse_args() -> argparse.Namespace: @@ -35,6 +50,12 @@ def _parse_args() -> argparse.Namespace: "--metrics", help="Path to a metrics JSON file. Defaults to the newest outputs/run_metrics_*.json.", ) + parser.add_argument( + "--metrics-glob", + help="Glob of metrics JSON files. When set, plots a multi-run KPI comparison " + "(2x2 grid of departure variance, route entropy, hazard exposure, avg travel time) " + "with bars sorted contiguously by scenario, instead of the single-run dashboard.", + ) parser.add_argument( "--params", help="Optional companion run_params JSON path. Defaults to the matching run_params_.json when present.", @@ -155,6 +176,132 @@ def _briefing_summary(params: dict | None) -> str | None: ) +def _kpi_multirun_specs() -> list[dict[str, str]]: + """Field/title/color descriptors for the multi-run KPI panels.""" + return [ + { + "field": "departure_variability", + "title": "Departure variance", + "ylabel": "Seconds^2", + "fmt": "{:.2f}", + }, + { + "field": "route_entropy", + "title": "Route entropy", + "ylabel": "Entropy (nats)", + "fmt": "{:.3f}", + }, + { + "field": "hazard_exposure", + "title": "Hazard exposure", + "ylabel": "Average risk score", + "fmt": "{:.3f}", + }, + { + "field": "avg_travel_time", + "title": "Avg travel time", + "ylabel": "Seconds", + "fmt": "{:.1f}", + }, + ] + + +def _short_run_label(row: dict[str, Any]) -> str: + """Return a compact bar label such as ``001`` extracted from the row label.""" + label = str(row.get("label", "")) + match = _CASE_INDEX_RE.search(label) + if match: + return match.group(1) + return label[:12] or "?" + + +def _sort_rows_by_scenario(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Sort rows so each scenario forms a contiguous group, then by label.""" + order_index = {name: i for i, name in enumerate(SCENARIO_ORDER)} + + def key(row: dict[str, Any]) -> tuple[int, str]: + scenario = str(row.get("scenario", "unknown")) + return (order_index.get(scenario, len(SCENARIO_ORDER)), str(row.get("label", ""))) + + return sorted(rows, key=key) + + +def plot_kpi_multirun( + rows: list[dict[str, Any]], + *, + source_path: Path, + out_path: Path, + show: bool, +) -> None: + """Render a 2x2 KPI comparison across multiple runs, grouped by scenario.""" + plt = require_matplotlib() + if not rows: + raise SystemExit("No runs to plot.") + + ordered = _sort_rows_by_scenario(rows) + short_labels = [_short_run_label(row) for row in ordered] + scenarios = [str(row.get("scenario", "unknown")) for row in ordered] + colors = [SCENARIO_COLORS.get(scn, SCENARIO_COLORS["unknown"]) for scn in scenarios] + + fig, axes = plt.subplots(2, 2, figsize=(14, 10)) + fig.suptitle( + f"AgentEvac Multi-Run KPIs\n{source_path.name} | runs={len(ordered)}", + fontsize=14, + ) + + for idx, spec in enumerate(_kpi_multirun_specs()): + ax = axes[idx // 2, idx % 2] + values = [float(row.get(spec["field"], 0.0)) for row in ordered] + positions = list(range(len(values))) + ax.bar(positions, values, color=colors) + ax.set_title(spec["title"], fontsize=11) + ax.set_ylabel(spec["ylabel"], fontsize=9) + ax.set_xticks(positions) + ax.set_xticklabels(short_labels, rotation=60, ha="right", fontsize=8) + ax.grid(axis="y", linestyle=":", alpha=0.35) + ymax = max(values) if values else 0.0 + ymin = min(values) if values else 0.0 + if ymax > 0.0: + ax.set_ylim(min(0.0, ymin * 1.1), ymax * 1.18) + for pos, val in zip(positions, values): + ax.text( + pos, + val if val >= 0.0 else 0.0, + spec["fmt"].format(val), + ha="center", + va="bottom", + fontsize=7, + rotation=0, + ) + + seen_scenarios: list[str] = [] + for scn in scenarios: + if scn not in seen_scenarios: + seen_scenarios.append(scn) + ordered_legend = [scn for scn in SCENARIO_ORDER if scn in seen_scenarios] + handles = [ + plt.Rectangle((0, 0), 1, 1, color=SCENARIO_COLORS.get(scn, SCENARIO_COLORS["unknown"])) + for scn in ordered_legend + ] + if handles: + fig.legend( + handles, + ordered_legend, + loc="lower center", + ncol=len(ordered_legend), + frameon=False, + fontsize=9, + ) + + fig.tight_layout(rect=(0, 0.05, 1, 0.95)) + fig.savefig(out_path, dpi=160, bbox_inches="tight") + print(f"[PLOT] source={source_path}") + print(f"[PLOT] output={out_path}") + if show: + plt.show() + plt.close(fig) + + def plot_metrics_dashboard( metrics_path: Path, *, @@ -225,6 +372,16 @@ def plot_metrics_dashboard( def main() -> None: """CLI entry point for the run-metrics dashboard.""" args = _parse_args() + if args.metrics_glob: + rows, source_path = load_cases(None, args.metrics_glob) + out_path = ensure_output_path(source_path, args.out, suffix="kpi_comparison") + plot_kpi_multirun( + rows, + source_path=source_path, + out_path=out_path, + show=args.show, + ) + return metrics_path = resolve_input(args.metrics, "outputs/run_metrics_*.json") params_path = resolve_optional_run_params(args.params, metrics_path) out_path = ensure_output_path(metrics_path, args.out, suffix="dashboard") diff --git a/sumo/Repaired.net.xml b/sumo/Repaired.net.xml index 7ed8640..e2ac443 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 83a24a1..06abd1f 100644 --- a/sumo/Repaired.sumocfg +++ b/sumo/Repaired.sumocfg @@ -1,6 +1,6 @@ - diff --git a/sumo/halifax.netecfg b/sumo/halifax.netecfg new file mode 100644 index 0000000..353fa71 --- /dev/null +++ b/sumo/halifax.netecfg @@ -0,0 +1,33 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/sumo/halifax.sumocfg b/sumo/halifax.sumocfg new file mode 100644 index 0000000..7a4f15d --- /dev/null +++ b/sumo/halifax.sumocfg @@ -0,0 +1,13 @@ + + + + + + + + + + + + diff --git a/sumo/halifax_spawn_1.json b/sumo/halifax_spawn_1.json new file mode 100644 index 0000000..99e3a9e --- /dev/null +++ b/sumo/halifax_spawn_1.json @@ -0,0 +1,156 @@ +{ + "groups": [ + { + "edge": "-1238930581", + "count": 3 + }, + { + "edge": "-1238930582", + "count": 7 + }, + { + "edge": "-1238930583", + "count": 12 + }, + { + "edge": "-1238933488#1", + "count": 1 + }, + { + "edge": "-1238933490#0", + "count": 7 + }, + { + "edge": "-1238933490#1", + "count": 6 + }, + { + "edge": "-71304551", + "count": 2 + }, + { + "edge": "-71304558", + "count": 4 + }, + { + "edge": "-71304578", + "count": 1 + }, + { + "edge": "-71304581", + "count": 7 + }, + { + "edge": "-71304588#0", + "count": 4 + }, + { + "edge": "-71304588#1", + "count": 7 + }, + { + "edge": "-71305607", + "count": 10 + }, + { + "edge": "-71306176", + "count": 8 + }, + { + "edge": "-71306177", + "count": 6 + }, + { + "edge": "-71306182", + "count": 1 + }, + { + "edge": "-71306185", + "count": 3 + }, + { + "edge": "-71306200", + "count": 5 + }, + { + "edge": "1238930581", + "count": 7 + }, + { + "edge": "1238930582", + "count": 3 + }, + { + "edge": "1238930583", + "count": 4 + }, + { + "edge": "1238933488#0", + "count": 2 + }, + { + "edge": "1238933488#1", + "count": 5 + }, + { + "edge": "1238933489", + "count": 5 + }, + { + "edge": "1238933490#0", + "count": 6 + }, + { + "edge": "1238933490#1", + "count": 6 + }, + { + "edge": "71304551", + "count": 6 + }, + { + "edge": "71304558", + "count": 4 + }, + { + "edge": "71304566", + "count": 1 + }, + { + "edge": "71304578", + "count": 1 + }, + { + "edge": "71304581", + "count": 1 + }, + { + "edge": "71304588#0", + "count": 4 + }, + { + "edge": "71304588#1", + "count": 4 + }, + { + "edge": "71305607", + "count": 9 + }, + { + "edge": "71306177", + "count": 7 + }, + { + "edge": "71306182", + "count": 14 + }, + { + "edge": "71306185", + "count": 3 + }, + { + "edge": "71306200", + "count": 8 + } + ] +} diff --git a/tests/test_messaging.py b/tests/test_messaging.py new file mode 100644 index 0000000..aa5096e --- /dev/null +++ b/tests/test_messaging.py @@ -0,0 +1,267 @@ +"""Unit tests for agentevac.agents.messaging — spatial broadcast filtering.""" + +import pytest + +from agentevac.agents.messaging import AgentMessagingBus, OutboxMessage + + +def _bus(comm_radius_m: float = 0.0, **kwargs) -> AgentMessagingBus: + """Create a bus with sensible defaults for testing.""" + defaults = dict( + enabled=True, + max_message_chars=400, + max_inbox_messages=20, + max_sends_per_agent_per_round=3, + max_broadcasts_per_round=20, + ttl_rounds=10, + comm_radius_m=comm_radius_m, + ) + defaults.update(kwargs) + return AgentMessagingBus(**defaults) + + +def _broadcast(text: str = "hello") -> list: + return [OutboxMessage(to="*", message=text)] + + +def _direct(to: str, text: str = "hello") -> list: + return [OutboxMessage(to=to, message=text)] + + +# --------------------------------------------------------------------------- +# Radius = 0 (disabled) — original broadcast-to-all behaviour +# --------------------------------------------------------------------------- + +class TestRadiusDisabled: + def test_broadcast_reaches_all_agents(self): + bus = _bus(comm_radius_m=0) + agents = ["A", "B", "C", "D", "E"] + bus.begin_round(1, agents) + bus.queue_outbox("A", _broadcast("fire nearby")) + bus.begin_round(2, agents) + + for agent in ["B", "C", "D", "E"]: + inbox = bus.get_inbox(agent) + assert len(inbox) == 1 + assert inbox[0]["message"] == "fire nearby" + + assert bus.get_inbox("A") == [] # sender excluded + + def test_broadcast_reaches_all_even_with_positions(self): + """When radius=0, positions are irrelevant — all agents receive.""" + bus = _bus(comm_radius_m=0) + agents = ["A", "B", "C"] + positions = {"A": (0.0, 0.0), "B": (99999.0, 99999.0), "C": (50000.0, 50000.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("test")) + bus.begin_round(2, agents, positions=positions) + + assert len(bus.get_inbox("B")) == 1 + assert len(bus.get_inbox("C")) == 1 + + +# --------------------------------------------------------------------------- +# Spatial filtering with finite radius +# --------------------------------------------------------------------------- + +class TestSpatialFiltering: + def test_nearby_agents_receive_broadcast(self): + bus = _bus(comm_radius_m=1000) + agents = ["A", "B", "C"] + # B is 500m away (within range), C is 2000m away (out of range) + positions = {"A": (0.0, 0.0), "B": (500.0, 0.0), "C": (2000.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("fire")) + bus.begin_round(2, agents, positions=positions) + + assert len(bus.get_inbox("B")) == 1 + assert len(bus.get_inbox("C")) == 0 + + def test_agent_exactly_at_boundary_receives(self): + bus = _bus(comm_radius_m=1000) + agents = ["A", "B"] + # B is exactly 1000m away — should receive (<=, not <) + positions = {"A": (0.0, 0.0), "B": (1000.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("test")) + bus.begin_round(2, agents, positions=positions) + + assert len(bus.get_inbox("B")) == 1 + + def test_agent_just_beyond_boundary_excluded(self): + bus = _bus(comm_radius_m=1000) + agents = ["A", "B"] + positions = {"A": (0.0, 0.0), "B": (1001.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("test")) + bus.begin_round(2, agents, positions=positions) + + assert len(bus.get_inbox("B")) == 0 + + def test_diagonal_distance(self): + """707m on each axis = ~1000m diagonal.""" + bus = _bus(comm_radius_m=1000) + agents = ["A", "B", "C"] + # B is ~707m diagonal (within 1000m), C is ~1414m diagonal (out of range) + positions = {"A": (0.0, 0.0), "B": (500.0, 500.0), "C": (1000.0, 1000.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("test")) + bus.begin_round(2, agents, positions=positions) + + assert len(bus.get_inbox("B")) == 1 # ~707m < 1000m + assert len(bus.get_inbox("C")) == 0 # ~1414m > 1000m + + def test_multiple_senders_different_reach(self): + bus = _bus(comm_radius_m=500) + agents = ["A", "B", "C"] + # A at origin, B at 300m, C at 800m + positions = {"A": (0.0, 0.0), "B": (300.0, 0.0), "C": (800.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("from A")) + bus.queue_outbox("C", _broadcast("from C")) + bus.begin_round(2, agents, positions=positions) + + # B is within 500m of both A and C + inbox_b = bus.get_inbox("B") + assert len(inbox_b) == 2 + + # A is 800m from C — out of range + inbox_a = bus.get_inbox("A") + assert len(inbox_a) == 0 + + # C is 800m from A — out of range + inbox_c = bus.get_inbox("C") + assert len(inbox_c) == 0 + + +# --------------------------------------------------------------------------- +# Direct messages bypass spatial filter +# --------------------------------------------------------------------------- + +class TestDirectMessagesUnfiltered: + def test_direct_message_delivered_regardless_of_distance(self): + bus = _bus(comm_radius_m=100) + agents = ["A", "B"] + positions = {"A": (0.0, 0.0), "B": (50000.0, 0.0)} # 50km apart + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _direct("B", "direct msg")) + bus.begin_round(2, agents, positions=positions) + + inbox = bus.get_inbox("B") + assert len(inbox) == 1 + assert inbox[0]["kind"] == "direct" + + def test_direct_and_broadcast_from_same_sender(self): + bus = _bus(comm_radius_m=500) + agents = ["A", "B", "C"] + positions = {"A": (0.0, 0.0), "B": (300.0, 0.0), "C": (5000.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", [ + OutboxMessage(to="*", message="broadcast"), + OutboxMessage(to="C", message="direct to C"), + ]) + bus.begin_round(2, agents, positions=positions) + + # B is nearby — gets broadcast + assert len(bus.get_inbox("B")) == 1 + # C is far — no broadcast, but gets direct message + inbox_c = bus.get_inbox("C") + assert len(inbox_c) == 1 + assert inbox_c[0]["kind"] == "direct" + + +# --------------------------------------------------------------------------- +# Missing positions — fail-open behaviour +# --------------------------------------------------------------------------- + +class TestMissingPositions: + def test_sender_without_position_broadcasts_to_all(self): + bus = _bus(comm_radius_m=1000) + agents = ["A", "B", "C"] + # A has no position entry + positions = {"B": (0.0, 0.0), "C": (5000.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("test")) + bus.begin_round(2, agents, positions=positions) + + # Both receive because sender position unknown — fail open + assert len(bus.get_inbox("B")) == 1 + assert len(bus.get_inbox("C")) == 1 + + def test_target_without_position_receives(self): + bus = _bus(comm_radius_m=1000) + agents = ["A", "B", "C"] + # C has no position entry + positions = {"A": (0.0, 0.0), "B": (500.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("test")) + bus.begin_round(2, agents, positions=positions) + + assert len(bus.get_inbox("B")) == 1 + # C has no position — fail open, receives message + assert len(bus.get_inbox("C")) == 1 + + def test_no_positions_dict_at_all(self): + """begin_round without positions arg — all broadcasts go through.""" + bus = _bus(comm_radius_m=1000) + agents = ["A", "B", "C"] + bus.begin_round(1, agents) # no positions + bus.queue_outbox("A", _broadcast("test")) + bus.begin_round(2, agents) + + assert len(bus.get_inbox("B")) == 1 + assert len(bus.get_inbox("C")) == 1 + + +# --------------------------------------------------------------------------- +# Existing caps still enforced alongside spatial filter +# --------------------------------------------------------------------------- + +class TestCapsWithSpatialFilter: + def test_per_agent_send_cap_enforced(self): + bus = _bus(comm_radius_m=5000, max_sends_per_agent_per_round=2) + agents = ["A", "B"] + positions = {"A": (0.0, 0.0), "B": (100.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", [ + OutboxMessage(to="*", message="msg1"), + OutboxMessage(to="*", message="msg2"), + OutboxMessage(to="*", message="msg3"), # should be dropped (cap=2) + ]) + bus.begin_round(2, agents, positions=positions) + + inbox = bus.get_inbox("B") + assert len(inbox) == 2 + + def test_global_broadcast_cap_enforced(self): + bus = _bus(comm_radius_m=5000, max_broadcasts_per_round=1, + max_sends_per_agent_per_round=5) + agents = ["A", "B", "C"] + positions = {"A": (0.0, 0.0), "B": (100.0, 0.0), "C": (200.0, 0.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", [ + OutboxMessage(to="*", message="first"), + OutboxMessage(to="*", message="second"), # dropped: global cap=1 + ]) + bus.begin_round(2, agents, positions=positions) + + assert len(bus.get_inbox("B")) == 1 + assert bus.get_inbox("B")[0]["message"] == "first" + + +# --------------------------------------------------------------------------- +# Spawn-edge co-location (same position) +# --------------------------------------------------------------------------- + +class TestSamePosition: + def test_agents_at_same_position_communicate(self): + """Agents on the same spawn edge have the same midpoint — distance 0.""" + bus = _bus(comm_radius_m=100) + agents = ["A", "B", "C"] + positions = {"A": (500.0, 200.0), "B": (500.0, 200.0), "C": (5000.0, 5000.0)} + bus.begin_round(1, agents, positions=positions) + bus.queue_outbox("A", _broadcast("local")) + bus.begin_round(2, agents, positions=positions) + + assert len(bus.get_inbox("B")) == 1 + assert len(bus.get_inbox("C")) == 0 diff --git a/tests/test_routing_utility.py b/tests/test_routing_utility.py index 2134a2b..b76166f 100644 --- a/tests/test_routing_utility.py +++ b/tests/test_routing_utility.py @@ -36,14 +36,18 @@ def _menu_item( min_margin_m=None, travel_time_s=300.0, reachable=True, + len_edges=None, ): - return { + d = { "risk_sum": risk_sum, "blocked_edges": blocked_edges, "min_margin_m": min_margin_m, "travel_time_s_fastest_path": travel_time_s, "reachable": reachable, } + if len_edges is not None: + d["len_edges"] = len_edges + return d class TestScoreDestinationUtility: @@ -478,3 +482,78 @@ def test_proximity_and_visual_penalties_stack(self): ) # 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) + + +# --------------------------------------------------------------------------- +# Edge-count invariance (risk_density normalisation) +# --------------------------------------------------------------------------- + +class TestEdgeCountInvariance: + """Verify that _expected_exposure normalises risk_sum by len_edges, + so two representations of the same physical route (few large edges vs + many small edges) receive the same exposure score.""" + + def test_same_risk_density_same_exposure(self): + """risk_sum=2/len_edges=10 == risk_sum=20/len_edges=100.""" + belief = _neutral_belief() + psych = _psychology() + profile = _profile() + few_edges = score_destination_utility( + _menu_item(risk_sum=2.0, len_edges=10), belief, psych, profile, + ) + many_edges = score_destination_utility( + _menu_item(risk_sum=20.0, len_edges=100), belief, psych, profile, + ) + assert few_edges == pytest.approx(many_edges, rel=1e-9) + + def test_higher_density_lower_score(self): + """Same edge count but higher risk_sum → worse score.""" + belief = _danger_belief() + psych = _psychology() + profile = _profile() + low_density = score_destination_utility( + _menu_item(risk_sum=1.0, len_edges=10), belief, psych, profile, + ) + high_density = score_destination_utility( + _menu_item(risk_sum=9.0, len_edges=10), belief, psych, profile, + ) + assert high_density < low_density + + def test_more_edges_same_risk_sum_improves_score(self): + """More edges dilute the same risk_sum → lower density → better score.""" + belief = _neutral_belief() + psych = _psychology() + profile = _profile() + concentrated = score_destination_utility( + _menu_item(risk_sum=5.0, len_edges=5), belief, psych, profile, + ) + diluted = score_destination_utility( + _menu_item(risk_sum=5.0, len_edges=50), belief, psych, profile, + ) + assert diluted > concentrated + + def test_missing_len_edges_defaults_to_one(self): + """Without len_edges, risk_density = risk_sum / 1 = risk_sum (backward compat).""" + belief = _neutral_belief() + psych = _psychology() + profile = _profile() + without_key = score_destination_utility( + _menu_item(risk_sum=3.0), belief, psych, profile, + ) + with_one = score_destination_utility( + _menu_item(risk_sum=3.0, len_edges=1), belief, psych, profile, + ) + assert without_key == pytest.approx(with_one, rel=1e-9) + + def test_route_mode_also_normalises(self): + """score_route_utility uses the same _expected_exposure normalisation.""" + belief = _neutral_belief() + psych = _psychology() + profile = _profile() + few = score_route_utility( + _menu_item(risk_sum=2.0, len_edges=10), belief, psych, profile, + ) + many = score_route_utility( + _menu_item(risk_sum=20.0, len_edges=100), belief, psych, profile, + ) + assert few == pytest.approx(many, rel=1e-9) diff --git a/tests/test_scenarios.py b/tests/test_scenarios.py index c7d63e4..4a203c5 100644 --- a/tests/test_scenarios.py +++ b/tests/test_scenarios.py @@ -5,6 +5,7 @@ from agentevac.agents.scenarios import ( SCENARIO_CHOICES, apply_scenario_to_signals, + filter_history_for_scenario, filter_menu_for_scenario, load_scenario_config, scenario_prompt_suffix, @@ -188,6 +189,133 @@ def test_returns_list_same_length(self): assert len(result) == 3 +class TestFilterHistoryForScenario: + def _sample_record(self): + return { + "decision_round": 5, + "current_edge": "e1", + "current_edge_margin_m": 1500.0, + "route_head_min_margin_m": 800.0, + "trend_vs_last_round": "stable", + "signals": { + "environment": { + "observed_state": "risky", + "is_delayed": False, + "base_margin_m": 800.0, + "observed_margin_m": 750.0, + "sigma_info": 40.0, + "source_metric": "route_head_min_margin_m", + }, + "social": {"observed_state": "danger", "sample_count": 2}, + }, + "forecast": { + "summary": {"fire_count": 3}, + "current_edge": {"margin_m": 1500}, + "route_head": {"available": True, "head_edges_evaluated": 4}, + "briefing": "Fire spreading east", + }, + "selected_option": { + "name": "shelter_1", + "dest_edge": "e_shelter", + "advisory": "Recommended", + "briefing": "Take this route", + "blocked_edges": 0, + "risk_sum": 1.2, + "min_margin_m": 800.0, + "travel_time_s": 300.0, + "expected_utility": -0.5, + }, + } + + # --- no_notice --- + + def test_no_notice_strips_forecast(self): + result = filter_history_for_scenario("no_notice", [self._sample_record()]) + assert result[0]["forecast"] == {"available": False} + + def test_no_notice_strips_advisory_and_briefing_from_selected_option(self): + result = filter_history_for_scenario("no_notice", [self._sample_record()]) + sel = result[0]["selected_option"] + assert "advisory" not in sel + assert "briefing" not in sel + + def test_no_notice_strips_fire_metrics_from_selected_option(self): + result = filter_history_for_scenario("no_notice", [self._sample_record()]) + sel = result[0]["selected_option"] + assert "blocked_edges" not in sel + assert "risk_sum" not in sel + assert "min_margin_m" not in sel + + def test_no_notice_keeps_local_knowledge_in_selected_option(self): + result = filter_history_for_scenario("no_notice", [self._sample_record()]) + sel = result[0]["selected_option"] + assert sel["name"] == "shelter_1" + assert sel["dest_edge"] == "e_shelter" + assert sel["expected_utility"] == -0.5 + assert sel["travel_time_s"] == 300.0 + + def test_no_notice_strips_raw_margin_from_env_signal(self): + result = filter_history_for_scenario("no_notice", [self._sample_record()]) + env = result[0]["signals"]["environment"] + assert env["observed_state"] == "risky" + assert "base_margin_m" not in env + assert "observed_margin_m" not in env + assert "sigma_info" not in env + + def test_no_notice_preserves_non_leaked_fields(self): + result = filter_history_for_scenario("no_notice", [self._sample_record()]) + assert result[0]["current_edge"] == "e1" + assert result[0]["current_edge_margin_m"] == 1500.0 + assert result[0]["decision_round"] == 5 + + # --- alert_guided --- + + def test_alert_guided_keeps_forecast_summary_but_strips_route_head(self): + result = filter_history_for_scenario("alert_guided", [self._sample_record()]) + fc = result[0]["forecast"] + assert "summary" in fc + assert "route_head" not in fc + + def test_alert_guided_strips_advisory_from_selected_option(self): + result = filter_history_for_scenario("alert_guided", [self._sample_record()]) + sel = result[0]["selected_option"] + assert "advisory" not in sel + assert "briefing" not in sel + # But fire metrics are kept in alert_guided + assert "blocked_edges" in sel + assert "risk_sum" in sel + + # --- advice_guided --- + + def test_advice_guided_returns_unmodified(self): + history = [self._sample_record()] + result = filter_history_for_scenario("advice_guided", history) + assert result is history # identity — no copy needed + + # --- general --- + + def test_original_record_not_mutated(self): + original = self._sample_record() + filter_history_for_scenario("no_notice", [original]) + assert "advisory" in original["selected_option"] + assert "summary" in original["forecast"] + + def test_empty_history(self): + assert filter_history_for_scenario("no_notice", []) == [] + + def test_record_without_selected_option(self): + rec = self._sample_record() + del rec["selected_option"] + result = filter_history_for_scenario("no_notice", [rec]) + assert "selected_option" not in result[0] + + def test_record_without_signals(self): + rec = self._sample_record() + del rec["signals"] + result = filter_history_for_scenario("no_notice", [rec]) + assert result[0]["forecast"] == {"available": False} + + class TestScenarioPromptSuffix: def test_no_notice_suffix_non_empty(self): s = scenario_prompt_suffix("no_notice")