Embed per-run plots and flight animation in the web dashboard

Clicking a config in the dashboard now shows its velocity/field/current
plots (rendered on the fly by /api/run/<id>/plot/<kind>.png) plus an
on-demand GIF of the slug flying through the coils
(/api/run/<id>/anim.gif). Matplotlib rendering is serialized behind a
lock since the Agg pyplot state machine isn't thread-safe and the server
is threaded. Verified end to end: endpoints return real PNG/GIF bytes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-07 00:57:02 +05:00
parent 950be5fcf2
commit 1ca807dcbe
4 changed files with 181 additions and 2 deletions

View File

@@ -34,9 +34,15 @@ PAGE_HTML = r"""<!doctype html>
white-space:pre-wrap; max-height:320px; overflow:auto; line-height:1.5; } white-space:pre-wrap; max-height:320px; overflow:auto; line-height:1.5; }
.pill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; background:#21262d; } .pill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; background:#21262d; }
#detail { margin-top:20px; background:#161b22; border:1px solid #30363d; border-radius:8px; padding:16px; display:none; } #detail { margin-top:20px; background:#161b22; border:1px solid #30363d; border-radius:8px; padding:16px; display:none; }
#detail pre { white-space:pre-wrap; font-family: ui-monospace, monospace; font-size:12px; margin:0; } #detail pre { white-space:pre-wrap; font-family: ui-monospace, monospace; font-size:12px; margin:0;
max-height:340px; overflow:auto; background:#0a0d12; padding:10px; border-radius:6px; }
.close { float:right; cursor:pointer; color:#8b949e; } .close { float:right; cursor:pointer; color:#8b949e; }
a { color:#58a6ff; } a { color:#58a6ff; }
.plots { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:12px; margin:12px 0; }
.plots img { width:100%; border:1px solid #30363d; border-radius:6px; background:#fff; }
.btn { display:inline-block; margin:8px 0; padding:6px 12px; background:#238636; color:#fff;
border:none; border-radius:6px; cursor:pointer; font-size:13px; }
#gifbox img { max-width:100%; border:1px solid #30363d; border-radius:6px; margin-top:8px; }
</style> </style>
</head> </head>
<body> <body>
@@ -77,7 +83,13 @@ PAGE_HTML = r"""<!doctype html>
</section> </section>
<div id="detail"><span class="close" onclick="document.getElementById('detail').style.display='none'">✕ закрыть</span> <div id="detail"><span class="close" onclick="document.getElementById('detail').style.display='none'">✕ закрыть</span>
<h2 id="d-title"></h2><pre id="d-body"></pre></div> <h2 id="d-title"></h2>
<div class="plots" id="d-plots"></div>
<button class="btn" id="d-gifbtn">▶ показать анимацию пролёта (GIF)</button>
<div id="gifbox"></div>
<h2 style="margin-top:14px">Полная детализация (расстояния, батарея, физика, результат)</h2>
<pre id="d-body"></pre>
</div>
</div> </div>
<script> <script>
@@ -131,6 +143,24 @@ async function showDetail(runId){
document.getElementById('d-title').textContent = 'Прогон '+runId.slice(0,8)+ document.getElementById('d-title').textContent = 'Прогон '+runId.slice(0,8)+
''+(d.feasible?('КПД '+fmtPct(d.efficiency)+', '+fmt(d.exit_velocity_mps,1)+' м/с'):'НЕ реализуемо'); ''+(d.feasible?('КПД '+fmtPct(d.efficiency)+', '+fmt(d.exit_velocity_mps,1)+' м/с'):'НЕ реализуемо');
document.getElementById('d-body').textContent = JSON.stringify(d, null, 2); document.getElementById('d-body').textContent = JSON.stringify(d, null, 2);
// графики строятся на лету эндпоинтом /api/run/<id>/plot/<kind>.png
const plots = document.getElementById('d-plots'); plots.innerHTML='';
['velocity','field','current'].forEach(kind=>{
const img=document.createElement('img');
img.alt=kind; img.loading='lazy'; img.src='/api/run/'+runId+'/plot/'+kind+'.png?t='+Date.now();
plots.appendChild(img);
});
// GIF тяжелее — грузим только по кнопке
document.getElementById('gifbox').innerHTML='';
const gifBtn=document.getElementById('d-gifbtn');
gifBtn.onclick=()=>{
document.getElementById('gifbox').innerHTML='<p class="muted">рендер анимации…</p>';
const g=document.createElement('img'); g.src='/api/run/'+runId+'/anim.gif?t='+Date.now();
g.onload=()=>{ document.getElementById('gifbox').innerHTML=''; document.getElementById('gifbox').appendChild(g); };
g.onerror=()=>{ document.getElementById('gifbox').innerHTML='<p class="muted">не удалось построить анимацию</p>'; };
};
const el=document.getElementById('detail'); el.style.display='block'; el.scrollIntoView({behavior:'smooth'}); const el=document.getElementById('detail'); el.style.display='block'; el.scrollIntoView({behavior:'smooth'});
} }

84
src/gausse/web/render.py Normal file
View File

