Add dual sensor models and single-stage simulator; fix energy-conservation bug

- physics/sensors.py: optical/Hall (velocity-independent) and inductive
  (velocity-scaled, sech^2 spatial sensitivity) trigger events for solve_ivp
- sim/stage.py: flight-to-trigger -> fire delay -> discharge -> energy
  accounting, returning StageResult(feasible=False, reason=...) instead of
  raising when a sensor never fires or discharge never commutates
- Found and fixed a real bug caught by the energy-conservation test: the
  saturation clamp was applied to the mechanical force but not the
  electrical back-EMF term, silently breaking energy balance by ~15%.
  Removed the dynamic clamp (documented as a deferred nonlinear-L(x,I)
  limitation) and kept saturation as a diagnostic-only warning
  (StageResult.saturation_warning) so numbers stay honest rather than
  quietly wrong. Balance error is now ~0.02%.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-06 19:55:26 +05:00
parent 2046dcba10
commit f9b77b3756
8 changed files with 484 additions and 33 deletions

View File

@@ -22,14 +22,13 @@ def test_saturation_scale_clamps_above_bsat():
assert scale == pytest.approx(0.5)
def test_force_is_reduced_once_saturated():
common = dict(dl_dx_unsaturated=1e-3, mu_eff=200, total_turns=300, coil_length_m=0.05, b_sat_tesla=1.8)
force_unsaturated = force_on_slug_newtons(current_a=5, **common)
force_saturated = force_on_slug_newtons(current_a=500, **common)
# без клэмпа сила росла бы как I^2 (в 10000 раз); с насыщением рост должен быть намного меньше
assert force_saturated / force_unsaturated < 5000
def test_force_scales_as_current_squared():
# F = 0.5*I^2*dL/dx: без клэмпа (см. модуль-докстринг force.py про энергобаланс)
# сила должна расти строго как I^2, иначе нарушится точный энергобаланс контура.
f_low = force_on_slug_newtons(current_a=5, dl_dx=1e-3)
f_high = force_on_slug_newtons(current_a=50, dl_dx=1e-3)
assert f_high / f_low == pytest.approx(100.0)
def test_force_zero_at_zero_current():
common = dict(dl_dx_unsaturated=1e-3, mu_eff=200, total_turns=300, coil_length_m=0.05, b_sat_tesla=1.8)
assert force_on_slug_newtons(current_a=0, **common) == pytest.approx(0.0)
assert force_on_slug_newtons(current_a=0, dl_dx=1e-3) == pytest.approx(0.0)

50
tests/test_sensors.py Normal file
View File

@@ -0,0 +1,50 @@
import numpy as np
from scipy.integrate import solve_ivp
from gausse.physics.sensors import make_inductive_sensor_event, make_optical_sensor_event
def _ballistic(t, state):
return [state[1], 0.0]
def test_optical_sensor_trigger_is_velocity_independent():
x_sensor = 0.02
trigger_xs = []
for v in (1.0, 5.0, 20.0):
event = make_optical_sensor_event(x_sensor)
sol = solve_ivp(_ballistic, (0, 1.0), [-0.05, v], events=event)
assert len(sol.t_events[0]) == 1
trigger_xs.append(sol.y_events[0][0][0])
assert np.allclose(trigger_xs, x_sensor, atol=1e-9)
def test_inductive_sensor_fires_earlier_at_higher_velocity():
x_sensor = 0.02
sensitivity = 0.05
threshold = 0.3
width = 0.005
trigger_xs = []
for v in (10.0, 20.0, 40.0):
event = make_inductive_sensor_event(x_sensor, sensitivity, threshold, width)
sol = solve_ivp(_ballistic, (0, 1.0), [-0.05, v], events=event)
assert len(sol.t_events[0]) == 1
trigger_xs.append(sol.y_events[0][0][0])
# чем выше скорость, тем раньше (дальше от датчика, т.е. при меньшем x)
# срабатывает индукционный датчик, т.к. sensitivity*v*bump(x) достигает
# порога при меньшем bump(x), а значит при большем |x - x_sensor|
assert trigger_xs[0] > trigger_xs[1] > trigger_xs[2]
assert all(x < x_sensor for x in trigger_xs)
def test_inductive_sensor_never_fires_below_threshold_speed():
x_sensor = 0.02
sensitivity = 0.05
threshold = 0.3
width = 0.005
# пиковый сигнал = sensitivity * v = 0.05 * 1.0 = 0.05 << порог 0.3
event = make_inductive_sensor_event(x_sensor, sensitivity, threshold, width)
sol = solve_ivp(_ballistic, (0, 1.0), [-0.05, 1.0], events=event)
assert len(sol.t_events[0]) == 0

View File

@@ -0,0 +1,89 @@
import pytest
from gausse.components.schema import (
CapacitorSpec,
ProjectileMaterialSpec,
SensorSpec,
SwitchSpec,
WireSpec,
)
from gausse.sim.stage import ProjectileConfig, StageConfig, run_stage
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=1000.0, voltage_v=400.0, esr_ohm=0.05, max_current_a=300.0,
price=300.0, source="test",
)
OPTICAL_SENSOR = SensorSpec(
part_number="s1", kind="optical", propagation_delay_ns=500.0, price=20.0, source="test"
)
def _wire(resistivity_ohm_m: float = 1.68e-8) -> WireSpec:
return WireSpec(
part_id="w1", material="copper", gauge_mm=0.8, insulation_od_mm=0.85,
resistivity_ohm_m=resistivity_ohm_m, max_current_a=10.0, price_per_m=3.0, source="test",
)
def _switch(on_resistance_ohm: float = 0.02) -> SwitchSpec:
return SwitchSpec(
part_number="sw1", kind="MOSFET", max_current_a=200.0, max_voltage_v=500.0,
on_resistance_ohm=on_resistance_ohm, on_voltage_drop_v=None, turn_on_time_ns=50.0,
price=50.0, source="test",
)
def _run(wire, switch, capacitor=CAPACITOR):
# turns_per_layer выбран так, чтобы длина катушки (~1.7см) была сравнима
# со снарядом (2см) — иначе снаряд, войдя глубоко внутрь длинной катушки,
# оказывается в плоской зоне перекрытия (dL/dx=0) и сила не действует.
stage = StageConfig(
wire=wire,
capacitor=capacitor,
switch=switch,
sensor=OPTICAL_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 run_stage(entry_x_m=-0.05, entry_v_mps=5.0, stage=stage, projectile=PROJECTILE)
def test_stage_is_feasible_with_realistic_components():
result = _run(_wire(), _switch())
assert result.feasible, result.reason
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,
max_current_a=300.0, price=300.0, source="test",
)
result = _run(
_wire(resistivity_ohm_m=0.0), _switch(on_resistance_ohm=0.0), capacitor=lossless_capacitor
)
assert result.feasible, result.reason
assert result.energy_dissipated_j == pytest.approx(0.0, abs=1e-9)
balance = result.energy_remaining_cap_j + result.kinetic_energy_delta_j
assert balance == pytest.approx(result.energy_in_j, rel=1e-4)
def test_energy_balance_holds_with_realistic_losses():
result = _run(_wire(), _switch())
assert result.feasible, result.reason
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)
def test_kinetic_energy_increases_for_approaching_slug():
result = _run(_wire(), _switch())
assert result.feasible, result.reason
assert result.kinetic_energy_delta_j > 0