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:
jze9
2026-07-06 20:50:32 +05:00
parent d73341d3a2
commit 65d8b633ad
10 changed files with 688 additions and 8 deletions

55
tests/test_cli_smoke.py Normal file
View File

@@ -0,0 +1,55 @@
from pathlib import Path
from gausse.cli import main
from gausse.storage.database import fetch_runs, open_connection
def test_cli_sweep_writes_database(tmp_path):
db_path = tmp_path / "runs.sqlite3"
rc = main(["sweep", "--db", str(db_path), "--n", "10", "--workers", "2", "--seed", "1", "--max-stages", "2"])
assert rc == 0
assert db_path.exists()
conn = open_connection(db_path)
assert len(fetch_runs(conn)) == 10
conn.close()
def test_cli_sweep_then_report_end_to_end(tmp_path):
db_path = tmp_path / "runs.sqlite3"
out_dir = tmp_path / "report"
rc = main(["sweep", "--db", str(db_path), "--n", "40", "--workers", "2", "--seed", "2"])
assert rc == 0
rc2 = main(["report", "--db", str(db_path), "--out", str(out_dir), "--top", "1"])
assert rc2 == 0
rank_dirs = list(out_dir.glob("rank1_*"))
assert len(rank_dirs) == 1
run_dir = rank_dirs[0]
assert (run_dir / "summary.txt").exists()
assert (run_dir / "summary.json").exists()
assert (run_dir / "bom.txt").exists()
assert (run_dir / "current.png").exists()
assert (run_dir / "velocity_vs_position.png").exists()
def test_cli_simulate_single_run_id(tmp_path):
db_path = tmp_path / "runs.sqlite3"
out_dir = tmp_path / "sim_out"
main(["sweep", "--db", str(db_path), "--n", "5", "--workers", "1", "--seed", "3", "--max-stages", "2"])
conn = open_connection(db_path)
row = fetch_runs(conn)[0]
conn.close()
rc = main(["simulate", "--db", str(db_path), "--run-id", row.run_id, "--out", str(out_dir)])
assert rc == 0
assert (out_dir / "summary.txt").exists()
def test_cli_report_with_empty_database_reports_error_not_crash(tmp_path):
db_path = tmp_path / "empty.sqlite3"
from gausse.storage.database import open_connection as oc
oc(db_path).close() # создаёт пустую (но валидную) базу
rc = main(["report", "--db", str(db_path), "--out", str(tmp_path / "out")])
assert rc == 1