Compare commits

..

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 15:03:45 +05:00
jze9
4f94811b49 Stage 9 core: GPU-ready batched discharge integrator, validated vs scipy
The expensive part of the sim (the 4-state [Q,I,x,v] discharge ODE) now
has a vectorized fixed-step RK4 integrator that runs N configs at once as
arrays, with the same saturating-iron physics as the scipy path. One code
path runs on numpy (CPU) or cupy (GPU) via a backend shim
(gpu/backend.py) -- so it's developed and validated WITHOUT a GPU, then
runs on GPU unchanged.

Honesty gate (per the project's no-smoke-and-mirrors rule): validated
against the scipy solve_ivp reference on 4 configs -- exit velocity, peak
current, and dissipated energy all match within ~1-3%, plus a batched
energy-conservation check. GPU speed is worthless if the numbers differ,
so this test is the point.

Benchmark: 5000 configs in 6.8s = 733/s single-threaded numpy, already
faster than the 5-core scipy sweep (~430/s); cupy parallelizes the same
arithmetic for the real win at large N.

Not yet batched: flight-to-trigger, multi-stage chaining, genome decode
(so this isn't a drop-in sweep replacement yet -- it's the validated
core). Next: batch the full pipeline + run cupy on the server GPU.

89 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 02:18:34 +05:00
jze9
85425e27bc Speed up dashboard and add exit-velocity ranking
- Web was slow because run_detail and the plot/GIF renderers loaded the
  ENTIRE runs table to find one row by id (O(n) on a 200k-row DB, seconds
  per request). Now query by PRIMARY KEY (run_id): run_detail drops from
  seconds to ~1ms. The KPD histogram is aggregated in SQL instead of
  streaming every feasible row into Python.
- Dashboard now toggles the top table between "по КПД" and "по скорости"
  (top_by_velocity, indexed on exit_velocity_mps); the best-of card shows
  both best efficiency and best exit velocity.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 02:04:58 +05:00
jze9
b0a35e08d5 Set realistic pulse-current ratings for electrolytic capacitors
Grounded in the user's hands-on experience: 8 electrolytics soldered in
parallel drove a nail hard enough to dent a metal PSU wall (~few joules,
~30-60 m/s -- matching the model's output range). So cheap electrolytics
in parallel packs really do deliver high pulse current; the DB's old
values were ripple (continuous) ratings, not single-shot pulse capability.
Updated max_current_a to pulse estimates that scale with size/ESR (bigger,
lower-ESR cans sustain more), clearly labeled ESTIMATE for coilgun pulse
use in each source field. A parallel bank sums these (n_parallel), so an
8-pack models the user's real setup.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 01:53:26 +05:00
jze9
7f040c3cf5 Fix two physics bugs the user caught: unbounded field and ignored current limits
1. Iron saturation now in the dynamics (was the "13 Tesla" bug). Flux
   linkage lambda(x,I) = L_air*I + L_iron*overlap(x)*g(I) with
   g(I)=I_sat*tanh(I/I_sat) saturating at the current where iron reaches
   B_sat. Both the circuit back-EMF (dlambda/dx) and the force (coenergy,
   dW'/dx) derive from the SAME lambda, so energy stays conserved (0.025%
   error) AND the field caps at B_sat instead of running to 13 T. Reduces
   to the old 0.5*I^2*dL/dx in the low-current limit. On the config that
   reported 60% efficiency / 11.6 T, it now gives 40% / 1.90 T.

2. Switch surge-current limit is now a hard feasibility constraint: a
   config whose peak discharge current exceeds the switch's pulse rating
   (continuous * surge factor per device kind) is infeasible -- otherwise
   the optimizer "wins" with configs that vaporize their own thyristor
   (e.g. 119 A through a 12 A BT151). Capacitor current stays a warning
   (electrolytics tolerate pulses; DB has continuous not pulse ratings).

Regression tests added for both (field cap near saturation, energy
conservation under strong discharge). 87 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 01:45:26 +05:00
jze9
b3151e94a5 Document capacitor banks, dashboard graphics, and server deployment status
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 01:02:21 +05:00
jze9
1ca807dcbe Embed per-run plots and flight animation in the web dashboard
Clicking a config in the dashboard now shows its velocity/field/current
plots (rendered on the fly by /api/run/<id>/plot/<kind>.png) plus an
on-demand GIF of the slug flying through the coils
(/api/run/<id>/anim.gif). Matplotlib rendering is serialized behind a
lock since the Agg pyplot state machine isn't thread-safe and the server
is threaded. Verified end to end: endpoints return real PNG/GIF bytes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:57:02 +05:00
jze9
950be5fcf2 Model capacitor banks (series for voltage, parallel for current/capacitance)
Per user: each stage's energy source is now a bank of real capacitors,
n_series x n_parallel, not a single cap.

- components/schema.py CapacitorBank.from_spec: series multiplies voltage
  and ESR and divides capacitance; parallel multiplies capacitance and
  max current and divides ESR; price and count scale with total cap count.
  Duck-typed as a drop-in for CapacitorSpec in StageConfig.
- search_space: cap_series (1-6) and cap_parallel (1-8) per stage gene,
  sampled/mutated/repaired; decode builds the bank; charge voltage capped
  by min(bank voltage, switch rating)
- objective.build_detail records the full bank composition (base part,
  series/parallel, count, totals) and current-capability diagnostics:
  bank_current_over_limit / switch_current_over_limit vs peak discharge
  current (a warning, not a hard reject -- DB holds datasheet not pulse
  ratings; parallel caps already help for real via lower ESR)
- BOM shows "N шт (Sпосл × Pпар)" with per-unit and total price

Already supported and confirmed present (also user-requested): per-coil
independent wire gauge (wire_idx per stage) and computed wire length
(winding_geometry.total_wire_length_m, drives resistance/cost/DB detail).

83 tests pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:53:33 +05:00
jze9
70b732d170 Enrich results DB, add process log, and build a live web dashboard
Requested during deployment: make the DB maximally detailed, add an
experiment process log, and a web UI to watch runs live.

- optim/objective.py build_detail(): each stored run now records
  inter-coil distances, absolute coil positions along the tube, every
  component part number/spec, winding geometry, computed physics
  (resistance, air inductance, peak current, peak field), and per-stage
  outcome (entry/exit velocity, sensor timing, energy breakdown)
- optim/progress_log.py: append-only <db>.log with progress %, feasible
  rate, throughput, ETA, and a line on each new best efficiency; wired
  into both sweep and evolve
- src/gausse/web/: stdlib-only (http.server) dashboard `gausse serve` --
  self-contained HTML polling /api/overview every 3s: counters, KPD
  histogram, top-configs table with per-run drill-down, failure reasons,
  live log tail. No new dependencies.
- docker-compose.yml: `web` service on port 8000
- 77 tests pass locally and in Docker

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-07 00:27:50 +05:00
jze9
d487021c2f Document Stage 8 end-to-end verification findings in PLAN.md
Real 5000-run sweep + 600-genome evolution through docker compose run,
not a synthetic smoke test. Two concrete outcomes worth recording:

1. Quantitative confirmation of the sensor discussion from the original
   planning conversation: inductive pickup sensor reached only 0.2%
   feasibility (6/2965 configs using it), vs ~45-47% for optical/Hall,
   because its trigger threshold sits above the fixed launch velocity.
2. Best honest result after the efficiency-metric fix: single stage,
   80.97% efficiency, 26.4 m/s, ~1029 RUB in real parts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 21:06:42 +05:00
jze9
1029f40318 Fix efficiency metric that could exceed 100% (real bug found by Stage 8 sweep)
A real 5000-run sweep through Docker found the evolutionary optimizer's
best genome reporting efficiency=387%. Root cause: efficiency was
computed as exit_kinetic_energy / capacitor_energy_in, but exit kinetic
energy includes the fixed initial_v_mps launch push (a modeling
assumption, not something paid for by the capacitors) -- for a light
enough projectile, that free energy dominates and the ratio blows past 1.

Fixed by using kinetic_energy_delta (the energy the coils actually added)
instead of absolute exit KE. The per-stage energy identity (energy_in =
remaining_cap + dissipated + kinetic_delta) guarantees kinetic_delta <=
energy_in, so this metric can never exceed 1.0 -- provably, not by luck.
Added a regression test with a light projectile + large initial velocity
reproducing the exact failure mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 21:03:30 +05:00
jze9
57a83cd4f8 Fix float64 overflow in inductive sensor's sech^2 bump function
cosh(z)**2 overflows past |z|~355, which happens routinely once solve_ivp
evaluates the sensor event far from x_sensor over a wide time budget --
correct result (bump->0) but with a RuntimeWarning on every call, which
would flood stderr across a million-run sweep. Clamp the argument to
±20 (already ~1e-17, physically indistinguishable from further growth)
before computing cosh. Caught by running a real 500-run sweep through
Docker as part of Stage 8 verification, not by unit tests alone -- added
a regression test asserting no warning and a finite result far from the
sensor.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 20:55:59 +05:00
jze9
65d8b633ad Wire up reporting (plots, BOM, summary, animation) and a working CLI (Stage 7)
- report/plots.py: current/field/velocity plots per stage, Agg backend so
  it works headless in Docker/on a server
- report/bom.py: itemized bill of materials with real component prices
- report/summary.py: honest text/JSON summary including a
  "model limitations" section and a saturation warning flag per stage
- report/animate.py: the visualization the user explicitly asked for --
  a GIF of the slug flying through the tube with each coil glowing by its
  instantaneous current, reconstructing the pre-trigger ballistic flight
  segment (not just the stored discharge phase) for a continuous timeline
- cli.py: sweep/evolve/simulate/report subcommands now actually call the
  underlying modules instead of being stubs
- Extended StageResult/StageOutcome with the coil/entry-state fields the
  report layer needed (mu_eff, turns, coil_length, entry_x/v) rather than
  recomputing them by other means

Verified end-to-end through `docker compose run`, not just pytest: a real
sweep (30 runs, 11 feasible) followed by `report --animate` produced a
correct GIF/plots/BOM/summary for the actual best result found (32.8%
efficiency, 982 RUB) -- not a synthetic fixture.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 20:50:32 +05:00
jze9
d73341d3a2 Add (mu+lambda) evolutionary optimizer with Nelder-Mead polish (Stage 6 complete)
- optim/worker_context.py: shared per-process DB/bounds init, extracted
  from sweep.py so evolutionary.py doesn't reach into another module's
  private state
- optim/objective.py: build_run_record() shared between sweep and
  evolutionary so both write identically-shaped rows
- optim/evolutionary.py: tournament selection, whole-stage-swap crossover,
  elitism, structural mutation (stage count itself evolves), then a serial
  Nelder-Mead polish of the best genome's continuous parameters with
  discrete component choices frozen
- Found and fixed a real crash: crossover() indexed into an empty
  inter_stage_gaps_m list when both parents had only 1 stage (0 gaps),
  raising IndexError. Fixed the fallback to only choose from gaps that
  actually exist, defaulting to a neutral value (clipped later by
  repair()) when neither parent has one. Added a regression test.
- Verified 59/59 tests pass both locally and inside the Docker image

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 20:36:10 +05:00
jze9
891ac8fee0 Add parallel Monte Carlo sweep engine writing every run to SQLite
optim/sweep.py: ProcessPoolExecutor workers sample+evaluate genomes in
parallel; each result (feasible or not) is pushed through the
queue-backed single writer built in Stage 5. Verified with real
multiprocessing (not mocks): 12-run sweep across 2 worker processes
lands all 12 rows in SQLite with parseable genome/summary JSON, and two
independent sweeps with the same seed produce byte-identical results.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 20:27:43 +05:00
jze9
56c7ab9c1d Add variable-length genome search space and objective function (Stage 6 pt.1)
- optim/search_space.py: Genome encodes only indices into the real
  component database plus continuous/discrete geometry parameters; number
  of stages is itself mutable via add/remove/duplicate-stage operators.
  repair() clips everything back into bounds after mutation/crossover so
  decode() never sees an invalid genome.
- optim/objective.py: evaluate() decodes a genome, computes real BOM cost
  from wire length/price and component prices, runs the full chain, and
  scores fitness = efficiency for feasible runs or a soft penalty scaled
  by how many stages it got through for infeasible ones (so the GA gets
  gradient instead of a wall).

Tested against the real component database (not synthetic fixtures) —
including an explicit infeasible case using the real inductive sensor's
actual sensitivity/threshold values.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 20:26:16 +05:00
jze9
069ea848e2 Add SQLite results storage with a resilient multiprocess writer (Stage 5)
- storage/schema.py: `runs` table recording every evaluated configuration,
  feasible or not, with an honest infeasible_reason instead of dropping
  failures
- storage/database.py: WAL-mode SQLite, single-writer-over-a-queue pattern
  for safe concurrent writes from many sweep worker processes
- Testing with real multiprocessing.Process (not mocks) caught a genuine
  bug: a duplicate run_id raised inside the writer loop and killed the
  writer process, silently halting queue drainage for the rest of a
  sweep. Fixed by catching the insert error per-record and logging to
  stderr instead of crashing; added a regression test for it.
- Verified locally and inside the Docker image (43/43 tests both ways)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 20:21:21 +05:00
jze9
acf846ab29 Add Docker as the primary way to build and run gausse
Dockerfile (non-root user, UID 1000 to match typical host users so bind
mounts don't end up root-owned) + docker-compose.yml with a ./results
volume for the SQLite database and reports that Stage 5/6 will write.
Verified by actually building the image and running the full test suite
and CLI inside the container, not just writing the files and assuming
they work.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-06 20:17:12 +05:00
65 changed files with 6026 additions and 147 deletions

14
.dockerignore Normal file
View File

@@ -0,0 +1,14 @@
.git
.venv
venv
__pycache__
*.pyc
.pytest_cache
*.egg-info
build
dist
out
.claude
*.sqlite3
*.sqlite3-wal
*.sqlite3-shm

2
.gitignore vendored
View File

@@ -10,5 +10,7 @@ dist/
*.sqlite3-wal
*.sqlite3-shm
out/
results/*
!results/.gitkeep
.DS_Store
.claude/settings.local.json

18
Dockerfile Normal file
View File

@@ -0,0 +1,18 @@
FROM python:3.12-slim
WORKDIR /app
COPY pyproject.toml README.md ./
COPY src ./src
COPY tests ./tests
RUN pip install --no-cache-dir -e ".[dev]"
# Не root: иначе всё, что контейнер пишет в примонтированный ./results
# (SQLite-база, отчёты), становится на хосте файлами root:root.
RUN useradd --create-home --uid 1000 gausse
RUN mkdir -p /app/results && chown -R gausse:gausse /app
USER gausse
ENTRYPOINT ["gausse"]
CMD ["--help"]

157
PLAN.md
View File

@@ -34,6 +34,12 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
Полный план архитектуры: см. историю обсуждения / `physics`, `sim`, `optim`,
`storage`, `report` модули ниже.
**Docker — основной способ запуска** (по требованию пользователя): `Dockerfile`
+ `docker-compose.yml` в корне репозитория, образ собран и провалидирован —
внутри контейнера запускаются все 37 тестов и CLI (`docker compose run --rm
--entrypoint pytest gausse -q`). Результаты (SQLite и отчёты, когда появятся)
монтируются в `./results` на хосте, чтобы переживать пересборку образа.
## Чек-лист этапов
- [x] **Этап 0 — Скелет проекта**: `pyproject.toml`, `README.md`, `.gitignore`, пакеты `src/gausse/*`, этот файл, первый коммит.
@@ -41,9 +47,148 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
- [x] **Этап 2 — Физическое ядро**: `physics/constants.py`, `inductance.py` (Уилер + ферромагнитный сердечник + размагничивание), `force.py`, `circuit.py` (ОДУ RLC). Юнит-тесты: аналитическое RLC-решение, согласованность dL/dx.
- [x] **Этап 3 — Датчики и одна ступень**: `physics/sensors.py` (оба типа как события solve_ivp), `sim/stage.py` (полёт → триггер → разряд → энергобаланс). Тест на сохранение энергии + найден/исправлен баг насыщения (см. выше).
- [x] **Этап 4 — Многоступенчатая цепочка**: `sim/coilgun.py` — сквозная координата, отбраковка нереализуемых конфигураций. Дымовой тест на реальной базе компонентов (`test_real_components_smoke.py`) подтверждает: полный путь реальные JSON → физика → цепочка работает (пример: 22.3 м/с, КПД 3.4% — честный неоптимизированный результат).
- [ ] **Этап 5 — Хранилище результатов**: `storage/schema.py` + `database.py` — SQLite (WAL), таблица `runs` со всеми прогонами (успех/провал + честная причина), однопроцессный писатель поверх многопроцессной очереди.
- [ ] **Этап 6 — Поиск и оптимизация**: `optim/search_space.py` (геном переменной длины), `objective.py` (КПД), дешёвый квазистатический предфильтр, `optim/sweep.py` (Monte Carlo/LHS, миллионы прогонов), `optim/evolutionary.py` (ГА + coordinate-descent полировка) — всё пишется в общую таблицу `runs`.
- [ ] **Этап 7 — Отчётность**: `report/plots.py`, `summary.py`, `bom.py`, раздел "ограничения модели", `cli.py` (`gausse sweep/evolve/simulate/report`).
- [ ] `report/animate.py` — анимация одного прогона: положение снаряда в трубе по времени + визуализация поля/тока каждой катушки (свечение/интенсивность цвета ~ ток), сохранение в GIF/MP4 (matplotlib `FuncAnimation`). Нужна по запросу пользователя — "графика где будет показана симуляция пролёта цилиндра по трубе и электромагнитные поля в каждый момент времени".
- [ ] **Этап 8 — Сквозная проверка**: резюмируемый прогон на реальной базе компонентов, проверка честной записи в SQLite, финальный отчёт (КПД, скорость, стоимость, сравнение датчиков) с разделом ограничений.
- [ ] **Этап 9 — GPU-ускорение массового sweep (сервер с GTX 1070)**: после того как CPU/`scipy.solve_ivp`-модель провалидирована тестами (Этап 2-4) — батч-версия интегратора с ФИКСИРОВАННЫМ шагом (RK4/полу-неявная схема), считающая сразу N траекторий параллельно как один тензор (`cupy`, если доступна CUDA, иначе векторизованный `numpy`/`numba`), для прогона по-настоящему миллионов конфигураций на сервере. Важно: сначала корректность на CPU, потом скорость на GPU — численные результаты GPU-пути должны сверяться с CPU-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки".
- [x] **Этап 5 — Хранилище результатов**: `storage/schema.py` + `database.py` — SQLite (WAL), таблица `runs` со всеми прогонами (успех/провал + честная причина), однопроцессный писатель поверх многопроцессной очереди. Тест с реальными `multiprocessing.Process` (не моками) поймал реальную проблему: коллизия `run_id` роняла writer и молча останавливала осушение очереди на весь sweep — писатель теперь переживает ошибку вставки одной записи (лог в stderr) и продолжает работу.
- [x] **Этап 6 — Поиск и оптимизация**: `optim/search_space.py` (геном переменной длины — число ступеней тоже эволюционирует), `objective.py` (fitness=КПД, честный мягкий штраф за нереализуемость пропорционально пройденным ступеням), `optim/sweep.py` (параллельный Monte Carlo, каждый прогон в SQLite), `optim/evolutionary.py` ((μ+λ)-ГА + Nelder-Mead полировка непрерывных параметров лучшего генома). Тест поймал реальный баг в `crossover()` — IndexError при скрещивании двух одноступенчатых геномов (пустой список зазоров), исправлено и покрыто регрессией.
- [ ] **Не сделано**: отдельный "дешёвый квазистатический предфильтр" перед полным 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, оптический датчик). *(Историческая запись: это физика 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`. *(УСТАРЕЛО с 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.
## Батареи конденсаторов + графики в веб-морде (по запросу пользователя)
- **Батареи конденсаторов** (`components/schema.py::CapacitorBank`): каждая ступень собирает банку из N одинаковых конденсаторов — `n_series` (1-6, складывает напряжение и ESR, делит ёмкость) × `n_parallel` (1-8, складывает ёмкость и макс.ток, делит ESR). Цена и количество масштабируются. Параметры банки — в геноме (`cap_series`, `cap_parallel` на ступень), в BOM ("N шт (Sпосл × Pпар)") и в подробной БД (`capacitor_bank`: состав, суммарные C/V/ESR/макс.ток). Диагностика по току: `bank_current_over_limit` / `switch_current_over_limit` (предупреждение, не жёсткий отказ — в базе паспортный, а не импульсный ток; параллельные конденсаторы дают реальный выигрыш через сниженный ESR).
- **Уже было (тоже запрашивалось)**: разная толщина провода на разных катушках (`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.47**)
**СТАТУС: развёрнуто, работает АВТОНОМНО (CPU-эволюция под systemd), 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`.
- [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/с стабильно.
## Физика: найденные завышения КПД и их исправления (хронология честности)
Пользователь ловил нереальные цифры; каждое исправление РОНЯЛО КПД к
реальности. Версия модели пишется в каждую запись БД (`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,20 +1,89 @@
# gausse
Симулятор и оптимизатор многоступенчатого электромагнитного ускорителя
(coilgun) на реальных, доступных в рознице компонентах.
(coilgun / пушка Гаусса) на реальных, доступных в рознице компонентах
(ChipDip, procontact74.ru, cable.ru — у каждой детали в базе ссылка на
магазин).
Полный план и чек-лист этапов — в [PLAN.md](PLAN.md).
Главный принцип — **честность**: все прогоны (включая нереализуемые, с
причиной отказа) пишутся в SQLite; физическая модель не приукрашивается ради
красивых цифр, а каждое известное упрощение задокументировано. История
проекта — это в том числе история найденных и исправленных завышений КПД
(80%+ → реалистичные единицы процентов), см. [PLAN.md](PLAN.md).
## Установка
## Что умеет
- **Физика** (`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 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
```
GPU-режим (нужны NVIDIA-драйвер и `pip install cupy-cuda12x[ctk]`, вне Docker):
```bash
gausse sweep --gpu --n 1000000 --db results/gausse.sqlite3
```
Результаты (SQLite + лог хода эксперимента `<db>.log`) живут в `./results`,
примонтированном в контейнер, и переживают пересборку образа.
## Установка без Docker (локальная разработка)
```bash
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
## Тесты
```bash
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

21
docker-compose.yml Normal file
View File

@@ -0,0 +1,21 @@
services:
# Веб-морда: дашборд хода эксперимента. Порт 8000 наружу.
# docker compose up -d web
# Открыть http://<адрес-сервера>:8000
web:
build: .
volumes:
- ./results:/app/results
ports:
- "8000:8000"
command: ["serve", "--db", "/app/results/gausse.sqlite3", "--host", "0.0.0.0", "--port", "8000"]
restart: unless-stopped
# Разовые команды (sweep/evolve/report). Пример:
# docker compose run --rm gausse sweep --db /app/results/gausse.sqlite3 --n 100000 --seed 1
gausse:
build: .
volumes:
- ./results:/app/results
entrypoint: ["gausse"]
command: ["--help"]

0
results/.gitkeep Normal file
View File

View File

@@ -1,17 +1,159 @@
import argparse
import json
from pathlib import Path
from gausse.components.database import ComponentDatabase
from gausse.optim.evolutionary import run_evolution
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
from gausse.optim.sweep import run_sweep
from gausse.report.animate import animate_run
from gausse.report.bom import build_bom, render_bom_text
from gausse.report.plots import save_all
from gausse.report.summary import build_summary, render_summary_json, render_summary_text
from gausse.sim.coilgun import run_coilgun
from gausse.storage.database import fetch_runs, open_connection
from gausse.web.server import serve as web_serve
def _bounds_from_args(args) -> SearchBounds:
kwargs = {}
if getattr(args, "max_stages", None):
kwargs["max_stages"] = args.max_stages
return SearchBounds(**kwargs)
def cmd_sweep(args) -> int:
bounds = _bounds_from_args(args)
if args.gpu:
from gausse.gpu.batch_sweep import run_gpu_sweep
summary = run_gpu_sweep(Path(args.db), n_runs=args.n, bounds=bounds, seed=args.seed)
print(f"GPU-путь backend={summary['backend']} (одноступ.)")
else:
summary = run_sweep(
Path(args.db), n_runs=args.n, bounds=bounds, n_workers=args.workers, seed=args.seed
)
rate = 100 * summary["n_feasible"] / summary["n_runs"] if summary["n_runs"] else 0.0
print(f"Прогнано {summary['n_runs']}, реализуемо {summary['n_feasible']} ({rate:.1f}%)")
return 0
def cmd_evolve(args) -> int:
bounds = _bounds_from_args(args)
summary = run_evolution(
Path(args.db),
n_generations=args.generations,
population_size=args.population,
bounds=bounds,
n_workers=args.workers,
seed=args.seed,
polish=not args.no_polish,
)
print(f"Оценено геномов: {summary['n_evaluated']}")
print(
f"Лучший найденный: feasible={summary['best_feasible']} "
f"efficiency={summary['best_efficiency']} cost={summary['best_cost_rub']}"
)
return 0
def _write_full_report(run_id: str, genome_json: str, out_dir: Path, animate: bool, db, bounds) -> None:
genome = genome_from_dict(json.loads(genome_json))
config, _initial_x, _initial_v = decode(genome, db, bounds)
result = run_coilgun(config)
out_dir.mkdir(parents=True, exist_ok=True)
save_all(result, out_dir)
summary = build_summary(result, config, db)
(out_dir / "summary.txt").write_text(render_summary_text(summary), encoding="utf-8")
(out_dir / "summary.json").write_text(render_summary_json(summary), encoding="utf-8")
(out_dir / "bom.txt").write_text(render_bom_text(build_bom(config, db)), encoding="utf-8")
if animate:
animate_run(result, config, str(out_dir / "run.gif"))
print(f"run_id={run_id}: отчёт записан в {out_dir}")
def cmd_serve(args) -> int:
web_serve(Path(args.db), host=args.host, port=args.port)
return 0
def cmd_report(args) -> int:
conn = open_connection(Path(args.db))
rows = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=args.top)
if not rows:
print(f"В {args.db} нет ни одного реализуемого прогона.")
return 1
db = ComponentDatabase.load()
bounds = SearchBounds()
out_dir = Path(args.out)
for rank, row in enumerate(rows, start=1):
run_out = out_dir / f"rank{rank}_{row.run_id[:8]}"
print(f"#{rank}: efficiency={row.efficiency:.4f} cost={row.cost_rub:.0f}")
_write_full_report(row.run_id, row.genome_json, run_out, args.animate, db, bounds)
return 0
def cmd_simulate(args) -> int:
conn = open_connection(Path(args.db))
rows = fetch_runs(conn)
row = next((r for r in rows if r.run_id == args.run_id), None)
if row is None:
print(f"run_id {args.run_id} не найден в {args.db}")
return 1
db = ComponentDatabase.load()
bounds = SearchBounds()
_write_full_report(row.run_id, row.genome_json, Path(args.out), args.animate, db, bounds)
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="gausse")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("sweep", help="массовый Monte Carlo/LHS перебор конфигураций")
subparsers.add_parser("evolve", help="эволюционный поиск поверх базы прогонов")
subparsers.add_parser("simulate", help="прогнать одну конфигурацию")
subparsers.add_parser("report", help="построить отчёт по сохранённым результатам")
sweep_p = subparsers.add_parser("sweep", help="массовый Monte Carlo перебор конфигураций")
sweep_p.add_argument("--db", required=True, help="путь к SQLite-файлу результатов")
sweep_p.add_argument("--n", type=int, required=True, help="число прогонов")
sweep_p.add_argument("--workers", type=int, default=None)
sweep_p.add_argument("--seed", type=int, default=None)
sweep_p.add_argument("--max-stages", type=int, default=None)
sweep_p.add_argument("--gpu", action="store_true", help="GPU/cupy батч-путь (одноступ., быстрый)")
sweep_p.set_defaults(func=cmd_sweep)
evolve_p = subparsers.add_parser("evolve", help="эволюционный поиск поверх базы прогонов")
evolve_p.add_argument("--db", required=True)
evolve_p.add_argument("--generations", type=int, default=20)
evolve_p.add_argument("--population", type=int, default=30)
evolve_p.add_argument("--workers", type=int, default=None)
evolve_p.add_argument("--seed", type=int, default=None)
evolve_p.add_argument("--max-stages", type=int, default=None)
evolve_p.add_argument("--no-polish", action="store_true")
evolve_p.set_defaults(func=cmd_evolve)
simulate_p = subparsers.add_parser("simulate", help="пересчитать и построить отчёт по одному run_id")
simulate_p.add_argument("--db", required=True)
simulate_p.add_argument("--run-id", required=True)
simulate_p.add_argument("--out", required=True, help="папка для отчёта")
simulate_p.add_argument("--animate", action="store_true")
simulate_p.set_defaults(func=cmd_simulate)
report_p = subparsers.add_parser("report", help="отчёт по лучшим сохранённым результатам")
report_p.add_argument("--db", required=True)
report_p.add_argument("--out", required=True)
report_p.add_argument("--top", type=int, default=1)
report_p.add_argument("--animate", action="store_true")
report_p.set_defaults(func=cmd_report)
serve_p = subparsers.add_parser("serve", help="веб-морда: дашборд хода эксперимента")
serve_p.add_argument("--db", required=True, help="путь к SQLite-файлу результатов")
serve_p.add_argument("--host", default="0.0.0.0")
serve_p.add_argument("--port", type=int, default=8000)
serve_p.set_defaults(func=cmd_serve)
args = parser.parse_args(argv)
parser.error(f"команда '{args.command}' ещё не реализована")
return 1
return args.func(args)
if __name__ == "__main__":

View File

@@ -4,53 +4,59 @@
"capacitance_uf": 22.0,
"voltage_v": 400.0,
"esr_ohm": 0.45,
"max_current_a": 5.0,
"max_current_a": 40.0,
"price": 140.0,
"source": "REAL цена: chipdip.ru, категория 'kondensator-400v' (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR не указан продавцом, ОЦЕНКА 0.3-0.6 Ом (типично для класса, взята середина)"
"source": "REAL цена: chipdip.ru, категория 'kondensator-400v' (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR ОЦЕНКА 0.3-0.6 Ом. max_current_a — ОЦЕНКА ИМПУЛЬСНОГО тока для одиночного coilgun-выстрела (не ripple из даташита), ограничено ESR/фольгой; в пачке параллельно (как у пользователя, 8шт) суммируется",
"url": "https://www.chipdip.ru/catalog/popular/kondensator-400v"
},
{
"part_number": "cap-68uf-400v",
"capacitance_uf": 68.0,
"voltage_v": 400.0,
"esr_ohm": 0.3,
"max_current_a": 8.0,
"max_current_a": 60.0,
"price": 440.0,
"source": "REAL цена: chipdip.ru, категория 'kondensator-400v' (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR не указан продавцом, ОЦЕНКА (типично для класса)"
"source": "REAL цена: chipdip.ru (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR ОЦЕНКА. max_current_a — ОЦЕНКА импульсного тока (одиночный выстрел), не ripple",
"url": "https://www.chipdip.ru/catalog/popular/kondensator-400v"
},
{
"part_number": "cap-180uf-400v",
"capacitance_uf": 180.0,
"voltage_v": 400.0,
"esr_ohm": 0.15,
"max_current_a": 15.0,
"max_current_a": 120.0,
"price": 109.9,
"source": "REAL цена: procontact74.ru, CHANG 180uF 400V 105C (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/); на странице встречались две цены (109.9₽ и 870₽ у разных продавцов) - взята бюджетная; ESR ОЦЕНКА"
"source": "REAL цена: procontact74.ru, CHANG 180uF 400V 105C (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/); ESR ОЦЕНКА. max_current_a — ОЦЕНКА импульсного тока, не ripple",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/"
},
{
"part_number": "cap-470uf-400v",
"capacitance_uf": 470.0,
"voltage_v": 400.0,
"esr_ohm": 0.08,
"max_current_a": 25.0,
"max_current_a": 250.0,
"price": 498.0,
"source": "REAL цена: procontact74.ru, JCCON 470uF 400V 105C 30x45мм (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/), полная страница проверена, 325.84₽ от 5шт, 279.29₽ от 25шт; ESR не указан продавцом, ОЦЕНКА (для банок этого размера обычно ниже, ~0.05-0.1 Ом)"
"source": "REAL цена: procontact74.ru, JCCON 470uF 400V 105C 30x45мм (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/), проверено, 325.84₽ от 5шт; ESR ОЦЕНКА ~0.05-0.1 Ом. max_current_a — ОЦЕНКА импульсного тока (низкий ESR -> держит сотни А в импульсе), не ripple",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/"
},
{
"part_number": "cap-1000uf-400v",
"capacitance_uf": 1000.0,
"voltage_v": 400.0,
"esr_ohm": 0.06,
"max_current_a": 40.0,
"max_current_a": 350.0,
"price": 700.0,
"source": "ОЦЕНКА (низкая уверенность): товарная категория подтверждена реально на procontact74.ru/asenergi.ru, но точная цена конкретной позиции не подтверждена при парсинге; цена экстраполирована из наблюдаемого тренда ₽/мкФ для банок этого класса"
"source": "ОЦЕНКА (низкая уверенность по цене): категория подтверждена на procontact74.ru/asenergi.ru, точная цена не подтверждена. max_current_a — ОЦЕНКА импульсного тока для coilgun, не ripple",
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/"
},
{
"part_number": "cap-2200uf-400v",
"capacitance_uf": 2200.0,
"voltage_v": 400.0,
"esr_ohm": 0.05,
"max_current_a": 60.0,
"max_current_a": 450.0,
"price": 1400.0,
"source": "ОЦЕНКА (низкая уверенность): категория подтверждена на asenergi.ru (https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html), точная цена не подтверждена; цена экстраполирована из тренда ₽/мкФ, брать с осторожностью"
"source": "ОЦЕНКА (низкая уверенность по цене): категория подтверждена на asenergi.ru (https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html). max_current_a — ОЦЕНКА импульсного тока для coilgun, не ripple",
"url": "https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html"
}
]

View File

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

View File

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

View File

@@ -8,7 +8,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мкс (типично для тиристора этого класса, точное значение в листинге не приведено)"
"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",
@@ -19,7 +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 и время включения ОЦЕНКА (типично для тиристора этого класса, не указаны продавцом)"
"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",
@@ -30,7 +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)"
"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",
@@ -41,7 +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В с даташита; время включения ОЦЕНКА"
"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",
@@ -52,7 +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В с даташита; время включения ОЦЕНКА"
"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",
@@ -63,6 +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, не подтверждено со страницы товара)"
"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

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

View File

@@ -14,6 +14,7 @@ class WireSpec:
max_current_a: float
price_per_m: float
source: str
url: str | None = None # прямая ссылка на магазин, где купить
@dataclass(frozen=True)
@@ -25,6 +26,49 @@ class CapacitorSpec:
max_current_a: float
price: float
source: str
url: str | None = None
@dataclass(frozen=True)
class CapacitorBank:
"""Батарея из одинаковых конденсаторов: n_series (напряжение) × n_parallel (ток/ёмкость).
Последовательное соединение складывает напряжение и ESR, делит ёмкость;
параллельное — складывает ёмкость, максимальный ток и делит ESR. Итоговая
батарея выставляет те же поля, что и одиночный конденсатор
(`capacitance_uf`, `voltage_v`, `esr_ohm`, `max_current_a`, `price`),
поэтому используется как drop-in замена `CapacitorSpec` в StageConfig.
"""
base_part_number: str
n_series: int
n_parallel: int
capacitance_uf: float
voltage_v: float
esr_ohm: float
max_current_a: float
price: float
url: str | None = None
@property
def count(self) -> int:
return self.n_series * self.n_parallel
@classmethod
def from_spec(cls, spec: "CapacitorSpec", n_series: int, n_parallel: int) -> "CapacitorBank":
n_series = max(1, int(n_series))
n_parallel = max(1, int(n_parallel))
return cls(
base_part_number=spec.part_number,
n_series=n_series,
n_parallel=n_parallel,
capacitance_uf=spec.capacitance_uf * n_parallel / n_series,
voltage_v=spec.voltage_v * n_series,
esr_ohm=spec.esr_ohm * n_series / n_parallel,
max_current_a=spec.max_current_a * n_parallel,
price=spec.price * n_series * n_parallel,
url=spec.url,
)
@dataclass(frozen=True)
@@ -38,6 +82,10 @@ class SwitchSpec:
turn_on_time_ns: float
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)
@@ -49,6 +97,7 @@ class SensorSpec:
source: str
sensitivity_v_per_mps: float | None = None
threshold_v: float | None = None
url: str | None = None
@dataclass(frozen=True)
@@ -59,3 +108,7 @@ class ProjectileMaterialSpec:
b_sat_tesla: float
price_per_kg: float
source: str
# электрическое удельное сопротивление — для расчёта вихревых потерь в снаряде
# (сплошной проводник в импульсном поле). Дефолт ~ конструкционная сталь.
resistivity_ohm_m: float = 1.6e-7
url: str | None = None

View File

30
src/gausse/gpu/backend.py Normal file
View File

@@ -0,0 +1,30 @@
"""Выбор массивного бэкенда: cupy (GPU), если доступен, иначе numpy (CPU).
Один и тот же код батч-интегратора работает на обоих. Разрабатывается и
валидируется на numpy без GPU; на сервере с CUDA автоматически подхватывает
cupy. Это гарантирует, что GPU-путь даёт те же числа, что и проверенный CPU.
"""
from typing import Any
def get_backend(prefer_gpu: bool = True) -> tuple[Any, str]:
"""Возвращает (модуль-как-numpy, имя_бэкенда)."""
if prefer_gpu:
try:
import cupy as cp
if cp.cuda.runtime.getDeviceCount() > 0:
return cp, "cupy"
except Exception:
pass
import numpy as np
return np, "numpy"
def to_cpu(xp, array):
"""Переносит массив в numpy (для записи в БД/сравнения), откуда бы он ни был."""
if xp.__name__ == "cupy":
return xp.asnumpy(array)
return array

View File

@@ -0,0 +1,410 @@
"""Батч-интегратор разряда: N конфигураций одновременно, фикс. шаг RK4.
Векторизует самую дорогую часть симуляции — 4-мерную ОДУ [Q, I, x, v]
разряда конденсатора через катушку с насыщающимся железом (та же физика,
что в `physics/circuit.py` + `physics/inductance.py`, но все параметры —
массивы формы (N,), а интегратор с фиксированным шагом вместо адаптивного
solve_ivp). Один код на numpy(CPU)/cupy(GPU) через `xp`.
Насыщение: λ(x,I) = L_air·I + L_iron·overlap(x)·g(I), g(I)=I_sat·tanh(I/I_sat).
I_sat кодируется большим КОНЕЧНЫМ числом (не inf), чтобы в линейном пределе
(mu_eff≈1) tanh(I/I_sat)→I/I_sat и всё сводилось к L_air·I без nan.
Валидируется против scipy-эталона в tests/test_batch_integrator.py — GPU-путь
обязан давать те же числа, что проверенный CPU (см. gausse-roadmap).
"""
from dataclasses import dataclass
from gausse.physics.constants import MU_0
NO_SATURATION_I_SAT = 1e12 # «бесконечный» I_sat: линейный (ненасыщающийся) предел
@dataclass
class BatchDischargeParams:
"""Пер-конфигурационные массивы (все формы (N,)) для батч-разряда."""
capacitance_f: "any"
r_total_ohm: "any"
mass_kg: "any"
l_air_h: "any"
l_iron_coeff: "any" # L_air*(mu_eff-1)
coil_length_m: "any"
slug_length_m: "any"
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:
"""Собирает BatchDischargeParams из списков CoilInductanceModel и StageCircuitParams.
Один источник истины с CPU-моделью: l_iron_coeff и saturation_current_a
берутся из тех же `CoilInductanceModel`, что использует scipy-путь.
"""
def col(vals):
return xp.asarray(vals, dtype=xp.float64)
i_sat = [
m.saturation_current_a if xp.isfinite(xp.asarray(m.saturation_current_a)) else NO_SATURATION_I_SAT
for m in models
]
return BatchDischargeParams(
capacitance_f=col([c.capacitance_f for c in circuit_params]),
r_total_ohm=col([c.r_total_ohm for c in circuit_params]),
mass_kg=col([c.mass_kg for c in circuit_params]),
l_air_h=col([m.l_air_h for m in models]),
l_iron_coeff=col([m.l_iron_coeff for m in models]),
coil_length_m=col([m.coil_length_m for m in models]),
slug_length_m=col([m.slug_length_m for m in models]),
smoothing_width_m=col([m.smoothing_width_m for m in models]),
i_sat_a=col(i_sat),
r_eddy_coeff_ohm=col([getattr(c, "r_eddy_coeff_ohm", 0.0) for c in circuit_params]),
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]),
)
def _ln_cosh(xp, z):
az = xp.abs(z)
return az + xp.log1p(xp.exp(-2 * az)) - xp.log(xp.asarray(2.0))
def _overlap(xp, x, p: BatchDischargeParams):
half_span = (p.coil_length_m + p.slug_length_m) / 2
w = p.smoothing_width_m
s1 = 0.5 * (1 + xp.tanh((x + half_span) / w / 2))
s2 = 0.5 * (1 + xp.tanh((half_span - x) / w / 2))
return s1 * s2, (s1 * s2 / w) * (s2 - s1)
def _magnetic_energy(xp, x, i, p: BatchDischargeParams):
"""W = ½L_air·I² + L_iron·overlap·(I·g G) — точная (с насыщением) энергия поля."""
overlap, _ = _overlap(xp, x, p)
z = i / p.i_sat_a
g = p.i_sat_a * xp.tanh(z)
g_integral = p.i_sat_a**2 * _ln_cosh(xp, z)
return 0.5 * p.l_air_h * i**2 + p.l_iron_coeff * overlap * (i * g - g_integral)
def _derivatives(xp, q, i, x, v, p: BatchDischargeParams):
overlap, d_overlap = _overlap(xp, x, p)
z = i / p.i_sat_a
tanhz = xp.tanh(z)
g = p.i_sat_a * tanhz
g_prime = 1.0 - tanhz**2
g_integral = p.i_sat_a**2 * _ln_cosh(xp, z)
dlambda_di = p.l_air_h + p.l_iron_coeff * overlap * g_prime
dlambda_dx = p.l_iron_coeff * d_overlap * g
force = p.l_iron_coeff * d_overlap * g_integral
r_eff = p.r_total_ohm + p.r_eddy_coeff_ohm * overlap # вихревые потери снаряда × overlap
v_c = q / p.capacitance_f
d_q = -i
d_i = (v_c - i * r_eff - dlambda_dx * v) / dlambda_di
# трение + воздух; 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,
x0,
v0,
params: BatchDischargeParams,
dt: float = 2e-6,
max_steps: int = 12000,
):
"""Интегрирует разряд для батча конфигураций до нуля тока у каждой.
Возвращает dict с массивами (N,): exit_v, exit_x, exit_q, peak_current,
energy_dissipated_j, feasible (ток вернулся к нулю в пределах max_steps).
"""
n = q0.shape[0]
q = xp.array(q0, dtype=xp.float64)
i = xp.zeros(n, dtype=xp.float64)
x = xp.array(x0, dtype=xp.float64)
v = xp.array(v0, dtype=xp.float64)
done = xp.zeros(n, dtype=bool)
committed = xp.zeros(n, dtype=bool) # реально скоммутировал (валидный обрыв), а не «взорвался»
past_peak = xp.zeros(n, dtype=bool) # ток уже прошёл пик и начал спадать
exit_v = xp.array(v0, dtype=xp.float64)
exit_x = xp.array(x0, dtype=xp.float64)
exit_q = xp.array(q0, dtype=xp.float64)
peak_current = xp.zeros(n, dtype=xp.float64)
energy_diss = xp.zeros(n, dtype=xp.float64)
_old_err = xp.seterr(all="ignore") if hasattr(xp, "seterr") else None # стиффные конфиги переполняют fixed-step
# на 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 step % SYNC_EVERY == 0 and not bool(xp.any(active)):
break
q_old, i_old, x_old, v_old = q, i, x, v
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
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) и пиковый ток — только для активных
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)
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)
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(cut_at_state, residual, 0.0)
committed = committed | cut # валидная коммутация
# стиффный конфиг «взорвал» fixed-step (inf/nan) -> стоп, НЕ реализуем
blew_up = active & ~cut & ~xp.isfinite(i_new)
done = done | cut | blew_up
# продвигаем только ещё активные и не взорвавшиеся конфигурации
advance = active & ~blew_up
q = xp.where(advance, q_new, q_old)
i = xp.where(advance, i_new, i_old)
x = xp.where(advance, x_new, x_old)
v = xp.where(advance, v_new, v_old)
if _old_err is not None and hasattr(xp, "seterr"):
xp.seterr(**_old_err)
feasible = committed # реализуемо = реально скоммутировал, а не done-по-переполнению
return {
"exit_v": exit_v,
"exit_x": exit_x,
"exit_q": exit_q,
"peak_current": peak_current,
"energy_dissipated_j": energy_diss,
"feasible": feasible,
}
def _interp(old, new, frac):
# значение в момент пересечения нуля тока между old и new
return old + frac * (new - old)

View File

@@ -0,0 +1,367 @@
"""GPU/CPU батч-sweep МНОГОСТУПЕНЧАТЫХ конфигураций (до max_stages).
Векторизует разряд сразу по многим конфигурациям через
`integrate_batch_discharge` (numpy CPU / cupy GPU). Многоступенчатость —
раунд за раундом: на раунде s все конфиги, у которых есть ступень s и
которые ещё «живы», считают свой разряд ОДНИМ батчем; состояние снаряда
(глобальная координата и скорость) переносится в следующий раунд.
Конфиги с меньшим числом ступеней просто не участвуют в поздних раундах.
Триггер датчика — аналитический при постоянной скорости подлёта (для
оптики/Холла точный; для индукционного — та же проверка порога, что на 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 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 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_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
@dataclass(frozen=True)
class StagePre:
"""Физика ступени, предвычисленная в ПЛОСКИЕ числа (пиклится дёшево).
Считается один раз на геном (в воркере пула, параллельно) через тот же
`build_stage_physics` — один источник истины с CPU-путём. Главному потоку
остаётся только склейка массивов и запуск GPU-ядер: раньше он строил
физобъекты 4000 геномов сам и душил GPU (карта ждала ~половину времени).
"""
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
@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 {
"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,
bounds: SearchBounds = SearchBounds(),
data_dir: Path | None = None,
seed: int | None = None,
prefer_gpu: bool = True,
batch_size: int = 20000,
log_path: Path | None = None,
) -> dict:
xp, backend = get_backend(prefer_gpu=prefer_gpu)
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
rng = random.Random(seed if seed is not None else random.randrange(2**31))
logger = ProgressLogger(log_path or default_log_path(db_path), mode=f"gpu-sweep({backend})", total=n_runs)
conn = open_connection(db_path)
n_feasible = 0
done = 0
try:
while done < n_runs:
n = min(batch_size, n_runs - done)
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:
records.append(_record(st, db, backend, None, None))
insert_runs(conn, records)
for r in records:
logger.update(r.feasible, r.efficiency)
done += n
finally:
logger.finish()
conn.close()
return {"n_runs": done, "n_feasible": n_feasible, "backend": backend}
def _record(st: _State, db, backend, efficiency, exit_v):
from gausse.optim.objective import decoded_summary
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(st.genome)),
decoded_summary_json=json.dumps(decoded_summary(st.genome, db), ensure_ascii=False),
feasible=feasible,
model_version=MODEL_VERSION,
infeasible_reason=st.reason,
failed_stage_index=None,
efficiency=efficiency,
exit_velocity_mps=exit_v,
cost_rub=None,
energy_breakdown_json=None,
)

View File

@@ -0,0 +1,304 @@
"""(μ+λ)-эволюционный поиск поверх той же базы SQLite, что и sweep.
Каждый оценённый геном каждого поколения пишется в общую таблицу `runs`
(search_mode="evolve") — включая неудачные, с той же честной причиной
отказа. После эволюции — локальная doводка (Nelder-Mead) непрерывной
части лучшего найденного генома при замороженных дискретных выборах
компонентов.
"""
import copy
import multiprocessing
import random
import signal
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
import numpy as np
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, compute_cost_rub, evaluate
from gausse.optim.progress_log import ProgressLogger, default_log_path
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
def _evaluate_genome(genome: Genome) -> tuple[Genome, EvaluationResult]:
result = evaluate(genome, worker_context.db, worker_context.bounds)
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:
contenders = rng.sample(pairs, min(k, len(pairs)))
winner = max(contenders, key=lambda pair: pair[1].fitness)
return winner[0]
def _continuous_vector(genome: Genome) -> list[float]:
vec = [genome.tube_inner_d_m, genome.tube_wall_m]
for stage in genome.stages:
vec.append(stage.sensor_to_coil_distance_m)
vec.append(stage.charge_voltage_fraction)
vec.extend(genome.inter_stage_gaps_m)
vec.append(genome.projectile.diameter_m)
vec.append(genome.projectile.length_m)
return vec
def _apply_continuous_vector(template: Genome, vec: list[float]) -> Genome:
genome = copy.deepcopy(template)
idx = 0
genome.tube_inner_d_m = vec[idx]
idx += 1
genome.tube_wall_m = vec[idx]
idx += 1
for stage in genome.stages:
stage.sensor_to_coil_distance_m = vec[idx]
idx += 1
stage.charge_voltage_fraction = vec[idx]
idx += 1
n_gaps = len(genome.inter_stage_gaps_m)
genome.inter_stage_gaps_m = list(vec[idx : idx + n_gaps])
idx += n_gaps
genome.projectile.diameter_m = vec[idx]
idx += 1
genome.projectile.length_m = vec[idx]
idx += 1
return genome
def polish_best(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> tuple[Genome, EvaluationResult]:
"""Локальная доводка непрерывных параметров лучшего генома (дискретные выборы заморожены)."""
x0 = np.array(_continuous_vector(genome))
def neg_fitness(x: np.ndarray) -> float:
candidate = repair(_apply_continuous_vector(genome, list(x)), db, bounds)
return -evaluate(candidate, db, bounds).fitness
opt = minimize(neg_fitness, x0, method="Nelder-Mead", options={"maxiter": 200, "xatol": 1e-4, "fatol": 1e-5})
polished = repair(_apply_continuous_vector(genome, list(opt.x)), db, bounds)
return polished, evaluate(polished, db, bounds)
def run_evolution(
db_path: Path,
n_generations: int,
population_size: int,
bounds: SearchBounds = SearchBounds(),
data_dir: Path | None = None,
n_workers: int | None = None,
seed: int | None = None,
elitism: int = 2,
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=mode, total=n_generations * population_size * islands
)
ctx = multiprocessing.get_context("spawn")
queue = ctx.Queue()
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
writer.start()
# 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:
# фитнес лучшей УЖЕ ЗАПИСАННОЙ строки каждого острова: полные
# строки в базу идут только когда остров улучшился — иначе элитизм
# дублировал бы один и тот же геном в топ дашборда каждое поколение
written_best_fitness = [float("-inf")] * islands
# СКОРОСТЬ отслеживается отдельно: у дашборда два топа (КПД и
# скорость), а рекордсмены скорости — почти всегда «прожорливые»
# геномы с низким КПД, которые КПД-рекорд не улучшают. Без этого
# трекера топ скоростей замирал навсегда (реальный кейс: после
# перехода на выборочную запись он не обновлялся сутки, пока
# эволюция находила всё новые быстрые геномы)
written_best_velocity = [float("-inf")] * islands
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)
# селекция и скрещивание — НА КАЖДОМ ОСТРОВЕ независимо
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)
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:
# ОДНУ запись пишем напрямую, без процесса-писателя и очереди:
# конкурентных писателей в этот момент нет (основной уже завершён),
# а редкая гонка 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 {
"n_evaluated": n_evaluated,
"best_feasible": best_result.feasible,
"best_efficiency": best_result.efficiency,
"best_fitness": best_result.fitness,
"best_cost_rub": best_result.cost_rub,
}

View File

@@ -0,0 +1,317 @@
"""Целевая функция: КПД — основная цель, стоимость и скорость — вторичные метрики.
Нереализуемые геномы не отбрасываются — получают "мягкий" штраф фитнеса,
пропорциональный тому, на какой ступени всё сломалось, чтобы у ГА был
градиент, а не стена (см. PLAN.md).
"""
import json
import math
import uuid
from dataclasses import dataclass
from datetime import datetime, timezone
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
# v4: диффузия поля в снаряд — намагничивается только скин-кольцо глубиной δ,
# закрыт эксплойт «медленной» катушки (30%). v3: fill factor (эксплойт тонкого
# снаряда), полное отражение вихрей, гард вырожденных катушек. v2: Доуэлл,
# трение+воздух, паспортные импульсные токи ключей.
MODEL_VERSION = "gausse-physics-v4"
@dataclass
class EvaluationResult:
feasible: bool
cost_rub: float
fitness: float
reason: str | None = None
failed_stage_index: int | None = None
efficiency: float | None = None
exit_velocity_mps: float | None = None
energy_breakdown: dict = None
def compute_cost_rub(config, db: ComponentDatabase) -> float:
total = 0.0
for stage in config.stages:
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)
total += geometry.total_wire_length_m * stage.wire.price_per_m
total += stage.capacitor.price
total += stage.switch.price
total += stage.sensor.price
total += config.projectile.mass_kg * config.projectile.material.price_per_kg
return total
def _wire_resistance_ohm(resistivity_ohm_m: float, gauge_mm: float, wire_length_m: float) -> float:
bare_radius_m = gauge_mm / 1000 / 2
area_m2 = math.pi * bare_radius_m**2
return resistivity_ohm_m * wire_length_m / area_m2
def decoded_summary(genome: Genome, db: ComponentDatabase) -> dict:
"""Краткая сводка только по геному (без результатов симуляции) — это и
пишется в 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 = []
for gene in genome.stages:
wire = db.wires[gene.wire_idx % len(db.wires)]
capacitor = db.capacitors[gene.capacitor_idx % len(db.capacitors)]
switch = db.switches[gene.switch_idx % len(db.switches)]
sensor = db.sensors[gene.sensor_idx % len(db.sensors)]
stages_summary.append(
{
"wire": wire.part_id,
"capacitor": capacitor.part_number,
"switch": switch.part_number,
"sensor": sensor.part_number,
"turns_per_layer": gene.turns_per_layer,
"layers": gene.layers,
"sensor_to_coil_distance_m": gene.sensor_to_coil_distance_m,
"charge_voltage_fraction": gene.charge_voltage_fraction,
}
)
return {
"tube_od_m": genome.tube_od_m,
"projectile_material": material.name,
"projectile_diameter_m": genome.projectile.diameter_m,
"projectile_length_m": genome.projectile.length_m,
"stages": stages_summary,
}
def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, initial_x_m: float, initial_v_mps: float, tube_inner_d_m: float | None = None, tube_wall_m: float | None = None) -> dict:
"""Максимально подробная запись: параметры + вычисленная физика + результат каждой ступени.
Именно это пишется в SQLite (decoded_summary_json), чтобы по базе можно
было восстановить ВСЁ: расстояния между катушками и абсолютные позиции
вдоль трубы, номиналы всех деталей, геометрию намотки, посчитанные
индуктивность/сопротивление/пиковый ток и что произошло на каждой ступени.
"""
outcomes_by_index = {o.stage_index: o for o in (coilgun_result.stage_outcomes if coilgun_result else [])}
projectile = config.projectile
# абсолютные позиции центров катушек вдоль трубы (сквозная координата)
coil_centers_m = []
cursor = 0.0
for i in range(len(config.stages)):
if i > 0:
cursor += config.inter_stage_gaps_m[i - 1]
coil_centers_m.append(cursor)
stages_detail = []
for i, stage in enumerate(config.stages):
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.resistivity_ohm_m, stage.wire.gauge_mm, geometry.total_wire_length_m)
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)
cap_base = getattr(cap, "base_part_number", getattr(cap, "part_number", "?"))
n_series = getattr(cap, "n_series", 1)
n_parallel = getattr(cap, "n_parallel", 1)
entry = {
"stage_index": 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,
"wire_gauge_mm": stage.wire.gauge_mm,
"wire_insulation_od_mm": stage.wire.insulation_od_mm,
"wire_url": stage.wire.url,
"switch": stage.switch.part_number,
"switch_kind": stage.switch.kind,
"switch_max_current_a": stage.switch.max_current_a,
"switch_max_voltage_v": stage.switch.max_voltage_v,
"switch_url": stage.switch.url,
"sensor": stage.sensor.part_number,
"sensor_kind": stage.sensor.kind,
"sensor_url": stage.sensor.url,
},
"capacitor_bank": {
"base_part": cap_base,
"n_series": n_series,
"n_parallel": n_parallel,
"count": n_series * n_parallel,
"single_capacitance_uf": getattr(cap, "capacitance_uf", 0) * n_series / max(n_parallel, 1),
"single_voltage_rating_v": getattr(cap, "voltage_v", 0) / max(n_series, 1),
"total_capacitance_uf": cap.capacitance_uf,
"total_voltage_rating_v": cap.voltage_v,
"total_esr_ohm": cap.esr_ohm,
"max_current_a": cap.max_current_a,
"url": getattr(cap, "url", None),
},
"winding": {
"turns_per_layer": stage.turns_per_layer,
"layers": stage.layers,
"total_turns": geometry.total_turns,
"coil_length_m": geometry.coil_length_m,
"coil_inner_diameter_m": stage.tube_od_m, # = внешний диаметр трубы
"coil_outer_diameter_m": stage.tube_od_m + 2 * geometry.radial_depth_m,
"mean_radius_m": geometry.mean_radius_m,
"radial_depth_m": geometry.radial_depth_m,
"total_wire_length_m": geometry.total_wire_length_m,
},
"electrical": {
"charge_voltage_v": stage.charge_voltage_v,
"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,
},
}
outcome = outcomes_by_index.get(i)
if outcome is not None:
r = outcome.result
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
)
# Диагностика по току. Жёсткая граница для ключа — ИМПУЛЬСНЫЙ рейтинг
# (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_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,
"reason": r.reason,
"entry_velocity_mps": outcome.entry_v_mps,
"exit_velocity_mps": r.exit_v_mps,
"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_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,
"energy_dissipated_j": r.energy_dissipated_j,
"kinetic_energy_delta_j": r.kinetic_energy_delta_j,
}
else:
entry["outcome"] = {"reached": False}
stages_detail.append(entry)
tube_od_m = config.stages[0].tube_od_m if config.stages else None
return {
"tube": {
"inner_diameter_m": tube_inner_d_m, # бор — снаряд летит внутри
"wall_thickness_m": tube_wall_m,
"outer_diameter_m": tube_od_m, # = внутренний диаметр катушки
},
"tube_od_m": tube_od_m, # для обратной совместимости
"tube_length_m": (coil_centers_m[-1] + 0.05) if coil_centers_m else None,
"projectile": {
"material": projectile.material.name,
"diameter_m": projectile.diameter_m,
"length_m": projectile.length_m,
"mass_kg": projectile.mass_kg,
"mass_g": projectile.mass_kg * 1000,
"mu_r": projectile.material.mu_r,
"b_sat_tesla": projectile.material.b_sat_tesla,
"resistivity_ohm_m": projectile.material.resistivity_ohm_m,
"fits_in_bore": (tube_inner_d_m is None) or (projectile.diameter_m < tube_inner_d_m),
"url": projectile.material.url,
},
"launch": {"initial_x_m": initial_x_m, "initial_v_mps": initial_v_mps},
"n_stages": len(config.stages),
"inter_stage_gaps_m": list(config.inter_stage_gaps_m),
"coil_center_positions_m": coil_centers_m,
"stages": stages_detail,
}
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)
if not result.feasible:
n_stages = len(config.stages)
progress_fraction = (result.failed_stage_index or 0) / n_stages if n_stages else 0.0
fitness = -1.0 + progress_fraction
return EvaluationResult(
feasible=False,
cost_rub=cost_rub,
fitness=fitness,
reason=result.reason,
failed_stage_index=result.failed_stage_index,
energy_breakdown={},
)
energy_breakdown = {
"total_energy_in_j": result.total_energy_in_j,
"total_energy_dissipated_j": result.total_energy_dissipated_j,
"total_kinetic_energy_delta_j": result.total_kinetic_energy_delta_j,
"exit_kinetic_energy_j": result.exit_kinetic_energy_j,
}
return EvaluationResult(
feasible=True,
cost_rub=cost_rub,
fitness=result.efficiency,
efficiency=result.efficiency,
exit_velocity_mps=result.exit_v_mps,
energy_breakdown=energy_breakdown,
)
def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord:
detail = decoded_summary(genome, db)
return RunRecord(
run_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(),
search_mode=search_mode,
genome_json=json.dumps(genome_to_dict(genome)),
decoded_summary_json=json.dumps(detail, ensure_ascii=False),
feasible=result.feasible,
model_version=MODEL_VERSION,
infeasible_reason=result.reason,
failed_stage_index=result.failed_stage_index,
efficiency=result.efficiency,
exit_velocity_mps=result.exit_velocity_mps,
cost_rub=result.cost_rub,
energy_breakdown_json=json.dumps(result.energy_breakdown) if result.energy_breakdown else None,
)

View File

@@ -0,0 +1,69 @@
"""Лог хода эксперимента: пишет прогресс sweep/evolve в файл рядом с базой.
Формат — по строке на контрольную точку, читаемый и человеком, и хвостом в
веб-морде: время, сколько прогнано из скольких, доля реализуемых, скорость,
лучший найденный КПД. Файл дописывается (append), переживает перезапуски.
"""
import time
from datetime import datetime, timezone
from pathlib import Path
def default_log_path(db_path: Path) -> Path:
db_path = Path(db_path)
return db_path.with_suffix(db_path.suffix + ".log")
class ProgressLogger:
def __init__(self, log_path: Path, mode: str, total: int | None, log_every: int = 200):
self.log_path = Path(log_path)
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self.mode = mode
self.total = total
self.log_every = max(log_every, 1)
self.start_time = time.monotonic()
self.completed = 0
self.n_feasible = 0
self.best_efficiency: float | None = None
self._write(f"=== старт {mode}, всего запланировано: {total} ===")
def _write(self, line: str) -> None:
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
with self.log_path.open("a", encoding="utf-8") as f:
f.write(f"{stamp} {line}\n")
def update(self, feasible: bool, efficiency: float | None) -> None:
self.completed += 1
if feasible:
self.n_feasible += 1
if efficiency is not None and (self.best_efficiency is None or efficiency > self.best_efficiency):
self.best_efficiency = efficiency
self._write(
f"новый лучший КПД: {efficiency * 100:.2f}% "
f"(на прогоне {self.completed})"
)
if self.completed % self.log_every == 0:
self._checkpoint()
def _checkpoint(self) -> None:
elapsed = time.monotonic() - self.start_time
rate = self.completed / elapsed if elapsed > 0 else 0.0
feas_pct = 100 * self.n_feasible / self.completed if self.completed else 0.0
best = f"{self.best_efficiency * 100:.2f}%" if self.best_efficiency is not None else ""
total_str = str(self.total) if self.total else "?"
pct = f"{100 * self.completed / self.total:.1f}%" if self.total else "?"
eta = ""
if self.total and rate > 0:
remaining = (self.total - self.completed) / rate
eta = f" ETA={remaining:.0f}с"
self._write(
f"прогресс: {self.completed}/{total_str} ({pct}) "
f"реализуемо={self.n_feasible} ({feas_pct:.1f}%) "
f"скорость={rate:.1f}/с лучший_КПД={best}{eta}"
)
def finish(self) -> None:
self._checkpoint()
elapsed = time.monotonic() - self.start_time
self._write(f"=== готово {self.mode}: {self.completed} прогонов за {elapsed:.0f}с ===")

View File

@@ -0,0 +1,374 @@
"""Пространство поиска: геном переменной длины (число ступеней эволюционирует).
Геном кодирует только ИНДЕКСЫ в базу реальных компонентов (не сами
параметры) + непрерывные величины (расстояния, напряжение, геометрия
снаряда). Это гарантирует, что что бы ни нашёл поиск, оно собрано из
реально продающихся деталей, а не из выдуманных чисел.
"""
import copy
import math
import random
from dataclasses import dataclass, field
from gausse.components.database import ComponentDatabase
from gausse.components.schema import CapacitorBank
from gausse.sim.coilgun import CoilgunConfig
from gausse.sim.stage import ProjectileConfig, StageConfig
# зазор между снарядом и внутренней стенкой трубы (бором), чтобы снаряд летел свободно
PROJECTILE_BORE_CLEARANCE_M = 0.0005
@dataclass(frozen=True)
class SearchBounds:
min_stages: int = 1
max_stages: int = 10
turns_per_layer_min: int = 5
turns_per_layer_max: int = 100
layers_min: int = 1
layers_max: int = 8
sensor_to_coil_distance_m_min: float = 0.005
sensor_to_coil_distance_m_max: float = 0.05
inter_stage_gap_m_min: float = 0.01
inter_stage_gap_m_max: float = 0.10
# ТРУБА: внутренний диаметр (бор — снаряд летит внутри) и толщина стенки.
# Внешний диаметр = внутренний + 2×стенка = внутренний диаметр КАТУШКИ.
tube_inner_d_m_min: float = 0.005
tube_inner_d_m_max: float = 0.030
tube_wall_m_min: float = 0.001
tube_wall_m_max: float = 0.003
# СНАРЯД: диаметр обязан быть < внутр. диаметра трубы; масса до 200 г.
projectile_diameter_m_min: float = 0.004
projectile_diameter_m_max: float = 0.028
projectile_length_m_min: float = 0.01
projectile_length_m_max: float = 0.15
projectile_mass_max_kg: float = 0.2
charge_voltage_fraction_min: float = 0.5
charge_voltage_fraction_max: float = 1.0
initial_launch_velocity_mps: float = 3.0
initial_approach_margin_m: float = 0.02
# батарея конденсаторов на ступень: последовательно (напряжение) × параллельно (ток/ёмкость)
cap_series_min: int = 1
cap_series_max: int = 6
cap_parallel_min: int = 1
cap_parallel_max: int = 8
def _max_length_for_mass(diameter_m: float, density_kg_m3: float, max_mass_kg: float) -> float:
"""Максимальная длина снаряда, при которой масса не превышает max_mass_kg."""
area = math.pi * (diameter_m / 2) ** 2
return max_mass_kg / (density_kg_m3 * area)
@dataclass
class StageGene:
wire_idx: int
capacitor_idx: int
switch_idx: int
sensor_idx: int
turns_per_layer: int
layers: int
sensor_to_coil_distance_m: float
charge_voltage_fraction: float
cap_series: int = 1
cap_parallel: int = 1
@dataclass
class ProjectileGene:
material_idx: int
diameter_m: float
length_m: float
@dataclass
class Genome:
tube_inner_d_m: float # бор трубы (снаряд летит внутри)
tube_wall_m: float # толщина стенки трубы
stages: list[StageGene]
inter_stage_gaps_m: list[float]
projectile: ProjectileGene
@property
def tube_od_m(self) -> float:
"""Внешний диаметр трубы = внутренний диаметр катушки."""
return self.tube_inner_d_m + 2 * self.tube_wall_m
def _clip(value: float, lo: float, hi: float) -> float:
return max(lo, min(hi, value))
def sample_stage_gene(db: ComponentDatabase, bounds: SearchBounds, rng: random.Random) -> StageGene:
return StageGene(
wire_idx=rng.randrange(len(db.wires)),
capacitor_idx=rng.randrange(len(db.capacitors)),
switch_idx=rng.randrange(len(db.switches)),
sensor_idx=rng.randrange(len(db.sensors)),
turns_per_layer=rng.randint(bounds.turns_per_layer_min, bounds.turns_per_layer_max),
layers=rng.randint(bounds.layers_min, bounds.layers_max),
sensor_to_coil_distance_m=rng.uniform(
bounds.sensor_to_coil_distance_m_min, bounds.sensor_to_coil_distance_m_max
),
charge_voltage_fraction=rng.uniform(
bounds.charge_voltage_fraction_min, bounds.charge_voltage_fraction_max
),
cap_series=rng.randint(bounds.cap_series_min, bounds.cap_series_max),
cap_parallel=rng.randint(bounds.cap_parallel_min, bounds.cap_parallel_max),
)
def sample_genome(db: ComponentDatabase, bounds: SearchBounds, rng: random.Random) -> Genome:
tube_inner_d_m = rng.uniform(bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
tube_wall_m = rng.uniform(bounds.tube_wall_m_min, bounds.tube_wall_m_max)
# диаметр снаряда < внутр. диаметра трубы минус зазор
max_diameter = min(bounds.projectile_diameter_m_max, tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M)
diameter_m = rng.uniform(bounds.projectile_diameter_m_min, max(max_diameter, bounds.projectile_diameter_m_min))
material_idx = rng.randrange(len(db.projectile_materials))
density = db.projectile_materials[material_idx].density_kg_m3
# длина ограничена и границами, и максимальной массой 200 г
len_cap = min(bounds.projectile_length_m_max, _max_length_for_mass(diameter_m, density, bounds.projectile_mass_max_kg))
length_m = rng.uniform(bounds.projectile_length_m_min, max(len_cap, bounds.projectile_length_m_min))
n_stages = rng.randint(bounds.min_stages, bounds.max_stages)
stages = [sample_stage_gene(db, bounds, rng) for _ in range(n_stages)]
gaps = [
rng.uniform(bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
for _ in range(n_stages - 1)
]
projectile = ProjectileGene(material_idx=material_idx, diameter_m=diameter_m, length_m=length_m)
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:
"""Приводит геном в границы после мутации/скрещивания (клэмп, не отбраковка)."""
genome.tube_inner_d_m = _clip(genome.tube_inner_d_m, bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
genome.tube_wall_m = _clip(genome.tube_wall_m, bounds.tube_wall_m_min, bounds.tube_wall_m_max)
genome.projectile.material_idx = genome.projectile.material_idx % len(db.projectile_materials)
# снаряд обязан влезать в бор трубы
max_diameter = max(genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M, bounds.projectile_diameter_m_min)
max_diameter = min(max_diameter, bounds.projectile_diameter_m_max)
genome.projectile.diameter_m = _clip(genome.projectile.diameter_m, bounds.projectile_diameter_m_min, max_diameter)
# длина в границах И под массой ≤ 200 г
density = db.projectile_materials[genome.projectile.material_idx].density_kg_m3
len_cap = min(bounds.projectile_length_m_max, _max_length_for_mass(genome.projectile.diameter_m, density, bounds.projectile_mass_max_kg))
genome.projectile.length_m = _clip(genome.projectile.length_m, bounds.projectile_length_m_min, max(len_cap, bounds.projectile_length_m_min))
for stage in genome.stages:
stage.wire_idx %= len(db.wires)
stage.capacitor_idx %= len(db.capacitors)
stage.switch_idx %= len(db.switches)
stage.sensor_idx %= len(db.sensors)
stage.turns_per_layer = int(_clip(stage.turns_per_layer, bounds.turns_per_layer_min, bounds.turns_per_layer_max))
stage.layers = int(_clip(stage.layers, bounds.layers_min, bounds.layers_max))
stage.sensor_to_coil_distance_m = _clip(
stage.sensor_to_coil_distance_m,
bounds.sensor_to_coil_distance_m_min,
bounds.sensor_to_coil_distance_m_max,
)
stage.charge_voltage_fraction = _clip(
stage.charge_voltage_fraction,
bounds.charge_voltage_fraction_min,
bounds.charge_voltage_fraction_max,
)
stage.cap_series = int(_clip(stage.cap_series, bounds.cap_series_min, bounds.cap_series_max))
stage.cap_parallel = int(_clip(stage.cap_parallel, bounds.cap_parallel_min, bounds.cap_parallel_max))
genome.inter_stage_gaps_m = [
_clip(g, bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
for g in genome.inter_stage_gaps_m[: len(genome.stages) - 1]
]
while len(genome.inter_stage_gaps_m) < len(genome.stages) - 1:
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
def decode(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> tuple[CoilgunConfig, float, float]:
"""Возвращает (CoilgunConfig, initial_x_m, initial_v_mps)."""
material = db.projectile_materials[genome.projectile.material_idx % len(db.projectile_materials)]
projectile = ProjectileConfig(
material=material,
diameter_m=genome.projectile.diameter_m,
length_m=genome.projectile.length_m,
)
stage_configs = []
for gene in genome.stages:
wire = db.wires[gene.wire_idx % len(db.wires)]
base_capacitor = db.capacitors[gene.capacitor_idx % len(db.capacitors)]
capacitor = CapacitorBank.from_spec(base_capacitor, gene.cap_series, gene.cap_parallel)
switch = db.switches[gene.switch_idx % len(db.switches)]
sensor = db.sensors[gene.sensor_idx % len(db.sensors)]
# напряжение батареи (base.V × n_series) может быть выше одиночного,
# но ключ обязан его выдержать — реальный ограничитель switch.max_voltage_v
max_voltage = min(capacitor.voltage_v, switch.max_voltage_v)
charge_voltage_v = gene.charge_voltage_fraction * max_voltage
stage_configs.append(
StageConfig(
wire=wire,
capacitor=capacitor,
switch=switch,
sensor=sensor,
tube_od_m=genome.tube_od_m, # внешний диаметр трубы = внутр. диаметр катушки
turns_per_layer=gene.turns_per_layer,
layers=gene.layers,
sensor_to_coil_distance_m=gene.sensor_to_coil_distance_m,
charge_voltage_v=charge_voltage_v,
)
)
config = CoilgunConfig(
stages=stage_configs,
inter_stage_gaps_m=list(genome.inter_stage_gaps_m),
projectile=projectile,
initial_x_m=-(stage_configs[0].sensor_to_coil_distance_m + bounds.initial_approach_margin_m),
initial_v_mps=bounds.initial_launch_velocity_mps,
)
return config, config.initial_x_m, config.initial_v_mps
def mutate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds, rng: random.Random, rate: float = 0.2) -> Genome:
child = copy.deepcopy(genome)
if rng.random() < rate:
child.tube_inner_d_m = rng.uniform(bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
if rng.random() < rate:
child.tube_wall_m = rng.uniform(bounds.tube_wall_m_min, bounds.tube_wall_m_max)
for stage in child.stages:
if rng.random() < rate:
stage.wire_idx = rng.randrange(len(db.wires))
if rng.random() < rate:
stage.capacitor_idx = rng.randrange(len(db.capacitors))
if rng.random() < rate:
stage.switch_idx = rng.randrange(len(db.switches))
if rng.random() < rate:
stage.sensor_idx = rng.randrange(len(db.sensors))
if rng.random() < rate:
stage.turns_per_layer += rng.randint(-10, 10)
if rng.random() < rate:
stage.layers += rng.randint(-1, 1)
if rng.random() < rate:
stage.sensor_to_coil_distance_m *= rng.uniform(0.7, 1.3)
if rng.random() < rate:
stage.charge_voltage_fraction *= rng.uniform(0.9, 1.1)
if rng.random() < rate:
stage.cap_series += rng.randint(-1, 1)
if rng.random() < rate:
stage.cap_parallel += rng.randint(-1, 1)
for i in range(len(child.inter_stage_gaps_m)):
if rng.random() < rate:
child.inter_stage_gaps_m[i] *= rng.uniform(0.7, 1.3)
if rng.random() < rate:
child.projectile.material_idx = rng.randrange(len(db.projectile_materials))
if rng.random() < rate:
child.projectile.diameter_m *= rng.uniform(0.8, 1.2)
if rng.random() < rate:
child.projectile.length_m *= rng.uniform(0.8, 1.2)
# структурные операторы: число ступеней тоже эволюционирует
structural_roll = rng.random()
if structural_roll < rate / 3 and len(child.stages) < bounds.max_stages:
new_stage = sample_stage_gene(db, bounds, rng)
child.stages.append(new_stage)
child.inter_stage_gaps_m.append(
rng.uniform(bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
)
elif structural_roll < 2 * rate / 3 and len(child.stages) > bounds.min_stages:
idx = rng.randrange(len(child.stages))
child.stages.pop(idx)
if child.inter_stage_gaps_m:
child.inter_stage_gaps_m.pop(min(idx, len(child.inter_stage_gaps_m) - 1))
elif structural_roll < rate and len(child.stages) < bounds.max_stages:
idx = rng.randrange(len(child.stages))
child.stages.insert(idx, copy.deepcopy(child.stages[idx]))
child.inter_stage_gaps_m.append(
rng.uniform(bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
)
return repair(child, db, bounds)
def crossover(parent_a: Genome, parent_b: Genome, rng: random.Random) -> Genome:
"""Обмен целыми ступенями между родителями — ступень физически цельная единица."""
n = rng.choice([len(parent_a.stages), len(parent_b.stages)])
stages = []
for i in range(n):
source = parent_a if rng.random() < 0.5 else parent_b
if i < len(source.stages):
stages.append(copy.deepcopy(source.stages[i]))
else:
fallback = parent_a if source is parent_b else parent_b
stages.append(copy.deepcopy(fallback.stages[i % len(fallback.stages)]))
gaps = []
for i in range(n - 1):
candidates = [
g.inter_stage_gaps_m[i]
for g in (parent_a, parent_b)
if i < len(g.inter_stage_gaps_m)
]
# Оба родителя короче, чем ребёнок (например, у обоих 1 ступень, а
# n=2 из-за структурной мутации выше по цепочке) -- нет зазора,
# который можно унаследовать; repair() всё равно подожмёт в границы.
gaps.append(rng.choice(candidates) if candidates else 0.03)
projectile_source = parent_a if rng.random() < 0.5 else parent_b
tube_source = parent_a if rng.random() < 0.5 else parent_b
return Genome(
tube_inner_d_m=tube_source.tube_inner_d_m,
tube_wall_m=tube_source.tube_wall_m,
stages=stages,
inter_stage_gaps_m=gaps,
projectile=copy.deepcopy(projectile_source.projectile),
)
def genome_to_dict(genome: Genome) -> dict:
return {
"tube_inner_d_m": genome.tube_inner_d_m,
"tube_wall_m": genome.tube_wall_m,
"stages": [vars(s) for s in genome.stages],
"inter_stage_gaps_m": genome.inter_stage_gaps_m,
"projectile": vars(genome.projectile),
}
def genome_from_dict(data: dict) -> Genome:
# обратная совместимость: старые геномы в БД несли tube_od_m без стенки
if "tube_inner_d_m" in data:
tube_inner_d_m = data["tube_inner_d_m"]
tube_wall_m = data["tube_wall_m"]
else:
tube_wall_m = 0.0015
tube_inner_d_m = max(data.get("tube_od_m", 0.012) - 2 * tube_wall_m, 0.005)
return Genome(
tube_inner_d_m=tube_inner_d_m,
tube_wall_m=tube_wall_m,
stages=[StageGene(**s) for s in data["stages"]],
inter_stage_gaps_m=list(data["inter_stage_gaps_m"]),
projectile=ProjectileGene(**data["projectile"]),
)

81
src/gausse/optim/sweep.py Normal file
View File

@@ -0,0 +1,81 @@
"""Массовый Monte Carlo sweep: честный широкий перебор, все прогоны в SQLite.
Воркеры (пул процессов) только сэмплируют и оценивают геномы; запись в
SQLite идёт через единственный writer-процесс поверх очереди
(`storage.database.run_writer_process`), чтобы не было конкурентной записи
в один файл базы.
"""
import multiprocessing
import random
from concurrent.futures import ProcessPoolExecutor
from pathlib import Path
from typing import Callable
from gausse.optim import worker_context
from gausse.optim.objective import MODEL_VERSION, build_run_record, evaluate
from gausse.optim.progress_log import ProgressLogger, default_log_path
from gausse.optim.search_space import SearchBounds, sample_genome
from gausse.storage.database import run_writer_process
from gausse.storage.schema import RunRecord
__all__ = ["MODEL_VERSION", "run_sweep"]
def _evaluate_one(seed: int) -> RunRecord:
rng = random.Random(seed)
genome = sample_genome(worker_context.db, worker_context.bounds, rng)
result = evaluate(genome, worker_context.db, worker_context.bounds)
return build_run_record(genome, result, worker_context.db, search_mode="sweep")
def run_sweep(
db_path: Path,
n_runs: int,
bounds: SearchBounds = SearchBounds(),
data_dir: Path | None = None,
n_workers: int | None = None,
seed: int | None = None,
progress_callback: Callable[[int, int], None] | None = None,
log_path: Path | None = None,
) -> dict:
"""Прогоняет n_runs случайных конфигураций параллельно, пишет каждую в SQLite.
Возвращает сводку {n_runs, n_feasible} — не для принятия решений (для
этого нужно читать саму базу), а как быстрая сверка "ничего не потерялось".
Ход эксперимента пишется в `log_path` (по умолчанию — <db>.log).
"""
n_workers = n_workers or multiprocessing.cpu_count()
base_seed = seed if seed is not None else random.randrange(2**31)
seeds = [base_seed + i for i in range(n_runs)]
logger = ProgressLogger(log_path or default_log_path(db_path), mode="sweep", total=n_runs)
ctx = multiprocessing.get_context("spawn")
queue = ctx.Queue()
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
writer.start()
completed = 0
n_feasible = 0
try:
with ProcessPoolExecutor(
max_workers=n_workers,
mp_context=ctx,
initializer=worker_context.init_worker,
initargs=(data_dir, bounds),
) as executor:
for record in executor.map(_evaluate_one, seeds):
queue.put(record)
completed += 1
if record.feasible:
n_feasible += 1
logger.update(record.feasible, record.efficiency)
if progress_callback:
progress_callback(completed, n_runs)
finally:
logger.finish()
queue.put(None)
writer.join()
return {"n_runs": completed, "n_feasible": n_feasible}

View File

@@ -0,0 +1,19 @@
"""Состояние процесса-воркера пула: база компонентов грузится один раз на процесс.
Используется и sweep.py (случайный перебор), и evolutionary.py (ГА) — общий
паттерн `ProcessPoolExecutor(initializer=init_worker, ...)`.
"""
from pathlib import Path
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import SearchBounds
db: ComponentDatabase | None = None
bounds: SearchBounds | None = None
def init_worker(data_dir: Path | None, worker_bounds: SearchBounds) -> None:
global db, bounds
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
bounds = worker_bounds

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

@@ -1,14 +1,14 @@
"""ОДУ разряда конденсатора через ключ и катушку, связанное с механикой снаряда.
Состояние: [Q, I, x, v] — заряд конденсатора, ток в катушке, положение и
скорость снаряда. Индуктивность зависит от x (проход снаряда), поэтому
уравнение на dI/dt выведено из закона сохранения потокосцепления
Ψ = L(x)*I:
скорость снаряда. Индуктивность железа насыщается по току, поэтому и член
dI/dt, и наведённая ЭДС, и сила выводятся из ОДНОГО потокосцепления
λ(x, I) (см. `inductance.CoilInductanceModel`), что сохраняет энергию:
V_c = Q / C
dQ/dt = -I
dI/dt = (V_c - I*R_total - I*(dL/dx)*v) / L(x)
dv/dt = F(x, I) / m
dI/dt = (V_c - I*R_total - (∂λ/∂x)*v) / (∂λ/∂I)
dv/dt = F(x, I) / m (F из coenergy того же λ)
dx/dt = v
"""
@@ -16,7 +16,6 @@ from dataclasses import dataclass
import numpy as np
from gausse.physics.force import force_on_slug_newtons
from gausse.physics.inductance import CoilInductanceModel
Q, I, X, V = range(4)
@@ -27,10 +26,32 @@ class StageCircuitParams:
capacitance_f: float
r_total_ohm: float
mass_kg: float
mu_eff: float
total_turns: int
coil_length_m: float
b_sat_tesla: float
# отражённое сопротивление вихревых токов снаряда; действует × 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:
"""R контура = провод+ESR+ключ (r_total) плюс вихревые потери снаряда, когда он в катушке."""
return params.r_total_ohm + params.r_eddy_coeff_ohm * inductance_model.overlap_fraction(x)
def derivatives(
@@ -40,14 +61,15 @@ def derivatives(
params: StageCircuitParams,
) -> list[float]:
q, current, x, v = state
l_x = inductance_model.l_of_x(x)
dl_dx = inductance_model.dl_dx(x)
dlambda_di = inductance_model.dlambda_di(x, current)
dlambda_dx = inductance_model.dlambda_dx(x, current)
r_eff = effective_resistance(inductance_model, params, x)
v_c = q / params.capacitance_f
d_q = -current
d_i = (v_c - current * params.r_total_ohm - current * dl_dx * v) / l_x
force = force_on_slug_newtons(current_a=current, dl_dx=dl_dx)
d_v = force / params.mass_kg
d_i = (v_c - current * r_eff - dlambda_dx * v) / dlambda_di
force = inductance_model.force_newtons(x, current)
d_v = (force - retarding_force_n(params, v)) / params.mass_kg
return [d_q, d_i, v, d_v]
@@ -57,3 +79,49 @@ def zero_current_crossing_event(t: float, state: np.ndarray, *_args) -> float:
zero_current_crossing_event.terminal = True
zero_current_crossing_event.direction = -1.0
def current_local_min_event(t: float, state: np.ndarray, inductance_model, params) -> float:
"""Первый локальный МИНИМУМ тока после пика — там снаряд начинает
«подкачивать» ток обратно (генераторный режим за центром катушки).
Тиристор проводит ОДИН импульс: как только ток, спадая, перестаёт падать
и пошёл бы вверх, ключ запирается (в реальности так и есть — за один
выстрел катушка стреляет один раз, конденсатор не перезаряжается за мкс).
Событие = dI/dt, ловим переход снизу вверх (direction=+1).
"""
q, current, x, v = state
dlambda_di = inductance_model.dlambda_di(x, current)
dlambda_dx = inductance_model.dlambda_dx(x, current)
r_eff = effective_resistance(inductance_model, params, x)
v_c = q / params.capacitance_f
return (v_c - current * r_eff - dlambda_dx * v) / dlambda_di
current_local_min_event.terminal = True
current_local_min_event.direction = 1.0
# Ток удержания ключа: тиристор сам закрывается, когда ток после пика падает
# ниже 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

@@ -4,3 +4,40 @@ MU_0 = 4e-7 * 3.141592653589793 # Гн/м, магнитная проницае
RESISTIVITY_COPPER_OHM_M = 1.68e-8 # при ~20°C
RESISTIVITY_ALUMINUM_OHM_M = 2.82e-8 # при ~20°C
# Импульсный (surge) ток намного выше паспортного среднего/непрерывного,
# который лежит в базе компонентов. Для одиночного коротко-импульсного
# выстрела coilgun применяем эти множители к max_current_a детали и
# отбраковываем конфигурацию, если пиковый ток разряда их превышает —
# иначе оптимизатор выдаёт "победителей", которые сожгли бы свой ключ.
# Значения — консервативные типовые (тиристор ITSM ~10× IT(AV), MOSFET
# импульсный ~4× непрерывного, IGBT ~3×, электролит surge ~5× ripple).
# Это оценки, а не паспортные 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

@@ -12,6 +12,8 @@ from dataclasses import dataclass
import numpy as np
from gausse.physics.constants import MU_0
INCH_M = 0.0254
@@ -76,12 +78,26 @@ def _sigmoid(z: np.ndarray) -> np.ndarray:
return 0.5 * (1 + np.tanh(z / 2))
def _ln_cosh(z: np.ndarray) -> np.ndarray:
"""Численно устойчивый ln(cosh z) = |z| + ln((1+e^{-2|z|})/2)."""
az = np.abs(z)
return az + np.log1p(np.exp(-2 * az)) - np.log(2.0)
@dataclass(frozen=True)
class CoilInductanceModel:
"""L(x) и аналитическая dL/dx для катушки с проходящим ферромагнитным снарядом.
"""Потокосцепление λ(x, I) катушки с ферромагнитным снарядом и насыщением.
x — положение центра снаряда относительно центра катушки (м), катушка
считается центрированной в x=0 в своей локальной системе координат.
x — положение центра снаряда относительно центра катушки (м).
Вклад железа в потокосцепление насыщается по току:
λ(x, I) = L_air·I + L_iron·overlap(x)·g(I),
g(I) = I_sat·tanh(I/I_sat), L_iron = L_air·(μ_eff1)
где I_sat — ток, при котором поле в железе достигает B_sat. И сила, и
наведённая ЭДС в контуре выводятся из ОДНОГО и того же λ (метод
coenergy), поэтому энергия сохраняется, а поле не улетает за B_sat.
В пределе малого тока (g(I)≈I) всё сводится к линейной модели
F = 0.5·I²·dL/dx.
"""
l_air_h: float
@@ -89,6 +105,25 @@ class CoilInductanceModel:
slug_length_m: float
mu_eff: float
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) * self.iron_fill_factor
@property
def saturation_current_a(self) -> float:
"""Ток, при котором поле в железе достигает B_sat. inf = без насыщения."""
if self.total_turns <= 0 or self.mu_eff <= 1.0 or self.b_sat_tesla >= 1e8:
return float("inf")
turns_per_m = self.total_turns / self.coil_length_m
return self.b_sat_tesla / (MU_0 * self.mu_eff * turns_per_m)
def _edges(self) -> tuple[float, float]:
half_span = (self.coil_length_m + self.slug_length_m) / 2
@@ -106,8 +141,55 @@ class CoilInductanceModel:
s2 = _sigmoid((e2 - x) / w)
return (s1 * s2 / w) * (s2 - s1)
# --- насыщающиеся токовые функции: g, g', G (интеграл g) ---
def _g(self, i: np.ndarray) -> np.ndarray:
i_sat = self.saturation_current_a
if not np.isfinite(i_sat):
return np.asarray(i, dtype=float)
return i_sat * np.tanh(i / i_sat)
def _g_prime(self, i: np.ndarray) -> np.ndarray:
i_sat = self.saturation_current_a
if not np.isfinite(i_sat):
return np.ones_like(np.asarray(i, dtype=float))
return 1.0 - np.tanh(i / i_sat) ** 2 # sech^2, устойчиво
def _g_integral(self, i: np.ndarray) -> np.ndarray:
i_sat = self.saturation_current_a
if not np.isfinite(i_sat):
return 0.5 * np.asarray(i, dtype=float) ** 2
return i_sat**2 * _ln_cosh(i / i_sat)
# --- линейные (ненасыщенные) L(x), dL/dx — для геометрических тестов ---
def l_of_x(self, x: np.ndarray) -> np.ndarray:
return self.l_air_h * (1 + (self.mu_eff - 1) * self.overlap_fraction(x))
def dl_dx(self, x: np.ndarray) -> np.ndarray:
return self.l_air_h * (self.mu_eff - 1) * self.d_overlap_dx(x)
return self.l_iron_coeff * self.d_overlap_dx(x)
# --- динамика с насыщением (используется контуром) ---
def dlambda_di(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
"""Дифференциальная индуктивность ∂λ/∂I для члена dI/dt."""
return self.l_air_h + self.l_iron_coeff * self.overlap_fraction(x) * self._g_prime(i)
def dlambda_dx(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
"""Пространственный член ∂λ/∂x для наведённой ЭДС."""
return self.l_iron_coeff * self.d_overlap_dx(x) * self._g(i)
def force_newtons(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
"""Сила из coenergy: F = ∂/∂x ∫₀ᴵ λ dI' = L_iron·overlap'(x)·G(I)."""
return self.l_iron_coeff * self.d_overlap_dx(x) * self._g_integral(i)
def magnetic_energy(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
"""Энергия магнитного поля W = ∫₀ᴵ i·(∂λ/∂i) di (с учётом насыщения).
= ½·L_air·I² + L_iron·overlap(x)·(I·g(I) G(I)). Нужна для точного
учёта остаточной энергии катушки при обрыве разряда (одиночный импульс).
"""
return 0.5 * self.l_air_h * i**2 + self.l_iron_coeff * self.overlap_fraction(x) * (
i * self._g(i) - self._g_integral(i)
)
def effective_field_current_a(self, i: float) -> float:
"""'Насыщенный' эффективный ток для оценки поля (поле ~ g(I), кап B_sat)."""
return float(self._g(np.asarray(float(i))))

View File

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

@@ -18,9 +18,18 @@ import numpy as np
SensorEvent = Callable[[float, np.ndarray], float]
# cosh(z)**2 переполняет float64 при |z| > ~355; клэмпим до этого аргумент
# sech^2, а не сам bump -- при |z|>=20 бамп уже ~1e-17, дальнейший рост z
# физически ничего не меняет (снаряд всё равно "не виден" датчику).
_SECH2_ARG_CLIP = 20.0
def _sech2(z: np.ndarray) -> np.ndarray:
return 1.0 / np.cosh(np.clip(z, -_SECH2_ARG_CLIP, _SECH2_ARG_CLIP)) ** 2
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
@@ -34,9 +43,9 @@ 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 = 1.0 / np.cosh((x - x_sensor_m) / width_m) ** 2
bump = _sech2((x - x_sensor_m) / width_m)
return sensitivity_v_per_mps * v * bump - threshold_v
event.terminal = True

View File

@@ -0,0 +1,205 @@
"""Анимация одного прогона: снаряд в трубе + ток катушек + скорость, всё во времени.
Честность по времени: кадры берутся на РАВНОМЕРНОЙ сетке по физическому
времени (раньше — по индексам массива, из-за чего плотные точки разряда
съедали все кадры, а подлёт между катушками «вырезался»). Всё событие
показывается целиком в замедлении, множитель замедления написан в кадре.
Результат эксперимента — в самом кадре:
- шапка: выходная скорость, КПД, энергия банок, масса снаряда, ступени;
- средняя панель: ток каждой катушки во времени (окна разрядов подкрашены);
- нижняя панель: скорость снаряда во времени;
- бегущий курсор на обеих панелях синхронен с положением снаряда в трубе.
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.animation as animation
import matplotlib.pyplot as plt
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):
"""Непрерывные t/x/v + ток по каждой ступени + окна разрядов.
Строится последовательно: подлёт (скорость почти постоянна на сантиметрах
межкатушечного зазора) + точные траектории разрядов из решателя.
Глобальная координата = центр катушки ступени + локальный discharge_x.
"""
n_stages = len(config.stages)
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
prev_v = None
for outcome in result.stage_outcomes:
r = outcome.result
if r.discharge_t is None or r.discharge_x is None:
continue
disch_global_x = outcome.global_coil_center_m + r.discharge_x
start_global_x = float(disch_global_x[0])
# подлёт от конца прошлого разряда до момента поджига этого
if prev_global_x is not None and prev_v and prev_v > 1e-6:
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
# окно разряда: точная траектория + ток именно этой катушки
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])
prev_v = float(r.discharge_v[-1]) if r.discharge_v is not None else outcome.entry_v_mps
if not seg_t:
raise ValueError("нет данных разряда для анимации")
times = np.concatenate(seg_t)
positions = np.concatenate(seg_x)
velocities = np.concatenate(seg_v)
currents = [np.concatenate(seg_i[s]) for s in range(n_stages)]
# страховка от немонотонности на стыках сегментов (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, 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]
t_ms = times * 1e3
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()
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):
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)
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):
# выкл -> светло-серый; максимум тока -> яркое золото (свечение поля)
t = min(max(intensity, 0.0), 1.0)
off = np.array([0.83, 0.83, 0.83])
on = np.array([1.0, 0.78, 0.0])
return tuple(off + (on - off) * t)
def update(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_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)
anim.save(out_path, writer=animation.PillowWriter(fps=fps))
plt.close(fig)
return out_path

106
src/gausse/report/bom.py Normal file
View File

@@ -0,0 +1,106 @@
"""Спецификация деталей (BOM) для найденной конфигурации — с ценами и итогом."""
from dataclasses import dataclass
from gausse.components.database import ComponentDatabase
from gausse.physics.inductance import winding_geometry
from gausse.sim.coilgun import CoilgunConfig
@dataclass
class BomLine:
stage_index: int | None # None для позиций, общих для всей пушки (снаряд)
part: str
quantity: str
unit_price_rub: float
total_price_rub: float
note: str = ""
url: str | None = None # ссылка на магазин, где купить
def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
lines: list[BomLine] = []
for i, stage in enumerate(config.stages):
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)
wire_length_m = geometry.total_wire_length_m
lines.append(
BomLine(
stage_index=i,
part=f"Провод {stage.wire.part_id}",
quantity=f"{wire_length_m:.2f} м",
unit_price_rub=stage.wire.price_per_m,
total_price_rub=wire_length_m * stage.wire.price_per_m,
note=f"{geometry.total_turns} витков ({stage.turns_per_layer}x{stage.layers}), ⌀{stage.wire.gauge_mm}мм",
url=stage.wire.url,
)
)
cap = stage.capacitor
cap_base = getattr(cap, "base_part_number", getattr(cap, "part_number", "?"))
n_series = getattr(cap, "n_series", 1)
n_parallel = getattr(cap, "n_parallel", 1)
count = n_series * n_parallel
unit_price = cap.price / count if count else cap.price
lines.append(
BomLine(
stage_index=i,
part=f"Конденсатор {cap_base}",
quantity=f"{count} шт ({n_series}посл × {n_parallel}пар)",
unit_price_rub=unit_price,
total_price_rub=cap.price,
note=f"батарея {cap.capacitance_uf:.0f}мкФ {cap.voltage_v:.0f}В, заряд {stage.charge_voltage_v:.0f}В, макс.ток {cap.max_current_a:.0f}А",
url=getattr(cap, "url", None),
)
)
lines.append(
BomLine(
stage_index=i,
part=f"Ключ {stage.switch.part_number}",
quantity="1 шт",
unit_price_rub=stage.switch.price,
total_price_rub=stage.switch.price,
note=f"{stage.switch.kind}, {stage.switch.max_current_a:.0f}А / {stage.switch.max_voltage_v:.0f}В",
url=stage.switch.url,
)
)
lines.append(
BomLine(
stage_index=i,
part=f"Датчик {stage.sensor.part_number}",
quantity="1 шт",
unit_price_rub=stage.sensor.price,
total_price_rub=stage.sensor.price,
note=stage.sensor.kind,
url=stage.sensor.url,
)
)
mass_kg = config.projectile.mass_kg
lines.append(
BomLine(
stage_index=None,
part=f"Снаряд: {config.projectile.material.name}",
quantity=f"{mass_kg * 1000:.1f} г",
unit_price_rub=config.projectile.material.price_per_kg,
total_price_rub=mass_kg * config.projectile.material.price_per_kg,
note=f"{config.projectile.diameter_m * 1000:.1f}мм x {config.projectile.length_m * 1000:.1f}мм",
url=config.projectile.material.url,
)
)
return lines
def total_cost_rub(lines: list[BomLine]) -> float:
return sum(line.total_price_rub for line in lines)
def render_bom_text(lines: list[BomLine]) -> str:
rows = ["Ступень\tДеталь\tКол-во\tЦена/ед\tИтого\tПримечание\tКупить"]
for line in lines:
stage_label = "-" if line.stage_index is None else str(line.stage_index)
rows.append(
f"{stage_label}\t{line.part}\t{line.quantity}\t{line.unit_price_rub:.2f}\t"
f"{line.total_price_rub:.2f}\t{line.note}\t{line.url or ''}"
)
rows.append(f"\t\t\t\tИТОГО: {total_cost_rub(lines):.2f}\t\t")
return "\n".join(rows)

105
src/gausse/report/plots.py Normal file
View File

@@ -0,0 +1,105 @@
"""Графики одного прогона: ток/поле каждой катушки по времени, скорость по положению.
Использует Agg-бэкенд matplotlib явно — код должен работать без дисплея
(в Docker/на сервере), а не только на машине разработчика.
"""
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
import numpy as np
from gausse.physics.force import solenoid_field_estimate_tesla
from gausse.sim.coilgun import CoilgunResult
def _stage_global_time(outcome) -> np.ndarray | None:
r = outcome.result
if r.discharge_t is None:
return None
return outcome.time_offset_s + (r.t_fire_s or 0.0) + r.discharge_t
def _legend_or_no_data_note(ax) -> None:
if ax.get_legend_handles_labels()[0]:
ax.legend()
else:
ax.text(
0.5, 0.5, "нет данных: прогон нереализуем ни на одной ступени",
ha="center", va="center", transform=ax.transAxes,
)
def plot_stage_currents(result: CoilgunResult):
fig, ax = plt.subplots()
for outcome in result.stage_outcomes:
t = _stage_global_time(outcome)
if t is None:
continue
ax.plot(t * 1e3, outcome.result.discharge_i, label=f"ступень {outcome.stage_index}")
ax.set_xlabel("время, мс")
ax.set_ylabel("ток катушки, А")
ax.set_title("Ток разряда по ступеням")
_legend_or_no_data_note(ax)
fig.tight_layout()
return fig
def plot_stage_fields(result: CoilgunResult):
"""Оценка магнитного поля катушки B(t) = MU_0*mu_eff*n*I(t) по ступеням."""
fig, ax = plt.subplots()
for outcome in result.stage_outcomes:
r = outcome.result
t = _stage_global_time(outcome)
if t is None or r.mu_eff is None:
continue
b_field = np.array(
[
solenoid_field_estimate_tesla(r.mu_eff, r.total_turns, r.coil_length_m, i)
for i in r.discharge_i
]
)
ax.plot(t * 1e3, b_field, label=f"ступень {outcome.stage_index}")
ax.set_xlabel("время, мс")
ax.set_ylabel("оценка поля в катушке, Тл")
ax.set_title("Магнитное поле по ступеням (грубая соленоидная оценка)")
_legend_or_no_data_note(ax)
fig.tight_layout()
return fig
def plot_velocity_vs_position(result: CoilgunResult):
fig, ax = plt.subplots()
for outcome in result.stage_outcomes:
r = outcome.result
if r.discharge_x is None:
continue
global_x = outcome.global_coil_center_m + r.discharge_x
ax.plot(global_x * 1e3, r.discharge_v, label=f"ступень {outcome.stage_index}")
ax.set_xlabel("положение снаряда, мм (сквозная координата)")
ax.set_ylabel("скорость, м/с")
ax.set_title("Скорость снаряда по положению")
_legend_or_no_data_note(ax)
fig.tight_layout()
return fig
def save_all(result: CoilgunResult, out_dir) -> list[str]:
out_dir = Path(out_dir)
out_dir.mkdir(parents=True, exist_ok=True)
saved = []
for name, builder in (
("current.png", plot_stage_currents),
("field.png", plot_stage_fields),
("velocity_vs_position.png", plot_velocity_vs_position),
):
fig = builder(result)
path = out_dir / name
fig.savefig(path, dpi=120)
plt.close(fig)
saved.append(str(path))
return saved

View File

@@ -0,0 +1,76 @@
"""Человеко-читаемая + JSON сводка по прогону — включая явные ограничения модели."""
import json
from gausse.components.database import ComponentDatabase
from gausse.report.bom import build_bom, total_cost_rub
from gausse.sim.coilgun import CoilgunConfig, CoilgunResult
MODEL_LIMITATIONS = [
"Насыщение сердечника не встроено в динамику (только диагностика "
"StageResult.saturation_warning) -- см. историю разработки Этапа 3.",
"Вихревые токи, гистерезис и скин-эффект провода на высокой частоте не учтены.",
"Профиль перекрытия катушка/снаряд L(x) -- сглаженная аппроксимация "
"(tanh-функция), не точная картина поля из FEA-расчёта.",
"Часть цен в базе компонентов -- оценка, а не подтверждённая розничная "
"цена (см. поле `source` каждой записи в components/data/*.json).",
"Нагрев провода/конденсаторов при повторных выстрелах не моделируется "
"(рассчитан одиночный холодный выстрел).",
]
def build_summary(result: CoilgunResult, config: CoilgunConfig, db: ComponentDatabase) -> dict:
bom_lines = build_bom(config, db)
saturation_warnings = [
outcome.stage_index
for outcome in result.stage_outcomes
if outcome.result.saturation_warning
]
return {
"feasible": result.feasible,
"reason": result.reason,
"failed_stage_index": result.failed_stage_index,
"n_stages": len(config.stages),
"exit_velocity_mps": result.exit_v_mps,
"exit_kinetic_energy_j": result.exit_kinetic_energy_j,
"efficiency": result.efficiency,
"total_energy_in_j": result.total_energy_in_j,
"total_energy_dissipated_j": result.total_energy_dissipated_j,
"total_kinetic_energy_delta_j": result.total_kinetic_energy_delta_j,
"cost_rub": total_cost_rub(bom_lines),
"sensor_types_by_stage": [s.sensor.kind for s in config.stages],
"stages_with_saturation_warning": saturation_warnings,
"model_limitations": MODEL_LIMITATIONS,
}
def render_summary_text(summary: dict) -> str:
lines = []
if summary["feasible"]:
lines.append("Результат: РЕАЛИЗУЕМО")
lines.append(f" Ступеней: {summary['n_stages']}")
lines.append(f" Скорость на выходе: {summary['exit_velocity_mps']:.2f} м/с")
lines.append(f" КПД: {summary['efficiency'] * 100:.2f}%")
lines.append(f" Энергия (вход/потери/кинетика): "
f"{summary['total_energy_in_j']:.2f} / "
f"{summary['total_energy_dissipated_j']:.2f} / "
f"{summary['total_kinetic_energy_delta_j']:.2f} Дж")
else:
lines.append("Результат: НЕ РЕАЛИЗУЕМО")
lines.append(f" Провал на ступени {summary['failed_stage_index']}: {summary['reason']}")
lines.append(f" Стоимость деталей: {summary['cost_rub']:.2f}")
lines.append(f" Типы датчиков по ступеням: {summary['sensor_types_by_stage']}")
if summary["stages_with_saturation_warning"]:
lines.append(
f" ВНИМАНИЕ: возможное насыщение сердечника на ступенях "
f"{summary['stages_with_saturation_warning']} (см. ограничения модели)"
)
lines.append("")
lines.append("Ограничения модели:")
for item in summary["model_limitations"]:
lines.append(f" - {item}")
return "\n".join(lines)
def render_summary_json(summary: dict) -> str:
return json.dumps(summary, ensure_ascii=False, indent=2)

View File

@@ -34,6 +34,8 @@ class StageOutcome:
result: StageResult
global_coil_center_m: float
time_offset_s: float
entry_x_m: float
entry_v_mps: float
@dataclass
@@ -76,6 +78,8 @@ def run_coilgun(config: CoilgunConfig) -> CoilgunResult:
result=result,
global_coil_center_m=global_coil_center_m,
time_offset_s=time_offset_s,
entry_x_m=entry_x_m,
entry_v_mps=entry_v_mps,
)
)
@@ -105,7 +109,13 @@ def run_coilgun(config: CoilgunConfig) -> CoilgunResult:
entry_x_m, entry_v_mps = result.exit_x_m, result.exit_v_mps
exit_kinetic_energy_j = 0.5 * config.projectile.mass_kg * entry_v_mps**2
efficiency = exit_kinetic_energy_j / total_energy_in_j if total_energy_in_j > 0 else None
# КПД = энергия, добавленная катушками (kinetic_energy_delta), а не вся
# exit-кинетическая энергия -- иначе фиксированный внешний "толчок"
# initial_v_mps (не учтённый в energy_in) может дать КПД > 100%, что
# физически бессмысленно. per-stage энергобаланс (energy_in =
# remaining_cap + dissipated + kinetic_delta) гарантирует
# kinetic_delta <= energy_in, так что этот КПД всегда <= 1.
efficiency = total_kinetic_energy_delta_j / total_energy_in_j if total_energy_in_j > 0 else None
return CoilgunResult(
feasible=True,

View File

@@ -28,7 +28,16 @@ from gausse.physics.inductance import (
effective_permeability,
winding_geometry,
)
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 (
inductive_signal_peak_v,
make_inductive_sensor_event,
@@ -83,6 +92,9 @@ class StageResult:
kinetic_energy_delta_j: float | None = None
saturation_warning: bool = False
peak_field_estimate_tesla: float | None = None
mu_eff: float | None = None
total_turns: int | None = None
coil_length_m: float | None = None
discharge_t: np.ndarray | None = None
discharge_q: np.ndarray | None = None
discharge_i: np.ndarray | None = None
@@ -109,8 +121,116 @@ 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)
class StagePhysics:
"""Физика ступени: индуктивная модель + параметры контура + геометрия.
Один источник истины для CPU-пути (run_stage) и GPU-батча — оба строят
физику через `build_stage_physics`, чтобы числа не расходились.
"""
inductance_model: CoilInductanceModel
circuit_params: StageCircuitParams
geometry: object
r_total_ohm: float
r_eddy_coeff_ohm: float
capacitance_f: float
mu_eff: float
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_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)
r_eddy_coeff = eddy_reflected_resistance_ohm(
slug_radius_m=projectile.diameter_m / 2,
slug_length_m=projectile.length_m,
slug_resistivity_ohm_m=projectile.material.resistivity_ohm_m,
mu_eff=mu_eff,
total_turns=geometry.total_turns,
coil_length_m=geometry.coil_length_m,
char_omega_rad_s=char_omega,
)
# доля сечения катушки, реально занятая железом (см. inductance.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,
slug_length_m=projectile.length_m,
mu_eff=mu_eff,
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=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,
circuit_params=circuit_params,
geometry=geometry,
r_total_ohm=r_total_ohm,
r_eddy_coeff_ohm=r_eddy_coeff,
capacitance_f=capacitance_f,
mu_eff=mu_eff,
r_wire_dc_ohm=r_wire_dc,
r_ac_factor=r_ac_factor,
)
def run_stage(
@@ -120,27 +240,28 @@ def run_stage(
projectile: ProjectileConfig,
) -> StageResult:
mass_kg = projectile.mass_kg
phys = build_stage_physics(stage, projectile)
geometry = phys.geometry
r_total_ohm = phys.r_total_ohm
r_eddy_coeff = phys.r_eddy_coeff_ohm
capacitance_f = phys.capacitance_f
mu_eff = phys.mu_eff
inductance_model = phys.inductance_model
wire_od_m = stage.wire.insulation_od_mm / 1000
geometry = winding_geometry(stage.tube_od_m, wire_od_m, stage.turns_per_layer, stage.layers)
r_wire = _wire_resistance_ohm(stage.wire, geometry.total_wire_length_m)
capacitance_f = stage.capacitor.capacitance_uf * 1e-6
l_air_h = air_core_inductance_wheeler(
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
)
r_switch = _switch_equivalent_resistance_ohm(
stage.switch, capacitance_f, l_air_h, stage.charge_voltage_v
)
r_total_ohm = r_wire + r_switch + stage.capacitor.esr_ohm
demag = demagnetizing_factor_prolate(projectile.aspect_ratio)
mu_eff = effective_permeability(projectile.material.mu_r, demag)
inductance_model = CoilInductanceModel(
l_air_h=l_air_h,
coil_length_m=geometry.coil_length_m,
slug_length_m=projectile.length_m,
mu_eff=mu_eff,
smoothing_width_m=wire_od_m,
# Вырожденный контур — вне области применимости модели: при постоянной
# времени 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
@@ -165,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,
@@ -186,40 +314,57 @@ def run_stage(
x_fire_m = x_at_sensor + v_at_sensor * fire_delay_s
v_fire_mps = v_at_sensor
circuit_params = StageCircuitParams(
capacitance_f=capacitance_f,
r_total_ohm=r_total_ohm,
mass_kg=mass_kg,
mu_eff=mu_eff,
total_turns=geometry.total_turns,
coil_length_m=geometry.coil_length_m,
b_sat_tesla=projectile.material.b_sat_tesla,
)
circuit_params = phys.circuit_params
q0 = capacitance_f * stage.charge_voltage_v
# Тиристор проводит ОДИН импульс: разряд обрывается на первом возврате тока
# к нулю ИЛИ на первом локальном минимуме тока (снаряд начал подкачивать ток
# обратно) — что раньше. Иначе модель гоняла бы катушку несколько раз за
# выстрел, что физически невозможно (конденсатор не перезарядить за мкс).
discharge_sol = solve_ivp(
circuit.derivatives,
(0.0, stage.discharge_time_budget_s),
[q0, 0.0, x_fire_m, v_fire_mps],
args=(inductance_model, circuit_params),
events=circuit.zero_current_crossing_event,
events=(
circuit.zero_current_crossing_event,
circuit.current_local_min_event,
# ключ закрывается сам при токе ниже удержания (см. circuit.py)
circuit.make_current_decay_event(),
),
rtol=1e-8,
atol=1e-11,
)
if len(discharge_sol.t_events[0]) == 0:
# какое из событий оборвало разряд первым
term_candidates = []
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:
return StageResult(
feasible=False,
reason="разряд не скоммутировался (ток не вернулся к нулю) в пределах временного бюджета",
t_sensor_s=t_sensor_s,
t_fire_s=t_sensor_s + fire_delay_s,
)
q_final, i_final, x_final, v_final = discharge_sol.y_events[0][0]
term_candidates.sort(key=lambda p: p[0])
q_final, i_final, x_final, v_final = term_candidates[0][1]
energy_in_j = 0.5 * capacitance_f * stage.charge_voltage_v**2
energy_remaining_cap_j = q_final**2 / (2 * capacitance_f)
energy_dissipated_j = float(
np.trapezoid(discharge_sol.y[1] ** 2 * r_total_ohm, discharge_sol.t)
# потери = I²·R_eff(x), где R_eff включает вихревые потери снаряда (× overlap)
overlap_during = inductance_model.overlap_fraction(discharge_sol.y[2])
r_eff_during = r_total_ohm + r_eddy_coeff * overlap_during
resistive_diss = float(np.trapezoid(discharge_sol.y[1] ** 2 * r_eff_during, discharge_sol.t))
# при обрыве на минимуме тока в катушке остаётся энергия магнитного поля —
# её гасит обратный диод (freewheel), учитываем как потери, чтобы энергия
# сходилась. Считаем ТОЧНО (с насыщением), а не ½LI².
residual_inductor_j = float(inductance_model.magnetic_energy(x_final, i_final))
# работа трения о трубку и воздуха ЗА РАЗРЯД: 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
@@ -228,11 +373,33 @@ def run_stage(
# материала снаряда — численные КПД/скорость в этом случае, вероятно,
# завышены относительно реального железа.
peak_current_a = float(np.max(np.abs(discharge_sol.y[1])))
# поле оцениваем по НАСЫЩЕННОМУ эффективному току g(I) — теперь оно не
# улетает за B_sat, а упирается в него (см. inductance.py).
effective_field_current_a = inductance_model.effective_field_current_a(peak_current_a)
peak_field_estimate_tesla = solenoid_field_estimate_tesla(
mu_eff, geometry.total_turns, geometry.coil_length_m, peak_current_a
mu_eff, geometry.total_turns, geometry.coil_length_m, effective_field_current_a
)
saturation_warning = (
saturation_scale(peak_field_estimate_tesla, projectile.material.b_sat_tesla) < 1.0
# предупреждение теперь означает реальный заход в насыщение (ток > I_sat)
saturation_warning = peak_current_a > inductance_model.saturation_current_a
# Жёсткая отбраковка по импульсному току: полупроводниковый ключ реально
# выходит из строя за пределами surge-рейтинга, а электролит перегревается.
# Конфигурация, где пиковый ток разряда это превышает, — не пушка, а
# уничтоженная деталь на первом выстреле, поэтому feasible=False.
# Ключ — жёсткий предел: полупроводник реально выходит из строя за surge.
# Батарея конденсаторов — только предупреждение (см. build_detail):
# электролиты переносят высокий импульсный ток, а в базе — паспортный
# непрерывный/ripple рейтинг, поэтому жёстко браковать по нему нечестно.
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} А, даташит/surge)"
),
t_sensor_s=t_sensor_s,
t_fire_s=t_sensor_s + fire_delay_s,
)
return StageResult(
@@ -247,6 +414,9 @@ def run_stage(
kinetic_energy_delta_j=kinetic_after_j - kinetic_before_j,
saturation_warning=saturation_warning,
peak_field_estimate_tesla=peak_field_estimate_tesla,
mu_eff=mu_eff,
total_turns=geometry.total_turns,
coil_length_m=geometry.coil_length_m,
discharge_t=discharge_sol.t,
discharge_q=discharge_sol.y[0],
discharge_i=discharge_sol.y[1],

View File

@@ -0,0 +1,298 @@
"""SQLite-хранилище прогонов: WAL-режим + однопроцессный писатель.
Множество воркеров (Stage 6, `ProcessPoolExecutor`) не пишут в SQLite
напрямую — каждый кладёт `RunRecord` в `multiprocessing.Queue`, а
единственный процесс-писатель (`run_writer_process`) читает очередь и
пишет в базу последовательно. Это исключает конкурентную запись в один
файл SQLite, которая иначе могла бы повредить базу или потерять прогоны.
"""
import sqlite3
import sys
from dataclasses import asdict, dataclass, fields
from pathlib import Path
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"])
return row
def _row_to_record(row: sqlite3.Row) -> RunRecord:
data = dict(row)
data["feasible"] = bool(data["feasible"])
return RunRecord(**data)
def insert_run(conn: sqlite3.Connection, record: RunRecord) -> None:
insert_runs(conn, [record])
def insert_runs(conn: sqlite3.Connection, records: list) -> None:
"""Пишет пачку RunRecord/StatsOnly одной транзакцией: строки runs — только
для RunRecord, агрегаты дашборда — по ВСЕМ элементам пачки (либо всё,
либо ничего — иначе цифры на морде разъедутся с таблицей)."""
if not records:
return
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()
def count_runs(conn: sqlite3.Connection, feasible: bool | None = None) -> int:
if feasible is None:
cursor = conn.execute("SELECT COUNT(*) FROM runs")
else:
cursor = conn.execute("SELECT COUNT(*) FROM runs WHERE feasible = ?", (int(feasible),))
return cursor.fetchone()[0]
def fetch_run_by_id(conn: sqlite3.Connection, run_id: str) -> RunRecord | None:
"""Одна запись по PRIMARY KEY — быстро (без загрузки всей таблицы)."""
conn.row_factory = sqlite3.Row
row = conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
return _row_to_record(row) if row is not None else None
def fetch_runs(
conn: sqlite3.Connection,
feasible: bool | None = None,
min_efficiency: float | None = None,
order_by_efficiency_desc: bool = False,
order_by_velocity_desc: bool = False,
limit: int | None = None,
) -> list[RunRecord]:
conn.row_factory = sqlite3.Row
# топы дашборда: принуждаем частичный 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 += f" AND feasible = {int(feasible)}"
if min_efficiency is not None:
query += " AND efficiency >= ?"
params.append(min_efficiency)
if order_by_velocity_desc:
query += " ORDER BY exit_velocity_mps DESC"
elif order_by_efficiency_desc:
query += " ORDER BY efficiency DESC"
if limit is not None:
query += " LIMIT ?"
params.append(limit)
cursor = conn.execute(query, params)
return [_row_to_record(row) for row in cursor.fetchall()]
def run_writer_process(queue, db_path: Path) -> None:
"""Читает `RunRecord`/`StatsOnly` из очереди и пишет в SQLite до None.
Предназначен для запуска как отдельный `multiprocessing.Process` —
единственный writer на файл базы, пока воркеры sweep/evolve только
кладут результаты в очередь.
ПАЧКАМИ, не по одной: коммит 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:
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_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

@@ -0,0 +1,82 @@
"""Схема таблицы `runs` + агрегаты дашборда.
Честность цифр — через агрегаты: счётчики/гистограмма/причины (stats_*)
инкрементируются для КАЖДОЙ оценки, включая нереализуемые, в той же
транзакции писателя. А вот полные СТРОКИ в `runs` пишутся по-разному:
- sweep/manual: каждая оценка — строка (исследовательский режим);
- evolve (с 2026-07-12): строка — только когда лучший геном острова
УЛУЧШИЛСЯ (+ полировка); рядовые миллионы геномов поколения идут в базу
как StatsOnly-инкременты счётчиков. Хранить их незачем («сохраняй то,
что нужно для эволюции»), а вставка каждого в HDD-базу с 6 индексами
была узким местом: GPU считал цикл ~23 мин, дозапись шла часами.
Нереализуемые не приукрашиваются: у строк feasible=0 + честная причина,
у агрегатов — счётчик причин отказов.
"""
from dataclasses import dataclass
CREATE_RUNS_TABLE_SQL = """
CREATE TABLE IF NOT EXISTS runs (
run_id TEXT PRIMARY KEY,
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,
failed_stage_index INTEGER,
efficiency REAL,
exit_velocity_mps REAL,
cost_rub REAL,
energy_breakdown_json TEXT,
model_version TEXT NOT NULL
)
"""
CREATE_INDEXES_SQL = [
"CREATE INDEX IF NOT EXISTS idx_runs_feasible ON runs(feasible)",
"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)",
]
@dataclass(frozen=True)
class RunRecord:
run_id: str
timestamp: str
search_mode: str # "sweep" | "evolve" | "manual"
genome_json: str
decoded_summary_json: str
feasible: bool
model_version: str
infeasible_reason: str | None = None
failed_stage_index: int | None = None
efficiency: float | None = None
exit_velocity_mps: float | None = None
cost_rub: float | None = None
energy_breakdown_json: str | None = None

View File

206
src/gausse/web/page.py Normal file
View File

@@ -0,0 +1,206 @@
"""Самодостаточная HTML-страница дашборда (inline CSS+JS, без внешних ресурсов)."""
PAGE_HTML = r"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>gausse — ход эксперимента</title>
<style>
:root { color-scheme: dark; }
* { box-sizing: border-box; }
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
background:#0e1116; color:#e6edf3; font-size:14px; }
header { padding:14px 20px; background:#161b22; border-bottom:1px solid #30363d;
display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
header h1 { font-size:16px; margin:0; font-weight:600; }
.muted { color:#8b949e; }
.wrap { padding:20px; max-width:1200px; margin:0 auto; }
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:20px; }
.card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:14px; }
.card .k { font-size:12px; color:#8b949e; text-transform:uppercase; letter-spacing:.04em; }
.card .v { font-size:26px; font-weight:700; margin-top:4px; }
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
@media (max-width:840px){ .grid2 { grid-template-columns:1fr; } }
section h2 { font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:#8b949e; margin:0 0 10px; }
table { width:100%; border-collapse:collapse; font-variant-numeric:tabular-nums; }
th,td { text-align:left; padding:7px 8px; border-bottom:1px solid #21262d; }
th { color:#8b949e; font-weight:600; font-size:12px; }
tr.clk:hover { background:#1c2230; cursor:pointer; }
.tgl { font-size:11px; padding:2px 8px; margin-left:6px; background:#21262d; color:#8b949e;
border:1px solid #30363d; border-radius:6px; cursor:pointer; text-transform:none; letter-spacing:0; }
.tgl.active { background:#238636; color:#fff; border-color:#238636; }
.bar { height:16px; background:#238636; border-radius:3px; }
.hist td { padding:3px 8px; }
.log { background:#0a0d12; border:1px solid #30363d; border-radius:8px; padding:12px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px;
white-space:pre-wrap; max-height:320px; overflow:auto; line-height:1.5; }
.pill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; background:#21262d; }
#detail { margin-top:20px; background:#161b22; border:1px solid #30363d; border-radius:8px; padding:16px; display:none; }
#detail pre { white-space:pre-wrap; font-family: ui-monospace, monospace; font-size:12px; margin:0;
max-height:340px; overflow:auto; background:#0a0d12; padding:10px; border-radius:6px; }
.close { float:right; cursor:pointer; color:#8b949e; }
a { color:#58a6ff; }
.plots { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:12px; margin:12px 0; }
.plots img { width:100%; border:1px solid #30363d; border-radius:6px; background:#fff; }
.btn { display:inline-block; margin:8px 0; padding:6px 12px; background:#238636; color:#fff;
border:none; border-radius:6px; cursor:pointer; font-size:13px; }
#gifbox img { max-width:100%; border:1px solid #30363d; border-radius:6px; margin-top:8px; }
</style>
</head>
<body>
<header>
<h1>⚡ gausse — ход эксперимента</h1>
<span class="muted" id="updated">загрузка…</span>
<span class="muted" id="err" style="color:#f85149"></span>
</header>
<div class="wrap">
<div class="cards">
<div class="card"><div class="k">Всего прогонов</div><div class="v" id="c-total">—</div></div>
<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-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">
<section>
<h2>Топ конфигураций
<button class="tgl" id="tab-eff" onclick="setRank('eff')">по КПД</button>
<button class="tgl" id="tab-vel" onclick="setRank('vel')">по скорости</button>
</h2>
<table>
<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>
</section>
<section>
<h2>Распределение КПД (реализуемые)</h2>
<table class="hist"><tbody id="hist"></tbody></table>
<h2 style="margin-top:18px">Причины отказа</h2>
<table><tbody id="reasons"></tbody></table>
</section>
</div>
<section style="margin-top:22px">
<h2>Лог процесса</h2>
<div class="log" id="log">—</div>
</section>
<div id="detail"><span class="close" onclick="document.getElementById('detail').style.display='none'">✕ закрыть</span>
<h2 id="d-title"></h2>
<div class="plots" id="d-plots"></div>
<button class="btn" id="d-gifbtn">▶ показать анимацию пролёта (GIF)</button>
<div id="gifbox"></div>
<h2 style="margin-top:14px">Полная детализация (расстояния, батарея, физика, результат)</h2>
<pre id="d-body"></pre>
</div>
</div>
<script>
const fmtPct = x => x==null ? "" : (x*100).toFixed(2)+"%";
const fmt = (x,d=1) => x==null ? "" : Number(x).toFixed(d);
let rankMode = 'eff'; // 'eff' | 'vel'
let lastOverview = null;
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 class="muted" style="white-space:nowrap">${fmtWhen(t.timestamp)}</td>`;
tr.onclick=()=>showDetail(t.run_id);
top.appendChild(tr);
});
}
async function refresh(){
try {
const r = await fetch('/api/overview'); const o = await r.json();
lastOverview = o;
document.getElementById('err').textContent = o.error ? ('ошибка: '+o.error) : '';
document.getElementById('c-total').textContent = o.total ?? 0;
document.getElementById('c-feas').textContent = o.feasible ?? 0;
document.getElementById('c-infeas').textContent = o.infeasible ?? 0;
document.getElementById('c-pct').textContent = (o.feasible_pct??0).toFixed(1)+"%";
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='';
const mx=Math.max(1,...(o.efficiency_histogram||[0]));
(o.efficiency_histogram||[]).forEach((c,i)=>{
const tr=document.createElement('tr');
tr.innerHTML=`<td class="muted" style="width:70px">${i*10}${i*10+10}%</td>`+
`<td><div class="bar" style="width:${Math.max(2,100*c/mx)}%"></div></td>`+
`<td class="muted" style="width:60px;text-align:right">${c}</td>`;
hist.appendChild(tr);
});
const rs=document.getElementById('reasons'); rs.innerHTML='';
(o.infeasible_reasons||[]).forEach(x=>{
const tr=document.createElement('tr');
tr.innerHTML=`<td class="muted">${x.reason}</td><td style="text-align:right">${x.count}</td>`;
rs.appendChild(tr);
});
document.getElementById('log').textContent = (o.log_tail||[]).join('\n') || '(лог пуст)';
document.getElementById('updated').textContent = 'обновлено '+new Date().toLocaleTimeString();
} catch(e){ document.getElementById('err').textContent='нет связи с сервером'; }
}
async function showDetail(runId){
const r = await fetch('/api/run/'+runId); const d = await r.json();
document.getElementById('d-title').textContent = 'Прогон '+runId.slice(0,8)+
''+(d.feasible?('КПД '+fmtPct(d.efficiency)+', '+fmt(d.exit_velocity_mps,1)+' м/с'):'НЕ реализуемо');
document.getElementById('d-body').textContent = JSON.stringify(d, null, 2);
// графики строятся на лету эндпоинтом /api/run/<id>/plot/<kind>.png
const plots = document.getElementById('d-plots'); plots.innerHTML='';
['velocity','field','current'].forEach(kind=>{
const img=document.createElement('img');
img.alt=kind; img.loading='lazy'; img.src='/api/run/'+runId+'/plot/'+kind+'.png?t='+Date.now();
plots.appendChild(img);
});
// GIF тяжелее — грузим только по кнопке
document.getElementById('gifbox').innerHTML='';
const gifBtn=document.getElementById('d-gifbtn');
gifBtn.onclick=()=>{
document.getElementById('gifbox').innerHTML='<p class="muted">рендер анимации…</p>';
const g=document.createElement('img'); g.src='/api/run/'+runId+'/anim.gif?t='+Date.now();
g.onload=()=>{ document.getElementById('gifbox').innerHTML=''; document.getElementById('gifbox').appendChild(g); };
g.onerror=()=>{ document.getElementById('gifbox').innerHTML='<p class="muted">не удалось построить анимацию</p>'; };
};
const el=document.getElementById('detail'); el.style.display='block'; el.scrollIntoView({behavior:'smooth'});
}
setRank('eff'); refresh(); setInterval(refresh, 3000);
</script>
</body>
</html>
"""

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

67
src/gausse/web/render.py Normal file
View File

@@ -0,0 +1,67 @@
"""Рендер графиков/анимации прогона на лету для веб-морды.
По run_id достаём геном из базы, восстанавливаем конфигурацию, гоняем
симуляцию и строим картинку. Matplotlib (Agg) не потокобезопасен на уровне
своей глобальной машины состояний, а сервер многопоточный — поэтому весь
рендер сериализуется одним локом.
"""
import io
import tempfile
import threading
from pathlib import Path
import matplotlib
matplotlib.use("Agg")
import matplotlib.pyplot as plt
from gausse.report.animate import animate_run
from gausse.report.plots import plot_stage_currents, plot_stage_fields, plot_velocity_vs_position
from gausse.web.rebuild import rebuild_run
_render_lock = threading.Lock()
_PLOT_BUILDERS = {
"velocity": plot_velocity_vs_position,
"field": plot_stage_fields,
"current": plot_stage_currents,
}
def _rebuild_result(db_path: Path, run_id: str):
rebuilt = rebuild_run(db_path, run_id) # общий кэш симуляций с run_detail
if rebuilt is None:
return None
_row, _genome, config, result, _ix, _iv = rebuilt
return config, result
def render_plot_png(db_path: Path, run_id: str, kind: str) -> bytes | None:
builder = _PLOT_BUILDERS.get(kind)
if builder is None:
return None
with _render_lock:
rebuilt = _rebuild_result(db_path, run_id)
if rebuilt is None:
return None
_config, result = rebuilt
fig = builder(result)
buf = io.BytesIO()
fig.savefig(buf, format="png", dpi=110)
plt.close(fig)
return buf.getvalue()
def render_animation_gif(db_path: Path, run_id: str) -> bytes | None:
with _render_lock:
rebuilt = _rebuild_result(db_path, run_id)
if rebuilt is None:
return None
config, result = rebuilt
if not result.stage_outcomes:
return None
with tempfile.NamedTemporaryFile(suffix=".gif", delete=True) as tmp:
animate_run(result, config, tmp.name, fps=20)
return Path(tmp.name).read_bytes()

101
src/gausse/web/server.py Normal file
View File

@@ -0,0 +1,101 @@
"""Веб-морда `gausse serve`: дашборд хода эксперимента поверх SQLite.
Только stdlib (`http.server`) — без новых зависимостей. Read-only: сервер
лишь читает базу и отдаёт JSON + одну самодостаточную HTML-страницу, которая
опрашивает /api/overview раз в несколько секунд. Ничего не пишет в базу,
поэтому безопасно запускать параллельно с идущим sweep/evolve.
"""
import json
from functools import partial
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import urlparse
from gausse.web.page import PAGE_HTML
from gausse.web.render import render_animation_gif, render_plot_png
from gausse.web.stats import overview, run_detail
class _Handler(BaseHTTPRequestHandler):
def __init__(self, *args, db_path: Path, log_path: Path, **kwargs):
self.db_path = db_path
self.log_path = log_path
super().__init__(*args, **kwargs)
def log_message(self, *args): # тише в stdout
pass
def _send(self, code: int, body: bytes, content_type: str) -> None:
self.send_response(code)
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
def _send_json(self, obj, code: int = 200) -> None:
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
def do_GET(self):
path = urlparse(self.path).path
if path in ("/", "/index.html"):
self._send(200, PAGE_HTML.encode("utf-8"), "text/html; charset=utf-8")
return
if path == "/api/overview":
try:
self._send_json(overview(self.db_path, self.log_path))
except Exception as exc: # база может ещё не существовать / быть пустой
self._send_json({"error": str(exc), "total": 0, "feasible": 0, "infeasible": 0}, code=200)
return
# /api/run/<id>/plot/<kind>.png — график прогона на лету
if path.startswith("/api/run/") and "/plot/" in path:
rest = path[len("/api/run/") :]
run_id, _, tail = rest.partition("/plot/")
kind = tail.removesuffix(".png")
try:
png = render_plot_png(self.db_path, run_id, kind)
except Exception as exc:
self._send(500, str(exc).encode("utf-8"), "text/plain; charset=utf-8")
return
if png is None:
self._send(404, b"no plot", "text/plain")
else:
self._send(200, png, "image/png")
return
# /api/run/<id>/anim.gif — анимация пролёта снаряда
if path.startswith("/api/run/") and path.endswith("/anim.gif"):
run_id = path[len("/api/run/") :].removesuffix("/anim.gif")
try:
gif = render_animation_gif(self.db_path, run_id)
except Exception as exc:
self._send(500, str(exc).encode("utf-8"), "text/plain; charset=utf-8")
return
if gif is None:
self._send(404, b"no animation", "text/plain")
else:
self._send(200, gif, "image/gif")
return
if path.startswith("/api/run/"):
run_id = path[len("/api/run/") :]
detail = run_detail(self.db_path, run_id)
if detail is None:
self._send_json({"error": "run_id не найден"}, code=404)
else:
self._send_json(detail)
return
self._send(404, b"not found", "text/plain")
def serve(db_path: Path, host: str = "0.0.0.0", port: int = 8000, log_path: Path | None = None) -> None:
db_path = Path(db_path)
if log_path is None:
log_path = db_path.with_suffix(db_path.suffix + ".log")
handler = partial(_Handler, db_path=db_path, log_path=log_path)
httpd = ThreadingHTTPServer((host, port), handler)
print(f"gausse web-морда: http://{host}:{port} (база: {db_path}, лог: {log_path})")
try:
httpd.serve_forever()
except KeyboardInterrupt:
print("остановлено")
finally:
httpd.server_close()

167
src/gausse/web/stats.py Normal file
View File

@@ -0,0 +1,167 @@
"""Агрегаты по таблице `runs` для веб-морды — считаются запросами к SQLite."""
import json
import time
from pathlib import Path
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 []
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:
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
by_mode = dict(conn.execute("SELECT mode, count FROM stats_modes"))
reasons = [
{"reason": reason, "count": cnt}
for reason, cnt in conn.execute(
"SELECT reason, count FROM stats_reasons ORDER BY count DESC LIMIT 10"
)
]
buckets = [0] * 10 # 0-10%,...,90-100%
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:
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,
# None (не 0), когда сводки нет — морда покажет «—»
"n_stages": len(stages) if stages is not None else None,
"timestamp": r.timestamp,
})
return out
top = _summarize(fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=top_n))
top_by_velocity = _summarize(fetch_runs(conn, feasible=True, order_by_velocity_desc=True, limit=top_n))
finally:
conn.close()
return {
"total": total,
"feasible": feasible,
"infeasible": infeasible,
"feasible_pct": (100 * feasible / total) if total else 0.0,
"by_mode": by_mode,
"infeasible_reasons": reasons,
"efficiency_histogram": buckets,
"top": top,
"top_by_velocity": top_by_velocity,
"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)
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

@@ -0,0 +1,105 @@
"""Валидация GPU-батч-интегратора против scipy-эталона.
Батч-путь (numpy/cupy, фикс. шаг RK4) обязан давать те же числа, что
проверенный scipy solve_ivp на той же физике — иначе ускорение бессмысленно.
Здесь backend всегда numpy (GPU для теста не нужен: код один и тот же).
"""
import numpy as np
from scipy.integrate import solve_ivp
from gausse.gpu.backend import get_backend
from gausse.gpu.batch_integrator import integrate_batch_discharge, params_from_models
from gausse.physics import circuit
from gausse.physics.circuit import StageCircuitParams
from gausse.physics.inductance import (
CoilInductanceModel,
air_core_inductance_wheeler,
demagnetizing_factor_prolate,
effective_permeability,
winding_geometry,
)
def _setup(turns_per_layer, layers, tube_od, slug_d, slug_len, mu_r, b_sat, cap_uf, esr, charge_v, wire_rho, wire_gauge):
"""Строит (model, circuit_params, q0, x_fire, v_fire) как это делает run_stage."""
wire_od = (wire_gauge + 0.05) / 1000
geo = winding_geometry(tube_od, wire_od, turns_per_layer, layers)
area = np.pi * (wire_gauge / 1000 / 2) ** 2
r_wire = wire_rho * geo.total_wire_length_m / area
l_air = air_core_inductance_wheeler(geo.mean_radius_m, geo.coil_length_m, geo.radial_depth_m, geo.total_turns)
aspect = slug_len / slug_d
mu_eff = effective_permeability(mu_r, demagnetizing_factor_prolate(aspect))
mass = np.pi * (slug_d / 2) ** 2 * slug_len * 7850.0
model = CoilInductanceModel(
l_air_h=l_air, coil_length_m=geo.coil_length_m, slug_length_m=slug_len,
mu_eff=mu_eff, smoothing_width_m=wire_od, total_turns=geo.total_turns, b_sat_tesla=b_sat,
)
cap_f = cap_uf * 1e-6
cp = StageCircuitParams(capacitance_f=cap_f, r_total_ohm=r_wire + esr, mass_kg=mass)
q0 = cap_f * charge_v
return model, cp, q0, -0.02, 5.0 # x_fire, v_fire
def _scipy_discharge(model, cp, q0, x_fire, v_fire):
sol = solve_ivp(
circuit.derivatives, (0.0, 0.03), [q0, 0.0, x_fire, v_fire],
args=(model, cp), events=circuit.zero_current_crossing_event, rtol=1e-9, atol=1e-12,
)
if len(sol.t_events[0]) == 0:
return None
q_f, i_f, x_f, v_f = sol.y_events[0][0]
energy_diss = float(np.trapezoid(sol.y[1] ** 2 * cp.r_total_ohm, sol.t))
return {"exit_v": v_f, "exit_x": x_f, "peak_current": float(np.max(np.abs(sol.y[1]))), "energy_diss": energy_diss}
CONFIGS = [
dict(turns_per_layer=20, layers=4, tube_od=0.01, slug_d=0.008, slug_len=0.02, mu_r=200, b_sat=1.9, cap_uf=100, esr=0.05, charge_v=300, wire_rho=1.68e-8, wire_gauge=1.0),
dict(turns_per_layer=30, layers=5, tube_od=0.012, slug_d=0.006, slug_len=0.018, mu_r=500, b_sat=1.8, cap_uf=220, esr=0.08, charge_v=400, wire_rho=1.68e-8, wire_gauge=0.8),
dict(turns_per_layer=15, layers=3, tube_od=0.009, slug_d=0.005, slug_len=0.015, mu_r=300, b_sat=2.0, cap_uf=68, esr=0.1, charge_v=250, wire_rho=2.82e-8, wire_gauge=1.4),
dict(turns_per_layer=25, layers=6, tube_od=0.011, slug_d=0.007, slug_len=0.025, mu_r=400, b_sat=1.9, cap_uf=150, esr=0.06, charge_v=350, wire_rho=1.68e-8, wire_gauge=0.71),
]
def test_batch_discharge_matches_scipy_reference():
xp, _ = get_backend(prefer_gpu=False) # тест на CPU/numpy
models, cps, q0s, x0s, v0s = [], [], [], [], []
refs = []
for c in CONFIGS:
model, cp, q0, x0, v0 = _setup(**c)
ref = _scipy_discharge(model, cp, q0, x0, v0)
assert ref is not None, "scipy-эталон должен скоммутироваться"
refs.append(ref)
models.append(model); cps.append(cp); q0s.append(q0); x0s.append(x0); v0s.append(v0)
params = params_from_models(xp, models, cps)
out = integrate_batch_discharge(
xp, xp.asarray(q0s), xp.asarray(x0s), xp.asarray(v0s), params, dt=1e-6, max_steps=40000
)
assert bool(xp.all(out["feasible"])), "батч должен скоммутировать все конфигурации"
for k, ref in enumerate(refs):
# фикс. шаг RK4 против адаптивного solve_ivp — совпадение в пределах ~1%
assert out["exit_v"][k] == np.float64(ref["exit_v"]) or abs(out["exit_v"][k] - ref["exit_v"]) <= 0.02 * abs(ref["exit_v"]) + 0.05, \
f"конфиг {k}: exit_v батч {out['exit_v'][k]:.3f} vs scipy {ref['exit_v']:.3f}"
assert abs(out["peak_current"][k] - ref["peak_current"]) <= 0.02 * ref["peak_current"] + 0.5, \
f"конфиг {k}: пик тока батч {out['peak_current'][k]:.1f} vs scipy {ref['peak_current']:.1f}"
assert abs(out["energy_dissipated_j"][k] - ref["energy_diss"]) <= 0.03 * ref["energy_diss"] + 0.01, \
f"конфиг {k}: потери батч {out['energy_dissipated_j'][k]:.3f} vs scipy {ref['energy_diss']:.3f}"
def test_batch_energy_conservation_lossless():
"""С R=0 энергия конденсатора = кинетика + магнитное поле (в конце I=0 -> вся в КЭ+остаток)."""
xp, _ = get_backend(prefer_gpu=False)
c = dict(turns_per_layer=20, layers=4, tube_od=0.01, slug_d=0.008, slug_len=0.02, mu_r=200,
b_sat=1.9, cap_uf=100, esr=0.0, charge_v=300, wire_rho=0.0, wire_gauge=1.0)
model, cp, q0, x0, v0 = _setup(**c)
params = params_from_models(xp, [model], [cp])
out = integrate_batch_discharge(xp, xp.asarray([q0]), xp.asarray([x0]), xp.asarray([v0]), params, dt=1e-6, max_steps=40000)
assert bool(out["feasible"][0])
energy_in = 0.5 * cp.capacitance_f * (q0 / cp.capacitance_f) ** 2
ke_before = 0.5 * cp.mass_kg * v0**2
ke_after = 0.5 * cp.mass_kg * float(out["exit_v"][0]) ** 2
remaining = float(out["exit_q"][0]) ** 2 / (2 * cp.capacitance_f)
balance = remaining + (ke_after - ke_before)
assert abs(balance - energy_in) <= 0.01 * energy_in, f"баланс {balance:.3f} vs вход {energy_in:.3f}"

47
tests/test_batch_sweep.py Normal file
View File

@@ -0,0 +1,47 @@
import json
from gausse.components.database import ComponentDatabase
from gausse.gpu.batch_sweep import run_gpu_sweep
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
from gausse.sim.coilgun import run_coilgun
from gausse.storage.database import count_runs, fetch_runs, open_connection
DB = ComponentDatabase.load()
def test_gpu_sweep_records_all_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)
# многоступенчатые конфиги допустимы (до max_stages)
stage_counts = set()
for r in rows:
g = genome_from_dict(json.loads(r.genome_json))
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."""
db_path = tmp_path / "gpu.sqlite3"
run_gpu_sweep(db_path, n_runs=2000, seed=5, prefer_gpu=False, batch_size=2000)
conn = open_connection(db_path)
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))
config, _, _ = decode(g, DB, bounds)
cpu = run_coilgun(config)
if not cpu.feasible:
continue
# фикс.шаг батча + аналитический триггер 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

@@ -0,0 +1,80 @@
import random
import pytest
from gausse.components.database import ComponentDatabase
from gausse.components.schema import CapacitorBank, CapacitorSpec
from gausse.optim.objective import build_detail
from gausse.optim.search_space import SearchBounds, decode, sample_genome
from gausse.sim.coilgun import run_coilgun
BASE = CapacitorSpec(
part_number="c-base", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.2,
max_current_a=20.0, price=150.0, source="test",
)
def test_series_multiplies_voltage_and_divides_capacitance():
bank = CapacitorBank.from_spec(BASE, n_series=3, n_parallel=1)
assert bank.voltage_v == pytest.approx(1200.0) # 400 * 3
assert bank.capacitance_uf == pytest.approx(100.0 / 3) # C / 3
assert bank.esr_ohm == pytest.approx(0.6) # ESR * 3
assert bank.count == 3
assert bank.price == pytest.approx(450.0) # 150 * 3
def test_parallel_multiplies_capacitance_current_and_divides_esr():
bank = CapacitorBank.from_spec(BASE, n_series=1, n_parallel=4)
assert bank.capacitance_uf == pytest.approx(400.0) # C * 4
assert bank.voltage_v == pytest.approx(400.0) # без изменения
assert bank.esr_ohm == pytest.approx(0.05) # ESR / 4
assert bank.max_current_a == pytest.approx(80.0) # 20 * 4 -- вот "на силу тока"
assert bank.count == 4
def test_series_parallel_combination():
bank = CapacitorBank.from_spec(BASE, n_series=2, n_parallel=3)
assert bank.voltage_v == pytest.approx(800.0)
assert bank.capacitance_uf == pytest.approx(100.0 * 3 / 2)
assert bank.esr_ohm == pytest.approx(0.2 * 2 / 3)
assert bank.count == 6
assert bank.price == pytest.approx(150.0 * 6)
def test_energy_scales_with_bank():
single = CapacitorBank.from_spec(BASE, 1, 1)
bank = CapacitorBank.from_spec(BASE, 2, 2) # V×2, C×1 (2 series, 2 parallel => C*2/2=C)
e_single = 0.5 * (single.capacitance_uf * 1e-6) * single.voltage_v**2
e_bank = 0.5 * (bank.capacitance_uf * 1e-6) * bank.voltage_v**2
# тот же C, но вдвое большее напряжение -> вчетверо больше энергии
assert e_bank == pytest.approx(4 * e_single)
def test_decoded_record_contains_capacitor_bank_composition():
db = ComponentDatabase.load()
bounds = SearchBounds()
rng = random.Random(1)
genome = sample_genome(db, bounds, rng)
config, ix, iv = decode(genome, db, bounds)
result = run_coilgun(config)
detail = build_detail(config, result, db, ix, iv)
bank = detail["stages"][0]["capacitor_bank"]
assert set(bank) >= {"base_part", "n_series", "n_parallel", "count", "total_voltage_rating_v", "max_current_a"}
assert bank["count"] == bank["n_series"] * bank["n_parallel"]
# база должна быть реальной деталью
assert bank["base_part"] in {c.part_number for c in db.capacitors}
def test_genome_carries_series_parallel_and_they_vary():
db = ComponentDatabase.load()
bounds = SearchBounds()
rng = random.Random(5)
series_vals, parallel_vals = set(), set()
for _ in range(40):
g = sample_genome(db, bounds, rng)
for st in g.stages:
assert bounds.cap_series_min <= st.cap_series <= bounds.cap_series_max
assert bounds.cap_parallel_min <= st.cap_parallel <= bounds.cap_parallel_max
series_vals.add(st.cap_series)
parallel_vals.add(st.cap_parallel)
assert len(series_vals) > 1 and len(parallel_vals) > 1

View File

@@ -28,15 +28,12 @@ def _make_frozen_model() -> CoilInductanceModel:
def _integrate(r_ohm: float, t_span: tuple[float, float], t_eval: np.ndarray):
# mu_eff=1.0 -> вклад железа нулевой, λ = L_air·I, чистый RLC-контур
model = _make_frozen_model()
params = StageCircuitParams(
capacitance_f=C_F,
r_total_ohm=r_ohm,
mass_kg=1.0,
mu_eff=1.0,
total_turns=100,
coil_length_m=0.05,
b_sat_tesla=1e9, # эффективно отключает клэмп насыщения для этого теста
)
state0 = [Q0, 0.0, 0.0, 0.0]
return solve_ivp(

55
tests/test_cli_smoke.py Normal file
View File

@@ -0,0 +1,55 @@
from pathlib import Path
from gausse.cli import main
from gausse.storage.database import fetch_runs, open_connection
def test_cli_sweep_writes_database(tmp_path):
db_path = tmp_path / "runs.sqlite3"
rc = main(["sweep", "--db", str(db_path), "--n", "10", "--workers", "2", "--seed", "1", "--max-stages", "2"])
assert rc == 0
assert db_path.exists()
conn = open_connection(db_path)
assert len(fetch_runs(conn)) == 10
conn.close()
def test_cli_sweep_then_report_end_to_end(tmp_path):
db_path = tmp_path / "runs.sqlite3"
out_dir = tmp_path / "report"
rc = main(["sweep", "--db", str(db_path), "--n", "40", "--workers", "2", "--seed", "2"])
assert rc == 0
rc2 = main(["report", "--db", str(db_path), "--out", str(out_dir), "--top", "1"])
assert rc2 == 0
rank_dirs = list(out_dir.glob("rank1_*"))
assert len(rank_dirs) == 1
run_dir = rank_dirs[0]
assert (run_dir / "summary.txt").exists()
assert (run_dir / "summary.json").exists()
assert (run_dir / "bom.txt").exists()
assert (run_dir / "current.png").exists()
assert (run_dir / "velocity_vs_position.png").exists()
def test_cli_simulate_single_run_id(tmp_path):
db_path = tmp_path / "runs.sqlite3"
out_dir = tmp_path / "sim_out"
main(["sweep", "--db", str(db_path), "--n", "5", "--workers", "1", "--seed", "3", "--max-stages", "2"])
conn = open_connection(db_path)
row = fetch_runs(conn)[0]
conn.close()
rc = main(["simulate", "--db", str(db_path), "--run-id", row.run_id, "--out", str(out_dir)])
assert rc == 0
assert (out_dir / "summary.txt").exists()
def test_cli_report_with_empty_database_reports_error_not_crash(tmp_path):
db_path = tmp_path / "empty.sqlite3"
from gausse.storage.database import open_connection as oc
oc(db_path).close() # создаёт пустую (но валидную) базу
rc = main(["report", "--db", str(db_path), "--out", str(tmp_path / "out")])
assert rc == 1

View File

@@ -96,6 +96,27 @@ def test_infeasible_stage_stops_chain_with_honest_reason():
assert len(result.stage_outcomes) == 2
def test_efficiency_never_exceeds_one_even_with_large_initial_velocity():
"""Регрессия: КПД считался как exit_KE/energy_in, а не как ДОБАВЛЕННАЯ
катушками энергия/energy_in. Для лёгкого снаряда с большой заданной
initial_v_mps (внешний "толчок", не учтённый в energy_in) это давало
КПД > 100% -- физически бессмысленно, найдено реальным sweep на Этапе 8."""
light_projectile = ProjectileConfig(material=STEEL, diameter_m=0.003, length_m=0.005)
config = CoilgunConfig(
stages=[_stage()],
inter_stage_gaps_m=[],
projectile=light_projectile,
initial_x_m=-0.05,
initial_v_mps=50.0, # заведомо большой "бесплатный" толчок относительно массы снаряда
)
result = run_coilgun(config)
assert result.feasible, result.reason
assert result.efficiency <= 1.0 + 1e-9
# и КПД действительно равен добавленной энергии, а не абсолютной exit-КЭ
expected = result.total_kinetic_energy_delta_j / result.total_energy_in_j
assert result.efficiency == pytest.approx(expected)
def test_mismatched_gap_count_raises():
with pytest.raises(ValueError):
CoilgunConfig(

117
tests/test_evolutionary.py Normal file
View File

@@ -0,0 +1,117 @@
import random
from gausse.components.database import ComponentDatabase
from gausse.optim.evolutionary import polish_best, run_evolution
from gausse.optim.search_space import SearchBounds, sample_genome
from gausse.storage.database import count_runs, fetch_runs, open_connection
DB = ComponentDatabase.load()
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)
summary = run_evolution(
db_path,
n_generations=2,
population_size=6,
bounds=bounds,
n_workers=2,
seed=7,
polish=False,
)
assert summary["n_evaluated"] == 12 # 2 поколения x 6 особей
assert "best_fitness" in summary
conn = open_connection(db_path)
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()
def test_evolution_is_reproducible_given_same_seed(tmp_path):
bounds = SearchBounds(max_stages=2)
summary_a = run_evolution(
tmp_path / "a.sqlite3", n_generations=2, population_size=6, bounds=bounds,
n_workers=1, seed=99, polish=False,
)
summary_b = run_evolution(
tmp_path / "b.sqlite3", n_generations=2, population_size=6, bounds=bounds,
n_workers=1, seed=99, polish=False,
)
assert summary_a["best_fitness"] == summary_b["best_fitness"]
assert summary_a["best_efficiency"] == summary_b["best_efficiency"]
def test_polish_does_not_make_the_best_genome_worse():
rng = random.Random(15)
bounds = SearchBounds(max_stages=2)
# ищем реализуемый геном как стартовую точку доводки
genome = None
for _ in range(30):
candidate = sample_genome(DB, bounds, rng)
from gausse.optim.objective import evaluate
result = evaluate(candidate, DB, bounds)
if result.feasible:
genome = candidate
baseline_fitness = result.fitness
break
assert genome is not None, "не нашли реализуемый геном за 30 попыток"
_, 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

62
tests/test_objective.py Normal file
View File

@@ -0,0 +1,62 @@
import random
from gausse.components.database import ComponentDatabase
from gausse.optim.objective import compute_cost_rub, decoded_summary, evaluate
from gausse.optim.search_space import SearchBounds, decode, sample_genome
DB = ComponentDatabase.load()
BOUNDS = SearchBounds()
def test_evaluate_feasible_genome_has_fitness_equal_efficiency():
rng = random.Random(2)
found_feasible = False
for _ in range(50):
genome = sample_genome(DB, BOUNDS, rng)
result = evaluate(genome, DB, BOUNDS)
if result.feasible:
found_feasible = True
assert result.fitness == result.efficiency
# КПД <= 1 всегда; может быть СЛЕГКА отрицательным: трение о трубку
# за время разряда может съесть больше, чем слабая катушка добавила
assert -0.01 <= result.efficiency <= 1.0
assert result.cost_rub > 0
break
assert found_feasible, "ни один из 50 случайных геномов не оказался реализуемым"
def test_evaluate_infeasible_genome_has_negative_fitness_and_reason():
rng = random.Random(9)
genome = sample_genome(DB, BOUNDS, rng)
# индукционный датчик требует скорости >= порог/чувствительность = 5 м/с,
# а старт по умолчанию 3 м/с -- первая ступень обязана провалиться
inductive_idx = next(i for i, s in enumerate(DB.sensors) if s.kind == "inductive")
genome.stages[0].sensor_idx = inductive_idx
result = evaluate(genome, DB, BOUNDS)
assert not result.feasible
assert result.fitness < 0
assert result.reason is not None
assert result.failed_stage_index == 0
def test_compute_cost_rub_is_positive_and_scales_with_stage_count():
rng = random.Random(4)
genome = sample_genome(DB, BOUNDS, rng)
config, _, _ = decode(genome, DB, BOUNDS)
cost = compute_cost_rub(config, DB)
assert cost > 0
genome.stages.append(genome.stages[0])
genome.inter_stage_gaps_m.append(0.05)
config2, _, _ = decode(genome, DB, BOUNDS)
cost2 = compute_cost_rub(config2, DB)
assert cost2 > cost
def test_decoded_summary_uses_real_part_numbers():
rng = random.Random(6)
genome = sample_genome(DB, BOUNDS, rng)
summary = decoded_summary(genome, DB)
assert len(summary["stages"]) == len(genome.stages)
real_wire_ids = {w.part_id for w in DB.wires}
assert all(s["wire"] in real_wire_ids for s in summary["stages"])

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

114
tests/test_report.py Normal file
View File

@@ -0,0 +1,114 @@
from gausse.components.database import ComponentDatabase
from gausse.report.animate import animate_run
from gausse.report.bom import build_bom, render_bom_text, total_cost_rub
from gausse.report.plots import (
plot_stage_currents,
plot_stage_fields,
plot_velocity_vs_position,
save_all,
)
from gausse.report.summary import build_summary, render_summary_json, render_summary_text
from gausse.sim.coilgun import CoilgunConfig, run_coilgun
from gausse.sim.stage import ProjectileConfig, StageConfig
DB = ComponentDatabase.load()
def _feasible_config() -> CoilgunConfig:
wire = next(w for w in DB.wires if w.part_id == "cu-petv2-1.0mm")
capacitor = next(c for c in DB.capacitors if c.part_number == "cap-470uf-400v")
# мощный ключ (180А, surge ~720А) — конфигурация с реальным пиковым током
switch = next(s for s in DB.switches if s.part_number == "IRFP4468PBF")
sensor = next(s for s in DB.sensors if s.part_number == "TCST2103")
steel = next(m for m in DB.projectile_materials if m.name == "Ст3 (конструкционная сталь)")
projectile = ProjectileConfig(material=steel, diameter_m=0.008, length_m=0.02)
stage = 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=200.0,
)
return CoilgunConfig(
stages=[stage, stage], inter_stage_gaps_m=[0.05], projectile=projectile,
initial_x_m=-0.05, initial_v_mps=5.0,
)
def test_feasible_fixture_is_actually_feasible():
result = run_coilgun(_feasible_config())
assert result.feasible, result.reason
def test_plots_render_without_error():
config = _feasible_config()
result = run_coilgun(config)
assert result.feasible
fig1 = plot_stage_currents(result)
fig2 = plot_stage_fields(result)
fig3 = plot_velocity_vs_position(result)
for fig in (fig1, fig2, fig3):
assert fig is not None
def test_save_all_writes_nonempty_png_files(tmp_path):
config = _feasible_config()
result = run_coilgun(config)
paths = save_all(result, tmp_path)
assert len(paths) == 3
for p in paths:
from pathlib import Path
assert Path(p).stat().st_size > 0
def test_bom_totals_are_positive_and_match_sum():
config = _feasible_config()
lines = build_bom(config, DB)
assert len(lines) > 0
total = total_cost_rub(lines)
assert total == sum(line.total_price_rub for line in lines)
assert total > 0
text = render_bom_text(lines)
assert "ИТОГО" in text
def test_summary_reports_feasible_run_honestly():
config = _feasible_config()
result = run_coilgun(config)
summary = build_summary(result, config, DB)
assert summary["feasible"] is True
assert summary["efficiency"] == result.efficiency
assert "model_limitations" in summary
assert len(summary["model_limitations"]) > 0
text = render_summary_text(summary)
assert "РЕАЛИЗУЕМО" in text
json_text = render_summary_json(summary)
assert "efficiency" in json_text
def test_summary_reports_infeasible_run_honestly():
import dataclasses
config = _feasible_config()
# индукционный датчик при скорости заведомо ниже порога -> провал ступени 0
weak_sensor = next(s for s in DB.sensors if s.kind == "inductive")
broken_stage = dataclasses.replace(config.stages[0], sensor=weak_sensor)
config = dataclasses.replace(
config, stages=[broken_stage, config.stages[1]], initial_v_mps=2.0
)
result = run_coilgun(config)
summary = build_summary(result, config, DB)
if not result.feasible:
text = render_summary_text(summary)
assert "НЕ РЕАЛИЗУЕМО" in text
assert summary["reason"] is not None
def test_animate_run_produces_nonempty_gif(tmp_path):
config = _feasible_config()
result = run_coilgun(config)
assert result.feasible
out_path = tmp_path / "run.gif"
animate_run(result, config, str(out_path), fps=10)
assert out_path.stat().st_size > 0

113
tests/test_search_space.py Normal file
View File

@@ -0,0 +1,113 @@
import random
import pytest
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import (
PROJECTILE_BORE_CLEARANCE_M,
SearchBounds,
crossover,
decode,
genome_from_dict,
genome_to_dict,
mutate,
repair,
sample_genome,
)
DB = ComponentDatabase.load()
BOUNDS = SearchBounds()
def test_sample_genome_respects_bounds():
rng = random.Random(42)
for _ in range(50):
genome = sample_genome(DB, BOUNDS, rng)
assert BOUNDS.min_stages <= len(genome.stages) <= BOUNDS.max_stages
assert len(genome.inter_stage_gaps_m) == len(genome.stages) - 1
assert BOUNDS.tube_inner_d_m_min <= genome.tube_inner_d_m <= BOUNDS.tube_inner_d_m_max
assert genome.tube_od_m == pytest.approx(genome.tube_inner_d_m + 2 * genome.tube_wall_m)
# снаряд влезает в бор трубы
assert genome.projectile.diameter_m <= genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M + 1e-9
# масса снаряда не больше 200 г
import math as _m
mat = DB.projectile_materials[genome.projectile.material_idx]
mass = _m.pi * (genome.projectile.diameter_m / 2) ** 2 * genome.projectile.length_m * mat.density_kg_m3
assert mass <= BOUNDS.projectile_mass_max_kg + 1e-6
for stage in genome.stages:
assert 0 <= stage.wire_idx < len(DB.wires)
assert BOUNDS.turns_per_layer_min <= stage.turns_per_layer <= BOUNDS.turns_per_layer_max
assert BOUNDS.layers_min <= stage.layers <= BOUNDS.layers_max
def test_decode_produces_valid_config():
rng = random.Random(1)
genome = sample_genome(DB, BOUNDS, rng)
config, initial_x_m, initial_v_mps = decode(genome, DB, BOUNDS)
assert len(config.stages) == len(genome.stages)
assert len(config.inter_stage_gaps_m) == len(genome.stages) - 1
assert initial_x_m < -config.stages[0].sensor_to_coil_distance_m
assert initial_v_mps == BOUNDS.initial_launch_velocity_mps
for stage_config, gene in zip(config.stages, genome.stages):
max_voltage = min(stage_config.capacitor.voltage_v, stage_config.switch.max_voltage_v)
assert stage_config.charge_voltage_v <= max_voltage + 1e-9
def test_mutate_keeps_genome_within_bounds():
rng = random.Random(7)
genome = sample_genome(DB, BOUNDS, rng)
for _ in range(200):
genome = mutate(genome, DB, BOUNDS, rng, rate=0.5)
assert BOUNDS.min_stages <= len(genome.stages) <= BOUNDS.max_stages
assert len(genome.inter_stage_gaps_m) == len(genome.stages) - 1
assert BOUNDS.tube_inner_d_m_min <= genome.tube_inner_d_m <= BOUNDS.tube_inner_d_m_max
assert genome.projectile.diameter_m <= genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M + 1e-9
# decode должен всегда успевать без исключений после repair
decode(genome, DB, BOUNDS)
def test_mutate_can_change_stage_count():
rng = random.Random(3)
small_bounds = SearchBounds(min_stages=1, max_stages=3)
genome = sample_genome(DB, small_bounds, rng)
genome.stages = genome.stages[:1]
genome.inter_stage_gaps_m = []
counts = set()
for _ in range(100):
genome = mutate(genome, DB, small_bounds, rng, rate=0.6)
counts.add(len(genome.stages))
assert len(counts) > 1 # число ступеней реально меняется, не застряло
def test_crossover_produces_decodable_child():
rng = random.Random(11)
a = sample_genome(DB, BOUNDS, rng)
b = sample_genome(DB, BOUNDS, rng)
for _ in range(20):
child = crossover(a, b, rng)
child = repair(child, DB, BOUNDS)
config, _, _ = decode(child, DB, BOUNDS)
assert len(config.stages) == len(child.stages)
def test_crossover_of_two_single_stage_genomes_does_not_crash():
"""Регрессия: оба родителя с 1 ступенью (0 зазоров) роняли crossover
с IndexError при попытке взять запасной зазор из пустого списка."""
rng = random.Random(13)
a = sample_genome(DB, BOUNDS, rng)
b = sample_genome(DB, BOUNDS, rng)
a.stages = a.stages[:1]
a.inter_stage_gaps_m = []
b.stages = b.stages[:1]
b.inter_stage_gaps_m = []
for _ in range(20):
child = crossover(a, b, rng)
child = repair(child, DB, BOUNDS)
decode(child, DB, BOUNDS)
def test_genome_dict_roundtrip():
rng = random.Random(5)
genome = sample_genome(DB, BOUNDS, rng)
restored = genome_from_dict(genome_to_dict(genome))
assert restored == genome

View File

@@ -1,3 +1,5 @@
import warnings
import numpy as np
from scipy.integrate import solve_ivp
@@ -48,3 +50,17 @@ def test_inductive_sensor_never_fires_below_threshold_speed():
event = make_inductive_sensor_event(x_sensor, sensitivity, threshold, width)
sol = solve_ivp(_ballistic, (0, 1.0), [-0.05, 1.0], events=event)
assert len(sol.t_events[0]) == 0
def test_inductive_event_far_from_sensor_is_finite_and_does_not_warn():
"""Регрессия: cosh(z)**2 переполнял float64 для больших |x - x_sensor| /
width_m (типично при интегрировании на широком временном бюджете) —
результат физически верный (bump -> 0), но с RuntimeWarning на каждый
вызов, что при миллионах прогонов sweep превращается в лавину спама."""
event = make_inductive_sensor_event(x_sensor_m=0.02, sensitivity_v_per_mps=0.05, threshold_v=0.3, width_m=0.005)
far_state = np.array([-5.0, 20.0]) # 5 м от датчика при ширине чувствительности 5мм
with warnings.catch_warnings():
warnings.simplefilter("error")
value = event(0.0, far_state)
assert np.isfinite(value)
assert value == -0.3 # bump практически 0 -> событие = 0 - порог

View File

@@ -30,14 +30,15 @@ def _wire(resistivity_ohm_m: float = 1.68e-8) -> WireSpec:
def _switch(on_resistance_ohm: float = 0.02) -> SwitchSpec:
# высокий рейтинг тока: эти тесты про энергобаланс/отдачу, а не про токовый предел
return SwitchSpec(
part_number="sw1", kind="MOSFET", max_current_a=200.0, max_voltage_v=500.0,
part_number="sw1", kind="MOSFET", max_current_a=500.0, max_voltage_v=500.0,
on_resistance_ohm=on_resistance_ohm, on_voltage_drop_v=None, turn_on_time_ns=50.0,
price=50.0, source="test",
)
def _run(wire, switch, capacitor=CAPACITOR):
def _run(wire, switch, capacitor=CAPACITOR, projectile=PROJECTILE):
# turns_per_layer выбран так, чтобы длина катушки (~1.7см) была сравнима
# со снарядом (2см) — иначе снаряд, войдя глубоко внутрь длинной катушки,
# оказывается в плоской зоне перекрытия (dL/dx=0) и сила не действует.
@@ -52,7 +53,7 @@ def _run(wire, switch, capacitor=CAPACITOR):
sensor_to_coil_distance_m=0.02,
charge_voltage_v=350.0,
)
return run_stage(entry_x_m=-0.05, entry_v_mps=5.0, stage=stage, projectile=PROJECTILE)
return run_stage(entry_x_m=-0.05, entry_v_mps=5.0, stage=stage, projectile=projectile)
def test_stage_is_feasible_with_realistic_components():
@@ -60,17 +61,27 @@ 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.
# Электрических потерь нет, но МЕХАНИЧЕСКИЕ (трение о трубку + воздух)
# есть всегда — они и составляют весь 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,
)
no_eddy_projectile = ProjectileConfig(material=no_eddy_steel, diameter_m=0.008, length_m=0.02)
result = _run(
_wire(resistivity_ohm_m=0.0), _switch(on_resistance_ohm=0.0), capacitor=lossless_capacitor
_wire(resistivity_ohm_m=0.0), _switch(on_resistance_ohm=0.0),
capacitor=lossless_capacitor, projectile=no_eddy_projectile,
)
assert result.feasible, result.reason
assert result.energy_dissipated_j == pytest.approx(0.0, abs=1e-9)
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)
@@ -87,29 +98,42 @@ def test_kinetic_energy_increases_for_approaching_slug():
result = _run(_wire(), _switch())
assert result.feasible, result.reason
assert result.kinetic_energy_delta_j > 0
# рост кинетической энергии тут должен означать разгон ВПЕРЁД, а не
# отброс назад (см. test_overpowered_discharge_can_fling_slug_backward)
# рост кинетической энергии тут должен означать разгон ВПЕРЁД
assert result.exit_v_mps > 0
def test_overpowered_discharge_can_fling_slug_backward():
"""Честная фиксация реального режима отказа, а не только "успешных" случаев.
Слишком большая ёмкость (медленный разряд относительно скорости снаряда)
держит ток высоким уже после того, как снаряд проходит центр катушки —
зона x>0 тянет назад (dL/dx<0), и снаряд может выйти с отрицательной
скоростью. Энергобаланс при этом всё ещё точен (КЭ ~ v^2 не зависит от
знака) — это подтверждает, что находка отражает реальную физику плохо
настроенного расстояния/ёмкости, а не ошибку в уравнениях.
def test_energy_conserved_under_strong_discharge():
"""Энергобаланс должен оставаться точным даже при сильном разряде большой
батареей (нелинейное насыщение железа не должно рвать сохранение энергии —
и сила, и ЭДС выведены из одного λ(x, I), см. inductance.py).
"""
overpowered_capacitor = CapacitorSpec(
big_capacitor = CapacitorSpec(
part_number="c-big", capacitance_uf=1000.0, voltage_v=400.0, esr_ohm=0.05,
max_current_a=300.0, price=300.0, source="test",
)
result = _run(_wire(), _switch(), capacitor=overpowered_capacitor)
result = _run(_wire(), _switch(), capacitor=big_capacitor)
assert result.feasible, result.reason
assert result.exit_v_mps < 0
balance = (
result.energy_remaining_cap_j + result.energy_dissipated_j + result.kinetic_energy_delta_j
)
assert balance == pytest.approx(result.energy_in_j, rel=1e-2)
def test_field_is_capped_near_saturation():
"""Регрессия на баг '13 Тесла': поле не должно улетать за B_sat железа."""
strong_capacitor = CapacitorSpec(
part_number="c-strong", capacitance_uf=200.0, voltage_v=600.0, esr_ohm=0.05,
max_current_a=500.0, price=400.0, source="test",
)
high_v_switch = SwitchSpec(
part_number="sw-hi", kind="MOSFET", max_current_a=1000.0, max_voltage_v=800.0,
on_resistance_ohm=0.01, on_voltage_drop_v=None, turn_on_time_ns=50.0, price=50.0, source="test",
)
stage = StageConfig(
wire=_wire(), capacitor=strong_capacitor, switch=high_v_switch, sensor=OPTICAL_SENSOR,
tube_od_m=0.01, turns_per_layer=40, layers=5, sensor_to_coil_distance_m=0.02, charge_voltage_v=550.0,
)
result = run_stage(entry_x_m=-0.05, entry_v_mps=5.0, stage=stage, projectile=PROJECTILE)
assert result.feasible, result.reason
# поле упирается в B_sat снаряда (1.8 Тл в фикстуре), а не улетает в десятки Тл
assert result.peak_field_estimate_tesla <= STEEL.b_sat_tesla * 1.05

View File

@@ -0,0 +1,156 @@
import multiprocessing
from pathlib import Path
from gausse.storage.database import (
count_runs,
fetch_runs,
insert_run,
insert_runs,
open_connection,
run_writer_process,
)
from gausse.storage.schema import RunRecord
def _record(run_id: str, feasible: bool = True, efficiency: float | None = 0.1, reason=None) -> RunRecord:
return RunRecord(
run_id=run_id,
timestamp="2026-07-06T00:00:00",
search_mode="sweep",
genome_json="{}",
decoded_summary_json="{}",
feasible=feasible,
model_version="v1",
infeasible_reason=reason,
efficiency=efficiency,
exit_velocity_mps=20.0 if feasible else None,
cost_rub=500.0,
)
def test_insert_and_fetch_roundtrip(tmp_path):
conn = open_connection(tmp_path / "runs.sqlite3")
insert_run(conn, _record("r1"))
rows = fetch_runs(conn)
assert len(rows) == 1
assert rows[0].run_id == "r1"
assert rows[0].feasible is True
conn.close()
def test_infeasible_runs_are_stored_not_dropped(tmp_path):
conn = open_connection(tmp_path / "runs.sqlite3")
insert_run(conn, _record("r-fail", feasible=False, efficiency=None, reason="датчик не сработал"))
assert count_runs(conn) == 1
assert count_runs(conn, feasible=False) == 1
assert count_runs(conn, feasible=True) == 0
row = fetch_runs(conn, feasible=False)[0]
assert row.infeasible_reason == "датчик не сработал"
conn.close()
def test_batch_insert_and_filters(tmp_path):
conn = open_connection(tmp_path / "runs.sqlite3")
records = [
_record("r1", efficiency=0.05),
_record("r2", efficiency=0.30),
_record("r3", feasible=False, efficiency=None, reason="разряд не скоммутировался"),
]
insert_runs(conn, records)
assert count_runs(conn) == 3
best = fetch_runs(conn, feasible=True, min_efficiency=0.1)
assert [r.run_id for r in best] == ["r2"]
ranked = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True)
assert [r.run_id for r in ranked] == ["r2", "r1"]
conn.close()
def test_reopening_database_preserves_previous_runs(tmp_path):
db_path = tmp_path / "runs.sqlite3"
conn = open_connection(db_path)
insert_run(conn, _record("r1"))
conn.close()
conn2 = open_connection(db_path)
assert count_runs(conn2) == 1
conn2.close()
def test_writer_survives_duplicate_run_id_and_keeps_draining_queue(tmp_path):
"""Регрессия: одна коллизия run_id раньше роняла writer-процесс и молча
останавливала осушение очереди (см. историю разработки Этапа 5)."""
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("dup"))
queue.put(_record("dup")) # коллизия PRIMARY KEY
queue.put(_record("after-collision"))
queue.put(None)
writer.join(timeout=10)
assert not writer.is_alive()
conn = open_connection(db_path)
assert count_runs(conn) == 2 # "dup" один раз + "after-collision"
assert {r.run_id for r in fetch_runs(conn)} == {"dup", "after-collision"}
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}"))
def test_multiprocess_writer_receives_all_records_from_multiple_workers(tmp_path):
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()
workers = [ctx.Process(target=_writer_worker, args=(queue, worker_id, 20)) for worker_id in range(3)]
for w in workers:
w.start()
for w in workers:
w.join()
queue.put(None)
writer.join()
conn = open_connection(db_path)
assert count_runs(conn) == 60
conn.close()

53
tests/test_sweep.py Normal file
View File

@@ -0,0 +1,53 @@
import json
from gausse.optim.search_space import SearchBounds, genome_from_dict
from gausse.optim.sweep import MODEL_VERSION, run_sweep
from gausse.storage.database import count_runs, fetch_runs, open_connection
def test_sweep_runs_all_seeds_and_records_everything(tmp_path):
db_path = tmp_path / "runs.sqlite3"
bounds = SearchBounds(max_stages=2) # маленькое пространство -> быстрый тест
summary = run_sweep(db_path, n_runs=12, bounds=bounds, n_workers=2, seed=123)
assert summary["n_runs"] == 12
conn = open_connection(db_path)
assert count_runs(conn) == 12
# хотя бы какие-то честно попали и в успех, и в провал -- иначе тест
# пространства слишком узкий/широкий, чтобы быть показательным
rows = fetch_runs(conn)
assert len(rows) == 12
for row in rows:
assert row.search_mode == "sweep"
assert row.model_version == MODEL_VERSION
# genome_json должен быть валидным и декодируемым геномом
genome = genome_from_dict(json.loads(row.genome_json))
assert len(genome.stages) >= 1
summary_dict = json.loads(row.decoded_summary_json)
assert "stages" in summary_dict
if row.feasible:
assert row.efficiency is not None
assert row.infeasible_reason is None
else:
assert row.infeasible_reason is not None
assert row.efficiency is None
conn.close()
def test_sweep_is_deterministic_given_same_seed(tmp_path):
bounds = SearchBounds(max_stages=2)
db_path_a = tmp_path / "a.sqlite3"
db_path_b = tmp_path / "b.sqlite3"
run_sweep(db_path_a, n_runs=8, bounds=bounds, n_workers=1, seed=42)
run_sweep(db_path_b, n_runs=8, bounds=bounds, n_workers=1, seed=42)
conn_a = open_connection(db_path_a)
conn_b = open_connection(db_path_b)
rows_a = sorted(fetch_runs(conn_a), key=lambda r: r.genome_json)
rows_b = sorted(fetch_runs(conn_b), key=lambda r: r.genome_json)
assert [r.genome_json for r in rows_a] == [r.genome_json for r in rows_b]
assert [r.efficiency for r in rows_a] == [r.efficiency for r in rows_b]
conn_a.close()
conn_b.close()

115
tests/test_web.py Normal file
View File

@@ -0,0 +1,115 @@
import json
import threading
import urllib.request
from http.server import ThreadingHTTPServer
from gausse.optim.sweep import run_sweep
from gausse.web.page import PAGE_HTML
from gausse.web.stats import overview, run_detail
def _make_db(tmp_path):
db = tmp_path / "runs.sqlite3"
run_sweep(db, n_runs=60, n_workers=2, seed=11)
return db
def test_overview_reports_counts_and_top(tmp_path):
db = _make_db(tmp_path)
o = overview(db, db.with_suffix(".sqlite3.log"))
assert o["total"] == 60
assert o["feasible"] + o["infeasible"] == 60
assert len(o["efficiency_histogram"]) == 10
# лог процесса должен существовать и попасть в хвост
assert len(o["log_tail"]) > 0
if o["top"]:
# топ отсортирован по КПД по убыванию
effs = [t["efficiency"] for t in o["top"]]
assert effs == sorted(effs, reverse=True)
def test_run_detail_has_full_physics_and_distances(tmp_path):
db = _make_db(tmp_path)
o = overview(db, None)
assert o["top"], "нужен хотя бы один реализуемый прогон"
d = run_detail(db, o["top"][0]["run_id"])
assert d is not None
detail = d["detail"]
# ключевое, что просил пользователь: расстояния между катушками и позиции
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_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
def test_run_detail_missing_id_returns_none(tmp_path):
db = _make_db(tmp_path)
assert run_detail(db, "нет-такого-id") is None
def test_http_server_serves_page_and_api(tmp_path):
from functools import partial
from gausse.web.server import _Handler
db = _make_db(tmp_path)
handler = partial(_Handler, db_path=db, log_path=db.with_suffix(".sqlite3.log"))
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
port = httpd.server_address[1]
t = threading.Thread(target=httpd.serve_forever, daemon=True)
t.start()
try:
base = f"http://127.0.0.1:{port}"
page = urllib.request.urlopen(base + "/", timeout=5).read().decode("utf-8")
assert "<!doctype html>" in page.lower()
api = json.loads(urllib.request.urlopen(base + "/api/overview", timeout=5).read())
assert api["total"] == 60
finally:
httpd.shutdown()
httpd.server_close()
def test_page_is_self_contained():
# никаких внешних ресурсов (CSP-безопасно): ни http-ссылок в src/href, ни CDN
assert "src=\"http" not in PAGE_HTML
assert "href=\"http" not in PAGE_HTML
def test_render_plot_png_returns_image_bytes(tmp_path):
from gausse.web.render import render_plot_png
from gausse.web.stats import overview
db = _make_db(tmp_path)
o = overview(db, None)
assert o["top"], "нужен реализуемый прогон для графика"
run_id = o["top"][0]["run_id"]
for kind in ("velocity", "field", "current"):
png = render_plot_png(db, run_id, kind)
assert png is not None and png[:8] == b"\x89PNG\r\n\x1a\n", f"{kind} не PNG"
def test_render_plot_unknown_kind_returns_none(tmp_path):
from gausse.web.render import render_plot_png
db = _make_db(tmp_path)
o = _overview_top_id(db)
assert render_plot_png(db, o, "nonsense") is None
def _overview_top_id(db):
from gausse.web.stats import overview
return overview(db, None)["top"][0]["run_id"]
def test_render_animation_gif_returns_gif_bytes(tmp_path):
from gausse.web.render import render_animation_gif
db = _make_db(tmp_path)
run_id = _overview_top_id(db)
gif = render_animation_gif(db, run_id)
assert gif is not None and gif[:6] in (b"GIF87a", b"GIF89a")