Compare commits

...

4 Commits

Author SHA1 Message Date
jze9
7803e147a7 Add GPU/CPU batch sweep for single-stage configs (gausse sweep --gpu)
Finishes the GPU path to a usable state: run_gpu_sweep vectorizes the
expensive discharge across N single-stage configs via the validated
batch integrator (numpy CPU / cupy GPU, same code). Setup (decode +
build_stage_physics) is a fast python loop; the ODE batch is one call.

- Extracted sim/stage.build_stage_physics so the CPU path (run_stage) and
  the GPU batch build IDENTICAL physics (coil model, eddy, saturation,
  circuit params) -- no divergence by construction.
- Batch integrator hardened: stiff configs that overflow fixed-step RK4
  are marked infeasible (blew_up), feasibility = actually-commutated
  (committed), warnings silenced via seterr. Single-pulse + eddy mirrored.
- Analytic sensor trigger (constant-velocity approach) for the batch;
  inductive threshold check preserved.

Honesty gate: test_batch_sweep validates GPU-path exit velocities against
the CPU run_coilgun -- 0.0% divergence on the checked configs. Limitation
stated plainly: single stage only (multi-stage stays on the CPU sweep);
cupy on the server GPU (1070 passthrough) is the remaining infra step.
91 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 16:00:49 +05:00
jze9
13c1d417d6 Add shop links, full dimensions, and fix the flight animation
- Every component now has a `url` (schema + JSON) pointing to the shop it
  was priced from; surfaced in the run detail and the BOM (new "Купить"
  column). Wire/switch/capacitor entries also expose gauge, ratings,
  single-vs-bank values; detail reports coil inner/outer diameter (=tube
  outer), tube inner bore + wall, projectile mass in grams and bore fit.
- Animation was wrong: it stitched flight+discharge segments by sorting on
  time, which tore the trajectory into discontinuities. Rebuilt it
  sequentially -- coast (ballistic) between discharges + exact discharge
  trajectory, global x = coil center + local discharge_x -- so the slug
  moves continuously and coils sit at their real positions. Cleaner
  visuals: labeled coils that glow gold on discharge, blue slug.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 15:44:26 +05:00
jze9
35cbbb04c6 Physical tube geometry, 200g projectiles, and single-pulse discharge
Three fixes from user feedback:

1. Single-pulse discharge (user spotted 3 current humps for one stage):
   a thyristor fires ONCE per shot -- you can't recharge the cap in
   microseconds. The discharge now terminates at the first current zero
   OR first local minimum (where the slug starts pumping current back),
   whichever comes first. Residual coil energy at cutoff is accounted as
   freewheel-diode dissipation using the exact saturating magnetic energy
   integral, so energy still balances. Verified: 3 humps -> 1.

2. Real tube geometry: the genome now carries tube INNER diameter (bore,
   the projectile flies through) and wall thickness; outer diameter =
   inner + 2*wall = the coil's inner diameter (which drives the field).
   The projectile must fit the bore (diameter < inner - clearance).

3. Projectiles up to 200 g: diameter to 28mm, length to 150mm, with mass
   capped at 200g (length clamped by density).

Detail/BOM now report inner/outer/wall tube diameters, projectile mass in
grams, and whether it fits the bore. Same single-pulse + eddy physics
mirrored into the GPU batch integrator. Evolutionary polish updated for
the new tube params. All test groups pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 15:33:47 +05:00
jze9
4d16825f7d Add eddy-current losses in the slug -- the missing loss channel
The user caught the evolution reporting 83.9% efficiency, which is
unphysical (real coilguns are single-digit %). Root cause: the model's
only loss channel was copper resistance; iron had no losses at all, so
the reluctance force came "for free" and the optimizer climbed into that
corner.

Adds eddy-current loss: the solid steel slug acts as a shorted secondary
(1-turn transformer), and its reflected resistance R_eddy = (wM)^2/R_e
is added to the circuit while the slug is inside the coil (x overlap(x)).
Energy now honestly goes to slug heating instead of kinetic. The formula
matches the classical solid-cylinder eddy loss (P ~ sigma*w^2*B^2*a^4),
so it's physically grounded, not tuned to a target. Wired through
schema/JSON (slug resistivity), losses.py, circuit.py, stage.py, and the
GPU batch integrator; energy conservation still holds (0.025%).

Effect: best efficiency 83.9% -> ~47%, and the distribution is now
realistic (median ~0%, most configs single-digit). 47% is still an
optimistic ceiling -- it's the optimizer's single best exploit, and the
model still omits tube friction, air drag, skin-effect field penetration
(skin depth ~0.44mm < slug radius), and timing imperfection. Hysteresis
computed but not in the dynamics (negligible, ~1e-4 J vs eddy). 89 tests.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 15:03:45 +05:00
21 changed files with 727 additions and 153 deletions

View File

@@ -24,9 +24,15 @@ def _bounds_from_args(args) -> SearchBounds:
def cmd_sweep(args) -> int:
bounds = _bounds_from_args(args)
summary = run_sweep(
Path(args.db), n_runs=args.n, bounds=bounds, n_workers=args.workers, seed=args.seed
)
if args.gpu:
from gausse.gpu.batch_sweep import run_gpu_sweep
summary = run_gpu_sweep(Path(args.db), n_runs=args.n, bounds=bounds, seed=args.seed)
print(f"GPU-путь backend={summary['backend']} (одноступ.)")
else:
summary = run_sweep(
Path(args.db), n_runs=args.n, bounds=bounds, n_workers=args.workers, seed=args.seed
)
rate = 100 * summary["n_feasible"] / summary["n_runs"] if summary["n_runs"] else 0.0
print(f"Прогнано {summary['n_runs']}, реализуемо {summary['n_feasible']} ({rate:.1f}%)")
return 0
@@ -113,6 +119,7 @@ def main(argv: list[str] | None = None) -> int:
sweep_p.add_argument("--workers", type=int, default=None)
sweep_p.add_argument("--seed", type=int, default=None)
sweep_p.add_argument("--max-stages", type=int, default=None)
sweep_p.add_argument("--gpu", action="store_true", help="GPU/cupy батч-путь (одноступ., быстрый)")
sweep_p.set_defaults(func=cmd_sweep)
evolve_p = subparsers.add_parser("evolve", help="эволюционный поиск поверх базы прогонов")

View File

@@ -6,7 +6,8 @@
"esr_ohm": 0.45,
"max_current_a": 40.0,
"price": 140.0,
"source": "REAL цена: chipdip.ru, категория 'kondensator-400v' (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR ОЦЕНКА 0.3-0.6 Ом. max_current_a — ОЦЕНКА ИМПУЛЬСНОГО тока для одиночного coilgun-выстрела (не ripple из даташита), ограничено ESR/фольгой; в пачке параллельно (как у пользователя, 8шт) суммируется"
"source": "REAL цена: chipdip.ru, категория 'kondensator-400v' (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR ОЦЕНКА 0.3-0.6 Ом. max_current_a — ОЦЕНКА ИМПУЛЬСНОГО тока для одиночного coilgun-выстрела (не ripple из даташита), ограничено ESR/фольгой; в пачке параллельно (как у пользователя, 8шт) суммируется",
"url": "https://www.chipdip.ru/catalog/popular/kondensator-400v"
},
{
"part_number": "cap-68uf-400v",
@@ -15,7 +16,8 @@
"esr_ohm": 0.3,
"max_current_a": 60.0,
"price": 440.0,
"source": "REAL цена: chipdip.ru (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR ОЦЕНКА. max_current_a — ОЦЕНКА импульсного тока (одиночный выстрел), не ripple"
"source": "REAL цена: chipdip.ru (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR ОЦЕНКА. max_current_a — ОЦЕНКА импульсного тока (одиночный выстрел), не ripple",
"url": "https://www.chipdip.ru/catalog/popular/kondensator-400v"
},
{
"part_number": "cap-180uf-400v",
@@ -24,7 +26,8 @@
"esr_ohm": 0.15,
"max_current_a": 120.0,
"price": 109.9,
"source": "REAL цена: procontact74.ru, CHANG 180uF 400V 105C (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/); ESR ОЦЕНКА. max_current_a — ОЦЕНКА импульсного тока, не ripple"
"source": "REAL цена: procontact74.ru, CHANG 180uF 400V 105C (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/); ESR ОЦЕНКА. max_current_a — ОЦЕНКА импульсного тока, не ripple",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/"
},
{
"part_number": "cap-470uf-400v",
@@ -33,7 +36,8 @@
"esr_ohm": 0.08,
"max_current_a": 250.0,
"price": 498.0,
"source": "REAL цена: procontact74.ru, JCCON 470uF 400V 105C 30x45мм (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/), проверено, 325.84₽ от 5шт; ESR ОЦЕНКА ~0.05-0.1 Ом. max_current_a — ОЦЕНКА импульсного тока (низкий ESR -> держит сотни А в импульсе), не ripple"
"source": "REAL цена: procontact74.ru, JCCON 470uF 400V 105C 30x45мм (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/), проверено, 325.84₽ от 5шт; ESR ОЦЕНКА ~0.05-0.1 Ом. max_current_a — ОЦЕНКА импульсного тока (низкий ESR -> держит сотни А в импульсе), не ripple",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/"
},
{
"part_number": "cap-1000uf-400v",
@@ -42,7 +46,8 @@
"esr_ohm": 0.06,
"max_current_a": 350.0,
"price": 700.0,
"source": "ОЦЕНКА (низкая уверенность по цене): категория подтверждена на procontact74.ru/asenergi.ru, точная цена не подтверждена. max_current_a — ОЦЕНКА импульсного тока для coilgun, не ripple"
"source": "ОЦЕНКА (низкая уверенность по цене): категория подтверждена на procontact74.ru/asenergi.ru, точная цена не подтверждена. max_current_a — ОЦЕНКА импульсного тока для coilgun, не ripple",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/"
},
{
"part_number": "cap-2200uf-400v",
@@ -51,6 +56,7 @@
"esr_ohm": 0.05,
"max_current_a": 450.0,
"price": 1400.0,
"source": "ОЦЕНКА (низкая уверенность по цене): категория подтверждена на asenergi.ru (https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html). max_current_a — ОЦЕНКА импульсного тока для coilgun, не ripple"
"source": "ОЦЕНКА (низкая уверенность по цене): категория подтверждена на asenergi.ru (https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html). max_current_a — ОЦЕНКА импульсного тока для coilgun, не ripple",
"url": "https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html"
}
]

View File

