Compare commits

...

34 Commits

Author SHA1 Message Date
jze9
6b52ba4660 Топ скоростей снова обновляется: рекорд скорости острова тоже пишется в базу
Регрессия выборочной записи (7732577): полная строка писалась только при
улучшении КПД лучшего генома острова, а рекордсмены СКОРОСТИ — почти
всегда прожорливые геномы с низким КПД (57.8 м/с при 0.5%), которые
КПД-рекорд не улучшают. Топ «по скорости» на дашборде застыл на сутки,
хотя эволюция продолжала находить всё более быстрые конфигурации
(пользователь поймал: «даже случайно никто не стал быстрее лететь»).
Теперь на каждом острове отслеживаются ДВА записываемых рекорда — КПД и
скорость; это по-прежнему максимум пара строк на остров за поколение.
2026-07-13 17:03:14 +05:00
jze9
9a783ec8aa Дашборд: колонка «Когда найден» в топе конфигураций
Без времени находки обновление топа было невидимо: новая строка-рекорд
13.63% глазом неотличима от старой 13.44%, и пользователю казалось, что
таблица застыла («тир-лист не обновлялся давно» при живом конвейере).
Свежие записи (моложе часа) подсвечиваются зелёным.
2026-07-13 16:51:52 +05:00
jze9
7732577f31 Эволюция: в базу только улучшения лучших + счётчики по всем; выборка 10000
По требованию пользователя «не сохранять всё подряд, а только то, что
нужно для эволюции»: рядовые геномы поколения больше НЕ пишутся строками
в runs — писатель получает лёгкие StatsOnly-инкременты (счётчики,
гистограмма, причины отказов на дашборде остаются честными по ВСЕМ
оценкам), а полная строка пишется только когда лучший геном острова
улучшился (+ полировка). Вставка миллионов строк в HDD-базу с 6 индексами
была узким местом конвейера: GPU считал цикл ~23 мин, дозапись хвоста
очереди шла часами (~150 строк/с), реальная скорость была ~165 оценок/с
вместо ~1300/с. gpu_record_worker стал не нужен — удалён.

Выборка эволюции уменьшена до 10000 на поколение (8 островов x 1250,
было 8x4000=32000) — по просьбе пользователя, чтобы поколения сменялись
быстрее; поколений за цикл теперь 120 (было 60) — при освобождённом
писателе цикл углубляется вдвое дальше за то же время.
2026-07-12 22:29:17 +05:00
jze9
c232f1d387 Ревью хранения: честная детализация прогона + общий rebuild + чистка мёртвого кода
По итогам код-ревью коммита 5c11999:
- run_detail: шапка и энергобаланс — СОХРАНЁННЫЕ при прогоне значения
  (пересчитанный на лету detail мог противоречить им: GPU-строки и старые
  model_version считались другим движком/моделью, а energy_breakdown
  прятался за feasible пересчёта). Пересчёт теперь явно помечен
  recomputed_with_model_version и обёрнут в try/except — /api/run был
  единственным эндпоинтом, где падение пересимуляции роняло ответ.
- web/rebuild.py: один общий путь «строка БД -> симуляция» для детализации
  и рендера (раньше два дубля; клик по прогону гонял одну и ту же
  симуляцию до 5 раз и перечитывал базу компонентов с диска на каждый
  запрос — теперь кэш компонентов + кэш последних симуляций по run_id).
- Мёртвый код после 5c11999: ветка build_details=True (единственный
  вызов передавал False; дефолт True возвращал бы как раз ту дорогую
  запись, от которой уходили), _FEASIBLE, параметр bounds у _record,
  поле EvaluationResult.detail и его мёртвая ветка в build_run_record.
- overview: n_stages=None (морда покажет «—»), а не 0, когда сводки нет.
- PLAN.md/schema.py: убраны утверждения, что полный build_detail хранится
  в каждой записи (устарело с 5c11999), отмечена старая форма записей.
