Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
67 changes: 67 additions & 0 deletions agentevac/simulation/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@
summarize_neighborhood_observation,
compute_social_departure_pressure,
)
from agentevac.utils.run_parameters import write_run_parameter_log
from agentevac.utils.replay import RouteReplay

# ---- OpenAI (LLM control) ----
Expand Down Expand Up @@ -249,6 +250,10 @@ def _parse_cli_args() -> argparse.Namespace:
"--metrics-log-path",
help="Override METRICS_LOG_PATH env var (timestamp is appended).",
)
parser.add_argument(
"--params-log-path",
help="Override PARAMS_LOG_PATH env var (companion run suffix is preserved).",
)
parser.add_argument("--overlay-max-label-chars", type=int, help="Max overlay label characters.")
parser.add_argument("--overlay-poi-layer", type=int, help="POI layer for overlays.")
parser.add_argument("--overlay-poi-offset-m", type=float, help="POI offset in meters.")
Expand Down Expand Up @@ -321,6 +326,7 @@ def _float_from_env_or_cli(cli_value: Optional[float], env_key: str, default: fl
if CLI_ARGS.metrics is not None:
METRICS_ENABLED = (CLI_ARGS.metrics == "on")
METRICS_LOG_PATH = CLI_ARGS.metrics_log_path or os.getenv("METRICS_LOG_PATH", "outputs/run_metrics.json")
PARAMS_LOG_PATH = CLI_ARGS.params_log_path or os.getenv("PARAMS_LOG_PATH", "outputs/run_params.json")
WEB_DASHBOARD_ENABLED = _parse_bool(os.getenv("WEB_DASHBOARD_ENABLED", "0"), False)
if CLI_ARGS.web_dashboard is not None:
WEB_DASHBOARD_ENABLED = (CLI_ARGS.web_dashboard == "on")
Expand Down Expand Up @@ -1311,6 +1317,61 @@ def cleanup(self, active_vehicle_ids: List[str]):
}


def _run_parameter_payload() -> Dict[str, Any]:
"""Build the persisted run-parameter snapshot used by post-run plotting tools."""
return {
"run_mode": RUN_MODE,
"scenario": SCENARIO_MODE,
"sumo_binary": SUMO_BINARY,
"messaging_controls": {
"enabled": MESSAGING_ENABLED,
"max_message_chars": MAX_MESSAGE_CHARS,
"max_inbox_messages": MAX_INBOX_MESSAGES,
"max_sends_per_agent_per_round": MAX_SENDS_PER_AGENT_PER_ROUND,
"max_broadcasts_per_round": MAX_BROADCASTS_PER_ROUND,
"ttl_rounds": TTL_ROUNDS,
},
"driver_briefing_thresholds": {
"margin_very_close_m": MARGIN_VERY_CLOSE_M,
"margin_near_m": MARGIN_NEAR_M,
"margin_buffered_m": MARGIN_BUFFERED_M,
"risk_density_low": RISK_DENSITY_LOW,
"risk_density_medium": RISK_DENSITY_MEDIUM,
"risk_density_high": RISK_DENSITY_HIGH,
"delay_fast_ratio": DELAY_FAST_RATIO,
"delay_moderate_ratio": DELAY_MODERATE_RATIO,
"delay_heavy_ratio": DELAY_HEAVY_RATIO,
"caution_min_margin_m": CAUTION_MIN_MARGIN_M,
"recommended_min_margin_m": RECOMMENDED_MIN_MARGIN_M,
},
"cognition": {
"info_sigma": INFO_SIGMA,
"info_delay_s": INFO_DELAY_S,
"social_signal_max_messages": SOCIAL_SIGNAL_MAX_MESSAGES,
"theta_trust": DEFAULT_THETA_TRUST,
"belief_inertia": BELIEF_INERTIA,
},
"departure": {
"theta_r": DEFAULT_THETA_R,
"theta_u": DEFAULT_THETA_U,
"gamma": DEFAULT_GAMMA,
},
"utility": {
"lambda_e": DEFAULT_LAMBDA_E,
"lambda_t": DEFAULT_LAMBDA_T,
},
"neighbor_observation": {
"scope": NEIGHBOR_SCOPE,
"window_s": DEFAULT_NEIGHBOR_WINDOW_S,
"social_recent_weight": DEFAULT_SOCIAL_RECENT_WEIGHT,
"social_total_weight": DEFAULT_SOCIAL_TOTAL_WEIGHT,
"social_trigger": DEFAULT_SOCIAL_TRIGGER,
"social_min_danger": DEFAULT_SOCIAL_MIN_DANGER,
"max_system_observations": MAX_SYSTEM_OBSERVATIONS,
},
}