@@ -5,7 +5,9 @@
"mu_r": 550.0,
"b_sat_tesla": 1.9,
"price_per_kg": 55.0,
"source": "Цена REAL: горячекатаный круг Ст3, ros-met.com/metallurg-moskva.ru, ~49-60₽/кг (одна из позиций ~51.4₽/кг за тонну); плотность/μr/B_sat — ОЦЕНКА (стандартные справочные значения для конструкционной стали, не найдено источника с числами конкретно для Ст3)"
"resistivity_ohm_m": 1.6e-07,
"source": "Цена REAL: горячекатаный круг Ст3, ros-met.com/metallurg-moskva.ru, ~49-60₽/кг (одна из позиций ~51.4₽/кг за тонну); плотность/μr/B_sat/resistivity — ОЦЕНКА (справочные значения для конструкционной стали; удельное сопротивление ~1.6e-7 Ом·м)",
"url": "https://ros-met.com/krug-stalnoy-kupit/"
},
{
"name": "Сталь 10 (низкоуглеродистая)",
@@ -13,7 +15,9 @@
"mu_r": 1500.0,
"b_sat_tesla": 2.05,
"price_per_kg": 55.0,
"source": "ОЦЕНКА по всем полям: отдельного розничного объявления на 'Сталь 10' не найдено, цена взята той же полосы, что и Ст3 (~50-70₽/кг); μr выше, чем у Ст3, из-за более высокой чистоты низкоуглеродистой стали — справочная оценка, не измерение"
"resistivity_ohm_m": 1.4e-07,
"source": "ОЦЕНКА по всем полям: отдельного розничного объявления на 'Сталь 10' не найдено, цена той же полосы, что и Ст3 (~50-70₽/кг); μr выше из-за чистоты; удельное сопротивление ~1.4e-7 Ом·м — справочная оценка",
"url": "https://ros-met.com/krug-stalnoy-kupit/"
},
{
"name": "Армко-железо (чистое железо)",
@@ -21,6 +25,8 @@
"mu_r": 3500.0,
"b_sat_tesla": 2.15,
"price_per_kg": 160.0,
"source": "ОЦЕНКА по всем полям: специализированный товар, не найдено самостоятельной розничной цены; цена оценена как ~3x от Ст3 на основе общего указания '2-4x дороже обычной стали'; μr/B_sat — стандартные справочные значения для хорошо отожжённого чистого железа"
"resistivity_ohm_m": 1e-07,
"source": "ОЦЕНКА по всем полям: специализированный товар, розничной цены не найдено; цена ~3x от Ст3; μr/B_sat — справочные для отожжённого чистого железа; удельное сопротивление ~1.0e-7 Ом·м (чище -> ниже)",
"url": "https://ros-met.com/krug-stalnoy-kupit/"
}
]

View File

@@ -4,14 +4,16 @@
"kind": "hall",
"propagation_delay_ns": 5000.0,
"price": 19.9,
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/34-datchiki/datchik-holla-a3144e-34111/), 19.90₽ (6.48₽ от 200шт); время срабатывания на странице не указано, ОЦЕНКА ~5мкс (середина типового диапазона 3-7мкс для А3144-класса)"
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/34-datchiki/datchik-holla-a3144e-34111/), 19.90₽ (6.48₽ от 200шт); время срабатывания на странице не указано, ОЦЕНКА ~5мкс (середина типового диапазона 3-7мкс для А3144-класса)",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/34-datchiki/datchik-holla-a3144e-34111/"
},
{
"part_number": "TCST2103",
"kind": "optical",
"propagation_delay_ns": 9000.0,
"price": 220.0,
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/tcst2103-optopara-shirokogo-naznacheniya-4-pin-vishay-2693015011), 220₽ (161₽ от 200шт); время включения/выключения 8-10мкс — REAL с даташита Vishay, взята середина диапазона"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/tcst2103-optopara-shirokogo-naznacheniya-4-pin-vishay-2693015011), 220₽ (161₽ от 200шт); время включения/выключения 8-10мкс — REAL с даташита Vishay, взята середина диапазона",
"url": "https://www.chipdip.ru/product/tcst2103-optopara-shirokogo-naznacheniya-4-pin-vishay-2693015011"
},
{
"part_number": "inductive-pickup-lm393",
@@ -20,6 +22,7 @@
"price": 15.0,
"source": "ОЦЕНКА: не готовый компонент, а самодельная схема — виток(-и) провода (цена уже учтена в wires.json) + компаратор LM393 (~15₽, типовая розничная цена); время задержки 1.3мкс — реальный паспортный параметр LM393, но чувствительность/порог ниже — грубая оценка, не датащит-значение",
"sensitivity_v_per_mps": 0.01,
"threshold_v": 0.05
"threshold_v": 0.05,
"url": "https://www.chipdip.ru/search?searchtext=LM393"
}
]

View File

@@ -8,7 +8,8 @@
"on_voltage_drop_v": 1.5,
"turn_on_time_ns": 10000.0,
"price": 140.0,
"source": "REAL: КУ202Н, chipdip.ru (https://www.chipdip.ru/product/ku202n) 140₽ / procontact74.ru (https://procontact74.ru/44-tiristory/ku202n-tiristor-10a-400v-44011/) 189₽; Von=1.5В с даташита; время включения ОЦЕНКА ~10мкс (типично для тиристора этого класса, точное значение в листинге не приведено)"
"source": "REAL: КУ202Н, chipdip.ru (https://www.chipdip.ru/product/ku202n) 140₽ / procontact74.ru (https://procontact74.ru/44-tiristory/ku202n-tiristor-10a-400v-44011/) 189₽; Von=1.5В с даташита; время включения ОЦЕНКА ~10мкс (типично для тиристора этого класса, точное значение в листинге не приведено)",
"url": "https://www.chipdip.ru/product/ku202n"
},
{
"part_number": "BT151-650R",
@@ -19,7 +20,8 @@
"on_voltage_drop_v": 1.7,
"turn_on_time_ns": 1500.0,
"price": 190.0,
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/bt151-650r), ~180-200₽ (BT151-600R вариант подтверждён на 180₽); Von и время включения ОЦЕНКА (типично для тиристора этого класса, не указаны продавцом)"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/bt151-650r), ~180-200₽ (BT151-600R вариант подтверждён на 180₽); Von и время включения ОЦЕНКА (типично для тиристора этого класса, не указаны продавцом)",
"url": "https://www.chipdip.ru/product/bt151-650r"
},
{
"part_number": "IRFP254PBF",
@@ -30,7 +32,8 @@
"on_voltage_drop_v": null,
"turn_on_time_ns": 30.0,
"price": 39.9,
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/); ВНИМАНИЕ: демонтированная (Б/У) деталь, не новая партия; время включения ОЦЕНКА ~30нс (типично для этого класса MOSFET)"
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/); ВНИМАНИЕ: демонтированная (Б/У) деталь, не новая партия; время включения ОЦЕНКА ~30нс (типично для этого класса MOSFET)",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/"
},
{
"part_number": "IRFP250PBF",
@@ -41,7 +44,8 @@
"on_voltage_drop_v": null,
"turn_on_time_ns": 35.0,
"price": 320.0,
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp250), новая деталь, 320₽ (276₽ от 15шт, 245₽ от 150шт); Rds(on)=0.085Ом при 18А/10В с даташита; время включения ОЦЕНКА"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp250), новая деталь, 320₽ (276₽ от 15шт, 245₽ от 150шт); Rds(on)=0.085Ом при 18А/10В с даташита; время включения ОЦЕНКА",
"url": "https://www.chipdip.ru/product/irfp250"
},
{
"part_number": "IRFP4468PBF",
@@ -52,7 +56,8 @@
"on_voltage_drop_v": null,
"turn_on_time_ns": 40.0,
"price": 610.0,
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp4468pbf), 610₽ (534₽ от 15шт); паспортный максимум 290А при 25°C, взято консервативное практическое значение 180А; Rds(on)=0.0026Ом при 180А/10В с даташита; время включения ОЦЕНКА"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp4468pbf), 610₽ (534₽ от 15шт); паспортный максимум 290А при 25°C, взято консервативное практическое значение 180А; Rds(on)=0.0026Ом при 180А/10В с даташита; время включения ОЦЕНКА",
"url": "https://www.chipdip.ru/product/irfp4468pbf"
},
{
"part_number": "IRG4PC50F",
@@ -63,6 +68,7 @@
"on_voltage_drop_v": 1.8,
"turn_on_time_ns": 300.0,
"price": 504.0,
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irg4pc50f-8048990896), ~504₽; Vce(sat) и время включения ОЦЕНКА (типично для этого семейства IGBT, не подтверждено со страницы товара)"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irg4pc50f-8048990896), ~504₽; Vce(sat) и время включения ОЦЕНКА (типично для этого семейства IGBT, не подтверждено со страницы товара)",
"url": "https://www.chipdip.ru/product/irg4pc50f-8048990896"
}
]

View File

