Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3623b4c
refactor: Change scenario prompts in agents/scenarios.py
legend5teve Mar 5, 2026
df2f056
chore: update the suggested routes and destination for Lytton
legend5teve Mar 6, 2026
477b0de
fix: update replay.py to fix key error
legend5teve Mar 6, 2026
0f4ac33
feat: optimize the visualization module for plotting statistic result…
legend5teve Mar 9, 2026
747d1ae
Merge branch 'main' into feat/visualization-module-plots
legend5teve Mar 9, 2026
4f3172f
feat: implement timeline analysis for evacuation in scripts/plot_agen…
legend5teve Mar 11, 2026
aaca505
Merge branch 'main' into feat/visualization-module-plots
legend5teve Mar 11, 2026
1c7ff71
chore: add test cases to cover newly added features; update doc strin…
legend5teve Mar 11, 2026
44bbd68
chore: update plotting scales according to actual KPI scales
legend5teve Mar 11, 2026
a1f935f
feat: log run parameters for plotting modules
legend5teve Mar 11, 2026
077be18
Merge branch 'main' into feat/visualization-module-plots
legend5teve Mar 11, 2026
bc3874e
feat: implement signal conflict modeling, distance-based noise scalin…
legend5teve Mar 16, 2026
d73e226
Merge branch 'main' into feat/visualization-module-plots
legend5teve Mar 16, 2026
ef0602e
feat: extend utility scoring to all scenarios and fix hazard exposure…
legend5teve Mar 16, 2026
73ab269
feat: replace vehicle-count loop with time-based sim end and tune fir…
legend5teve Mar 17, 2026
b357efe
feat: add LLM input-hash caching and parallel predeparture LLM dispatch
legend5teve Mar 18, 2026
edd71e9
feat: add early termination when all agents have evacuated
legend5teve Mar 19, 2026
e0c0695
feat: add prioritized prompt framework and fix arrival-based termination
legend5teve Mar 20, 2026
36014f4
Merge remote-tracking branch 'origin/main' into feat/visualization-mo…
legend5teve Mar 20, 2026
e01c017
feat: add edge-trace replay, departure destination choice, and SUMO n…
legend5teve Mar 22, 2026
7892b89
Merge remote-tracking branch 'origin/main' into feat/visualization-mo…
legend5teve Mar 22, 2026
92db674
fix: record departure destination in metrics and round-robin vehicle …
legend5teve Mar 22, 2026
62eea81
fix: add --sumo-seed CLI arg and make RQ scripts POSIX-compatible
legend5teve Mar 22, 2026
ad162c7
feat: export per-agent profile parameters to JSON at simulation end
legend5teve Mar 23, 2026
01145a9
feat: scale fire proximity thresholds to match wildfire simulation di…
legend5teve Mar 24, 2026
5fbf2a9
feat: add visual fire observation penalty for no_notice agents
legend5teve Mar 24, 2026
f3858cd
fix: use travel time instead of edge count for no_notice exposure sca…
legend5teve Mar 24, 2026
814ebb9
feat: add anti-hallucination factual grounding guard to all LLM polic…
legend5teve Mar 24, 2026
a65814b
feat: add proximity-based fire perception for no_notice agents
legend5teve Mar 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions agentevac/agents/belief_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,11 +68,11 @@ def categorize_hazard_state(signal: Dict[str, Any]) -> Dict[str, float]:
If neither is present (e.g., no fire active), returns a uniform prior.

Threshold rationale:
- ≤ 0 m : fire has reached / overtaken the edge → near-certain danger.
- ≤ 100 m : fire front is on the street block → high danger.
- ≤ 300 m : fire within roughly two city blocks → risky (watch closely).
- ≤ 700 m : fire is a few blocks away → elevated but manageable.
- > 700 m : fire is well clear of the route → predominantly safe.
- ≤ 0 m : fire has reached / overtaken the edge → near-certain danger.
- ≤ 1200 m : within ember-attack range → high danger.
- ≤ 2500 m : smoke / radiant-heat proximity → risky (watch closely).
- ≤ 5000 m : fire visible but with buffer → elevated but manageable.
- > 5000 m : fire is well clear of the route → predominantly safe.

