Add component database layer and validated physics core

- components/schema.py + database.py: typed loader for real retail parts
  (wire, capacitors, switches, sensors, projectile materials)
- physics/inductance.py: multilayer solenoid inductance (Wheeler) with
  ferromagnetic-slug coupling via smooth overlap model and analytic dL/dx
- physics/force.py: F = 0.5*I^2*dL/dx with a saturation clamp
- physics/circuit.py: coupled [Q,I,x,v] discharge ODE
- Validated against textbook RLC analytical solutions (under/over/critically
  damped) and energy conservation, not just spot-checked by eye
- PLAN.md updated with animation (per-run field visualization) and
  GPU-accelerated batch sweep as explicit later stages

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
jze9
2026-07-06 19:45:32 +05:00
parent 04233aefd7
commit 2046dcba10
17 changed files with 698 additions and 0 deletions

11
tests/fixtures/data/capacitors.json vendored Normal file
View File

@@ -0,0 +1,11 @@
[
{
"part_number": "test-cap-450v-220uf",
"capacitance_uf": 220.0,
"voltage_v": 450.0,
"esr_ohm": 0.05,
"max_current_a": 200.0,
"price": 150.0,
"source": "fixture"
}
]

View File

@@ -0,0 +1,10 @@
[
{
"name": "test-steel",
"density_kg_m3": 7850.0,
"mu_r": 200.0,
"b_sat_tesla": 1.8,
"price_per_kg": 100.0,
"source": "fixture"
}
]

18
tests/fixtures/data/sensors.json vendored Normal file
View File

@@ -0,0 +1,18 @@
[
{
"part_number": "test-optical",
"kind": "optical",
"propagation_delay_ns": 500.0,
"price": 20.0,
"source": "fixture"
},
{
"part_number": "test-inductive-pickup",
"kind": "inductive",
"propagation_delay_ns": 0.0,
"price": 5.0,
"source": "fixture",
"sensitivity_v_per_mps": 0.02,
"threshold_v": 0.1
}
]

13
tests/fixtures/data/switches.json vendored Normal file
View File

@@ -0,0 +1,13 @@
[
{
"part_number": "test-scr-25a",
"kind": "SCR",
"max_current_a": 25.0,
"max_voltage_v": 600.0,
"on_resistance_ohm": null,
"on_voltage_drop_v": 1.5,
"turn_on_time_ns": 2000.0,
"price": 80.0,
"source": "fixture"
}
]

22
tests/fixtures/data/wires.json vendored Normal file
View File

@@ -0,0 +1,22 @@
[
{
"part_id": "test-cu-0.5",
"material": "copper",
"gauge_mm": 0.5,
"insulation_od_mm": 0.55,
"resistivity_ohm_m": 1.72e-8,
"max_current_a": 3.0,
"price_per_m": 5.0,
"source": "fixture"
},
{
"part_id": "test-al-0.5",
"material": "aluminum",
"gauge_mm": 0.5,
"insulation_od_mm": 0.55,
"resistivity_ohm_m": 2.82e-8,
"max_current_a": 2.2,
"price_per_m": 2.0,
"source": "fixture"
}
]

View File

