Compare commits
2 Commits
70b732d170
...
1ca807dcbe
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ca807dcbe | ||
|
|
950be5fcf2 |
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
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 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)
|
||||
|
||||
80
tests/test_capacitor_bank.py
Normal file
80
tests/test_capacitor_bank.py
Normal 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
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user