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

View File

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

13
deploy/README.md Normal file
View File

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

37
deploy/evolve_forever.sh Executable file
View File

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

View File

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

View File

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

13
deploy/gpustat_loop.sh Executable file
View File

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

View File

@@ -167,6 +167,97 @@ def _get_fused_step(cp):
return step
def _get_fused_pair(cp):
"""Два слитых ядра на шаг: RK4+физика и cut-логика.
Профиль на 1070: RK4 был слит, но бухгалтерия обрыва (_magnetic_energy,
_overlap, ~15 where на шаг) запускала десятки мелких ядер и съедала >90%
времени. Одним ядром не влезает в лимит параметров CUDA (4096 байт,
«Formal parameter space overflowed»), поэтому два сбалансированных:
step_phys — RK4 + потери шага + остаточная магнитная энергия;
step_cut — обрывы/пики/выходные состояния, вообще без физических параметров.
"""
if "pair" in _FUSED_STEP_CACHE:
return _FUSED_STEP_CACHE["pair"]
LN2 = 0.6931471805599453
def _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
s1 = 0.5 * (1.0 + cp.tanh((x + hs) / w * 0.5))
s2 = 0.5 * (1.0 + cp.tanh((hs - x) / w * 0.5))
ov = s1 * s2
dov = (s1 * s2 / w) * (s2 - s1)
z = i / isat
tz = cp.tanh(z)
g = isat * tz
gp = 1.0 - tz * tz
az = cp.abs(z)
G = isat * isat * (az + cp.log1p(cp.exp(-2.0 * az)) - LN2)
dl_di = lair + liron * ov * gp
reff = rt + red * ov
dq = -i
di = (q / C - i * reff - liron * dov * g * v) / dl_di
dv = (liron * dov * G - cp.tanh(v * 100.0) * (fc + dc * v * v)) / m
return dq, di, v, dv
@cp.fuse()
def step_phys(q, i, x, v, dt, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
a = _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
b = _der(q + dt * 0.5 * a[0], i + dt * 0.5 * a[1], x + dt * 0.5 * a[2], v + dt * 0.5 * a[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
c = _der(q + dt * 0.5 * b[0], i + dt * 0.5 * b[1], x + dt * 0.5 * b[2], v + dt * 0.5 * b[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
d = _der(q + dt * c[0], i + dt * c[1], x + dt * c[2], v + dt * c[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
qn = q + dt / 6.0 * (a[0] + 2 * b[0] + 2 * c[0] + d[0])
in_ = i + dt / 6.0 * (a[1] + 2 * b[1] + 2 * c[1] + d[1])
xn = x + dt / 6.0 * (a[2] + 2 * b[2] + 2 * c[2] + d[2])
vn = v + dt / 6.0 * (a[3] + 2 * b[3] + 2 * c[3] + d[3])
s1o = 0.5 * (1.0 + cp.tanh((x + hs) / w * 0.5))
s2o = 0.5 * (1.0 + cp.tanh((hs - x) / w * 0.5))
ov_old = s1o * s2o
s1n = 0.5 * (1.0 + cp.tanh((xn + hs) / w * 0.5))
s2n = 0.5 * (1.0 + cp.tanh((hs - xn) / w * 0.5))
ov_new = s1n * s2n
# потери шага: I²R_eff (вихревые × overlap) + трение/воздух (трапеция)
step_diss = 0.5 * (i * i * (rt + red * ov_old) + in_ * in_ * (rt + red * ov_new)) * dt
fric = 0.5 * ((fc + dc * v * v) * cp.abs(v) + (fc + dc * vn * vn) * cp.abs(vn)) * dt
# остаточная магнитная энергия (с насыщением) в состоянии ДО шага
z = i / isat
g = isat * cp.tanh(z)
az = cp.abs(z)
G = isat * isat * (az + cp.log1p(cp.exp(-2.0 * az)) - LN2)
w_mag = 0.5 * lair * i * i + liron * ov_old * (i * g - G)
return qn, in_, xn, vn, step_diss + fric, w_mag
@cp.fuse()
def step_cut(q, i, x, v, qn, in_, xn, vn, sdiss, w_mag,
done, committed, past_peak, exit_v, exit_x, exit_q, peak_i, e_diss):
active = ~done
zero = 0.0 * q
e2 = e_diss + cp.where(active, sdiss, zero)
peak2 = cp.maximum(peak_i, cp.where(active, cp.abs(in_), zero))
past2 = past_peak | (active & (i > 1.0) & (in_ < i))
crossed = active & (i > 0) & (in_ <= 0)
decayed = active & past2 & (in_ < 0.1) & ~crossed # ток < удержания ключа
local_min = active & past2 & (in_ > i) & ~crossed & ~decayed
cut_state = local_min | decayed # обрыв в состоянии ДО шага
cut = crossed | cut_state
frac = cp.where(crossed, i / (i - in_ + 1e-30), zero)
exit_v2 = cp.where(crossed, v + frac * (vn - v), cp.where(cut_state, v, exit_v))
exit_x2 = cp.where(crossed, x + frac * (xn - x), cp.where(cut_state, x, exit_x))
exit_q2 = cp.where(crossed, q + frac * (qn - q), cp.where(cut_state, q, exit_q))
e3 = e2 + cp.where(cut_state, w_mag, zero) # freewheel-диод
committed2 = committed | cut
blew = active & ~cut & ~(cp.abs(in_) < 1e30) # inf/nan: стиффный взрыв
done2 = done | cut | blew
adv = active & ~blew
q2 = cp.where(adv, qn, q)
i2 = cp.where(adv, in_, i)
x2 = cp.where(adv, xn, x)
v2 = cp.where(adv, vn, v)
return q2, i2, x2, v2, done2, committed2, past2, exit_v2, exit_x2, exit_q2, peak2, e3
_FUSED_STEP_CACHE["pair"] = (step_phys, step_cut)
return _FUSED_STEP_CACHE["pair"]
def integrate_batch_discharge(
xp,
q0,
@@ -197,9 +288,14 @@ def integrate_batch_discharge(
energy_diss = xp.zeros(n, dtype=xp.float64)
_old_err = xp.seterr(all="ignore") if hasattr(xp, "seterr") else None # стиффные конфиги переполняют fixed-step
# на cupy — слитое в одно ядро RK4-ядро (cupy.fuse), на numpy — обычный путь
# на cupy — ВЕСЬ шаг (RK4 + cut-логика) одним fused-ядром; numpy — обычный путь
is_cupy = xp.__name__ == "cupy"
fused_pair = None
if is_cupy:
try:
fused_pair = _get_fused_pair(xp)
except Exception:
fused_pair = None # честный fallback на пошаговый путь
fused = _get_fused_step(xp)
hs = (params.coil_length_m + params.slug_length_m) / 2
# проверку «все ли готовы» делаем НЕ каждый шаг: на GPU это device->host
@@ -212,6 +308,27 @@ def integrate_batch_discharge(
q_old, i_old, x_old, v_old = q, i, x, v
if fused_pair is not None:
# весь шаг = 2 ядра: RK4+физика, затем cut-логика (вместо ~20 мелких)
try:
qn, in_, xn, vn, sdiss, w_mag = fused_pair[0](
q_old, i_old, x_old, v_old, dt, hs, params.smoothing_width_m,
params.i_sat_a, params.l_air_h, params.l_iron_coeff, params.capacitance_f,
params.r_total_ohm, params.r_eddy_coeff_ohm,
params.retard_const_n, params.drag_coeff_n, params.mass_kg,
)
(q, i, x, v, done, committed, past_peak,
exit_v, exit_x, exit_q, peak_current, energy_diss) = fused_pair[1](
q_old, i_old, x_old, v_old, qn, in_, xn, vn, sdiss, w_mag,
done, committed, past_peak, exit_v, exit_x, exit_q, peak_current, energy_diss,
)
continue
except Exception:
if step == 0: # fuse не собрался — честный откат на пошаговый путь
fused_pair = None
else:
raise
if is_cupy:
q_new, i_new, x_new, v_new, overlap_old, overlap_new = fused(
q_old, i_old, x_old, v_old, dt, hs, params.smoothing_width_m,

View File

@@ -23,32 +23,118 @@ from pathlib import Path
from gausse.components.database import ComponentDatabase
from gausse.gpu.backend import get_backend, to_cpu
from gausse.gpu.batch_integrator import integrate_batch_discharge, params_from_models
from gausse.optim.objective import MODEL_VERSION, build_detail
from gausse.gpu.batch_integrator import NO_SATURATION_I_SAT, BatchDischargeParams, integrate_batch_discharge
from gausse.optim.objective import MODEL_VERSION
from gausse.optim.progress_log import ProgressLogger, default_log_path
from gausse.optim.search_space import SearchBounds, decode, genome_to_dict, sample_genome
from gausse.physics.constants import switch_pulse_limit_a
from gausse.sim.coilgun import CoilgunResult
from gausse.sim.stage import build_stage_physics
from gausse.storage.database import insert_runs, open_connection
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
class _State:
genome: object
config: object
initial_x_m: float
initial_v_mps: float
coil_centers: list # абсолютные центры катушек вдоль трубы
pre: GenomePre
cur_x: float # текущая глобальная координата снаряда
cur_v: float # текущая скорость
energy_in_j: float = 0.0
kinetic_delta_j: float = 0.0
alive: bool = True
reason: str | None = None
failed_stage_index: int | None = None
def _coast_velocity(v0: float, distance_m: float, retard_const_n: float, drag_coeff: float, mass_kg: float) -> float:
@@ -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
def _prepare_stage(stage, projectile, cur_x_global, cur_v, coil_center_global):
"""Готовит разряд ступени: физика + аналитический fire-state из текущего (x,v)."""
def _prepare_stage(sp: StagePre, mass_kg: float, cur_x_global, cur_v, coil_center_global):
"""Аналитический fire-state ступени из текущего (x,v) — только арифметика."""
if cur_v <= 1e-6:
return None, "снаряд остановился/пошёл назад"
phys = build_stage_physics(stage, projectile)
# вырожденный контур (см. sim/stage.py) — тот же честный быстрый отказ
tau_worst = phys.inductance_model.l_air_h / (phys.r_total_ohm + phys.r_eddy_coeff_ohm + 1e-12)
if tau_worst < 1e-7:
return None, "вырожденная катушка: L/R < 100 нс (вне области модели)"
sensor_global = coil_center_global - stage.sensor_to_coil_distance_m
if sp.degenerate_reason:
return None, sp.degenerate_reason
sensor_global = coil_center_global - sp.sensor_to_coil_m
if sensor_global < cur_x_global - 1e-9:
return None, "датчик позади снаряда (не сработает)"
# подлёт до датчика — с трением о трубку и воздухом (та же физика, что на CPU)
cp_ = phys.circuit_params
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:
return None, "снаряд остановлен трением о трубку, не долетев до датчика"
if stage.sensor.kind == "inductive":
if stage.sensor.sensitivity_v_per_mps * v_at_sensor < (stage.sensor.threshold_v or 0.0):
if sp.sensor_kind == "inductive":
if sp.sensor_sens_v_per_mps * v_at_sensor < sp.sensor_threshold_v:
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 * fire_delay
x_fire_global = sensor_global + v_at_sensor * sp.fire_delay_s
x_fire_local = x_fire_global - coil_center_global
q0 = phys.capacitance_f * stage.charge_voltage_v
return {
"phys": phys, "q0": q0, "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, "stage": stage, "mass": projectile.mass_kg,
"sp": sp, "x_fire": x_fire_local, "v_fire": v_at_sensor,
"coil_center": coil_center_global, "mass": mass_kg,
}, None
def _simulate_states(xp, states: list) -> None:
"""Раунд-за-раундом прогоняет разряды всех живых ступеней батчами (мутирует states)."""
if not states:
return
max_ns = max(len(s.pre.stages) for s in states)
for s_idx in range(max_ns):
prepared = [] # (state, prep)
for st in states:
if not st.alive or len(st.pre.stages) <= s_idx:
continue
p, reason = _prepare_stage(
st.pre.stages[s_idx], st.pre.mass_kg, st.cur_x, st.cur_v, st.pre.coil_centers[s_idx]
)
if p is None:
st.alive = False
st.reason = f"ступень {s_idx}: {reason}"
st.failed_stage_index = s_idx
else:
prepared.append((st, p))
if not prepared:
continue
def col(get):
return xp.asarray([get(p["sp"], p) for _, p in prepared], dtype=xp.float64)
params = BatchDischargeParams(
capacitance_f=col(lambda sp, p: sp.capacitance_f),
r_total_ohm=col(lambda sp, p: sp.r_total_ohm),
mass_kg=col(lambda sp, p: p["mass"]),
l_air_h=col(lambda sp, p: sp.l_air_h),
l_iron_coeff=col(lambda sp, p: sp.l_iron_coeff),
coil_length_m=col(lambda sp, p: sp.coil_length_m),
slug_length_m=col(lambda sp, p: sp.slug_length_m),
smoothing_width_m=col(lambda sp, p: sp.smoothing_width_m),
i_sat_a=col(lambda sp, p: sp.i_sat_a),
r_eddy_coeff_ohm=col(lambda sp, p: sp.r_eddy_coeff_ohm),
retard_const_n=col(lambda sp, p: sp.retard_const_n),
drag_coeff_n=col(lambda sp, p: sp.drag_coeff_n),
)
out = integrate_batch_discharge(
xp,
col(lambda sp, p: sp.q0),
col(lambda sp, p: p["x_fire"]),
col(lambda sp, p: p["v_fire"]),
params, dt=2e-6, max_steps=15000,
)
exit_v = to_cpu(xp, out["exit_v"]); exit_x = to_cpu(xp, out["exit_x"])
peak_i = to_cpu(xp, out["peak_current"]); feas = to_cpu(xp, out["feasible"])
for k, (st, p) in enumerate(prepared):
sp = p["sp"]
if not bool(feas[k]):
st.alive = False
st.reason = f"ступень {s_idx}: разряд не скоммутировался"
st.failed_stage_index = s_idx
continue
if float(peak_i[k]) > sp.pulse_limit_a:
st.alive = False
st.reason = f"ступень {s_idx}: пиковый ток {float(peak_i[k]):.0f}А > импульсного предела ключа ({sp.pulse_limit_a:.0f}А)"
st.failed_stage_index = s_idx
continue
ev = float(exit_v[k])
st.kinetic_delta_j += 0.5 * p["mass"] * (ev**2 - p["v_fire"] ** 2)
st.energy_in_j += sp.energy_in_j
st.cur_v = ev
st.cur_x = p["coil_center"] + float(exit_x[k])
def evaluate_genomes_gpu(
xp, genomes: list, db: ComponentDatabase, bounds: SearchBounds, executor=None,
) -> list:
"""Оценка списка геномов ОДНИМ батчем (для эволюции): та же физика и та же
формула фитнеса, что в objective.evaluate (КПД либо -1+доля пройденных
ступеней), но разряды всех геномов интегрируются вместе на GPU/numpy.
Возвращает только то, что нужно фитнесу (без detail/стоимости): записи
для БД параллельно собирает пул процессов (`gpu_record_worker`).
executor — пул для ПАРАЛЛЕЛЬНОГО предвычисления физики ступеней
(precompute_worker): без него один поток Python готовит физику всей
популяции и GPU простаивает, ожидая данные.
"""
from gausse.optim.objective import EvaluationResult
if executor is not None:
pres = list(executor.map(precompute_worker, genomes, chunksize=64))
else:
pres = [precompute_genome(g, db, bounds) for g in genomes]
states = [
_State(g, pre, cur_x=pre.initial_x_m, cur_v=pre.initial_v_mps)
for g, pre in zip(genomes, pres)
]
_simulate_states(xp, states)
results = []
for st in states:
ok = st.alive and st.energy_in_j > 0
if ok:
eff = st.kinetic_delta_j / st.energy_in_j
results.append(EvaluationResult(
feasible=True, cost_rub=None, fitness=eff, efficiency=eff,
exit_velocity_mps=st.cur_v,
energy_breakdown={
"total_energy_in_j": st.energy_in_j,
"total_kinetic_energy_delta_j": st.kinetic_delta_j,
},
))
else:
n_stages = len(st.pre.stages)
progress = (st.failed_stage_index or 0) / n_stages if n_stages else 0.0
results.append(EvaluationResult(
feasible=False, cost_rub=None, fitness=-1.0 + progress,
reason=st.reason or "нет ни одной сработавшей ступени",
failed_stage_index=st.failed_stage_index,
energy_breakdown={},
))
return results
def run_gpu_sweep(
db_path: Path,
n_runs: int,
@@ -125,55 +323,10 @@ def run_gpu_sweep(
states = []
for _ in range(n):
g = sample_genome(db, bounds, rng)
cfg, ix, iv = decode(g, db, bounds)
centers = [0.0]
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))
pre = precompute_genome(g, db, bounds)
states.append(_State(g, pre, cur_x=pre.initial_x_m, cur_v=pre.initial_v_mps))
max_ns = max(len(s.config.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.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])
_simulate_states(xp, states)
records = []
for st in states:
@@ -194,17 +347,15 @@ def run_gpu_sweep(
def _record(st: _State, db, backend, efficiency, exit_v):
from gausse.optim.objective import decoded_summary
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(
run_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(),
search_mode=f"gpu-sweep-{backend}",
genome_json=json.dumps(genome_to_dict(st.genome)),
decoded_summary_json=json.dumps(detail, ensure_ascii=False),
decoded_summary_json=json.dumps(decoded_summary(st.genome, db), ensure_ascii=False),
feasible=feasible,
model_version=MODEL_VERSION,
infeasible_reason=st.reason,

View File

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

View File

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

View File

@@ -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))
def iron_penetration_fraction(
slug_radius_m: float,
slug_resistivity_ohm_m: float,
mu_r: float,
omega_rad_s: float,
) -> float:
"""Доля сечения снаряда, реально намагниченная за импульс (диффузия поля).
Переменное поле проникает в проводящее железо на глубину скин-слоя
δ = √(2ρ/(ω·μ₀·μᵣ)) — за миллисекундный импульс у стали это доли
миллиметра. Внутренность снаряда не успевает намагнититься и силы не
даёт. Работает кольцо глубиной δ: A_eff/A = 1(1δ/a)².
ЧЕСТНО: это одночастотная оценка (ω=1/√(LC)) решения уравнения диффузии,
а не само решение; ошибка — десятки процентов. Но без неё модель считала,
что намагничивается ВСЁ сечение, и оптимизатор строил «медленные»
катушки с нефизично высоким КПД (30%+).
"""
if omega_rad_s <= 0 or slug_resistivity_ohm_m <= 0 or mu_r < 1 or slug_radius_m <= 0:
return 1.0
delta = math.sqrt(2.0 * slug_resistivity_ohm_m / (omega_rad_s * MU_0 * mu_r))
if delta >= slug_radius_m:
return 1.0
return 1.0 - (1.0 - delta / slug_radius_m) ** 2
def dowell_ac_factor(
wire_bare_d_m: float,
wire_insulated_d_m: float,

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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