@@ -0,0 +1,84 @@
"""Рендер графиков/анимации прогона на лету для веб-морды.
По run_id достаём геном из базы, восстанавливаем конфигурацию, гоняем
симуляцию и строим картинку. Matplotlib (Agg) не потокобезопасен на уровне
своей глобальной машины состояний, а сервер многопоточный — поэтому весь
рендер сериализуется одним локом.
"""
import io
import json
import tempfile
import threading
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
from gausse.report.animate import animate_run
from gausse.report.plots import plot_stage_currents, plot_stage_fields, plot_velocity_vs_position
from gausse.sim.coilgun import run_coilgun
from gausse.storage.database import fetch_runs, open_connection
_render_lock = threading.Lock()
_db_cache: ComponentDatabase | None = None
_PLOT_BUILDERS = {
"velocity": plot_velocity_vs_position,
"field": plot_stage_fields,
"current": plot_stage_currents,
}
def _components_db() -> ComponentDatabase:
global _db_cache
if _db_cache is None:
_db_cache = ComponentDatabase.load()
return _db_cache
def _rebuild_result(db_path: Path, run_id: str):
conn = open_connection(db_path)
try:
row = next((r for r in fetch_runs(conn) if r.run_id == run_id), None)
finally:
conn.close()
if row is None:
return None
genome = genome_from_dict(json.loads(row.genome_json))
config, _ix, _iv = decode(genome, _components_db(), SearchBounds())
return config, run_coilgun(config)
def render_plot_png(db_path: Path, run_id: str, kind: str) -> bytes | None:
builder = _PLOT_BUILDERS.get(kind)
if builder is None:
return None
with _render_lock:
rebuilt = _rebuild_result(db_path, run_id)
if rebuilt is None:
return None
_config, result = rebuilt
fig = builder(result)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110)
plt.close(fig)
return buf.getvalue()
def render_animation_gif(db_path: Path, run_id: str) -> bytes | None:
with _render_lock:
rebuilt = _rebuild_result(db_path, run_id)
if rebuilt is None:
return None
config, result = rebuilt
if not result.stage_outcomes:
return None
with tempfile.NamedTemporaryFile(suffix=".gif", delete=True) as tmp:
animate_run(result, config, tmp.name, fps=20)
return Path(tmp.name).read_bytes()

View File

@@ -13,6 +13,7 @@ from pathlib import Path
from urllib.parse import urlparse from urllib.parse import urlparse
from gausse.web.page import PAGE_HTML from gausse.web.page import PAGE_HTML
from gausse.web.render import render_animation_gif, render_plot_png
from gausse.web.stats import overview, run_detail from gausse.web.stats import overview, run_detail
@@ -46,6 +47,34 @@ class _Handler(BaseHTTPRequestHandler):
except Exception as exc: # база может ещё не существовать / быть пустой except Exception as exc: # база может ещё не существовать / быть пустой
self._send_json({"error": str(exc), "total": 0, "feasible": 0, "infeasible": 0}, code=200) self._send_json({"error": str(exc), "total": 0, "feasible": 0, "infeasible": 0}, code=200)
return return
# /api/run/<id>/plot/<kind>.png — график прогона на лету
if path.startswith("/api/run/") and "/plot/" in path:
rest = path[len("/api/run/") :]
run_id, _, tail = rest.partition("/plot/")
kind = tail.removesuffix(".png")
try:
png = render_plot_png(self.db_path, run_id, kind)
except Exception as exc:
self._send(500, str(exc).encode("utf-8"), "text/plain; charset=utf-8")
return
if png is None:
self._send(404, b"no plot", "text/plain")
else:
self._send(200, png, "image/png")
return
# /api/run/<id>/anim.gif — анимация пролёта снаряда
if path.startswith("/api/run/") and path.endswith("/anim.gif"):
run_id = path[len("/api/run/") :].removesuffix("/anim.gif")
try:
gif = render_animation_gif(self.db_path, run_id)
except Exception as exc:
self._send(500, str(exc).encode("utf-8"), "text/plain; charset=utf-8")
return
if gif is None:
self._send(404, b"no animation", "text/plain")
else:
self._send(200, gif, "image/gif")
return
if path.startswith("/api/run/"): if path.startswith("/api/run/"):
run_id = path[len("/api/run/") :] run_id = path[len("/api/run/") :]
detail = run_detail(self.db_path, run_id) detail = run_detail(self.db_path, run_id)

View File

@@ -75,3 +75,39 @@ def test_page_is_self_contained():
# никаких внешних ресурсов (CSP-безопасно): ни http-ссылок в src/href, ни CDN # никаких внешних ресурсов (CSP-безопасно): ни http-ссылок в src/href, ни CDN
assert "src=\"http" not in PAGE_HTML assert "src=\"http" not in PAGE_HTML
assert "href=\"http" not in PAGE_HTML assert "href=\"http" not in PAGE_HTML
def test_render_plot_png_returns_image_bytes(tmp_path):
from gausse.web.render import render_plot_png
from gausse.web.stats import overview
db = _make_db(tmp_path)
o = overview(db, None)
assert o["top"], "нужен реализуемый прогон для графика"
run_id = o["top"][0]["run_id"]
for kind in ("velocity", "field", "current"):
png = render_plot_png(db, run_id, kind)
assert png is not None and png[:8] == b"\x89PNG\r\n\x1a\n", f"{kind} не PNG"
def test_render_plot_unknown_kind_returns_none(tmp_path):
from gausse.web.render import render_plot_png
db = _make_db(tmp_path)
o = _overview_top_id(db)
assert render_plot_png(db, o, "nonsense") is None
def _overview_top_id(db):
from gausse.web.stats import overview
return overview(db, None)["top"][0]["run_id"]
def test_render_animation_gif_returns_gif_bytes(tmp_path):
from gausse.web.render import render_animation_gif
db = _make_db(tmp_path)
run_id = _overview_top_id(db)
gif = render_animation_gif(db, run_id)
assert gif is not None and gif[:6] in (b"GIF87a", b"GIF89a")