Wire up reporting (plots, BOM, summary, animation) and a working CLI (Stage 7)
- report/plots.py: current/field/velocity plots per stage, Agg backend so it works headless in Docker/on a server - report/bom.py: itemized bill of materials with real component prices - report/summary.py: honest text/JSON summary including a "model limitations" section and a saturation warning flag per stage - report/animate.py: the visualization the user explicitly asked for -- a GIF of the slug flying through the tube with each coil glowing by its instantaneous current, reconstructing the pre-trigger ballistic flight segment (not just the stored discharge phase) for a continuous timeline - cli.py: sweep/evolve/simulate/report subcommands now actually call the underlying modules instead of being stubs - Extended StageResult/StageOutcome with the coil/entry-state fields the report layer needed (mu_eff, turns, coil_length, entry_x/v) rather than recomputing them by other means Verified end-to-end through `docker compose run`, not just pytest: a real sweep (30 runs, 11 feasible) followed by `report --animate` produced a correct GIF/plots/BOM/summary for the actual best result found (32.8% efficiency, 982 RUB) -- not a synthetic fixture. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
113
tests/test_report.py
Normal file
113
tests/test_report.py
Normal file
@@ -0,0 +1,113 @@
|
||||
from gausse.components.database import ComponentDatabase
|
||||
from gausse.report.animate import animate_run
|
||||
from gausse.report.bom import build_bom, render_bom_text, total_cost_rub
|
||||
from gausse.report.plots import (
|
||||
plot_stage_currents,
|
||||
plot_stage_fields,
|
||||
plot_velocity_vs_position,
|
||||
save_all,
|
||||
)
|
||||
from gausse.report.summary import build_summary, render_summary_json, render_summary_text
|
||||
from gausse.sim.coilgun import CoilgunConfig, run_coilgun
|
||||
from gausse.sim.stage import ProjectileConfig, StageConfig
|
||||
|
||||
DB = ComponentDatabase.load()
|
||||
|
||||
|
||||
def _feasible_config() -> CoilgunConfig:
|
||||
wire = next(w for w in DB.wires if w.part_id == "cu-petv2-1.0mm")
|
||||
capacitor = next(c for c in DB.capacitors if c.part_number == "cap-470uf-400v")
|
||||
switch = next(s for s in DB.switches if s.part_number == "IRFP250PBF")
|
||||
sensor = next(s for s in DB.sensors if s.part_number == "TCST2103")
|
||||
steel = next(m for m in DB.projectile_materials if m.name == "Ст3 (конструкционная сталь)")
|
||||
|
||||
projectile = ProjectileConfig(material=steel, diameter_m=0.008, length_m=0.02)
|
||||
stage = StageConfig(
|
||||
wire=wire, capacitor=capacitor, switch=switch, sensor=sensor, tube_od_m=0.01,
|
||||
turns_per_layer=20, layers=4, sensor_to_coil_distance_m=0.02, charge_voltage_v=350.0,
|
||||
)
|
||||
return CoilgunConfig(
|
||||
stages=[stage, stage], inter_stage_gaps_m=[0.05], projectile=projectile,
|
||||
initial_x_m=-0.05, initial_v_mps=5.0,
|
||||
)
|
||||
|
||||
|
||||
def test_feasible_fixture_is_actually_feasible():
|
||||
result = run_coilgun(_feasible_config())
|
||||
assert result.feasible, result.reason
|
||||
|
||||
|
||||
def test_plots_render_without_error():
|
||||
config = _feasible_config()
|
||||
result = run_coilgun(config)
|
||||
assert result.feasible
|
||||
|
||||
fig1 = plot_stage_currents(result)
|
||||
fig2 = plot_stage_fields(result)
|
||||
fig3 = plot_velocity_vs_position(result)
|
||||
for fig in (fig1, fig2, fig3):
|
||||
assert fig is not None
|
||||
|
||||
|
||||
def test_save_all_writes_nonempty_png_files(tmp_path):
|
||||
config = _feasible_config()
|
||||
result = run_coilgun(config)
|
||||
paths = save_all(result, tmp_path)
|
||||
assert len(paths) == 3
|
||||
for p in paths:
|
||||
from pathlib import Path
|
||||
|
||||
assert Path(p).stat().st_size > 0
|
||||
|
||||
|
||||
def test_bom_totals_are_positive_and_match_sum():
|
||||
config = _feasible_config()
|
||||
lines = build_bom(config, DB)
|
||||
assert len(lines) > 0
|
||||
total = total_cost_rub(lines)
|
||||
assert total == sum(line.total_price_rub for line in lines)
|
||||
assert total > 0
|
||||
text = render_bom_text(lines)
|
||||
assert "ИТОГО" in text
|
||||
|
||||
|
||||
def test_summary_reports_feasible_run_honestly():
|
||||
config = _feasible_config()
|
||||
result = run_coilgun(config)
|
||||
summary = build_summary(result, config, DB)
|
||||
assert summary["feasible"] is True
|
||||
assert summary["efficiency"] == result.efficiency
|
||||
assert "model_limitations" in summary
|
||||
assert len(summary["model_limitations"]) > 0
|
||||
|
||||
text = render_summary_text(summary)
|
||||
assert "РЕАЛИЗУЕМО" in text
|
||||
json_text = render_summary_json(summary)
|
||||
assert "efficiency" in json_text
|
||||
|
||||
|
||||
def test_summary_reports_infeasible_run_honestly():
|
||||
import dataclasses
|
||||
|
||||
config = _feasible_config()
|
||||
# индукционный датчик при скорости заведомо ниже порога -> провал ступени 0
|
||||
weak_sensor = next(s for s in DB.sensors if s.kind == "inductive")
|
||||
broken_stage = dataclasses.replace(config.stages[0], sensor=weak_sensor)
|
||||
config = dataclasses.replace(
|
||||
config, stages=[broken_stage, config.stages[1]], initial_v_mps=2.0
|
||||
)
|
||||
result = run_coilgun(config)
|
||||
summary = build_summary(result, config, DB)
|
||||
if not result.feasible:
|
||||
text = render_summary_text(summary)
|
||||
assert "НЕ РЕАЛИЗУЕМО" in text
|
||||
assert summary["reason"] is not None
|
||||
|
||||
|
||||
def test_animate_run_produces_nonempty_gif(tmp_path):
|
||||
config = _feasible_config()
|
||||
result = run_coilgun(config)
|
||||
assert result.feasible
|
||||
out_path = tmp_path / "run.gif"
|
||||
animate_run(result, config, str(out_path), fps=10)
|
||||
assert out_path.stat().st_size > 0
|
||||
Reference in New Issue
Block a user