Compare commits

..

2 Commits

Author SHA1 Message Date
jze9
1ca807dcbe 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>
2026-07-07 00:57:02 +05:00
jze9
950be5fcf2 Model capacitor banks (series for voltage, parallel for current/capacitance)
Per user: each stage's energy source is now a bank of real capacitors,
n_series x n_parallel, not a single cap.

- components/schema.py CapacitorBank.from_spec: series multiplies voltage
  and ESR and divides capacitance; parallel multiplies capacitance and
  max current and divides ESR; price and count scale with total cap count.
  Duck-typed as a drop-in for CapacitorSpec in StageConfig.
- search_space: cap_series (1-6) and cap_parallel (1-8) per stage gene,
  sampled/mutated/repaired; decode builds the bank; charge voltage capped
  by min(bank voltage, switch rating)
- objective.build_detail records the full bank composition (base part,
  series/parallel, count, totals) and current-capability diagnostics:
  bank_current_over_limit / switch_current_over_limit vs peak discharge
  current (a warning, not a hard reject -- DB holds datasheet not pulse
  ratings; parallel caps already help for real via lower ESR)
- BOM shows "N шт (Sпосл × Pпар)" with per-unit and total price

Already supported and confirmed present (also user-requested): per-coil
independent wire gauge (wire_idx per stage) and computed wire length
(winding_geometry.total_wire_length_m, drives resistance/cost/DB detail).

83 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:53:33 +05:00
9 changed files with 358 additions and 11 deletions

View File

@@ -27,6 +27,46 @@ class CapacitorSpec:
source: str
@dataclass(frozen=True)
class CapacitorBank:
"""Батарея из одинаковых конденсаторов: n_series (напряжение) × n_parallel (ток/ёмкость).
Последовательное соединение складывает напряжение и ESR, делит ёмкость;
параллельное — складывает ёмкость, максимальный ток и делит ESR. Итоговая
батарея выставляет те же поля, что и одиночный конденсатор
(`capacitance_uf`, `voltage_v`, `esr_ohm`, `max_current_a`, `price`),
поэтому используется как drop-in замена `CapacitorSpec` в StageConfig.
"""
base_part_number: str
n_series: int
n_parallel: int
capacitance_uf: float
voltage_v: float
esr_ohm: float
max_current_a: float
price: float
@property
def count(self) -> int:
return self.n_series * self.n_parallel
@classmethod
def from_spec(cls, spec: "CapacitorSpec", n_series: int, n_parallel: int) -> "CapacitorBank":
n_series = max(1, int(n_series))
n_parallel = max(1, int(n_parallel))
return cls(
base_part_number=spec.part_number,
n_series=n_series,
n_parallel=n_parallel,
capacitance_uf=spec.capacitance_uf * n_parallel / n_series,
voltage_v=spec.voltage_v * n_series,
esr_ohm=spec.esr_ohm * n_series / n_parallel,
max_current_a=spec.max_current_a * n_parallel,
price=spec.price * n_series * n_parallel,
)
@dataclass(frozen=True)
class SwitchSpec:
part_number: str

View File

