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:
@@ -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;'>●</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.
|
- **Overview**: ultima run e stato globale.
|
||||||
- **GA Convergence**: fitness per generazione.
|
- **GA Convergence**: fitness per generazione.
|
||||||
- **Genomes**: top-K genomi e ispezione qualitativa.
|
- **Genomes**: top-K genomi e ispezione qualitativa.
|
||||||
|
- **Aquarium**: visualizzazione 2D dei genomi come pesci animati.
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import importlib
|
import importlib
|
||||||
|
|
||||||
|
import pandas as pd
|
||||||
|
|
||||||
|
|
||||||
def test_streamlit_app_imports():
|
def test_streamlit_app_imports():
|
||||||
importlib.import_module("multi_swarm.dashboard.data")
|
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, "generations_df")
|
||||||
assert hasattr(data, "evaluations_df")
|
assert hasattr(data, "evaluations_df")
|
||||||
assert hasattr(data, "genomes_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
|
||||||
|
|||||||
Reference in New Issue
Block a user