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.
|
||||
- **GA Convergence**: fitness per generazione.
|
||||
- **Genomes**: top-K genomi e ispezione qualitativa.
|
||||
- **Aquarium**: visualizzazione 2D dei genomi come pesci animati.
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user