@@ -116,6 +116,12 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
)
cap = stage.capacitor
# cap может быть батареей (CapacitorBank) или одиночным конденсатором (CapacitorSpec)
cap_base = getattr(cap, "base_part_number", getattr(cap, "part_number", "?"))
n_series = getattr(cap, "n_series", 1)
n_parallel = getattr(cap, "n_parallel", 1)
entry = {
"stage_index": i,
"coil_center_position_m": coil_centers_m[i],
@@ -125,14 +131,22 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
"wire": stage.wire.part_id,
"wire_material": stage.wire.material,
"wire_gauge_mm": stage.wire.gauge_mm,
"capacitor": stage.capacitor.part_number,
"capacitance_uf": stage.capacitor.capacitance_uf,
"capacitor_voltage_rating_v": stage.capacitor.voltage_v,
"switch": stage.switch.part_number,
"switch_kind": stage.switch.kind,
"switch_max_current_a": stage.switch.max_current_a,
"sensor": stage.sensor.part_number,
"sensor_kind": stage.sensor.kind,
},
"capacitor_bank": {
"base_part": cap_base,
"n_series": n_series,
"n_parallel": n_parallel,
"count": n_series * n_parallel,
"total_capacitance_uf": cap.capacitance_uf,
"total_voltage_rating_v": cap.voltage_v,
"total_esr_ohm": cap.esr_ohm,
"max_current_a": cap.max_current_a,
},
"winding": {
"turns_per_layer": stage.turns_per_layer,
"layers": stage.layers,
@@ -155,6 +169,13 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
peak_current_a = (
float(np.max(np.abs(r.discharge_i))) if r.discharge_i is not None and len(r.discharge_i) else None
)
# диагностика по току: хватает ли батареи и ключа на пиковый ток разряда.
# Это предупреждение, а не жёсткий отказ: в базе — паспортный (не импульсный)
# максимум тока, а одиночный выстрел кратковременный, поэтому честнее
# флажок, чем ложная отбраковка. Параллельные конденсаторы уже дают
# реальный выигрыш через сниженный ESR (меньше потери), см. capacitor_bank.
bank_over = peak_current_a is not None and peak_current_a > cap.max_current_a
switch_over = peak_current_a is not None and peak_current_a > stage.switch.max_current_a
entry["outcome"] = {
"reached": True,
"feasible": r.feasible,
@@ -164,6 +185,8 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
"t_sensor_s": r.t_sensor_s,
"t_fire_s": r.t_fire_s,
"peak_current_a": peak_current_a,
"bank_current_over_limit": bank_over,
"switch_current_over_limit": switch_over,
"peak_field_estimate_tesla": r.peak_field_estimate_tesla,
"saturation_warning": r.saturation_warning,
"energy_in_j": r.energy_in_j,

View File

@@ -12,6 +12,7 @@ import random
from dataclasses import dataclass, field
from gausse.components.database import ComponentDatabase
from gausse.components.schema import CapacitorBank
from gausse.sim.coilgun import CoilgunConfig
from gausse.sim.stage import ProjectileConfig, StageConfig
@@ -40,6 +41,11 @@ class SearchBounds:
charge_voltage_fraction_max: float = 1.0
initial_launch_velocity_mps: float = 3.0
initial_approach_margin_m: float = 0.02
# батарея конденсаторов на ступень: последовательно (напряжение) × параллельно (ток/ёмкость)
cap_series_min: int = 1
cap_series_max: int = 6
cap_parallel_min: int = 1
cap_parallel_max: int = 8
@dataclass
@@ -52,6 +58,8 @@ class StageGene:
layers: int
sensor_to_coil_distance_m: float
charge_voltage_fraction: float
cap_series: int = 1
cap_parallel: int = 1
@dataclass
@@ -87,6 +95,8 @@ def sample_stage_gene(db: ComponentDatabase, bounds: SearchBounds, rng: random.R
charge_voltage_fraction=rng.uniform(
bounds.charge_voltage_fraction_min, bounds.charge_voltage_fraction_max
),
cap_series=rng.randint(bounds.cap_series_min, bounds.cap_series_max),
cap_parallel=rng.randint(bounds.cap_parallel_min, bounds.cap_parallel_max),
)
@@ -138,6 +148,8 @@ def repair(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Genom
bounds.charge_voltage_fraction_min,
bounds.charge_voltage_fraction_max,
)
stage.cap_series = int(_clip(stage.cap_series, bounds.cap_series_min, bounds.cap_series_max))
stage.cap_parallel = int(_clip(stage.cap_parallel, bounds.cap_parallel_min, bounds.cap_parallel_max))
genome.inter_stage_gaps_m = [
_clip(g, bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
for g in genome.inter_stage_gaps_m[: len(genome.stages) - 1]
@@ -161,9 +173,12 @@ def decode(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> tuple
stage_configs = []
for gene in genome.stages:
wire = db.wires[gene.wire_idx % len(db.wires)]
capacitor = db.capacitors[gene.capacitor_idx % len(db.capacitors)]
base_capacitor = db.capacitors[gene.capacitor_idx % len(db.capacitors)]
capacitor = CapacitorBank.from_spec(base_capacitor, gene.cap_series, gene.cap_parallel)
switch = db.switches[gene.switch_idx % len(db.switches)]
sensor = db.sensors[gene.sensor_idx % len(db.sensors)]
# напряжение батареи (base.V × n_series) может быть выше одиночного,
# но ключ обязан его выдержать — реальный ограничитель switch.max_voltage_v
max_voltage = min(capacitor.voltage_v, switch.max_voltage_v)
charge_voltage_v = gene.charge_voltage_fraction * max_voltage
stage_configs.append(
@@ -213,6 +228,10 @@ def mutate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds, rng: ran
stage.sensor_to_coil_distance_m *= rng.uniform(0.7, 1.3)
if rng.random() < rate:
stage.charge_voltage_fraction *= rng.uniform(0.9, 1.1)
if rng.random() < rate:
stage.cap_series += rng.randint(-1, 1)
if rng.random() < rate:
stage.cap_parallel += rng.randint(-1, 1)
for i in range(len(child.inter_stage_gaps_m)):
if rng.random() < rate:

View File

@@ -33,14 +33,20 @@ def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
note=f"{geometry.total_turns} витков ({stage.turns_per_layer}x{stage.layers})",
)
)
cap = stage.capacitor
cap_base = getattr(cap, "base_part_number", getattr(cap, "part_number", "?"))
n_series = getattr(cap, "n_series", 1)
n_parallel = getattr(cap, "n_parallel", 1)
count = n_series * n_parallel
unit_price = cap.price / count if count else cap.price
lines.append(
BomLine(
stage_index=i,
part=f"Конденсатор {stage.capacitor.part_number}",
quantity="1 шт",
unit_price_rub=stage.capacitor.price,
total_price_rub=stage.capacitor.price,
note=f"{stage.capacitor.capacitance_uf}мкФ {stage.capacitor.voltage_v}В, заряд {stage.charge_voltage_v:.0f}В",
part=f"Конденсатор {cap_base}",
quantity=f"{count} шт ({n_series}посл × {n_parallel}пар)",
unit_price_rub=unit_price,
total_price_rub=cap.price,
note=f"батарея {cap.capacitance_uf:.0f}мкФ {cap.voltage_v:.0f}В, заряд {stage.charge_voltage_v:.0f}В, макс.ток {cap.max_current_a:.0f}А",
)
)
lines.append(

View File

@@ -34,9 +34,15 @@ PAGE_HTML = r"""<!doctype html>
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; }
#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; }
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>
</head>
<body>
@@ -77,7 +83,13 @@ PAGE_HTML = r"""<!doctype html>
</section>
<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>
<script>
@@ -131,6 +143,24 @@ async function showDetail(runId){
document.getElementById('d-title').textContent = 'Прогон '+runId.slice(0,8)+
''+(d.feasible?('КПД '+fmtPct(d.efficiency)+', '+fmt(d.exit_velocity_mps,1)+' м/с'):'НЕ реализуемо');
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'});
}

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 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
@@ -46,6 +47,34 @@ class _Handler(BaseHTTPRequestHandler):
except Exception as exc: # база может ещё не существовать / быть пустой
self._send_json({"error": str(exc), "total": 0, "feasible": 0, "infeasible": 0}, code=200)
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/"):
run_id = path[len("/api/run/") :]
detail = run_detail(self.db_path, run_id)

View File

@@ -0,0 +1,80 @@
import random
import pytest
from gausse.components.database import ComponentDatabase
from gausse.components.schema import CapacitorBank, CapacitorSpec
from gausse.optim.objective import build_detail
from gausse.optim.search_space import SearchBounds, decode, sample_genome
from gausse.sim.coilgun import run_coilgun
BASE = CapacitorSpec(
part_number="c-base", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.2,
max_current_a=20.0, price=150.0, source="test",
)
def test_series_multiplies_voltage_and_divides_capacitance():
bank = CapacitorBank.from_spec(BASE, n_series=3, n_parallel=1)
assert bank.voltage_v == pytest.approx(1200.0) # 400 * 3
assert bank.capacitance_uf == pytest.approx(100.0 / 3) # C / 3
assert bank.esr_ohm == pytest.approx(0.6) # ESR * 3
assert bank.count == 3
assert bank.price == pytest.approx(450.0) # 150 * 3
def test_parallel_multiplies_capacitance_current_and_divides_esr():
bank = CapacitorBank.from_spec(BASE, n_series=1, n_parallel=4)
assert bank.capacitance_uf == pytest.approx(400.0) # C * 4
assert bank.voltage_v == pytest.approx(400.0) # без изменения
assert bank.esr_ohm == pytest.approx(0.05) # ESR / 4
assert bank.max_current_a == pytest.approx(80.0) # 20 * 4 -- вот "на силу тока"
assert bank.count == 4
def test_series_parallel_combination():
bank = CapacitorBank.from_spec(BASE, n_series=2, n_parallel=3)
assert bank.voltage_v == pytest.approx(800.0)
assert bank.capacitance_uf == pytest.approx(100.0 * 3 / 2)
assert bank.esr_ohm == pytest.approx(0.2 * 2 / 3)
assert bank.count == 6
assert bank.price == pytest.approx(150.0 * 6)
def test_energy_scales_with_bank():
single = CapacitorBank.from_spec(BASE, 1, 1)
bank = CapacitorBank.from_spec(BASE, 2, 2) # V×2, C×1 (2 series, 2 parallel => C*2/2=C)
e_single = 0.5 * (single.capacitance_uf * 1e-6) * single.voltage_v**2
e_bank = 0.5 * (bank.capacitance_uf * 1e-6) * bank.voltage_v**2
# тот же C, но вдвое большее напряжение -> вчетверо больше энергии
assert e_bank == pytest.approx(4 * e_single)
def test_decoded_record_contains_capacitor_bank_composition():
db = ComponentDatabase.load()
bounds = SearchBounds()
rng = random.Random(1)
genome = sample_genome(db, bounds, rng)
config, ix, iv = decode(genome, db, bounds)
result = run_coilgun(config)
detail = build_detail(config, result, db, ix, iv)
bank = detail["stages"][0]["capacitor_bank"]
assert set(bank) >= {"base_part", "n_series", "n_parallel", "count", "total_voltage_rating_v", "max_current_a"}
assert bank["count"] == bank["n_series"] * bank["n_parallel"]
# база должна быть реальной деталью
assert bank["base_part"] in {c.part_number for c in db.capacitors}
def test_genome_carries_series_parallel_and_they_vary():
db = ComponentDatabase.load()
bounds = SearchBounds()
rng = random.Random(5)
series_vals, parallel_vals = set(), set()
for _ in range(40):
g = sample_genome(db, bounds, rng)
for st in g.stages:
assert bounds.cap_series_min <= st.cap_series <= bounds.cap_series_max
assert bounds.cap_parallel_min <= st.cap_parallel <= bounds.cap_parallel_max
series_vals.add(st.cap_series)
parallel_vals.add(st.cap_parallel)
assert len(series_vals) > 1 and len(parallel_vals) > 1

View File

@@ -75,3 +75,39 @@ def test_page_is_self_contained():
# никаких внешних ресурсов (CSP-безопасно): ни http-ссылок в src/href, ни CDN
assert "src=\"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")