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

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