Skip to content

Commit 9f70935

Browse files
authored
feat: optimize the visualization module for plotting statistic results and agent communication (#38)
* refactor: Change scenario prompts in agents/scenarios.py * chore: update the suggested routes and destination for Lytton Changes to be committed: modified: agentevac/simulation/main.py modified: agentevac/simulation/spawn_events.py modified: agentevac/utils/replay.py modified: sumo/Repaired.netecfg modified: sumo/Repaired.sumocfg * fix: update replay.py to fix key error Module updated: agentevac/utils/replay.py - Fixed RouteReplay._load_schedule(...) so it only reads step and veh_id for replayable events: - route_change - departure_release - Non-replayable events like agent_cognition and metrics_snapshot are now ignored without touching veh_id. Cause - The loader was accessing rec["veh_id"] before checking the event type. - metrics_snapshot records do not have veh_id, so replay loading crashed with KeyError. Verification 1. python3 -m py_compile agentevac/utils/replay.py passed. 2. Reproduced the failing case with a small local script: - one route_change - one agent_cognition - one metrics_snapshot - replay load now succeeds and only indexes the route-change step. * feat: optimize the visualization module for plotting statistic results and agent communication
1 parent 7c6ea7a commit 9f70935

8 files changed

Lines changed: 1040 additions & 0 deletions

README.md

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,3 +95,46 @@ agentevac-study \
9595
```
9696

9797
This runs a grid search over information noise, delay, and trust parameters and fits results against a reference metrics file.
98+
99+
## Plotting Completed Runs
100+
101+
Install the plotting dependency:
102+
103+
```bash
104+
pip install -e .[plot]
105+
```
106+
107+
Generate figures for the latest run:
108+
109+
```bash
110+
python3 scripts/plot_all_run_artifacts.py
111+
```
112+
113+
Generate figures for a specific run ID:
114+
115+
```bash
116+
python3 scripts/plot_all_run_artifacts.py --run-id 20260309_030340
117+
```
118+
119+
Useful individual plotting commands:
120+
121+
```bash
122+
# 2x2 dashboard for one run_metrics_*.json
123+
python3 scripts/plot_run_metrics.py --metrics outputs/run_metrics_20260309_030340.json
124+
125+
# Departures, messages, system observations, and route changes over time
126+
python3 scripts/plot_departure_timeline.py \
127+
--events outputs/events_20260309_030340.jsonl \
128+
--replay outputs/llm_routes_20260309_030340.jsonl
129+
130+
# Messaging and dialog activity
131+
python3 scripts/plot_agent_communication.py \
132+
--events outputs/events_20260309_030340.jsonl \
133+
--dialogs outputs/llm_routes_20260309_030340.dialogs.csv
134+
135+
# Compare multiple completed runs or sweep outputs
136+
python3 scripts/plot_experiment_comparison.py \
137+
--results-json outputs/experiments/experiment_results.json
138+
```
139+
140+
By default, plots are saved under `outputs/figures/` or next to the selected input file.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,9 @@ dev = [
2424
"mkdocs-material",
2525
"build",
2626
]
27+
plot = [
28+
"matplotlib>=3.8",
29+
]
2730

2831
[project.scripts]
2932
# Calibration / sweep tools expose a proper main() and work as CLI scripts.

scripts/_plot_common.py

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
"""Shared helpers for plotting completed simulation artifacts."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
import os
7+
from pathlib import Path
8+
from typing import Any, Iterable, List
9+
10+
11+
def newest_file(pattern: str) -> Path:
12+
"""Return the newest file matching ``pattern``.
13+
14+
Raises:
15+
FileNotFoundError: If no matching files exist.
16+
"""
17+
matches = sorted(Path().glob(pattern), key=lambda p: p.stat().st_mtime, reverse=True)
18+
if not matches:
19+
raise FileNotFoundError(f"No files match pattern: {pattern}")
20+
return matches[0]
21+
22+
23+
def resolve_input(path_arg: str | None, pattern: str) -> Path:
24+
"""Resolve an explicit input path or fall back to the newest matching file."""
25+
if path_arg:
26+
path = Path(path_arg)
27+
if not path.exists():
28+
raise FileNotFoundError(f"Input file does not exist: {path}")
29+
return path
30+
return newest_file(pattern)
31+
32+
33+
def load_json(path: Path) -> Any:
34+
"""Load a JSON document from ``path``."""
35+
with path.open("r", encoding="utf-8") as fh:
36+
return json.load(fh)
37+
38+
39+
def load_jsonl(path: Path) -> List[dict[str, Any]]:
40+
"""Load JSON Lines from ``path`` into a list of dicts."""
41+
rows: List[dict[str, Any]] = []
42+
with path.open("r", encoding="utf-8") as fh:
43+
for line in fh:
44+
text = line.strip()
45+
if not text:
46+
continue
47+
rows.append(json.loads(text))
48+
return rows
49+
50+
51+
def ensure_output_path(
52+
input_path: Path,
53+
output_arg: str | None,
54+
*,
55+
suffix: str,
56+
) -> Path:
57+
"""Resolve output path and ensure its parent directory exists."""
58+
if output_arg:
59+
out = Path(output_arg)
60+
else:
61+
out = input_path.with_suffix("")
62+
out = out.with_name(f"{out.name}.{suffix}.png")
63+
out.parent.mkdir(parents=True, exist_ok=True)
64+
return out
65+
66+
67+
def top_items(mapping: dict[str, float], limit: int) -> list[tuple[str, float]]:
68+
"""Return up to ``limit`` items sorted by descending value then key."""
69+
items = sorted(mapping.items(), key=lambda item: (-item[1], item[0]))
70+
return items[: max(1, int(limit))]
71+
72+
73+
def bin_counts(
74+
times_s: Iterable[float],
75+
*,
76+
bin_s: float,
77+
) -> list[tuple[float, int]]:
78+
"""Bin event times into fixed-width buckets.
79+
80+
Returns:
81+
List of ``(bin_start_s, count)`` tuples in ascending order.
82+
"""
83+
counts: dict[float, int] = {}
84+
width = max(float(bin_s), 1e-9)
85+
for t in times_s:
86+
bucket = width * int(float(t) // width)
87+
counts[bucket] = counts.get(bucket, 0) + 1
88+
return sorted(counts.items(), key=lambda item: item[0])
89+
90+
91+
def require_matplotlib():
92+
"""Import matplotlib lazily with a useful error message."""
93+
# Constrain thread-hungry numeric backends before importing matplotlib/numpy.
94+
os.environ.setdefault("MPLBACKEND", "Agg")
95+
os.environ.setdefault("OMP_NUM_THREADS", "1")
96+
os.environ.setdefault("OPENBLAS_NUM_THREADS", "1")
97+
os.environ.setdefault("MKL_NUM_THREADS", "1")
98+
os.environ.setdefault("NUMEXPR_NUM_THREADS", "1")
99+
try:
100+
import matplotlib.pyplot as plt
101+
except ImportError as exc:
102+
raise SystemExit(
103+
"matplotlib is required for plotting. Install it with "
104+
"`pip install -e .[plot]` or `pip install matplotlib`."
105+
) from exc
106+
return plt
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
#!/usr/bin/env python3
2+
"""Visualize agent-to-agent messaging and LLM dialog volume for one run."""
3+
4+
from __future__ import annotations
5+
6+
import argparse
7+
import csv
8+
from pathlib import Path
9+
from typing import Any
10+
11+
try:
12+
from scripts._plot_common import ensure_output_path, load_jsonl, require_matplotlib, resolve_input, top_items
13+
except ModuleNotFoundError:
14+
from _plot_common import ensure_output_path, load_jsonl, require_matplotlib, resolve_input, top_items
15+
16+
17+
def _parse_args() -> argparse.Namespace:
18+
parser = argparse.ArgumentParser(
19+
description="Visualize messaging and dialog activity from events JSONL and dialogs CSV."
20+
)
21+
parser.add_argument(
22+
"--events",
23+
help="Path to an events_*.jsonl file. Defaults to the newest outputs/events_*.jsonl.",
24+
)
25+
parser.add_argument(
26+
"--dialogs",
27+
help="Path to a *.dialogs.csv file. Defaults to the newest outputs/*.dialogs.csv.",
28+
)
29+
parser.add_argument(
30+
"--out",
31+
help="Output PNG path. Defaults to <events>.communication.png.",
32+
)
33+
parser.add_argument(
34+
"--show",
35+
action="store_true",
36+
help="Open the figure window in addition to saving the PNG.",
37+
)
38+
parser.add_argument(
39+
"--top-n",
40+
type=int,
41+
default=15,
42+
help="Maximum number of bars to draw in sender/recipient charts (default: 15).",
43+
)
44+
return parser.parse_args()
45+
46+
47+
def _load_dialog_rows(path: Path) -> list[dict[str, str]]:
48+
with path.open("r", encoding="utf-8", newline="") as fh:
49+
return list(csv.DictReader(fh))
50+
51+
52+
def _draw_bar(ax, items: list[tuple[str, float]], title: str, ylabel: str, color: str) -> None:
53+
if not items:
54+
ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11)
55+
ax.set_title(title)
56+
ax.set_axis_off()
57+
return
58+
labels = [k for k, _ in items]
59+
values = [v for _, v in items]
60+
ax.bar(range(len(values)), values, color=color)
61+
ax.set_xticks(range(len(labels)))
62+
ax.set_xticklabels(labels, rotation=60, ha="right", fontsize=8)
63+
ax.set_title(title)
64+
ax.set_ylabel(ylabel)
65+
66+
67+
def _round_value(rec: dict[str, Any]) -> int | None:
68+
for key in ("delivery_round", "deliver_round", "sent_round", "round"):
69+
value = rec.get(key)
70+
if value is None:
71+
continue
72+
try:
73+
return int(value)
74+
except (TypeError, ValueError):
75+
continue
76+
return None
77+
78+
79+
def _plot_round_series(ax, event_rows: list[dict[str, Any]]) -> None:
80+
series = {
81+
"queued": {},
82+
"delivered": {},
83+
"llm": {},
84+
"predeparture": {},
85+
}
86+
for rec in event_rows:
87+
event = rec.get("event")
88+
round_idx = _round_value(rec)
89+
if round_idx is None:
90+
continue
91+
if event == "message_queued":
92+
series["queued"][round_idx] = series["queued"].get(round_idx, 0) + 1
93+
elif event == "message_delivered":
94+
series["delivered"][round_idx] = series["delivered"].get(round_idx, 0) + 1
95+
elif event == "llm_decision":
96+
series["llm"][round_idx] = series["llm"].get(round_idx, 0) + 1
97+
elif event == "predeparture_llm_decision":
98+
series["predeparture"][round_idx] = series["predeparture"].get(round_idx, 0) + 1
99+
100+
plotted = False
101+
colors = {
102+
"queued": "#4C78A8",
103+
"delivered": "#54A24B",
104+
"llm": "#F58518",
105+
"predeparture": "#E45756",
106+
}
107+
for name, mapping in series.items():
108+
if not mapping:
109+
continue
110+
xs = sorted(mapping.keys())
111+
ys = [mapping[x] for x in xs]
112+
ax.plot(xs, ys, marker="o", linewidth=1.8, label=name, color=colors[name])
113+
plotted = True
114+
115+
if not plotted:
116+
ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11)
117+
ax.set_axis_off()
118+
return
119+
ax.set_title("Message and Decision Volume by Round")
120+
ax.set_xlabel("Decision Round")
121+
ax.set_ylabel("Event Count")
122+
ax.legend()
123+
124+
125+
def _plot_dialog_modes(ax, dialog_rows: list[dict[str, str]]) -> None:
126+
counts: dict[str, int] = {}
127+
response_lengths: dict[str, list[int]] = {}
128+
for row in dialog_rows:
129+
mode = str(row.get("control_mode") or "unknown")
130+
counts[mode] = counts.get(mode, 0) + 1
131+
response_text = row.get("response_text") or ""
132+
response_lengths.setdefault(mode, []).append(len(response_text))
133+
134+
labels = sorted(counts.keys())
135+
if not labels:
136+
ax.text(0.5, 0.5, "No data", ha="center", va="center", fontsize=11)
137+
ax.set_axis_off()
138+
return
139+
140+
xs = list(range(len(labels)))
141+
count_vals = [counts[label] for label in labels]
142+
avg_lens = [
143+
(sum(response_lengths[label]) / float(len(response_lengths[label])))
144+
if response_lengths[label] else 0.0
145+
for label in labels
146+
]
147+
148+
ax.bar(xs, count_vals, color="#72B7B2", label="dialogs")
149+
ax.set_xticks(xs)
150+
ax.set_xticklabels(labels, rotation=20, ha="right")
151+
ax.set_title("Dialog Volume and Avg Response Length")
152+
ax.set_ylabel("Dialog Count")
153+
154+
ax2 = ax.twinx()
155+
ax2.plot(xs, avg_lens, color="#B279A2", marker="o", linewidth=1.8, label="avg response chars")
156+
ax2.set_ylabel("Average Response Length (chars)")
157+
158+
159+
def plot_agent_communication(
160+
*,
161+
events_path: Path,
162+
dialogs_path: Path,
163+
out_path: Path,
164+
show: bool,
165+
top_n: int,
166+
) -> None:
167+
plt = require_matplotlib()
168+
event_rows = load_jsonl(events_path)
169+
dialog_rows = _load_dialog_rows(dialogs_path)
170+
171+
sender_counts: dict[str, int] = {}
172+
recipient_counts: dict[str, int] = {}
173+
for rec in event_rows:
174+
event = rec.get("event")
175+
if event == "message_queued":
176+
sender = str(rec.get("from_id") or "unknown")
177+
sender_counts[sender] = sender_counts.get(sender, 0) + 1
178+
elif event == "message_delivered":
179+
recipient = str(rec.get("to_id") or "unknown")
180+
recipient_counts[recipient] = recipient_counts.get(recipient, 0) + 1
181+
182+
fig, axes = plt.subplots(2, 2, figsize=(14, 10))
183+
fig.suptitle(
184+
f"AgentEvac Communication Analysis\n{events_path.name} | {dialogs_path.name}",
185+
fontsize=14,
186+
)
187+
188+
_draw_bar(
189+
axes[0, 0],
190+
top_items({k: float(v) for k, v in sender_counts.items()}, top_n),
191+
f"Top Message Senders (top {top_n})",
192+
"Queued Messages",
193+
"#4C78A8",
194+
)
195+
_draw_bar(
196+
axes[0, 1],
197+
top_items({k: float(v) for k, v in recipient_counts.items()}, top_n),
198+
f"Top Message Recipients (top {top_n})",
199+
"Delivered Messages",
200+
"#54A24B",
201+
)
202+
_plot_round_series(axes[1, 0], event_rows)
203+
_plot_dialog_modes(axes[1, 1], dialog_rows)
204+
205+
fig.tight_layout(rect=(0, 0, 1, 0.95))
206+
fig.savefig(out_path, dpi=160, bbox_inches="tight")
207+
print(f"[PLOT] events={events_path}")
208+
print(f"[PLOT] dialogs={dialogs_path}")
209+
print(f"[PLOT] output={out_path}")
210+
if show:
211+
plt.show()
212+
plt.close(fig)
213+
214+
215+
def main() -> None:
216+
args = _parse_args()
217+
events_path = resolve_input(args.events, "outputs/events_*.jsonl")
218+
dialogs_path = resolve_input(args.dialogs, "outputs/*.dialogs.csv")
219+
out_path = ensure_output_path(events_path, args.out, suffix="communication")
220+
plot_agent_communication(
221+
events_path=events_path,
222+
dialogs_path=dialogs_path,
223+
out_path=out_path,
224+
show=args.show,
225+
top_n=args.top_n,
226+
)
227+
228+
229+
if __name__ == "__main__":
230+
main()

0 commit comments

Comments
 (0)