@@ -0,0 +1,116 @@
"""Проверка ОДУ разряда против аналитического решения простого RLC-контура.
dL/dx заморожена в ноль (mu_eff=1.0), поэтому механика отвязана от контура
и [Q, I] эволюционируют как классический последовательный RLC-разряд
конденсатора. Сравниваем численное решение solve_ivp с учебным аналитическим
решением во всех трёх режимах затухания.
"""
import math
import numpy as np
import pytest
from scipy.integrate import solve_ivp
from gausse.physics.circuit import Q, StageCircuitParams, derivatives
from gausse.physics.inductance import CoilInductanceModel
L_H = 100e-6
C_F = 200e-6
V0 = 400.0
Q0 = C_F * V0
def _make_frozen_model() -> CoilInductanceModel:
return CoilInductanceModel(
l_air_h=L_H, coil_length_m=0.05, slug_length_m=0.03, mu_eff=1.0, smoothing_width_m=0.001
)
def _integrate(r_ohm: float, t_span: tuple[float, float], t_eval: np.ndarray):
model = _make_frozen_model()
params = StageCircuitParams(
capacitance_f=C_F,
r_total_ohm=r_ohm,
mass_kg=1.0,
mu_eff=1.0,
total_turns=100,
coil_length_m=0.05,
b_sat_tesla=1e9, # эффективно отключает клэмп насыщения для этого теста
)
state0 = [Q0, 0.0, 0.0, 0.0]
return solve_ivp(
derivatives,
t_span,
state0,
args=(model, params),
t_eval=t_eval,
method="RK45",
rtol=1e-10,
atol=1e-14,
)
def test_underdamped_matches_analytical_solution():
r_ohm = 0.05
alpha = r_ohm / (2 * L_H)
omega0 = 1 / math.sqrt(L_H * C_F)
omega_d = math.sqrt(omega0**2 - alpha**2)
t_eval = np.linspace(0, 5 / alpha, 200)
sol = _integrate(r_ohm, (t_eval[0], t_eval[-1]), t_eval)
expected_q = Q0 * np.exp(-alpha * t_eval) * (
np.cos(omega_d * t_eval) + (alpha / omega_d) * np.sin(omega_d * t_eval)
)
expected_i = Q0 * (omega0**2 / omega_d) * np.exp(-alpha * t_eval) * np.sin(omega_d * t_eval)
assert np.allclose(sol.y[Q], expected_q, rtol=1e-3, atol=Q0 * 1e-4)
assert np.allclose(sol.y[1], expected_i, rtol=1e-3, atol=abs(expected_i).max() * 1e-3)
def test_overdamped_matches_analytical_solution():
r_ohm = 5.0
alpha = r_ohm / (2 * L_H)
omega0 = 1 / math.sqrt(L_H * C_F)
root = math.sqrt(alpha**2 - omega0**2)
s1, s2 = -alpha + root, -alpha - root
t_eval = np.linspace(0, 5 / abs(s1), 200)
sol = _integrate(r_ohm, (t_eval[0], t_eval[-1]), t_eval)
a_coef = -Q0 * s2 / (s1 - s2)
b_coef = Q0 * s1 / (s1 - s2)
expected_q = a_coef * np.exp(s1 * t_eval) + b_coef * np.exp(s2 * t_eval)
expected_i = -(a_coef * s1 * np.exp(s1 * t_eval) + b_coef * s2 * np.exp(s2 * t_eval))
assert np.allclose(sol.y[Q], expected_q, rtol=1e-3, atol=Q0 * 1e-4)
assert np.allclose(sol.y[1], expected_i, rtol=1e-3, atol=max(abs(expected_i).max(), 1e-9) * 1e-3)
def test_critically_damped_matches_analytical_solution():
alpha = 1 / math.sqrt(L_H * C_F) # R = 2*sqrt(L/C) => alpha = omega0
r_ohm = 2 * alpha * L_H
t_eval = np.linspace(0, 5 / alpha, 200)
sol = _integrate(r_ohm, (t_eval[0], t_eval[-1]), t_eval)
expected_q = Q0 * (1 + alpha * t_eval) * np.exp(-alpha * t_eval)
expected_i = alpha**2 * Q0 * t_eval * np.exp(-alpha * t_eval)
assert np.allclose(sol.y[Q], expected_q, rtol=1e-3, atol=Q0 * 1e-4)
assert np.allclose(sol.y[1], expected_i, rtol=1e-3, atol=max(abs(expected_i).max(), 1e-9) * 1e-3)
def test_energy_conserved_when_lossless():
"""С R=0 энергия конденсатора должна переходить в энергию катушки без потерь."""
r_ohm = 0.0
t_eval = np.linspace(0, 1e-3, 500)
sol = _integrate(r_ohm, (t_eval[0], t_eval[-1]), t_eval)
cap_energy = sol.y[Q] ** 2 / (2 * C_F)
inductor_energy = 0.5 * L_H * sol.y[1] ** 2
total_energy = cap_energy + inductor_energy
initial_energy = Q0**2 / (2 * C_F)
assert np.allclose(total_energy, initial_energy, rtol=1e-4)

View File

@@ -0,0 +1,45 @@
from pathlib import Path
from gausse.components.database import ComponentDatabase
FIXTURES = Path(__file__).parent / "fixtures" / "data"
def test_loads_all_component_types():
db = ComponentDatabase.load(FIXTURES)
assert len(db.wires) == 2
assert len(db.capacitors) == 1
assert len(db.switches) == 1
assert len(db.sensors) == 2
assert len(db.projectile_materials) == 1
def test_wires_by_material_filters_correctly():
db = ComponentDatabase.load(FIXTURES)
copper = db.wires_by_material("copper")
assert len(copper) == 1
assert copper[0].part_id == "test-cu-0.5"
def test_capacitors_rated_at_least_filters_by_voltage():
db = ComponentDatabase.load(FIXTURES)
assert len(db.capacitors_rated_at_least(400.0)) == 1
assert len(db.capacitors_rated_at_least(500.0)) == 0
def test_switches_rated_for_current_and_voltage():
db = ComponentDatabase.load(FIXTURES)
assert len(db.switches_rated_for(20.0, 500.0)) == 1
assert len(db.switches_rated_for(30.0, 500.0)) == 0
def test_sensors_by_kind():
db = ComponentDatabase.load(FIXTURES)
assert len(db.sensors_by_kind("optical")) == 1
assert len(db.sensors_by_kind("inductive")) == 1
def test_missing_data_dir_returns_empty_database(tmp_path):
db = ComponentDatabase.load(tmp_path)
assert db.wires == []
assert db.capacitors == []