Args:
signal: Environment signal dict, typically from ``information_model.sample_environment_signal``.
Expand All @@ -89,11 +89,11 @@ def categorize_hazard_state(signal: Dict[str, Any]) -> Dict[str, float]:
margin_f = float(margin)
if margin_f <= 0.0:
return {"p_safe": 0.02, "p_risky": 0.08, "p_danger": 0.90}
if margin_f <= 100.0:
if margin_f <= 1200.0:
return {"p_safe": 0.05, "p_risky": 0.20, "p_danger": 0.75}
if margin_f <= 300.0:
if margin_f <= 2500.0:
return {"p_safe": 0.15, "p_risky": 0.55, "p_danger": 0.30}
if margin_f <= 700.0:
if margin_f <= 5000.0:
return {"p_safe": 0.35, "p_risky": 0.50, "p_danger": 0.15}
return {"p_safe": 0.75, "p_risky": 0.20, "p_danger": 0.05}

Expand Down
6 changes: 3 additions & 3 deletions agentevac/agents/information_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,13 @@ def _state_from_margin(margin_m: Optional[float]) -> str:
margin_m: Distance in metres from the nearest fire edge; ``None`` if unknown.

Returns:
One of "danger" (≤ 100 m), "risky" (≤ 300 m), "safe" (> 300 m), or "unknown".
One of "danger" (≤ 1200 m), "risky" (≤ 2500 m), "safe" (> 2500 m), or "unknown".
"""
if margin_m is None:
return "unknown"
if margin_m <= 100.0:
if margin_m <= 1200.0:
return "danger"
if margin_m <= 300.0:
if margin_m <= 2500.0:
return "risky"
return "safe"

Expand Down
76 changes: 56 additions & 20 deletions agentevac/agents/routing_utility.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ def _effective_margin_penalty(min_margin_m: Any) -> float:
model uncertainty: even routes with no fire currently nearby may become risky.

Thresholds:
- ∞ (no fire detected) → 0.25 (nominal baseline)
- ≤ 0 m (inside fire) → 5.0 (highest risk)
- ≤ 100 m (very close) → 3.0
- ≤ 300 m (near) → 1.5
- ≤ 700 m (buffered) → 0.6
- > 700 m (clear) → 0.15 (lowest non-zero risk)
- ∞ (no fire detected) → 0.25 (nominal baseline)
- ≤ 0 m (inside fire) → 5.0 (highest risk)
- ≤ 1200 m (very close) → 3.0
- ≤ 2500 m (near) → 1.5
- ≤ 5000 m (buffered) → 0.6
- > 5000 m (clear) → 0.15 (lowest non-zero risk)

Args:
min_margin_m: Minimum fire margin in metres along the route; may be ``None``
Expand All @@ -82,11 +82,11 @@ def _effective_margin_penalty(min_margin_m: Any) -> float:
return 0.25
if margin <= 0.0:
return 5.0
if margin <= 100.0:
if margin <= 1200.0:
return 3.0
if margin <= 300.0:
if margin <= 2500.0:
return 1.5
if margin <= 700.0:
if margin <= 5000.0:
return 0.6
return 0.15

Expand Down Expand Up @@ -123,16 +123,26 @@ def _observation_based_exposure(
Used in the ``no_notice`` scenario where agents have only their own noisy
observation of the current edge. Without per-route fire metrics (risk_sum,
blocked_edges, min_margin_m), exposure is derived from the agent's general
belief state scaled by route length:
belief state scaled by estimated travel duration:

hazard_level = 0.3 * p_risky + 0.7 * p_danger + 0.4 * perceived_risk
length_factor = len_edges * 0.15
length_factor = travel_time_minutes * 0.3 (or len_edges * 0.15 fallback)
exposure = hazard_level * length_factor + uncertainty_penalty

Longer routes are penalised more because a longer route means more time
spent driving through a potentially hazardous environment. The coefficients
prioritise ``p_danger`` (0.7) over ``p_risky`` (0.3) to maintain consistency
with the severity weighting in ``_expected_exposure``.
spent driving through a potentially hazardous environment. Travel time
(from SUMO ``findRoute``) is preferred over edge count because edge
lengths vary widely; a 2 km highway segment should count more than a
50 m residential street. The coefficients prioritise ``p_danger`` (0.7)
over ``p_risky`` (0.3) to maintain consistency with the severity
weighting in ``_expected_exposure``.

When ``visual_blocked_edges`` or ``visual_min_margin_m`` keys are present on
the menu item, a **visual fire observation penalty** is added. This models a
no-notice agent who can see fire on the first few edges ahead of their current
position. The penalty uses the same weights as ``_expected_exposure``
(``blocked_edges * 8.0`` + margin lookup) so that a visually blocked route
gets a large score increase, prompting the agent to switch shelters.

Args:
menu_item: A destination or route dict.
Expand All @@ -148,14 +158,40 @@ def _observation_based_exposure(
confidence = _num(psychology.get("confidence"), 0.0)

hazard_level = 0.3 * p_risky + 0.7 * p_danger + 0.4 * perceived_risk
len_edges = _num(
menu_item.get("len_edges", menu_item.get("len_edges_fastest_path")),
1.0,
)
length_factor = len_edges * 0.15
travel_time_s = menu_item.get("travel_time_s_fastest_path")
if travel_time_s is not None:
length_factor = _num(travel_time_s, 60.0) / 60.0 * 0.3
else:
len_edges = _num(
menu_item.get("len_edges", menu_item.get("len_edges_fastest_path")),
1.0,
)
length_factor = len_edges * 0.15
uncertainty_penalty = max(0.0, 1.0 - confidence) * 0.75

return hazard_level * length_factor + uncertainty_penalty
# Visual fire observation penalty: present only for the agent's current
# destination when fire is detected on the first few route-head edges.
visual_penalty = 0.0
if "visual_blocked_edges" in menu_item:
visual_blocked = _num(menu_item.get("visual_blocked_edges"), 0.0)
visual_min_margin = menu_item.get("visual_min_margin_m")
visual_penalty = visual_blocked * 8.0
if visual_min_margin is not None:
visual_penalty += _effective_margin_penalty(visual_min_margin)

# Proximity fire perception penalty: present on ALL reachable destinations
# when the agent is within perception range of a fire. Uses the same
# weights as _expected_exposure (blocked * 8.0 + margin_penalty) so that
# routes passing near visible fires are strongly deprioritised.
proximity_penalty = 0.0
if "proximity_blocked_edges" in menu_item:
prox_blocked = _num(menu_item.get("proximity_blocked_edges"), 0.0)
prox_min_margin = menu_item.get("proximity_min_margin_m")
proximity_penalty = prox_blocked * 8.0
if prox_min_margin is not None:
proximity_penalty += _effective_margin_penalty(prox_min_margin)

return hazard_level * length_factor + uncertainty_penalty + visual_penalty + proximity_penalty


def _expected_exposure(
Expand Down
17 changes: 12 additions & 5 deletions agentevac/agents/scenarios.py
Original file line number Diff line number Diff line change
Expand Up @@ -194,6 +194,8 @@ def filter_menu_for_scenario(

for item in menu:
out = dict(item)
# Strip internal fields that should never reach the LLM prompt.
out.pop("_fastest_path_edges", None)
if not cfg["official_route_guidance_visible"]:
# Remove advisory labels and authority source produced by the operator briefing logic.
out.pop("advisory", None)
Expand All @@ -210,6 +212,12 @@ def filter_menu_for_scenario(
"idx", "name", "dest_edge", "reachable", "note",
"travel_time_s_fastest_path", "len_edges_fastest_path",
"expected_utility", "utility_components",
# Visual fire observation fields (agent can see fire on
# the first few edges of their current route).
"visual_blocked_edges", "visual_min_margin_m",
# Proximity fire perception fields (agent is close enough
# to a fire to assess its impact on each candidate route).
"proximity_blocked_edges", "proximity_min_margin_m",
}
else:
keep_keys = {
Expand Down Expand Up @@ -238,11 +246,10 @@ def scenario_prompt_suffix(mode: str) -> str:
cfg = load_scenario_config(mode)
if cfg["mode"] == "no_notice":
return (
"This is a no-notice wildfire scenario: do not assume official route instructions exist. "
"Rely mainly on your_observation, inbox messages, and your own caution. "
"Do NOT invent official instructions. Base decisions on environmental cues (smoke/flames/visibility), "
"your current hazard or forecast inputs if provided, and peer-to-peer messages. Seek credible info when available "
", and choose conservative actions if uncertain."
"This is a no-notice wildfire scenario: no official warnings or route instructions exist yet. "
"Do NOT invent official instructions. "
"Base decisions on your_observation.environment_signal, inbox messages, "
"and neighborhood_observation. Choose conservative actions if uncertain."
)
if cfg["mode"] == "alert_guided":
return (
Expand Down
8 changes: 8 additions & 0 deletions agentevac/analysis/experiments.py
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ def run_experiment_case(
sumo_binary: str = "sumo",
run_mode: str = "record",
timeout_s: Optional[float] = None,
sumo_seed: Optional[int] = None,
) -> Dict[str, Any]:
"""Execute one parameter-grid case by spawning a simulation subprocess.

