From f8f6eaa765042a98952dec3e373c89e7c3246038 Mon Sep 17 00:00:00 2001 From: jze9 Date: Mon, 6 Jul 2026 20:02:05 +0500 Subject: [PATCH] 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 --- src/gausse/sim/coilgun.py | 119 ++++++++++++++++++++++++ tests/test_coilgun_chain.py | 107 +++++++++++++++++++++ tests/test_stage_energy_conservation.py | 30 +++++- 3 files changed, 254 insertions(+), 2 deletions(-) create mode 100644 src/gausse/sim/coilgun.py create mode 100644 tests/test_coilgun_chain.py diff --git a/src/gausse/sim/coilgun.py b/src/gausse/sim/coilgun.py new file mode 100644 index 0000000..0b0b34a --- /dev/null +++ b/src/gausse/sim/coilgun.py @@ -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, + ) diff --git a/tests/test_coilgun_chain.py b/tests/test_coilgun_chain.py new file mode 100644 index 0000000..ac719ef --- /dev/null +++ b/tests/test_coilgun_chain.py @@ -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, + ) diff --git a/tests/test_stage_energy_conservation.py b/tests/test_stage_energy_conservation.py index 86bd43b..5b7137c 100644 --- a/tests/test_stage_energy_conservation.py +++ b/tests/test_stage_energy_conservation.py @@ -14,7 +14,7 @@ STEEL = ProjectileMaterialSpec( ) PROJECTILE = ProjectileConfig(material=STEEL, diameter_m=0.008, length_m=0.02) 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", ) OPTICAL_SENSOR = SensorSpec( @@ -62,7 +62,7 @@ def test_stage_is_feasible_with_realistic_components(): def test_energy_conserved_exactly_when_lossless(): 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", ) result = _run( @@ -87,3 +87,29 @@ def test_kinetic_energy_increases_for_approaching_slug(): result = _run(_wire(), _switch()) assert result.feasible, result.reason 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)