ЭКСПЛОЙТ (нашёлся по подозрительным 75-82% КПД на дашборде): модель умножала ВСЮ индуктивность катушки на (mu_eff-1)*overlap, как будто железо заполняет всё сечение. Тонкий снаряд (4мм) в толстой катушке (29мм) получал ~50x нефизичной силы бесплатно, и эволюция сгрузилась именно туда. Фикс: iron_fill_factor = A_снаряда/A_среднего витка (классическое λ = n*(B_fe*A_fe + B_возд*(A-A_fe))). ЗАВИСАНИЯ (сервер стоял 35+ мин на одной конфигурации): - сглаживание сухого трения tanh(v/0.01) вместо разрывного sign(v) - событие тока удержания ключа (SCR закрывается при I<0.1А после пика) -- передемпфированный хвост больше не молотит весь 20мс бюджет - полная формула отражения вихревого контура (ωM)²R2/(R2²+(ωL2)²) вместо резистивного приближения (114кОм -> 2.5кОм на вырожденных катушках) - гард: L/R < 100нс = вне области применимости модели, мгновенный отказ Бенч 200 геномов: 608с -> 22с, худшая оценка 540с -> 2.5с. MODEL_VERSION -> gausse-physics-v3. 104 теста. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
223 lines
10 KiB
Python
223 lines
10 KiB
Python
"""Физреализм v2: скин/близость (Доуэлл), трение+воздух, импульсные рейтинги ключей."""
|
||
|
||
import math
|
||
|
||
import numpy as np
|
||
|
||
from gausse.components.database import ComponentDatabase
|
||
from gausse.components.schema import (
|
||
CapacitorSpec,
|
||
ProjectileMaterialSpec,
|
||
SensorSpec,
|
||
SwitchSpec,
|
||
WireSpec,
|
||
)
|
||
from gausse.physics.ac_resistance import dowell_ac_factor, skin_depth_m
|
||
from gausse.physics.constants import switch_pulse_limit_a
|
||
from gausse.sim.stage import ProjectileConfig, StageConfig, build_stage_physics, run_stage
|
||
|
||
DB = ComponentDatabase.load()
|
||
|
||
|
||
def make_projectile() -> ProjectileConfig:
|
||
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"
|
||
)
|
||
return ProjectileConfig(material=steel, diameter_m=0.008, length_m=0.02)
|
||
|
||
|
||
def make_stage() -> StageConfig:
|
||
wire = WireSpec(
|
||
part_id="w1", material="copper", gauge_mm=0.8, insulation_od_mm=0.85,
|
||
resistivity_ohm_m=1.68e-8, max_current_a=10.0, price_per_m=3.0, source="test",
|
||
)
|
||
capacitor = CapacitorSpec(
|
||
part_number="c1", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.05, max_current_a=300.0,
|
||
price=300.0, source="test",
|
||
)
|
||
switch = SwitchSpec(
|
||
part_number="sw1", kind="MOSFET", max_current_a=500.0, max_voltage_v=500.0,
|
||
on_resistance_ohm=0.02, on_voltage_drop_v=None, turn_on_time_ns=50.0, price=50.0, source="test",
|
||
)
|
||
sensor = SensorSpec(part_number="s1", kind="optical", propagation_delay_ns=500.0, price=20.0, source="test")
|
||
return StageConfig(
|
||
wire=wire, capacitor=capacitor, switch=switch, sensor=sensor,
|
||
tube_od_m=0.01, turns_per_layer=20, layers=4,
|
||
sensor_to_coil_distance_m=0.02, charge_voltage_v=350.0,
|
||
)
|
||
|
||
|
||
# --- Доуэлл ---
|
||
|
||
|
||
def test_skin_depth_copper_50hz():
|
||
# медь на 50 Гц: δ ≈ 9.3 мм (классическое справочное значение)
|
||
delta = skin_depth_m(1.68e-8, 2 * math.pi * 50)
|
||
assert abs(delta - 9.2e-3) < 0.5e-3
|
||
|
||
|
||
def test_dowell_limits():
|
||
# низкая частота -> фактор стремится к 1
|
||
assert dowell_ac_factor(0.001, 0.00108, 8, 1.68e-8, 2 * math.pi * 1.0) < 1.001
|
||
# больше слоёв -> сильнее эффект близости (монотонность)
|
||
omega = 2 * math.pi * 2000
|
||
f = [dowell_ac_factor(0.002, 0.00208, m, 1.68e-8, omega) for m in (1, 4, 10)]
|
||
assert f[0] < f[1] < f[2]
|
||
# всегда >= 1
|
||
assert all(x >= 1.0 for x in f)
|
||
|
||
|
||
def test_dowell_series_matches_formula_at_boundary():
|
||
# непрерывность на стыке ряда и полной формулы (Δ≈0.25)
|
||
omega_lo, omega_hi = None, None
|
||
# подберём частоты чуть ниже/выше границы Δ=0.25 для d=1мм
|
||
d, rho = 0.001, 1.68e-8
|
||
for omega in np.logspace(2, 6, 4000):
|
||
delta = skin_depth_m(rho, omega)
|
||
big = (math.pi / 4) ** 0.75 * d / delta # porosity~1
|
||
if big < 0.25:
|
||
omega_lo = omega
|
||
elif omega_hi is None:
|
||
omega_hi = omega
|
||
break
|
||
f_lo = dowell_ac_factor(d, d, 6, rho, omega_lo)
|
||
f_hi = dowell_ac_factor(d, d, 6, rho, omega_hi)
|
||
assert abs(f_lo - f_hi) < 0.01
|
||
|
||
|
||
def test_thick_wire_many_layers_significant_ac_factor():
|
||
# толстый провод (2мм) в 10 слоёв на кГц — эффект близости должен быть
|
||
# существенным (>1.3), иначе канал потерь не работает
|
||
f = dowell_ac_factor(0.002, 0.00208, 10, 1.68e-8, 2 * math.pi * 1500)
|
||
assert f > 1.3
|
||
|
||
|
||
# --- трение и воздух ---
|
||
|
||
|
||
def test_stage_with_friction_slower_than_ideal():
|
||
"""Выходная скорость с трением/воздухом ниже, чем была бы без них."""
|
||
stage = make_stage()
|
||
projectile = make_projectile()
|
||
res = run_stage(-0.06, 3.0, stage, projectile)
|
||
assert res.feasible, res.reason
|
||
# подлёт 3 м/с на ~3 см: только трение уже отъедает заметную скорость
|
||
phys = build_stage_physics(stage, projectile)
|
||
assert phys.circuit_params.retard_const_n > 0
|
||
assert phys.circuit_params.drag_coeff_n_per_mps2 > 0
|
||
|
||
|
||
def test_slow_projectile_stalls_before_sensor():
|
||
"""Медленный снаряд далеко от датчика останавливается трением — честный отказ."""
|
||
stage = make_stage()
|
||
projectile = make_projectile()
|
||
res = run_stage(-1.0, 0.15, stage, projectile) # 15 см/с за метр до катушки
|
||
assert not res.feasible
|
||
assert "трением" in res.reason
|
||
|
||
|
||
def test_energy_balance_with_friction():
|
||
"""Баланс: E_in = E_ост.конденсатора + E_потерь(включая трение) + ΔE_кин."""
|
||
stage = make_stage()
|
||
projectile = make_projectile()
|
||
res = run_stage(-0.06, 3.0, stage, projectile)
|
||
assert res.feasible, res.reason
|
||
balance = res.energy_remaining_cap_j + res.energy_dissipated_j + res.kinetic_energy_delta_j
|
||
assert abs(balance - res.energy_in_j) < 0.02 * res.energy_in_j
|
||
|
||
|
||
# --- импульсные рейтинги ключей ---
|
||
|
||
|
||
def test_pulse_ratings_loaded_from_datasheets():
|
||
by_pn = {s.part_number: s for s in DB.switches}
|
||
assert by_pn["IRFP254PBF"].pulse_current_a == 92.0 # IDM из даташита
|
||
assert by_pn["BT151-650R"].pulse_current_a == 100.0 # ITSM
|
||
assert by_pn["IRG4PC50F"].pulse_current_a == 280.0 # ICM
|
||
for s in DB.switches:
|
||
limit = switch_pulse_limit_a(s)
|
||
assert limit >= s.max_current_a # импульсный >= продолжительного
|
||
|
||
|
||
def test_pulse_limit_fallback_without_datasheet():
|
||
from dataclasses import replace
|
||
|
||
sw = replace(DB.switches[0], pulse_current_a=None)
|
||
# без даташита — консервативный множитель по типу ключа
|
||
assert switch_pulse_limit_a(sw) == sw.max_current_a * {"SCR": 10.0, "MOSFET": 4.0, "IGBT": 3.0}[sw.kind]
|
||
|
||
|
||
def test_friction_force_smooth_at_zero_velocity():
|
||
"""Регрессия на завис решателя: сила трения обязана быть ГЛАДКОЙ в v=0.
|
||
|
||
Разрывный sign(v) заставлял solve_ivp бесконечно дробить шаг у v≈0
|
||
(одна конфигурация считалась 35+ минут на сервере).
|
||
"""
|
||
from gausse.physics.circuit import StageCircuitParams, retarding_force_n
|
||
|
||
p = StageCircuitParams(
|
||
capacitance_f=1e-4, r_total_ohm=0.1, mass_kg=0.01,
|
||
retard_const_n=0.034, drag_coeff_n_per_mps2=2e-5,
|
||
)
|
||
# непрерывность: около нуля сила ~ 0, нечётная, без скачка
|
||
assert abs(retarding_force_n(p, 1e-6)) < 1e-5
|
||
assert abs(retarding_force_n(p, 1e-6) + retarding_force_n(p, -1e-6)) < 1e-12
|
||
# вдали от нуля выходит на полное трение
|
||
assert retarding_force_n(p, 1.0) > 0.9 * p.retard_const_n
|
||
|
||
|
||
def test_iron_fill_factor_punishes_thin_slug_in_fat_coil():
|
||
"""Регрессия на эксплойт «КПД 82%»: тонкий снаряд в толстой катушке
|
||
не может усиливать весь поток — только долю сечения, которую занимает."""
|
||
from gausse.physics.inductance import CoilInductanceModel
|
||
|
||
base = dict(l_air_h=1e-3, coil_length_m=0.05, slug_length_m=0.02,
|
||
mu_eff=40.0, smoothing_width_m=0.001, total_turns=300)
|
||
full = CoilInductanceModel(**base, iron_fill_factor=1.0)
|
||
thin = CoilInductanceModel(**base, iron_fill_factor=0.02) # ⌀4мм в ⌀29мм
|
||
# сила при том же токе в 50 раз меньше — халявы больше нет
|
||
f_full = float(full.force_newtons(-0.03, 100.0))
|
||
f_thin = float(thin.force_newtons(-0.03, 100.0))
|
||
assert f_thin < 0.03 * f_full
|
||
|
||
|
||
def test_degenerate_coil_fails_fast():
|
||
"""Регрессия на завис 540с: вырожденная катушка бракуется мгновенно."""
|
||
import time
|
||
from dataclasses import replace
|
||
|
||
stage = make_stage()
|
||
degenerate = replace(stage, turns_per_layer=2, layers=2) # 4 витка, ~2мм
|
||
t0 = time.time()
|
||
res = run_stage(-0.06, 3.0, degenerate, make_projectile())
|
||
assert time.time() - t0 < 5.0, "вырожденный конфиг должен отваливаться быстро"
|
||
# либо честно отбракован гардом, либо честно посчитан быстро — оба исхода ок
|
||
if not res.feasible:
|
||
assert res.reason
|
||
|
||
|
||
def test_eddy_reflection_bounded_at_high_frequency():
|
||
"""Полная формула отражения (R₂²+(ωL₂)² в знаменателе): на высоких ω
|
||
заметно ниже старого резистивно-доминированного приближения, на низких —
|
||
сходится к нему. Совсем вырожденные катушки добивает гард L/R в run_stage."""
|
||
import math as _m
|
||
|
||
from gausse.physics.constants import MU_0
|
||
from gausse.physics.losses import EDDY_COUPLING_FACTOR, eddy_reflected_resistance_ohm
|
||
|
||
kwargs = dict(slug_radius_m=0.002, slug_length_m=0.03,
|
||
slug_resistivity_ohm_m=1.6e-7, mu_eff=259.0,
|
||
total_turns=5, coil_length_m=0.002)
|
||
|
||
def resistive_approx(om):
|
||
a = _m.pi * kwargs["slug_radius_m"] ** 2
|
||
m = EDDY_COUPLING_FACTOR * MU_0 * kwargs["mu_eff"] * (5 / 0.002) * a
|
||
r2 = 2 * _m.pi * kwargs["slug_resistivity_ohm_m"] / kwargs["slug_length_m"]
|
||
return (om * m) ** 2 / r2
|
||
|
||
om_hi, om_lo = 1.25e5, 1e2
|
||
r_hi = eddy_reflected_resistance_ohm(**kwargs, char_omega_rad_s=om_hi)
|
||
r_lo = eddy_reflected_resistance_ohm(**kwargs, char_omega_rad_s=om_lo)
|
||
assert r_hi < 0.5 * resistive_approx(om_hi) # высокие ω: ограничена
|
||
assert abs(r_lo - resistive_approx(om_lo)) < 0.05 * resistive_approx(om_lo) # низкие: сходится
|