35
tests/test_force_model.py Normal file
View File

@@ -0,0 +1,35 @@
import pytest
from gausse.physics.force import (
force_on_slug_newtons,
saturation_scale,
solenoid_field_estimate_tesla,
)
def test_field_estimate_scales_with_current_and_turns():
b_low = solenoid_field_estimate_tesla(mu_eff=100, total_turns=100, coil_length_m=0.05, current_a=10)
b_high = solenoid_field_estimate_tesla(mu_eff=100, total_turns=100, coil_length_m=0.05, current_a=100)
assert b_high > b_low
def test_saturation_scale_is_one_below_bsat():
assert saturation_scale(b_estimate_tesla=0.5, b_sat_tesla=1.8) == pytest.approx(1.0)
def test_saturation_scale_clamps_above_bsat():
scale = saturation_scale(b_estimate_tesla=3.6, b_sat_tesla=1.8)
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_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)

82
tests/test_inductance.py Normal file
View File

@@ -0,0 +1,82 @@
import math
import numpy as np
import pytest
from gausse.physics.inductance import (
CoilInductanceModel,
air_core_inductance_wheeler,
demagnetizing_factor_prolate,
effective_permeability,
winding_geometry,
)
def test_winding_geometry_scales_with_turns_and_layers():
geo = winding_geometry(tube_od_m=0.02, wire_od_m=0.001, turns_per_layer=50, layers=3)
assert geo.total_turns == 150
assert geo.coil_length_m == pytest.approx(0.05)
assert geo.radial_depth_m == pytest.approx(0.003)
assert geo.total_wire_length_m > 0
# больше слоёв -> больше провода при тех же витках на слой
geo_more_layers = winding_geometry(0.02, 0.001, 50, 5)
assert geo_more_layers.total_wire_length_m > geo.total_wire_length_m
def test_wheeler_inductance_is_positive_and_grows_with_turns():
l_small = air_core_inductance_wheeler(mean_radius_m=0.015, length_m=0.05, depth_m=0.003, turns=50)
l_large = air_core_inductance_wheeler(mean_radius_m=0.015, length_m=0.05, depth_m=0.003, turns=200)
assert l_small > 0
assert l_large > l_small
def test_demagnetizing_factor_bounds():
# почти сфера (aspect_ratio -> 1): классическое значение 1/3
assert demagnetizing_factor_prolate(1.001) == pytest.approx(1 / 3, abs=0.02)
# очень вытянутый стержень: коэффициент размагничивания стремится к 0
assert demagnetizing_factor_prolate(200.0) < 0.01
def test_effective_permeability_limits():
# без размагничивания (Nd=0) эффективная проницаемость равна мю материала
assert effective_permeability(mu_r=500.0, demagnetizing_factor=0.0) == pytest.approx(500.0)
# полное размагничивание (Nd=1) гасит эффект проницаемости до 1
assert effective_permeability(mu_r=500.0, demagnetizing_factor=1.0) == pytest.approx(1.0)
@pytest.fixture
def model() -> CoilInductanceModel:
return CoilInductanceModel(
l_air_h=100e-6,
coil_length_m=0.05,
slug_length_m=0.03,
mu_eff=50.0,
smoothing_width_m=0.001,
)
def test_inductance_peaks_when_slug_centered(model):
l_center = model.l_of_x(np.array(0.0))
l_far = model.l_of_x(np.array(0.2))
assert l_center > l_far
assert l_far == pytest.approx(model.l_air_h, rel=1e-3)
def test_dl_dx_vanishes_at_symmetric_center(model):
assert model.dl_dx(np.array(0.0)) == pytest.approx(0.0, abs=1e-9)
def test_dl_dx_sign_flips_across_center(model):
# слева от центра индуктивность растёт (dL/dx > 0), справа убывает (< 0)
assert model.dl_dx(np.array(-0.01)) > 0
assert model.dl_dx(np.array(0.01)) < 0
def test_analytic_dl_dx_matches_finite_difference(model):
xs = np.linspace(-0.06, 0.06, 25)
h = 1e-7
analytic = np.array([model.dl_dx(x) for x in xs])
finite_diff = np.array(
[(model.l_of_x(x + h) - model.l_of_x(x - h)) / (2 * h) for x in xs]
)
assert np.allclose(analytic, finite_diff, rtol=1e-3, atol=1e-9)