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:
@@ -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
50
tests/test_sensors.py
Normal 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
|
||||
89
tests/test_stage_energy_conservation.py
Normal file
89
tests/test_stage_energy_conservation.py
Normal 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
|
||||
Reference in New Issue
Block a user