@@ -7,7 +7,8 @@
"resistivity_ohm_m": 1.68e-08,
"max_current_a": 0.35,
"price_per_m": 3.5,
"source": "REAL: chipdip.ru, ПЭТВ-2 0.3мм, 1050₽/200г-катушка ~300м (https://www.chipdip.ru/product/petv-2-d-0.3-mm-provod-emalirovannyy-obmotochnyy-300m-dlina-9000272019)"
"source": "REAL: chipdip.ru, ПЭТВ-2 0.3мм, 1050₽/200г-катушка ~300м (https://www.chipdip.ru/product/petv-2-d-0.3-mm-provod-emalirovannyy-obmotochnyy-300m-dlina-9000272019)",
"url": "https://www.chipdip.ru/product/petv-2-d-0.3-mm-provod-emalirovannyy-obmotochnyy-300m-dlina-9000272019"
},
{
"part_id": "cu-petv2-0.71mm",
@@ -17,7 +18,8 @@
"resistivity_ohm_m": 1.68e-08,
"max_current_a": 1.98,
"price_per_m": 16.2,
"source": "REAL: chipdip.ru, ПЭТВ-2 0.71мм, 890₽/200г-катушка ~55м (https://www.chipdip.ru/product/petv-2-d-0.71-mm)"
"source": "REAL: chipdip.ru, ПЭТВ-2 0.71мм, 890₽/200г-катушка ~55м (https://www.chipdip.ru/product/petv-2-d-0.71-mm)",
"url": "https://www.chipdip.ru/product/petv-2-d-0.71-mm"
},
{
"part_id": "cu-petv2-1.0mm",
@@ -27,7 +29,8 @@
"resistivity_ohm_m": 1.68e-08,
"max_current_a": 3.93,
"price_per_m": 8.445,
"source": "REAL: chipdip.ru, ПЭТВ-2 1.0мм, 1200₽/кг (https://www.chipdip.ru/product0/9000289279); ₽/м пересчитано нами из ₽/кг"
"source": "REAL: chipdip.ru, ПЭТВ-2 1.0мм, 1200₽/кг (https://www.chipdip.ru/product0/9000289279); ₽/м пересчитано нами из ₽/кг",
"url": "https://www.chipdip.ru/product0/9000289279"
},
{
"part_id": "cu-petv2-1.4mm",
@@ -37,7 +40,8 @@
"resistivity_ohm_m": 1.68e-08,
"max_current_a": 7.7,
"price_per_m": 45.266,
"source": "REAL: chipdip.ru, ПЭТВ-2 1.4мм, 3610₽/1.1кг-упаковка (https://www.chipdip.ru/product0/8004178560); ₽/м пересчитано нами из ₽/кг"
"source": "REAL: chipdip.ru, ПЭТВ-2 1.4мм, 3610₽/1.1кг-упаковка (https://www.chipdip.ru/product0/8004178560); ₽/м пересчитано нами из ₽/кг",
"url": "https://www.chipdip.ru/product0/8004178560"
},
{
"part_id": "cu-petv2-0.8mm",
@@ -47,7 +51,8 @@
"resistivity_ohm_m": 1.68e-08,
"max_current_a": 2.51,
"price_per_m": 11.259,
"source": "ОЦЕНКА: товар реален на chipdip.ru (https://www.chipdip.ru/product0/8004178423), но точная цена не подтверждена при парсинге; 2500₽/кг интерполировано между подтверждёнными 0.71мм (~4566₽/кг) и 1.0мм (1200₽/кг)"
"source": "ОЦЕНКА: товар реален на chipdip.ru (https://www.chipdip.ru/product0/8004178423), но точная цена не подтверждена при парсинге; 2500₽/кг интерполировано между подтверждёнными 0.71мм (~4566₽/кг) и 1.0мм (1200₽/кг)",
"url": "https://www.chipdip.ru/product0/8004178423"
},
{
"part_id": "cu-petv2-2.0mm",
@@ -57,7 +62,8 @@
"resistivity_ohm_m": 1.68e-08,
"max_current_a": 15.71,
"price_per_m": 30.964,
"source": "ОЦЕНКА: chipdip.ru (https://www.chipdip.ru/product0/9000289280), цена частично 'по запросу', взята середина наблюдаемого диапазона 1000-1200₽/кг"
"source": "ОЦЕНКА: chipdip.ru (https://www.chipdip.ru/product0/9000289280), цена частично 'по запросу', взята середина наблюдаемого диапазона 1000-1200₽/кг",
"url": "https://www.chipdip.ru/product0/9000289280"
},
{
"part_id": "al-0.3mm",
@@ -67,7 +73,8 @@
"resistivity_ohm_m": 2.82e-08,
"max_current_a": 0.28,
"price_per_m": 1.4,
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)"
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)",
"url": "https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php"
},
{
"part_id": "al-0.71mm",
@@ -77,7 +84,8 @@
"resistivity_ohm_m": 2.82e-08,
"max_current_a": 1.58,
"price_per_m": 6.48,
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)"
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)",
"url": "https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php"
},
{
"part_id": "al-1.0mm",
@@ -87,7 +95,8 @@
"resistivity_ohm_m": 2.82e-08,
"max_current_a": 3.14,
"price_per_m": 3.378,
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)"
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)",
"url": "https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php"
},
{
"part_id": "al-1.4mm",
@@ -97,6 +106,7 @@
"resistivity_ohm_m": 2.82e-08,
"max_current_a": 6.16,
"price_per_m": 18.106,
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)"
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)",
"url": "https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php"
}
]
]

View File

@@ -14,6 +14,7 @@ class WireSpec:
max_current_a: float
price_per_m: float
source: str
url: str | None = None # прямая ссылка на магазин, где купить
@dataclass(frozen=True)
@@ -25,6 +26,7 @@ class CapacitorSpec:
max_current_a: float
price: float
source: str
url: str | None = None
@dataclass(frozen=True)
@@ -46,6 +48,7 @@ class CapacitorBank:
esr_ohm: float
max_current_a: float
price: float
url: str | None = None
@property
def count(self) -> int:
@@ -64,6 +67,7 @@ class CapacitorBank:
esr_ohm=spec.esr_ohm * n_series / n_parallel,
max_current_a=spec.max_current_a * n_parallel,
price=spec.price * n_series * n_parallel,
url=spec.url,
)
@@ -78,6 +82,7 @@ class SwitchSpec:
turn_on_time_ns: float
price: float
source: str
url: str | None = None
@dataclass(frozen=True)
@@ -89,6 +94,7 @@ class SensorSpec:
source: str
sensitivity_v_per_mps: float | None = None
threshold_v: float | None = None
url: str | None = None
@dataclass(frozen=True)
@@ -99,3 +105,7 @@ class ProjectileMaterialSpec:
b_sat_tesla: float
price_per_kg: float
source: str
# электрическое удельное сопротивление — для расчёта вихревых потерь в снаряде
# (сплошной проводник в импульсном поле). Дефолт ~ конструкционная сталь.
resistivity_ohm_m: float = 1.6e-7
url: str | None = None

View File