# =========================
# Step 4: Define SUMO configuration
# =========================
Expand All @@ -1330,6 +1391,11 @@ def cleanup(self, active_vehicle_ids: List[str]):
replay = RouteReplay(RUN_MODE, REPLAY_LOG_PATH)
events = LiveEventStream(EVENTS_ENABLED, EVENTS_LOG_PATH, EVENTS_STDOUT)
metrics = RunMetricsCollector(METRICS_ENABLED, METRICS_LOG_PATH, RUN_MODE)
params_log_path = write_run_parameter_log(
PARAMS_LOG_PATH,
_run_parameter_payload(),
reference_path=metrics.path or events.path or replay.path,
)
dashboard = WebDashboard(
enabled=WEB_DASHBOARD_ENABLED,
host=WEB_DASHBOARD_HOST,
Expand Down Expand Up @@ -1357,6 +1423,7 @@ def cleanup(self, active_vehicle_ids: List[str]):
print(f"[EVENTS] enabled={EVENTS_ENABLED} path={events.path} stdout={EVENTS_STDOUT}")
if metrics.path:
print(f"[METRICS] enabled={METRICS_ENABLED} path={metrics.path}")
print(f"[RUN_PARAMS] path={params_log_path}")
print(
f"[WEB_DASHBOARD] enabled={dashboard.enabled} host={WEB_DASHBOARD_HOST} "
f"port={WEB_DASHBOARD_PORT} max_events={WEB_DASHBOARD_MAX_EVENTS}"
Expand Down
79 changes: 79 additions & 0 deletions agentevac/utils/run_parameters.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
"""Helpers for recording and locating per-run parameter snapshots."""

from __future__ import annotations

import json
import time
from pathlib import Path
from typing import Any, Mapping, Optional

_REFERENCE_PREFIXES = (
"run_params_",
"run_metrics_",
"metrics_",
"events_",
"llm_routes_",
"routes_",
)


def reference_suffix(reference_path: str | Path) -> str:
"""Return the variable suffix portion of a run artifact filename.

Examples:
``run_metrics_20260311_012202.json`` -> ``20260311_012202``
``metrics_sigma-40_20260311_012202.json`` -> ``sigma-40_20260311_012202``
"""
stem = Path(reference_path).stem
for prefix in _REFERENCE_PREFIXES:
if stem.startswith(prefix):
suffix = stem[len(prefix):]
if suffix:
return suffix
return stem


def build_parameter_log_path(base_path: str, *, reference_path: Optional[str | Path] = None) -> str:
"""Build a parameter-log path, preserving a companion artifact suffix when possible."""
base = Path(base_path)
ext = base.suffix or ".json"
stem = base.stem if base.suffix else base.name

if reference_path:
suffix = reference_suffix(reference_path)
candidate = base.with_name(f"{stem}_{suffix}{ext}")
idx = 1
while candidate.exists():
candidate = base.with_name(f"{stem}_{suffix}_{idx:02d}{ext}")
idx += 1
return str(candidate)

ts = time.strftime("%Y%m%d_%H%M%S")
candidate = base.with_name(f"{stem}_{ts}{ext}")
idx = 1
while candidate.exists():
candidate = base.with_name(f"{stem}_{ts}_{idx:02d}{ext}")
idx += 1
return str(candidate)


def write_run_parameter_log(
base_path: str,
payload: Mapping[str, Any],
*,
reference_path: Optional[str | Path] = None,
) -> str:
"""Write one JSON parameter snapshot to disk and return its path."""
target = Path(build_parameter_log_path(base_path, reference_path=reference_path))
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("w", encoding="utf-8") as fh:
json.dump(dict(payload), fh, ensure_ascii=False, indent=2, sort_keys=True)
fh.write("\n")
return str(target)


def companion_parameter_path(reference_path: str | Path, *, base_name: str = "run_params") -> Path:
"""Derive the expected companion parameter-log path for a run artifact."""
ref = Path(reference_path)
suffix = reference_suffix(ref)
return ref.with_name(f"{base_name}_{suffix}.json")
15 changes: 15 additions & 0 deletions scripts/_plot_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
from pathlib import Path
from typing import Any, Iterable, List

from agentevac.utils.run_parameters import companion_parameter_path


def newest_file(pattern: str) -> Path:
"""Return the newest file matching ``pattern``.
Expand All @@ -30,6 +32,19 @@ def resolve_input(path_arg: str | None, pattern: str) -> Path:
return newest_file(pattern)


def resolve_optional_run_params(path_arg: str | None, reference_path: Path | None) -> Path | None:
"""Resolve an explicit or companion run-parameter log path if available."""
if path_arg:
path = Path(path_arg)
if not path.exists():
raise FileNotFoundError(f"Input file does not exist: {path}")
return path
if reference_path is None:
return None
candidate = companion_parameter_path(reference_path)
return candidate if candidate.exists() else None


def load_json(path: Path) -> Any:
"""Load a JSON document from ``path``."""
with path.open("r", encoding="utf-8") as fh:
Expand Down
55 changes: 52 additions & 3 deletions scripts/plot_agent_communication.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,25 @@
from typing import Any

try:
from scripts._plot_common import ensure_output_path, load_jsonl, require_matplotlib, resolve_input, top_items
from scripts._plot_common import (
ensure_output_path,
load_json,
load_jsonl,
require_matplotlib,
resolve_input,
resolve_optional_run_params,
top_items,
)
except ModuleNotFoundError:
from _plot_common import ensure_output_path, load_jsonl, require_matplotlib, resolve_input, top_items
from _plot_common import (
ensure_output_path,
load_json,
load_jsonl,
require_matplotlib,
resolve_input,
resolve_optional_run_params,
top_items,
)


def _parse_args() -> argparse.Namespace:
Expand All @@ -26,6 +42,10 @@ def _parse_args() -> argparse.Namespace:
"--dialogs",
help="Path to a *.dialogs.csv file. Defaults to the newest outputs/*.dialogs.csv.",
)
parser.add_argument(
"--params",
help="Optional companion run_params JSON path. Defaults to the matching run_params_<id>.json when present.",
)
parser.add_argument(
"--out",
help="Output PNG path. Defaults to <events>.communication.png.",
Expand Down Expand Up @@ -156,17 +176,37 @@ def _plot_dialog_modes(ax, dialog_rows: list[dict[str, str]]) -> None:
ax2.set_ylabel("Average Response Length (chars)")


def _messaging_summary(params: dict | None) -> str | None:
"""Format messaging anti-bloat controls for the dashboard footer."""
if not params:
return None
messaging = params.get("messaging_controls") or {}
if not messaging:
return None
return (
"Messaging controls: "
f"enabled={messaging.get('enabled', '?')} "
f"max_chars={messaging.get('max_message_chars', '?')} "
f"max_inbox={messaging.get('max_inbox_messages', '?')} "
f"max_sends={messaging.get('max_sends_per_agent_per_round', '?')} "
f"max_broadcasts={messaging.get('max_broadcasts_per_round', '?')} "
f"ttl_rounds={messaging.get('ttl_rounds', '?')}"
)


def plot_agent_communication(
*,
events_path: Path,
dialogs_path: Path,
out_path: Path,
show: bool,
top_n: int,
params_path: Path | None = None,
) -> None:
plt = require_matplotlib()
event_rows = load_jsonl(events_path)
dialog_rows = _load_dialog_rows(dialogs_path)
params = load_json(params_path) if params_path else None

sender_counts: dict[str, int] = {}
recipient_counts: dict[str, int] = {}
Expand Down Expand Up @@ -202,10 +242,17 @@ def plot_agent_communication(
_plot_round_series(axes[1, 0], event_rows)
_plot_dialog_modes(axes[1, 1], dialog_rows)

fig.tight_layout(rect=(0, 0, 1, 0.95))
footer = _messaging_summary(params)
rect_bottom = 0.04 if footer else 0.0
if footer:
fig.text(0.02, 0.012, footer, ha="left", va="bottom", fontsize=8)

fig.tight_layout(rect=(0, rect_bottom, 1, 0.95))
fig.savefig(out_path, dpi=160, bbox_inches="tight")
print(f"[PLOT] events={events_path}")
print(f"[PLOT] dialogs={dialogs_path}")
if params_path:
print(f"[PLOT] params={params_path}")
print(f"[PLOT] output={out_path}")
if show:
plt.show()
Expand All @@ -216,13 +263,15 @@ def main() -> None:
args = _parse_args()
events_path = resolve_input(args.events, "outputs/events_*.jsonl")
dialogs_path = resolve_input(args.dialogs, "outputs/*.dialogs.csv")
params_path = resolve_optional_run_params(args.params, events_path)
out_path = ensure_output_path(events_path, args.out, suffix="communication")
plot_agent_communication(
events_path=events_path,
dialogs_path=dialogs_path,
out_path=out_path,
show=args.show,
top_n=args.top_n,
params_path=params_path,
)


Expand Down
Loading
Loading