1. Iron saturation now in the dynamics (was the "13 Tesla" bug). Flux linkage lambda(x,I) = L_air*I + L_iron*overlap(x)*g(I) with g(I)=I_sat*tanh(I/I_sat) saturating at the current where iron reaches B_sat. Both the circuit back-EMF (dlambda/dx) and the force (coenergy, dW'/dx) derive from the SAME lambda, so energy stays conserved (0.025% error) AND the field caps at B_sat instead of running to 13 T. Reduces to the old 0.5*I^2*dL/dx in the low-current limit. On the config that reported 60% efficiency / 11.6 T, it now gives 40% / 1.90 T. 2. Switch surge-current limit is now a hard feasibility constraint: a config whose peak discharge current exceeds the switch's pulse rating (continuous * surge factor per device kind) is infeasible -- otherwise the optimizer "wins" with configs that vaporize their own thyristor (e.g. 119 A through a 12 A BT151). Capacitor current stays a warning (electrolytics tolerate pulses; DB has continuous not pulse ratings). Regression tests added for both (field cap near saturation, energy conservation under strong discharge). 87 tests pass. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
115 lines
4.1 KiB
Python
115 lines
4.1 KiB
Python
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")
|
||
# мощный ключ (180А, surge ~720А) — конфигурация с реальным пиковым током
|
||
switch = next(s for s in DB.switches if s.part_number == "IRFP4468PBF")
|
||
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=200.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
|