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:
@@ -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
84
src/gausse/web/render.py
Normal 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()
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user