feat(dashboard): aquarium 2D visualization (fish per agent, size by fitness)

Nuova pagina Streamlit "Aquarium" che renderizza ogni genoma come pesce
animato su canvas HTML5: dimensione proporzionale alla fitness, colore
per cognitive_style, halo per i top-3. Helper puro-Python testabile per
costruire dataset e HTML self-contained (no CDN).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-10 09:26:53 +02:00
parent 33d8e275e7
commit 70b8bc3d6c
4 changed files with 439 additions and 0 deletions
+281
View File
@@ -0,0 +1,281 @@
"""Aquarium 2D visualization helpers.
Builds a list of fish records from a merged DataFrame (evaluations + genomes)
and renders a self-contained HTML/JS canvas animation, embeddable in Streamlit
via ``streamlit.components.v1.html``.
"""
from __future__ import annotations
import json
import math
from typing import Any
import pandas as pd # type: ignore[import-untyped]
# Color palette per cognitive style. Default fallback for unknown styles is grey.
STYLE_COLORS: dict[str, str] = {
"physicist": "#4cc9f0",
"biologist": "#52b788",
"historian": "#e76f51",
"meteorologist": "#ffd166",
"ecologist": "#a78bfa",
"engineer": "#fb6f92",
}
DEFAULT_COLOR: str = "#94a3b8"
def build_fish_dataset(merged: pd.DataFrame, max_fish: int = 30) -> list[dict[str, Any]]:
"""Build a list of fish records from a merged evaluations+genomes DataFrame.
Expects columns: ``genome_id``, ``fitness``, ``cognitive_style``, ``n_trades``,
``dsr``. Rows with NaN ``fitness`` are dropped. Result is sorted by fitness
descending and capped at ``max_fish`` entries.
"""
if merged.empty:
return []
cols_needed = ["genome_id", "fitness", "cognitive_style", "n_trades", "dsr"]
available = [c for c in cols_needed if c in merged.columns]
df = merged[available].copy()
if "fitness" not in df.columns:
return []
df = df.dropna(subset=["fitness"])
df = df.sort_values("fitness", ascending=False).head(max_fish)
fish: list[dict[str, Any]] = []
for _, row in df.iterrows():
gid = str(row.get("genome_id", ""))
fitness_val = float(row.get("fitness", 0.0))
if math.isnan(fitness_val):
fitness_val = 0.0
style_raw = row.get("cognitive_style", None)
style = str(style_raw) if style_raw is not None and not _is_nan(style_raw) else "unknown"
n_trades_raw = row.get("n_trades", 0)
n_trades = int(n_trades_raw) if not _is_nan(n_trades_raw) else 0
dsr_raw = row.get("dsr", 0.0)
dsr = float(dsr_raw) if not _is_nan(dsr_raw) else 0.0
fish.append(
{
"id": gid,
"fitness": fitness_val,
"cognitive_style": style,
"n_trades": n_trades,
"dsr": dsr,
}
)
return fish
def _is_nan(v: Any) -> bool:
try:
return bool(pd.isna(v))
except (TypeError, ValueError):
return False
def build_aquarium_html(
fish: list[dict[str, Any]],
canvas_w: int = 1000,
canvas_h: int = 600,
show_labels: bool = False,
) -> str:
"""Build the self-contained HTML/JS string for the aquarium canvas."""
fish_json = json.dumps(fish)
palette_json = json.dumps(STYLE_COLORS)
default_color = DEFAULT_COLOR
show_labels_js = "true" if show_labels else "false"
# All braces inside <style>/<script> are escaped to literals using {{ }}
# so we can use Python f-string substitution for the few JSON payloads.
return f"""
<div style="position:relative;width:100%;height:{canvas_h}px;">
<canvas id="aquarium" width="{canvas_w}" height="{canvas_h}"
style="width:100%;height:{canvas_h}px;border-radius:12px;
background:linear-gradient(180deg,#0a2540 0%,#1a4d80 100%);
display:block;"></canvas>
</div>
<script>
(function() {{
const FISH_DATA = {fish_json};
const STYLE_COLORS = {palette_json};
const DEFAULT_COLOR = {json.dumps(default_color)};
const SHOW_LABELS = {show_labels_js};
const canvas = document.getElementById('aquarium');
if (!canvas) return;
const ctx = canvas.getContext('2d');
const W = canvas.width;
const H = canvas.height;
// Normalize fitness for sizing.
let maxFit = 0.0;
for (const f of FISH_DATA) {{
if (typeof f.fitness === 'number' && f.fitness > maxFit) maxFit = f.fitness;
}}
function lerp(a, b, t) {{ return a + (b - a) * t; }}
function radiusFor(fitness) {{
if (maxFit <= 0) return 8;
const t = Math.max(0.05, Math.min(1.0, fitness / maxFit));
return lerp(8, 40, t);
}}
function colorFor(style) {{
return STYLE_COLORS[style] || DEFAULT_COLOR;
}}
// Init fish state.
const fish = FISH_DATA.map((f, idx) => {{
const r = radiusFor(f.fitness);
return {{
id: f.id,
fitness: f.fitness,
style: f.cognitive_style,
color: colorFor(f.cognitive_style),
r: r,
x: Math.random() * (W - 2 * r) + r,
y: Math.random() * (H - 2 * r) + r,
vx: (Math.random() - 0.5) * 1.5,
vy: (Math.random() - 0.5) * 1.0,
rank: idx,
}};
}});
// Bubbles for ambience.
const N_BUBBLES = 25;
const bubbles = Array.from({{length: N_BUBBLES}}, () => ({{
x: Math.random() * W,
y: Math.random() * H,
r: 1 + Math.random() * 3,
vy: 0.3 + Math.random() * 0.7,
}}));
function drawBubble(b) {{
ctx.beginPath();
ctx.arc(b.x, b.y, b.r, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(255,255,255,0.18)';
ctx.fill();
}}
function updateBubble(b) {{
b.y -= b.vy;
if (b.y < -10) {{
b.y = H + 5;
b.x = Math.random() * W;
}}
}}
function drawFish(f) {{
const facingLeft = f.vx < 0;
ctx.save();
ctx.translate(f.x, f.y);
if (facingLeft) ctx.scale(-1, 1);
// Halo for top-3 fish.
if (f.rank < 3) {{
const grad = ctx.createRadialGradient(0, 0, f.r * 0.5, 0, 0, f.r * 2.0);
grad.addColorStop(0, f.color + 'aa');
grad.addColorStop(1, f.color + '00');
ctx.fillStyle = grad;
ctx.beginPath();
ctx.arc(0, 0, f.r * 2.0, 0, Math.PI * 2);
ctx.fill();
}}
// Body (ellipse).
ctx.fillStyle = f.color;
ctx.beginPath();
ctx.ellipse(0, 0, f.r, f.r * 0.6, 0, 0, Math.PI * 2);
ctx.fill();
// Tail (triangle pointing left, body extends right).
ctx.beginPath();
ctx.moveTo(-f.r, 0);
ctx.lineTo(-f.r * 1.6, -f.r * 0.5);
ctx.lineTo(-f.r * 1.6, f.r * 0.5);
ctx.closePath();
ctx.fill();
// Eye.
ctx.fillStyle = '#ffffff';
ctx.beginPath();
ctx.arc(f.r * 0.45, -f.r * 0.15, Math.max(1.5, f.r * 0.12), 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = '#1a1a1a';
ctx.beginPath();
ctx.arc(f.r * 0.50, -f.r * 0.15, Math.max(0.8, f.r * 0.06), 0, Math.PI * 2);
ctx.fill();
ctx.restore();
if (SHOW_LABELS && f.id) {{
ctx.fillStyle = 'rgba(255,255,255,0.85)';
ctx.font = '10px sans-serif';
ctx.textAlign = 'center';
ctx.fillText(String(f.id).slice(0, 6), f.x, f.y - f.r - 4);
}}
}}
function updateFish(f) {{
// Random perturbation (organic motion).
f.vx += (Math.random() - 0.5) * 0.05;
f.vy += (Math.random() - 0.5) * 0.05;
// Clamp speed.
const speed = Math.hypot(f.vx, f.vy);
const maxSpeed = 1.5;
if (speed > maxSpeed) {{
f.vx = (f.vx / speed) * maxSpeed;
f.vy = (f.vy / speed) * maxSpeed;
}}
f.x += f.vx;
f.y += f.vy;
// Bounce on edges.
if (f.x < f.r) {{ f.x = f.r; f.vx = -f.vx; }}
if (f.x > W - f.r) {{ f.x = W - f.r; f.vx = -f.vx; }}
if (f.y < f.r) {{ f.y = f.r; f.vy = -f.vy; }}
if (f.y > H - f.r) {{ f.y = H - f.r; f.vy = -f.vy; }}
}}
function frame() {{
ctx.clearRect(0, 0, W, H);
// Subtle current lines.
ctx.strokeStyle = 'rgba(255,255,255,0.04)';
ctx.lineWidth = 1;
for (let i = 0; i < 6; i++) {{
const y = (H / 6) * i + (Date.now() / 50 % (H / 6));
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(W, y);
ctx.stroke();
}}
for (const b of bubbles) {{
updateBubble(b);
drawBubble(b);
}}
for (const f of fish) {{
updateFish(f);
drawFish(f);
}}
requestAnimationFrame(frame);
}}
if (fish.length === 0) {{
ctx.fillStyle = 'rgba(255,255,255,0.7)';
ctx.font = '16px sans-serif';
ctx.textAlign = 'center';
ctx.fillText('Acquario vuoto: nessun genoma da mostrare.', W / 2, H / 2);
}} else {{
requestAnimationFrame(frame);
}}
}})();
</script>
"""
@@ -0,0 +1,84 @@
from __future__ import annotations
import streamlit as st
import streamlit.components.v1 as components
from multi_swarm.dashboard.aquarium import (
STYLE_COLORS,
build_aquarium_html,
build_fish_dataset,
)
from multi_swarm.dashboard.data import (
evaluations_df,
generations_df,
genomes_df,
get_repo,
list_runs_df,
)
st.title("Aquarium 2D")
st.caption(
"Ogni genoma è un pesce: dimensione proporzionale alla fitness, "
"colore per cognitive_style."
)
db_path = st.session_state.get("db_path", "./runs.db")
repo = get_repo(db_path)
runs = list_runs_df(repo)
if runs.empty:
st.info("Nessuna run.")
st.stop()
selected = st.selectbox("Run", runs["id"].tolist())
gens = generations_df(repo, selected)
gen_options: list[int | None] = [None]
if not gens.empty:
gen_options.extend(sorted(gens["generation_idx"].unique().tolist()))
def _fmt_gen(v: int | None) -> str:
return "tutte le generazioni" if v is None else f"gen {v}"
# Default to latest gen if available.
default_idx = len(gen_options) - 1 if len(gen_options) > 1 else 0
gen_choice = st.selectbox(
"Generazione", gen_options, index=default_idx, format_func=_fmt_gen
)
with st.sidebar:
st.subheader("Aquarium controls")
max_fish = st.slider("Max pesci", min_value=5, max_value=100, value=30, step=1)
show_labels = st.toggle("Mostra ID genoma", value=False)
if st.button("Refresh"):
st.rerun()
evals = evaluations_df(repo, selected)
genomes = genomes_df(repo, selected, generation_idx=gen_choice)
if evals.empty or genomes.empty:
st.warning("Nessun dato disponibile per questa run/generazione.")
st.stop()
merged = evals.merge(
genomes, left_on="genome_id", right_on="id", how="inner", suffixes=("", "_g")
)
if merged.empty:
st.warning("Nessuna evaluation associata ai genomi della generazione scelta.")
st.stop()
fish = build_fish_dataset(merged, max_fish=max_fish)
st.write(f"Visualizzati {len(fish)} pesci (top per fitness).")
html_str = build_aquarium_html(
fish, canvas_w=1000, canvas_h=600, show_labels=show_labels
)
components.html(html_str, height=620, scrolling=False)
with st.expander("Legenda colori"):
legend_md = "\n".join(
f"- <span style='color:{color};font-weight:bold;'>&#9679;</span> "
f"`{style}`"
for style, color in STYLE_COLORS.items()
)
st.markdown(legend_md, unsafe_allow_html=True)
@@ -13,6 +13,7 @@ Naviga le pagine nel menu a sinistra:
- **Overview**: ultima run e stato globale.
- **GA Convergence**: fitness per generazione.
- **Genomes**: top-K genomi e ispezione qualitativa.
- **Aquarium**: visualizzazione 2D dei genomi come pesci animati.
"""
)
+73
View File
@@ -1,5 +1,7 @@
import importlib
import pandas as pd
def test_streamlit_app_imports():
importlib.import_module("multi_swarm.dashboard.data")
@@ -12,3 +14,74 @@ def test_dashboard_data_helpers_signatures():
assert hasattr(data, "generations_df")
assert hasattr(data, "evaluations_df")
assert hasattr(data, "genomes_df")
def test_aquarium_helper_builds_html():
from multi_swarm.dashboard.aquarium import build_aquarium_html
fish = [
{
"id": "abc123",
"fitness": 0.8,
"cognitive_style": "physicist",
"n_trades": 30,
"dsr": 0.7,
},
{
"id": "def456",
"fitness": 0.0,
"cognitive_style": "biologist",
"n_trades": 0,
"dsr": 0.0,
},
]
html = build_aquarium_html(fish, canvas_w=800, canvas_h=400, show_labels=True)
assert "canvas" in html
assert "abc123" in html
assert "physicist" in html or "4cc9f0" in html
assert "requestAnimationFrame" in html
def test_aquarium_build_fish_dataset_sorts_and_caps():
from multi_swarm.dashboard.aquarium import build_fish_dataset
df = pd.DataFrame(
[
{"genome_id": "low", "fitness": 0.1, "cognitive_style": "physicist",
"n_trades": 1, "dsr": 0.0},
{"genome_id": "high", "fitness": 0.9, "cognitive_style": "biologist",
"n_trades": 10, "dsr": 0.5},
{"genome_id": "mid", "fitness": 0.5, "cognitive_style": "engineer",
"n_trades": 5, "dsr": 0.2},
]
)
out = build_fish_dataset(df, max_fish=2)
assert len(out) == 2
assert out[0]["id"] == "high"
assert out[1]["id"] == "mid"
assert out[0]["cognitive_style"] == "biologist"
def test_aquarium_build_fish_dataset_drops_nan_fitness():
from multi_swarm.dashboard.aquarium import build_fish_dataset
df = pd.DataFrame(
[
{"genome_id": "ok", "fitness": 0.4, "cognitive_style": "historian",
"n_trades": 2, "dsr": 0.1},
{"genome_id": "bad", "fitness": float("nan"), "cognitive_style": "ecologist",
"n_trades": 0, "dsr": 0.0},
]
)
out = build_fish_dataset(df)
assert len(out) == 1
assert out[0]["id"] == "ok"
def test_aquarium_empty_input_returns_empty():
from multi_swarm.dashboard.aquarium import build_aquarium_html, build_fish_dataset
assert build_fish_dataset(pd.DataFrame()) == []
html = build_aquarium_html([], canvas_w=400, canvas_h=200)
assert "canvas" in html
assert "Acquario vuoto" in html