Compare commits

...

25 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
22 changed files with 1220 additions and 229 deletions

32
PLAN.md
View File

@@ -55,7 +55,7 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
- **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `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 БЕЗ потерь в железе, скина и трения — после их добавления такие КПД невозможны, см. «хронологию честности» ниже.)* - **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `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 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости. - **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 5000 прогонах: индукционный датчик (`inductive-pickup-lm393`) дал **0.2% реализуемых** конфигураций (6 из 2965, где он стоял хотя бы на одной ступени), тогда как оптический (TCST2103) и Холла (A3144E) — **~45-47%** реализуемых каждый. Это количественно подтверждает опасение, высказанное в самом начале обсуждения плана: индукционная катушка-датчик ненадёжна именно потому, что требует скорости выше её порога (здесь — фиксированный старт 3 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости.
- Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат. - Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат.
- [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. Ход эксперимента пишется в `<db>.log` (`optim/progress_log.py`): прогресс, доля реализуемых, скорость, ETA, отметки нового лучшего КПД. Веб-дашборд `gausse serve` (`src/gausse/web/`, только stdlib `http.server`, self-contained HTML) — счётчики, гистограмма КПД, топ конфигураций с drill-down, причины отказа, хвост лога, автообновление 3с. В `docker-compose.yml` сервис `web` на порту 8000. 77 тестов, проверено в Docker. - [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. *(УСТАРЕЛО с 2026-07-11, коммит 5c11999: полный build_detail больше НЕ хранится на каждый прогон — база раздувалась до 70ГБ; в decoded_summary_json теперь дешёвая сводка decoded_summary(), а полная детализация пересимулируется на лету из genome_json при просмотре прогона — web/rebuild.py + web/stats.run_detail.)* Ход эксперимента пишется в `<db>.log` (`optim/progress_log.py`): прогресс, доля реализуемых, скорость, ETA, отметки нового лучшего КПД. Веб-дашборд `gausse serve` (`src/gausse/web/`, только stdlib `http.server`, self-contained HTML) — счётчики, гистограмма КПД, топ конфигураций с drill-down, причины отказа, хвост лога, автообновление 3с. В `docker-compose.yml` сервис `web` на порту 8000. 77 тестов, проверено в Docker.
## Батареи конденсаторов + графики в веб-морде (по запросу пользователя) ## Батареи конденсаторов + графики в веб-морде (по запросу пользователя)
@@ -102,7 +102,13 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
CPU-эталоном — расхождение exit_v **0.084%** (tests/test_batch_sweep.py, CPU-эталоном — расхождение exit_v **0.084%** (tests/test_batch_sweep.py,
test_batch_integrator.py). Стиффные конфиги, переполняющие фикс-шаг, test_batch_integrator.py). Стиффные конфиги, переполняющие фикс-шаг,
честно бракуются (blew_up), а не записываются мусором. честно бракуются (blew_up), а не записываются мусором.
- [ ] (опция) дослить cut-логику в ядро (>3.8x); GPU-батч внутрь эволюции. - [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/с стабильно.
## Физика: найденные завышения КПД и их исправления (хронология честности) ## Физика: найденные завышения КПД и их исправления (хронология честности)
@@ -134,6 +140,22 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
слегка отрицательным (трение съело больше слабой катушки) — это честно. слегка отрицательным (трение съело больше слабой катушки) — это честно.
Для сравнения на одной и той же эволюции: v1 давал «38%» там, где v2 даёт Для сравнения на одной и той же эволюции: v1 давал «38%» там, где v2 даёт
~8% — столько стоили неучтённые скин и трение. ~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 ## Требования пользователя, закрытые после Этапа 8
@@ -146,11 +168,13 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
катушку — repair()). катушку — repair()).
- **Ссылки на магазины** (`url`) у каждого компонента: в JSON-базе, BOM - **Ссылки на магазины** (`url`) у каждого компонента: в JSON-базе, BOM
(«Купить») и в детализации прогона. («Купить») и в детализации прогона.
- **Максимально детальная запись** в БД (`build_detail`): труба - **Максимально детальная запись** (`build_detail`): труба
внутр/стенка/внеш, масса снаряда в граммах и влезает-ли-в-бор, внутр/стенка/внеш, масса снаряда в граммах и влезает-ли-в-бор,
внутр/внеш диаметры катушки, состав банки конденсаторов, DC/AC внутр/внеш диаметры катушки, состав банки конденсаторов, DC/AC
сопротивление обмотки, все позиции датчиков и катушек вдоль трубы, сопротивление обмотки, все позиции датчиков и катушек вдоль трубы,
энергобаланс каждой ступени. энергобаланс каждой ступени. *(С 2026-07-11 НЕ хранится в БД построчно,
а строится на лету из genome_json при просмотре прогона/отчёте —
хранение полной детализации на 12.9М строк раздуло базу до 70ГБ.)*
- **Анимация переделана** (была «время вырезается, результата не видно»): - **Анимация переделана** (была «время вырезается, результата не видно»):
кадры на равномерной сетке ФИЗИЧЕСКОГО времени (а не по индексам массива, кадры на равномерной сетке ФИЗИЧЕСКОГО времени (а не по индексам массива,
из-за чего плотные точки разряда съедали все кадры и подлёт выпадал), из-за чего плотные точки разряда съедали все кадры и подлёт выпадал),

View File

@@ -16,6 +16,8 @@
- **Физика** (`src/gausse/physics/`): RLC-разряд конденсаторной банки через - **Физика** (`src/gausse/physics/`): RLC-разряд конденсаторной банки через
катушку с ферромагнитным снарядом; насыщение железа через единое катушку с ферромагнитным снарядом; насыщение железа через единое
потокосцепление λ(x,I) (энергия сохраняется); вихревые потери в снаряде; потокосцепление λ(x,I) (энергия сохраняется); вихревые потери в снаряде;
коэффициент заполнения (тонкий снаряд не усиливает весь поток) и
диффузия поля (за импульс намагничивается только скин-кольцо);
скин-эффект + эффект близости обмотки (метод Доуэлла); трение о трубку и скин-эффект + эффект близости обмотки (метод Доуэлла); трение о трубку и
сопротивление воздуха; одиночный импульс (тиристор не перезапускается); сопротивление воздуха; одиночный импульс (тиристор не перезапускается);
паспортные импульсные токи ключей (ITSM/IDM/ICM из даташитов). паспортные импульсные токи ключей (ITSM/IDM/ICM из даташитов).
@@ -27,7 +29,8 @@
полировкой; всё в одну SQLite-базу (WAL, писатель поверх очереди). полировкой; всё в одну SQLite-базу (WAL, писатель поверх очереди).
- **GPU** (`src/gausse/gpu/`): батч-интегратор RK4 (numpy/cupy), весь шаг - **GPU** (`src/gausse/gpu/`): батч-интегратор RK4 (numpy/cupy), весь шаг
слит в одно cupy.fuse-ядро — на GTX 1070 ~3.8x к CPU; многоступенчатый слит в одно cupy.fuse-ядро — на GTX 1070 ~3.8x к CPU; многоступенчатый
GPU-sweep раунд-за-раундом; сверен с CPU-эталоном (<0.1%). GPU-sweep раунд-за-раундом И GPU-эволюция (use_gpu=True: всё поколение —
один батч, ~150 геномов/с на 1070); сверено с CPU-эталоном (<0.1%).
- **Отчёты** (`src/gausse/report/`, `src/gausse/web/`): веб-дашборд с - **Отчёты** (`src/gausse/report/`, `src/gausse/web/`): веб-дашборд с
drill-down до каждой детали и энергобаланса ступени, графики ток/поле/ drill-down до каждой детали и энергобаланса ступени, графики ток/поле/
скорость, GIF-анимация выстрела (равномерное физическое время, результат скорость, GIF-анимация выстрела (равномерное физическое время, результат
@@ -65,9 +68,9 @@ pytest
## Автономная работа на сервере ## Автономная работа на сервере
Эволюция оформлена как systemd-сервис `gausse-evolve` (вечный цикл: 80 Эволюция оформлена как systemd-сервис `gausse-evolve` (вечный цикл: 120
поколений × 300 особей, по окончании новый цикл со свежей случайной поколений × 1000 особей на GPU, по окончании новый цикл со свежей
популяцией; `Restart=always`, автозапуск при загрузке). Дашборд Docker- случайной популяцией; `Restart=always`, автозапуск при загрузке). Дашборд Docker-
сервис `web` на :8000, читает ту же базу. Счётчик «запланировано» на сервис `web` на :8000, читает ту же базу. Счётчик «запланировано» на
дашборде план **текущего цикла**, а не всего поиска: поиск бесконечен. дашборде план **текущего цикла**, а не всего поиска: поиск бесконечен.
@@ -75,13 +78,12 @@ pytest
- Индуктивность инженерная модель (Уилер + размагничивание сфероида + - Индуктивность инженерная модель (Уилер + размагничивание сфероида +
tanh-перекрытие), не МКЭ; точная картина поля требует FEA. tanh-перекрытие), не МКЭ; точная картина поля требует FEA.
- Вихревые потери снаряда сосредоточенный виток на характерной частоте - Вихревые потери снаряда сосредоточенный виток; диффузия поля в снаряд
импульса; диффузия поля (скин-эффект в самом снаряде) не решается. одночастотная оценка скин-кольца, а не решение уравнения диффузии.
- Доуэлл берёт одну характерную частоту ω=1/√(LC), без спектра импульса. - Доуэлл берёт одну характерную частоту ω=1/√(LC), без спектра импульса.
- Воздух лобовое сопротивление; поршневой эффект столба воздуха в трубе - Воздух лобовое сопротивление; поршневой эффект столба воздуха в трубе
не учтён. Трение константа 0.35 (сталь-пластик), не измерено. не учтён. Трение константа 0.35 (сталь-пластик), не измерено.
- Гистерезис снаряда посчитан и сознательно не включён в динамику (<0.1%). - Гистерезис снаряда посчитан и сознательно не включён в динамику (<0.1%).
- Нагрев провода за импульс и от серии выстрелов не моделируется. - Нагрев провода за импульс и от серии выстрелов не моделируется.
Версия физики пишется в каждую запись (`model_version`, сейчас Версия физики пишется в каждую запись (`model_version`, сейчас `gausse-physics-v4`) прогоны разных моделей не смешиваются.
`gausse-physics-v2`) прогоны разных моделей не смешиваются.

13
deploy/README.md Normal file
View File

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

37
deploy/evolve_forever.sh Executable file
View File

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

View File

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

View File

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

13
deploy/gpustat_loop.sh Executable file
View File

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

View File

@@ -167,6 +167,97 @@ def _get_fused_step(cp):
return 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( def integrate_batch_discharge(
xp, xp,
q0, q0,
@@ -197,9 +288,14 @@ def integrate_batch_discharge(
energy_diss = 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 _old_err = xp.seterr(all="ignore") if hasattr(xp, "seterr") else None # стиффные конфиги переполняют fixed-step
# на cupy — слитое в одно ядро RK4-ядро (cupy.fuse), на numpy — обычный путь # на cupy — ВЕСЬ шаг (RK4 + cut-логика) одним fused-ядром; numpy — обычный путь
is_cupy = xp.__name__ == "cupy" is_cupy = xp.__name__ == "cupy"
fused_pair = None
if is_cupy: if is_cupy:
try:
fused_pair = _get_fused_pair(xp)
except Exception:
fused_pair = None # честный fallback на пошаговый путь
fused = _get_fused_step(xp) fused = _get_fused_step(xp)
hs = (params.coil_length_m + params.slug_length_m) / 2 hs = (params.coil_length_m + params.slug_length_m) / 2
# проверку «все ли готовы» делаем НЕ каждый шаг: на GPU это device->host # проверку «все ли готовы» делаем НЕ каждый шаг: на GPU это device->host
@@ -212,6 +308,27 @@ def integrate_batch_discharge(
q_old, i_old, x_old, v_old = q, i, x, v 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: if is_cupy:
q_new, i_new, x_new, v_new, overlap_old, overlap_new = fused( 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, q_old, i_old, x_old, v_old, dt, hs, params.smoothing_width_m,

View File

@@ -23,32 +23,118 @@ from pathlib import Path
from gausse.components.database import ComponentDatabase from gausse.components.database import ComponentDatabase
from gausse.gpu.backend import get_backend, to_cpu from gausse.gpu.backend import get_backend, to_cpu
from gausse.gpu.batch_integrator import integrate_batch_discharge, params_from_models from gausse.gpu.batch_integrator import NO_SATURATION_I_SAT, BatchDischargeParams, integrate_batch_discharge
from gausse.optim.objective import MODEL_VERSION, build_detail from gausse.optim.objective import MODEL_VERSION
from gausse.optim.progress_log import ProgressLogger, default_log_path 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.optim.search_space import SearchBounds, decode, genome_to_dict, sample_genome
from gausse.physics.constants import switch_pulse_limit_a from gausse.physics.constants import switch_pulse_limit_a
from gausse.sim.coilgun import CoilgunResult
from gausse.sim.stage import build_stage_physics from gausse.sim.stage import build_stage_physics
from gausse.storage.database import insert_runs, open_connection from gausse.storage.database import insert_runs, open_connection
from gausse.storage.schema import RunRecord from gausse.storage.schema import RunRecord
_FEASIBLE = CoilgunResult(feasible=True, stage_outcomes=[])
@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 @dataclass
class _State: class _State:
genome: object genome: object
config: object pre: GenomePre
initial_x_m: float
initial_v_mps: float
coil_centers: list # абсолютные центры катушек вдоль трубы
cur_x: float # текущая глобальная координата снаряда cur_x: float # текущая глобальная координата снаряда
cur_v: float # текущая скорость cur_v: float # текущая скорость
energy_in_j: float = 0.0 energy_in_j: float = 0.0
kinetic_delta_j: float = 0.0 kinetic_delta_j: float = 0.0
alive: bool = True alive: bool = True
reason: str | None = None 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: def _coast_velocity(v0: float, distance_m: float, retard_const_n: float, drag_coeff: float, mass_kg: float) -> float:
@@ -68,39 +154,151 @@ def _coast_velocity(v0: float, distance_m: float, retard_const_n: float, drag_co
return math.sqrt(u) if u > 0 else 0.0 return math.sqrt(u) if u > 0 else 0.0
def _prepare_stage(stage, projectile, cur_x_global, cur_v, coil_center_global): def _prepare_stage(sp: StagePre, mass_kg: float, cur_x_global, cur_v, coil_center_global):
"""Готовит разряд ступени: физика + аналитический fire-state из текущего (x,v).""" """Аналитический fire-state ступени из текущего (x,v) — только арифметика."""
if cur_v <= 1e-6: if cur_v <= 1e-6:
return None, "снаряд остановился/пошёл назад" return None, "снаряд остановился/пошёл назад"
phys = build_stage_physics(stage, projectile)
# вырожденный контур (см. sim/stage.py) — тот же честный быстрый отказ # вырожденный контур (см. sim/stage.py) — тот же честный быстрый отказ
tau_worst = phys.inductance_model.l_air_h / (phys.r_total_ohm + phys.r_eddy_coeff_ohm + 1e-12) if sp.degenerate_reason:
if tau_worst < 1e-7: return None, sp.degenerate_reason
return None, "вырожденная катушка: L/R < 100 нс (вне области модели)" sensor_global = coil_center_global - sp.sensor_to_coil_m
sensor_global = coil_center_global - stage.sensor_to_coil_distance_m
if sensor_global < cur_x_global - 1e-9: if sensor_global < cur_x_global - 1e-9:
return None, "датчик позади снаряда (не сработает)" return None, "датчик позади снаряда (не сработает)"
# подлёт до датчика — с трением о трубку и воздухом (та же физика, что на CPU) # подлёт до датчика — с трением о трубку и воздухом (та же физика, что на CPU)
cp_ = phys.circuit_params
v_at_sensor = _coast_velocity( v_at_sensor = _coast_velocity(
cur_v, sensor_global - cur_x_global, cp_.retard_const_n, cp_.drag_coeff_n_per_mps2, projectile.mass_kg cur_v, sensor_global - cur_x_global, sp.retard_const_n, sp.drag_coeff_n, mass_kg
) )
if v_at_sensor <= 1e-3: if v_at_sensor <= 1e-3:
return None, "снаряд остановлен трением о трубку, не долетев до датчика" return None, "снаряд остановлен трением о трубку, не долетев до датчика"
if stage.sensor.kind == "inductive": if sp.sensor_kind == "inductive":
if stage.sensor.sensitivity_v_per_mps * v_at_sensor < (stage.sensor.threshold_v or 0.0): if sp.sensor_sens_v_per_mps * v_at_sensor < sp.sensor_threshold_v:
return None, "инд. датчик: сигнал ниже порога" return None, "инд. датчик: сигнал ниже порога"
fire_delay = (stage.sensor.propagation_delay_ns + stage.switch.turn_on_time_ns) * 1e-9 x_fire_global = sensor_global + v_at_sensor * sp.fire_delay_s
x_fire_global = sensor_global + v_at_sensor * fire_delay
x_fire_local = x_fire_global - coil_center_global x_fire_local = x_fire_global - coil_center_global
q0 = phys.capacitance_f * stage.charge_voltage_v
return { return {
"phys": phys, "q0": q0, "x_fire": x_fire_local, "v_fire": v_at_sensor, "sp": sp, "x_fire": x_fire_local, "v_fire": v_at_sensor,
"energy_in": 0.5 * phys.capacitance_f * stage.charge_voltage_v**2, "coil_center": coil_center_global, "mass": mass_kg,
"coil_center": coil_center_global, "stage": stage, "mass": projectile.mass_kg,
}, None }, 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( def run_gpu_sweep(
db_path: Path, db_path: Path,
n_runs: int, n_runs: int,
@@ -125,55 +323,10 @@ def run_gpu_sweep(
states = [] states = []
for _ in range(n): for _ in range(n):
g = sample_genome(db, bounds, rng) g = sample_genome(db, bounds, rng)
cfg, ix, iv = decode(g, db, bounds) pre = precompute_genome(g, db, bounds)
centers = [0.0] states.append(_State(g, pre, cur_x=pre.initial_x_m, cur_v=pre.initial_v_mps))
for gap in cfg.inter_stage_gaps_m:
centers.append(centers[-1] + gap)
states.append(_State(g, cfg, ix, iv, centers, cur_x=ix, cur_v=iv))
max_ns = max(len(s.config.stages) for s in states) _simulate_states(xp, states)
for s_idx in range(max_ns):
prepared = [] # (state, prep)
for st in states:
if not st.alive or len(st.config.stages) <= s_idx:
continue
p, reason = _prepare_stage(
st.config.stages[s_idx], st.config.projectile, st.cur_x, st.cur_v, st.coil_centers[s_idx]
)
if p is None:
st.alive = False
st.reason = f"ступень {s_idx}: {reason}"
else:
prepared.append((st, p))
if not prepared:
continue
models = [p["phys"].inductance_model for _, p in prepared]
cps = [p["phys"].circuit_params for _, p in prepared]
params = params_from_models(xp, models, cps)
out = integrate_batch_discharge(
xp,
xp.asarray([p["q0"] for _, p in prepared]),
xp.asarray([p["x_fire"] for _, p in prepared]),
xp.asarray([p["v_fire"] for _, p in prepared]),
params, dt=2e-6, max_steps=15000,
)
exit_v = to_cpu(xp, out["exit_v"]); 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):
stage = p["stage"]
if not bool(feas[k]):
st.alive = False; st.reason = f"ступень {s_idx}: разряд не скоммутировался"; continue
surge = switch_pulse_limit_a(stage.switch)
if float(peak_i[k]) > surge:
st.alive = False
st.reason = f"ступень {s_idx}: пиковый ток {float(peak_i[k]):.0f}А > импульсного предела ключа ({surge:.0f}А)"
continue
ev = float(exit_v[k])
st.kinetic_delta_j += 0.5 * p["mass"] * (ev**2 - p["v_fire"] ** 2)
st.energy_in_j += p["energy_in"]
st.cur_v = ev
st.cur_x = p["coil_center"] + float(exit_x[k])
records = [] records = []
for st in states: for st in states:
@@ -194,17 +347,15 @@ def run_gpu_sweep(
def _record(st: _State, db, backend, efficiency, exit_v): def _record(st: _State, db, backend, efficiency, exit_v):
from gausse.optim.objective import decoded_summary
feasible = efficiency is not None feasible = efficiency is not None
detail = build_detail(
st.config, _FEASIBLE if feasible else None, db, st.initial_x_m, st.initial_v_mps,
st.genome.tube_inner_d_m, st.genome.tube_wall_m,
)
return RunRecord( return RunRecord(
run_id=str(uuid.uuid4()), run_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(), timestamp=datetime.now(timezone.utc).isoformat(),
search_mode=f"gpu-sweep-{backend}", search_mode=f"gpu-sweep-{backend}",
genome_json=json.dumps(genome_to_dict(st.genome)), genome_json=json.dumps(genome_to_dict(st.genome)),
decoded_summary_json=json.dumps(detail, ensure_ascii=False), decoded_summary_json=json.dumps(decoded_summary(st.genome, db), ensure_ascii=False),
feasible=feasible, feasible=feasible,
model_version=MODEL_VERSION, model_version=MODEL_VERSION,
infeasible_reason=st.reason, infeasible_reason=st.reason,

View File

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

View File

@@ -21,10 +21,11 @@ from gausse.physics.inductance import air_core_inductance_wheeler, winding_geome
from gausse.sim.coilgun import CoilgunResult, run_coilgun from gausse.sim.coilgun import CoilgunResult, run_coilgun
from gausse.storage.schema import RunRecord from gausse.storage.schema import RunRecord
# v3: коэффициент заполнения железа (fill factor) — закрыт эксплойт «тонкий # v4: диффузия поля в снаряд — намагничивается только скин-кольцо глубиной δ,
# снаряд в толстой катушке»; полная формула отражения вихревого контура; # закрыт эксплойт «медленной» катушки (30%). v3: fill factor (эксплойт тонкого
# гард вырожденных катушек. v2: Доуэлл, трение+воздух, импульсные токи ключей. # снаряда), полное отражение вихрей, гард вырожденных катушек. v2: Доуэлл,
MODEL_VERSION = "gausse-physics-v3" # трение+воздух, паспортные импульсные токи ключей.
MODEL_VERSION = "gausse-physics-v4"
@dataclass @dataclass
@@ -37,7 +38,6 @@ class EvaluationResult:
efficiency: float | None = None efficiency: float | None = None
exit_velocity_mps: float | None = None exit_velocity_mps: float | None = None
energy_breakdown: dict = None energy_breakdown: dict = None
detail: dict = None
def compute_cost_rub(config, db: ComponentDatabase) -> float: def compute_cost_rub(config, db: ComponentDatabase) -> float:
@@ -60,10 +60,11 @@ def _wire_resistance_ohm(resistivity_ohm_m: float, gauge_mm: float, wire_length_
def decoded_summary(genome: Genome, db: ComponentDatabase) -> dict: def decoded_summary(genome: Genome, db: ComponentDatabase) -> dict:
"""Краткая сводка только по геному (без результатов симуляции). """Краткая сводка только по геному (без результатов симуляции) — это и
пишется в decoded_summary_json для каждого прогона: genome_json уже
Оставлена для обратной совместимости; подробную запись со всей физикой достаточен для полного воспроизведения через build_detail() на лету,
и результатами по каждой ступени строит `build_detail` (её и пишем в БД). так что хранить дорогую подробную запись для каждого из миллионов
прогонов незачем (см. web/stats.run_detail для просмотра одного прогона).
""" """
material = db.projectile_materials[genome.projectile.material_idx % len(db.projectile_materials)] material = db.projectile_materials[genome.projectile.material_idx % len(db.projectile_materials)]
stages_summary = [] stages_summary = []
@@ -258,10 +259,15 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> EvaluationResult: 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) config, initial_x_m, initial_v_mps = decode(genome, db, bounds)
cost_rub = compute_cost_rub(config, db) cost_rub = compute_cost_rub(config, db)
result = run_coilgun(config) result = run_coilgun(config)
detail = build_detail(config, result, db, initial_x_m, initial_v_mps, genome.tube_inner_d_m, genome.tube_wall_m)
if not result.feasible: if not result.feasible:
n_stages = len(config.stages) n_stages = len(config.stages)
@@ -274,7 +280,6 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
reason=result.reason, reason=result.reason,
failed_stage_index=result.failed_stage_index, failed_stage_index=result.failed_stage_index,
energy_breakdown={}, energy_breakdown={},
detail=detail,
) )
energy_breakdown = { energy_breakdown = {
@@ -290,12 +295,11 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
efficiency=result.efficiency, efficiency=result.efficiency,
exit_velocity_mps=result.exit_v_mps, exit_velocity_mps=result.exit_v_mps,
energy_breakdown=energy_breakdown, energy_breakdown=energy_breakdown,
detail=detail,
) )
def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord: def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord:
detail = result.detail if result.detail is not None else decoded_summary(genome, db) detail = decoded_summary(genome, db)
return RunRecord( return RunRecord(
run_id=str(uuid.uuid4()), run_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(), timestamp=datetime.now(timezone.utc).isoformat(),

View File

@@ -33,6 +33,32 @@ def skin_depth_m(resistivity_ohm_m: float, omega_rad_s: float) -> float:
return math.sqrt(2.0 * resistivity_ohm_m / (omega_rad_s * MU_0)) 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( def dowell_ac_factor(
wire_bare_d_m: float, wire_bare_d_m: float,
wire_insulated_d_m: float, wire_insulated_d_m: float,

View File

@@ -28,7 +28,7 @@ from gausse.physics.inductance import (
effective_permeability, effective_permeability,
winding_geometry, winding_geometry,
) )
from gausse.physics.ac_resistance import dowell_ac_factor from gausse.physics.ac_resistance import dowell_ac_factor, iron_penetration_fraction
from gausse.physics.constants import ( from gausse.physics.constants import (
AIR_DENSITY_KG_M3, AIR_DENSITY_KG_M3,
DRAG_COEFF_CYLINDER, DRAG_COEFF_CYLINDER,
@@ -190,8 +190,16 @@ def build_stage_physics(stage: StageConfig, projectile: ProjectileConfig) -> Sta
char_omega_rad_s=char_omega, char_omega_rad_s=char_omega,
) )
# доля сечения катушки, реально занятая железом (см. inductance.py): # доля сечения катушки, реально занятая железом (см. inductance.py):
# тонкий снаряд в толстой катушке не может усиливать ВЕСЬ поток # тонкий снаряд в толстой катушке не может усиливать ВЕСЬ поток;
# и из этого железа за импульс намагничивается только скин-кольцо
# глубиной δ (диффузия поля, см. ac_resistance.iron_penetration_fraction)
fill = min((projectile.diameter_m / 2) ** 2 / geometry.mean_radius_m**2, 1.0) 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( inductance_model = CoilInductanceModel(
l_air_h=l_air_h, l_air_h=l_air_h,
coil_length_m=geometry.coil_length_m, coil_length_m=geometry.coil_length_m,

View File

@@ -9,26 +9,142 @@
import sqlite3 import sqlite3
import sys import sys
from dataclasses import asdict, fields from dataclasses import asdict, dataclass, fields
from pathlib import Path from pathlib import Path
from gausse.storage.schema import CREATE_INDEXES_SQL, CREATE_RUNS_TABLE_SQL, RunRecord from gausse.storage.schema import CREATE_INDEXES_SQL, CREATE_RUNS_TABLE_SQL, CREATE_STATS_SQL, RunRecord
_COLUMNS = [f.name for f in fields(RunRecord)] _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: def open_connection(db_path: Path) -> sqlite3.Connection:
db_path = Path(db_path) db_path = Path(db_path)
db_path.parent.mkdir(parents=True, exist_ok=True) db_path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(str(db_path)) conn = sqlite3.connect(str(db_path))
conn.execute("PRAGMA journal_mode=WAL") conn.execute("PRAGMA journal_mode=WAL")
# не давать WAL расти безгранично: при потоке вставок 400/с и постоянных
# читателях (веб) авто-чекпоинт голодает, WAL раздувался до гигабайт и
# замедлял ВСЕ запросы. Лимит + периодический чекпоинт в писателе.
conn.execute("PRAGMA journal_size_limit=67108864") # 64МБ
conn.execute(CREATE_RUNS_TABLE_SQL) conn.execute(CREATE_RUNS_TABLE_SQL)
for statement in CREATE_INDEXES_SQL: for statement in CREATE_INDEXES_SQL:
conn.execute(statement) conn.execute(statement)
for statement in CREATE_STATS_SQL:
conn.execute(statement)
conn.commit() conn.commit()
return conn 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: def _record_to_params(record: RunRecord) -> dict:
row = asdict(record) row = asdict(record)
row["feasible"] = int(row["feasible"]) row["feasible"] = int(row["feasible"])
@@ -45,15 +161,21 @@ def insert_run(conn: sqlite3.Connection, record: RunRecord) -> None:
insert_runs(conn, [record]) insert_runs(conn, [record])
def insert_runs(conn: sqlite3.Connection, records: list[RunRecord]) -> None: def insert_runs(conn: sqlite3.Connection, records: list) -> None:
"""Пишет пачку RunRecord/StatsOnly одной транзакцией: строки runs — только
для RunRecord, агрегаты дашборда — по ВСЕМ элементам пачки (либо всё,
либо ничего — иначе цифры на морде разъедутся с таблицей)."""
if not records: if not records:
return return
placeholders = ", ".join(f":{name}" for name in _COLUMNS) rows = [r for r in records if isinstance(r, RunRecord)]
columns = ", ".join(_COLUMNS) if rows:
conn.executemany( placeholders = ", ".join(f":{name}" for name in _COLUMNS)
f"INSERT INTO runs ({columns}) VALUES ({placeholders})", columns = ", ".join(_COLUMNS)
[_record_to_params(r) for r in records], conn.executemany(
) f"INSERT INTO runs ({columns}) VALUES ({placeholders})",
[_record_to_params(r) for r in rows],
)
_apply_stats(conn, records)
conn.commit() conn.commit()
@@ -81,11 +203,20 @@ def fetch_runs(
limit: int | None = None, limit: int | None = None,
) -> list[RunRecord]: ) -> list[RunRecord]:
conn.row_factory = sqlite3.Row conn.row_factory = sqlite3.Row
query = "SELECT * FROM runs WHERE 1=1" # топы дашборда: принуждаем частичный DESC-индекс (INDEXED BY) — иначе
# планировщик берёт idx_runs_feasible и сортирует сотни тысяч строк
# TEMP B-TREE'ом (~2с на запрос). Условие feasible=1 обязано быть
# ЛИТЕРАЛОМ: частичный индекс не матчится с bound-параметром.
forced_index = ""
if feasible is True and min_efficiency is None:
if order_by_velocity_desc:
forced_index = " INDEXED BY idx_runs_feas_vel"
elif order_by_efficiency_desc:
forced_index = " INDEXED BY idx_runs_feas_eff"
query = f"SELECT * FROM runs{forced_index} WHERE 1=1"
params: list = [] params: list = []
if feasible is not None: if feasible is not None:
query += " AND feasible = ?" query += f" AND feasible = {int(feasible)}"
params.append(int(feasible))
if min_efficiency is not None: if min_efficiency is not None:
query += " AND efficiency >= ?" query += " AND efficiency >= ?"
params.append(min_efficiency) params.append(min_efficiency)
@@ -101,26 +232,67 @@ def fetch_runs(
def run_writer_process(queue, db_path: Path) -> None: def run_writer_process(queue, db_path: Path) -> None:
"""Читает `RunRecord` из очереди и пишет в SQLite, пока не придёт None. """Читает `RunRecord`/`StatsOnly` из очереди и пишет в SQLite до None.
Предназначен для запуска как отдельный `multiprocessing.Process` — Предназначен для запуска как отдельный `multiprocessing.Process` —
единственный writer на файл базы, пока воркеры sweep/evolve только единственный writer на файл базы, пока воркеры sweep/evolve только
кладут результаты в очередь. кладут результаты в очередь.
Один "плохой" прогон (например, коллизия run_id) не должен уронить ПАЧКАМИ, не по одной: коммит SQLite = fsync (~5-10мс), и по-одному
писатель и остановить осушение очереди на много часов sweep — при писатель выдавал ~150/с — GPU-эволюция (380+/с) копила хвост очереди в
ошибке вставки конкретная запись пропускается с сообщением в stderr, 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) conn = open_connection(db_path)
# если база старая (без агрегатов) — пересобрать ДО первых вставок,
# иначе счётчики начнут вестись только с нуля и разойдутся с таблицей
ensure_stats(conn)
last_checkpoint = time_mod.monotonic()
try: try:
while True: finished = False
record = queue.get() while not finished:
if record is None: batch = [queue.get()] # блокируемся на первой
break 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: try:
insert_run(conn, record) insert_runs(conn, batch)
except sqlite3.Error as exc: except sqlite3.Error:
print(f"[storage] не удалось записать run_id={record.run_id}: {exc}", file=sys.stderr) 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: finally:
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
conn.close() conn.close()

View File

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

View File

@@ -60,7 +60,9 @@ PAGE_HTML = r"""<!doctype html>
<div class="card"><div class="k">Реализуемо</div><div class="v" id="c-feas">—</div></div> <div class="card"><div class="k">Реализуемо</div><div class="v" id="c-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-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-pct">—</div></div>
<div class="card"><div class="k">Лучший КПД / скорость</div><div class="v" id="c-best" style="font-size:20px">—</div></div> <div class="card"><div class="k">Лучший КПД за всё время / скорость</div><div class="v" id="c-best" style="font-size:20px">—</div></div>
<div class="card"><div class="k">Лучший КПД текущего цикла</div><div class="v" id="c-cycle" style="font-size:20px">—</div></div>
<div class="card"><div class="k">GPU (GTX 1070)</div><div class="v" id="c-gpu" style="font-size:20px">—</div></div>
</div> </div>
<div class="grid2"> <div class="grid2">
@@ -70,7 +72,7 @@ PAGE_HTML = r"""<!doctype html>
<button class="tgl" id="tab-vel" onclick="setRank('vel')">по скорости</button> <button class="tgl" id="tab-vel" onclick="setRank('vel')">по скорости</button>
</h2> </h2>
<table> <table>
<thead><tr><th>КПД</th><th>Скор., м/с</th><th>Ступ.</th><th>Цена, ₽</th><th>Режим</th></tr></thead> <thead><tr><th>КПД</th><th>Скор., м/с</th><th>Ступ.</th><th>Цена, ₽</th><th>Режим</th><th>Когда найден</th></tr></thead>
<tbody id="top"></tbody> <tbody id="top"></tbody>
</table> </table>
<p class="muted" style="font-size:12px">Клик по строке — полная детализация прогона (расстояния, номиналы, физика).</p> <p class="muted" style="font-size:12px">Клик по строке — полная детализация прогона (расстояния, номиналы, физика).</p>
@@ -107,13 +109,23 @@ function setRank(m){ rankMode=m; if(lastOverview) renderTop(lastOverview);
document.getElementById('tab-eff').classList.toggle('active', m==='eff'); document.getElementById('tab-eff').classList.toggle('active', m==='eff');
document.getElementById('tab-vel').classList.toggle('active', m==='vel'); } 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){ function renderTop(o){
const rows = rankMode==='vel' ? (o.top_by_velocity||[]) : (o.top||[]); const rows = rankMode==='vel' ? (o.top_by_velocity||[]) : (o.top||[]);
const top = document.getElementById('top'); top.innerHTML=''; const top = document.getElementById('top'); top.innerHTML='';
rows.forEach(t=>{ rows.forEach(t=>{
const tr=document.createElement('tr'); tr.className='clk'; const tr=document.createElement('tr'); tr.className='clk';
tr.innerHTML=`<td>${fmtPct(t.efficiency)}</td><td>${fmt(t.exit_velocity_mps,1)}</td>`+ tr.innerHTML=`<td>${fmtPct(t.efficiency)}</td><td>${fmt(t.exit_velocity_mps,1)}</td>`+
`<td>${t.n_stages??''}</td><td>${fmt(t.cost_rub,0)}</td><td><span class="pill">${t.search_mode}</span></td>`; `<td>${t.n_stages??''}</td><td>${fmt(t.cost_rub,0)}</td><td><span class="pill">${t.search_mode}</span></td>`+
`<td class="muted" style="white-space:nowrap">${fmtWhen(t.timestamp)}</td>`;
tr.onclick=()=>showDetail(t.run_id); tr.onclick=()=>showDetail(t.run_id);
top.appendChild(tr); top.appendChild(tr);
}); });
@@ -131,6 +143,12 @@ async function refresh(){
let best = (o.top && o.top.length) ? o.top[0].efficiency : null; 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; 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-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); renderTop(o);
const hist=document.getElementById('hist'); hist.innerHTML=''; const hist=document.getElementById('hist'); hist.innerHTML='';

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

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

View File

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

View File

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

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

View File

@@ -220,3 +220,19 @@ def test_eddy_reflection_bounded_at_high_frequency():
r_lo = eddy_reflected_resistance_ohm(**kwargs, char_omega_rad_s=om_lo) r_lo = eddy_reflected_resistance_ohm(**kwargs, char_omega_rad_s=om_lo)
assert r_hi < 0.5 * resistive_approx(om_hi) # высокие ω: ограничена assert r_hi < 0.5 * resistive_approx(om_hi) # высокие ω: ограничена
assert abs(r_lo - resistive_approx(om_lo)) < 0.05 * resistive_approx(om_lo) # низкие: сходится assert abs(r_lo - resistive_approx(om_lo)) < 0.05 * resistive_approx(om_lo) # низкие: сходится
def test_iron_penetration_fraction_limits():
"""Диффузия поля: тонкий снаряд/медленный импульс — всё сечение; толстый
снаряд на быстром импульсе — только скин-кольцо (эксплойт «30%» закрыт)."""
from gausse.physics.ac_resistance import iron_penetration_fraction
# очень низкая частота: поле успевает пропитать всё сечение
assert iron_penetration_fraction(0.005, 1.4e-7, 500.0, 1.0) == 1.0
# сталь ⌀9.3мм на ω~1350 рад/с (медленная катушка из эксплойта):
# намагничивается лишь внешнее кольцо — заметно меньше половины сечения
frac = iron_penetration_fraction(0.00465, 1.4e-7, 500.0, 1350.0)
assert 0.02 < frac < 0.5
# быстрее импульс -> тоньше кольцо (монотонность по ω)
frac_fast = iron_penetration_fraction(0.00465, 1.4e-7, 500.0, 13500.0)
assert frac_fast < frac

View File

@@ -99,6 +99,36 @@ def test_writer_survives_duplicate_run_id_and_keeps_draining_queue(tmp_path):
conn.close() 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): def _writer_worker(queue, worker_id, n):
for i in range(n): for i in range(n):
queue.put(_record(f"proc-{worker_id}-run-{i}")) queue.put(_record(f"proc-{worker_id}-run-{i}"))