@@ -34,6 +34,7 @@ class BatchDischargeParams:
slug_length_m: "any"
smoothing_width_m: "any"
i_sat_a: "any" # ток насыщения; NO_SATURATION_I_SAT где железа нет
r_eddy_coeff_ohm: "any" # вихревое сопротивление снаряда (× overlap)
def params_from_models(xp, models, circuit_params) -> BatchDischargeParams:
@@ -60,6 +61,7 @@ def params_from_models(xp, models, circuit_params) -> BatchDischargeParams:
slug_length_m=col([m.slug_length_m for m in models]),
smoothing_width_m=col([m.smoothing_width_m for m in models]),
i_sat_a=col(i_sat),
r_eddy_coeff_ohm=col([getattr(c, "r_eddy_coeff_ohm", 0.0) for c in circuit_params]),
)
@@ -68,13 +70,25 @@ def _ln_cosh(xp, z):
return az + xp.log1p(xp.exp(-2 * az)) - xp.log(xp.asarray(2.0))
def _derivatives(xp, q, i, x, v, p: BatchDischargeParams):
def _overlap(xp, x, p: BatchDischargeParams):
half_span = (p.coil_length_m + p.slug_length_m) / 2
w = p.smoothing_width_m
s1 = 0.5 * (1 + xp.tanh((x + half_span) / w / 2))
s2 = 0.5 * (1 + xp.tanh((half_span - x) / w / 2))
overlap = s1 * s2
d_overlap = (s1 * s2 / w) * (s2 - s1)
return s1 * s2, (s1 * s2 / w) * (s2 - s1)
def _magnetic_energy(xp, x, i, p: BatchDischargeParams):
"""W = ½L_air·I² + L_iron·overlap·(I·g G) — точная (с насыщением) энергия поля."""
overlap, _ = _overlap(xp, x, p)
z = i / p.i_sat_a
g = p.i_sat_a * xp.tanh(z)
g_integral = p.i_sat_a**2 * _ln_cosh(xp, z)
return 0.5 * p.l_air_h * i**2 + p.l_iron_coeff * overlap * (i * g - g_integral)
def _derivatives(xp, q, i, x, v, p: BatchDischargeParams):
overlap, d_overlap = _overlap(xp, x, p)
z = i / p.i_sat_a
tanhz = xp.tanh(z)
@@ -86,9 +100,10 @@ def _derivatives(xp, q, i, x, v, p: BatchDischargeParams):
dlambda_dx = p.l_iron_coeff * d_overlap * g
force = p.l_iron_coeff * d_overlap * g_integral
r_eff = p.r_total_ohm + p.r_eddy_coeff_ohm * overlap # вихревые потери снаряда × overlap
v_c = q / p.capacitance_f
d_q = -i
d_i = (v_c - i * p.r_total_ohm - dlambda_dx * v) / dlambda_di
d_i = (v_c - i * r_eff - dlambda_dx * v) / dlambda_di
d_v = force / p.mass_kg
d_x = v
return d_q, d_i, d_x, d_v
@@ -115,12 +130,15 @@ def integrate_batch_discharge(
v = xp.array(v0, dtype=xp.float64)
done = xp.zeros(n, dtype=bool)
committed = xp.zeros(n, dtype=bool) # реально скоммутировал (валидный обрыв), а не «взорвался»
past_peak = xp.zeros(n, dtype=bool) # ток уже прошёл пик и начал спадать
exit_v = xp.array(v0, dtype=xp.float64)
exit_x = xp.array(x0, dtype=xp.float64)
exit_q = xp.array(q0, dtype=xp.float64)
peak_current = xp.zeros(n, dtype=xp.float64)
energy_diss = xp.zeros(n, dtype=xp.float64)
_old_err = xp.seterr(all="ignore") if hasattr(xp, "seterr") else None # стиффные конфиги переполняют fixed-step
for _ in range(max_steps):
active = ~done
if not bool(xp.any(active)):
@@ -139,27 +157,48 @@ def integrate_batch_discharge(
x_new = x_old + dt / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
v_new = v_old + dt / 6 * (k1[3] + 2 * k2[3] + 2 * k3[3] + k4[3])
# потери R dt (трапеция) и пиковый ток — только для активных
step_diss = 0.5 * (i_old**2 + i_new**2) * params.r_total_ohm * dt
# потери I²·R_eff (с вихревыми × overlap) и пиковый ток — только для активных
overlap_old, _ = _overlap(xp, x_old, params)
overlap_new, _ = _overlap(xp, x_new, params)
r_eff_old = params.r_total_ohm + params.r_eddy_coeff_ohm * overlap_old
r_eff_new = params.r_total_ohm + params.r_eddy_coeff_ohm * overlap_new
step_diss = 0.5 * (i_old**2 * r_eff_old + i_new**2 * r_eff_new) * dt
energy_diss = energy_diss + xp.where(active, step_diss, 0.0)
peak_current = xp.maximum(peak_current, xp.where(active, xp.abs(i_new), 0.0))
past_peak = past_peak | (active & (i_old > 1.0) & (i_new < i_old))
# нуль тока: ток был положительным и стал <=0 -> разряд закончился.
# exit-значения берём интерполяцией СТАРОГО и НОВОГО состояния в точке I=0.
# ОДИН импульс: обрыв на нуле тока ИЛИ на первом локальном минимуме
# (снаряд начал подкачивать ток обратно). Что раньше.
crossed = active & (i_old > 0) & (i_new <= 0)
local_min = active & past_peak & (i_new > i_old) & ~crossed
cut = crossed | local_min
# zero-crossing: интерполяция к I=0; local_min: берём состояние минимума (old)
frac = xp.where(crossed, i_old / (i_old - i_new + 1e-30), 0.0)
exit_v = xp.where(crossed, _interp(v_old, v_new, frac), exit_v)
exit_x = xp.where(crossed, _interp(x_old, x_new, frac), exit_x)
exit_q = xp.where(crossed, _interp(q_old, q_new, frac), exit_q)
done = done | crossed
exit_v = xp.where(local_min, v_old, exit_v)
exit_x = xp.where(local_min, x_old, exit_x)
exit_q = xp.where(local_min, q_old, exit_q)
# остаточная энергия катушки при обрыве на минимуме -> в потери (freewheel)
residual = _magnetic_energy(xp, x_old, i_old, params)
energy_diss = energy_diss + xp.where(local_min, residual, 0.0)
committed = committed | cut # валидная коммутация
# продвигаем только ещё активные конфигурации
q = xp.where(active, q_new, q_old)
i = xp.where(active, i_new, i_old)
x = xp.where(active, x_new, x_old)
v = xp.where(active, v_new, v_old)
# стиффный конфиг «взорвал» fixed-step (inf/nan) -> стоп, НЕ реализуем
blew_up = active & ~cut & ~xp.isfinite(i_new)
done = done | cut | blew_up
feasible = done
# продвигаем только ещё активные и не взорвавшиеся конфигурации
advance = active & ~blew_up
q = xp.where(advance, q_new, q_old)
i = xp.where(advance, i_new, i_old)
x = xp.where(advance, x_new, x_old)
v = xp.where(advance, v_new, v_old)
if _old_err is not None and hasattr(xp, "seterr"):
xp.seterr(**_old_err)
feasible = committed # реализуемо = реально скоммутировал, а не done-по-переполнению
return {
"exit_v": exit_v,
"exit_x": exit_x,

View File

@@ -0,0 +1,176 @@
"""GPU/CPU батч-sweep одноступенчатых конфигураций.
Векторизует самую дорогую часть (разряд) сразу по N конфигурациям через
`integrate_batch_discharge` (numpy CPU / cupy GPU). Настройка каждой
конфигурации (decode + build_stage_physics) — обычный питон-цикл (быстрый),
интегрирование — один батч.
Ограничения (честно): только ОДНА ступень; триггер датчика берётся
аналитически при постоянной скорости подлёта (x_fire ≈ x_sensor + v·delay,
одинаково для оптики/Холла/индукции), для индукционного датчика применяется
та же проверка порога, что в CPU-пути. Полный конвейер (много ступеней,
точный триггер) остаётся на CPU sweep/evolve. Числа физики идентичны CPU —
через общий `build_stage_physics` и валидированный батч-интегратор.
"""
import json
import math
import random
import uuid
from dataclasses import replace
from datetime import datetime, timezone
from pathlib import Path
from gausse.components.database import ComponentDatabase
from gausse.gpu.backend import get_backend, to_cpu
from gausse.gpu.batch_integrator import integrate_batch_discharge, params_from_models
from gausse.optim.objective import MODEL_VERSION, build_detail
from gausse.optim.progress_log import ProgressLogger, default_log_path
from gausse.optim.search_space import SearchBounds, decode, genome_to_dict, sample_genome
from gausse.physics.constants import SWITCH_SURGE_FACTOR
from gausse.sim.coilgun import CoilgunResult, StageOutcome
from gausse.sim.stage import StageResult, build_stage_physics
from gausse.storage.database import insert_runs, open_connection
from gausse.storage.schema import RunRecord
def _prepare(genome, db, bounds):
"""Готовит один одноступенчатый прогон: физика + аналитический fire-state.
Возвращает dict с моделью/параметрами/начальным состоянием или
(None, причина) если конфигурация нереализуема ещё до интегрирования.
"""
config, initial_x_m, initial_v_mps = decode(genome, db, bounds)
stage = config.stages[0]
proj = config.projectile
phys = build_stage_physics(stage, proj)
x_sensor = -stage.sensor_to_coil_distance_m
fire_delay = (stage.sensor.propagation_delay_ns + stage.switch.turn_on_time_ns) * 1e-9
if stage.sensor.kind == "inductive":
peak_v = stage.sensor.sensitivity_v_per_mps * abs(initial_v_mps)
if peak_v < (stage.sensor.threshold_v or 0.0):
return None, ("инд. датчик: сигнал ниже порога", config, initial_x_m, initial_v_mps)
x_fire = x_sensor + initial_v_mps * fire_delay
q0 = phys.capacitance_f * stage.charge_voltage_v
return {
"config": config, "initial_x_m": initial_x_m, "initial_v_mps": initial_v_mps,
"phys": phys, "q0": q0, "x_fire": x_fire, "v_fire": initial_v_mps,
"energy_in": 0.5 * phys.capacitance_f * stage.charge_voltage_v**2,
"mass": proj.mass_kg, "stage": stage,
}, None
def run_gpu_sweep(
db_path: Path,
n_runs: int,
bounds: SearchBounds = SearchBounds(),
data_dir: Path | None = None,
seed: int | None = None,
prefer_gpu: bool = True,
batch_size: int = 20000,
log_path: Path | None = None,
) -> dict:
xp, backend = get_backend(prefer_gpu=prefer_gpu)
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
# форсируем одну ступень для GPU-пути
bounds = replace(bounds, min_stages=1, max_stages=1)
rng = random.Random(seed if seed is not None else random.randrange(2**31))
logger = ProgressLogger(log_path or default_log_path(db_path), mode=f"gpu-sweep({backend})", total=n_runs)
conn = open_connection(db_path)
n_feasible = 0
done = 0
try:
while done < n_runs:
n = min(batch_size, n_runs - done)
genomes = [sample_genome(db, bounds, rng) for _ in range(n)]
prepared, records = [], []
for g in genomes:
p, infeasible = _prepare(g, db, bounds)
if p is None:
_, reason, config, ix, iv = (None, *infeasible)
records.append(_record(g, db, config, None, reason, ix, iv, backend))
else:
prepared.append((g, p))
if prepared:
models = [p["phys"].inductance_model for _, p in prepared]
cps = [p["phys"].circuit_params for _, p in prepared]
params = params_from_models(xp, models, cps)
out = integrate_batch_discharge(
xp,
xp.asarray([p["q0"] for _, p in prepared]),
xp.asarray([p["x_fire"] for _, p in prepared]),
xp.asarray([p["v_fire"] for _, p in prepared]),
params, dt=2e-6, max_steps=15000,
)
exit_v = to_cpu(xp, out["exit_v"]); peak_i = to_cpu(xp, out["peak_current"])
e_diss = to_cpu(xp, out["energy_dissipated_j"]); feas = to_cpu(xp, out["feasible"])
for k, (g, p) in enumerate(prepared):
rec, ok = _finish_record(g, db, p, float(exit_v[k]), float(peak_i[k]),
float(e_diss[k]), bool(feas[k]), backend)
records.append(rec)
if ok:
n_feasible += 1
insert_runs(conn, records)
for r in records:
logger.update(r.feasible, r.efficiency)
done += n
finally:
logger.finish()
conn.close()
return {"n_runs": done, "n_feasible": n_feasible, "backend": backend}
def _finish_record(genome, db, p, exit_v, peak_i, e_diss, commutated, backend):
stage = p["stage"]
energy_in = p["energy_in"]; mass = p["mass"]; v_fire = p["v_fire"]
# surge-предел ключа (как в CPU-пути) + должна быть коммутация
surge = stage.switch.max_current_a * SWITCH_SURGE_FACTOR.get(stage.switch.kind, 4.0)
if not commutated:
return _record(genome, db, p["config"], None, "разряд не скоммутировался (батч)", p["initial_x_m"], p["initial_v_mps"], backend), False
if peak_i > surge:
return _record(genome, db, p["config"], None,
f"пиковый ток {peak_i:.0f}А > импульсного предела ключа ({surge:.0f}А)",
p["initial_x_m"], p["initial_v_mps"], backend), False
kinetic_delta = 0.5 * mass * (exit_v**2 - v_fire**2)
efficiency = kinetic_delta / energy_in if energy_in > 0 else None
result = _synth_coilgun_result(p, exit_v, peak_i, e_diss, efficiency, kinetic_delta, energy_in)
return _record(genome, db, p["config"], result, None, p["initial_x_m"], p["initial_v_mps"], backend, efficiency, exit_v), True
def _synth_coilgun_result(p, exit_v, peak_i, e_diss, efficiency, kinetic_delta, energy_in):
r = StageResult(
feasible=True, exit_v_mps=exit_v, energy_in_j=energy_in,
energy_dissipated_j=e_diss, kinetic_energy_delta_j=kinetic_delta,
)
outcome = StageOutcome(stage_index=0, result=r, global_coil_center_m=0.0,
time_offset_s=0.0, entry_x_m=p["initial_x_m"], entry_v_mps=p["v_fire"])
return CoilgunResult(
feasible=True, stage_outcomes=[outcome], exit_v_mps=exit_v,
exit_kinetic_energy_j=0.5 * p["mass"] * exit_v**2,
total_energy_in_j=energy_in, total_energy_dissipated_j=e_diss,
total_kinetic_energy_delta_j=kinetic_delta, efficiency=efficiency,
)
def _record(genome, db, config, result, reason, initial_x_m, initial_v_mps, backend, efficiency=None, exit_v=None):
detail = build_detail(config, result, db, initial_x_m, initial_v_mps, genome.tube_inner_d_m, genome.tube_wall_m) if config else {}
return RunRecord(
run_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(),
search_mode=f"gpu-sweep-{backend}",
genome_json=json.dumps(genome_to_dict(genome)),
decoded_summary_json=json.dumps(detail, ensure_ascii=False),
feasible=result is not None,
model_version=MODEL_VERSION,
infeasible_reason=reason,
efficiency=efficiency,
exit_velocity_mps=exit_v,
cost_rub=None,
energy_breakdown_json=None,
)

View File

@@ -39,7 +39,7 @@ def _tournament_select(
def _continuous_vector(genome: Genome) -> list[float]:
vec = [genome.tube_od_m]
vec = [genome.tube_inner_d_m, genome.tube_wall_m]
for stage in genome.stages:
vec.append(stage.sensor_to_coil_distance_m)
vec.append(stage.charge_voltage_fraction)
@@ -52,7 +52,9 @@ def _continuous_vector(genome: Genome) -> list[float]:
def _apply_continuous_vector(template: Genome, vec: list[float]) -> Genome:
genome = copy.deepcopy(template)
idx = 0
genome.tube_od_m = vec[idx]
genome.tube_inner_d_m = vec[idx]
idx += 1
genome.tube_wall_m = vec[idx]
idx += 1
for stage in genome.stages:
stage.sensor_to_coil_distance_m = vec[idx]

View File

@@ -88,7 +88,7 @@ def decoded_summary(genome: Genome, db: ComponentDatabase) -> dict:
}
def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, initial_x_m: float, initial_v_mps: float) -> dict:
def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, initial_x_m: float, initial_v_mps: float, tube_inner_d_m: float | None = None, tube_wall_m: float | None = None) -> dict:
"""Максимально подробная запись: параметры + вычисленная физика + результат каждой ступени.
Именно это пишется в SQLite (decoded_summary_json), чтобы по базе можно
@@ -96,7 +96,7 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
вдоль трубы, номиналы всех деталей, геометрию намотки, посчитанные
индуктивность/сопротивление/пиковый ток и что произошло на каждой ступени.
"""
outcomes_by_index = {o.stage_index: o for o in coilgun_result.stage_outcomes}
outcomes_by_index = {o.stage_index: o for o in (coilgun_result.stage_outcomes if coilgun_result else [])}
projectile = config.projectile
# абсолютные позиции центров катушек вдоль трубы (сквозная координата)
@@ -131,27 +131,37 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
"wire": stage.wire.part_id,
"wire_material": stage.wire.material,
"wire_gauge_mm": stage.wire.gauge_mm,
"wire_insulation_od_mm": stage.wire.insulation_od_mm,
"wire_url": stage.wire.url,
"switch": stage.switch.part_number,
"switch_kind": stage.switch.kind,
"switch_max_current_a": stage.switch.max_current_a,
"switch_max_voltage_v": stage.switch.max_voltage_v,
"switch_url": stage.switch.url,
"sensor": stage.sensor.part_number,
"sensor_kind": stage.sensor.kind,
"sensor_url": stage.sensor.url,
},
"capacitor_bank": {
"base_part": cap_base,
"n_series": n_series,
"n_parallel": n_parallel,
"count": n_series * n_parallel,
"single_capacitance_uf": getattr(cap, "capacitance_uf", 0) * n_series / max(n_parallel, 1),
"single_voltage_rating_v": getattr(cap, "voltage_v", 0) / max(n_series, 1),
"total_capacitance_uf": cap.capacitance_uf,
"total_voltage_rating_v": cap.voltage_v,
"total_esr_ohm": cap.esr_ohm,
"max_current_a": cap.max_current_a,
"url": getattr(cap, "url", None),
},
"winding": {
"turns_per_layer": stage.turns_per_layer,
"layers": stage.layers,
"total_turns": geometry.total_turns,
"coil_length_m": geometry.coil_length_m,
"coil_inner_diameter_m": stage.tube_od_m, # = внешний диаметр трубы
"coil_outer_diameter_m": stage.tube_od_m + 2 * geometry.radial_depth_m,
"mean_radius_m": geometry.mean_radius_m,
"radial_depth_m": geometry.radial_depth_m,
"total_wire_length_m": geometry.total_wire_length_m,
@@ -197,16 +207,26 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
entry["outcome"] = {"reached": False}
stages_detail.append(entry)
tube_od_m = config.stages[0].tube_od_m if config.stages else None
return {
"tube_od_m": config.stages[0].tube_od_m if config.stages else None,
"tube": {
"inner_diameter_m": tube_inner_d_m, # бор — снаряд летит внутри
"wall_thickness_m": tube_wall_m,
"outer_diameter_m": tube_od_m, # = внутренний диаметр катушки
},
"tube_od_m": tube_od_m, # для обратной совместимости
"tube_length_m": (coil_centers_m[-1] + 0.05) if coil_centers_m else None,
"projectile": {
"material": projectile.material.name,
"diameter_m": projectile.diameter_m,
"length_m": projectile.length_m,
"mass_kg": projectile.mass_kg,
"mass_g": projectile.mass_kg * 1000,
"mu_r": projectile.material.mu_r,
"b_sat_tesla": projectile.material.b_sat_tesla,
"resistivity_ohm_m": projectile.material.resistivity_ohm_m,
"fits_in_bore": (tube_inner_d_m is None) or (projectile.diameter_m < tube_inner_d_m),
"url": projectile.material.url,
},
"launch": {"initial_x_m": initial_x_m, "initial_v_mps": initial_v_mps},
"n_stages": len(config.stages),
@@ -220,7 +240,7 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
config, initial_x_m, initial_v_mps = decode(genome, db, bounds)
cost_rub = compute_cost_rub(config, db)
result = run_coilgun(config)
detail = build_detail(config, result, db, initial_x_m, initial_v_mps)
detail = build_detail(config, result, db, initial_x_m, initial_v_mps, genome.tube_inner_d_m, genome.tube_wall_m)
if not result.feasible:
n_stages = len(config.stages)

View File

@@ -16,7 +16,8 @@ from gausse.components.schema import CapacitorBank
from gausse.sim.coilgun import CoilgunConfig
from gausse.sim.stage import ProjectileConfig, StageConfig
TUBE_WALL_CLEARANCE_M = 0.001
# зазор между снарядом и внутренней стенкой трубы (бором), чтобы снаряд летел свободно
PROJECTILE_BORE_CLEARANCE_M = 0.0005
@dataclass(frozen=True)
@@ -31,12 +32,18 @@ class SearchBounds:
sensor_to_coil_distance_m_max: float = 0.05
inter_stage_gap_m_min: float = 0.01
inter_stage_gap_m_max: float = 0.10
tube_od_m_min: float = 0.009
tube_od_m_max: float = 0.02
# ТРУБА: внутренний диаметр (бор — снаряд летит внутри) и толщина стенки.
# Внешний диаметр = внутренний + 2×стенка = внутренний диаметр КАТУШКИ.
tube_inner_d_m_min: float = 0.005
tube_inner_d_m_max: float = 0.030
tube_wall_m_min: float = 0.001
tube_wall_m_max: float = 0.003
# СНАРЯД: диаметр обязан быть < внутр. диаметра трубы; масса до 200 г.
projectile_diameter_m_min: float = 0.004
projectile_diameter_m_max: float = 0.008
projectile_diameter_m_max: float = 0.028
projectile_length_m_min: float = 0.01
projectile_length_m_max: float = 0.03
projectile_length_m_max: float = 0.15
projectile_mass_max_kg: float = 0.2
charge_voltage_fraction_min: float = 0.5
charge_voltage_fraction_max: float = 1.0
initial_launch_velocity_mps: float = 3.0
@@ -48,6 +55,12 @@ class SearchBounds:
cap_parallel_max: int = 8
def _max_length_for_mass(diameter_m: float, density_kg_m3: float, max_mass_kg: float) -> float:
"""Максимальная длина снаряда, при которой масса не превышает max_mass_kg."""
area = math.pi * (diameter_m / 2) ** 2
return max_mass_kg / (density_kg_m3 * area)
@dataclass
class StageGene:
wire_idx: int
@@ -71,11 +84,17 @@ class ProjectileGene:
@dataclass
class Genome:
tube_od_m: float
tube_inner_d_m: float # бор трубы (снаряд летит внутри)
tube_wall_m: float # толщина стенки трубы
stages: list[StageGene]
inter_stage_gaps_m: list[float]
projectile: ProjectileGene
@property
def tube_od_m(self) -> float:
"""Внешний диаметр трубы = внутренний диаметр катушки."""
return self.tube_inner_d_m + 2 * self.tube_wall_m
def _clip(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
@@ -101,36 +120,47 @@ def sample_stage_gene(db: ComponentDatabase, bounds: SearchBounds, rng: random.R
def sample_genome(db: ComponentDatabase, bounds: SearchBounds, rng: random.Random) -> Genome:
tube_od_m = rng.uniform(bounds.tube_od_m_min, bounds.tube_od_m_max)
max_diameter = min(bounds.projectile_diameter_m_max, tube_od_m - TUBE_WALL_CLEARANCE_M)
tube_inner_d_m = rng.uniform(bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
tube_wall_m = rng.uniform(bounds.tube_wall_m_min, bounds.tube_wall_m_max)
# диаметр снаряда < внутр. диаметра трубы минус зазор
max_diameter = min(bounds.projectile_diameter_m_max, tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M)
diameter_m = rng.uniform(bounds.projectile_diameter_m_min, max(max_diameter, bounds.projectile_diameter_m_min))
material_idx = rng.randrange(len(db.projectile_materials))
density = db.projectile_materials[material_idx].density_kg_m3
# длина ограничена и границами, и максимальной массой 200 г
len_cap = min(bounds.projectile_length_m_max, _max_length_for_mass(diameter_m, density, bounds.projectile_mass_max_kg))
length_m = rng.uniform(bounds.projectile_length_m_min, max(len_cap, bounds.projectile_length_m_min))
n_stages = rng.randint(bounds.min_stages, bounds.max_stages)
stages = [sample_stage_gene(db, bounds, rng) for _ in range(n_stages)]
gaps = [
rng.uniform(bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
for _ in range(n_stages - 1)
]
projectile = ProjectileGene(
material_idx=rng.randrange(len(db.projectile_materials)),
diameter_m=diameter_m,
length_m=rng.uniform(bounds.projectile_length_m_min, bounds.projectile_length_m_max),
projectile = ProjectileGene(material_idx=material_idx, diameter_m=diameter_m, length_m=length_m)
return Genome(
tube_inner_d_m=tube_inner_d_m, tube_wall_m=tube_wall_m,
stages=stages, inter_stage_gaps_m=gaps, projectile=projectile,
)
return Genome(tube_od_m=tube_od_m, stages=stages, inter_stage_gaps_m=gaps, projectile=projectile)
def repair(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Genome:
"""Приводит геном в границы после мутации/скрещивания (клэмп, не отбраковка)."""
genome.tube_od_m = _clip(genome.tube_od_m, bounds.tube_od_m_min, bounds.tube_od_m_max)
max_diameter = max(genome.tube_od_m - TUBE_WALL_CLEARANCE_M, bounds.projectile_diameter_m_min)
genome.projectile.diameter_m = _clip(
genome.projectile.diameter_m, bounds.projectile_diameter_m_min, max_diameter
)
genome.projectile.length_m = _clip(
genome.projectile.length_m, bounds.projectile_length_m_min, bounds.projectile_length_m_max
)
genome.tube_inner_d_m = _clip(genome.tube_inner_d_m, bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
genome.tube_wall_m = _clip(genome.tube_wall_m, bounds.tube_wall_m_min, bounds.tube_wall_m_max)
genome.projectile.material_idx = genome.projectile.material_idx % len(db.projectile_materials)
# снаряд обязан влезать в бор трубы
max_diameter = max(genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M, bounds.projectile_diameter_m_min)
max_diameter = min(max_diameter, bounds.projectile_diameter_m_max)
genome.projectile.diameter_m = _clip(genome.projectile.diameter_m, bounds.projectile_diameter_m_min, max_diameter)
# длина в границах И под массой ≤ 200 г
density = db.projectile_materials[genome.projectile.material_idx].density_kg_m3
len_cap = min(bounds.projectile_length_m_max, _max_length_for_mass(genome.projectile.diameter_m, density, bounds.projectile_mass_max_kg))
genome.projectile.length_m = _clip(genome.projectile.length_m, bounds.projectile_length_m_min, max(len_cap, bounds.projectile_length_m_min))
for stage in genome.stages:
stage.wire_idx %= len(db.wires)
stage.capacitor_idx %= len(db.capacitors)
@@ -187,7 +217,7 @@ def decode(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> tuple
capacitor=capacitor,
switch=switch,
sensor=sensor,
tube_od_m=genome.tube_od_m,
tube_od_m=genome.tube_od_m, # внешний диаметр трубы = внутр. диаметр катушки
turns_per_layer=gene.turns_per_layer,
layers=gene.layers,
sensor_to_coil_distance_m=gene.sensor_to_coil_distance_m,
@@ -209,7 +239,9 @@ def mutate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds, rng: ran
child = copy.deepcopy(genome)
if rng.random() < rate:
child.tube_od_m = rng.uniform(bounds.tube_od_m_min, bounds.tube_od_m_max)
child.tube_inner_d_m = rng.uniform(bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
if rng.random() < rate:
child.tube_wall_m = rng.uniform(bounds.tube_wall_m_min, bounds.tube_wall_m_max)
for stage in child.stages:
if rng.random() < rate:
@@ -295,7 +327,8 @@ def crossover(parent_a: Genome, parent_b: Genome, rng: random.Random) -> Genome:
tube_source = parent_a if rng.random() < 0.5 else parent_b
return Genome(
tube_od_m=tube_source.tube_od_m,
tube_inner_d_m=tube_source.tube_inner_d_m,
tube_wall_m=tube_source.tube_wall_m,
stages=stages,
inter_stage_gaps_m=gaps,
projectile=copy.deepcopy(projectile_source.projectile),
@@ -304,7 +337,8 @@ def crossover(parent_a: Genome, parent_b: Genome, rng: random.Random) -> Genome:
def genome_to_dict(genome: Genome) -> dict:
return {
"tube_od_m": genome.tube_od_m,
"tube_inner_d_m": genome.tube_inner_d_m,
"tube_wall_m": genome.tube_wall_m,
"stages": [vars(s) for s in genome.stages],
"inter_stage_gaps_m": genome.inter_stage_gaps_m,
"projectile": vars(genome.projectile),
@@ -312,8 +346,16 @@ def genome_to_dict(genome: Genome) -> dict:
def genome_from_dict(data: dict) -> Genome:
# обратная совместимость: старые геномы в БД несли tube_od_m без стенки
if "tube_inner_d_m" in data:
tube_inner_d_m = data["tube_inner_d_m"]
tube_wall_m = data["tube_wall_m"]
else:
tube_wall_m = 0.0015
tube_inner_d_m = max(data.get("tube_od_m", 0.012) - 2 * tube_wall_m, 0.005)
return Genome(
tube_od_m=data["tube_od_m"],
tube_inner_d_m=tube_inner_d_m,
tube_wall_m=tube_wall_m,
stages=[StageGene(**s) for s in data["stages"]],
inter_stage_gaps_m=list(data["inter_stage_gaps_m"]),
projectile=ProjectileGene(**data["projectile"]),

View File

@@ -26,6 +26,14 @@ class StageCircuitParams:
capacitance_f: float
r_total_ohm: float
mass_kg: float
# отражённое сопротивление вихревых токов снаряда; действует × overlap(x)
# (только пока снаряд в катушке). См. physics/losses.py.
r_eddy_coeff_ohm: float = 0.0
def effective_resistance(inductance_model: CoilInductanceModel, params: StageCircuitParams, x) -> float:
"""R контура = провод+ESR+ключ (r_total) плюс вихревые потери снаряда, когда он в катушке."""
return params.r_total_ohm + params.r_eddy_coeff_ohm * inductance_model.overlap_fraction(x)
def derivatives(
@@ -37,10 +45,11 @@ def derivatives(
q, current, x, v = state
dlambda_di = inductance_model.dlambda_di(x, current)
dlambda_dx = inductance_model.dlambda_dx(x, current)
r_eff = effective_resistance(inductance_model, params, x)
v_c = q / params.capacitance_f
d_q = -current
d_i = (v_c - current * params.r_total_ohm - dlambda_dx * v) / dlambda_di
d_i = (v_c - current * r_eff - dlambda_dx * v) / dlambda_di
force = inductance_model.force_newtons(x, current)
d_v = force / params.mass_kg
return [d_q, d_i, v, d_v]
@@ -52,3 +61,24 @@ def zero_current_crossing_event(t: float, state: np.ndarray, *_args) -> float:
zero_current_crossing_event.terminal = True
zero_current_crossing_event.direction = -1.0
def current_local_min_event(t: float, state: np.ndarray, inductance_model, params) -> float:
"""Первый локальный МИНИМУМ тока после пика — там снаряд начинает
«подкачивать» ток обратно (генераторный режим за центром катушки).
Тиристор проводит ОДИН импульс: как только ток, спадая, перестаёт падать
и пошёл бы вверх, ключ запирается (в реальности так и есть — за один
выстрел катушка стреляет один раз, конденсатор не перезаряжается за мкс).
Событие = dI/dt, ловим переход снизу вверх (direction=+1).
"""
q, current, x, v = state
dlambda_di = inductance_model.dlambda_di(x, current)
dlambda_dx = inductance_model.dlambda_dx(x, current)
r_eff = effective_resistance(inductance_model, params, x)
v_c = q / params.capacitance_f
return (v_c - current * r_eff - dlambda_dx * v) / dlambda_di
current_local_min_event.terminal = True
current_local_min_event.direction = 1.0

View File

@@ -175,6 +175,16 @@ class CoilInductanceModel:
"""Сила из coenergy: F = ∂/∂x ∫₀ᴵ λ dI' = L_iron·overlap'(x)·G(I)."""
return self.l_iron_coeff * self.d_overlap_dx(x) * self._g_integral(i)
def magnetic_energy(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
"""Энергия магнитного поля W = ∫₀ᴵ i·(∂λ/∂i) di (с учётом насыщения).
= ½·L_air·I² + L_iron·overlap(x)·(I·g(I) G(I)). Нужна для точного
учёта остаточной энергии катушки при обрыве разряда (одиночный импульс).
"""
return 0.5 * self.l_air_h * i**2 + self.l_iron_coeff * self.overlap_fraction(x) * (
i * self._g(i) - self._g_integral(i)
)
def effective_field_current_a(self, i: float) -> float:
"""'Насыщенный' эффективный ток для оценки поля (поле ~ g(I), кап B_sat)."""
return float(self._g(np.asarray(float(i))))

View File

@@ -0,0 +1,55 @@
"""Потери в железе снаряда: вихревые токи + гистерезис (первого порядка).
Сплошной стальной снаряд в импульсном поле теряет энергию на вихревые токи
(греется). Модель: снаряд — короткозамкнутый виток (вторичка трансформатора),
резистивно-доминированный на частоте импульса. Отражённое в катушку
сопротивление R_eddy = (ω·M)²/R_e добавляется в контур, ПОКА снаряд в
катушке (× overlap(x)) — энергия честно уходит в нагрев снаряда, а не в
кинетику. Это и есть тот канал потерь, из-за отсутствия которого КПД был
нереально высоким.
ВАЖНО (честно): это оценка первого порядка. Точные вихревые потери требуют
решения уравнения диффузии поля в снаряде (скин-эффект, частотная
зависимость). Здесь — сосредоточенный виток + характерная частота импульса
ω=1/√(L·C). Геометрия вихревого контура (R_e) и коэффициент связи оценены
приближённо; величина может отличаться в разы. Коэффициенты вынесены, чтобы
их можно было уточнить по реальным замерам.
"""
import math
from gausse.physics.constants import MU_0
# доля потока катушки, реально сцепленная с вихревым контуром снаряда (k<1).
# Снаряд у́же катушки и захватывает не весь поток — грубая оценка.
EDDY_COUPLING_FACTOR = 0.5
# гистерезис: энергия на площадь петли B-H за перемагничивание, Дж/м³/цикл.
# Для конструкционной стали ~сотни Дж/м³; берём ~300 (справочная оценка).
HYSTERESIS_LOSS_J_PER_M3 = 300.0
def eddy_reflected_resistance_ohm(
slug_radius_m: float,
slug_length_m: float,
slug_resistivity_ohm_m: float,
mu_eff: float,
total_turns: int,
coil_length_m: float,
char_omega_rad_s: float,
) -> float:
"""Отражённое в катушку сопротивление вихревого контура снаряда (Ом)."""
a_slug = math.pi * slug_radius_m**2
turns_per_m = total_turns / coil_length_m
# взаимная индуктивность катушка↔снаряд (снаряд как 1 виток)
mutual = EDDY_COUPLING_FACTOR * MU_0 * mu_eff * turns_per_m * a_slug
# сопротивление сосредоточенного вихревого контура (кольцевой путь в снаряде)
r_eddy_loop = 2 * math.pi * slug_resistivity_ohm_m / slug_length_m
if r_eddy_loop <= 0:
return 0.0
return (char_omega_rad_s * mutual) ** 2 / r_eddy_loop
def hysteresis_energy_j(slug_radius_m: float, slug_length_m: float, n_cycles: float = 1.0) -> float:
"""Энергия гистерезиса за выстрел (Дж) — вторичный канал, обычно << вихревых."""
volume = math.pi * slug_radius_m**2 * slug_length_m
return HYSTERESIS_LOSS_J_PER_M3 * volume * n_cycles

View File

@@ -21,44 +21,57 @@ from gausse.sim.coilgun import CoilgunConfig, CoilgunResult
def _build_global_timeline(result: CoilgunResult, config: CoilgunConfig, fps: int):
"""Возвращает (times, positions, per_stage_current_at_time) с равномерным шагом."""
segments_t = []
segments_x = []
segments_i = [] # ток КАЖДОЙ катушки в момент времени (список массивов той же длины, что t)
"""Непрерывная траектория снаряда: коаст (по инерции) + разряды по ступеням.
Строится ПОСЛЕДОВАТЕЛЬНО (без сортировки по времени, которая рвала
траекторию): между разрядами снаряд летит по инерции от конца прошлого
разряда до начала следующего; во время разряда берём точно посчитанные
discharge_x/v/i. Глобальная координата = центр катушки ступени + локальный
discharge_x. Ток каждой катушки задан только в окне её собственного разряда.
"""
n_stages = len(config.stages)
seg_t, seg_x = [], []
seg_i = [[] for _ in range(n_stages)]
t_cursor = 0.0
prev_global_x = None
prev_v = None
for outcome in result.stage_outcomes:
r = outcome.result
# Баллистический подлёт к датчику: постоянная скорость, восстанавливается аналитически
# (в модели v1 нет трения на этом участке — см. sim/stage.py::_ballistic_derivatives).
t_sensor = r.t_sensor_s if r.t_sensor_s is not None else 0.0
flight_t = np.linspace(0.0, t_sensor, max(int(t_sensor * fps) + 2, 2))
flight_x = outcome.entry_x_m + outcome.entry_v_mps * flight_t
flight_global_t = outcome.time_offset_s + flight_t
flight_global_x = outcome.global_coil_center_m + flight_x
flight_currents = [np.zeros_like(flight_t) for _ in range(n_stages)]
flight_currents[outcome.stage_index] = np.zeros_like(flight_t)
segments_t.append(flight_global_t)
segments_x.append(flight_global_x)
segments_i.append(flight_currents)
if r.discharge_t is None or r.discharge_x is None:
continue
disch_global_x = outcome.global_coil_center_m + r.discharge_x
start_global_x = float(disch_global_x[0])
if r.discharge_t is not None:
discharge_global_t = outcome.time_offset_s + (r.t_fire_s or 0.0) + r.discharge_t
discharge_global_x = outcome.global_coil_center_m + r.discharge_x
discharge_currents = [np.zeros_like(r.discharge_t) for _ in range(n_stages)]
discharge_currents[outcome.stage_index] = r.discharge_i
segments_t.append(discharge_global_t)
segments_x.append(discharge_global_x)
segments_i.append(discharge_currents)
# коаст по инерции от конца прошлого разряда до начала этого
if prev_global_x is not None and prev_v and prev_v > 1e-6:
coast_dist = start_global_x - prev_global_x
coast_dur = max(coast_dist / prev_v, 0.0)
n_coast = max(int(coast_dur * fps) + 2, 2)
ct = np.linspace(0.0, coast_dur, n_coast)
seg_t.append(t_cursor + ct)
seg_x.append(np.linspace(prev_global_x, start_global_x, n_coast))
for s in range(n_stages):
seg_i[s].append(np.zeros(n_coast))
t_cursor += coast_dur
times = np.concatenate(segments_t)
positions = np.concatenate(segments_x)
currents = [np.concatenate([seg[stage] for seg in segments_i]) for stage in range(n_stages)]
# окно разряда: точная траектория + ток именно этой катушки
seg_t.append(t_cursor + r.discharge_t)
seg_x.append(disch_global_x)
for s in range(n_stages):
seg_i[s].append(r.discharge_i if s == outcome.stage_index else np.zeros_like(r.discharge_t))
t_cursor += float(r.discharge_t[-1])
order = np.argsort(times)
times = times[order]
positions = positions[order]
currents = [c[order] for c in currents]
prev_global_x = float(disch_global_x[-1])
prev_v = float(r.discharge_v[-1]) if r.discharge_v is not None else outcome.entry_v_mps
if not seg_t:
raise ValueError("нет данных разряда для анимации")
times = np.concatenate(seg_t)
positions = np.concatenate(seg_x)
currents = [np.concatenate(seg_i[s]) for s in range(n_stages)]
return times, positions, currents
@@ -81,20 +94,30 @@ def animate_run(result: CoilgunResult, config: CoilgunConfig, out_path: str, fps
ax.set_yticks([])
ax.set_title("Пролёт снаряда через ступени")
tube_line = ax.axhline(0, color="gray", linewidth=2, zorder=1)
coil_dots = [
ax.scatter([c * 1e3], [0], s=400, c="lightgray", edgecolors="black", zorder=2)
for c in coil_centers
]
slug_dot = ax.scatter([], [], s=150, c="red", zorder=3)
time_text = ax.text(0.02, 0.9, "", transform=ax.transAxes)
ax.axhline(0, color="#888", linewidth=3, zorder=1) # труба
coil_dots = []
for k, c in enumerate(coil_centers):
dot = ax.scatter([c * 1e3], [0], s=600, c="lightgray", edgecolors="#333", linewidths=1.5, zorder=2)
ax.annotate(f"катушка {k}", (c * 1e3, 0), textcoords="offset points", xytext=(0, 22),
ha="center", fontsize=8, color="#555")
coil_dots.append(dot)
slug_dot = ax.scatter([], [], s=180, c="#1f4e8c", edgecolors="white", linewidths=1, zorder=3)
time_text = ax.text(0.02, 0.88, "", transform=ax.transAxes, fontsize=10)
def _coil_color(intensity):
# выкл -> светло-серый; максимум тока -> яркое золото (свечение поля)
t = min(max(intensity, 0.0), 1.0)
off = np.array([0.83, 0.83, 0.83])
on = np.array([1.0, 0.78, 0.0])
return tuple(off + (on - off) * t)
def update(frame_i):
idx = frame_idx[frame_i]
slug_dot.set_offsets([[positions[idx] * 1e3, 0]])
for stage_idx, dots in enumerate(coil_dots):
intensity = abs(currents[stage_idx][idx]) / max_current_per_stage[stage_idx]
dots.set_color(plt.cm.autumn(1.0 - min(intensity, 1.0)))
dots.set_color(_coil_color(intensity))
dots.set_sizes([600 + 400 * min(intensity, 1.0)]) # чуть раздувается при разряде
time_text.set_text(f"t = {times[idx] * 1e3:.3f} мс")
return [slug_dot, time_text, *coil_dots]

View File

@@ -15,6 +15,7 @@ class BomLine:
unit_price_rub: float
total_price_rub: float
note: str = ""
url: str | None = None # ссылка на магазин, где купить
def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
@@ -30,7 +31,8 @@ def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
quantity=f"{wire_length_m:.2f} м",
unit_price_rub=stage.wire.price_per_m,
total_price_rub=wire_length_m * stage.wire.price_per_m,
note=f"{geometry.total_turns} витков ({stage.turns_per_layer}x{stage.layers})",
note=f"{geometry.total_turns} витков ({stage.turns_per_layer}x{stage.layers}), ⌀{stage.wire.gauge_mm}мм",
url=stage.wire.url,
)
)
cap = stage.capacitor
@@ -47,6 +49,7 @@ def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
unit_price_rub=unit_price,
total_price_rub=cap.price,
note=f"батарея {cap.capacitance_uf:.0f}мкФ {cap.voltage_v:.0f}В, заряд {stage.charge_voltage_v:.0f}В, макс.ток {cap.max_current_a:.0f}А",
url=getattr(cap, "url", None),
)
)
lines.append(
@@ -56,7 +59,8 @@ def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
quantity="1 шт",
unit_price_rub=stage.switch.price,
total_price_rub=stage.switch.price,
note=stage.switch.kind,
note=f"{stage.switch.kind}, {stage.switch.max_current_a:.0f}А / {stage.switch.max_voltage_v:.0f}В",
url=stage.switch.url,
)
)
lines.append(
@@ -67,6 +71,7 @@ def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
unit_price_rub=stage.sensor.price,
total_price_rub=stage.sensor.price,
note=stage.sensor.kind,
url=stage.sensor.url,
)
)
@@ -79,6 +84,7 @@ def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
unit_price_rub=config.projectile.material.price_per_kg,
total_price_rub=mass_kg * config.projectile.material.price_per_kg,
note=f"{config.projectile.diameter_m * 1000:.1f}мм x {config.projectile.length_m * 1000:.1f}мм",
url=config.projectile.material.url,
)
)
return lines
@@ -89,12 +95,12 @@ def total_cost_rub(lines: list[BomLine]) -> float:
def render_bom_text(lines: list[BomLine]) -> str:
rows = ["Ступень\tДеталь\tКол-во\tЦена/ед\tИтого\tПримечание"]
rows = ["Ступень\tДеталь\tКол-во\tЦена/ед\tИтого\tПримечание\tКупить"]
for line in lines:
stage_label = "-" if line.stage_index is None else str(line.stage_index)
rows.append(
f"{stage_label}\t{line.part}\t{line.quantity}\t{line.unit_price_rub:.2f}\t"
f"{line.total_price_rub:.2f}\t{line.note}"
f"{line.total_price_rub:.2f}\t{line.note}\t{line.url or ''}"
)
rows.append(f"\t\t\t\tИТОГО: {total_cost_rub(lines):.2f}\t")
rows.append(f"\t\t\t\tИТОГО: {total_cost_rub(lines):.2f}\t\t")
return "\n".join(rows)

View File

@@ -30,6 +30,7 @@ from gausse.physics.inductance import (
)
from gausse.physics.constants import SWITCH_SURGE_FACTOR
from gausse.physics.force import saturation_scale, solenoid_field_estimate_tesla
from gausse.physics.losses import eddy_reflected_resistance_ohm
from gausse.physics.sensors import (
inductive_signal_peak_v,
make_inductive_sensor_event,
@@ -117,16 +118,26 @@ 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
@dataclass(frozen=True)
class StagePhysics:
"""Физика ступени: индуктивная модель + параметры контура + геометрия.
Один источник истины для CPU-пути (run_stage) и GPU-батча — оба строят
физику через `build_stage_physics`, чтобы числа не расходились.
"""
inductance_model: CoilInductanceModel
circuit_params: StageCircuitParams
geometry: object
r_total_ohm: float
r_eddy_coeff_ohm: float
capacitance_f: float
mu_eff: float
def build_stage_physics(stage: StageConfig, projectile: ProjectileConfig) -> StagePhysics:
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(
@@ -136,9 +147,18 @@ def run_stage(
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)
char_omega = 1.0 / math.sqrt(l_air_h * capacitance_f)
r_eddy_coeff = eddy_reflected_resistance_ohm(
slug_radius_m=projectile.diameter_m / 2,
slug_length_m=projectile.length_m,
slug_resistivity_ohm_m=projectile.material.resistivity_ohm_m,
mu_eff=mu_eff,
total_turns=geometry.total_turns,
coil_length_m=geometry.coil_length_m,
char_omega_rad_s=char_omega,
)
inductance_model = CoilInductanceModel(
l_air_h=l_air_h,
coil_length_m=geometry.coil_length_m,
@@ -148,6 +168,38 @@ def run_stage(
total_turns=geometry.total_turns,
b_sat_tesla=projectile.material.b_sat_tesla,
)
circuit_params = StageCircuitParams(
capacitance_f=capacitance_f,
r_total_ohm=r_total_ohm,
mass_kg=projectile.mass_kg,
r_eddy_coeff_ohm=r_eddy_coeff,
)
return StagePhysics(
inductance_model=inductance_model,
circuit_params=circuit_params,
geometry=geometry,
r_total_ohm=r_total_ohm,
r_eddy_coeff_ohm=r_eddy_coeff,
capacitance_f=capacitance_f,
mu_eff=mu_eff,
)
def run_stage(
entry_x_m: float,
entry_v_mps: float,
stage: StageConfig,
projectile: ProjectileConfig,
) -> StageResult:
mass_kg = projectile.mass_kg
phys = build_stage_physics(stage, projectile)
geometry = phys.geometry
r_total_ohm = phys.r_total_ohm
r_eddy_coeff = phys.r_eddy_coeff_ohm
capacitance_f = phys.capacitance_f
mu_eff = phys.mu_eff
inductance_model = phys.inductance_model
wire_od_m = stage.wire.insulation_od_mm / 1000
x_sensor_m = -stage.sensor_to_coil_distance_m
@@ -192,36 +244,47 @@ def run_stage(
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,
)
circuit_params = phys.circuit_params
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,
events=(circuit.zero_current_crossing_event, circuit.current_local_min_event),
rtol=1e-8,
atol=1e-11,
)
if len(discharge_sol.t_events[0]) == 0:
# какое из событий оборвало разряд первым
term_candidates = []
for ev_idx in range(2):
if len(discharge_sol.t_events[ev_idx]) > 0:
term_candidates.append((discharge_sol.t_events[ev_idx][0], discharge_sol.y_events[ev_idx][0]))
if not term_candidates:
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]
term_candidates.sort(key=lambda p: p[0])
q_final, i_final, x_final, v_final = term_candidates[0][1]
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)
)
# потери = I²·R_eff(x), где R_eff включает вихревые потери снаряда (× overlap)
overlap_during = inductance_model.overlap_fraction(discharge_sol.y[2])
r_eff_during = r_total_ohm + r_eddy_coeff * overlap_during
resistive_diss = float(np.trapezoid(discharge_sol.y[1] ** 2 * r_eff_during, discharge_sol.t))
# при обрыве на минимуме тока в катушке остаётся энергия магнитного поля —
# её гасит обратный диод (freewheel), учитываем как потери, чтобы энергия
# сходилась. Считаем ТОЧНО (с насыщением), а не ½LI².
residual_inductor_j = float(inductance_model.magnetic_energy(x_final, i_final))
energy_dissipated_j = resistive_diss + residual_inductor_j
kinetic_before_j = 0.5 * mass_kg * v_fire_mps**2
kinetic_after_j = 0.5 * mass_kg * v_final**2

45
tests/test_batch_sweep.py Normal file
View File

@@ -0,0 +1,45 @@
import json
from gausse.components.database import ComponentDatabase
from gausse.gpu.batch_sweep import run_gpu_sweep
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
from gausse.sim.coilgun import run_coilgun
from gausse.storage.database import count_runs, fetch_runs, open_connection
DB = ComponentDatabase.load()
def test_gpu_sweep_records_all_and_writes_single_stage(tmp_path):
db_path = tmp_path / "gpu.sqlite3"
summary = run_gpu_sweep(db_path, n_runs=300, seed=3, prefer_gpu=False, batch_size=300)
assert summary["n_runs"] == 300
conn = open_connection(db_path)
assert count_runs(conn) == 300
rows = fetch_runs(conn)
# GPU-путь одноступенчатый
for r in rows:
g = genome_from_dict(json.loads(r.genome_json))
assert len(g.stages) == 1
assert r.search_mode.startswith("gpu-sweep")
def test_gpu_sweep_matches_cpu_within_tolerance(tmp_path):
"""Честная сверка: GPU-батч должен давать те же exit_v, что CPU run_coilgun."""
db_path = tmp_path / "gpu.sqlite3"
run_gpu_sweep(db_path, n_runs=1500, seed=5, prefer_gpu=False, batch_size=1500)
conn = open_connection(db_path)
bounds = SearchBounds(min_stages=1, max_stages=1)
feasible = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=10)
assert len(feasible) >= 3, "нужно несколько реализуемых для сверки"
checked = 0
for r in feasible:
g = genome_from_dict(json.loads(r.genome_json))
config, _, _ = decode(g, DB, bounds)
cpu = run_coilgun(config)
if not cpu.feasible:
continue
# фикс.шаг батча vs адаптивный solve_ivp + аналитический триггер: ~2%
assert abs(r.exit_velocity_mps - cpu.exit_v_mps) <= 0.02 * abs(cpu.exit_v_mps) + 0.1, \
f"GPU {r.exit_velocity_mps:.2f} vs CPU {cpu.exit_v_mps:.2f}"
checked += 1
assert checked >= 3

View File

@@ -4,7 +4,7 @@ import pytest
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import (
TUBE_WALL_CLEARANCE_M,
PROJECTILE_BORE_CLEARANCE_M,
SearchBounds,
crossover,
decode,
@@ -25,8 +25,15 @@ def test_sample_genome_respects_bounds():
genome = sample_genome(DB, BOUNDS, rng)
assert BOUNDS.min_stages <= len(genome.stages) <= BOUNDS.max_stages
assert len(genome.inter_stage_gaps_m) == len(genome.stages) - 1
assert BOUNDS.tube_od_m_min <= genome.tube_od_m <= BOUNDS.tube_od_m_max
assert genome.projectile.diameter_m <= genome.tube_od_m - TUBE_WALL_CLEARANCE_M
assert BOUNDS.tube_inner_d_m_min <= genome.tube_inner_d_m <= BOUNDS.tube_inner_d_m_max
assert genome.tube_od_m == pytest.approx(genome.tube_inner_d_m + 2 * genome.tube_wall_m)
# снаряд влезает в бор трубы
assert genome.projectile.diameter_m <= genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M + 1e-9
# масса снаряда не больше 200 г
import math as _m
mat = DB.projectile_materials[genome.projectile.material_idx]
mass = _m.pi * (genome.projectile.diameter_m / 2) ** 2 * genome.projectile.length_m * mat.density_kg_m3
assert mass <= BOUNDS.projectile_mass_max_kg + 1e-6
for stage in genome.stages:
assert 0 <= stage.wire_idx < len(DB.wires)
assert BOUNDS.turns_per_layer_min <= stage.turns_per_layer <= BOUNDS.turns_per_layer_max
@@ -53,8 +60,8 @@ def test_mutate_keeps_genome_within_bounds():
genome = mutate(genome, DB, BOUNDS, rng, rate=0.5)
assert BOUNDS.min_stages <= len(genome.stages) <= BOUNDS.max_stages
assert len(genome.inter_stage_gaps_m) == len(genome.stages) - 1
assert BOUNDS.tube_od_m_min <= genome.tube_od_m <= BOUNDS.tube_od_m_max
assert genome.projectile.diameter_m <= genome.tube_od_m - TUBE_WALL_CLEARANCE_M + 1e-9
assert BOUNDS.tube_inner_d_m_min <= genome.tube_inner_d_m <= BOUNDS.tube_inner_d_m_max
assert genome.projectile.diameter_m <= genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M + 1e-9
# decode должен всегда успевать без исключений после repair
decode(genome, DB, BOUNDS)

View File

@@ -38,7 +38,7 @@ def _switch(on_resistance_ohm: float = 0.02) -> SwitchSpec:
)
def _run(wire, switch, capacitor=CAPACITOR):
def _run(wire, switch, capacitor=CAPACITOR, projectile=PROJECTILE):
# turns_per_layer выбран так, чтобы длина катушки (~1.7см) была сравнима
# со снарядом (2см) — иначе снаряд, войдя глубоко внутрь длинной катушки,
# оказывается в плоской зоне перекрытия (dL/dx=0) и сила не действует.
@@ -53,7 +53,7 @@ def _run(wire, switch, capacitor=CAPACITOR):
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)
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():
@@ -66,8 +66,16 @@ def test_energy_conserved_exactly_when_lossless():
part_number="c-lossless", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.0,
max_current_a=300.0, price=300.0, source="test",
)
# снаряд без вихревых потерь: огромное удельное сопротивление -> R_eddy≈0,
# чтобы «без потерь» действительно означало отсутствие всех каналов диссипации
no_eddy_steel = ProjectileMaterialSpec(
name="steel-no-eddy", density_kg_m3=7850.0, mu_r=200.0, b_sat_tesla=1.8,
price_per_kg=100.0, source="test", resistivity_ohm_m=1e12,
)
no_eddy_projectile = ProjectileConfig(material=no_eddy_steel, diameter_m=0.008, length_m=0.02)
result = _run(
_wire(resistivity_ohm_m=0.0), _switch(on_resistance_ohm=0.0), capacitor=lossless_capacitor
_wire(resistivity_ohm_m=0.0), _switch(on_resistance_ohm=0.0),
capacitor=lossless_capacitor, projectile=no_eddy_projectile,
)
assert result.feasible, result.reason
assert result.energy_dissipated_j == pytest.approx(0.0, abs=1e-9)