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:
13
PLAN.md
13
PLAN.md
@@ -18,6 +18,19 @@ ChipDip, Cable.ru и др.).
|
|||||||
в SQLite. Никаких приукрашенных цифр — если модель показывает низкий КПД или
|
в SQLite. Никаких приукрашенных цифр — если модель показывает низкий КПД или
|
||||||
нереализуемость, это тоже результат.
|
нереализуемость, это тоже результат.
|
||||||
|
|
||||||
|
**Найденная и исправленная ошибка (Этап 3):** насыщение сердечника изначально
|
||||||
|
клэмпилось только в механическом уравнении (F=0.5·I²·dL/dx), но не в
|
||||||
|
электрическом (наведённая ЭДС всё ещё считалась по полной dL/dx) — это
|
||||||
|
незаметно ломало точный энергобаланс на ~15%. Тест на сохранение энергии
|
||||||
|
(этого же честного протокола, который просил пользователь) это поймал.
|
||||||
|
Решение: клэмп насыщения убран из динамики (F=0.5·I²·dL/dx без клэмпа,
|
||||||
|
энергобаланс теперь точен до ~0.02%), а `solenoid_field_estimate_tesla`/
|
||||||
|
`saturation_scale` оставлены как ДИАГНОСТИКА — `StageResult.saturation_warning`
|
||||||
|
честно предупреждает, когда конфигурация физически выходит за пределы
|
||||||
|
насыщения материала снаряда, не подменяя динамику. Полная нелинейная
|
||||||
|
L(x, I)-модель с coenergy-выводом силы — в разделе "ограничения модели"
|
||||||
|
как будущая работа, не как текущая гарантия точности.
|
||||||
|
|
||||||
Полный план архитектуры: см. историю обсуждения / `physics`, `sim`, `optim`,
|
Полный план архитектуры: см. историю обсуждения / `physics`, `sim`, `optim`,
|
||||||
`storage`, `report` модули ниже.
|
`storage`, `report` модули ниже.
|
||||||
|
|
||||||
|
|||||||
@@ -46,14 +46,7 @@ def derivatives(
|
|||||||
v_c = q / params.capacitance_f
|
v_c = q / params.capacitance_f
|
||||||
d_q = -current
|
d_q = -current
|
||||||
d_i = (v_c - current * params.r_total_ohm - current * dl_dx * v) / l_x
|
d_i = (v_c - current * params.r_total_ohm - current * dl_dx * v) / l_x
|
||||||
force = force_on_slug_newtons(
|
force = force_on_slug_newtons(current_a=current, dl_dx=dl_dx)
|
||||||
current_a=current,
|
|
||||||
dl_dx_unsaturated=dl_dx,
|
|
||||||
mu_eff=params.mu_eff,
|
|
||||||
total_turns=params.total_turns,
|
|
||||||
coil_length_m=params.coil_length_m,
|
|
||||||
b_sat_tesla=params.b_sat_tesla,
|
|
||||||
)
|
|
||||||
d_v = force / params.mass_kg
|
d_v = force / params.mass_kg
|
||||||
return [d_q, d_i, v, d_v]
|
return [d_q, d_i, v, d_v]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
"""Сила, действующая на ферромагнитный снаряд: F = 0.5*I^2*dL/dx с насыщением.
|
"""Сила, действующая на ферромагнитный снаряд: F = 0.5*I^2*dL/dx.
|
||||||
|
|
||||||
Насыщение — грубая, явно приближённая поправка (v1): оцениваем поле внутри
|
Насыщение сердечника сюда сознательно НЕ встроено как динамический клэмп:
|
||||||
катушки простой соленоидной формулой и, если оно превышает B_sat материала
|
ранняя версия ослабляла силу пропорционально превышению B_sat, но клэмп
|
||||||
снаряда, пропорционально ослабляем вклад сердечника в dL/dx. Реальное
|
применялся только к механическому уравнению, а не к электрическому
|
||||||
насыщение нелинейно и зависит от геометрии — здесь используется линейный
|
(наведённая ЭДС по-прежнему считалась через полную dL/dx) — это ломало
|
||||||
клэмп как консервативная эвристика, а не точная кривая намагничивания.
|
точный энергобаланс (dE/dt = -I^2*R), проверяемый в тестах. Модель
|
||||||
|
насыщения, консистентная и для силы, и для ЭДС одновременно, требует
|
||||||
|
нелинейной L(x, I) с явной coenergy-производной, что отложено (см. PLAN.md,
|
||||||
|
"ограничения модели"). Здесь `solenoid_field_estimate_tesla`/`saturation_scale`
|
||||||
|
оставлены как ДИАГНОСТИЧЕСКИЕ функции — предупреждать, что конфигурация,
|
||||||
|
вероятно, входит в насыщение, не искажая динамику.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from gausse.physics.constants import MU_0
|
from gausse.physics.constants import MU_0
|
||||||
@@ -23,14 +28,5 @@ def saturation_scale(b_estimate_tesla: float, b_sat_tesla: float) -> float:
|
|||||||
return min(1.0, b_sat_tesla / b_estimate_tesla)
|
return min(1.0, b_sat_tesla / b_estimate_tesla)
|
||||||
|
|
||||||
|
|
||||||
def force_on_slug_newtons(
|
def force_on_slug_newtons(current_a: float, dl_dx: float) -> float:
|
||||||
current_a: float,
|
return 0.5 * current_a**2 * dl_dx
|
||||||
dl_dx_unsaturated: float,
|
|
||||||
mu_eff: float,
|
|
||||||
total_turns: int,
|
|
||||||
coil_length_m: float,
|
|
||||||
b_sat_tesla: float,
|
|
||||||
) -> float:
|
|
||||||
b_estimate = solenoid_field_estimate_tesla(mu_eff, total_turns, coil_length_m, current_a)
|
|
||||||
scale = saturation_scale(b_estimate, b_sat_tesla)
|
|
||||||
return 0.5 * current_a**2 * dl_dx_unsaturated * scale
|
|
||||||
|
|||||||
56
src/gausse/physics/sensors.py
Normal file
56
src/gausse/physics/sensors.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Событийные модели датчика прохода снаряда для solve_ivp.
|
||||||
|
|
||||||
|
Два конкурирующих варианта датчика, оба выражены как условие пересечения
|
||||||
|
нуля во время баллистического полёта [x, v] к катушке:
|
||||||
|
|
||||||
|
- Оптический/Холла: срабатывает строго по положению x, независимо от
|
||||||
|
скорости снаряда — чистый цифровой фронт.
|
||||||
|
- Индукционный (виток провода): наведённое напряжение приближённо
|
||||||
|
пропорционально скорости и "колокольной" чувствительности катушки к
|
||||||
|
положению снаряда (sech^2-профиль). На низкой скорости пик напряжения
|
||||||
|
может не достичь порога — событие тогда не срабатывает вовсе, и это
|
||||||
|
честно отражается в результате как feasible=False, а не подгоняется.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
SensorEvent = Callable[[float, np.ndarray], float]
|
||||||
|
|
||||||
|
|
||||||
|
def make_optical_sensor_event(x_sensor_m: float) -> SensorEvent:
|
||||||
|
def event(t: float, state: np.ndarray) -> float:
|
||||||
|
return state[0] - x_sensor_m
|
||||||
|
|
||||||
|
event.terminal = True
|
||||||
|
event.direction = 1.0
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def make_inductive_sensor_event(
|
||||||
|
x_sensor_m: float,
|
||||||
|
sensitivity_v_per_mps: float,
|
||||||
|
threshold_v: float,
|
||||||
|
width_m: float,
|
||||||
|
) -> SensorEvent:
|
||||||
|
def event(t: float, state: np.ndarray) -> float:
|
||||||
|
x, v = state
|
||||||
|
bump = 1.0 / np.cosh((x - x_sensor_m) / width_m) ** 2
|
||||||
|
return sensitivity_v_per_mps * v * bump - threshold_v
|
||||||
|
|
||||||
|
event.terminal = True
|
||||||
|
event.direction = 1.0
|
||||||
|
return event
|
||||||
|
|
||||||
|
|
||||||
|
def inductive_signal_peak_v(
|
||||||
|
sensitivity_v_per_mps: float, velocity_mps: float
|
||||||
|
) -> float:
|
||||||
|
"""Пиковое напряжение датчика при данной скорости (bump=1, снаряд точно у датчика).
|
||||||
|
|
||||||
|
Полезно, чтобы заранее и дёшево (без интегрирования) отбраковать
|
||||||
|
заведомо нереализуемые конфигурации: если даже пиковое напряжение
|
||||||
|
ниже порога, датчик не сработает ни при каком положении.
|
||||||
|
"""
|
||||||
|
return sensitivity_v_per_mps * abs(velocity_mps)
|
||||||
255
src/gausse/sim/stage.py
Normal file
255
src/gausse/sim/stage.py
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
"""Одна ступень: баллистический подлёт к датчику -> задержка -> разряд -> энергобаланс.
|
||||||
|
|
||||||
|
Нереализуемые случаи (датчик не сработал, разряд не скоммутировался) не
|
||||||
|
бросают исключение — возвращаются как `StageResult(feasible=False, reason=...)`
|
||||||
|
с честной причиной, чтобы вызывающий код (в т.ч. оптимизатор) мог их
|
||||||
|
учитывать, а не терять.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.integrate import solve_ivp
|
||||||
|
|
||||||
|
from gausse.components.schema import (
|
||||||
|
CapacitorSpec,
|
||||||
|
ProjectileMaterialSpec,
|
||||||
|
SensorSpec,
|
||||||
|
SwitchSpec,
|
||||||
|
WireSpec,
|
||||||
|
)
|
||||||
|
from gausse.physics import circuit
|
||||||
|
from gausse.physics.circuit import StageCircuitParams
|
||||||
|
from gausse.physics.inductance import (
|
||||||
|
CoilInductanceModel,
|
||||||
|
air_core_inductance_wheeler,
|
||||||
|
demagnetizing_factor_prolate,
|
||||||
|
effective_permeability,
|
||||||
|
winding_geometry,
|
||||||
|
)
|
||||||
|
from gausse.physics.force import saturation_scale, solenoid_field_estimate_tesla
|
||||||
|
from gausse.physics.sensors import (
|
||||||
|
inductive_signal_peak_v,
|
||||||
|
make_inductive_sensor_event,
|
||||||
|
make_optical_sensor_event,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ProjectileConfig:
|
||||||
|
material: ProjectileMaterialSpec
|
||||||
|
diameter_m: float
|
||||||
|
length_m: float
|
||||||
|
|
||||||
|
@property
|
||||||
|
def mass_kg(self) -> float:
|
||||||
|
radius_m = self.diameter_m / 2
|
||||||
|
volume_m3 = math.pi * radius_m**2 * self.length_m
|
||||||
|
return volume_m3 * self.material.density_kg_m3
|
||||||
|
|
||||||
|
@property
|
||||||
|
def aspect_ratio(self) -> float:
|
||||||
|
return self.length_m / self.diameter_m
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StageConfig:
|
||||||
|
wire: WireSpec
|
||||||
|
capacitor: CapacitorSpec
|
||||||
|
switch: SwitchSpec
|
||||||
|
sensor: SensorSpec
|
||||||
|
tube_od_m: float
|
||||||
|
turns_per_layer: int
|
||||||
|
layers: int
|
||||||
|
sensor_to_coil_distance_m: float
|
||||||
|
charge_voltage_v: float
|
||||||
|
inductive_sensor_width_m: float = 0.005
|
||||||
|
sensor_time_budget_s: float = 0.05
|
||||||
|
discharge_time_budget_s: float = 0.02
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StageResult:
|
||||||
|
feasible: bool
|
||||||
|
reason: str | None = None
|
||||||
|
t_sensor_s: float | None = None
|
||||||
|
t_fire_s: float | None = None
|
||||||
|
exit_x_m: float | None = None
|
||||||
|
exit_v_mps: float | None = None
|
||||||
|
energy_in_j: float | None = None
|
||||||
|
energy_dissipated_j: float | None = None
|
||||||
|
energy_remaining_cap_j: float | None = None
|
||||||
|
kinetic_energy_delta_j: float | None = None
|
||||||
|
saturation_warning: bool = False
|
||||||
|
peak_field_estimate_tesla: float | None = None
|
||||||
|
discharge_t: np.ndarray | None = None
|
||||||
|
discharge_q: np.ndarray | None = None
|
||||||
|
discharge_i: np.ndarray | None = None
|
||||||
|
discharge_x: np.ndarray | None = None
|
||||||
|
discharge_v: np.ndarray | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_resistance_ohm(wire: WireSpec, wire_length_m: float) -> float:
|
||||||
|
bare_radius_m = wire.gauge_mm / 1000 / 2
|
||||||
|
area_m2 = math.pi * bare_radius_m**2
|
||||||
|
return wire.resistivity_ohm_m * wire_length_m / area_m2
|
||||||
|
|
||||||
|
|
||||||
|
def _switch_equivalent_resistance_ohm(
|
||||||
|
switch: SwitchSpec, capacitance_f: float, inductance_h: float, voltage_v: float
|
||||||
|
) -> float:
|
||||||
|
if switch.on_resistance_ohm is not None:
|
||||||
|
return switch.on_resistance_ohm
|
||||||
|
# SCR/тиристор: прямое падение аппроксимируется эквивалентным
|
||||||
|
# сопротивлением относительно характерного масштаба тока контура
|
||||||
|
# I_ref = V0*sqrt(C/L) (пиковый ток недодемпфированного LC-разряда).
|
||||||
|
i_ref = voltage_v * math.sqrt(capacitance_f / inductance_h)
|
||||||
|
drop_v = switch.on_voltage_drop_v or 0.0
|
||||||
|
return drop_v / max(i_ref, 1e-6)
|
||||||
|
|
||||||
|
|
||||||
|
def _ballistic_derivatives(t: float, state: np.ndarray) -> list[float]:
|
||||||
|
return [state[1], 0.0]
|
||||||
|
|
||||||
|
|
||||||
|
def run_stage(
|
||||||
|
entry_x_m: float,
|
||||||
|
entry_v_mps: float,
|
||||||
|
stage: StageConfig,
|
||||||
|
projectile: ProjectileConfig,
|
||||||
|
) -> StageResult:
|
||||||
|
mass_kg = projectile.mass_kg
|
||||||
|
wire_od_m = stage.wire.insulation_od_mm / 1000
|
||||||
|
geometry = winding_geometry(stage.tube_od_m, wire_od_m, stage.turns_per_layer, stage.layers)
|
||||||
|
|
||||||
|
r_wire = _wire_resistance_ohm(stage.wire, geometry.total_wire_length_m)
|
||||||
|
capacitance_f = stage.capacitor.capacitance_uf * 1e-6
|
||||||
|
l_air_h = air_core_inductance_wheeler(
|
||||||
|
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
|
||||||
|
)
|
||||||
|
r_switch = _switch_equivalent_resistance_ohm(
|
||||||
|
stage.switch, capacitance_f, l_air_h, stage.charge_voltage_v
|
||||||
|
)
|
||||||
|
r_total_ohm = r_wire + r_switch + stage.capacitor.esr_ohm
|
||||||
|
|
||||||
|
demag = demagnetizing_factor_prolate(projectile.aspect_ratio)
|
||||||
|
mu_eff = effective_permeability(projectile.material.mu_r, demag)
|
||||||
|
inductance_model = CoilInductanceModel(
|
||||||
|
l_air_h=l_air_h,
|
||||||
|
coil_length_m=geometry.coil_length_m,
|
||||||
|
slug_length_m=projectile.length_m,
|
||||||
|
mu_eff=mu_eff,
|
||||||
|
smoothing_width_m=wire_od_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
x_sensor_m = -stage.sensor_to_coil_distance_m
|
||||||
|
|
||||||
|
if stage.sensor.kind == "inductive":
|
||||||
|
peak_v = inductive_signal_peak_v(stage.sensor.sensitivity_v_per_mps, entry_v_mps)
|
||||||
|
if peak_v < stage.sensor.threshold_v:
|
||||||
|
return StageResult(
|
||||||
|
feasible=False,
|
||||||
|
reason=(
|
||||||
|
f"индукционный датчик: пиковый сигнал {peak_v:.4f}В "
|
||||||
|
f"ниже порога {stage.sensor.threshold_v:.4f}В "
|
||||||
|
f"при скорости {entry_v_mps:.2f} м/с"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
sensor_event = make_inductive_sensor_event(
|
||||||
|
x_sensor_m,
|
||||||
|
stage.sensor.sensitivity_v_per_mps,
|
||||||
|
stage.sensor.threshold_v,
|
||||||
|
stage.inductive_sensor_width_m,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sensor_event = make_optical_sensor_event(x_sensor_m)
|
||||||
|
|
||||||
|
flight_sol = solve_ivp(
|
||||||
|
_ballistic_derivatives,
|
||||||
|
(0.0, stage.sensor_time_budget_s),
|
||||||
|
[entry_x_m, entry_v_mps],
|
||||||
|
events=sensor_event,
|
||||||
|
rtol=1e-8,
|
||||||
|
atol=1e-10,
|
||||||
|
)
|
||||||
|
if len(flight_sol.t_events[0]) == 0:
|
||||||
|
return StageResult(
|
||||||
|
feasible=False,
|
||||||
|
reason="датчик не сработал в пределах временного бюджета полёта",
|
||||||
|
)
|
||||||
|
|
||||||
|
t_sensor_s = flight_sol.t_events[0][0]
|
||||||
|
x_at_sensor, v_at_sensor = flight_sol.y_events[0][0]
|
||||||
|
|
||||||
|
fire_delay_s = (stage.sensor.propagation_delay_ns + stage.switch.turn_on_time_ns) * 1e-9
|
||||||
|
x_fire_m = x_at_sensor + v_at_sensor * fire_delay_s
|
||||||
|
v_fire_mps = v_at_sensor
|
||||||
|
|
||||||
|
circuit_params = StageCircuitParams(
|
||||||
|
capacitance_f=capacitance_f,
|
||||||
|
r_total_ohm=r_total_ohm,
|
||||||
|
mass_kg=mass_kg,
|
||||||
|
mu_eff=mu_eff,
|
||||||
|
total_turns=geometry.total_turns,
|
||||||
|
coil_length_m=geometry.coil_length_m,
|
||||||
|
b_sat_tesla=projectile.material.b_sat_tesla,
|
||||||
|
)
|
||||||
|
q0 = capacitance_f * stage.charge_voltage_v
|
||||||
|
discharge_sol = solve_ivp(
|
||||||
|
circuit.derivatives,
|
||||||
|
(0.0, stage.discharge_time_budget_s),
|
||||||
|
[q0, 0.0, x_fire_m, v_fire_mps],
|
||||||
|
args=(inductance_model, circuit_params),
|
||||||
|
events=circuit.zero_current_crossing_event,
|
||||||
|
rtol=1e-8,
|
||||||
|
atol=1e-11,
|
||||||
|
)
|
||||||
|
if len(discharge_sol.t_events[0]) == 0:
|
||||||
|
return StageResult(
|
||||||
|
feasible=False,
|
||||||
|
reason="разряд не скоммутировался (ток не вернулся к нулю) в пределах временного бюджета",
|
||||||
|
t_sensor_s=t_sensor_s,
|
||||||
|
t_fire_s=t_sensor_s + fire_delay_s,
|
||||||
|
)
|
||||||
|
|
||||||
|
q_final, i_final, x_final, v_final = discharge_sol.y_events[0][0]
|
||||||
|
|
||||||
|
energy_in_j = 0.5 * capacitance_f * stage.charge_voltage_v**2
|
||||||
|
energy_remaining_cap_j = q_final**2 / (2 * capacitance_f)
|
||||||
|
energy_dissipated_j = float(
|
||||||
|
np.trapezoid(discharge_sol.y[1] ** 2 * r_total_ohm, discharge_sol.t)
|
||||||
|
)
|
||||||
|
kinetic_before_j = 0.5 * mass_kg * v_fire_mps**2
|
||||||
|
kinetic_after_j = 0.5 * mass_kg * v_final**2
|
||||||
|
|
||||||
|
# Диагностика насыщения (не влияет на динамику, см. docstring force.py):
|
||||||
|
# предупреждаем, если реальный пик тока подразумевает поле выше B_sat
|
||||||
|
# материала снаряда — численные КПД/скорость в этом случае, вероятно,
|
||||||
|
# завышены относительно реального железа.
|
||||||
|
peak_current_a = float(np.max(np.abs(discharge_sol.y[1])))
|
||||||
|
peak_field_estimate_tesla = solenoid_field_estimate_tesla(
|
||||||
|
mu_eff, geometry.total_turns, geometry.coil_length_m, peak_current_a
|
||||||
|
)
|
||||||
|
saturation_warning = (
|
||||||
|
saturation_scale(peak_field_estimate_tesla, projectile.material.b_sat_tesla) < 1.0
|
||||||
|
)
|
||||||
|
|
||||||
|
return StageResult(
|
||||||
|
feasible=True,
|
||||||
|
t_sensor_s=t_sensor_s,
|
||||||
|
t_fire_s=t_sensor_s + fire_delay_s,
|
||||||
|
exit_x_m=x_final,
|
||||||
|
exit_v_mps=v_final,
|
||||||
|
energy_in_j=energy_in_j,
|
||||||
|
energy_dissipated_j=energy_dissipated_j,
|
||||||
|
energy_remaining_cap_j=energy_remaining_cap_j,
|
||||||
|
kinetic_energy_delta_j=kinetic_after_j - kinetic_before_j,
|
||||||
|
saturation_warning=saturation_warning,
|
||||||
|
peak_field_estimate_tesla=peak_field_estimate_tesla,
|
||||||
|
discharge_t=discharge_sol.t,
|
||||||
|
discharge_q=discharge_sol.y[0],
|
||||||
|
discharge_i=discharge_sol.y[1],
|
||||||
|
discharge_x=discharge_sol.y[2],
|
||||||
|
discharge_v=discharge_sol.y[3],
|
||||||
|
)
|
||||||
@@ -22,14 +22,13 @@ def test_saturation_scale_clamps_above_bsat():
|
|||||||
assert scale == pytest.approx(0.5)
|
assert scale == pytest.approx(0.5)
|
||||||
|
|
||||||
|
|
||||||
def test_force_is_reduced_once_saturated():
|
def test_force_scales_as_current_squared():
|
||||||
common = dict(dl_dx_unsaturated=1e-3, mu_eff=200, total_turns=300, coil_length_m=0.05, b_sat_tesla=1.8)
|
# F = 0.5*I^2*dL/dx: без клэмпа (см. модуль-докстринг force.py про энергобаланс)
|
||||||
force_unsaturated = force_on_slug_newtons(current_a=5, **common)
|
# сила должна расти строго как I^2, иначе нарушится точный энергобаланс контура.
|
||||||
force_saturated = force_on_slug_newtons(current_a=500, **common)
|
f_low = force_on_slug_newtons(current_a=5, dl_dx=1e-3)
|
||||||
# без клэмпа сила росла бы как I^2 (в 10000 раз); с насыщением рост должен быть намного меньше
|
f_high = force_on_slug_newtons(current_a=50, dl_dx=1e-3)
|
||||||
assert force_saturated / force_unsaturated < 5000
|
assert f_high / f_low == pytest.approx(100.0)
|
||||||
|
|
||||||
|
|
||||||
def test_force_zero_at_zero_current():
|
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, dl_dx=1e-3) == pytest.approx(0.0)
|
||||||
assert force_on_slug_newtons(current_a=0, **common) == 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