Chain stages into a multi-stage coilgun; document an over-pull failure mode
- sim/coilgun.py: chains StageConfig results through a global coordinate, stops honestly on the first infeasible stage (with which stage and why), and computes overall efficiency = exit KE / total capacitor energy in - Discovered while wiring up the chain: a high-capacitance single-stage test configuration let current stay high past the coil center, decelerating and reversing the slug (exit velocity negative). This is a real coilgun tuning failure mode (not a bug) since KE ~ v^2 doesn't care about direction and the energy balance still holds to ~1%. Kept it as an explicit regression test (test_overpowered_discharge_can_fling_slug_backward) instead of quietly picking parameters that hide it, and tightened the "kinetic energy increases" test to also require forward (exit_v > 0) motion. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
119
src/gausse/sim/coilgun.py
Normal file
119
src/gausse/sim/coilgun.py
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
"""Цепочка ступеней: сквозная координата, честная отбраковка нереализуемых конфигураций.
|
||||||
|
|
||||||
|
Каждая ступень моделируется в своей ЛОКАЛЬНОЙ системе координат (центр её
|
||||||
|
разгонной катушки — x=0, см. `sim/stage.py`). Переход между ступенями:
|
||||||
|
центр катушки следующей ступени находится на `inter_stage_gaps_m[i]` дальше
|
||||||
|
центра катушки текущей, поэтому положение снаряда на выходе текущей ступени
|
||||||
|
пересчитывается в локальную координату следующей простым сдвигом.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from gausse.sim.stage import ProjectileConfig, StageConfig, StageResult, run_stage
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CoilgunConfig:
|
||||||
|
stages: list[StageConfig]
|
||||||
|
inter_stage_gaps_m: list[float] # длина = len(stages) - 1
|
||||||
|
projectile: ProjectileConfig
|
||||||
|
initial_x_m: float
|
||||||
|
initial_v_mps: float
|
||||||
|
|
||||||
|
def __post_init__(self):
|
||||||
|
if len(self.inter_stage_gaps_m) != len(self.stages) - 1:
|
||||||
|
raise ValueError(
|
||||||
|
"inter_stage_gaps_m должен содержать ровно len(stages)-1 значений "
|
||||||
|
f"(получено {len(self.inter_stage_gaps_m)} для {len(self.stages)} ступеней)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StageOutcome:
|
||||||
|
stage_index: int
|
||||||
|
result: StageResult
|
||||||
|
global_coil_center_m: float
|
||||||
|
time_offset_s: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class CoilgunResult:
|
||||||
|
feasible: bool
|
||||||
|
reason: str | None = None
|
||||||
|
failed_stage_index: int | None = None
|
||||||
|
stage_outcomes: list[StageOutcome] = field(default_factory=list)
|
||||||
|
exit_v_mps: float | None = None
|
||||||
|
exit_kinetic_energy_j: float | None = None
|
||||||
|
total_energy_in_j: float = 0.0
|
||||||
|
total_energy_dissipated_j: float = 0.0
|
||||||
|
total_kinetic_energy_delta_j: float = 0.0
|
||||||
|
efficiency: float | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_duration_s(result: StageResult) -> float:
|
||||||
|
discharge_duration = 0.0
|
||||||
|
if result.discharge_t is not None and len(result.discharge_t) > 0:
|
||||||
|
discharge_duration = float(result.discharge_t[-1])
|
||||||
|
return (result.t_fire_s or 0.0) + discharge_duration
|
||||||
|
|
||||||
|
|
||||||
|
def run_coilgun(config: CoilgunConfig) -> CoilgunResult:
|
||||||
|
global_coil_center_m = 0.0
|
||||||
|
time_offset_s = 0.0
|
||||||
|
entry_x_m = config.initial_x_m
|
||||||
|
entry_v_mps = config.initial_v_mps
|
||||||
|
|
||||||
|
stage_outcomes: list[StageOutcome] = []
|
||||||
|
total_energy_in_j = 0.0
|
||||||
|
total_energy_dissipated_j = 0.0
|
||||||
|
total_kinetic_energy_delta_j = 0.0
|
||||||
|
|
||||||
|
for i, stage in enumerate(config.stages):
|
||||||
|
result = run_stage(entry_x_m, entry_v_mps, stage, config.projectile)
|
||||||
|
stage_outcomes.append(
|
||||||
|
StageOutcome(
|
||||||
|
stage_index=i,
|
||||||
|
result=result,
|
||||||
|
global_coil_center_m=global_coil_center_m,
|
||||||
|
time_offset_s=time_offset_s,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if not result.feasible:
|
||||||
|
return CoilgunResult(
|
||||||
|
feasible=False,
|
||||||
|
reason=f"ступень {i}: {result.reason}",
|
||||||
|
failed_stage_index=i,
|
||||||
|
stage_outcomes=stage_outcomes,
|
||||||
|
total_energy_in_j=total_energy_in_j,
|
||||||
|
total_energy_dissipated_j=total_energy_dissipated_j,
|
||||||
|
total_kinetic_energy_delta_j=total_kinetic_energy_delta_j,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_energy_in_j += result.energy_in_j
|
||||||
|
total_energy_dissipated_j += result.energy_dissipated_j
|
||||||
|
total_kinetic_energy_delta_j += result.kinetic_energy_delta_j
|
||||||
|
time_offset_s += _stage_duration_s(result)
|
||||||
|
|
||||||
|
is_last_stage = i == len(config.stages) - 1
|
||||||
|
if not is_last_stage:
|
||||||
|
gap = config.inter_stage_gaps_m[i]
|
||||||
|
entry_x_m = result.exit_x_m - gap
|
||||||
|
entry_v_mps = result.exit_v_mps
|
||||||
|
global_coil_center_m += gap
|
||||||
|
else:
|
||||||
|
entry_x_m, entry_v_mps = result.exit_x_m, result.exit_v_mps
|
||||||
|
|
||||||
|
exit_kinetic_energy_j = 0.5 * config.projectile.mass_kg * entry_v_mps**2
|
||||||
|
efficiency = exit_kinetic_energy_j / total_energy_in_j if total_energy_in_j > 0 else None
|
||||||
|
|
||||||
|
return CoilgunResult(
|
||||||
|
feasible=True,
|
||||||
|
stage_outcomes=stage_outcomes,
|
||||||
|
exit_v_mps=entry_v_mps,
|
||||||
|
exit_kinetic_energy_j=exit_kinetic_energy_j,
|
||||||
|
total_energy_in_j=total_energy_in_j,
|
||||||
|
total_energy_dissipated_j=total_energy_dissipated_j,
|
||||||
|
total_kinetic_energy_delta_j=total_kinetic_energy_delta_j,
|
||||||
|
efficiency=efficiency,
|
||||||
|
)
|
||||||
107
tests/test_coilgun_chain.py
Normal file
107
tests/test_coilgun_chain.py
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from gausse.components.schema import (
|
||||||
|
CapacitorSpec,
|
||||||
|
ProjectileMaterialSpec,
|
||||||
|
SensorSpec,
|
||||||
|
SwitchSpec,
|
||||||
|
WireSpec,
|
||||||
|
)
|
||||||
|
from gausse.sim.coilgun import CoilgunConfig, run_coilgun
|
||||||
|
from gausse.sim.stage import ProjectileConfig, StageConfig
|
||||||
|
|
||||||
|
STEEL = ProjectileMaterialSpec(
|
||||||
|
name="steel", density_kg_m3=7850.0, mu_r=200.0, b_sat_tesla=1.8, price_per_kg=100.0, source="test"
|
||||||
|
)
|
||||||
|
PROJECTILE = ProjectileConfig(material=STEEL, diameter_m=0.008, length_m=0.02)
|
||||||
|
CAPACITOR = CapacitorSpec(
|
||||||
|
part_number="c1", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.05, max_current_a=300.0,
|
||||||
|
price=300.0, source="test",
|
||||||
|
)
|
||||||
|
WIRE = WireSpec(
|
||||||
|
part_id="w1", material="copper", gauge_mm=0.8, insulation_od_mm=0.85,
|
||||||
|
resistivity_ohm_m=1.68e-8, max_current_a=10.0, price_per_m=3.0, source="test",
|
||||||
|
)
|
||||||
|
SWITCH = SwitchSpec(
|
||||||
|
part_number="sw1", kind="MOSFET", max_current_a=200.0, max_voltage_v=500.0,
|
||||||
|
on_resistance_ohm=0.02, on_voltage_drop_v=None, turn_on_time_ns=50.0, price=50.0, source="test",
|
||||||
|
)
|
||||||
|
OPTICAL_SENSOR = SensorSpec(
|
||||||
|
part_number="s1", kind="optical", propagation_delay_ns=500.0, price=20.0, source="test"
|
||||||
|
)
|
||||||
|
WEAK_INDUCTIVE_SENSOR = SensorSpec(
|
||||||
|
part_number="s2", kind="inductive", propagation_delay_ns=0.0, price=5.0, source="test",
|
||||||
|
sensitivity_v_per_mps=0.001, threshold_v=1.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _stage(sensor=OPTICAL_SENSOR, sensor_to_coil_distance_m=0.02):
|
||||||
|
return 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=sensor_to_coil_distance_m,
|
||||||
|
charge_voltage_v=350.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_stage_chain_is_feasible_and_accelerates():
|
||||||
|
config = CoilgunConfig(
|
||||||
|
stages=[_stage(), _stage(), _stage()],
|
||||||
|
inter_stage_gaps_m=[0.05, 0.05],
|
||||||
|
projectile=PROJECTILE,
|
||||||
|
initial_x_m=-0.05,
|
||||||
|
initial_v_mps=5.0,
|
||||||
|
)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
assert result.feasible, result.reason
|
||||||
|
assert len(result.stage_outcomes) == 3
|
||||||
|
assert result.exit_v_mps > 5.0
|
||||||
|
assert 0.0 < result.efficiency < 1.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_global_coil_centers_accumulate_gaps():
|
||||||
|
config = CoilgunConfig(
|
||||||
|
stages=[_stage(), _stage(), _stage()],
|
||||||
|
inter_stage_gaps_m=[0.05, 0.07],
|
||||||
|
projectile=PROJECTILE,
|
||||||
|
initial_x_m=-0.05,
|
||||||
|
initial_v_mps=5.0,
|
||||||
|
)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
assert result.feasible, result.reason
|
||||||
|
centers = [o.global_coil_center_m for o in result.stage_outcomes]
|
||||||
|
assert centers == pytest.approx([0.0, 0.05, 0.12])
|
||||||
|
|
||||||
|
|
||||||
|
def test_infeasible_stage_stops_chain_with_honest_reason():
|
||||||
|
# у слабого индукционного датчика порог 1В при чувствительности 0.001В/(м/с)
|
||||||
|
# требует скорости 1000 м/с, которой у снаряда не будет -> ступень 2 обязана провалиться
|
||||||
|
config = CoilgunConfig(
|
||||||
|
stages=[_stage(), _stage(sensor=WEAK_INDUCTIVE_SENSOR), _stage()],
|
||||||
|
inter_stage_gaps_m=[0.05, 0.05],
|
||||||
|
projectile=PROJECTILE,
|
||||||
|
initial_x_m=-0.05,
|
||||||
|
initial_v_mps=5.0,
|
||||||
|
)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
assert not result.feasible
|
||||||
|
assert result.failed_stage_index == 1
|
||||||
|
assert "индукционный датчик" in result.reason
|
||||||
|
# ступень 2 (индекс 2) не должна была даже запуститься
|
||||||
|
assert len(result.stage_outcomes) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_mismatched_gap_count_raises():
|
||||||
|
with pytest.raises(ValueError):
|
||||||
|
CoilgunConfig(
|
||||||
|
stages=[_stage(), _stage()],
|
||||||
|
inter_stage_gaps_m=[0.05, 0.05],
|
||||||
|
projectile=PROJECTILE,
|
||||||
|
initial_x_m=-0.05,
|
||||||
|
initial_v_mps=5.0,
|
||||||
|
)
|
||||||
@@ -14,7 +14,7 @@ STEEL = ProjectileMaterialSpec(
|
|||||||
)
|
)
|
||||||
PROJECTILE = ProjectileConfig(material=STEEL, diameter_m=0.008, length_m=0.02)
|
PROJECTILE = ProjectileConfig(material=STEEL, diameter_m=0.008, length_m=0.02)
|
||||||
CAPACITOR = CapacitorSpec(
|
CAPACITOR = CapacitorSpec(
|
||||||
part_number="c1", capacitance_uf=1000.0, voltage_v=400.0, esr_ohm=0.05, max_current_a=300.0,
|
part_number="c1", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.05, max_current_a=300.0,
|
||||||
price=300.0, source="test",
|
price=300.0, source="test",
|
||||||
)
|
)
|
||||||
OPTICAL_SENSOR = SensorSpec(
|
OPTICAL_SENSOR = SensorSpec(
|
||||||
@@ -62,7 +62,7 @@ def test_stage_is_feasible_with_realistic_components():
|
|||||||
|
|
||||||
def test_energy_conserved_exactly_when_lossless():
|
def test_energy_conserved_exactly_when_lossless():
|
||||||
lossless_capacitor = CapacitorSpec(
|
lossless_capacitor = CapacitorSpec(
|
||||||
part_number="c-lossless", capacitance_uf=1000.0, voltage_v=400.0, esr_ohm=0.0,
|
part_number="c-lossless", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.0,
|
||||||
max_current_a=300.0, price=300.0, source="test",
|
max_current_a=300.0, price=300.0, source="test",
|
||||||
)
|
)
|
||||||
result = _run(
|
result = _run(
|
||||||
@@ -87,3 +87,29 @@ def test_kinetic_energy_increases_for_approaching_slug():
|
|||||||
result = _run(_wire(), _switch())
|
result = _run(_wire(), _switch())
|
||||||
assert result.feasible, result.reason
|
assert result.feasible, result.reason
|
||||||
assert result.kinetic_energy_delta_j > 0
|
assert result.kinetic_energy_delta_j > 0
|
||||||
|
# рост кинетической энергии тут должен означать разгон ВПЕРЁД, а не
|
||||||
|
# отброс назад (см. test_overpowered_discharge_can_fling_slug_backward)
|
||||||
|
assert result.exit_v_mps > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_overpowered_discharge_can_fling_slug_backward():
|
||||||
|
"""Честная фиксация реального режима отказа, а не только "успешных" случаев.
|
||||||
|
|
||||||
|
Слишком большая ёмкость (медленный разряд относительно скорости снаряда)
|
||||||
|
держит ток высоким уже после того, как снаряд проходит центр катушки —
|
||||||
|
зона x>0 тянет назад (dL/dx<0), и снаряд может выйти с отрицательной
|
||||||
|
скоростью. Энергобаланс при этом всё ещё точен (КЭ ~ v^2 не зависит от
|
||||||
|
знака) — это подтверждает, что находка отражает реальную физику плохо
|
||||||
|
настроенного расстояния/ёмкости, а не ошибку в уравнениях.
|
||||||
|
"""
|
||||||
|
overpowered_capacitor = CapacitorSpec(
|
||||||
|
part_number="c-big", capacitance_uf=1000.0, voltage_v=400.0, esr_ohm=0.05,
|
||||||
|
max_current_a=300.0, price=300.0, source="test",
|
||||||
|
)
|
||||||
|
result = _run(_wire(), _switch(), capacitor=overpowered_capacitor)
|
||||||
|
assert result.feasible, result.reason
|
||||||
|
assert result.exit_v_mps < 0
|
||||||
|
balance = (
|
||||||
|
result.energy_remaining_cap_j + result.energy_dissipated_j + result.kinetic_energy_delta_j
|
||||||
|
)
|
||||||
|
assert balance == pytest.approx(result.energy_in_j, rel=1e-2)
|
||||||
|
|||||||
Reference in New Issue
Block a user