2026-07-12 12:31:37 +05:00
jze9
5c119990b7 Хранить только genome_json+скаляры на прогон, детали строить на лету
decoded_summary_json раньше писал полную build_detail() (геометрия, URL,
электрика по каждой ступени) на каждый из миллионов прогонов — это и
душило CPU при GPU-эволюции (пул процессов нужен был только чтобы успевать
за GPU), и разогнало базу до 70ГБ на 12.9М строк, пока диск не забился под
завязку. genome_json уже достаточен для полного воспроизведения — теперь
пишем дешёвую decoded_summary(), а run_detail() на веб-морде пересчитывает
полную физику по клику (как уже делает render.py для графиков/анимации).
2026-07-11 21:50:54 +05:00
jze9
e394fd6d59 Дашборд: раздельные карточки «лучший за всё время» и «лучший текущего цикла»
Лог показывает лучший ТЕКУЩЕГО цикла (свежая популяция карабкается заново),
дашборд — за всё время; без подписи числа выглядели несвязанными.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:42:43 +05:00
jze9
17323335e0 evolve_forever: 8 островов по 4000 (батч 32к на GPU)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:32:00 +05:00
jze9
51464b2139 Островная эволюция: K популяций одним GPU-батчем + миграция по кольцу
GPU на маленьких батчах упирается в накладные на запуск ядер (1070: батч
4000 = 306/с, 32000 = 1211/с при 99МБ видеопамяти из 8ГБ). Вместо одной
гигантской популяции — 8 независимых островов по 4000: их поколения
оцениваются одним жирным батчем 32к, а изоляция островов + миграция лучших
по кольцу раз в 10 поколений дают разнообразие против скатывания в один
локальный оптимум. На более мощной карте достаточно поднять islands.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:31:45 +05:00
jze9
fe34bb3672 GPU не ждёт CPU: физика ступеней предвычисляется в пуле процессов
precompute_genome/StagePre: decode+build_stage_physics (тот же единый
источник истины) выполняется в воркерах пула и возвращает ПЛОСКИЕ числа;
главному потоку остаётся склейка массивов и запуск ядер. Раньше он строил
физобъекты 4000 геномов один и GPU ждал ~половину времени поколения.
_simulate_states/_prepare_stage переведены на StagePre (без объектов
компонентов в горячем цикле), run_gpu_sweep — на тот же путь.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 12:12:34 +05:00
jze9
005ffaf04b evolve_forever: лучший-за-всё-время через частичный индекс, а не max()-скан
MAX(efficiency) сканировал всю базу (6.7ГБ) на старте каждого цикла —
минуты мёртвого времени, растущие с базой.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:54:19 +05:00
jze9
e52026d2b2 Полировка: писать одну запись напрямую — убран вечный завис в join()
Редкая гонка spawn+mp.Queue вешала главный процесс НАВЕЧНО после полировки
(22.5 часа простоя; /proc/*/stack: главный в do_wait, polish-писатель в
pipe_read — данные из очереди не доехали). Для одной записи процесс-писатель
и очередь не нужны вовсе: конкурентных писателей в этот момент нет, пишем
insert_run'ом напрямую. Плюс возврат SIGTERM к SIG_DFL после дренажа —
иначе фаза полировки неубиваема мягко.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-09 11:51:21 +05:00
jze9
9b98dbe27b Убрать шум финализаторов очереди при завершении цикла
queue.close()+join_thread() после writer.join(): иначе семафоры очереди
финализируются в гонке с resource_tracker на выходе интерпретатора и после
каждого цикла в журнал сыпалось «Exception ignored … sem_unlink
FileNotFoundError». Проверено репродукцией: stderr пуст.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 05:30:25 +05:00
jze9
b2fc51bb72 SQLite: обуздать WAL (4.4ГБ!) + принудить частичные индексы топов
WAL раздувался до гигабайт (авто-чекпоинт голодал при 400 вставках/с и
постоянных читателях) и замедлял ВСЕ запросы: journal_size_limit=64МБ +
периодический wal_checkpoint(PASSIVE) в писателе. Планировщик игнорировал
частичные DESC-индексы (брал idx_runs_feasible + TEMP B-TREE сортировку
620к строк) — принуждаем INDEXED BY. ensure_stats при старте писателя —
закрыта гонка миграции счётчиков.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 05:14:44 +05:00
jze9
b43885c6e1 fetch_runs: feasible литералом, чтобы работали частичные индексы топов
С bound-параметром SQLite не может применить partial index (WHERE feasible=1)
на этапе подготовки запроса — топ-15 продолжал сканировать сотни тысяч строк.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 05:05:51 +05:00
jze9
4f0c38500b Морда O(1) + GPU без удушения одним потоком Python
Дашборд: /api/overview занимал 5.2с на 630к строк (сканы всей таблицы на
каждый рефреш при автообновлении 3с — морда хронически отставала, и с
ростом базы становилось бы хуже). Теперь:
- счётчики/гистограмма/причины/режимы — инкрементальные таблицы stats_*,
  обновляются В ТРАНЗАКЦИИ вставки (insert_runs), чтение O(1);
  ensure_stats() мигрирует старую базу одним пересчётом
- топы — частичные индексы (efficiency/exit_velocity DESC WHERE feasible=1)
- хвост лога — чтение последних 64КБ с конца, а не всего файла

GPU: сборка записей (decode+build_detail+JSON+стоимость) вынесена в пул
процессов (gpu_record_worker), GPU больше не ждёт один поток Python между
батчами.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:56:32 +05:00
jze9
a4219c7212 evolve_forever: не печатать трейсбек штатной остановки (KeyboardInterrupt)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:32:02 +05:00
jze9
185d7f4361 Фикс обрыва дренажа очереди повторным SIGTERM + model_version в API деталей
systemd шлёт TERM каждому процессу группы: повторный сигнал ловился внутри
writer.join() и обрывал дописывание базы (FileNotFoundError-агония очереди
в журнале, потерянный хвост). Теперь после первого TERM сигнал игнорируется,
join неубиваем. run_detail отдаёт model_version — прогоны разных версий
физики несравнимы, версия обязана быть видна.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:28:10 +05:00
jze9
2af292e094 deploy/: серверные скрипты и юниты в репозиторий + нумерация циклов в логе
evolve_forever.sh теперь пишет в лог явные маркеры конвейера: «ЦИКЛ #N
стартует (лучший КПД за всё время: X%)» и «ЦИКЛ #N завершён — НЕ остановка,
стартует #N+1». Раньше скрипты жили только на VM без версионирования.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:21:07 +05:00
jze9
e6b877bab7 Хранилище: писатель пачками + выживание при SIGTERM — конец потерь записей
GPU-эволюция (380+/с) обгоняла писателя (~150/с, коммит=fsync на каждую
запись): хвост очереди копился в RAM минутами и ТЕРЯЛСЯ при рестарте
сервиса (~80к записей за один рестарт) — нарушение «сохраняем всё».
- run_writer_process: вставка пачками до 500 в одной транзакции; при ошибке
  пачки — откат и поштучный поиск виновника; SIGTERM игнорирует (дописывает
  всё до None)
- run_evolution: SIGTERM -> исключение -> штатный finally досылает None и
  ждёт писателя; в лог явные строки про дописывание базы и полировку
  (чтобы «=== готово ===» не выглядело остановкой)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:18:11 +05:00
jze9
ac459d26a4 Дашборд: карточка загрузки GPU (util/Вт/°C из results/gpustat.json)
Веб в Docker без nvidia-smi: хост-сервис gausse-gpustat пишет JSON рядом с
базой, /api/overview отдаёт поле gpu (протухшее >30с — честно null).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 04:01:48 +05:00
jze9
6fba55f2d6 Доки: v3/v4 в хронологии честности, GPU-эволюция, актуальные ограничения
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 03:51:52 +05:00
jze9
83a3e72bd5 GPU: cut-логика двумя fused-ядрами (лимит параметров CUDA 4096 байт)
Одно ядро на весь шаг переполняло formal parameter space (4272>4096).
Разбито на step_phys (RK4+потери+ост.энергия) и step_cut (обрывы, без
физпараметров): 2 запуска ядра на шаг вместо ~20.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 03:48:40 +05:00
jze9
965c18f327 GPU: вся cut-логика шага слита в одно cupy.fuse-ядро
Профиль на 1070: RK4 был слит, но пошаговая бухгалтерия (_magnetic_energy,
_overlap, ~15 where) запускала десятки мелких ядер и съедала >90% времени
(10 геномов/с). Теперь весь шаг — одно ядро; при несборке fuse — честный
fallback на прежний путь. numpy-путь не тронут (эталон для тестов).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 03:42:05 +05:00
jze9
1480f4ce1a Физика v4: диффузия поля в снаряд (намагничивается только скин-кольцо)
Эволюция на v3 нашла новый обход: «медленная» катушка (600+ витков, ~1мс
импульс, 36А) давала КПД 30% — на низкой частоте слабы и Доуэлл, и вихревые,
и трение. Неучтённая физика: за импульс поле проникает в сталь лишь на
глубину скин-слоя δ=√(2ρ/(ω·μ0·μr)) ~ доли мм; внутренность снаряда не
намагничивается и силы не даёт. iron_penetration_fraction = 1-(1-δ/a)²
умножает коэффициент заполнения. MODEL_VERSION -> gausse-physics-v4.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 03:34:12 +05:00
jze9
ef83512601 GPU в эволюции: оценка поколения одним батчем (use_gpu=True)
evaluate_genomes_gpu в gpu/batch_sweep.py — общий раундовый симулятор
(_simulate_states) для sweep и эволюции, та же формула фитнеса, что в
objective.evaluate (КПД либо -1+доля пройденных ступеней). run_evolution
получил use_gpu: поколение целиком уходит в батч-интегратор (на сервере
cupy/GTX 1070), полировка остаётся точной на CPU. search_mode=evolve-gpu(...).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 03:22:47 +05:00
jze9
ecd6508f29 Физика v3: коэффициент заполнения железа + фикс зависаний решателя
ЭКСПЛОЙТ (нашёлся по подозрительным 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>
2026-07-08 02:22:29 +05:00
jze9
78af686e6d Фикс зависа решателя: сгладить сухое трение tanh'ом у v=0
Разрывный sign(v) в силе трения заставлял адаптивный solve_ivp бесконечно
дробить шаг у v≈0 (chattering): одна конфигурация считалась 35+ минут,
скорость эволюции упала с ~430/с до 3-5/с. Стандартная регуляризация
sign(v)->tanh(v/0.01) во всех путях (CPU-разряд, подлёт, GPU numpy, fused
cupy-ядро) + регрессионный тест на гладкость силы в нуле.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:41:59 +05:00
jze9
8708d0f922 Обновить README и PLAN до актуального состояния (физика v2, GPU, автономная эволюция)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:29:26 +05:00
jze9
a0f02e246a Анимация: равномерное время (ничего не вырезается) + результат эксперимента в кадре
- кадры на равномерной сетке по ФИЗИЧЕСКОМУ времени вместо индексов массива
  (раньше плотные точки разряда съедали все кадры, подлёт вырезался)
- множитель замедления написан в кадре, событие показано целиком
- шапка: выходная скорость, КПД, ступени, энергия банок, масса снаряда
- панель тока каждой катушки во времени + панель скорости снаряда,
  бегущий курсор синхронен с положением в трубе, окна разрядов подкрашены
- датчики каждой ступени отмечены на трубе

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 01:21:18 +05:00
jze9
d4affa9574 Физреализм v2: скин/близость (Доуэлл), трение+воздух, паспортные импульсные токи ключей
- physics/ac_resistance.py: R_AC обмотки по Доуэллу на частоте импульса
  1/sqrt(LC) — для толстого провода во многих слоях потери в разы выше DC
- трение о трубку (0.35·m·g) + аэродинамика (0.5·rho·Cd·A·v^2) во всей
  динамике: CPU ОДУ разряда, подлёт к датчику (с событием остановки),
  GPU numpy-путь, fused cupy-ядро, аналитический coast GPU-sweep'а
- SwitchSpec.pulse_current_a: паспортные ITSM/IDM/ICM из даташитов вместо
  generic-множителей; отчёт теперь различает превышение продолжительного
  рейтинга (норма для импульса) и импульсного предела (отбраковка) — фикс
  вводившего в заблуждение флага switch_current_over_limit
- КПД теперь может быть слегка отрицательным (трение съело больше, чем
  добавила слабая катушка) — это честно
- MODEL_VERSION -> gausse-physics-v2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-08 00:57:07 +05:00
jze9
83a4a7b805 GPU sweep now multi-stage (up to 10 coils); raise max_stages to 10
The GPU batch sweep processes multi-stage configs round-by-round: at round
s every still-alive config with a stage s runs its discharge in one batch,
carrying the slug's global position/velocity forward to the next round.
Configs with fewer stages drop out of later rounds. Analytic
constant-velocity sensor trigger per stage. Validated vs CPU run_coilgun
for multi-stage configs (<=3% exit velocity). Default max_stages 4 -> 10.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 23:44:22 +05:00
jze9
ae6481ceb0 GPU: fuse the whole RK4 discharge step into one cupy kernel
The naive elementwise port launched ~60 tiny kernels per step, so the
GTX 1070 was only ~1.3x over CPU (launch-bound). Fold the entire RK4 step
(4 derivative evals + combine + overlap) into a single cupy.fuse kernel;
numpy path unchanged and still validates vs scipy. GPU correctness
re-checked against CPU on deploy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 23:26:52 +05:00
jze9
fd4c9ad541 GPU: cut per-step device->host sync in batch integrator
The naive port synced every step (bool(any(active)) early-break), which
on GPU is a device->host copy per iteration that serializes the pipeline
and made cupy slower than numpy. Check the break condition only every 256
steps. Correctness unchanged (batch still validates vs scipy and CPU).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 23:08:37 +05:00
jze9
4cfa946315 Enforce per-stage sensor placement in the gap; report absolute sensor position
The model already gave every coil its own sensor, its own sensor->coil
distance, and searched inter-coil gaps (all of which set when each later
coil fires) -- confirmed on a 3-stage run. Two refinements from user
feedback:

- Each stage's sensor must sit in the gap BEFORE its coil, not overlap
  the previous coil: repair() now clamps sensor_to_coil_distance < gap for
  stages > 0. sample_genome now also routes through repair() (it didn't
  before, so freshly sampled genomes skipped every physical constraint).
  Verified: 161/780 previously-overlapping sensors -> 0.
- build_detail now records each sensor's absolute position along the tube
  (sensor_position_m), not just the relative distance, so it's clear where
  every stage's sensor physically sits.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 22:19:32 +05:00
35 changed files with 2214 additions and 384 deletions

138
PLAN.md
View File

@@ -52,10 +52,10 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
- [ ] **Не сделано**: отдельный "дешёвый квазистатический предфильтр" перед полным ODE не реализован (кроме уже встроенной в `sim/stage.py` дешёвой проверки порога индукционного датчика). Если миллионы прогонов на сервере окажутся слишком медленными, это первое место для ускорения.
- [x] **Этап 7 — Отчётность**: `report/plots.py` (ток/поле/скорость по ступеням), `summary.py` (честная сводка + раздел "ограничения модели"), `bom.py` (спецификация деталей с ценами), `animate.py` (GIF: снаряд летит по трубе, катушки светятся пропорционально току — по запросу пользователя), `cli.py` (`gausse sweep/evolve/simulate/report`, все 4 команды реально вызывают соответствующие модули, не заглушки). Проверено сквозным прогоном через `docker compose run`: sweep → report с анимацией на реальной базе компонентов, лучший найденный результат — КПД 32.8% за 982₽ (не выдумано, из реального SQLite).
- [x] **Этап 8 — Сквозная проверка**: реальный прогон через `docker compose run` — sweep на 5000 конфигураций (16 воркеров, ~59с, 30.5% реализуемо), затем `evolve` (15 поколений x 40 особей = 600 оценок, ~18с) поверх той же базы, затем `report --top 3 --animate`.
- **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `evolve` показал 387% — оказалось, `efficiency` считался как `exit_kinetic_energy / energy_in`, а `exit_kinetic_energy` включает фиксированный "бесплатный" толчок `initial_v_mps`, не учтённый в `energy_in`. Для лёгкого снаряда этот толчок доминировал и КПД пробивал 100%. Исправлено на `kinetic_energy_delta / energy_in` (энергия, реально добавленная катушками) — эта величина математически не может превысить 1 (следует из поэтапного энергобаланса). После исправления лучший честный результат: **1 ступень, КПД 80.97%, скорость 26.4 м/с, снаряд Ст3 ⌀4мм x 30мм, стоимость 1029₽** (провод cu-petv2-1.0mm, конденсатор 22мкФ/400В, ключ IRG4PC50F, оптический датчик).
- **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `evolve` показал 387% — оказалось, `efficiency` считался как `exit_kinetic_energy / energy_in`, а `exit_kinetic_energy` включает фиксированный "бесплатный" толчок `initial_v_mps`, не учтённый в `energy_in`. Для лёгкого снаряда этот толчок доминировал и КПД пробивал 100%. Исправлено на `kinetic_energy_delta / energy_in` (энергия, реально добавленная катушками) — эта величина математически не может превысить 1 (следует из поэтапного энергобаланса). После исправления лучший результат на тот момент: **1 ступень, КПД 80.97%, скорость 26.4 м/с, снаряд Ст3 ⌀4мм x 30мм, стоимость 1029₽** (провод cu-petv2-1.0mm, конденсатор 22мкФ/400В, ключ IRG4PC50F, оптический датчик). *(Историческая запись: это физика v1 БЕЗ потерь в железе, скина и трения — после их добавления такие КПД невозможны, см. «хронологию честности» ниже.)*
- **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 5000 прогонах: индукционный датчик (`inductive-pickup-lm393`) дал **0.2% реализуемых** конфигураций (6 из 2965, где он стоял хотя бы на одной ступени), тогда как оптический (TCST2103) и Холла (A3144E) — **~45-47%** реализуемых каждый. Это количественно подтверждает опасение, высказанное в самом начале обсуждения плана: индукционная катушка-датчик ненадёжна именно потому, что требует скорости выше её порога (здесь — фиксированный старт 3 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости.
- Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат.
- [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. Ход эксперимента пишется в `<db>.log` (`optim/progress_log.py`): прогресс, доля реализуемых, скорость, ETA, отметки нового лучшего КПД. Веб-дашборд `gausse serve` (`src/gausse/web/`, только stdlib `http.server`, self-contained HTML) — счётчики, гистограмма КПД, топ конфигураций с drill-down, причины отказа, хвост лога, автообновление 3с. В `docker-compose.yml` сервис `web` на порту 8000. 77 тестов, проверено в Docker.
- [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. *(УСТАРЕЛО с 2026-07-11, коммит 5c11999: полный build_detail больше НЕ хранится на каждый прогон — база раздувалась до 70ГБ; в decoded_summary_json теперь дешёвая сводка decoded_summary(), а полная детализация пересимулируется на лету из genome_json при просмотре прогона — web/rebuild.py + web/stats.run_detail.)* Ход эксперимента пишется в `<db>.log` (`optim/progress_log.py`): прогресс, доля реализуемых, скорость, ETA, отметки нового лучшего КПД. Веб-дашборд `gausse serve` (`src/gausse/web/`, только stdlib `http.server`, self-contained HTML) — счётчики, гистограмма КПД, топ конфигураций с drill-down, причины отказа, хвост лога, автообновление 3с. В `docker-compose.yml` сервис `web` на порту 8000. 77 тестов, проверено в Docker.
## Батареи конденсаторов + графики в веб-морде (по запросу пользователя)
@@ -63,16 +63,132 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
- **Уже было (тоже запрашивалось)**: разная толщина провода на разных катушках (`wire_idx` на ступень) и длина провода (`winding_geometry.total_wire_length_m` → сопротивление/цена/БД).
- **Графики в дашборде**: клик по прогону → графики скорость/поле/ток строятся на лету (`web/render.py`, `/api/run/<id>/plot/<kind>.png`) + GIF пролёта по кнопке (`/api/run/<id>/anim.gif`). Рендер matplotlib сериализован локом (Agg не потокобезопасен). 86 тестов.
## Развёртывание на сервере (Proxmox 192.168.20.254 / VM 106 "test-math" = 192.168.20.45)
## Развёртывание на сервере (Proxmox 192.168.20.254 / VM 106 "test-math" = **192.168.20.47**)
**СТАТУС: развёрнуто и работает на CPU.** VM 106: RAM 2→6ГБ, Docker+git, клон с gitea, образ собран, 86 тестов проходят в контейнере на сервере. Веб-морда живёт на **http://192.168.20.45:8000** (сервис `web`, restart unless-stopped), sweep пишет в `~/gausse/results/gausse.sqlite3` (+ лог `.log`). Дашборд доступен снаружи (Proxmox firewall порт 8000 не блокирует). Обновление кода: на VM `cd ~/gausse && git pull && docker compose build && docker compose up -d web`.
**СТАТУС: развёрнуто, работает АВТОНОМНО (CPU-эволюция под systemd), GPU проброшен и рабочий.**
Обнаружено при осмотре: GTX 1070 (`10de:1b81`+аудио `10de:10f0`) стоит в Proxmox-хосте, **уже привязана к vfio-pci**, blacklist'ы nouveau/nvidia прописаны, IOMMU включён (`intel_iommu=on`), карта одна в IOMMU-группе 1. То есть хост заранее подготовлен под проброс — можно пробросить в VM **без перезагрузки хоста** (не заденет другие VM: nextcloud/minecraft/web-player). Целевая VM 106 = 5 ядер, 2ГБ RAM (мало для CUDA+Docker, поднять). GPU ей ещё не назначен.
- VM 106: Ubuntu 24.04, 5 ядер, 12ГБ RAM. После перевода на q35 (нужен для
PCIe-проброса) интерфейс переименовался и IP сменился **.45 → .47**
(netplan починен привязкой по MAC).
- [x] Пуш на gitea (`gitea.jze9.ru/jze9/gausse.git`) — рабочий процесс: пуш с
локальной машины → `git pull` на VM.
- [x] GPU: `qm set 106 --machine q35 --hostpci0 0000:01:00,pcie=1`; в VM
драйвер NVIDIA 580 + CUDA, `cupy-cuda12x[ctk]` в `~/gausse/.venv`.
`nvidia-smi` видит GTX 1070 без Code 43.
- [x] Веб-морда: **http://192.168.20.47:8000** (Docker-сервис `web`).
- [x] **Автономная эволюция — systemd-сервис `gausse-evolve`** (пользователь
явно потребовал: «алгоритм должен считать сам, годами, без тебя»):
`/etc/systemd/system/gausse-evolve.service` крутит `evolve_forever.sh`
вечный цикл `run_evolution(80 поколений × 300 особей, polish=True)` с новым
случайным семенем на каждом заходе (свежая популяция = «революция» против
застревания в локальном оптимуме). `Restart=always` + `enabled` → переживает
и падение процесса, и перезагрузку VM. Всё пишет в одну базу
`~/gausse/results/gausse.sqlite3`; «запланировано N» на дашборде — план
ТЕКУЩЕГО цикла, сам поиск бесконечен. Управление: `sudo systemctl
status|stop|restart gausse-evolve`, лог `journalctl -u gausse-evolve`.
- Обновление кода на VM: `cd ~/gausse && git pull && docker compose build web
&& docker compose up -d web && sudo systemctl restart gausse-evolve`.
- [ ] Пуш на gitea (`gitea.jze9.ru/jze9/gausse.git`) или rsync прямо в VM.
- [ ] `hostpci0: 0000:01:00,pcie=1` в конфиг VM 106, поднять RAM, перезагрузить VM 106.
- [ ] В VM: NVIDIA-драйвер + CUDA + Docker + nvidia-container-toolkit.
- [ ] Развернуть проект, запустить sweep (пока CPU!), поднять `gausse serve` (порт 8000).
- [ ] Дать доступ: веб-морда http://192.168.20.45:8000, SQLite `results/gausse.sqlite3`, лог `results/gausse.sqlite3.log`.
- [x] **Этап 9 — GPU-ускорение массового sweep — СДЕЛАН**:
`gpu/batch_integrator.py` — батч-RK4 фиксированного шага [Q,I,x,v] на N
конфигураций одним тензором (numpy/cupy через `xp`), та же физика, что
CPU-путь (общий `sim/stage.build_stage_physics` — один источник истины).
Весь RK4-шаг слит в ОДНО `cupy.fuse`-ядро → **GTX 1070: 740 конф/с vs 193
на numpy = 3.8x** (наивный порт был 1.3x — упирался в запуск ядер);
SYNC_EVERY=256 убирает device→host синхронизацию на каждом шаге.
`gpu/batch_sweep.py` — МНОГОСТУПЕНЧАТЫЙ sweep раунд-за-раундом (подлёт
аналитически, состояние снаряда переносится между раундами), CLI
`gausse sweep --gpu`. Требование честности выполнено: GPU сверен с
CPU-эталоном — расхождение exit_v **0.084%** (tests/test_batch_sweep.py,
test_batch_integrator.py). Стиффные конфиги, переполняющие фикс-шаг,
честно бракуются (blew_up), а не записываются мусором.
- [x] cut-логика дослита: весь шаг = 2 fused-ядра (одно не влезает в лимит
параметров CUDA 4096Б). Оценка 1000 геномов: 98.5с → 6.6с (15x), ~150
геномов/с на 1070, сверка с numpy-эталоном 0.001%.
- [x] GPU-батч в эволюции: `run_evolution(use_gpu=True)` оценивает всё
поколение одним батчем (`evaluate_genomes_gpu`), та же формула фитнеса;
полировка (Nelder-Mead) остаётся точной на CPU. Сервис `gausse-evolve`
переведён на venv+GPU: 120 поколений × 1000 особей, ~115/с стабильно.
- [ ] **Этап 9 — GPU-ускорение массового sweep (сервер с GTX 1070)**: после того как CPU/`scipy.solve_ivp`-модель провалидирована тестами (Этап 2-4) — батч-версия интегратора с ФИКСИРОВАННЫМ шагом (RK4/полу-неявная схема), считающая сразу N траекторий параллельно как один тензор (`cupy`, если доступна CUDA, иначе векторизованный `numpy`/`numba`), для прогона по-настоящему миллионов конфигураций на сервере. Важно: сначала корректность на CPU, потом скорость на GPU — численные результаты GPU-пути должны сверяться с CPU-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки".
## Физика: найденные завышения КПД и их исправления (хронология честности)
Пользователь ловил нереальные цифры; каждое исправление РОНЯЛО КПД к
реальности. Версия модели пишется в каждую запись БД (`model_version`),
прогоны разных версий не смешиваются.
1. **Поле 13 Тл** → насыщение железа через единое потокосцепление
λ(x,I)=L_air·I+L_iron·overlap(x)·g(I), g=I_sat·tanh(I/I_sat); сила и ЭДС
из одного λ (coenergy) → энергия сохраняется, поле упирается в B_sat.
2. **Ток сверх рейтинга ключа** → жёсткая отбраковка по импульсному пределу.
3. **КПД 83.9%: не было потерь в железе** → вихревые токи: снаряд =
короткозамкнутый виток, отражённое R_eddy=(ωM)²/R_e × overlap(x)
(physics/losses.py). Лучший упал 83.9% → ~47%, медиана ~0%.
4. **Три горба тока за выстрел** (физически невозможно — конденсатор не
перезарядить за мкс) → одиночный импульс: обрыв на первом нуле тока ИЛИ
первом локальном минимуме; остаточная энергия катушки → freewheel-диод,
считается точно через magnetic_energy (с насыщением). Энергобаланс сошёлся.
5. **gausse-physics-v2 (2026-07-08)**: скин-эффект + эффект близости обмотки
(метод Доуэлла на ω=1/√(LC), `physics/ac_resistance.py`) — для толстого
провода во многих слоях R_AC в разы выше DC; **трение о трубку**
(0.35·m·g) и **сопротивление воздуха** (½ρ·Cd·A·v²) во всей динамике
(разряд, подлёт с событием «снаряд остановлен трением», GPU-ядро,
аналитический межступенчатый пролёт — точная квадратура); паспортные
импульсные токи ключей из даташитов (`pulse_current_a`: ITSM/IDM/ICM)
вместо generic-множителей + починен вводивший в заблуждение флаг
(`switch_current_over_continuous_rating` — норма для импульса,
`switch_current_over_pulse_limit` — брак). КПД ступени теперь может быть
слегка отрицательным (трение съело больше слабой катушки) — это честно.
Для сравнения на одной и той же эволюции: v1 давал «38%» там, где v2 даёт
~8% — столько стоили неучтённые скин и трение.
6. **gausse-physics-v3**: эволюция нашла «КПД 82%» → вскрытие генома: тонкий
снаряд (⌀4мм) в толстой катушке (⌀29мм) — модель умножала ВСЮ L на
(μ_eff1), как будто железо заполняет всё сечение. Фикс: коэффициент
заполнения A_снаряда/A_катушки (iron_fill_factor). Плюс лечение зависаний
решателя: tanh-сглаживание сухого трения (разрывный sign(v) дробил шаг
бесконечно), событие тока удержания ключа (передемпфированный хвост),
полная формула отражения вихрей (ωM)²R₂/(R₂²+(ωL₂)²), гард вырожденных
катушек (L/R<100нс). Бенч: 200 геномов 608с → 22с.
7. **gausse-physics-v4**: эволюция нашла «30%» через МЕДЛЕННУЮ катушку
(600+ витков, ~1мс, 36А — там слабы и Доуэлл, и вихревые, и трение).
Неучтённая физика: за импульс поле проникает в сталь на глубину скин-слоя
(~доли мм) — внутренность снаряда не намагничивается. Фикс:
iron_penetration_fraction = 1(1δ/a)² множит fill factor. После v4
лучший КПД эволюции — единицы процентов, что соответствует реальным
reluctance-пушкам. Урок зафиксирован: подозрительно красивый КПД =
вскрывать лучший геном и искать обойдённый канал потерь.
## Требования пользователя, закрытые после Этапа 8
- **Снаряд до 200 г** (⌀ до 28мм, длина до 150мм, длина зажата по плотности).
- **Труба — часть поиска**: геном несёт внутренний диаметр (бор) + толщину
стенки; внешний диаметр трубы = внутренний диаметр катушки (влияет на
поле); снаряд обязан влезать в бор с зазором.
- **До 10 ступеней**; у каждой катушки СВОЙ датчик на своём расстоянии
(sensor_to_coil_distance_m на ступень; datчик не может попасть в предыдущую
катушку — repair()).
- **Ссылки на магазины** (`url`) у каждого компонента: в JSON-базе, BOM
(«Купить») и в детализации прогона.
- **Максимально детальная запись** (`build_detail`): труба
внутр/стенка/внеш, масса снаряда в граммах и влезает-ли-в-бор,
внутр/внеш диаметры катушки, состав банки конденсаторов, DC/AC
сопротивление обмотки, все позиции датчиков и катушек вдоль трубы,
энергобаланс каждой ступени. *(С 2026-07-11 НЕ хранится в БД построчно,
а строится на лету из genome_json при просмотре прогона/отчёте —
хранение полной детализации на 12.9М строк раздуло базу до 70ГБ.)*
- **Анимация переделана** (была «время вырезается, результата не видно»):
кадры на равномерной сетке ФИЗИЧЕСКОГО времени (а не по индексам массива,
из-за чего плотные точки разряда съедали все кадры и подлёт выпадал),
множитель замедления в кадре; в кадре сам результат эксперимента — шапка
(выход м/с, КПД, ступени, энергия, масса), панель тока каждой катушки во
времени, панель скорости снаряда, бегущий курсор, датчики на трубе.
## Ограничения модели / следующие шаги по реализму (если потребуются)
- Поршневой эффект воздуха в трубе (учтено только лобовое сопротивление).
- Диффузия поля в снаряде (наш r_eddy — сосредоточенная оценка 1-го порядка).
- Спектр импульса для Доуэлла (взята одна характерная частота).
- Нагрев провода за импульс/серию; коэффициент трения не измерен (0.35).
- Гистерезис посчитан (losses.hysteresis_energy_j) и СОЗНАТЕЛЬНО не в
динамике: <0.1% энергии выстрела включение изображало бы ложную точность.
- Индуктивность Уилер+размагничивание, не МКЭ/FEA.
- Осторожно: не тюнить модель под «желаемую» цифру КПД только физика.

View File

@@ -1,21 +1,61 @@
# gausse
Симулятор и оптимизатор многоступенчатого электромагнитного ускорителя
(coilgun) на реальных, доступных в рознице компонентах.
(coilgun / пушка Гаусса) на реальных, доступных в рознице компонентах
(ChipDip, procontact74.ru, cable.ru — у каждой детали в базе ссылка на
магазин).
Полный план и чек-лист этапов — в [PLAN.md](PLAN.md).
Главный принцип — **честность**: все прогоны (включая нереализуемые, с
причиной отказа) пишутся в SQLite; физическая модель не приукрашивается ради
красивых цифр, а каждое известное упрощение задокументировано. История
проекта — это в том числе история найденных и исправленных завышений КПД
(80%+ → реалистичные единицы процентов), см. [PLAN.md](PLAN.md).
## Установка (Docker — основной способ)
## Что умеет
- **Физика** (`src/gausse/physics/`): RLC-разряд конденсаторной банки через
катушку с ферромагнитным снарядом; насыщение железа через единое
потокосцепление λ(x,I) (энергия сохраняется); вихревые потери в снаряде;
коэффициент заполнения (тонкий снаряд не усиливает весь поток) и
диффузия поля (за импульс намагничивается только скин-кольцо);
скин-эффект + эффект близости обмотки (метод Доуэлла); трение о трубку и
сопротивление воздуха; одиночный импульс (тиристор не перезапускается);
паспортные импульсные токи ключей (ITSM/IDM/ICM из даташитов).
- **Ступени** (`src/gausse/sim/`): у каждой катушки свой датчик
(оптика/Холл/индукционный) на своём расстоянии; подлёт → триггер →
задержки датчика и ключа → разряд → энергобаланс; цепочка до 10 ступеней.
- **Поиск** (`src/gausse/optim/`): геном переменной длины (число ступеней
эволюционирует), Monte-Carlo sweep и (μ+λ)-эволюция с Nelder-Mead
полировкой; всё в одну SQLite-базу (WAL, писатель поверх очереди).
- **GPU** (`src/gausse/gpu/`): батч-интегратор RK4 (numpy/cupy), весь шаг
слит в одно cupy.fuse-ядро — на GTX 1070 ~3.8x к CPU; многоступенчатый
GPU-sweep раунд-за-раундом И GPU-эволюция (use_gpu=True: всё поколение —
один батч, ~150 геномов/с на 1070); сверено с CPU-эталоном (<0.1%).
- **Отчёты** (`src/gausse/report/`, `src/gausse/web/`): веб-дашборд с
drill-down до каждой детали и энергобаланса ступени, графики ток/поле/
скорость, GIF-анимация выстрела (равномерное физическое время, результат
эксперимента в кадре), BOM с ценами и ссылками «купить».
## Запуск (Docker — основной способ)
```bash
docker compose build
docker compose run --rm --entrypoint pytest gausse -q # тесты
docker compose run --rm gausse sweep --n 1000 # CLI (после реализации Этапа 6)
docker compose run --rm --entrypoint pytest gausse -q # тесты
docker compose up -d web # дашборд :8000
docker compose run --rm gausse sweep --n 100000 --db /app/results/gausse.sqlite3
docker compose run --rm gausse evolve --generations 80 --population 300 \
--db /app/results/gausse.sqlite3
docker compose run --rm gausse report --top 3 --animate --db /app/results/gausse.sqlite3
```
Результаты (SQLite, отчёты) должны сохраняться в `./results`, примонтированный
в контейнер как `/app/results` (см. `docker-compose.yml`), чтобы переживать
пересборку образа.
GPU-режим (нужны NVIDIA-драйвер и `pip install cupy-cuda12x[ctk]`, вне Docker):
```bash
gausse sweep --gpu --n 1000000 --db results/gausse.sqlite3
```
Результаты (SQLite + лог хода эксперимента `<db>.log`) живут в `./results`,
примонтированном в контейнер, и переживают пересборку образа.
## Установка без Docker (локальная разработка)
@@ -25,3 +65,25 @@ source .venv/bin/activate
pip install -e ".[dev]"
pytest
```
## Автономная работа на сервере
Эволюция оформлена как systemd-сервис `gausse-evolve` (вечный цикл: 120
поколений × 1000 особей на GPU, по окончании новый цикл со свежей
случайной популяцией; `Restart=always`, автозапуск при загрузке). Дашборд Docker-
сервис `web` на :8000, читает ту же базу. Счётчик «запланировано» на
дашборде план **текущего цикла**, а не всего поиска: поиск бесконечен.
## Ограничения модели (честно)
- Индуктивность инженерная модель (Уилер + размагничивание сфероида +
tanh-перекрытие), не МКЭ; точная картина поля требует FEA.
- Вихревые потери снаряда сосредоточенный виток; диффузия поля в снаряд
одночастотная оценка скин-кольца, а не решение уравнения диффузии.
- Доуэлл берёт одну характерную частоту ω=1/√(LC), без спектра импульса.
- Воздух лобовое сопротивление; поршневой эффект столба воздуха в трубе
не учтён. Трение константа 0.35 (сталь-пластик), не измерено.
- Гистерезис снаряда посчитан и сознательно не включён в динамику (<0.1%).
- Нагрев провода за импульс и от серии выстрелов не моделируется.
Версия физики пишется в каждую запись (`model_version`, сейчас `gausse-physics-v4`) прогоны разных моделей не смешиваются.

13
deploy/README.md Normal file
View File

@@ -0,0 +1,13 @@
# Развёртывание на сервере (VM 106, 192.168.20.47)
Автономная работа — три systemd-сервиса (все enabled, переживают ребут):
| Сервис | Что делает |
|---|---|
| `gausse-evolve` | вечный цикл эволюции на GPU (`evolve_forever.sh`), `Restart=always`, `TimeoutStopSec=180` (дать дописать очередь в базу при остановке) |
| `gausse-gpustat` | пишет `results/gpustat.json` каждые 5с — карточка GPU на дашборде |
| Docker `web` | дашборд :8000 (наружу: https://gausse.jze9.ru через nginx LXC 102 + certbot) |
Обновление кода: `git pull && pip install -e . (в .venv) && docker compose build web && docker compose up -d web && sudo systemctl restart gausse-evolve`.
Остановка сервиса безопасна: главный процесс по SIGTERM дописывает всю очередь
оценённых прогонов в базу перед выходом («сохраняем всё»).

37
deploy/evolve_forever.sh Executable file
View File

@@ -0,0 +1,37 @@
#!/bin/bash
# Вечная эволюция на GPU: бесконечный конвейер циклов. Каждый цикл — свежая
# случайная популяция («революция» против застревания в локальном оптимуме),
# всё пишется в одну базу, дашборд показывает лучший за всё время.
# Развёртывание: см. deploy/README.md; systemd-юнит: gausse-evolve.service.
cd /home/user/gausse
source .venv/bin/activate
LOG=results/gausse.sqlite3.log
cycle=1
while true; do
best=$(python3 - <<'PY'
import sqlite3
try:
c = sqlite3.connect("results/gausse.sqlite3")
# НЕ max(): он сканирует всю базу (гигабайты); частичный индекс отдаёт мгновенно
row = c.execute("select efficiency from runs indexed by idx_runs_feas_eff "
"where feasible=1 order by efficiency desc limit 1").fetchone()
print(f"{row[0]*100:.2f}%" if row and row[0] is not None else "пока нет")
except Exception:
print("пока нет")
PY
)
echo "$(date -Is) ═════ ЦИКЛ #$cycle стартует (конвейер бесконечный; лучший КПД за всё время: $best) ═════" >> "$LOG"
# выборка 10000 на поколение (8 островов x 1250) — по просьбе пользователя:
# меньше популяция + больше поколений за цикл = быстрее углубляется.
# Рядовые оценки в базу не пишутся (только счётчики) — писатель больше
# не душит конвейер, GPU занят почти всё время цикла.
python3 -c "from pathlib import Path
from gausse.optim.evolutionary import run_evolution
try:
run_evolution(Path('results/gausse.sqlite3'), n_generations=120, population_size=1250, seed=None, polish=True, use_gpu=True, islands=8)
except KeyboardInterrupt:
pass # штатная остановка сервиса: база уже дописана в finally"
echo "$(date -Is) ═════ ЦИКЛ #$cycle завершён — это НЕ остановка: через 3с стартует цикл #$((cycle+1)) со свежей случайной популяцией ═════" >> "$LOG"
cycle=$((cycle+1))
sleep 3
done

View File

@@ -0,0 +1,15 @@
[Unit]
Description=gausse continuous evolution (autonomous, runs for years)
After=network-online.target docker.service
[Service]
Type=simple
User=user
WorkingDirectory=/home/user/gausse
ExecStart=/bin/bash /home/user/gausse/evolve_forever.sh
Restart=always
RestartSec=15
TimeoutStopSec=180
[Install]
WantedBy=multi-user.target

View File

@@ -0,0 +1,13 @@
[Unit]
Description=gausse GPU stat writer for dashboard
After=gausse-evolve.service
[Service]
Type=simple
User=user
ExecStart=/bin/bash /home/user/gausse/gpustat_loop.sh
Restart=always
RestartSec=10
[Install]
WantedBy=multi-user.target

13
deploy/gpustat_loop.sh Executable file
View File

@@ -0,0 +1,13 @@
#!/bin/bash
# Пишет загрузку GPU в results/gpustat.json для веб-морды (раз в 5с)
cd /home/user/gausse
while true; do
line=$(nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,power.draw,temperature.gpu --format=csv,noheader,nounits 2>/dev/null)
if [ -n "$line" ]; then
IFS=", " read -r util mem memtot pw temp <<< "$line"
printf '{"ts": %s, "util_pct": %s, "mem_used_mib": %s, "mem_total_mib": %s, "power_w": %s, "temp_c": %s}\n' \
"$(date +%s)" "$util" "$mem" "$memtot" "$pw" "$temp" > results/gpustat.json.tmp
mv results/gpustat.json.tmp results/gpustat.json
fi
sleep 5
done

View File

@@ -8,8 +8,9 @@
"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мкс (типично для тиристора этого класса, точное значение в листинге не приведено)",
"url": "https://www.chipdip.ru/product/ku202n"
"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мкс (типично для тиристора этого класса, точное значение в листинге не приведено); ITSM=100А (ударный неповторяющийся, 10мс) по справочнику КУ202Н",
"url": "https://www.chipdip.ru/product/ku202n",
"pulse_current_a": 100.0
},
{
"part_number": "BT151-650R",
@@ -20,8 +21,9 @@
"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 и время включения ОЦЕНКА (типично для тиристора этого класса, не указаны продавцом)",
"url": "https://www.chipdip.ru/product/bt151-650r"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/bt151-650r), ~180-200₽ (BT151-600R вариант подтверждён на 180₽); Von и время включения ОЦЕНКА (типично для тиристора этого класса, не указаны продавцом); ITSM=100А (даташит NXP, полусинус 10мс)",
"url": "https://www.chipdip.ru/product/bt151-650r",
"pulse_current_a": 100.0
},
{
"part_number": "IRFP254PBF",
@@ -32,8 +34,9 @@
"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)",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/"
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/); ВНИМАНИЕ: демонтированная (Б/У) деталь, не новая партия; время включения ОЦЕНКА ~30нс (типично для этого класса MOSFET); IDM=92А (импульсный ток стока, даташит)",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/",
"pulse_current_a": 92.0
},
{
"part_number": "IRFP250PBF",
@@ -44,8 +47,9 @@
"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В с даташита; время включения ОЦЕНКА",
"url": "https://www.chipdip.ru/product/irfp250"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp250), новая деталь, 320₽ (276₽ от 15шт, 245₽ от 150шт); Rds(on)=0.085Ом при 18А/10В с даташита; время включения ОЦЕНКА; IDM=120А (импульсный ток стока, даташит)",
"url": "https://www.chipdip.ru/product/irfp250",
"pulse_current_a": 120.0
},
{
"part_number": "IRFP4468PBF",
@@ -56,8 +60,9 @@
"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В с даташита; время включения ОЦЕНКА",
"url": "https://www.chipdip.ru/product/irfp4468pbf"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp4468pbf), 610₽ (534₽ от 15шт); паспортный максимум 290А при 25°C, взято консервативное практическое значение 180А; Rds(on)=0.0026Ом при 180А/10В с даташита; время включения ОЦЕНКА; IDM=1080А (импульсный ток стока, даташит)",
"url": "https://www.chipdip.ru/product/irfp4468pbf",
"pulse_current_a": 1080.0
},
{
"part_number": "IRG4PC50F",
@@ -68,7 +73,8 @@
"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, не подтверждено со страницы товара)",
"url": "https://www.chipdip.ru/product/irg4pc50f-8048990896"
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irg4pc50f-8048990896), ~504₽; Vce(sat) и время включения ОЦЕНКА (типично для этого семейства IGBT, не подтверждено со страницы товара); ICM=280А (импульсный ток коллектора, даташит)",
"url": "https://www.chipdip.ru/product/irg4pc50f-8048990896",
"pulse_current_a": 280.0
}
]

View File

@@ -83,6 +83,9 @@ class SwitchSpec:
price: float
source: str
url: str | None = None
# паспортный импульсный ток из даташита (ITSM тиристора / IDM MOSFET /
# ICM IGBT). Если None — применяется типовой множитель SWITCH_SURGE_FACTOR.
pulse_current_a: float | None = None
@dataclass(frozen=True)

View File

@@ -35,6 +35,8 @@ class BatchDischargeParams:
smoothing_width_m: "any"
i_sat_a: "any" # ток насыщения; NO_SATURATION_I_SAT где железа нет
r_eddy_coeff_ohm: "any" # вихревое сопротивление снаряда (× overlap)
retard_const_n: "any" # сухое трение о трубку (Н, против движения)
drag_coeff_n: "any" # аэродинамика: сила = drag_coeff·v² (против движения)
def params_from_models(xp, models, circuit_params) -> BatchDischargeParams:
@@ -62,6 +64,8 @@ def params_from_models(xp, models, circuit_params) -> BatchDischargeParams:
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]),
retard_const_n=col([getattr(c, "retard_const_n", 0.0) for c in circuit_params]),
drag_coeff_n=col([getattr(c, "drag_coeff_n_per_mps2", 0.0) for c in circuit_params]),
)
@@ -104,11 +108,156 @@ def _derivatives(xp, q, i, x, v, p: BatchDischargeParams):
v_c = q / p.capacitance_f
d_q = -i
d_i = (v_c - i * r_eff - dlambda_dx * v) / dlambda_di
d_v = force / p.mass_kg
# трение + воздух; tanh-сглаживание у v=0 — как в physics/circuit.py
retard = xp.tanh(v / 0.01) * (p.retard_const_n + p.drag_coeff_n * v * v)
d_v = (force - retard) / p.mass_kg
d_x = v
return d_q, d_i, d_x, d_v
_FUSED_STEP_CACHE = {}
def _get_fused_step(cp):
"""Собирает (и кэширует) cupy.fuse-ядро полного RK4-шага разряда.
Весь шаг (4 вычисления производных + сборка + overlap) сливается в ОДНО
GPU-ядро вместо ~60 мелких — убирает накладные на запуск ядер, из-за
которых наивный порт был лишь ~1.3x к CPU.
"""
if "step" in _FUSED_STEP_CACHE:
return _FUSED_STEP_CACHE["step"]
LN2 = 0.6931471805599453
def _ovl(x, hs, w):
s1 = 0.5 * (1.0 + cp.tanh((x + hs) / w * 0.5))
s2 = 0.5 * (1.0 + cp.tanh((hs - x) / w * 0.5))
return s1 * s2, (s1 * s2 / w) * (s2 - s1)
def _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
ov, dov = _ovl(x, hs, w)
z = i / isat
tz = cp.tanh(z)
g = isat * tz
gp = 1.0 - tz * tz
az = cp.abs(z)
G = isat * isat * (az + cp.log1p(cp.exp(-2.0 * az)) - LN2)
dl_di = lair + liron * ov * gp
reff = rt + red * ov
dq = -i
di = (q / C - i * reff - liron * dov * g * v) / dl_di
dv = (liron * dov * G - cp.tanh(v * 100.0) * (fc + dc * v * v)) / m
return dq, di, v, dv
@cp.fuse()
def step(q, i, x, v, dt, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
a = _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
b = _der(q + dt * 0.5 * a[0], i + dt * 0.5 * a[1], x + dt * 0.5 * a[2], v + dt * 0.5 * a[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
c = _der(q + dt * 0.5 * b[0], i + dt * 0.5 * b[1], x + dt * 0.5 * b[2], v + dt * 0.5 * b[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
d = _der(q + dt * c[0], i + dt * c[1], x + dt * c[2], v + dt * c[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
qn = q + dt / 6.0 * (a[0] + 2 * b[0] + 2 * c[0] + d[0])
in_ = i + dt / 6.0 * (a[1] + 2 * b[1] + 2 * c[1] + d[1])
xn = x + dt / 6.0 * (a[2] + 2 * b[2] + 2 * c[2] + d[2])
vn = v + dt / 6.0 * (a[3] + 2 * b[3] + 2 * c[3] + d[3])
ov_old, _ = _ovl(x, hs, w)
ov_new, _ = _ovl(xn, hs, w)
return qn, in_, xn, vn, ov_old, ov_new
_FUSED_STEP_CACHE["step"] = step
return step
def _get_fused_pair(cp):
"""Два слитых ядра на шаг: RK4+физика и cut-логика.
Профиль на 1070: RK4 был слит, но бухгалтерия обрыва (_magnetic_energy,
_overlap, ~15 where на шаг) запускала десятки мелких ядер и съедала >90%
времени. Одним ядром не влезает в лимит параметров CUDA (4096 байт,
«Formal parameter space overflowed»), поэтому два сбалансированных:
step_phys — RK4 + потери шага + остаточная магнитная энергия;
step_cut — обрывы/пики/выходные состояния, вообще без физических параметров.
"""
if "pair" in _FUSED_STEP_CACHE:
return _FUSED_STEP_CACHE["pair"]
LN2 = 0.6931471805599453
def _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
s1 = 0.5 * (1.0 + cp.tanh((x + hs) / w * 0.5))
s2 = 0.5 * (1.0 + cp.tanh((hs - x) / w * 0.5))
ov = s1 * s2
dov = (s1 * s2 / w) * (s2 - s1)
z = i / isat
tz = cp.tanh(z)
g = isat * tz
gp = 1.0 - tz * tz
az = cp.abs(z)
G = isat * isat * (az + cp.log1p(cp.exp(-2.0 * az)) - LN2)
dl_di = lair + liron * ov * gp
reff = rt + red * ov
dq = -i
di = (q / C - i * reff - liron * dov * g * v) / dl_di
dv = (liron * dov * G - cp.tanh(v * 100.0) * (fc + dc * v * v)) / m
return dq, di, v, dv
@cp.fuse()
def step_phys(q, i, x, v, dt, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
a = _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
b = _der(q + dt * 0.5 * a[0], i + dt * 0.5 * a[1], x + dt * 0.5 * a[2], v + dt * 0.5 * a[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
c = _der(q + dt * 0.5 * b[0], i + dt * 0.5 * b[1], x + dt * 0.5 * b[2], v + dt * 0.5 * b[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
d = _der(q + dt * c[0], i + dt * c[1], x + dt * c[2], v + dt * c[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
qn = q + dt / 6.0 * (a[0] + 2 * b[0] + 2 * c[0] + d[0])
in_ = i + dt / 6.0 * (a[1] + 2 * b[1] + 2 * c[1] + d[1])
xn = x + dt / 6.0 * (a[2] + 2 * b[2] + 2 * c[2] + d[2])
vn = v + dt / 6.0 * (a[3] + 2 * b[3] + 2 * c[3] + d[3])
s1o = 0.5 * (1.0 + cp.tanh((x + hs) / w * 0.5))
s2o = 0.5 * (1.0 + cp.tanh((hs - x) / w * 0.5))
ov_old = s1o * s2o
s1n = 0.5 * (1.0 + cp.tanh((xn + hs) / w * 0.5))
s2n = 0.5 * (1.0 + cp.tanh((hs - xn) / w * 0.5))
ov_new = s1n * s2n
# потери шага: I²R_eff (вихревые × overlap) + трение/воздух (трапеция)
step_diss = 0.5 * (i * i * (rt + red * ov_old) + in_ * in_ * (rt + red * ov_new)) * dt
fric = 0.5 * ((fc + dc * v * v) * cp.abs(v) + (fc + dc * vn * vn) * cp.abs(vn)) * dt
# остаточная магнитная энергия (с насыщением) в состоянии ДО шага
z = i / isat
g = isat * cp.tanh(z)
az = cp.abs(z)
G = isat * isat * (az + cp.log1p(cp.exp(-2.0 * az)) - LN2)
w_mag = 0.5 * lair * i * i + liron * ov_old * (i * g - G)
return qn, in_, xn, vn, step_diss + fric, w_mag
@cp.fuse()
def step_cut(q, i, x, v, qn, in_, xn, vn, sdiss, w_mag,
done, committed, past_peak, exit_v, exit_x, exit_q, peak_i, e_diss):
active = ~done
zero = 0.0 * q
e2 = e_diss + cp.where(active, sdiss, zero)
peak2 = cp.maximum(peak_i, cp.where(active, cp.abs(in_), zero))
past2 = past_peak | (active & (i > 1.0) & (in_ < i))
crossed = active & (i > 0) & (in_ <= 0)
decayed = active & past2 & (in_ < 0.1) & ~crossed # ток < удержания ключа
local_min = active & past2 & (in_ > i) & ~crossed & ~decayed
cut_state = local_min | decayed # обрыв в состоянии ДО шага
cut = crossed | cut_state
frac = cp.where(crossed, i / (i - in_ + 1e-30), zero)
exit_v2 = cp.where(crossed, v + frac * (vn - v), cp.where(cut_state, v, exit_v))
exit_x2 = cp.where(crossed, x + frac * (xn - x), cp.where(cut_state, x, exit_x))
exit_q2 = cp.where(crossed, q + frac * (qn - q), cp.where(cut_state, q, exit_q))
e3 = e2 + cp.where(cut_state, w_mag, zero) # freewheel-диод
committed2 = committed | cut
blew = active & ~cut & ~(cp.abs(in_) < 1e30) # inf/nan: стиффный взрыв
done2 = done | cut | blew
adv = active & ~blew
q2 = cp.where(adv, qn, q)
i2 = cp.where(adv, in_, i)
x2 = cp.where(adv, xn, x)
v2 = cp.where(adv, vn, v)
return q2, i2, x2, v2, done2, committed2, past2, exit_v2, exit_x2, exit_q2, peak2, e3
_FUSED_STEP_CACHE["pair"] = (step_phys, step_cut)
return _FUSED_STEP_CACHE["pair"]
def integrate_batch_discharge(
xp,
q0,
@@ -139,50 +288,97 @@ def integrate_batch_discharge(
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):
# на cupy — ВЕСЬ шаг (RK4 + cut-логика) одним fused-ядром; numpy — обычный путь
is_cupy = xp.__name__ == "cupy"
fused_pair = None
if is_cupy:
try:
fused_pair = _get_fused_pair(xp)
except Exception:
fused_pair = None # честный fallback на пошаговый путь
fused = _get_fused_step(xp)
hs = (params.coil_length_m + params.slug_length_m) / 2
# проверку «все ли готовы» делаем НЕ каждый шаг: на GPU это device->host
# синхронизация, которая убивает конвейер. Раз в SYNC_EVERY шагов достаточно.
SYNC_EVERY = 256
for step in range(max_steps):
active = ~done
if not bool(xp.any(active)):
if step % SYNC_EVERY == 0 and not bool(xp.any(active)):
break
q_old, i_old, x_old, v_old = q, i, x, v
# RK4 от старого состояния
k1 = _derivatives(xp, q_old, i_old, x_old, v_old, params)
k2 = _derivatives(xp, q_old + dt / 2 * k1[0], i_old + dt / 2 * k1[1], x_old + dt / 2 * k1[2], v_old + dt / 2 * k1[3], params)
k3 = _derivatives(xp, q_old + dt / 2 * k2[0], i_old + dt / 2 * k2[1], x_old + dt / 2 * k2[2], v_old + dt / 2 * k2[3], params)
k4 = _derivatives(xp, q_old + dt * k3[0], i_old + dt * k3[1], x_old + dt * k3[2], v_old + dt * k3[3], params)
if fused_pair is not None:
# весь шаг = 2 ядра: RK4+физика, затем cut-логика (вместо ~20 мелких)
try:
qn, in_, xn, vn, sdiss, w_mag = fused_pair[0](
q_old, i_old, x_old, v_old, dt, hs, params.smoothing_width_m,
params.i_sat_a, params.l_air_h, params.l_iron_coeff, params.capacitance_f,
params.r_total_ohm, params.r_eddy_coeff_ohm,
params.retard_const_n, params.drag_coeff_n, params.mass_kg,
)
(q, i, x, v, done, committed, past_peak,
exit_v, exit_x, exit_q, peak_current, energy_diss) = fused_pair[1](
q_old, i_old, x_old, v_old, qn, in_, xn, vn, sdiss, w_mag,
done, committed, past_peak, exit_v, exit_x, exit_q, peak_current, energy_diss,
)
continue
except Exception:
if step == 0: # fuse не собрался — честный откат на пошаговый путь
fused_pair = None
else:
raise
q_new = q_old + dt / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0])
i_new = i_old + dt / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
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])
if is_cupy:
q_new, i_new, x_new, v_new, overlap_old, overlap_new = fused(
q_old, i_old, x_old, v_old, dt, hs, params.smoothing_width_m,
params.i_sat_a, params.l_air_h, params.l_iron_coeff, params.capacitance_f,
params.r_total_ohm, params.r_eddy_coeff_ohm,
params.retard_const_n, params.drag_coeff_n, params.mass_kg,
)
else:
k1 = _derivatives(xp, q_old, i_old, x_old, v_old, params)
k2 = _derivatives(xp, q_old + dt / 2 * k1[0], i_old + dt / 2 * k1[1], x_old + dt / 2 * k1[2], v_old + dt / 2 * k1[3], params)
k3 = _derivatives(xp, q_old + dt / 2 * k2[0], i_old + dt / 2 * k2[1], x_old + dt / 2 * k2[2], v_old + dt / 2 * k2[3], params)
k4 = _derivatives(xp, q_old + dt * k3[0], i_old + dt * k3[1], x_old + dt * k3[2], v_old + dt * k3[3], params)
q_new = q_old + dt / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0])
i_new = i_old + dt / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
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])
overlap_old, _ = _overlap(xp, x_old, params)
overlap_new, _ = _overlap(xp, x_new, params)
# потери 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
# работа трения/воздуха: P = (F_тр + drag·v²)·|v|
fric_old = (params.retard_const_n + params.drag_coeff_n * v_old**2) * xp.abs(v_old)
fric_new = (params.retard_const_n + params.drag_coeff_n * v_new**2) * xp.abs(v_new)
step_diss = step_diss + 0.5 * (fric_old + fric_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))
# ОДИН импульс: обрыв на нуле тока ИЛИ на первом локальном минимуме
# (снаряд начал подкачивать ток обратно). Что раньше.
# ОДИН импульс: обрыв на нуле тока, ИЛИ на первом локальном минимуме
# (снаряд начал подкачивать ток обратно), ИЛИ при падении ниже тока
# удержания ключа (передемпфированный хвост; см. physics/circuit.py).
crossed = active & (i_old > 0) & (i_new <= 0)
local_min = active & past_peak & (i_new > i_old) & ~crossed
cut = crossed | local_min
decayed = active & past_peak & (i_new < 0.1) & ~crossed # I_hold=0.1А
local_min = active & past_peak & (i_new > i_old) & ~crossed & ~decayed
cut = crossed | local_min | decayed
# 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)
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)
cut_at_state = local_min | decayed # обрыв в текущем состоянии (не интерп.)
exit_v = xp.where(cut_at_state, v_old, exit_v)
exit_x = xp.where(cut_at_state, x_old, exit_x)
exit_q = xp.where(cut_at_state, 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)
energy_diss = energy_diss + xp.where(cut_at_state, residual, 0.0)
committed = committed | cut # валидная коммутация
# стиффный конфиг «взорвал» fixed-step (inf/nan) -> стоп, НЕ реализуем

View File

@@ -1,68 +1,304 @@
"""GPU/CPU батч-sweep одноступенчатых конфигураций.
"""GPU/CPU батч-sweep МНОГОСТУПЕНЧАТЫХ конфигураций (до max_stages).
Векторизует самую дорогую часть (разряд) сразу по N конфигурациям через
`integrate_batch_discharge` (numpy CPU / cupy GPU). Настройка каждой
конфигурации (decode + build_stage_physics) — обычный питон-цикл (быстрый),
интегрирование — один батч.
Векторизует разряд сразу по многим конфигурациям через
`integrate_batch_discharge` (numpy CPU / cupy GPU). Многоступенчатость —
раунд за раундом: на раунде s все конфиги, у которых есть ступень s и
которые ещё «живы», считают свой разряд ОДНИМ батчем; состояние снаряда
(глобальная координата и скорость) переносится в следующий раунд.
Конфиги с меньшим числом ступеней просто не участвуют в поздних раундах.
Ограничения (честно): только ОДНА ступень; триггер датчика берётся
аналитически при постоянной скорости подлёта (x_fire ≈ x_sensor + v·delay,
одинаково для оптики/Холла/индукции), для индукционного датчика применяется
та же проверка порога, что в CPU-пути. Полный конвейер (много ступеней,
точный триггер) остаётся на CPU sweep/evolve. Числа физики идентичны CPU —
через общий `build_stage_physics` и валидированный батч-интегратор.
Триггер датчика — аналитический при постоянной скорости подлёта (для
оптики/Холла точный; для индукционного — та же проверка порога, что на CPU).
Физика идентична CPU-пути (общий `sim.stage.build_stage_physics`), сверено
в tests/test_batch_sweep.py (0.0% расхождение exit_v).
"""
import json
import math
import random
import uuid
from dataclasses import replace
from dataclasses import dataclass, field, 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.gpu.batch_integrator import NO_SATURATION_I_SAT, BatchDischargeParams, integrate_batch_discharge
from gausse.optim.objective import MODEL_VERSION
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.physics.constants import switch_pulse_limit_a
from gausse.sim.stage import 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.
@dataclass(frozen=True)
class StagePre:
"""Физика ступени, предвычисленная в ПЛОСКИЕ числа (пиклится дёшево).
Возвращает dict с моделью/параметрами/начальным состоянием или
(None, причина) если конфигурация нереализуема ещё до интегрирования.
Считается один раз на геном (в воркере пула, параллельно) через тот же
`build_stage_physics` — один источник истины с CPU-путём. Главному потоку
остаётся только склейка массивов и запуск GPU-ядер: раньше он строил
физобъекты 4000 геномов сам и душил GPU (карта ждала ~половину времени).
"""
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
l_air_h: float
l_iron_coeff: float
coil_length_m: float
slug_length_m: float
smoothing_width_m: float
i_sat_a: float
r_total_ohm: float
r_eddy_coeff_ohm: float
retard_const_n: float
drag_coeff_n: float
capacitance_f: float
q0: float
energy_in_j: float
sensor_to_coil_m: float
fire_delay_s: float
sensor_kind: str
sensor_sens_v_per_mps: float
sensor_threshold_v: float
pulse_limit_a: float
degenerate_reason: str | None
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
@dataclass(frozen=True)
class GenomePre:
stages: tuple # StagePre по ступеням
coil_centers: tuple # абсолютные центры катушек вдоль трубы
mass_kg: float
initial_x_m: float
initial_v_mps: float
def precompute_genome(genome, db: ComponentDatabase, bounds: SearchBounds) -> GenomePre:
"""decode + build_stage_physics -> плоские числа (годится для пула процессов)."""
cfg, ix, iv = decode(genome, db, bounds)
centers = [0.0]
for gap in cfg.inter_stage_gaps_m:
centers.append(centers[-1] + gap)
stages = []
for stage in cfg.stages:
phys = build_stage_physics(stage, cfg.projectile)
m = phys.inductance_model
cp_ = phys.circuit_params
tau_worst = m.l_air_h / (phys.r_total_ohm + phys.r_eddy_coeff_ohm + 1e-12)
i_sat = m.saturation_current_a
stages.append(StagePre(
l_air_h=m.l_air_h,
l_iron_coeff=m.l_iron_coeff,
coil_length_m=m.coil_length_m,
slug_length_m=m.slug_length_m,
smoothing_width_m=m.smoothing_width_m,
i_sat_a=i_sat if math.isfinite(i_sat) else NO_SATURATION_I_SAT,
r_total_ohm=phys.r_total_ohm,
r_eddy_coeff_ohm=phys.r_eddy_coeff_ohm,
retard_const_n=cp_.retard_const_n,
drag_coeff_n=cp_.drag_coeff_n_per_mps2,
capacitance_f=phys.capacitance_f,
q0=phys.capacitance_f * stage.charge_voltage_v,
energy_in_j=0.5 * phys.capacitance_f * stage.charge_voltage_v**2,
sensor_to_coil_m=stage.sensor_to_coil_distance_m,
fire_delay_s=(stage.sensor.propagation_delay_ns + stage.switch.turn_on_time_ns) * 1e-9,
sensor_kind=stage.sensor.kind,
sensor_sens_v_per_mps=getattr(stage.sensor, "sensitivity_v_per_mps", None) or 0.0,
sensor_threshold_v=getattr(stage.sensor, "threshold_v", None) or 0.0,
pulse_limit_a=switch_pulse_limit_a(stage.switch),
degenerate_reason=(
"вырожденная катушка: L/R < 100 нс (вне области модели)" if tau_worst < 1e-7 else None
),
))
return GenomePre(
stages=tuple(stages), coil_centers=tuple(centers),
mass_kg=cfg.projectile.mass_kg, initial_x_m=ix, initial_v_mps=iv,
)
def precompute_worker(genome) -> GenomePre:
"""Вариант для пула (ProcessPoolExecutor c worker_context.init_worker)."""
from gausse.optim import worker_context
return precompute_genome(genome, worker_context.db, worker_context.bounds)
@dataclass
class _State:
genome: object
pre: GenomePre
cur_x: float # текущая глобальная координата снаряда
cur_v: float # текущая скорость
energy_in_j: float = 0.0
kinetic_delta_j: float = 0.0
alive: bool = True
reason: str | None = None
failed_stage_index: int | None = None
def _coast_velocity(v0: float, distance_m: float, retard_const_n: float, drag_coeff: float, mass_kg: float) -> float:
"""Скорость после подлёта на distance_m с трением и воздухом (точное решение).
m·v·dv/dx = (a + b·v²) ⇒ v²(d) = (v0² + a/b)·e^{2bd/m} a/b (b>0),
либо v² = v0² 2ad/m при b=0. Возвращает 0.0, если снаряд останавливается.
Та же физика, что численный подлёт в sim/stage.py (там ОДУ, тут квадратура).
"""
if distance_m <= 0:
return v0
a, b, m = retard_const_n, drag_coeff, mass_kg
if b <= 0:
u = v0 * v0 - 2.0 * a * distance_m / m
else:
u = (v0 * v0 + a / b) * math.exp(-2.0 * b * distance_m / m) - a / b
return math.sqrt(u) if u > 0 else 0.0
def _prepare_stage(sp: StagePre, mass_kg: float, cur_x_global, cur_v, coil_center_global):
"""Аналитический fire-state ступени из текущего (x,v) — только арифметика."""
if cur_v <= 1e-6:
return None, "снаряд остановился/пошёл назад"
# вырожденный контур (см. sim/stage.py) — тот же честный быстрый отказ
if sp.degenerate_reason:
return None, sp.degenerate_reason
sensor_global = coil_center_global - sp.sensor_to_coil_m
if sensor_global < cur_x_global - 1e-9:
return None, "датчик позади снаряда (не сработает)"
# подлёт до датчика — с трением о трубку и воздухом (та же физика, что на CPU)
v_at_sensor = _coast_velocity(
cur_v, sensor_global - cur_x_global, sp.retard_const_n, sp.drag_coeff_n, mass_kg
)
if v_at_sensor <= 1e-3:
return None, "снаряд остановлен трением о трубку, не долетев до датчика"
if sp.sensor_kind == "inductive":
if sp.sensor_sens_v_per_mps * v_at_sensor < sp.sensor_threshold_v:
return None, "инд. датчик: сигнал ниже порога"
x_fire_global = sensor_global + v_at_sensor * sp.fire_delay_s
x_fire_local = x_fire_global - coil_center_global
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,
"sp": sp, "x_fire": x_fire_local, "v_fire": v_at_sensor,
"coil_center": coil_center_global, "mass": mass_kg,
}, None
def _simulate_states(xp, states: list) -> None:
"""Раунд-за-раундом прогоняет разряды всех живых ступеней батчами (мутирует states)."""
if not states:
return
max_ns = max(len(s.pre.stages) for s in states)
for s_idx in range(max_ns):
prepared = [] # (state, prep)
for st in states:
if not st.alive or len(st.pre.stages) <= s_idx:
continue
p, reason = _prepare_stage(
st.pre.stages[s_idx], st.pre.mass_kg, st.cur_x, st.cur_v, st.pre.coil_centers[s_idx]
)
if p is None:
st.alive = False
st.reason = f"ступень {s_idx}: {reason}"
st.failed_stage_index = s_idx
else:
prepared.append((st, p))
if not prepared:
continue
def col(get):
return xp.asarray([get(p["sp"], p) for _, p in prepared], dtype=xp.float64)
params = BatchDischargeParams(
capacitance_f=col(lambda sp, p: sp.capacitance_f),
r_total_ohm=col(lambda sp, p: sp.r_total_ohm),
mass_kg=col(lambda sp, p: p["mass"]),
l_air_h=col(lambda sp, p: sp.l_air_h),
l_iron_coeff=col(lambda sp, p: sp.l_iron_coeff),
coil_length_m=col(lambda sp, p: sp.coil_length_m),
slug_length_m=col(lambda sp, p: sp.slug_length_m),
smoothing_width_m=col(lambda sp, p: sp.smoothing_width_m),
i_sat_a=col(lambda sp, p: sp.i_sat_a),
r_eddy_coeff_ohm=col(lambda sp, p: sp.r_eddy_coeff_ohm),
retard_const_n=col(lambda sp, p: sp.retard_const_n),
drag_coeff_n=col(lambda sp, p: sp.drag_coeff_n),
)
out = integrate_batch_discharge(
xp,
col(lambda sp, p: sp.q0),
col(lambda sp, p: p["x_fire"]),
col(lambda sp, p: p["v_fire"]),
params, dt=2e-6, max_steps=15000,
)
exit_v = to_cpu(xp, out["exit_v"]); exit_x = to_cpu(xp, out["exit_x"])
peak_i = to_cpu(xp, out["peak_current"]); feas = to_cpu(xp, out["feasible"])
for k, (st, p) in enumerate(prepared):
sp = p["sp"]
if not bool(feas[k]):
st.alive = False
st.reason = f"ступень {s_idx}: разряд не скоммутировался"
st.failed_stage_index = s_idx
continue
if float(peak_i[k]) > sp.pulse_limit_a:
st.alive = False
st.reason = f"ступень {s_idx}: пиковый ток {float(peak_i[k]):.0f}А > импульсного предела ключа ({sp.pulse_limit_a:.0f}А)"
st.failed_stage_index = s_idx
continue
ev = float(exit_v[k])
st.kinetic_delta_j += 0.5 * p["mass"] * (ev**2 - p["v_fire"] ** 2)
st.energy_in_j += sp.energy_in_j
st.cur_v = ev
st.cur_x = p["coil_center"] + float(exit_x[k])
def evaluate_genomes_gpu(
xp, genomes: list, db: ComponentDatabase, bounds: SearchBounds, executor=None,
) -> list:
"""Оценка списка геномов ОДНИМ батчем (для эволюции): та же физика и та же
формула фитнеса, что в objective.evaluate (КПД либо -1+доля пройденных
ступеней), но разряды всех геномов интегрируются вместе на GPU/numpy.
Возвращает только то, что нужно фитнесу (без detail/стоимости): записи
для БД параллельно собирает пул процессов (`gpu_record_worker`).
executor — пул для ПАРАЛЛЕЛЬНОГО предвычисления физики ступеней
(precompute_worker): без него один поток Python готовит физику всей
популяции и GPU простаивает, ожидая данные.
"""
from gausse.optim.objective import EvaluationResult
if executor is not None:
pres = list(executor.map(precompute_worker, genomes, chunksize=64))
else:
pres = [precompute_genome(g, db, bounds) for g in genomes]
states = [
_State(g, pre, cur_x=pre.initial_x_m, cur_v=pre.initial_v_mps)
for g, pre in zip(genomes, pres)
]
_simulate_states(xp, states)
results = []
for st in states:
ok = st.alive and st.energy_in_j > 0
if ok:
eff = st.kinetic_delta_j / st.energy_in_j
results.append(EvaluationResult(
feasible=True, cost_rub=None, fitness=eff, efficiency=eff,
exit_velocity_mps=st.cur_v,
energy_breakdown={
"total_energy_in_j": st.energy_in_j,
"total_kinetic_energy_delta_j": st.kinetic_delta_j,
},
))
else:
n_stages = len(st.pre.stages)
progress = (st.failed_stage_index or 0) / n_stages if n_stages else 0.0
results.append(EvaluationResult(
feasible=False, cost_rub=None, fitness=-1.0 + progress,
reason=st.reason or "нет ни одной сработавшей ступени",
failed_stage_index=st.failed_stage_index,
energy_breakdown={},
))
return results
def run_gpu_sweep(
db_path: Path,
n_runs: int,
@@ -75,8 +311,6 @@ def run_gpu_sweep(
) -> 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)
@@ -86,36 +320,22 @@ def run_gpu_sweep(
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))
states = []
for _ in range(n):
g = sample_genome(db, bounds, rng)
pre = precompute_genome(g, db, bounds)
states.append(_State(g, pre, cur_x=pre.initial_x_m, cur_v=pre.initial_v_mps))
_simulate_states(xp, states)
records = []
for st in states:
if st.alive and st.energy_in_j > 0:
eff = st.kinetic_delta_j / st.energy_in_j
records.append(_record(st, db, backend, eff, st.cur_v))
n_feasible += 1
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
records.append(_record(st, db, backend, None, None))
insert_runs(conn, records)
for r in records:
logger.update(r.feasible, r.efficiency)
@@ -126,49 +346,20 @@ def run_gpu_sweep(
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 _record(st: _State, db, backend, efficiency, exit_v):
from gausse.optim.objective import decoded_summary
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 {}
feasible = efficiency is not None
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,
genome_json=json.dumps(genome_to_dict(st.genome)),
decoded_summary_json=json.dumps(decoded_summary(st.genome, db), ensure_ascii=False),
feasible=feasible,
model_version=MODEL_VERSION,
infeasible_reason=reason,
infeasible_reason=st.reason,
failed_stage_index=None,
efficiency=efficiency,
exit_velocity_mps=exit_v,
cost_rub=None,

View File

@@ -10,6 +10,7 @@
import copy
import multiprocessing
import random
import signal
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
@@ -18,10 +19,10 @@ from scipy.optimize import minimize
from gausse.components.database import ComponentDatabase
from gausse.optim import worker_context
from gausse.optim.objective import EvaluationResult, build_run_record, evaluate
from gausse.optim.objective import EvaluationResult, build_run_record, compute_cost_rub, evaluate
from gausse.optim.progress_log import ProgressLogger, default_log_path
from gausse.optim.search_space import Genome, SearchBounds, crossover, mutate, repair, sample_genome
from gausse.storage.database import run_writer_process
from gausse.optim.search_space import Genome, SearchBounds, crossover, decode, mutate, repair, sample_genome
from gausse.storage.database import StatsOnly, insert_run, open_connection, run_writer_process
from gausse.storage.schema import RunRecord
@@ -30,6 +31,15 @@ def _evaluate_genome(genome: Genome) -> tuple[Genome, EvaluationResult]:
return genome, result
def _best_record(genome: Genome, result: EvaluationResult, db, bounds, mode: str) -> RunRecord:
"""Полная строка БД для сохраняемого лучшего генома. GPU-путь не считает
стоимость для рядовых оценок — дочитываем её только здесь, для избранных."""
if result.cost_rub is None:
config, _, _ = decode(genome, db, bounds)
result.cost_rub = compute_cost_rub(config, db)
return build_run_record(genome, result, db, search_mode=mode)
def _tournament_select(
pairs: list[tuple[Genome, EvaluationResult]], rng: random.Random, k: int = 3
) -> Genome:
@@ -96,13 +106,36 @@ def run_evolution(
mutation_rate: float = 0.2,
polish: bool = True,
log_path: Path | None = None,
use_gpu: bool = False,
islands: int = 1,
migrate_every: int = 10,
) -> dict:
"""islands>1 — островная модель: K независимых популяций эволюционируют
параллельно, а их поколения оцениваются ОДНИМ батчем (GPU любит жирные
батчи: на 1070 батч 4000 давал 306/с, батч 32000 — 1211/с, потому что
маленькие ядра упираются в накладные на запуск). Раз в migrate_every
поколений лучший геном каждого острова мигрирует к соседу по кольцу —
разнообразие против скатывания всех в один локальный оптимум.
"""
n_workers = n_workers or multiprocessing.cpu_count()
rng = random.Random(seed)
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
# GPU-режим: всё поколение оценивается ОДНИМ батчем (gpu/batch_sweep) —
# та же физика через общий build_stage_physics, фикс.шаг RK4 вместо
# адаптивного solve_ivp (сверено в тестах, ~3%). Полировка остаётся на CPU.
gpu_xp = None
backend = "cpu-pool"
if use_gpu:
from gausse.gpu.backend import get_backend
gpu_xp, backend = get_backend(prefer_gpu=True)
mode = "evolve" if gpu_xp is None else f"evolve-gpu({backend})"
if islands > 1:
mode += f"-x{islands}остр"
logger = ProgressLogger(
log_path or default_log_path(db_path), mode="evolve", total=n_generations * population_size
log_path or default_log_path(db_path), mode=mode, total=n_generations * population_size * islands
)
ctx = multiprocessing.get_context("spawn")
@@ -110,54 +143,156 @@ def run_evolution(
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
writer.start()
population = [sample_genome(db, bounds, rng) for _ in range(population_size)]
# SIGTERM (остановка/рестарт systemd) -> исключение -> блок finally ниже
# штатно досылает None и ЖДЁТ писателя: всё уже оценённое дописывается в
# базу, а не теряется вместе с очередью в RAM («сохраняем всё»).
# ВАЖНО: после первого TERM остальные игнорируем — systemd шлёт сигнал
# каждому процессу группы, и повторный TERM ловился ВНУТРИ writer.join(),
# обрывая дописывание базы (реальный кейс с потерей хвоста очереди).
def _terminate(_sig, _frm):
signal.signal(signal.SIGTERM, signal.SIG_IGN)
raise KeyboardInterrupt("SIGTERM: дописываем очередь и выходим")
signal.signal(signal.SIGTERM, _terminate)
populations = [
[sample_genome(db, bounds, rng) for _ in range(population_size)] for _ in range(islands)
]
best_pair: tuple[Genome, EvaluationResult] | None = None
n_evaluated = 0
try:
# пул нужен в ОБОИХ режимах: на CPU он оценивает геномы, в GPU-режиме
# параллельно собирает записи (detail+JSON) — иначе один поток Python
# душит GPU (карта простаивала ~2/3 времени поколения)
with ProcessPoolExecutor(
max_workers=n_workers,
mp_context=ctx,
initializer=worker_context.init_worker,
initargs=(data_dir, bounds),
) as executor:
for generation in range(n_generations):
pairs = list(executor.map(_evaluate_genome, population))
n_evaluated += len(pairs)
# фитнес лучшей УЖЕ ЗАПИСАННОЙ строки каждого острова: полные
# строки в базу идут только когда остров улучшился — иначе элитизм
# дублировал бы один и тот же геном в топ дашборда каждое поколение
written_best_fitness = [float("-inf")] * islands
# СКОРОСТЬ отслеживается отдельно: у дашборда два топа (КПД и
# скорость), а рекордсмены скорости — почти всегда «прожорливые»
# геномы с низким КПД, которые КПД-рекорд не улучшают. Без этого
# трекера топ скоростей замирал навсегда (реальный кейс: после
# перехода на выборочную запись он не обновлялся сутки, пока
# эволюция находила всё новые быстрые геномы)
written_best_velocity = [float("-inf")] * islands
for genome, result in pairs:
record: RunRecord = build_run_record(genome, result, db, search_mode="evolve")
queue.put(record)
for generation in range(n_generations):
# все острова — ОДНИМ плоским батчем (жирный батч кормит GPU)
flat = [g for island in populations for g in island]
if gpu_xp is not None:
from gausse.gpu.batch_sweep import evaluate_genomes_gpu
results = evaluate_genomes_gpu(gpu_xp, flat, db, bounds, executor=executor)
pairs_flat = list(zip(flat, results))
else:
pairs_flat = list(executor.map(_evaluate_genome, flat))
n_evaluated += len(pairs_flat)
for genome, result in pairs_flat:
logger.update(result.feasible, result.efficiency)
if best_pair is None or result.fitness > best_pair[1].fitness:
best_pair = (genome, result)
pairs.sort(key=lambda pair: pair[1].fitness, reverse=True)
next_population = [copy.deepcopy(pair[0]) for pair in pairs[:elitism]]
while len(next_population) < population_size:
parent_a = _tournament_select(pairs, rng)
parent_b = _tournament_select(pairs, rng)
child = crossover(parent_a, parent_b, rng)
child = mutate(child, db, bounds, rng, rate=mutation_rate)
next_population.append(child)
population = next_population
# селекция и скрещивание — НА КАЖДОМ ОСТРОВЕ независимо
island_bests: list[Genome] = []
next_populations = []
recorded = set() # id() результатов, ушедших полной строкой
for i in range(islands):
pairs = pairs_flat[i * population_size:(i + 1) * population_size]
pairs.sort(key=lambda pair: pair[1].fitness, reverse=True)
island_bests.append(pairs[0][0])
# улучшение лучшего на острове — полная строка в базу
# (топы дашборда, best за всё время, отчёты). Новый
# глобальный best — всегда чей-то островной best, так что
# он не теряется.
if pairs[0][1].fitness > written_best_fitness[i]:
written_best_fitness[i] = pairs[0][1].fitness
queue.put(_best_record(pairs[0][0], pairs[0][1], db, bounds, mode))
recorded.add(id(pairs[0][1]))
# рекорд СКОРОСТИ острова — тоже полной строкой
fastest = max(
(p for p in pairs if p[1].feasible and p[1].exit_velocity_mps is not None),
key=lambda p: p[1].exit_velocity_mps,
default=None,
)
if fastest is not None and fastest[1].exit_velocity_mps > written_best_velocity[i]:
written_best_velocity[i] = fastest[1].exit_velocity_mps
if id(fastest[1]) not in recorded:
queue.put(_best_record(fastest[0], fastest[1], db, bounds, mode))
recorded.add(id(fastest[1]))
next_population = [copy.deepcopy(pair[0]) for pair in pairs[:elitism]]
while len(next_population) < population_size:
parent_a = _tournament_select(pairs, rng)
parent_b = _tournament_select(pairs, rng)
child = crossover(parent_a, parent_b, rng)
child = mutate(child, db, bounds, rng, rate=mutation_rate)
next_population.append(child)
next_populations.append(next_population)
# рядовые оценки — в базу ТОЛЬКО агрегатами (счётчики/
# гистограмма честно считают все оценки, вставка полной строки
# выше сама инкрементирует их для избранных — без двойного
# счёта). Хранить миллионы проходных геномов незачем
# («сохраняй то, что нужно для эволюции»), а вставка каждого
# в HDD-базу с 6 индексами была узким местом конвейера:
# GPU считал цикл ~23 мин, дозапись хвоста шла часами.
for _genome, result in pairs_flat:
if id(result) not in recorded:
queue.put(StatsOnly(mode, result.feasible, result.efficiency, result.reason))
# миграция по кольцу: лучший острова i замещает слот у острова i+1
if islands > 1 and (generation + 1) % migrate_every == 0:
for i in range(islands):
next_populations[(i + 1) % islands][elitism] = copy.deepcopy(island_bests[i])
populations = next_populations
finally:
# с этого места нас нельзя прерывать: дописываем базу до конца
signal.signal(signal.SIGTERM, signal.SIG_IGN)
logger.finish()
logger._write("дописываю хвост очереди в базу (писатель)…")
queue.put(None)
writer.join()
while True:
try:
writer.join()
break
except KeyboardInterrupt:
continue # уже завершаемся; ждём писателя до конца
# явно закрыть очередь: иначе её семафоры финализируются в гонке с
# resource_tracker на выходе интерпретатора и сыплют в журнал
# «Exception ignored … sem_unlink FileNotFoundError» после каждого цикла
queue.close()
queue.join_thread()
# после дренажа возвращаем обработку SIGTERM по умолчанию: иначе
# процесс в фазе полировки нельзя остановить мягко (только SIGKILL)
signal.signal(signal.SIGTERM, signal.SIG_DFL)
logger._write("база дописана полностью")
assert best_pair is not None
best_genome, best_result = best_pair
if polish and best_result.feasible:
logger._write("полировка лучшего генома (Nelder-Mead, CPU) — это НЕ остановка, новый цикл стартует после неё")
polished_genome, polished_result = polish_best(best_genome, db, bounds)
logger._write(f"полировка готова: КПД {best_result.fitness*100:.2f}% -> {polished_result.fitness*100:.2f}%")
if polished_result.fitness > best_result.fitness:
conn_queue = ctx.Queue()
polish_writer = ctx.Process(target=run_writer_process, args=(conn_queue, db_path))
polish_writer.start()
conn_queue.put(build_run_record(polished_genome, polished_result, db, search_mode="evolve-polish"))
conn_queue.put(None)
polish_writer.join()
# ОДНУ запись пишем напрямую, без процесса-писателя и очереди:
# конкурентных писателей в этот момент нет (основной уже завершён),
# а редкая гонка spawn+Queue вешала главный процесс в join()
# НАВЕЧНО (22.5 часа простоя, пойман по /proc/*/stack: главный в
# do_wait, писатель в pipe_read — данные из очереди не доехали).
conn = open_connection(db_path)
try:
insert_run(conn, build_run_record(polished_genome, polished_result, db, search_mode="evolve-polish"))
finally:
conn.close()
logger._write("полированный геном записан в базу")
best_genome, best_result = polished_genome, polished_result
return {

View File

@@ -15,11 +15,17 @@ import numpy as np
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import Genome, SearchBounds, decode, genome_to_dict
from gausse.physics.ac_resistance import dowell_ac_factor
from gausse.physics.constants import switch_pulse_limit_a
from gausse.physics.inductance import air_core_inductance_wheeler, winding_geometry
from gausse.sim.coilgun import CoilgunResult, run_coilgun
from gausse.storage.schema import RunRecord
MODEL_VERSION = "gausse-physics-v1"
# v4: диффузия поля в снаряд — намагничивается только скин-кольцо глубиной δ,
# закрыт эксплойт «медленной» катушки (30%). v3: fill factor (эксплойт тонкого
# снаряда), полное отражение вихрей, гард вырожденных катушек. v2: Доуэлл,
# трение+воздух, паспортные импульсные токи ключей.
MODEL_VERSION = "gausse-physics-v4"
@dataclass
@@ -32,7 +38,6 @@ class EvaluationResult:
efficiency: float | None = None
exit_velocity_mps: float | None = None
energy_breakdown: dict = None
detail: dict = None
def compute_cost_rub(config, db: ComponentDatabase) -> float:
@@ -55,10 +60,11 @@ def _wire_resistance_ohm(resistivity_ohm_m: float, gauge_mm: float, wire_length_
def decoded_summary(genome: Genome, db: ComponentDatabase) -> dict:
"""Краткая сводка только по геному (без результатов симуляции).
Оставлена для обратной совместимости; подробную запись со всей физикой
и результатами по каждой ступени строит `build_detail` (её и пишем в БД).
"""Краткая сводка только по геному (без результатов симуляции) — это и
пишется в decoded_summary_json для каждого прогона: genome_json уже
достаточен для полного воспроизведения через build_detail() на лету,
так что хранить дорогую подробную запись для каждого из миллионов
прогонов незачем (см. web/stats.run_detail для просмотра одного прогона).
"""
material = db.projectile_materials[genome.projectile.material_idx % len(db.projectile_materials)]
stages_summary = []
@@ -115,6 +121,10 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
l_air_h = air_core_inductance_wheeler(
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
)
_char_omega = 1.0 / math.sqrt(l_air_h * stage.capacitor.capacitance_uf * 1e-6)
r_ac_factor = dowell_ac_factor(
stage.wire.gauge_mm / 1000, wire_od_m, stage.layers, stage.wire.resistivity_ohm_m, _char_omega
)
cap = stage.capacitor
# cap может быть батареей (CapacitorBank) или одиночным конденсатором (CapacitorSpec)
@@ -127,6 +137,8 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
"coil_center_position_m": coil_centers_m[i],
"gap_before_stage_m": (config.inter_stage_gaps_m[i - 1] if i > 0 else None),
"sensor_to_coil_distance_m": stage.sensor_to_coil_distance_m,
# абсолютная позиция ЭТОГО датчика вдоль трубы (у каждой катушки свой)
"sensor_position_m": coil_centers_m[i] - stage.sensor_to_coil_distance_m,
"components": {
"wire": stage.wire.part_id,
"wire_material": stage.wire.material,
@@ -168,7 +180,10 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
},
"electrical": {
"charge_voltage_v": stage.charge_voltage_v,
"wire_resistance_ohm": r_wire,
"wire_resistance_dc_ohm": r_wire,
# скин + эффект близости на частоте импульса (Доуэлл)
"wire_ac_resistance_factor": r_ac_factor,
"wire_resistance_ac_ohm": r_wire * r_ac_factor,
"air_inductance_h": l_air_h,
},
}
@@ -179,13 +194,16 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
peak_current_a = (
float(np.max(np.abs(r.discharge_i))) if r.discharge_i is not None and len(r.discharge_i) else None
)
# диагностика по току: хватает ли батареи и ключа на пиковый ток разряда.
# Это предупреждение, а не жёсткий отказ: в базе — паспортный (не импульсный)
# максимум тока, а одиночный выстрел кратковременный, поэтому честнее
# флажок, чем ложная отбраковка. Параллельные конденсаторы уже дают
# реальный выигрыш через сниженный ESR (меньше потери), см. capacitor_bank.
# Диагностика по току. Жёсткая граница для ключа — ИМПУЛЬСНЫЙ рейтинг
# (ITSM/IDM/ICM из даташита): её превышение уже даёт feasible=False в
# run_stage. Пик выше ПРОДОЛЖИТЕЛЬНОГО рейтинга при одиночном коротком
# импульсе — норма (так работают все coilgun'ы), это ИНФОРМАЦИОННЫЙ
# флаг, а не противоречие с feasible. Для батареи конденсаторов — тоже
# предупреждение (в базе паспортный ripple, не импульсный максимум).
switch_pulse_a = switch_pulse_limit_a(stage.switch)
bank_over = peak_current_a is not None and peak_current_a > cap.max_current_a
switch_over = peak_current_a is not None and peak_current_a > stage.switch.max_current_a
switch_over_cont = peak_current_a is not None and peak_current_a > stage.switch.max_current_a
switch_over_pulse = peak_current_a is not None and peak_current_a > switch_pulse_a
entry["outcome"] = {
"reached": True,
"feasible": r.feasible,
@@ -195,8 +213,12 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
"t_sensor_s": r.t_sensor_s,
"t_fire_s": r.t_fire_s,
"peak_current_a": peak_current_a,
"switch_pulse_limit_a": switch_pulse_a,
"bank_current_over_limit": bank_over,
"switch_current_over_limit": switch_over,
# пик > продолжительного рейтинга: НОРМА для одиночного импульса
"switch_current_over_continuous_rating": switch_over_cont,
# пик > импульсного (даташит): такой конфиг отбраковывается
"switch_current_over_pulse_limit": switch_over_pulse,
"peak_field_estimate_tesla": r.peak_field_estimate_tesla,
"saturation_warning": r.saturation_warning,
"energy_in_j": r.energy_in_j,
@@ -237,10 +259,15 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> EvaluationResult:
"""detail НЕ строится здесь: build_detail — дорогая (полная физика по
каждой ступени) и не нужна ни фитнесу, ни хранению — genome_json уже
достаточен, чтобы воспроизвести всё через decode()+build_detail() на
лету (см. web/stats.run_detail). build_run_record() кладёт в БД дешёвую
decoded_summary() вместо этого.
"""
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, genome.tube_inner_d_m, genome.tube_wall_m)
if not result.feasible:
n_stages = len(config.stages)
@@ -253,7 +280,6 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
reason=result.reason,
failed_stage_index=result.failed_stage_index,
energy_breakdown={},
detail=detail,
)
energy_breakdown = {
@@ -269,12 +295,11 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
efficiency=result.efficiency,
exit_velocity_mps=result.exit_v_mps,
energy_breakdown=energy_breakdown,
detail=detail,
)
def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord:
detail = result.detail if result.detail is not None else decoded_summary(genome, db)
detail = decoded_summary(genome, db)
return RunRecord(
run_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(),

View File

@@ -23,7 +23,7 @@ PROJECTILE_BORE_CLEARANCE_M = 0.0005
@dataclass(frozen=True)
class SearchBounds:
min_stages: int = 1
max_stages: int = 4
max_stages: int = 10
turns_per_layer_min: int = 5
turns_per_layer_max: int = 100
layers_min: int = 1
@@ -140,10 +140,13 @@ def sample_genome(db: ComponentDatabase, bounds: SearchBounds, rng: random.Rando
for _ in range(n_stages - 1)
]
projectile = ProjectileGene(material_idx=material_idx, diameter_m=diameter_m, length_m=length_m)
return Genome(
genome = Genome(
tube_inner_d_m=tube_inner_d_m, tube_wall_m=tube_wall_m,
stages=stages, inter_stage_gaps_m=gaps, projectile=projectile,
)
# прогоняем через repair, чтобы применились все физические ограничения
# (в т.ч. датчик каждой ступени должен стоять в зазоре перед своей катушкой)
return repair(genome, db, bounds)
def repair(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Genome:
@@ -188,6 +191,15 @@ def repair(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Genom
genome.inter_stage_gaps_m.append(
(bounds.inter_stage_gap_m_min + bounds.inter_stage_gap_m_max) / 2
)
# Датчик ступени i>0 должен стоять В ЗАЗОРЕ перед своей катушкой, а не
# залезать на предыдущую: расстояние датчик→катушка < зазор до пред.катушки.
# (у первой катушки датчик перед ней в свободном пространстве — без ограничения.)
for i in range(1, len(genome.stages)):
gap = genome.inter_stage_gaps_m[i - 1]
max_s2c = max(gap * 0.9, bounds.sensor_to_coil_distance_m_min)
genome.stages[i].sensor_to_coil_distance_m = min(
genome.stages[i].sensor_to_coil_distance_m, max_s2c
)
return genome

View File

@@ -0,0 +1,87 @@
"""AC-сопротивление обмотки: скин-эффект + эффект близости (метод Доуэлла).
Разряд конденсатора через катушку — импульс с характерной частотой
ω ≈ 1/√(L·C) (сотни Гц — единицы кГц). На этой частоте ток вытесняется
к поверхности провода (скин-эффект), а в многослойной намотке соседние
слои дополнительно вытесняют ток друг у друга (эффект близости). Для
толстого провода и многих слоёв эффективное сопротивление в 1.53 раза
выше DC — этот канал потерь раньше не учитывался и завышал КПД.
Модель — классический Доуэлл (Dowell, 1966) для m-слойной обмотки,
круглый провод сведён к эквивалентной фольге:
Δ = (π/4)^{3/4} · (d/δ) · √η, δ = √(2ρ/(ω·μ₀)) — глубина скина,
η = d_голый/d_изолир — коэффициент заполнения слоя,
F_R = Δ·[φ₁(Δ) + (2(m²1)/3)·φ₂(Δ)],
φ₁ = (sh2Δ+sin2Δ)/(ch2Δcos2Δ), φ₂ = (shΔsinΔ)/(chΔ+cosΔ).
ЧЕСТНО об ограничениях: ток разряда — затухающий импульс, а не синусоида;
берём одну характерную частоту ω=1/√(LC) (первая гармоника импульса), без
разложения в спектр. Доуэлл предполагает плотную рядовую намотку и
пренебрегает кривизной. Точность — десятки процентов, но это несравнимо
честнее, чем DC-сопротивление, занижавшее потери в разы для толстого
провода во многих слоях.
"""
import math
from gausse.physics.constants import MU_0
def skin_depth_m(resistivity_ohm_m: float, omega_rad_s: float) -> float:
"""Глубина скин-слоя δ = √(2ρ/(ω·μ₀)) для немагнитного проводника (Cu/Al)."""
return math.sqrt(2.0 * resistivity_ohm_m / (omega_rad_s * MU_0))
def iron_penetration_fraction(
slug_radius_m: float,
slug_resistivity_ohm_m: float,
mu_r: float,
omega_rad_s: float,
) -> float:
"""Доля сечения снаряда, реально намагниченная за импульс (диффузия поля).
Переменное поле проникает в проводящее железо на глубину скин-слоя
δ = √(2ρ/(ω·μ₀·μᵣ)) — за миллисекундный импульс у стали это доли
миллиметра. Внутренность снаряда не успевает намагнититься и силы не
даёт. Работает кольцо глубиной δ: A_eff/A = 1(1δ/a)².
ЧЕСТНО: это одночастотная оценка (ω=1/√(LC)) решения уравнения диффузии,
а не само решение; ошибка — десятки процентов. Но без неё модель считала,
что намагничивается ВСЁ сечение, и оптимизатор строил «медленные»
катушки с нефизично высоким КПД (30%+).
"""
if omega_rad_s <= 0 or slug_resistivity_ohm_m <= 0 or mu_r < 1 or slug_radius_m <= 0:
return 1.0
delta = math.sqrt(2.0 * slug_resistivity_ohm_m / (omega_rad_s * MU_0 * mu_r))
if delta >= slug_radius_m:
return 1.0
return 1.0 - (1.0 - delta / slug_radius_m) ** 2
def dowell_ac_factor(
wire_bare_d_m: float,
wire_insulated_d_m: float,
n_layers: int,
resistivity_ohm_m: float,
omega_rad_s: float,
) -> float:
"""F_R = R_AC/R_DC ≥ 1 для m-слойной обмотки на частоте ω (Доуэлл)."""
if omega_rad_s <= 0.0 or n_layers < 1 or resistivity_ohm_m <= 0.0:
return 1.0 # ρ=0 (идеальный провод): R_DC=0, фактор не имеет смысла
delta = skin_depth_m(resistivity_ohm_m, omega_rad_s)
porosity = min(wire_bare_d_m / wire_insulated_d_m, 1.0)
d_eff = (math.pi / 4.0) ** 0.75 * wire_bare_d_m * math.sqrt(porosity)
big_delta = d_eff / delta
m = float(n_layers)
if big_delta < 0.25:
# ряд малых Δ (φ-формулы теряют точность из-за сокращения знаков):
# F_R ≈ 1 + (5m²1)/45 · Δ⁴
return 1.0 + (5.0 * m * m - 1.0) / 45.0 * big_delta**4
two_d = 2.0 * big_delta
phi1 = (math.sinh(two_d) + math.sin(two_d)) / (math.cosh(two_d) - math.cos(two_d))
phi2 = (math.sinh(big_delta) - math.sin(big_delta)) / (math.cosh(big_delta) + math.cos(big_delta))
f_r = big_delta * (phi1 + (2.0 * (m * m - 1.0) / 3.0) * phi2)
return max(f_r, 1.0)

View File

@@ -29,6 +29,24 @@ class StageCircuitParams:
# отражённое сопротивление вихревых токов снаряда; действует × overlap(x)
# (только пока снаряд в катушке). См. physics/losses.py.
r_eddy_coeff_ohm: float = 0.0
# тормозящие силы на снаряд: сухое трение о трубку (константа, Н) и
# аэродинамическое сопротивление (× v², Н·с²/м²). Обе против движения.
retard_const_n: float = 0.0
drag_coeff_n_per_mps2: float = 0.0
# Сглаживание сухого трения около v=0: sign(v) -> tanh(v/ε). РАЗРЫВНАЯ сила
# (чистый sign) ломает адаптивный решатель: у v≈0 шаг дробится бесконечно
# (chattering), одна конфигурация считается десятки минут. tanh — стандартная
# регуляризация (модель залипания); при |v| >> ε неотличима от sign.
FRICTION_SMOOTHING_V_MPS = 0.01
def retarding_force_n(params: StageCircuitParams, v: float) -> float:
"""Тормозящая сила (трение + воздух), гладкая по v, против скорости."""
return float(np.tanh(v / FRICTION_SMOOTHING_V_MPS)) * (
params.retard_const_n + params.drag_coeff_n_per_mps2 * v * v
)
def effective_resistance(inductance_model: CoilInductanceModel, params: StageCircuitParams, x) -> float:
@@ -51,7 +69,7 @@ def derivatives(
d_q = -current
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
d_v = (force - retarding_force_n(params, v)) / params.mass_kg
return [d_q, d_i, v, d_v]
@@ -82,3 +100,28 @@ def current_local_min_event(t: float, state: np.ndarray, inductance_model, param
current_local_min_event.terminal = True
current_local_min_event.direction = 1.0
# Ток удержания ключа: тиристор сам закрывается, когда ток после пика падает
# ниже I_H (типично 30-100 мА; берём консервативно 0.1 А). Без этого события
# передемпфированный разряд (ток -> 0 асимптотически, нуля не пересекает)
# заставляет решатель молотить весь временной бюджет микросекундными шагами —
# одна конфигурация считалась минутами. Обрыв при I<I_hold — физика, не хак.
HOLDING_CURRENT_A = 0.1
def make_current_decay_event(i_hold_a: float = HOLDING_CURRENT_A):
"""Терминальное событие: ток после пика упал ниже тока удержания ключа."""
peak = 0.0
def event(t: float, state: np.ndarray, *_args) -> float:
nonlocal peak
i = float(state[I])
if i > peak:
peak = i
if peak < 2.0 * i_hold_a: # импульса ещё толком не было
return 1.0
return i - i_hold_a
event.terminal = True
event.direction = -1.0
return event

View File

@@ -15,3 +15,29 @@ RESISTIVITY_ALUMINUM_OHM_M = 2.82e-8 # при ~20°C
# Это оценки, а не паспортные surge-рейтинги конкретных деталей.
SWITCH_SURGE_FACTOR = {"SCR": 10.0, "MOSFET": 4.0, "IGBT": 3.0}
CAPACITOR_SURGE_FACTOR = 5.0
def switch_pulse_limit_a(switch) -> float:
"""Импульсный предел тока ключа для одиночного выстрела.
Приоритет — паспортный импульсный рейтинг из даташита (ITSM тиристора,
IDM MOSFET, ICM IGBT), занесённый в базу компонентов. Только если его
нет — консервативный типовой множитель SWITCH_SURGE_FACTOR к
продолжительному току.
"""
pulse = getattr(switch, "pulse_current_a", None)
if pulse:
return float(pulse)
return switch.max_current_a * SWITCH_SURGE_FACTOR.get(switch.kind, 4.0)
G_ACCEL_M_S2 = 9.81
# Сухое трение скольжения сталь↔пластик (ПВХ/оргстекло): типично 0.30.4.
# Труба горизонтальна: нормальная сила = m·g. Оценка, вынесена для уточнения
# по реальному замеру (наклонная плоскость с реальной трубкой и снарядом).
FRICTION_COEFF_SLUG_TUBE = 0.35
AIR_DENSITY_KG_M3 = 1.2 # при ~20°C, уровень моря
# Cd плоского торца цилиндра в свободном потоке ~0.81.2. ЧЕСТНО: в узкой
# трубе есть ещё поршневой эффект (снаряд толкает столб воздуха) — он НЕ
# учтён и занижает потери на высоких скоростях в длинной трубе.
DRAG_COEFF_CYLINDER = 0.85

View File

@@ -107,10 +107,15 @@ class CoilInductanceModel:
smoothing_width_m: float
total_turns: int = 0
b_sat_tesla: float = 1e9 # по умолчанию насыщение отключено (для геом. тестов)
# Доля сечения катушки, занятая железом: A_снаряда/A_среднего витка (≤1).
# λ = n·(B_fe·A_fe + B_возд·(AA_fe)) ⇒ L = L_air·(1+(μ1)·fill·overlap).
# БЕЗ этого тонкий снаряд в толстой катушке получал усиление как будто
# железо заполняет ВСЁ сечение — оптимизатор эксплуатировал (КПД «82%»).
iron_fill_factor: float = 1.0
@property
def l_iron_coeff(self) -> float:
return self.l_air_h * (self.mu_eff - 1.0)
return self.l_air_h * (self.mu_eff - 1.0) * self.iron_fill_factor
@property
def saturation_current_a(self) -> float:

View File

@@ -46,10 +46,22 @@ def eddy_reflected_resistance_ohm(
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
# собственная индуктивность вихревого контура (виток-соленоид длиной снаряда):
# полная формула отражения R = (ωM)²·R₂/(R₂²+(ωL₂)²) вместо резистивно-
# доминированного приближения (ωM)²/R₂ — иначе на высоких ω (вырожденные
# короткие катушки) отражённое сопротивление нефизично улетало в сотни кОм.
l_eddy_loop = MU_0 * a_slug / slug_length_m
om = char_omega_rad_s
return (om * mutual) ** 2 * r_eddy_loop / (r_eddy_loop**2 + (om * l_eddy_loop) ** 2)
def hysteresis_energy_j(slug_radius_m: float, slug_length_m: float, n_cycles: float = 1.0) -> float:
"""Энергия гистерезиса за выстрел (Дж) — вторичный канал, обычно << вихревых."""
"""Энергия гистерезиса за выстрел (Дж) — вторичный канал, обычно << вихревых.
СОЗНАТЕЛЬНО не подключена в динамику: для типичных снарядов это
~0.0050.05 Дж на выстрел (<0.1% энергии банки) — на порядки меньше
вихревых потерь, уже сидящих в контуре через r_eddy. Вкручивать её в
ОДУ значило бы изображать точность, которой у модели нет.
"""
volume = math.pi * slug_radius_m**2 * slug_length_m
return HYSTERESIS_LOSS_J_PER_M3 * volume * n_cycles

View File

@@ -29,7 +29,7 @@ def _sech2(z: np.ndarray) -> np.ndarray:
def make_optical_sensor_event(x_sensor_m: float) -> SensorEvent:
def event(t: float, state: np.ndarray) -> float:
def event(t: float, state: np.ndarray, *_args) -> float:
return state[0] - x_sensor_m
event.terminal = True
@@ -43,7 +43,7 @@ def make_inductive_sensor_event(
threshold_v: float,
width_m: float,
) -> SensorEvent:
def event(t: float, state: np.ndarray) -> float:
def event(t: float, state: np.ndarray, *_args) -> float:
x, v = state
bump = _sech2((x - x_sensor_m) / width_m)
return sensitivity_v_per_mps * v * bump - threshold_v

View File

@@ -1,10 +1,15 @@
"""Анимация одного прогона: снаряд летит по трубе, катушки светятся по току.
"""Анимация одного прогона: снаряд в трубе + ток катушек + скорость, всё во времени.
Строит СПЛОШНУЮ траекторию (баллистический подлёт к датчику + разряд по
каждой ступени, сшитые по глобальному времени/координате) и рендерит кадр
за кадром: положение снаряда в трубе + "свечение" каждой катушки
пропорционально её текущему току (яркая -- сильное поле, тусклая -- нет).
Сохраняется как GIF через встроенный в matplotlib PillowWriter.
Честность по времени: кадры берутся на РАВНОМЕРНОЙ сетке по физическому
времени (раньше — по индексам массива, из-за чего плотные точки разряда
съедали все кадры, а подлёт между катушками «вырезался»). Всё событие
показывается целиком в замедлении, множитель замедления написан в кадре.
Результат эксперимента — в самом кадре:
- шапка: выходная скорость, КПД, энергия банок, масса снаряда, ступени;
- средняя панель: ток каждой катушки во времени (окна разрядов подкрашены);
- нижняя панель: скорость снаряда во времени;
- бегущий курсор на обеих панелях синхронен с положением снаряда в трубе.
"""
from pathlib import Path
@@ -19,19 +24,21 @@ import numpy as np
from gausse.sim.coilgun import CoilgunConfig, CoilgunResult
_STAGE_COLORS = ["#d4a017", "#c0392b", "#2980b9", "#27ae60", "#8e44ad",
"#d35400", "#16a085", "#7f8c8d", "#2c3e50", "#e91e63"]
def _build_global_timeline(result: CoilgunResult, config: CoilgunConfig, fps: int):
"""Непрерывная траектория снаряда: коаст (по инерции) + разряды по ступеням.
Строится ПОСЛЕДОВАТЕЛЬНО (без сортировки по времени, которая рвала
траекторию): между разрядами снаряд летит по инерции от конца прошлого
разряда до начала следующего; во время разряда берём точно посчитанные
discharge_x/v/i. Глобальная координата = центр катушки ступени + локальный
discharge_x. Ток каждой катушки задан только в окне её собственного разряда.
def _build_global_timeline(result: CoilgunResult, config: CoilgunConfig):
"""Непрерывные t/x/v + ток по каждой ступени + окна разрядов.
Строится последовательно: подлёт (скорость почти постоянна на сантиметрах
межкатушечного зазора) + точные траектории разрядов из решателя.
Глобальная координата = центр катушки ступени + локальный discharge_x.
"""
n_stages = len(config.stages)
seg_t, seg_x = [], []
seg_t, seg_x, seg_v = [], [], []
seg_i = [[] for _ in range(n_stages)]
discharge_windows = [] # (t_start, t_end, stage_idx)
t_cursor = 0.0
prev_global_x = None
@@ -44,14 +51,15 @@ def _build_global_timeline(result: CoilgunResult, config: CoilgunConfig, fps: in
disch_global_x = outcome.global_coil_center_m + r.discharge_x
start_global_x = float(disch_global_x[0])
# коаст по инерции от конца прошлого разряда до начала этого
# подлёт от конца прошлого разряда до момента поджига этого
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)
coast_dist = max(start_global_x - prev_global_x, 0.0)
coast_dur = coast_dist / prev_v
n_coast = 16
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))
seg_v.append(np.full(n_coast, prev_v))
for s in range(n_stages):
seg_i[s].append(np.zeros(n_coast))
t_cursor += coast_dur
@@ -59,8 +67,10 @@ def _build_global_timeline(result: CoilgunResult, config: CoilgunConfig, fps: in
# окно разряда: точная траектория + ток именно этой катушки
seg_t.append(t_cursor + r.discharge_t)
seg_x.append(disch_global_x)
seg_v.append(r.discharge_v)
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))
discharge_windows.append((t_cursor, t_cursor + float(r.discharge_t[-1]), outcome.stage_index))
t_cursor += float(r.discharge_t[-1])
prev_global_x = float(disch_global_x[-1])
@@ -71,38 +81,102 @@ def _build_global_timeline(result: CoilgunResult, config: CoilgunConfig, fps: in
times = np.concatenate(seg_t)
positions = np.concatenate(seg_x)
velocities = np.concatenate(seg_v)
currents = [np.concatenate(seg_i[s]) for s in range(n_stages)]
return times, positions, currents
# страховка от немонотонности на стыках сегментов (float-складки)
order = np.argsort(times, kind="stable")
return times[order], positions[order], velocities[order], currents, order, discharge_windows
def animate_run(result: CoilgunResult, config: CoilgunConfig, out_path: str, fps: int = 30) -> str:
if not result.stage_outcomes:
raise ValueError("нечего анимировать: нет ни одной ступени в результате")
times, positions, currents = _build_global_timeline(result, config, fps)
n_frames = min(len(times), fps * 10) # ограничение на длину анимации
frame_idx = np.linspace(0, len(times) - 1, n_frames).astype(int)
times, positions, velocities, currents_raw, order, windows = _build_global_timeline(result, config)
currents = [c[order] for c in currents_raw]
total_t = float(times[-1])
# РАВНОМЕРНАЯ сетка по физическому времени: ничего не вырезается,
# всё событие целиком в замедлении (множитель — в кадре)
wall_duration_s = 8.0
n_frames = int(wall_duration_s * fps)
t_grid = np.linspace(0.0, total_t, n_frames)
x_grid = np.interp(t_grid, times, positions)
v_grid = np.interp(t_grid, times, velocities)
i_grid = [np.interp(t_grid, times, c) for c in currents]
slowmo = wall_duration_s / max(total_t, 1e-9)
coil_centers = [o.global_coil_center_m for o in result.stage_outcomes]
max_current_per_stage = [max(np.max(np.abs(c)), 1e-9) for c in currents]
t_ms = times * 1e3
fig, ax = plt.subplots(figsize=(8, 3))
fig, (ax_tube, ax_i, ax_v) = plt.subplots(
3, 1, figsize=(9, 7.5), gridspec_kw={"height_ratios": [1.1, 1.4, 1.4], "hspace": 0.45}
)
# --- шапка: РЕЗУЛЬТАТ эксперимента ---
eff = result.efficiency
header = (
f"выход {result.exit_v_mps:.1f} м/с | КПД {eff * 100:.1f}% | "
f"{len(config.stages)} ступ. | E_банок {result.total_energy_in_j:.0f} Дж | "
f"снаряд {config.projectile.mass_kg * 1e3:.0f} г"
if result.feasible and eff is not None
else f"НЕРЕАЛИЗУЕМО: {result.reason}"
)
fig.suptitle(header, fontsize=12, fontweight="bold")
# --- панель 1: труба ---
tube_min, tube_max = positions.min(), positions.max()
ax.set_xlim(tube_min * 1e3 - 5, tube_max * 1e3 + 5)
ax.set_ylim(-1, 1)
ax.set_xlabel("положение, мм")
ax.set_yticks([])
ax.set_title("Пролёт снаряда через ступени")
ax.axhline(0, color="#888", linewidth=3, zorder=1) # труба
pad = max((tube_max - tube_min) * 0.05, 0.005)
ax_tube.set_xlim((tube_min - pad) * 1e3, (tube_max + pad) * 1e3)
ax_tube.set_ylim(-1, 1)
ax_tube.set_xlabel("положение, мм")
ax_tube.set_yticks([])
ax_tube.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")
color = _STAGE_COLORS[k % len(_STAGE_COLORS)]
dot = ax_tube.scatter([c * 1e3], [0], s=500, c="lightgray",
edgecolors=color, linewidths=2, zorder=2)
ax_tube.annotate(f"катушка {k}", (c * 1e3, 0), textcoords="offset points",
xytext=(0, 20), ha="center", fontsize=8, color=color)
# позиция датчика этой ступени (у каждой катушки свой)
sensor_x = (c - config.stages[k].sensor_to_coil_distance_m) * 1e3
ax_tube.plot([sensor_x], [-0.35], marker="v", color=color, markersize=6, zorder=2)
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)
ax_tube.plot([], [], marker="v", linestyle="", color="#555", label="датчики")
ax_tube.legend(loc="upper left", fontsize=7, frameon=False)
slug_dot = ax_tube.scatter([], [], s=160, c="#1f4e8c", edgecolors="white", linewidths=1, zorder=3)
info_text = ax_tube.text(0.99, 1.25, "", transform=ax_tube.transAxes,
fontsize=9, ha="right", va="top")
# --- панель 2: ток каждой катушки во времени ---
for k in range(len(config.stages)):
color = _STAGE_COLORS[k % len(_STAGE_COLORS)]
ax_i.plot(t_ms, currents[k], color=color, linewidth=1.2, label=f"катушка {k}")
for (w0, w1, k) in windows:
ax_i.axvspan(w0 * 1e3, w1 * 1e3, color=_STAGE_COLORS[k % len(_STAGE_COLORS)], alpha=0.08)
ax_i.set_ylabel("ток, А")
ax_i.set_xlabel("время, мс")
ax_i.legend(loc="upper right", fontsize=7, frameon=False, ncols=2)
ax_i.grid(alpha=0.25)
cursor_i = ax_i.axvline(0.0, color="#333", linewidth=1)
# --- панель 3: скорость снаряда во времени ---
ax_v.plot(t_ms, velocities, color="#1f4e8c", linewidth=1.6)
for (w0, w1, k) in windows:
ax_v.axvspan(w0 * 1e3, w1 * 1e3, color=_STAGE_COLORS[k % len(_STAGE_COLORS)], alpha=0.08)
ax_v.set_ylabel("скорость, м/с")
ax_v.set_xlabel("время, мс")
ax_v.grid(alpha=0.25)
if result.feasible and result.exit_v_mps is not None:
ax_v.axhline(result.exit_v_mps, color="#27ae60", linewidth=0.8, linestyle="--")
ax_v.annotate(f"выход {result.exit_v_mps:.1f} м/с",
(t_ms[-1], result.exit_v_mps), ha="right", va="bottom",
fontsize=8, color="#27ae60")
cursor_v = ax_v.axvline(0.0, color="#333", linewidth=1)
v_dot = ax_v.scatter([], [], s=30, c="#1f4e8c", zorder=3)
max_i_per_stage = [max(float(np.max(np.abs(c))), 1e-9) for c in currents]
def _coil_color(intensity):
# выкл -> светло-серый; максимум тока -> яркое золото (свечение поля)
@@ -111,15 +185,18 @@ def animate_run(result: CoilgunResult, config: CoilgunConfig, out_path: str, fps
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]
def update(f):
slug_dot.set_offsets([[x_grid[f] * 1e3, 0]])
for k, dots in enumerate(coil_dots):
intensity = abs(i_grid[k][f]) / max_i_per_stage[k]
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]
dots.set_edgecolor(_STAGE_COLORS[k % len(_STAGE_COLORS)])
dots.set_sizes([500 + 350 * min(intensity, 1.0)])
info_text.set_text(f"t = {t_grid[f] * 1e3:.2f} мс | v = {v_grid[f]:.1f} м/с | замедление ×{slowmo:.0f}")
cursor_i.set_xdata([t_grid[f] * 1e3])
cursor_v.set_xdata([t_grid[f] * 1e3])
v_dot.set_offsets([[t_grid[f] * 1e3, v_grid[f]]])
return [slug_dot, info_text, cursor_i, cursor_v, v_dot, *coil_dots]
anim = animation.FuncAnimation(fig, update, frames=n_frames, interval=1000 / fps, blit=False)
out_path = str(out_path)

View File

@@ -28,7 +28,14 @@ from gausse.physics.inductance import (
effective_permeability,
winding_geometry,
)
from gausse.physics.constants import SWITCH_SURGE_FACTOR
from gausse.physics.ac_resistance import dowell_ac_factor, iron_penetration_fraction
from gausse.physics.constants import (
AIR_DENSITY_KG_M3,
DRAG_COEFF_CYLINDER,
FRICTION_COEFF_SLUG_TUBE,
G_ACCEL_M_S2,
switch_pulse_limit_a,
)
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 (
@@ -114,8 +121,23 @@ def _switch_equivalent_resistance_ohm(
return drop_v / max(i_ref, 1e-6)
def _ballistic_derivatives(t: float, state: np.ndarray) -> list[float]:
return [state[1], 0.0]
def _coast_derivatives(t: float, state: np.ndarray, retard_const_n: float, drag_coeff: float, mass_kg: float) -> list[float]:
"""Подлёт к датчику: не баллистика в вакууме, а с трением о трубку и воздухом.
Трение сглажено tanh'ом (см. circuit.FRICTION_SMOOTHING_V_MPS) — разрывный
sign(v) заставляет адаптивный решатель бесконечно дробить шаг у v≈0.
"""
v = state[1]
retard = (retard_const_n + drag_coeff * v * v) * math.tanh(v / circuit.FRICTION_SMOOTHING_V_MPS)
return [v, -retard / mass_kg]
def _stall_event(t: float, state: np.ndarray, *_args) -> float:
return state[1] - 1e-3 # снаряд практически остановился (трение съело скорость)
_stall_event.terminal = True
_stall_event.direction = -1.0
@dataclass(frozen=True)
@@ -133,23 +155,31 @@ class StagePhysics:
r_eddy_coeff_ohm: float
capacitance_f: float
mu_eff: float
r_wire_dc_ohm: float = 0.0
r_ac_factor: float = 1.0 # R_AC/R_DC обмотки (скин + близость, Доуэлл)
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)
r_wire_dc = _wire_resistance_ohm(stage.wire, geometry.total_wire_length_m)
capacitance_f = stage.capacitor.capacitance_uf * 1e-6
l_air_h = air_core_inductance_wheeler(
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
)
char_omega = 1.0 / math.sqrt(l_air_h * capacitance_f)
# скин + эффект близости: на частоте импульса обмотка сопротивляется
# сильнее, чем по DC (для толстого провода во многих слоях — в разы)
r_ac_factor = dowell_ac_factor(
stage.wire.gauge_mm / 1000, wire_od_m, stage.layers, stage.wire.resistivity_ohm_m, char_omega
)
r_wire = r_wire_dc * r_ac_factor
r_switch = _switch_equivalent_resistance_ohm(
stage.switch, capacitance_f, l_air_h, stage.charge_voltage_v
)
r_total_ohm = r_wire + r_switch + stage.capacitor.esr_ohm
demag = demagnetizing_factor_prolate(projectile.aspect_ratio)
mu_eff = effective_permeability(projectile.material.mu_r, demag)
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,
@@ -159,6 +189,17 @@ def build_stage_physics(stage: StageConfig, projectile: ProjectileConfig) -> Sta
coil_length_m=geometry.coil_length_m,
char_omega_rad_s=char_omega,
)
# доля сечения катушки, реально занятая железом (см. inductance.py):
# тонкий снаряд в толстой катушке не может усиливать ВЕСЬ поток;
# и из этого железа за импульс намагничивается только скин-кольцо
# глубиной δ (диффузия поля, см. ac_resistance.iron_penetration_fraction)
fill = min((projectile.diameter_m / 2) ** 2 / geometry.mean_radius_m**2, 1.0)
fill *= iron_penetration_fraction(
projectile.diameter_m / 2,
projectile.material.resistivity_ohm_m,
projectile.material.mu_r,
char_omega,
)
inductance_model = CoilInductanceModel(
l_air_h=l_air_h,
coil_length_m=geometry.coil_length_m,
@@ -167,12 +208,17 @@ def build_stage_physics(stage: StageConfig, projectile: ProjectileConfig) -> Sta
smoothing_width_m=wire_od_m,
total_turns=geometry.total_turns,
b_sat_tesla=projectile.material.b_sat_tesla,
iron_fill_factor=fill,
)
mass_kg = projectile.mass_kg
frontal_area_m2 = math.pi * (projectile.diameter_m / 2) ** 2
circuit_params = StageCircuitParams(
capacitance_f=capacitance_f,
r_total_ohm=r_total_ohm,
mass_kg=projectile.mass_kg,
mass_kg=mass_kg,
r_eddy_coeff_ohm=r_eddy_coeff,
retard_const_n=FRICTION_COEFF_SLUG_TUBE * mass_kg * G_ACCEL_M_S2,
drag_coeff_n_per_mps2=0.5 * AIR_DENSITY_KG_M3 * DRAG_COEFF_CYLINDER * frontal_area_m2,
)
return StagePhysics(
inductance_model=inductance_model,
@@ -182,6 +228,8 @@ def build_stage_physics(stage: StageConfig, projectile: ProjectileConfig) -> Sta
r_eddy_coeff_ohm=r_eddy_coeff,
capacitance_f=capacitance_f,
mu_eff=mu_eff,
r_wire_dc_ohm=r_wire_dc,
r_ac_factor=r_ac_factor,
)
@@ -201,6 +249,21 @@ def run_stage(
inductance_model = phys.inductance_model
wire_od_m = stage.wire.insulation_od_mm / 1000
# Вырожденный контур — вне области применимости модели: при постоянной
# времени L/R (с учётом вихревых при полном перекрытии) короче ~100 нс
# (например, катушка в несколько витков длиной пару мм) ОДУ становится
# неинтегрируемо жёсткой (решатель молотит минутами), а сама инженерная
# модель индуктивности/вихрей там уже не имеет смысла. Честный быстрый отказ.
tau_worst_s = inductance_model.l_air_h / (r_total_ohm + r_eddy_coeff + 1e-12)
if tau_worst_s < 1e-7:
return StageResult(
feasible=False,
reason=(
f"вырожденная катушка: постоянная времени контура {tau_worst_s*1e9:.1f} нс "
f"(< 100 нс) — вне области применимости модели"
),
)
x_sensor_m = -stage.sensor_to_coil_distance_m
if stage.sensor.kind == "inductive":
@@ -223,14 +286,21 @@ def run_stage(
else:
sensor_event = make_optical_sensor_event(x_sensor_m)
cp = phys.circuit_params
flight_sol = solve_ivp(
_ballistic_derivatives,
_coast_derivatives,
(0.0, stage.sensor_time_budget_s),
[entry_x_m, entry_v_mps],
events=sensor_event,
args=(cp.retard_const_n, cp.drag_coeff_n_per_mps2, mass_kg),
events=(sensor_event, _stall_event),
rtol=1e-8,
atol=1e-10,
)
if len(flight_sol.t_events[1]) > 0 and len(flight_sol.t_events[0]) == 0:
return StageResult(
feasible=False,
reason="снаряд остановлен трением о трубку, не долетев до датчика",
)
if len(flight_sol.t_events[0]) == 0:
return StageResult(
feasible=False,
@@ -255,13 +325,18 @@ def run_stage(
(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, circuit.current_local_min_event),
events=(
circuit.zero_current_crossing_event,
circuit.current_local_min_event,
# ключ закрывается сам при токе ниже удержания (см. circuit.py)
circuit.make_current_decay_event(),
),
rtol=1e-8,
atol=1e-11,
)
# какое из событий оборвало разряд первым
term_candidates = []
for ev_idx in range(2):
for ev_idx in range(3):
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:
@@ -284,7 +359,12 @@ def run_stage(
# её гасит обратный диод (freewheel), учитываем как потери, чтобы энергия
# сходилась. Считаем ТОЧНО (с насыщением), а не ½LI².
residual_inductor_j = float(inductance_model.magnetic_energy(x_final, i_final))
energy_dissipated_j = resistive_diss + residual_inductor_j
# работа трения о трубку и воздуха ЗА РАЗРЯД: P = (F_тр + F_возд)·|v|
v_during = discharge_sol.y[3]
friction_diss = float(
np.trapezoid((cp.retard_const_n + cp.drag_coeff_n_per_mps2 * v_during**2) * np.abs(v_during), discharge_sol.t)
)
energy_dissipated_j = resistive_diss + residual_inductor_j + friction_diss
kinetic_before_j = 0.5 * mass_kg * v_fire_mps**2
kinetic_after_j = 0.5 * mass_kg * v_final**2
@@ -310,13 +390,13 @@ def run_stage(
# Батарея конденсаторов — только предупреждение (см. build_detail):
# электролиты переносят высокий импульсный ток, а в базе — паспортный
# непрерывный/ripple рейтинг, поэтому жёстко браковать по нему нечестно.
switch_surge_a = stage.switch.max_current_a * SWITCH_SURGE_FACTOR.get(stage.switch.kind, 4.0)
switch_surge_a = switch_pulse_limit_a(stage.switch)
if peak_current_a > switch_surge_a:
return StageResult(
feasible=False,
reason=(
f"пиковый ток {peak_current_a:.0f} А превышает импульсный предел ключа "
f"{stage.switch.part_number} ({switch_surge_a:.0f} А = {stage.switch.max_current_a:.0f} А × surge)"
f"{stage.switch.part_number} ({switch_surge_a:.0f} А, даташит/surge)"
),
t_sensor_s=t_sensor_s,
t_fire_s=t_sensor_s + fire_delay_s,

View File

@@ -9,26 +9,142 @@
import sqlite3
import sys
from dataclasses import asdict, fields
from dataclasses import asdict, dataclass, fields
from pathlib import Path
from gausse.storage.schema import CREATE_INDEXES_SQL, CREATE_RUNS_TABLE_SQL, RunRecord
from gausse.storage.schema import CREATE_INDEXES_SQL, CREATE_RUNS_TABLE_SQL, CREATE_STATS_SQL, RunRecord
_COLUMNS = [f.name for f in fields(RunRecord)]
@dataclass(frozen=True)
class StatsOnly:
"""Лёгкое сообщение писателю: учесть оценку в агрегатах дашборда
(счётчики/гистограмма/причины), НЕ сохраняя строку в runs.
Эволюция шлёт такое для РЯДОВЫХ геномов поколения, а полной строкой
пишет только улучшения лучших (см. optim/evolutionary.py): хранить
миллионы проходных геномов незачем, а вставка каждого в HDD-базу с
6 индексами была узким местом всего конвейера (GPU считал цикл за
~23 мин, дозапись хвоста очереди шла часами при ~150 строк/с).
Атрибуты названы как у RunRecord — _apply_stats работает с обоими.
"""
search_mode: str
feasible: bool
efficiency: float | None
infeasible_reason: str | None
def open_connection(db_path: Path) -> sqlite3.Connection:
db_path = Path(db_path)
db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL")
# не давать WAL расти безгранично: при потоке вставок 400/с и постоянных
# читателях (веб) авто-чекпоинт голодает, WAL раздувался до гигабайт и
# замедлял ВСЕ запросы. Лимит + периодический чекпоинт в писателе.
conn.execute("PRAGMA journal_size_limit=67108864") # 64МБ
conn.execute(CREATE_RUNS_TABLE_SQL)
for statement in CREATE_INDEXES_SQL:
conn.execute(statement)
for statement in CREATE_STATS_SQL:
conn.execute(statement)
conn.commit()
return conn
def short_reason(reason: str | None) -> str | None:
"""Причина отказа без деталей конкретного прогона (для группировки)."""
return reason.split(":", 1)[0] if reason else reason
def _efficiency_bucket(efficiency: float | None) -> int | None:
"""Корзина гистограммы КПД 0..9 (та же логика, что раньше в SQL-агрегате)."""
if efficiency is None:
return None
b = min(int(efficiency * 10), 9)
return b if 0 <= b <= 9 else None
def _apply_stats(conn: sqlite3.Connection, records: list) -> None:
"""Инкремент агрегатов дашборда — вызывается внутри транзакции вставки.
Принимает и RunRecord, и StatsOnly (одинаковые имена атрибутов)."""
n_feasible = sum(1 for r in records if r.feasible)
conn.execute(
"INSERT INTO stats_counters(key, value) VALUES('total', ?) "
"ON CONFLICT(key) DO UPDATE SET value = value + excluded.value",
(len(records),),
)
conn.execute(
"INSERT INTO stats_counters(key, value) VALUES('feasible', ?) "
"ON CONFLICT(key) DO UPDATE SET value = value + excluded.value",
(n_feasible,),
)
hist: dict[int, int] = {}
reasons: dict[str, int] = {}
modes: dict[str, int] = {}
for r in records:
modes[r.search_mode] = modes.get(r.search_mode, 0) + 1
if r.feasible:
b = _efficiency_bucket(r.efficiency)
if b is not None:
hist[b] = hist.get(b, 0) + 1
elif r.infeasible_reason:
key = short_reason(r.infeasible_reason)
reasons[key] = reasons.get(key, 0) + 1
conn.executemany(
"INSERT INTO stats_hist(bucket, count) VALUES(?, ?) "
"ON CONFLICT(bucket) DO UPDATE SET count = count + excluded.count",
list(hist.items()),
)
conn.executemany(
"INSERT INTO stats_reasons(reason, count) VALUES(?, ?) "
"ON CONFLICT(reason) DO UPDATE SET count = count + excluded.count",
list(reasons.items()),
)
conn.executemany(
"INSERT INTO stats_modes(mode, count) VALUES(?, ?) "
"ON CONFLICT(mode) DO UPDATE SET count = count + excluded.count",
list(modes.items()),
)
def rebuild_stats(conn: sqlite3.Connection) -> None:
"""Пересобирает агрегаты из таблицы runs (миграция старой базы; один раз)."""
conn.execute("DELETE FROM stats_counters")
conn.execute("DELETE FROM stats_hist")
conn.execute("DELETE FROM stats_reasons")
conn.execute("DELETE FROM stats_modes")
total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
feasible = conn.execute("SELECT COUNT(*) FROM runs WHERE feasible=1").fetchone()[0]
conn.execute("INSERT INTO stats_counters VALUES('total', ?)", (total,))
conn.execute("INSERT INTO stats_counters VALUES('feasible', ?)", (feasible,))
conn.execute(
"INSERT INTO stats_hist SELECT b, COUNT(*) FROM "
"(SELECT MIN(CAST(efficiency*10 AS INT), 9) AS b FROM runs WHERE feasible=1 AND efficiency IS NOT NULL) "
"WHERE b BETWEEN 0 AND 9 GROUP BY b"
)
for reason, cnt in conn.execute(
"SELECT infeasible_reason, COUNT(*) FROM runs WHERE feasible=0 AND infeasible_reason IS NOT NULL "
"GROUP BY infeasible_reason"
).fetchall():
conn.execute(
"INSERT INTO stats_reasons VALUES(?, ?) ON CONFLICT(reason) DO UPDATE SET count = count + excluded.count",
(short_reason(reason), cnt),
)
conn.execute("INSERT INTO stats_modes SELECT search_mode, COUNT(*) FROM runs GROUP BY search_mode")
conn.commit()
def ensure_stats(conn: sqlite3.Connection) -> None:
"""Если агрегаты пусты, а прогоны есть — база со старой схемой: пересобрать."""
has_stats = conn.execute("SELECT COUNT(*) FROM stats_counters").fetchone()[0] > 0
if not has_stats and conn.execute("SELECT EXISTS(SELECT 1 FROM runs)").fetchone()[0]:
rebuild_stats(conn)
def _record_to_params(record: RunRecord) -> dict:
row = asdict(record)
row["feasible"] = int(row["feasible"])
@@ -45,15 +161,21 @@ def insert_run(conn: sqlite3.Connection, record: RunRecord) -> None:
insert_runs(conn, [record])
def insert_runs(conn: sqlite3.Connection, records: list[RunRecord]) -> None:
def insert_runs(conn: sqlite3.Connection, records: list) -> None:
"""Пишет пачку RunRecord/StatsOnly одной транзакцией: строки runs — только
для RunRecord, агрегаты дашборда — по ВСЕМ элементам пачки (либо всё,
либо ничего — иначе цифры на морде разъедутся с таблицей)."""
if not records:
return
placeholders = ", ".join(f":{name}" for name in _COLUMNS)
columns = ", ".join(_COLUMNS)
conn.executemany(
f"INSERT INTO runs ({columns}) VALUES ({placeholders})",
[_record_to_params(r) for r in records],
)
rows = [r for r in records if isinstance(r, RunRecord)]
if rows:
placeholders = ", ".join(f":{name}" for name in _COLUMNS)
columns = ", ".join(_COLUMNS)
conn.executemany(
f"INSERT INTO runs ({columns}) VALUES ({placeholders})",
[_record_to_params(r) for r in rows],
)
_apply_stats(conn, records)
conn.commit()
@@ -81,11 +203,20 @@ def fetch_runs(
limit: int | None = None,
) -> list[RunRecord]:
conn.row_factory = sqlite3.Row
query = "SELECT * FROM runs WHERE 1=1"
# топы дашборда: принуждаем частичный DESC-индекс (INDEXED BY) — иначе
# планировщик берёт idx_runs_feasible и сортирует сотни тысяч строк
# TEMP B-TREE'ом (~2с на запрос). Условие feasible=1 обязано быть
# ЛИТЕРАЛОМ: частичный индекс не матчится с bound-параметром.
forced_index = ""
if feasible is True and min_efficiency is None:
if order_by_velocity_desc:
forced_index = " INDEXED BY idx_runs_feas_vel"
elif order_by_efficiency_desc:
forced_index = " INDEXED BY idx_runs_feas_eff"
query = f"SELECT * FROM runs{forced_index} WHERE 1=1"
params: list = []
if feasible is not None:
query += " AND feasible = ?"
params.append(int(feasible))
query += f" AND feasible = {int(feasible)}"
if min_efficiency is not None:
query += " AND efficiency >= ?"
params.append(min_efficiency)
@@ -101,26 +232,67 @@ def fetch_runs(
def run_writer_process(queue, db_path: Path) -> None:
"""Читает `RunRecord` из очереди и пишет в SQLite, пока не придёт None.
"""Читает `RunRecord`/`StatsOnly` из очереди и пишет в SQLite до None.
Предназначен для запуска как отдельный `multiprocessing.Process` —
единственный writer на файл базы, пока воркеры sweep/evolve только
кладут результаты в очередь.
Один "плохой" прогон (например, коллизия run_id) не должен уронить
писатель и остановить осушение очереди на много часов sweep — при
ошибке вставки конкретная запись пропускается с сообщением в stderr,
а не молча и не ценой падения всего процесса.
ПАЧКАМИ, не по одной: коммит SQLite = fsync (~5-10мс), и по-одному
писатель выдавал ~150/с — GPU-эволюция (380+/с) копила хвост очереди в
RAM на минуты, который терялся при рестарте сервиса («сохраняем всё»
нарушалось). Теперь копим до BATCH записей (или пока очередь не
опустела) и вставляем одной транзакцией — тысячи вставок в секунду,
хвост исчезает.
SIGTERM игнорируется: при остановке сервиса systemd шлёт TERM всей
группе; писатель обязан дожить до None от главного процесса и дописать
ВСЁ, что уже оценено, а не умереть с полной очередью.
Одна "плохая" пачка (например, коллизия run_id) не роняет писатель:
откатываемся и вставляем пачку по одной, пропуская только виновника.
"""
import queue as queue_mod
import signal
import time as time_mod
signal.signal(signal.SIGTERM, signal.SIG_IGN)
BATCH = 500
CHECKPOINT_EVERY_S = 60.0
conn = open_connection(db_path)
# если база старая (без агрегатов) — пересобрать ДО первых вставок,
# иначе счётчики начнут вестись только с нуля и разойдутся с таблицей
ensure_stats(conn)
last_checkpoint = time_mod.monotonic()
try:
while True:
record = queue.get()
if record is None:
break
finished = False
while not finished:
batch = [queue.get()] # блокируемся на первой
while len(batch) < BATCH:
try:
batch.append(queue.get_nowait())
except queue_mod.Empty:
break
if batch[-1] is None:
finished = True
batch.pop()
if not batch:
continue
try:
insert_run(conn, record)
except sqlite3.Error as exc:
print(f"[storage] не удалось записать run_id={record.run_id}: {exc}", file=sys.stderr)
insert_runs(conn, batch)
except sqlite3.Error:
conn.rollback()
for record in batch: # ищем виновника, остальное сохраняем
try:
insert_run(conn, record)
except sqlite3.Error as exc:
rid = getattr(record, "run_id", "<stats-only>")
print(f"[storage] не удалось записать run_id={rid}: {exc}", file=sys.stderr)
# авто-чекпоинт голодает при постоянном потоке — двигаем WAL сами,
# PASSIVE не блокирует читателей
if time_mod.monotonic() - last_checkpoint > CHECKPOINT_EVERY_S:
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
last_checkpoint = time_mod.monotonic()
finally:
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
conn.close()

View File

@@ -1,9 +1,18 @@
"""Схема таблицы `runs` ВСЕ прогоны (успешные и нет), без прикрас.
"""Схема таблицы `runs` + агрегаты дашборда.
Нереализуемые конфигурации хранятся наравне с успешными: `feasible=0` +
`infeasible_reason` с честной причиной. Ничего не отбрасывается перед
записью — отбор "хороших" результатов делается запросом к этой таблице
постфактум, а не фильтрацией на входе.
Честность цифр — через агрегаты: счётчики/гистограмма/причины (stats_*)
инкрементируются для КАЖДОЙ оценки, включая нереализуемые, в той же
транзакции писателя. А вот полные СТРОКИ в `runs` пишутся по-разному:
- sweep/manual: каждая оценка — строка (исследовательский режим);
- evolve (с 2026-07-12): строка — только когда лучший геном острова
УЛУЧШИЛСЯ (+ полировка); рядовые миллионы геномов поколения идут в базу
как StatsOnly-инкременты счётчиков. Хранить их незачем («сохраняй то,
что нужно для эволюции»), а вставка каждого в HDD-базу с 6 индексами
была узким местом: GPU считал цикл ~23 мин, дозапись шла часами.
Нереализуемые не приукрашиваются: у строк feasible=0 + честная причина,
у агрегатов — счётчик причин отказов.
"""
from dataclasses import dataclass
@@ -14,6 +23,13 @@ CREATE TABLE IF NOT EXISTS runs (
timestamp TEXT NOT NULL,
search_mode TEXT NOT NULL,
genome_json TEXT NOT NULL,
-- ДЕШЁВАЯ сводка (objective.decoded_summary) — НЕ полная физика по
-- ступеням. genome_json сам по себе достаточен, чтобы воспроизвести
-- всё через decode()+build_detail() на лету (см. web/stats.run_detail);
-- хранить дорогую детализацию для каждого из миллионов прогонов
-- бессмысленно — так база росла на пустом месте (70ГБ на 12.9М строк).
-- В базах, созданных ДО 2026-07-11, эта колонка содержит старый полный
-- build_detail (другая форма записей ступеней) — учитывать при чтении.
decoded_summary_json TEXT NOT NULL,
feasible INTEGER NOT NULL,
infeasible_reason TEXT,
@@ -31,6 +47,21 @@ CREATE_INDEXES_SQL = [
"CREATE INDEX IF NOT EXISTS idx_runs_efficiency ON runs(efficiency)",
"CREATE INDEX IF NOT EXISTS idx_runs_velocity ON runs(exit_velocity_mps)",
"CREATE INDEX IF NOT EXISTS idx_runs_search_mode ON runs(search_mode)",
# частичные индексы под топы дашборда: ORDER BY ... DESC при feasible=1
# без них top-15 на сотнях тысяч строк занимал ~1.2с на каждый рефреш
"CREATE INDEX IF NOT EXISTS idx_runs_feas_eff ON runs(efficiency DESC) WHERE feasible=1",
"CREATE INDEX IF NOT EXISTS idx_runs_feas_vel ON runs(exit_velocity_mps DESC) WHERE feasible=1",
]
# Инкрементальные агрегаты для дашборда: счётчики/гистограмма/причины/режимы
# обновляются В ТОЙ ЖЕ транзакции, что и вставка прогонов (insert_runs), и
# читаются за O(1) — иначе каждый рефреш морды сканировал ВСЮ таблицу runs
# (5.2с на 630к строк, а база растёт годами).
CREATE_STATS_SQL = [
"CREATE TABLE IF NOT EXISTS stats_counters (key TEXT PRIMARY KEY, value INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS stats_hist (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS stats_reasons (reason TEXT PRIMARY KEY, count INTEGER NOT NULL)",
"CREATE TABLE IF NOT EXISTS stats_modes (mode TEXT PRIMARY KEY, count INTEGER NOT NULL)",
]

View File

@@ -60,7 +60,9 @@ PAGE_HTML = r"""<!doctype html>
<div class="card"><div class="k">Реализуемо</div><div class="v" id="c-feas">—</div></div>
<div class="card"><div class="k">Нереализуемо</div><div class="v" id="c-infeas">—</div></div>
<div class="card"><div class="k">Доля реализуемых</div><div class="v" id="c-pct">—</div></div>
<div class="card"><div class="k">Лучший КПД / скорость</div><div class="v" id="c-best" style="font-size:20px">—</div></div>
<div class="card"><div class="k">Лучший КПД за всё время / скорость</div><div class="v" id="c-best" style="font-size:20px">—</div></div>
<div class="card"><div class="k">Лучший КПД текущего цикла</div><div class="v" id="c-cycle" style="font-size:20px">—</div></div>
<div class="card"><div class="k">GPU (GTX 1070)</div><div class="v" id="c-gpu" style="font-size:20px">—</div></div>
</div>
<div class="grid2">
@@ -70,7 +72,7 @@ PAGE_HTML = r"""<!doctype html>
<button class="tgl" id="tab-vel" onclick="setRank('vel')">по скорости</button>
</h2>
<table>
<thead><tr><th>КПД</th><th>Скор., м/с</th><th>Ступ.</th><th>Цена, ₽</th><th>Режим</th></tr></thead>
<thead><tr><th>КПД</th><th>Скор., м/с</th><th>Ступ.</th><th>Цена, ₽</th><th>Режим</th><th>Когда найден</th></tr></thead>
<tbody id="top"></tbody>
</table>
<p class="muted" style="font-size:12px">Клик по строке — полная детализация прогона (расстояния, номиналы, физика).</p>
@@ -107,13 +109,23 @@ function setRank(m){ rankMode=m; if(lastOverview) renderTop(lastOverview);
document.getElementById('tab-eff').classList.toggle('active', m==='eff');
document.getElementById('tab-vel').classList.toggle('active', m==='vel'); }
// «Когда найден»: без этой колонки обновление топа было НЕВИДИМО — новая
// строка 13.63% глазом неотличима от старой 13.44%, и казалось, что таблица
// застыла. Свежие записи (< 1 часа) подсвечиваются.
const fmtWhen = ts => {
if(!ts) return "";
const d = new Date(ts), ageMs = Date.now()-d.getTime();
const t = d.toLocaleString([], {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
return ageMs < 3600e3 ? `<b style="color:#3fb950">${t}</b>` : t;
};
function renderTop(o){
const rows = rankMode==='vel' ? (o.top_by_velocity||[]) : (o.top||[]);
const top = document.getElementById('top'); top.innerHTML='';
rows.forEach(t=>{
const tr=document.createElement('tr'); tr.className='clk';
tr.innerHTML=`<td>${fmtPct(t.efficiency)}</td><td>${fmt(t.exit_velocity_mps,1)}</td>`+
`<td>${t.n_stages??''}</td><td>${fmt(t.cost_rub,0)}</td><td><span class="pill">${t.search_mode}</span></td>`;
`<td>${t.n_stages??''}</td><td>${fmt(t.cost_rub,0)}</td><td><span class="pill">${t.search_mode}</span></td>`+
`<td class="muted" style="white-space:nowrap">${fmtWhen(t.timestamp)}</td>`;
tr.onclick=()=>showDetail(t.run_id);
top.appendChild(tr);
});
@@ -131,6 +143,12 @@ async function refresh(){
let best = (o.top && o.top.length) ? o.top[0].efficiency : null;
let bestV = (o.top_by_velocity && o.top_by_velocity.length) ? o.top_by_velocity[0].exit_velocity_mps : null;
document.getElementById('c-best').textContent = fmtPct(best) + (bestV!=null ? ' / '+fmt(bestV,0)+' м/с' : '');
document.getElementById('c-cycle').textContent =
o.cycle_best_pct==null ? '' : o.cycle_best_pct.toFixed(2)+'% (цикл идёт)';
const g = o.gpu;
document.getElementById('c-gpu').textContent = g
? `${g.util_pct}% · ${fmt(g.power_w,0)}Вт · ${g.temp_c}°C`
: 'нет данных';
renderTop(o);
const hist=document.getElementById('hist'); hist.innerHTML='';

60
src/gausse/web/rebuild.py Normal file
View File

@@ -0,0 +1,60 @@
"""Восстановление симуляции прогона из genome_json — ОДИН общий путь для
детализации (stats.run_detail) и рендера графиков/анимации (render.py).
Раньше эти два места дублировали decode()+run_coilgun() независимо, причём
каждый клик по прогону запускал одну и ту же симуляцию до 5 раз (детализация
+ 3 графика + GIF), а run_detail ещё и перечитывал базу компонентов с диска
на каждый запрос. Здесь: кэш базы компонентов + маленький кэш последних
симуляций по run_id.
"""
import json
import threading
from pathlib import Path
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
from gausse.sim.coilgun import run_coilgun
from gausse.storage.database import fetch_run_by_id, open_connection
_db_cache: ComponentDatabase | None = None
_sim_lock = threading.Lock()
_sim_cache: dict[str, tuple] = {} # run_id -> (row, genome, config, result, x0, v0)
_SIM_CACHE_MAX = 8 # клик по прогону = detail + 3 графика + GIF того же run_id
def components_db() -> ComponentDatabase:
global _db_cache
if _db_cache is None:
_db_cache = ComponentDatabase.load()
return _db_cache
def rebuild_run(db_path: Path, run_id: str):
"""(row, genome, config, result, initial_x_m, initial_v_mps) либо None,
если run_id нет в базе. Ошибки пересимуляции пробрасываются наружу —
вызывающий решает, что показать (детализация не должна ронять сервер).
"""
with _sim_lock:
cached = _sim_cache.get(run_id)
if cached is not None:
return cached
conn = open_connection(db_path)
try:
row = fetch_run_by_id(conn, run_id)
finally:
conn.close()
if row is None:
return None
genome = genome_from_dict(json.loads(row.genome_json))
config, initial_x_m, initial_v_mps = decode(genome, components_db(), SearchBounds())
result = run_coilgun(config)
entry = (row, genome, config, result, initial_x_m, initial_v_mps)
with _sim_lock:
if len(_sim_cache) >= _SIM_CACHE_MAX:
_sim_cache.clear()
_sim_cache[run_id] = entry
return entry

View File

@@ -7,7 +7,6 @@
"""
import io
import json
import tempfile
import threading
from pathlib import Path
@@ -18,15 +17,11 @@ matplotlib.use("Agg")
import matplotlib.pyplot as plt
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
from gausse.report.animate import animate_run
from gausse.report.plots import plot_stage_currents, plot_stage_fields, plot_velocity_vs_position
from gausse.sim.coilgun import run_coilgun
from gausse.storage.database import fetch_run_by_id, open_connection
from gausse.web.rebuild import rebuild_run
_render_lock = threading.Lock()
_db_cache: ComponentDatabase | None = None
_PLOT_BUILDERS = {
"velocity": plot_velocity_vs_position,
@@ -35,24 +30,12 @@ _PLOT_BUILDERS = {
}
def _components_db() -> ComponentDatabase:
global _db_cache
if _db_cache is None:
_db_cache = ComponentDatabase.load()
return _db_cache
def _rebuild_result(db_path: Path, run_id: str):
conn = open_connection(db_path)
try:
row = fetch_run_by_id(conn, run_id)
finally:
conn.close()
if row is None:
rebuilt = rebuild_run(db_path, run_id) # общий кэш симуляций с run_detail
if rebuilt is None:
return None
genome = genome_from_dict(json.loads(row.genome_json))
config, _ix, _iv = decode(genome, _components_db(), SearchBounds())
return config, run_coilgun(config)
_row, _genome, config, result, _ix, _iv = rebuilt
return config, result
def render_plot_png(db_path: Path, run_id: str, kind: str) -> bytes | None:

View File

@@ -1,57 +1,85 @@
"""Агрегаты по таблице `runs` для веб-морды — считаются запросами к SQLite."""
import json
import time
from pathlib import Path
from gausse.storage.database import fetch_run_by_id, fetch_runs, open_connection
from gausse.storage.database import ensure_stats, fetch_run_by_id, fetch_runs, open_connection
def _tail_log(log_path: Path, n_lines: int = 40) -> list[str]:
"""Последние строки лога чтением С КОНЦА файла (лог растёт годами —
перечитывать его целиком на каждый рефреш нельзя)."""
if not log_path.exists():
return []
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
with open(log_path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 64 * 1024))
chunk = f.read().decode("utf-8", errors="replace")
lines = chunk.splitlines()
if size > 64 * 1024 and lines:
lines = lines[1:] # первая строка чанка может быть обрезана
return lines[-n_lines:]
def _gpu_stat(db_path: Path) -> dict | None:
"""Загрузка GPU из gpustat.json рядом с базой (пишет gausse-gpustat на хосте).
Веб живёт в Docker без доступа к nvidia-smi, поэтому хост-сервис раз в
несколько секунд кладёт JSON в каталог результатов. Протухшие данные
(>30с — писатель умер/GPU нет) честно не показываем.
"""
p = db_path.parent / "gpustat.json"
try:
stat = json.loads(p.read_text(encoding="utf-8"))
if time.time() - float(stat.get("ts", 0)) > 30:
return None
return stat
except (OSError, ValueError):
return None
def overview(db_path: Path, log_path: Path | None = None, top_n: int = 15) -> dict:
"""Сводка для дашборда: счётчики, гистограмма КПД, топ конфигураций, хвост лога."""
"""Сводка для дашборда: счётчики, гистограмма КПД, топ конфигураций, хвост лога.
Счётчики/гистограмма/причины/режимы читаются из инкрементальных
агрегатов (stats_*, ведутся в транзакции вставки) за O(1) — раньше
каждый рефреш сканировал всю таблицу runs (5.2с на 630к строк).
"""
conn = open_connection(db_path)
try:
total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
feasible = conn.execute("SELECT COUNT(*) FROM runs WHERE feasible=1").fetchone()[0]
ensure_stats(conn) # миграция базы со старой схемой (один раз)
counters = dict(conn.execute("SELECT key, value FROM stats_counters"))
total = counters.get("total", 0)
feasible = counters.get("feasible", 0)
infeasible = total - feasible
# распределение по типу датчика ступени 0 (реализуемо / всего)
by_mode = {}
for mode, cnt in conn.execute("SELECT search_mode, COUNT(*) FROM runs GROUP BY search_mode"):
by_mode[mode] = cnt
by_mode = dict(conn.execute("SELECT mode, count FROM stats_modes"))
# причины отказа (топ)
reasons = []
for reason, cnt in conn.execute(
"SELECT infeasible_reason, COUNT(*) c FROM runs WHERE feasible=0 AND infeasible_reason IS NOT NULL "
"GROUP BY infeasible_reason ORDER BY c DESC LIMIT 10"
):
short = reason.split(":", 1)[0] if reason else reason
reasons.append({"reason": short, "count": cnt})
reasons = [
{"reason": reason, "count": cnt}
for reason, cnt in conn.execute(
"SELECT reason, count FROM stats_reasons ORDER BY count DESC LIMIT 10"
)
]
# гистограмма КПД — агрегируем в SQL (не тянем все строки в питон)
buckets = [0] * 10 # 0-10%,...,90-100%
for bucket, cnt in conn.execute(
"SELECT MIN(CAST(efficiency*10 AS INT), 9) AS b, COUNT(*) FROM runs "
"WHERE feasible=1 AND efficiency IS NOT NULL GROUP BY b"
):
if bucket is not None and 0 <= bucket <= 9:
for bucket, cnt in conn.execute("SELECT bucket, count FROM stats_hist"):
if 0 <= bucket <= 9:
buckets[int(bucket)] = cnt
def _summarize(rows):
out = []
for r in rows:
detail = json.loads(r.decoded_summary_json) if r.decoded_summary_json else {}
summary = json.loads(r.decoded_summary_json) if r.decoded_summary_json else {}
stages = summary.get("stages")
out.append({
"run_id": r.run_id, "search_mode": r.search_mode, "efficiency": r.efficiency,
"exit_velocity_mps": r.exit_velocity_mps, "cost_rub": r.cost_rub,
"n_stages": detail.get("n_stages"), "timestamp": r.timestamp,
# None (не 0), когда сводки нет — морда покажет «—»
"n_stages": len(stages) if stages is not None else None,
"timestamp": r.timestamp,
})
return out
@@ -70,27 +98,70 @@ def overview(db_path: Path, log_path: Path | None = None, top_n: int = 15) -> di
"efficiency_histogram": buckets,
"top": top,
"top_by_velocity": top_by_velocity,
"log_tail": _tail_log(log_path, 40) if log_path else [],
"log_tail": (tail := _tail_log(log_path, 40) if log_path else []),
# лучший КПД ТЕКУЩЕГО цикла (из лога): каждый цикл стартует со свежей
# популяции и карабкается заново — это ДРУГОЕ число, чем best за всё
# время, и без подписи их путали («сервер и морда не связаны»)
"cycle_best_pct": _last_cycle_best(tail),
"gpu": _gpu_stat(db_path),
}
def _last_cycle_best(log_lines: list[str]) -> float | None:
import re
for line in reversed(log_lines):
m = re.search(r"лучший_КПД=(-?[0-9.]+)%", line)
if m:
return float(m.group(1))
return None
def run_detail(db_path: Path, run_id: str) -> dict | None:
"""Подробности одного прогона. Шапка (feasible/КПД/скорость/энергобаланс)
— СОХРАНЁННЫЕ при прогоне значения: они посчитаны тем движком и той
версией физики, что записали строку, и только они сравнимы между собой.
Полная физика по ступеням в БД не хранится (см. schema.py) — она
пересимулируется на лету из genome_json через общий web/rebuild.py.
Пересчёт идёт ТЕКУЩЕЙ моделью на CPU и может расходиться с сохранёнными
числами (GPU-строки, старые model_version) — поэтому detail явно помечен
версией пересчёта, а его ошибка не роняет запрос (раньше /api/run был
единственным эндпоинтом без защиты)."""
from gausse.optim.objective import MODEL_VERSION, build_detail
from gausse.web.rebuild import components_db, rebuild_run
conn = open_connection(db_path)
try:
row = fetch_run_by_id(conn, run_id)
if row is None:
return None
return {
"run_id": row.run_id,
"search_mode": row.search_mode,
"feasible": row.feasible,
"infeasible_reason": row.infeasible_reason,
"efficiency": row.efficiency,
"exit_velocity_mps": row.exit_velocity_mps,
"cost_rub": row.cost_rub,
"timestamp": row.timestamp,
"detail": json.loads(row.decoded_summary_json) if row.decoded_summary_json else {},
"energy_breakdown": json.loads(row.energy_breakdown_json) if row.energy_breakdown_json else {},
}
finally:
conn.close()
if row is None:
return None
try:
_, genome, config, result, initial_x_m, initial_v_mps = rebuild_run(db_path, run_id)
detail = build_detail(
config, result, components_db(), initial_x_m, initial_v_mps,
genome.tube_inner_d_m, genome.tube_wall_m,
)
detail["recomputed_with_model_version"] = MODEL_VERSION
except Exception as exc: # деталь — вспомогательная, шапка важнее
detail = {"error": f"пересимуляция не удалась: {exc}"}
return {
"run_id": row.run_id,
"search_mode": row.search_mode,
# версия физики обязана быть видна: прогоны разных моделей
# несравнимы, и «лучший» старой версии — не лучший вообще
"model_version": row.model_version,
"feasible": row.feasible,
"infeasible_reason": row.infeasible_reason,
"efficiency": row.efficiency,
"exit_velocity_mps": row.exit_velocity_mps,
"cost_rub": row.cost_rub,
"timestamp": row.timestamp,
"detail": detail,
# сохранённый при записи баланс — консистентен с feasible/КПД шапки
"energy_breakdown": json.loads(row.energy_breakdown_json) if row.energy_breakdown_json else {},
}

View File

@@ -9,28 +9,30 @@ 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):
def test_gpu_sweep_records_all_runs(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-путь одноступенчатый
# многоступенчатые конфиги допустимы (до max_stages)
stage_counts = set()
for r in rows:
g = genome_from_dict(json.loads(r.genome_json))
assert len(g.stages) == 1
stage_counts.add(len(g.stages))
assert r.search_mode.startswith("gpu-sweep")
assert max(stage_counts) >= 2, "должны встречаться многоступенчатые конфиги"
def test_gpu_sweep_matches_cpu_within_tolerance(tmp_path):
"""Честная сверка: GPU-батч должен давать те же exit_v, что CPU run_coilgun."""
"""Честная сверка: 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)
run_gpu_sweep(db_path, n_runs=2000, seed=5, prefer_gpu=False, batch_size=2000)
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, "нужно несколько реализуемых для сверки"
bounds = SearchBounds()
feasible = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=12)
assert len(feasible) >= 3
checked = 0
for r in feasible:
g = genome_from_dict(json.loads(r.genome_json))
@@ -38,8 +40,8 @@ def test_gpu_sweep_matches_cpu_within_tolerance(tmp_path):
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}"
# фикс.шаг батча + аналитический триггер vs адаптивный solve_ivp: ~3%
assert abs(r.exit_velocity_mps - cpu.exit_v_mps) <= 0.03 * abs(cpu.exit_v_mps) + 0.2, \
f"GPU {r.exit_velocity_mps:.2f} vs CPU {cpu.exit_v_mps:.2f} ({len(g.stages)} ступ.)"
checked += 1
assert checked >= 3

View File

@@ -8,7 +8,14 @@ from gausse.storage.database import count_runs, fetch_runs, open_connection
DB = ComponentDatabase.load()
def test_run_evolution_writes_all_evaluations_and_returns_summary(tmp_path):
def _stats_total(conn) -> int:
row = conn.execute("SELECT value FROM stats_counters WHERE key='total'").fetchone()
return row[0] if row else 0
def test_run_evolution_counts_all_evaluations_but_stores_only_best_rows(tmp_path):
"""Контракт хранения evolve: агрегаты честно считают ВСЕ оценки,
а полные строки — только улучшения лучшего генома острова."""
db_path = tmp_path / "runs.sqlite3"
bounds = SearchBounds(max_stages=2)
@@ -26,9 +33,17 @@ def test_run_evolution_writes_all_evaluations_and_returns_summary(tmp_path):
assert "best_fitness" in summary
conn = open_connection(db_path)
assert count_runs(conn) == 12
assert _stats_total(conn) == 12 # счётчики дашборда видят все оценки
n_rows = count_runs(conn)
# строк меньше, чем оценок: максимум 2 улучшения (КПД+скорость) на
# остров на поколение
assert 1 <= n_rows <= 4
rows = fetch_runs(conn)
assert all(r.search_mode == "evolve" for r in rows)
# лучший найденный обязан лежать в базе полной строкой
best_row_fitness = max((r.efficiency if r.feasible else -1.0) for r in rows)
if summary["best_feasible"]:
assert best_row_fitness == summary["best_efficiency"]
conn.close()
@@ -63,3 +78,40 @@ def test_polish_does_not_make_the_best_genome_worse():
_, polished_result = polish_best(genome, DB, bounds)
assert polished_result.fitness >= baseline_fitness - 1e-9
def test_run_evolution_gpu_batch_mode(tmp_path):
"""GPU-режим эволюции (батч-оценка поколения; здесь numpy-бэкенд):
агрегаты считают все оценки, строки — только улучшения лучшего."""
from gausse.storage.database import count_runs, fetch_runs, open_connection
db_path = tmp_path / "evo_gpu.sqlite3"
summary = run_evolution(
db_path, n_generations=3, population_size=40, seed=5, polish=False, use_gpu=True
)
assert summary["n_evaluated"] == 120
conn = open_connection(db_path)
assert _stats_total(conn) == 120
# 1 остров x 3 поколения, максимум 2 улучшения (КПД+скорость) за поколение
assert 1 <= count_runs(conn) <= 6
rows = fetch_runs(conn, limit=5)
assert all(r.search_mode.startswith("evolve-gpu") for r in rows)
# у сохранённых лучших должна быть посчитана стоимость (GPU-путь
# не считает её для рядовых, но дочитывает для избранных)
assert all(r.cost_rub is not None for r in rows)
def test_run_evolution_islands(tmp_path):
"""Островная модель: K популяций одним батчем + миграция по кольцу."""
from gausse.storage.database import count_runs, open_connection
db_path = tmp_path / "evo_isl.sqlite3"
summary = run_evolution(
db_path, n_generations=4, population_size=15, seed=7, polish=False,
use_gpu=True, islands=3, migrate_every=2,
)
assert summary["n_evaluated"] == 4 * 15 * 3
conn = open_connection(db_path)
assert _stats_total(conn) == 4 * 15 * 3
# максимум 2 улучшения (КПД+скорость) на остров на поколение
assert 3 <= count_runs(conn) <= 4 * 3 * 2

View File

@@ -17,7 +17,9 @@ def test_evaluate_feasible_genome_has_fitness_equal_efficiency():
if result.feasible:
found_feasible = True
assert result.fitness == result.efficiency
assert 0.0 <= result.efficiency <= 1.0
# КПД <= 1 всегда; может быть СЛЕГКА отрицательным: трение о трубку
# за время разряда может съесть больше, чем слабая катушка добавила
assert -0.01 <= result.efficiency <= 1.0
assert result.cost_rub > 0
break
assert found_feasible, "ни один из 50 случайных геномов не оказался реализуемым"

238
tests/test_realism_v2.py Normal file
View File

@@ -0,0 +1,238 @@
"""Физреализм 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) # низкие: сходится
def test_iron_penetration_fraction_limits():
"""Диффузия поля: тонкий снаряд/медленный импульс — всё сечение; толстый
снаряд на быстром импульсе — только скин-кольцо (эксплойт «30%» закрыт)."""
from gausse.physics.ac_resistance import iron_penetration_fraction
# очень низкая частота: поле успевает пропитать всё сечение
assert iron_penetration_fraction(0.005, 1.4e-7, 500.0, 1.0) == 1.0
# сталь ⌀9.3мм на ω~1350 рад/с (медленная катушка из эксплойта):
# намагничивается лишь внешнее кольцо — заметно меньше половины сечения
frac = iron_penetration_fraction(0.00465, 1.4e-7, 500.0, 1350.0)
assert 0.02 < frac < 0.5
# быстрее импульс -> тоньше кольцо (монотонность по ω)
frac_fast = iron_penetration_fraction(0.00465, 1.4e-7, 500.0, 13500.0)
assert frac_fast < frac

View File

@@ -61,13 +61,14 @@ def test_stage_is_feasible_with_realistic_components():
assert result.feasible, result.reason
def test_energy_conserved_exactly_when_lossless():
def test_energy_conserved_exactly_when_electrically_lossless():
lossless_capacitor = CapacitorSpec(
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,
# чтобы «без потерь» действительно означало отсутствие всех каналов диссипации
# снаряд без вихревых потерь: огромное удельное сопротивление -> R_eddy≈0.
# Электрических потерь нет, но МЕХАНИЧЕСКИЕ (трение о трубку + воздух)
# есть всегда — они и составляют весь energy_dissipated_j.
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,
@@ -78,8 +79,9 @@ def test_energy_conserved_exactly_when_lossless():
capacitor=lossless_capacitor, projectile=no_eddy_projectile,
)
assert result.feasible, result.reason
assert result.energy_dissipated_j == pytest.approx(0.0, abs=1e-9)
balance = result.energy_remaining_cap_j + result.kinetic_energy_delta_j
# только трение/воздух: заметно меньше 1% энергии банки за миллисекунды разряда
assert 0.0 <= result.energy_dissipated_j < 0.01 * result.energy_in_j
balance = result.energy_remaining_cap_j + result.energy_dissipated_j + result.kinetic_energy_delta_j
assert balance == pytest.approx(result.energy_in_j, rel=1e-4)

View File

@@ -99,6 +99,36 @@ def test_writer_survives_duplicate_run_id_and_keeps_draining_queue(tmp_path):
conn.close()
def test_writer_stats_only_bumps_counters_without_rows(tmp_path):
"""StatsOnly-сообщения (эволюция шлёт их для рядовых геномов): агрегаты
дашборда растут, а строк в runs не появляется."""
from gausse.storage.database import StatsOnly
db_path = tmp_path / "runs.sqlite3"
ctx = multiprocessing.get_context("spawn")
queue = ctx.Queue()
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
writer.start()
queue.put(_record("best-one")) # одна полная строка
queue.put(StatsOnly("evolve", True, 0.42, None))
queue.put(StatsOnly("evolve", False, None, "датчик: сигнал ниже порога"))
queue.put(StatsOnly("evolve", False, None, None))
queue.put(None)
writer.join(timeout=10)
assert not writer.is_alive()
conn = open_connection(db_path)
assert count_runs(conn) == 1 # строка только у полной записи
counters = dict(conn.execute("SELECT key, value FROM stats_counters"))
assert counters["total"] == 4 # но посчитаны все четыре
assert counters["feasible"] == 2
reasons = dict(conn.execute("SELECT reason, count FROM stats_reasons"))
assert reasons.get("датчик") == 1
conn.close()
def _writer_worker(queue, worker_id, n):
for i in range(n):
queue.put(_record(f"proc-{worker_id}-run-{i}"))

View File

@@ -39,7 +39,9 @@ def test_run_detail_has_full_physics_and_distances(tmp_path):
assert "inter_stage_gaps_m" in detail
assert "coil_center_positions_m" in detail
stage0 = detail["stages"][0]
assert "electrical" in stage0 and "wire_resistance_ohm" in stage0["electrical"]
assert "electrical" in stage0 and "wire_resistance_ac_ohm" in stage0["electrical"]
# v2: скин/близость — AC-сопротивление обмотки выше DC
assert stage0["electrical"]["wire_ac_resistance_factor"] >= 1.0
assert "winding" in stage0 and "total_turns" in stage0["winding"]
assert "sensor_to_coil_distance_m" in stage0