Expand Down Expand Up @@ -228,6 +229,8 @@ def run_experiment_case(
"DEFAULT_THETA_TRUST": str(float(case_cfg["theta_trust"])),
"SUMO_BINARY": str(sumo_binary),
})
if sumo_seed is not None:
env["SUMO_SEED"] = str(int(sumo_seed))
if "DEFAULT_LAMBDA_E" in case_cfg:
env["DEFAULT_LAMBDA_E"] = str(float(case_cfg["DEFAULT_LAMBDA_E"]))
if "DEFAULT_LAMBDA_T" in case_cfg:
Expand Down Expand Up @@ -285,6 +288,7 @@ def run_parameter_sweep(
sumo_binary: str = "sumo",
run_mode: str = "record",
timeout_s: Optional[float] = None,
sumo_seed: Optional[int] = None,
) -> List[Dict[str, Any]]:
"""Run all cases in the experiment grid sequentially.

Expand Down Expand Up @@ -317,6 +321,7 @@ def run_parameter_sweep(
sumo_binary=sumo_binary,
run_mode=run_mode,
timeout_s=timeout_s,
sumo_seed=sumo_seed,
)
)
return results
Expand Down Expand Up @@ -405,6 +410,8 @@ def _parse_args() -> argparse.Namespace:
parser.add_argument("--trust-values", default="0.5")
parser.add_argument("--scenario-values", default="advice_guided")
parser.add_argument("--messaging", choices=["on", "off"], default="on")
parser.add_argument("--sumo-seed", type=int, default=None,
help="SUMO random seed (integer). Overrides SUMO_SEED env var.")
return parser.parse_args()


Expand All @@ -427,6 +434,7 @@ def main() -> int:
sumo_binary=args.sumo_binary,
run_mode=args.run_mode,
timeout_s=args.timeout_s,
sumo_seed=args.sumo_seed,
)
exported = export_experiment_results(results, output_dir=args.output_dir)
print(f"[EXPERIMENTS] cases={len(results)}")
Expand Down
38 changes: 37 additions & 1 deletion agentevac/analysis/metrics.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,8 @@ def __init__(self, enabled: bool, base_path: str, run_mode: str):
self._conflict_by_agent_sum: Dict[str, float] = {}
self._conflict_by_agent_count: Dict[str, int] = {}

self._agent_profiles: Dict[str, Dict[str, float]] = {}

@staticmethod
def _timestamped_path(base_path: str) -> str:
"""Generate a unique timestamped output path by appending ``YYYYMMDD_HHMMSS``.
Expand Down Expand Up @@ -212,6 +214,39 @@ def record_decision_snapshot(
if state.get("control_mode") == "destination":
self._final_destination_by_agent[agent_id] = str(choice_name)

def record_agent_profile(self, agent_id: str, profile: Dict[str, float]) -> None:
"""Record the sampled profile parameters for an agent.

Only the first call per agent_id is stored (profiles are immutable).

Args:
agent_id: Vehicle ID.
profile: Dict of psychological parameter values (theta_trust, theta_r, etc.).
"""
if not self.enabled:
return
if agent_id not in self._agent_profiles:
self._agent_profiles[agent_id] = dict(profile)

def export_agent_profiles(self) -> Optional[str]:
"""Write per-agent profiles to a JSON file alongside the metrics file.

Returns:
The path of the written file, or ``None`` if metrics are disabled or no path is set.
"""
if not self.enabled or not self.path:
return None
if not self._agent_profiles:
return None
base = Path(self.path)
profiles_path = base.with_name(base.stem.replace("run_metrics", "agent_profiles", 1) + base.suffix)
if str(profiles_path) == self.path:
profiles_path = base.with_name(base.stem + "_profiles" + base.suffix)
with open(profiles_path, "w", encoding="utf-8") as fh:
json.dump(self._agent_profiles, fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
return str(profiles_path)

def record_exposure_sample(
self,
agent_id: str,
Expand Down Expand Up @@ -439,11 +474,12 @@ def export_run_metrics(self, path: Optional[str] = None) -> Optional[str]:
return target

def close(self) -> Optional[str]:
"""Flush and export metrics; typically called at simulation end.
"""Flush and export metrics and agent profiles; typically called at simulation end.

Returns:
The path of the written metrics file, or ``None``.
"""
if not self.enabled:
return None
self.export_agent_profiles()
return self.export_run_metrics()
Loading
Loading