Compare commits
55 Commits
f22fd89612
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6b52ba4660 | ||
|
|
9a783ec8aa | ||
|
|
7732577f31 | ||
|
|
c232f1d387 | ||
|
|
5c119990b7 | ||
|
|
e394fd6d59 | ||
|
|
17323335e0 | ||
|
|
51464b2139 | ||
|
|
fe34bb3672 | ||
|
|
005ffaf04b | ||
|
|
e52026d2b2 | ||
|
|
9b98dbe27b | ||
|
|
b2fc51bb72 | ||
|
|
b43885c6e1 | ||
|
|
4f0c38500b | ||
|
|
a4219c7212 | ||
|
|
185d7f4361 | ||
|
|
2af292e094 | ||
|
|
e6b877bab7 | ||
|
|
ac459d26a4 | ||
|
|
6fba55f2d6 | ||
|
|
83a3e72bd5 | ||
|
|
965c18f327 | ||
|
|
1480f4ce1a | ||
|
|
ef83512601 | ||
|
|
ecd6508f29 | ||
|
|
78af686e6d | ||
|
|
8708d0f922 | ||
|
|
a0f02e246a | ||
|
|
d4affa9574 | ||
|
|
83a4a7b805 | ||
|
|
ae6481ceb0 | ||
|
|
fd4c9ad541 | ||
|
|
4cfa946315 | ||
|
|
7803e147a7 | ||
|
|
13c1d417d6 | ||
|
|
35cbbb04c6 | ||
|
|
4d16825f7d | ||
|
|
4f94811b49 | ||
|
|
85425e27bc | ||
|
|
b0a35e08d5 | ||
|
|
7f040c3cf5 | ||
|
|
b3151e94a5 | ||
|
|
1ca807dcbe | ||
|
|
950be5fcf2 | ||
|
|
70b732d170 | ||
|
|
d487021c2f | ||
|
|
1029f40318 | ||
|
|
57a83cd4f8 | ||
|
|
65d8b633ad | ||
|
|
d73341d3a2 | ||
|
|
891ac8fee0 | ||
|
|
56c7ab9c1d | ||
|
|
069ea848e2 | ||
|
|
acf846ab29 |
14
.dockerignore
Normal file
14
.dockerignore
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
.git
|
||||||
|
.venv
|
||||||
|
venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.pytest_cache
|
||||||
|
*.egg-info
|
||||||
|
build
|
||||||
|
dist
|
||||||
|
out
|
||||||
|
.claude
|
||||||
|
*.sqlite3
|
||||||
|
*.sqlite3-wal
|
||||||
|
*.sqlite3-shm
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -10,5 +10,7 @@ dist/
|
|||||||
*.sqlite3-wal
|
*.sqlite3-wal
|
||||||
*.sqlite3-shm
|
*.sqlite3-shm
|
||||||
out/
|
out/
|
||||||
|
results/*
|
||||||
|
!results/.gitkeep
|
||||||
.DS_Store
|
.DS_Store
|
||||||
.claude/settings.local.json
|
.claude/settings.local.json
|
||||||
|
|||||||
18
Dockerfile
Normal file
18
Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml README.md ./
|
||||||
|
COPY src ./src
|
||||||
|
COPY tests ./tests
|
||||||
|
|
||||||
|
RUN pip install --no-cache-dir -e ".[dev]"
|
||||||
|
|
||||||
|
# Не root: иначе всё, что контейнер пишет в примонтированный ./results
|
||||||
|
# (SQLite-база, отчёты), становится на хосте файлами root:root.
|
||||||
|
RUN useradd --create-home --uid 1000 gausse
|
||||||
|
RUN mkdir -p /app/results && chown -R gausse:gausse /app
|
||||||
|
USER gausse
|
||||||
|
|
||||||
|
ENTRYPOINT ["gausse"]
|
||||||
|
CMD ["--help"]
|
||||||
157
PLAN.md
157
PLAN.md
@@ -34,6 +34,12 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
|
|||||||
Полный план архитектуры: см. историю обсуждения / `physics`, `sim`, `optim`,
|
Полный план архитектуры: см. историю обсуждения / `physics`, `sim`, `optim`,
|
||||||
`storage`, `report` модули ниже.
|
`storage`, `report` модули ниже.
|
||||||
|
|
||||||
|
**Docker — основной способ запуска** (по требованию пользователя): `Dockerfile`
|
||||||
|
+ `docker-compose.yml` в корне репозитория, образ собран и провалидирован —
|
||||||
|
внутри контейнера запускаются все 37 тестов и CLI (`docker compose run --rm
|
||||||
|
--entrypoint pytest gausse -q`). Результаты (SQLite и отчёты, когда появятся)
|
||||||
|
монтируются в `./results` на хосте, чтобы переживать пересборку образа.
|
||||||
|
|
||||||
## Чек-лист этапов
|
## Чек-лист этапов
|
||||||
|
|
||||||
- [x] **Этап 0 — Скелет проекта**: `pyproject.toml`, `README.md`, `.gitignore`, пакеты `src/gausse/*`, этот файл, первый коммит.
|
- [x] **Этап 0 — Скелет проекта**: `pyproject.toml`, `README.md`, `.gitignore`, пакеты `src/gausse/*`, этот файл, первый коммит.
|
||||||
@@ -41,9 +47,148 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
|
|||||||
- [x] **Этап 2 — Физическое ядро**: `physics/constants.py`, `inductance.py` (Уилер + ферромагнитный сердечник + размагничивание), `force.py`, `circuit.py` (ОДУ RLC). Юнит-тесты: аналитическое RLC-решение, согласованность dL/dx.
|
- [x] **Этап 2 — Физическое ядро**: `physics/constants.py`, `inductance.py` (Уилер + ферромагнитный сердечник + размагничивание), `force.py`, `circuit.py` (ОДУ RLC). Юнит-тесты: аналитическое RLC-решение, согласованность dL/dx.
|
||||||
- [x] **Этап 3 — Датчики и одна ступень**: `physics/sensors.py` (оба типа как события solve_ivp), `sim/stage.py` (полёт → триггер → разряд → энергобаланс). Тест на сохранение энергии + найден/исправлен баг насыщения (см. выше).
|
- [x] **Этап 3 — Датчики и одна ступень**: `physics/sensors.py` (оба типа как события solve_ivp), `sim/stage.py` (полёт → триггер → разряд → энергобаланс). Тест на сохранение энергии + найден/исправлен баг насыщения (см. выше).
|
||||||
- [x] **Этап 4 — Многоступенчатая цепочка**: `sim/coilgun.py` — сквозная координата, отбраковка нереализуемых конфигураций. Дымовой тест на реальной базе компонентов (`test_real_components_smoke.py`) подтверждает: полный путь реальные JSON → физика → цепочка работает (пример: 22.3 м/с, КПД 3.4% — честный неоптимизированный результат).
|
- [x] **Этап 4 — Многоступенчатая цепочка**: `sim/coilgun.py` — сквозная координата, отбраковка нереализуемых конфигураций. Дымовой тест на реальной базе компонентов (`test_real_components_smoke.py`) подтверждает: полный путь реальные JSON → физика → цепочка работает (пример: 22.3 м/с, КПД 3.4% — честный неоптимизированный результат).
|
||||||
- [ ] **Этап 5 — Хранилище результатов**: `storage/schema.py` + `database.py` — SQLite (WAL), таблица `runs` со всеми прогонами (успех/провал + честная причина), однопроцессный писатель поверх многопроцессной очереди.
|
- [x] **Этап 5 — Хранилище результатов**: `storage/schema.py` + `database.py` — SQLite (WAL), таблица `runs` со всеми прогонами (успех/провал + честная причина), однопроцессный писатель поверх многопроцессной очереди. Тест с реальными `multiprocessing.Process` (не моками) поймал реальную проблему: коллизия `run_id` роняла writer и молча останавливала осушение очереди на весь sweep — писатель теперь переживает ошибку вставки одной записи (лог в stderr) и продолжает работу.
|
||||||
- [ ] **Этап 6 — Поиск и оптимизация**: `optim/search_space.py` (геном переменной длины), `objective.py` (КПД), дешёвый квазистатический предфильтр, `optim/sweep.py` (Monte Carlo/LHS, миллионы прогонов), `optim/evolutionary.py` (ГА + coordinate-descent полировка) — всё пишется в общую таблицу `runs`.
|
- [x] **Этап 6 — Поиск и оптимизация**: `optim/search_space.py` (геном переменной длины — число ступеней тоже эволюционирует), `objective.py` (fitness=КПД, честный мягкий штраф за нереализуемость пропорционально пройденным ступеням), `optim/sweep.py` (параллельный Monte Carlo, каждый прогон в SQLite), `optim/evolutionary.py` ((μ+λ)-ГА + Nelder-Mead полировка непрерывных параметров лучшего генома). Тест поймал реальный баг в `crossover()` — IndexError при скрещивании двух одноступенчатых геномов (пустой список зазоров), исправлено и покрыто регрессией.
|
||||||
- [ ] **Этап 7 — Отчётность**: `report/plots.py`, `summary.py`, `bom.py`, раздел "ограничения модели", `cli.py` (`gausse sweep/evolve/simulate/report`).
|
- [ ] **Не сделано**: отдельный "дешёвый квазистатический предфильтр" перед полным ODE не реализован (кроме уже встроенной в `sim/stage.py` дешёвой проверки порога индукционного датчика). Если миллионы прогонов на сервере окажутся слишком медленными, это первое место для ускорения.
|
||||||
- [ ] `report/animate.py` — анимация одного прогона: положение снаряда в трубе по времени + визуализация поля/тока каждой катушки (свечение/интенсивность цвета ~ ток), сохранение в GIF/MP4 (matplotlib `FuncAnimation`). Нужна по запросу пользователя — "графика где будет показана симуляция пролёта цилиндра по трубе и электромагнитные поля в каждый момент времени".
|
- [x] **Этап 7 — Отчётность**: `report/plots.py` (ток/поле/скорость по ступеням), `summary.py` (честная сводка + раздел "ограничения модели"), `bom.py` (спецификация деталей с ценами), `animate.py` (GIF: снаряд летит по трубе, катушки светятся пропорционально току — по запросу пользователя), `cli.py` (`gausse sweep/evolve/simulate/report`, все 4 команды реально вызывают соответствующие модули, не заглушки). Проверено сквозным прогоном через `docker compose run`: sweep → report с анимацией на реальной базе компонентов, лучший найденный результат — КПД 32.8% за 982₽ (не выдумано, из реального SQLite).
|
||||||
- [ ] **Этап 8 — Сквозная проверка**: резюмируемый прогон на реальной базе компонентов, проверка честной записи в SQLite, финальный отчёт (КПД, скорость, стоимость, сравнение датчиков) с разделом ограничений.
|
- [x] **Этап 8 — Сквозная проверка**: реальный прогон через `docker compose run` — sweep на 5000 конфигураций (16 воркеров, ~59с, 30.5% реализуемо), затем `evolve` (15 поколений x 40 особей = 600 оценок, ~18с) поверх той же базы, затем `report --top 3 --animate`.
|
||||||
- [ ] **Этап 9 — GPU-ускорение массового sweep (сервер с GTX 1070)**: после того как CPU/`scipy.solve_ivp`-модель провалидирована тестами (Этап 2-4) — батч-версия интегратора с ФИКСИРОВАННЫМ шагом (RK4/полу-неявная схема), считающая сразу N траекторий параллельно как один тензор (`cupy`, если доступна CUDA, иначе векторизованный `numpy`/`numba`), для прогона по-настоящему миллионов конфигураций на сервере. Важно: сначала корректность на CPU, потом скорость на GPU — численные результаты GPU-пути должны сверяться с CPU-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки".
|
- **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `evolve` показал 387% — оказалось, `efficiency` считался как `exit_kinetic_energy / energy_in`, а `exit_kinetic_energy` включает фиксированный "бесплатный" толчок `initial_v_mps`, не учтённый в `energy_in`. Для лёгкого снаряда этот толчок доминировал и КПД пробивал 100%. Исправлено на `kinetic_energy_delta / energy_in` (энергия, реально добавленная катушками) — эта величина математически не может превысить 1 (следует из поэтапного энергобаланса). После исправления лучший результат на тот момент: **1 ступень, КПД 80.97%, скорость 26.4 м/с, снаряд Ст3 ⌀4мм x 30мм, стоимость 1029₽** (провод cu-petv2-1.0mm, конденсатор 22мкФ/400В, ключ IRG4PC50F, оптический датчик). *(Историческая запись: это физика v1 БЕЗ потерь в железе, скина и трения — после их добавления такие КПД невозможны, см. «хронологию честности» ниже.)*
|
||||||
|
- **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 5000 прогонах: индукционный датчик (`inductive-pickup-lm393`) дал **0.2% реализуемых** конфигураций (6 из 2965, где он стоял хотя бы на одной ступени), тогда как оптический (TCST2103) и Холла (A3144E) — **~45-47%** реализуемых каждый. Это количественно подтверждает опасение, высказанное в самом начале обсуждения плана: индукционная катушка-датчик ненадёжна именно потому, что требует скорости выше её порога (здесь — фиксированный старт 3 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости.
|
||||||
|
- Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат.
|
||||||
|
- [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. *(УСТАРЕЛО с 2026-07-11, коммит 5c11999: полный build_detail больше НЕ хранится на каждый прогон — база раздувалась до 70ГБ; в decoded_summary_json теперь дешёвая сводка decoded_summary(), а полная детализация пересимулируется на лету из genome_json при просмотре прогона — web/rebuild.py + web/stats.run_detail.)* Ход эксперимента пишется в `<db>.log` (`optim/progress_log.py`): прогресс, доля реализуемых, скорость, ETA, отметки нового лучшего КПД. Веб-дашборд `gausse serve` (`src/gausse/web/`, только stdlib `http.server`, self-contained HTML) — счётчики, гистограмма КПД, топ конфигураций с drill-down, причины отказа, хвост лога, автообновление 3с. В `docker-compose.yml` сервис `web` на порту 8000. 77 тестов, проверено в Docker.
|
||||||
|
|
||||||
|
## Батареи конденсаторов + графики в веб-морде (по запросу пользователя)
|
||||||
|
|
||||||
|
- **Батареи конденсаторов** (`components/schema.py::CapacitorBank`): каждая ступень собирает банку из N одинаковых конденсаторов — `n_series` (1-6, складывает напряжение и ESR, делит ёмкость) × `n_parallel` (1-8, складывает ёмкость и макс.ток, делит ESR). Цена и количество масштабируются. Параметры банки — в геноме (`cap_series`, `cap_parallel` на ступень), в BOM ("N шт (Sпосл × Pпар)") и в подробной БД (`capacitor_bank`: состав, суммарные C/V/ESR/макс.ток). Диагностика по току: `bank_current_over_limit` / `switch_current_over_limit` (предупреждение, не жёсткий отказ — в базе паспортный, а не импульсный ток; параллельные конденсаторы дают реальный выигрыш через сниженный ESR).
|
||||||
|
- **Уже было (тоже запрашивалось)**: разная толщина провода на разных катушках (`wire_idx` на ступень) и длина провода (`winding_geometry.total_wire_length_m` → сопротивление/цена/БД).
|
||||||
|
- **Графики в дашборде**: клик по прогону → графики скорость/поле/ток строятся на лету (`web/render.py`, `/api/run/<id>/plot/<kind>.png`) + GIF пролёта по кнопке (`/api/run/<id>/anim.gif`). Рендер matplotlib сериализован локом (Agg не потокобезопасен). 86 тестов.
|
||||||
|
|
||||||
|
## Развёртывание на сервере (Proxmox 192.168.20.254 / VM 106 "test-math" = **192.168.20.47**)
|
||||||
|
|
||||||
|
**СТАТУС: развёрнуто, работает АВТОНОМНО (CPU-эволюция под systemd), GPU проброшен и рабочий.**
|
||||||
|
|
||||||
|
- VM 106: Ubuntu 24.04, 5 ядер, 12ГБ RAM. После перевода на q35 (нужен для
|
||||||
|
PCIe-проброса) интерфейс переименовался и IP сменился **.45 → .47**
|
||||||
|
(netplan починен привязкой по MAC).
|
||||||
|
- [x] Пуш на gitea (`gitea.jze9.ru/jze9/gausse.git`) — рабочий процесс: пуш с
|
||||||
|
локальной машины → `git pull` на VM.
|
||||||
|
- [x] GPU: `qm set 106 --machine q35 --hostpci0 0000:01:00,pcie=1`; в VM
|
||||||
|
драйвер NVIDIA 580 + CUDA, `cupy-cuda12x[ctk]` в `~/gausse/.venv`.
|
||||||
|
`nvidia-smi` видит GTX 1070 без Code 43.
|
||||||
|
- [x] Веб-морда: **http://192.168.20.47:8000** (Docker-сервис `web`).
|
||||||
|
- [x] **Автономная эволюция — systemd-сервис `gausse-evolve`** (пользователь
|
||||||
|
явно потребовал: «алгоритм должен считать сам, годами, без тебя»):
|
||||||
|
`/etc/systemd/system/gausse-evolve.service` крутит `evolve_forever.sh` —
|
||||||
|
вечный цикл `run_evolution(80 поколений × 300 особей, polish=True)` с новым
|
||||||
|
случайным семенем на каждом заходе (свежая популяция = «революция» против
|
||||||
|
застревания в локальном оптимуме). `Restart=always` + `enabled` → переживает
|
||||||
|
и падение процесса, и перезагрузку VM. Всё пишет в одну базу
|
||||||
|
`~/gausse/results/gausse.sqlite3`; «запланировано N» на дашборде — план
|
||||||
|
ТЕКУЩЕГО цикла, сам поиск бесконечен. Управление: `sudo systemctl
|
||||||
|
status|stop|restart gausse-evolve`, лог `journalctl -u gausse-evolve`.
|
||||||
|
- Обновление кода на VM: `cd ~/gausse && git pull && docker compose build web
|
||||||
|
&& docker compose up -d web && sudo systemctl restart gausse-evolve`.
|
||||||
|
|
||||||
|
- [x] **Этап 9 — GPU-ускорение массового sweep — СДЕЛАН**:
|
||||||
|
`gpu/batch_integrator.py` — батч-RK4 фиксированного шага [Q,I,x,v] на N
|
||||||
|
конфигураций одним тензором (numpy/cupy через `xp`), та же физика, что
|
||||||
|
CPU-путь (общий `sim/stage.build_stage_physics` — один источник истины).
|
||||||
|
Весь RK4-шаг слит в ОДНО `cupy.fuse`-ядро → **GTX 1070: 740 конф/с vs 193
|
||||||
|
на numpy = 3.8x** (наивный порт был 1.3x — упирался в запуск ядер);
|
||||||
|
SYNC_EVERY=256 убирает device→host синхронизацию на каждом шаге.
|
||||||
|
`gpu/batch_sweep.py` — МНОГОСТУПЕНЧАТЫЙ sweep раунд-за-раундом (подлёт
|
||||||
|
аналитически, состояние снаряда переносится между раундами), CLI
|
||||||
|
`gausse sweep --gpu`. Требование честности выполнено: GPU сверен с
|
||||||
|
CPU-эталоном — расхождение exit_v **0.084%** (tests/test_batch_sweep.py,
|
||||||
|
test_batch_integrator.py). Стиффные конфиги, переполняющие фикс-шаг,
|
||||||
|
честно бракуются (blew_up), а не записываются мусором.
|
||||||
|
- [x] cut-логика дослита: весь шаг = 2 fused-ядра (одно не влезает в лимит
|
||||||
|
параметров CUDA 4096Б). Оценка 1000 геномов: 98.5с → 6.6с (15x), ~150
|
||||||
|
геномов/с на 1070, сверка с numpy-эталоном 0.001%.
|
||||||
|
- [x] GPU-батч в эволюции: `run_evolution(use_gpu=True)` оценивает всё
|
||||||
|
поколение одним батчем (`evaluate_genomes_gpu`), та же формула фитнеса;
|
||||||
|
полировка (Nelder-Mead) остаётся точной на CPU. Сервис `gausse-evolve`
|
||||||
|
переведён на venv+GPU: 120 поколений × 1000 особей, ~115/с стабильно.
|
||||||
|
|
||||||
|
## Физика: найденные завышения КПД и их исправления (хронология честности)
|
||||||
|
|
||||||
|
Пользователь ловил нереальные цифры; каждое исправление РОНЯЛО КПД к
|
||||||
|
реальности. Версия модели пишется в каждую запись БД (`model_version`),
|
||||||
|
прогоны разных версий не смешиваются.
|
||||||
|
|
||||||
|
1. **Поле 13 Тл** → насыщение железа через единое потокосцепление
|
||||||
|
λ(x,I)=L_air·I+L_iron·overlap(x)·g(I), g=I_sat·tanh(I/I_sat); сила и ЭДС
|
||||||
|
из одного λ (coenergy) → энергия сохраняется, поле упирается в B_sat.
|
||||||
|
2. **Ток сверх рейтинга ключа** → жёсткая отбраковка по импульсному пределу.
|
||||||
|
3. **КПД 83.9%: не было потерь в железе** → вихревые токи: снаряд =
|
||||||
|
короткозамкнутый виток, отражённое R_eddy=(ωM)²/R_e × overlap(x)
|
||||||
|
(physics/losses.py). Лучший упал 83.9% → ~47%, медиана ~0%.
|
||||||
|
4. **Три горба тока за выстрел** (физически невозможно — конденсатор не
|
||||||
|
перезарядить за мкс) → одиночный импульс: обрыв на первом нуле тока ИЛИ
|
||||||
|
первом локальном минимуме; остаточная энергия катушки → freewheel-диод,
|
||||||
|
считается точно через magnetic_energy (с насыщением). Энергобаланс сошёлся.
|
||||||
|
5. **gausse-physics-v2 (2026-07-08)**: скин-эффект + эффект близости обмотки
|
||||||
|
(метод Доуэлла на ω=1/√(LC), `physics/ac_resistance.py`) — для толстого
|
||||||
|
провода во многих слоях R_AC в разы выше DC; **трение о трубку**
|
||||||
|
(0.35·m·g) и **сопротивление воздуха** (½ρ·Cd·A·v²) во всей динамике
|
||||||
|
(разряд, подлёт с событием «снаряд остановлен трением», GPU-ядро,
|
||||||
|
аналитический межступенчатый пролёт — точная квадратура); паспортные
|
||||||
|
импульсные токи ключей из даташитов (`pulse_current_a`: ITSM/IDM/ICM)
|
||||||
|
вместо generic-множителей + починен вводивший в заблуждение флаг
|
||||||
|
(`switch_current_over_continuous_rating` — норма для импульса,
|
||||||
|
`switch_current_over_pulse_limit` — брак). КПД ступени теперь может быть
|
||||||
|
слегка отрицательным (трение съело больше слабой катушки) — это честно.
|
||||||
|
Для сравнения на одной и той же эволюции: v1 давал «38%» там, где v2 даёт
|
||||||
|
~8% — столько стоили неучтённые скин и трение.
|
||||||
|
6. **gausse-physics-v3**: эволюция нашла «КПД 82%» → вскрытие генома: тонкий
|
||||||
|
снаряд (⌀4мм) в толстой катушке (⌀29мм) — модель умножала ВСЮ L на
|
||||||
|
(μ_eff−1), как будто железо заполняет всё сечение. Фикс: коэффициент
|
||||||
|
заполнения A_снаряда/A_катушки (iron_fill_factor). Плюс лечение зависаний
|
||||||
|
решателя: tanh-сглаживание сухого трения (разрывный sign(v) дробил шаг
|
||||||
|
бесконечно), событие тока удержания ключа (передемпфированный хвост),
|
||||||
|
полная формула отражения вихрей (ωM)²R₂/(R₂²+(ωL₂)²), гард вырожденных
|
||||||
|
катушек (L/R<100нс). Бенч: 200 геномов 608с → 22с.
|
||||||
|
7. **gausse-physics-v4**: эволюция нашла «30%» через МЕДЛЕННУЮ катушку
|
||||||
|
(600+ витков, ~1мс, 36А — там слабы и Доуэлл, и вихревые, и трение).
|
||||||
|
Неучтённая физика: за импульс поле проникает в сталь на глубину скин-слоя
|
||||||
|
(~доли мм) — внутренность снаряда не намагничивается. Фикс:
|
||||||
|
iron_penetration_fraction = 1−(1−δ/a)² множит fill factor. После v4
|
||||||
|
лучший КПД эволюции — единицы процентов, что соответствует реальным
|
||||||
|
reluctance-пушкам. Урок зафиксирован: подозрительно красивый КПД =
|
||||||
|
вскрывать лучший геном и искать обойдённый канал потерь.
|
||||||
|
|
||||||
|
## Требования пользователя, закрытые после Этапа 8
|
||||||
|
|
||||||
|
- **Снаряд до 200 г** (⌀ до 28мм, длина до 150мм, длина зажата по плотности).
|
||||||
|
- **Труба — часть поиска**: геном несёт внутренний диаметр (бор) + толщину
|
||||||
|
стенки; внешний диаметр трубы = внутренний диаметр катушки (влияет на
|
||||||
|
поле); снаряд обязан влезать в бор с зазором.
|
||||||
|
- **До 10 ступеней**; у каждой катушки СВОЙ датчик на своём расстоянии
|
||||||
|
(sensor_to_coil_distance_m на ступень; datчик не может попасть в предыдущую
|
||||||
|
катушку — repair()).
|
||||||
|
- **Ссылки на магазины** (`url`) у каждого компонента: в JSON-базе, BOM
|
||||||
|
(«Купить») и в детализации прогона.
|
||||||
|
- **Максимально детальная запись** (`build_detail`): труба
|
||||||
|
внутр/стенка/внеш, масса снаряда в граммах и влезает-ли-в-бор,
|
||||||
|
внутр/внеш диаметры катушки, состав банки конденсаторов, DC/AC
|
||||||
|
сопротивление обмотки, все позиции датчиков и катушек вдоль трубы,
|
||||||
|
энергобаланс каждой ступени. *(С 2026-07-11 НЕ хранится в БД построчно,
|
||||||
|
а строится на лету из genome_json при просмотре прогона/отчёте —
|
||||||
|
хранение полной детализации на 12.9М строк раздуло базу до 70ГБ.)*
|
||||||
|
- **Анимация переделана** (была «время вырезается, результата не видно»):
|
||||||
|
кадры на равномерной сетке ФИЗИЧЕСКОГО времени (а не по индексам массива,
|
||||||
|
из-за чего плотные точки разряда съедали все кадры и подлёт выпадал),
|
||||||
|
множитель замедления в кадре; в кадре сам результат эксперимента — шапка
|
||||||
|
(выход м/с, КПД, ступени, энергия, масса), панель тока каждой катушки во
|
||||||
|
времени, панель скорости снаряда, бегущий курсор, датчики на трубе.
|
||||||
|
|
||||||
|
## Ограничения модели / следующие шаги по реализму (если потребуются)
|
||||||
|
|
||||||
|
- Поршневой эффект воздуха в трубе (учтено только лобовое сопротивление).
|
||||||
|
- Диффузия поля в снаряде (наш r_eddy — сосредоточенная оценка 1-го порядка).
|
||||||
|
- Спектр импульса для Доуэлла (взята одна характерная частота).
|
||||||
|
- Нагрев провода за импульс/серию; коэффициент трения не измерен (0.35).
|
||||||
|
- Гистерезис посчитан (losses.hysteresis_energy_j) и СОЗНАТЕЛЬНО не в
|
||||||
|
динамике: <0.1% энергии выстрела — включение изображало бы ложную точность.
|
||||||
|
- Индуктивность — Уилер+размагничивание, не МКЭ/FEA.
|
||||||
|
- Осторожно: не тюнить модель под «желаемую» цифру КПД — только физика.
|
||||||
|
|||||||
85
README.md
85
README.md
@@ -1,20 +1,89 @@
|
|||||||
# gausse
|
# gausse
|
||||||
|
|
||||||
Симулятор и оптимизатор многоступенчатого электромагнитного ускорителя
|
Симулятор и оптимизатор многоступенчатого электромагнитного ускорителя
|
||||||
(coilgun) на реальных, доступных в рознице компонентах.
|
(coilgun / пушка Гаусса) на реальных, доступных в рознице компонентах
|
||||||
|
(ChipDip, procontact74.ru, cable.ru — у каждой детали в базе ссылка на
|
||||||
|
магазин).
|
||||||
|
|
||||||
Полный план и чек-лист этапов — в [PLAN.md](PLAN.md).
|
Главный принцип — **честность**: все прогоны (включая нереализуемые, с
|
||||||
|
причиной отказа) пишутся в SQLite; физическая модель не приукрашивается ради
|
||||||
|
красивых цифр, а каждое известное упрощение задокументировано. История
|
||||||
|
проекта — это в том числе история найденных и исправленных завышений КПД
|
||||||
|
(80%+ → реалистичные единицы процентов), см. [PLAN.md](PLAN.md).
|
||||||
|
|
||||||
## Установка
|
## Что умеет
|
||||||
|
|
||||||
|
- **Физика** (`src/gausse/physics/`): RLC-разряд конденсаторной банки через
|
||||||
|
катушку с ферромагнитным снарядом; насыщение железа через единое
|
||||||
|
потокосцепление λ(x,I) (энергия сохраняется); вихревые потери в снаряде;
|
||||||
|
коэффициент заполнения (тонкий снаряд не усиливает весь поток) и
|
||||||
|
диффузия поля (за импульс намагничивается только скин-кольцо);
|
||||||
|
скин-эффект + эффект близости обмотки (метод Доуэлла); трение о трубку и
|
||||||
|
сопротивление воздуха; одиночный импульс (тиристор не перезапускается);
|
||||||
|
паспортные импульсные токи ключей (ITSM/IDM/ICM из даташитов).
|
||||||
|
- **Ступени** (`src/gausse/sim/`): у каждой катушки свой датчик
|
||||||
|
(оптика/Холл/индукционный) на своём расстоянии; подлёт → триггер →
|
||||||
|
задержки датчика и ключа → разряд → энергобаланс; цепочка до 10 ступеней.
|
||||||
|
- **Поиск** (`src/gausse/optim/`): геном переменной длины (число ступеней
|
||||||
|
эволюционирует), Monte-Carlo sweep и (μ+λ)-эволюция с Nelder-Mead
|
||||||
|
полировкой; всё в одну SQLite-базу (WAL, писатель поверх очереди).
|
||||||
|
- **GPU** (`src/gausse/gpu/`): батч-интегратор RK4 (numpy/cupy), весь шаг
|
||||||
|
слит в одно cupy.fuse-ядро — на GTX 1070 ~3.8x к CPU; многоступенчатый
|
||||||
|
GPU-sweep раунд-за-раундом И GPU-эволюция (use_gpu=True: всё поколение —
|
||||||
|
один батч, ~150 геномов/с на 1070); сверено с CPU-эталоном (<0.1%).
|
||||||
|
- **Отчёты** (`src/gausse/report/`, `src/gausse/web/`): веб-дашборд с
|
||||||
|
drill-down до каждой детали и энергобаланса ступени, графики ток/поле/
|
||||||
|
скорость, GIF-анимация выстрела (равномерное физическое время, результат
|
||||||
|
эксперимента в кадре), BOM с ценами и ссылками «купить».
|
||||||
|
|
||||||
|
## Запуск (Docker — основной способ)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose run --rm --entrypoint pytest gausse -q # тесты
|
||||||
|
docker compose up -d web # дашборд :8000
|
||||||
|
docker compose run --rm gausse sweep --n 100000 --db /app/results/gausse.sqlite3
|
||||||
|
docker compose run --rm gausse evolve --generations 80 --population 300 \
|
||||||
|
--db /app/results/gausse.sqlite3
|
||||||
|
docker compose run --rm gausse report --top 3 --animate --db /app/results/gausse.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
GPU-режим (нужны NVIDIA-драйвер и `pip install cupy-cuda12x[ctk]`, вне Docker):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
gausse sweep --gpu --n 1000000 --db results/gausse.sqlite3
|
||||||
|
```
|
||||||
|
|
||||||
|
Результаты (SQLite + лог хода эксперимента `<db>.log`) живут в `./results`,
|
||||||
|
примонтированном в контейнер, и переживают пересборку образа.
|
||||||
|
|
||||||
|
## Установка без Docker (локальная разработка)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
python3 -m venv .venv
|
python3 -m venv .venv
|
||||||
source .venv/bin/activate
|
source .venv/bin/activate
|
||||||
pip install -e ".[dev]"
|
pip install -e ".[dev]"
|
||||||
```
|
|
||||||
|
|
||||||
## Тесты
|
|
||||||
|
|
||||||
```bash
|
|
||||||
pytest
|
pytest
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Автономная работа на сервере
|
||||||
|
|
||||||
|
Эволюция оформлена как systemd-сервис `gausse-evolve` (вечный цикл: 120
|
||||||
|
поколений × 1000 особей на GPU, по окончании — новый цикл со свежей
|
||||||
|
случайной популяцией; `Restart=always`, автозапуск при загрузке). Дашборд — Docker-
|
||||||
|
сервис `web` на :8000, читает ту же базу. Счётчик «запланировано» на
|
||||||
|
дашборде — план **текущего цикла**, а не всего поиска: поиск бесконечен.
|
||||||
|
|
||||||
|
## Ограничения модели (честно)
|
||||||
|
|
||||||
|
- Индуктивность — инженерная модель (Уилер + размагничивание сфероида +
|
||||||
|
tanh-перекрытие), не МКЭ; точная картина поля требует FEA.
|
||||||
|
- Вихревые потери снаряда — сосредоточенный виток; диффузия поля в снаряд —
|
||||||
|
одночастотная оценка скин-кольца, а не решение уравнения диффузии.
|
||||||
|
- Доуэлл берёт одну характерную частоту ω=1/√(LC), без спектра импульса.
|
||||||
|
- Воздух — лобовое сопротивление; поршневой эффект столба воздуха в трубе
|
||||||
|
не учтён. Трение — константа 0.35 (сталь-пластик), не измерено.
|
||||||
|
- Гистерезис снаряда посчитан и сознательно не включён в динамику (<0.1%).
|
||||||
|
- Нагрев провода за импульс и от серии выстрелов не моделируется.
|
||||||
|
|
||||||
|
Версия физики пишется в каждую запись (`model_version`, сейчас `gausse-physics-v4`) — прогоны разных моделей не смешиваются.
|
||||||
|
|||||||
13
deploy/README.md
Normal file
13
deploy/README.md
Normal 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
37
deploy/evolve_forever.sh
Executable 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
|
||||||
15
deploy/gausse-evolve.service
Normal file
15
deploy/gausse-evolve.service
Normal 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
|
||||||
13
deploy/gausse-gpustat.service
Normal file
13
deploy/gausse-gpustat.service
Normal 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
13
deploy/gpustat_loop.sh
Executable file
@@ -0,0 +1,13 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# Пишет загрузку GPU в results/gpustat.json для веб-морды (раз в 5с)
|
||||||
|
cd /home/user/gausse
|
||||||
|
while true; do
|
||||||
|
line=$(nvidia-smi --query-gpu=utilization.gpu,memory.used,memory.total,power.draw,temperature.gpu --format=csv,noheader,nounits 2>/dev/null)
|
||||||
|
if [ -n "$line" ]; then
|
||||||
|
IFS=", " read -r util mem memtot pw temp <<< "$line"
|
||||||
|
printf '{"ts": %s, "util_pct": %s, "mem_used_mib": %s, "mem_total_mib": %s, "power_w": %s, "temp_c": %s}\n' \
|
||||||
|
"$(date +%s)" "$util" "$mem" "$memtot" "$pw" "$temp" > results/gpustat.json.tmp
|
||||||
|
mv results/gpustat.json.tmp results/gpustat.json
|
||||||
|
fi
|
||||||
|
sleep 5
|
||||||
|
done
|
||||||
21
docker-compose.yml
Normal file
21
docker-compose.yml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
services:
|
||||||
|
# Веб-морда: дашборд хода эксперимента. Порт 8000 наружу.
|
||||||
|
# docker compose up -d web
|
||||||
|
# Открыть http://<адрес-сервера>:8000
|
||||||
|
web:
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- ./results:/app/results
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
command: ["serve", "--db", "/app/results/gausse.sqlite3", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
# Разовые команды (sweep/evolve/report). Пример:
|
||||||
|
# docker compose run --rm gausse sweep --db /app/results/gausse.sqlite3 --n 100000 --seed 1
|
||||||
|
gausse:
|
||||||
|
build: .
|
||||||
|
volumes:
|
||||||
|
- ./results:/app/results
|
||||||
|
entrypoint: ["gausse"]
|
||||||
|
command: ["--help"]
|
||||||
0
results/.gitkeep
Normal file
0
results/.gitkeep
Normal file
@@ -1,17 +1,159 @@
|
|||||||
import argparse
|
import argparse
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim.evolutionary import run_evolution
|
||||||
|
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
|
||||||
|
from gausse.optim.sweep import run_sweep
|
||||||
|
from gausse.report.animate import animate_run
|
||||||
|
from gausse.report.bom import build_bom, render_bom_text
|
||||||
|
from gausse.report.plots import save_all
|
||||||
|
from gausse.report.summary import build_summary, render_summary_json, render_summary_text
|
||||||
|
from gausse.sim.coilgun import run_coilgun
|
||||||
|
from gausse.storage.database import fetch_runs, open_connection
|
||||||
|
from gausse.web.server import serve as web_serve
|
||||||
|
|
||||||
|
|
||||||
|
def _bounds_from_args(args) -> SearchBounds:
|
||||||
|
kwargs = {}
|
||||||
|
if getattr(args, "max_stages", None):
|
||||||
|
kwargs["max_stages"] = args.max_stages
|
||||||
|
return SearchBounds(**kwargs)
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_sweep(args) -> int:
|
||||||
|
bounds = _bounds_from_args(args)
|
||||||
|
if args.gpu:
|
||||||
|
from gausse.gpu.batch_sweep import run_gpu_sweep
|
||||||
|
|
||||||
|
summary = run_gpu_sweep(Path(args.db), n_runs=args.n, bounds=bounds, seed=args.seed)
|
||||||
|
print(f"GPU-путь backend={summary['backend']} (одноступ.)")
|
||||||
|
else:
|
||||||
|
summary = run_sweep(
|
||||||
|
Path(args.db), n_runs=args.n, bounds=bounds, n_workers=args.workers, seed=args.seed
|
||||||
|
)
|
||||||
|
rate = 100 * summary["n_feasible"] / summary["n_runs"] if summary["n_runs"] else 0.0
|
||||||
|
print(f"Прогнано {summary['n_runs']}, реализуемо {summary['n_feasible']} ({rate:.1f}%)")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_evolve(args) -> int:
|
||||||
|
bounds = _bounds_from_args(args)
|
||||||
|
summary = run_evolution(
|
||||||
|
Path(args.db),
|
||||||
|
n_generations=args.generations,
|
||||||
|
population_size=args.population,
|
||||||
|
bounds=bounds,
|
||||||
|
n_workers=args.workers,
|
||||||
|
seed=args.seed,
|
||||||
|
polish=not args.no_polish,
|
||||||
|
)
|
||||||
|
print(f"Оценено геномов: {summary['n_evaluated']}")
|
||||||
|
print(
|
||||||
|
f"Лучший найденный: feasible={summary['best_feasible']} "
|
||||||
|
f"efficiency={summary['best_efficiency']} cost={summary['best_cost_rub']}"
|
||||||
|
)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _write_full_report(run_id: str, genome_json: str, out_dir: Path, animate: bool, db, bounds) -> None:
|
||||||
|
genome = genome_from_dict(json.loads(genome_json))
|
||||||
|
config, _initial_x, _initial_v = decode(genome, db, bounds)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
save_all(result, out_dir)
|
||||||
|
summary = build_summary(result, config, db)
|
||||||
|
(out_dir / "summary.txt").write_text(render_summary_text(summary), encoding="utf-8")
|
||||||
|
(out_dir / "summary.json").write_text(render_summary_json(summary), encoding="utf-8")
|
||||||
|
(out_dir / "bom.txt").write_text(render_bom_text(build_bom(config, db)), encoding="utf-8")
|
||||||
|
if animate:
|
||||||
|
animate_run(result, config, str(out_dir / "run.gif"))
|
||||||
|
print(f"run_id={run_id}: отчёт записан в {out_dir}")
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_serve(args) -> int:
|
||||||
|
web_serve(Path(args.db), host=args.host, port=args.port)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_report(args) -> int:
|
||||||
|
conn = open_connection(Path(args.db))
|
||||||
|
rows = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=args.top)
|
||||||
|
if not rows:
|
||||||
|
print(f"В {args.db} нет ни одного реализуемого прогона.")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
db = ComponentDatabase.load()
|
||||||
|
bounds = SearchBounds()
|
||||||
|
out_dir = Path(args.out)
|
||||||
|
for rank, row in enumerate(rows, start=1):
|
||||||
|
run_out = out_dir / f"rank{rank}_{row.run_id[:8]}"
|
||||||
|
print(f"#{rank}: efficiency={row.efficiency:.4f} cost={row.cost_rub:.0f}₽")
|
||||||
|
_write_full_report(row.run_id, row.genome_json, run_out, args.animate, db, bounds)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def cmd_simulate(args) -> int:
|
||||||
|
conn = open_connection(Path(args.db))
|
||||||
|
rows = fetch_runs(conn)
|
||||||
|
row = next((r for r in rows if r.run_id == args.run_id), None)
|
||||||
|
if row is None:
|
||||||
|
print(f"run_id {args.run_id} не найден в {args.db}")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
db = ComponentDatabase.load()
|
||||||
|
bounds = SearchBounds()
|
||||||
|
_write_full_report(row.run_id, row.genome_json, Path(args.out), args.animate, db, bounds)
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> int:
|
def main(argv: list[str] | None = None) -> int:
|
||||||
parser = argparse.ArgumentParser(prog="gausse")
|
parser = argparse.ArgumentParser(prog="gausse")
|
||||||
subparsers = parser.add_subparsers(dest="command", required=True)
|
subparsers = parser.add_subparsers(dest="command", required=True)
|
||||||
subparsers.add_parser("sweep", help="массовый Monte Carlo/LHS перебор конфигураций")
|
|
||||||
subparsers.add_parser("evolve", help="эволюционный поиск поверх базы прогонов")
|
sweep_p = subparsers.add_parser("sweep", help="массовый Monte Carlo перебор конфигураций")
|
||||||
subparsers.add_parser("simulate", help="прогнать одну конфигурацию")
|
sweep_p.add_argument("--db", required=True, help="путь к SQLite-файлу результатов")
|
||||||
subparsers.add_parser("report", help="построить отчёт по сохранённым результатам")
|
sweep_p.add_argument("--n", type=int, required=True, help="число прогонов")
|
||||||
|
sweep_p.add_argument("--workers", type=int, default=None)
|
||||||
|
sweep_p.add_argument("--seed", type=int, default=None)
|
||||||
|
sweep_p.add_argument("--max-stages", type=int, default=None)
|
||||||
|
sweep_p.add_argument("--gpu", action="store_true", help="GPU/cupy батч-путь (одноступ., быстрый)")
|
||||||
|
sweep_p.set_defaults(func=cmd_sweep)
|
||||||
|
|
||||||
|
evolve_p = subparsers.add_parser("evolve", help="эволюционный поиск поверх базы прогонов")
|
||||||
|
evolve_p.add_argument("--db", required=True)
|
||||||
|
evolve_p.add_argument("--generations", type=int, default=20)
|
||||||
|
evolve_p.add_argument("--population", type=int, default=30)
|
||||||
|
evolve_p.add_argument("--workers", type=int, default=None)
|
||||||
|
evolve_p.add_argument("--seed", type=int, default=None)
|
||||||
|
evolve_p.add_argument("--max-stages", type=int, default=None)
|
||||||
|
evolve_p.add_argument("--no-polish", action="store_true")
|
||||||
|
evolve_p.set_defaults(func=cmd_evolve)
|
||||||
|
|
||||||
|
simulate_p = subparsers.add_parser("simulate", help="пересчитать и построить отчёт по одному run_id")
|
||||||
|
simulate_p.add_argument("--db", required=True)
|
||||||
|
simulate_p.add_argument("--run-id", required=True)
|
||||||
|
simulate_p.add_argument("--out", required=True, help="папка для отчёта")
|
||||||
|
simulate_p.add_argument("--animate", action="store_true")
|
||||||
|
simulate_p.set_defaults(func=cmd_simulate)
|
||||||
|
|
||||||
|
report_p = subparsers.add_parser("report", help="отчёт по лучшим сохранённым результатам")
|
||||||
|
report_p.add_argument("--db", required=True)
|
||||||
|
report_p.add_argument("--out", required=True)
|
||||||
|
report_p.add_argument("--top", type=int, default=1)
|
||||||
|
report_p.add_argument("--animate", action="store_true")
|
||||||
|
report_p.set_defaults(func=cmd_report)
|
||||||
|
|
||||||
|
serve_p = subparsers.add_parser("serve", help="веб-морда: дашборд хода эксперимента")
|
||||||
|
serve_p.add_argument("--db", required=True, help="путь к SQLite-файлу результатов")
|
||||||
|
serve_p.add_argument("--host", default="0.0.0.0")
|
||||||
|
serve_p.add_argument("--port", type=int, default=8000)
|
||||||
|
serve_p.set_defaults(func=cmd_serve)
|
||||||
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
parser.error(f"команда '{args.command}' ещё не реализована")
|
return args.func(args)
|
||||||
return 1
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -4,53 +4,59 @@
|
|||||||
"capacitance_uf": 22.0,
|
"capacitance_uf": 22.0,
|
||||||
"voltage_v": 400.0,
|
"voltage_v": 400.0,
|
||||||
"esr_ohm": 0.45,
|
"esr_ohm": 0.45,
|
||||||
"max_current_a": 5.0,
|
"max_current_a": 40.0,
|
||||||
"price": 140.0,
|
"price": 140.0,
|
||||||
"source": "REAL цена: chipdip.ru, категория 'kondensator-400v' (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR не указан продавцом, ОЦЕНКА 0.3-0.6 Ом (типично для класса, взята середина)"
|
"source": "REAL цена: chipdip.ru, категория 'kondensator-400v' (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR ОЦЕНКА 0.3-0.6 Ом. max_current_a — ОЦЕНКА ИМПУЛЬСНОГО тока для одиночного coilgun-выстрела (не ripple из даташита), ограничено ESR/фольгой; в пачке параллельно (как у пользователя, 8шт) суммируется",
|
||||||
|
"url": "https://www.chipdip.ru/catalog/popular/kondensator-400v"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "cap-68uf-400v",
|
"part_number": "cap-68uf-400v",
|
||||||
"capacitance_uf": 68.0,
|
"capacitance_uf": 68.0,
|
||||||
"voltage_v": 400.0,
|
"voltage_v": 400.0,
|
||||||
"esr_ohm": 0.3,
|
"esr_ohm": 0.3,
|
||||||
"max_current_a": 8.0,
|
"max_current_a": 60.0,
|
||||||
"price": 440.0,
|
"price": 440.0,
|
||||||
"source": "REAL цена: chipdip.ru, категория 'kondensator-400v' (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR не указан продавцом, ОЦЕНКА (типично для класса)"
|
"source": "REAL цена: chipdip.ru (https://www.chipdip.ru/catalog/popular/kondensator-400v); ESR ОЦЕНКА. max_current_a — ОЦЕНКА импульсного тока (одиночный выстрел), не ripple",
|
||||||
|
"url": "https://www.chipdip.ru/catalog/popular/kondensator-400v"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "cap-180uf-400v",
|
"part_number": "cap-180uf-400v",
|
||||||
"capacitance_uf": 180.0,
|
"capacitance_uf": 180.0,
|
||||||
"voltage_v": 400.0,
|
"voltage_v": 400.0,
|
||||||
"esr_ohm": 0.15,
|
"esr_ohm": 0.15,
|
||||||
"max_current_a": 15.0,
|
"max_current_a": 120.0,
|
||||||
"price": 109.9,
|
"price": 109.9,
|
||||||
"source": "REAL цена: procontact74.ru, CHANG 180uF 400V 105C (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/); на странице встречались две цены (109.9₽ и 870₽ у разных продавцов) - взята бюджетная; ESR ОЦЕНКА"
|
"source": "REAL цена: procontact74.ru, CHANG 180uF 400V 105C (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/); ESR ОЦЕНКА. max_current_a — ОЦЕНКА импульсного тока, не ripple",
|
||||||
|
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/180uf-400v-105c-2530-411213/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "cap-470uf-400v",
|
"part_number": "cap-470uf-400v",
|
||||||
"capacitance_uf": 470.0,
|
"capacitance_uf": 470.0,
|
||||||
"voltage_v": 400.0,
|
"voltage_v": 400.0,
|
||||||
"esr_ohm": 0.08,
|
"esr_ohm": 0.08,
|
||||||
"max_current_a": 25.0,
|
"max_current_a": 250.0,
|
||||||
"price": 498.0,
|
"price": 498.0,
|
||||||
"source": "REAL цена: procontact74.ru, JCCON 470uF 400V 105C 30x45мм (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/), полная страница проверена, 325.84₽ от 5шт, 279.29₽ от 25шт; ESR не указан продавцом, ОЦЕНКА (для банок этого размера обычно ниже, ~0.05-0.1 Ом)"
|
"source": "REAL цена: procontact74.ru, JCCON 470uF 400V 105C 30x45мм (https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/), проверено, 325.84₽ от 5шт; ESR ОЦЕНКА ~0.05-0.1 Ом. max_current_a — ОЦЕНКА импульсного тока (низкий ESR -> держит сотни А в импульсе), не ripple",
|
||||||
|
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/kondensator-elektroliticheskiy----470uf-400v-105-c-35-45-cd294-elzet/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "cap-1000uf-400v",
|
"part_number": "cap-1000uf-400v",
|
||||||
"capacitance_uf": 1000.0,
|
"capacitance_uf": 1000.0,
|
||||||
"voltage_v": 400.0,
|
"voltage_v": 400.0,
|
||||||
"esr_ohm": 0.06,
|
"esr_ohm": 0.06,
|
||||||
"max_current_a": 40.0,
|
"max_current_a": 350.0,
|
||||||
"price": 700.0,
|
"price": 700.0,
|
||||||
"source": "ОЦЕНКА (низкая уверенность): товарная категория подтверждена реально на procontact74.ru/asenergi.ru, но точная цена конкретной позиции не подтверждена при парсинге; цена экстраполирована из наблюдаемого тренда ₽/мкФ для банок этого класса"
|
"source": "ОЦЕНКА (низкая уверенность по цене): категория подтверждена на procontact74.ru/asenergi.ru, точная цена не подтверждена. max_current_a — ОЦЕНКА импульсного тока для coilgun, не ripple",
|
||||||
|
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/41-kondensatory/411-elektroliticheskie-kondensatory/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "cap-2200uf-400v",
|
"part_number": "cap-2200uf-400v",
|
||||||
"capacitance_uf": 2200.0,
|
"capacitance_uf": 2200.0,
|
||||||
"voltage_v": 400.0,
|
"voltage_v": 400.0,
|
||||||
"esr_ohm": 0.05,
|
"esr_ohm": 0.05,
|
||||||
"max_current_a": 60.0,
|
"max_current_a": 450.0,
|
||||||
"price": 1400.0,
|
"price": 1400.0,
|
||||||
"source": "ОЦЕНКА (низкая уверенность): категория подтверждена на asenergi.ru (https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html), точная цена не подтверждена; цена экстраполирована из тренда ₽/мкФ, брать с осторожностью"
|
"source": "ОЦЕНКА (низкая уверенность по цене): категория подтверждена на asenergi.ru (https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html). max_current_a — ОЦЕНКА импульсного тока для coilgun, не ripple",
|
||||||
|
"url": "https://asenergi.ru/catalog/kondensatory-elektroliticheskie/2200mkf.html"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
"mu_r": 550.0,
|
"mu_r": 550.0,
|
||||||
"b_sat_tesla": 1.9,
|
"b_sat_tesla": 1.9,
|
||||||
"price_per_kg": 55.0,
|
"price_per_kg": 55.0,
|
||||||
"source": "Цена REAL: горячекатаный круг Ст3, ros-met.com/metallurg-moskva.ru, ~49-60₽/кг (одна из позиций ~51.4₽/кг за тонну); плотность/μr/B_sat — ОЦЕНКА (стандартные справочные значения для конструкционной стали, не найдено источника с числами конкретно для Ст3)"
|
"resistivity_ohm_m": 1.6e-07,
|
||||||
|
"source": "Цена REAL: горячекатаный круг Ст3, ros-met.com/metallurg-moskva.ru, ~49-60₽/кг (одна из позиций ~51.4₽/кг за тонну); плотность/μr/B_sat/resistivity — ОЦЕНКА (справочные значения для конструкционной стали; удельное сопротивление ~1.6e-7 Ом·м)",
|
||||||
|
"url": "https://ros-met.com/krug-stalnoy-kupit/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Сталь 10 (низкоуглеродистая)",
|
"name": "Сталь 10 (низкоуглеродистая)",
|
||||||
@@ -13,7 +15,9 @@
|
|||||||
"mu_r": 1500.0,
|
"mu_r": 1500.0,
|
||||||
"b_sat_tesla": 2.05,
|
"b_sat_tesla": 2.05,
|
||||||
"price_per_kg": 55.0,
|
"price_per_kg": 55.0,
|
||||||
"source": "ОЦЕНКА по всем полям: отдельного розничного объявления на 'Сталь 10' не найдено, цена взята той же полосы, что и Ст3 (~50-70₽/кг); μr выше, чем у Ст3, из-за более высокой чистоты низкоуглеродистой стали — справочная оценка, не измерение"
|
"resistivity_ohm_m": 1.4e-07,
|
||||||
|
"source": "ОЦЕНКА по всем полям: отдельного розничного объявления на 'Сталь 10' не найдено, цена той же полосы, что и Ст3 (~50-70₽/кг); μr выше из-за чистоты; удельное сопротивление ~1.4e-7 Ом·м — справочная оценка",
|
||||||
|
"url": "https://ros-met.com/krug-stalnoy-kupit/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "Армко-железо (чистое железо)",
|
"name": "Армко-железо (чистое железо)",
|
||||||
@@ -21,6 +25,8 @@
|
|||||||
"mu_r": 3500.0,
|
"mu_r": 3500.0,
|
||||||
"b_sat_tesla": 2.15,
|
"b_sat_tesla": 2.15,
|
||||||
"price_per_kg": 160.0,
|
"price_per_kg": 160.0,
|
||||||
"source": "ОЦЕНКА по всем полям: специализированный товар, не найдено самостоятельной розничной цены; цена оценена как ~3x от Ст3 на основе общего указания '2-4x дороже обычной стали'; μr/B_sat — стандартные справочные значения для хорошо отожжённого чистого железа"
|
"resistivity_ohm_m": 1e-07,
|
||||||
|
"source": "ОЦЕНКА по всем полям: специализированный товар, розничной цены не найдено; цена ~3x от Ст3; μr/B_sat — справочные для отожжённого чистого железа; удельное сопротивление ~1.0e-7 Ом·м (чище -> ниже)",
|
||||||
|
"url": "https://ros-met.com/krug-stalnoy-kupit/"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -4,14 +4,16 @@
|
|||||||
"kind": "hall",
|
"kind": "hall",
|
||||||
"propagation_delay_ns": 5000.0,
|
"propagation_delay_ns": 5000.0,
|
||||||
"price": 19.9,
|
"price": 19.9,
|
||||||
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/34-datchiki/datchik-holla-a3144e-34111/), 19.90₽ (6.48₽ от 200шт); время срабатывания на странице не указано, ОЦЕНКА ~5мкс (середина типового диапазона 3-7мкс для А3144-класса)"
|
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/34-datchiki/datchik-holla-a3144e-34111/), 19.90₽ (6.48₽ от 200шт); время срабатывания на странице не указано, ОЦЕНКА ~5мкс (середина типового диапазона 3-7мкс для А3144-класса)",
|
||||||
|
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/34-datchiki/datchik-holla-a3144e-34111/"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "TCST2103",
|
"part_number": "TCST2103",
|
||||||
"kind": "optical",
|
"kind": "optical",
|
||||||
"propagation_delay_ns": 9000.0,
|
"propagation_delay_ns": 9000.0,
|
||||||
"price": 220.0,
|
"price": 220.0,
|
||||||
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/tcst2103-optopara-shirokogo-naznacheniya-4-pin-vishay-2693015011), 220₽ (161₽ от 200шт); время включения/выключения 8-10мкс — REAL с даташита Vishay, взята середина диапазона"
|
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/tcst2103-optopara-shirokogo-naznacheniya-4-pin-vishay-2693015011), 220₽ (161₽ от 200шт); время включения/выключения 8-10мкс — REAL с даташита Vishay, взята середина диапазона",
|
||||||
|
"url": "https://www.chipdip.ru/product/tcst2103-optopara-shirokogo-naznacheniya-4-pin-vishay-2693015011"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "inductive-pickup-lm393",
|
"part_number": "inductive-pickup-lm393",
|
||||||
@@ -20,6 +22,7 @@
|
|||||||
"price": 15.0,
|
"price": 15.0,
|
||||||
"source": "ОЦЕНКА: не готовый компонент, а самодельная схема — виток(-и) провода (цена уже учтена в wires.json) + компаратор LM393 (~15₽, типовая розничная цена); время задержки 1.3мкс — реальный паспортный параметр LM393, но чувствительность/порог ниже — грубая оценка, не датащит-значение",
|
"source": "ОЦЕНКА: не готовый компонент, а самодельная схема — виток(-и) провода (цена уже учтена в wires.json) + компаратор LM393 (~15₽, типовая розничная цена); время задержки 1.3мкс — реальный паспортный параметр LM393, но чувствительность/порог ниже — грубая оценка, не датащит-значение",
|
||||||
"sensitivity_v_per_mps": 0.01,
|
"sensitivity_v_per_mps": 0.01,
|
||||||
"threshold_v": 0.05
|
"threshold_v": 0.05,
|
||||||
|
"url": "https://www.chipdip.ru/search?searchtext=LM393"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -8,7 +8,9 @@
|
|||||||
"on_voltage_drop_v": 1.5,
|
"on_voltage_drop_v": 1.5,
|
||||||
"turn_on_time_ns": 10000.0,
|
"turn_on_time_ns": 10000.0,
|
||||||
"price": 140.0,
|
"price": 140.0,
|
||||||
"source": "REAL: КУ202Н, chipdip.ru (https://www.chipdip.ru/product/ku202n) 140₽ / procontact74.ru (https://procontact74.ru/44-tiristory/ku202n-tiristor-10a-400v-44011/) 189₽; Von=1.5В с даташита; время включения ОЦЕНКА ~10мкс (типично для тиристора этого класса, точное значение в листинге не приведено)"
|
"source": "REAL: КУ202Н, chipdip.ru (https://www.chipdip.ru/product/ku202n) 140₽ / procontact74.ru (https://procontact74.ru/44-tiristory/ku202n-tiristor-10a-400v-44011/) 189₽; Von=1.5В с даташита; время включения ОЦЕНКА ~10мкс (типично для тиристора этого класса, точное значение в листинге не приведено); ITSM=100А (ударный неповторяющийся, 10мс) по справочнику КУ202Н",
|
||||||
|
"url": "https://www.chipdip.ru/product/ku202n",
|
||||||
|
"pulse_current_a": 100.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "BT151-650R",
|
"part_number": "BT151-650R",
|
||||||
@@ -19,7 +21,9 @@
|
|||||||
"on_voltage_drop_v": 1.7,
|
"on_voltage_drop_v": 1.7,
|
||||||
"turn_on_time_ns": 1500.0,
|
"turn_on_time_ns": 1500.0,
|
||||||
"price": 190.0,
|
"price": 190.0,
|
||||||
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/bt151-650r), ~180-200₽ (BT151-600R вариант подтверждён на 180₽); Von и время включения ОЦЕНКА (типично для тиристора этого класса, не указаны продавцом)"
|
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/bt151-650r), ~180-200₽ (BT151-600R вариант подтверждён на 180₽); Von и время включения ОЦЕНКА (типично для тиристора этого класса, не указаны продавцом); ITSM=100А (даташит NXP, полусинус 10мс)",
|
||||||
|
"url": "https://www.chipdip.ru/product/bt151-650r",
|
||||||
|
"pulse_current_a": 100.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "IRFP254PBF",
|
"part_number": "IRFP254PBF",
|
||||||
@@ -30,7 +34,9 @@
|
|||||||
"on_voltage_drop_v": null,
|
"on_voltage_drop_v": null,
|
||||||
"turn_on_time_ns": 30.0,
|
"turn_on_time_ns": 30.0,
|
||||||
"price": 39.9,
|
"price": 39.9,
|
||||||
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/); ВНИМАНИЕ: демонтированная (Б/У) деталь, не новая партия; время включения ОЦЕНКА ~30нс (типично для этого класса MOSFET)"
|
"source": "REAL: procontact74.ru (https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/); ВНИМАНИЕ: демонтированная (Б/У) деталь, не новая партия; время включения ОЦЕНКА ~30нс (типично для этого класса MOSFET); IDM=92А (импульсный ток стока, даташит)",
|
||||||
|
"url": "https://procontact74.ru/01-elektronnyie-komponentyi-/43-tranzistory/polevyie--mosfet-/58406-tranzistorirfp254pbfmosfetnch250v23ato247acoriginalbydemontajnogi10mm/",
|
||||||
|
"pulse_current_a": 92.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "IRFP250PBF",
|
"part_number": "IRFP250PBF",
|
||||||
@@ -41,7 +47,9 @@
|
|||||||
"on_voltage_drop_v": null,
|
"on_voltage_drop_v": null,
|
||||||
"turn_on_time_ns": 35.0,
|
"turn_on_time_ns": 35.0,
|
||||||
"price": 320.0,
|
"price": 320.0,
|
||||||
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp250), новая деталь, 320₽ (276₽ от 15шт, 245₽ от 150шт); Rds(on)=0.085Ом при 18А/10В с даташита; время включения ОЦЕНКА"
|
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp250), новая деталь, 320₽ (276₽ от 15шт, 245₽ от 150шт); Rds(on)=0.085Ом при 18А/10В с даташита; время включения ОЦЕНКА; IDM=120А (импульсный ток стока, даташит)",
|
||||||
|
"url": "https://www.chipdip.ru/product/irfp250",
|
||||||
|
"pulse_current_a": 120.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "IRFP4468PBF",
|
"part_number": "IRFP4468PBF",
|
||||||
@@ -52,7 +60,9 @@
|
|||||||
"on_voltage_drop_v": null,
|
"on_voltage_drop_v": null,
|
||||||
"turn_on_time_ns": 40.0,
|
"turn_on_time_ns": 40.0,
|
||||||
"price": 610.0,
|
"price": 610.0,
|
||||||
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp4468pbf), 610₽ (534₽ от 15шт); паспортный максимум 290А при 25°C, взято консервативное практическое значение 180А; Rds(on)=0.0026Ом при 180А/10В с даташита; время включения ОЦЕНКА"
|
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irfp4468pbf), 610₽ (534₽ от 15шт); паспортный максимум 290А при 25°C, взято консервативное практическое значение 180А; Rds(on)=0.0026Ом при 180А/10В с даташита; время включения ОЦЕНКА; IDM=1080А (импульсный ток стока, даташит)",
|
||||||
|
"url": "https://www.chipdip.ru/product/irfp4468pbf",
|
||||||
|
"pulse_current_a": 1080.0
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_number": "IRG4PC50F",
|
"part_number": "IRG4PC50F",
|
||||||
@@ -63,6 +73,8 @@
|
|||||||
"on_voltage_drop_v": 1.8,
|
"on_voltage_drop_v": 1.8,
|
||||||
"turn_on_time_ns": 300.0,
|
"turn_on_time_ns": 300.0,
|
||||||
"price": 504.0,
|
"price": 504.0,
|
||||||
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irg4pc50f-8048990896), ~504₽; Vce(sat) и время включения ОЦЕНКА (типично для этого семейства IGBT, не подтверждено со страницы товара)"
|
"source": "REAL: chipdip.ru (https://www.chipdip.ru/product/irg4pc50f-8048990896), ~504₽; Vce(sat) и время включения ОЦЕНКА (типично для этого семейства IGBT, не подтверждено со страницы товара); ICM=280А (импульсный ток коллектора, даташит)",
|
||||||
|
"url": "https://www.chipdip.ru/product/irg4pc50f-8048990896",
|
||||||
|
"pulse_current_a": 280.0
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
"resistivity_ohm_m": 1.68e-08,
|
"resistivity_ohm_m": 1.68e-08,
|
||||||
"max_current_a": 0.35,
|
"max_current_a": 0.35,
|
||||||
"price_per_m": 3.5,
|
"price_per_m": 3.5,
|
||||||
"source": "REAL: chipdip.ru, ПЭТВ-2 0.3мм, 1050₽/200г-катушка ~300м (https://www.chipdip.ru/product/petv-2-d-0.3-mm-provod-emalirovannyy-obmotochnyy-300m-dlina-9000272019)"
|
"source": "REAL: chipdip.ru, ПЭТВ-2 0.3мм, 1050₽/200г-катушка ~300м (https://www.chipdip.ru/product/petv-2-d-0.3-mm-provod-emalirovannyy-obmotochnyy-300m-dlina-9000272019)",
|
||||||
|
"url": "https://www.chipdip.ru/product/petv-2-d-0.3-mm-provod-emalirovannyy-obmotochnyy-300m-dlina-9000272019"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "cu-petv2-0.71mm",
|
"part_id": "cu-petv2-0.71mm",
|
||||||
@@ -17,7 +18,8 @@
|
|||||||
"resistivity_ohm_m": 1.68e-08,
|
"resistivity_ohm_m": 1.68e-08,
|
||||||
"max_current_a": 1.98,
|
"max_current_a": 1.98,
|
||||||
"price_per_m": 16.2,
|
"price_per_m": 16.2,
|
||||||
"source": "REAL: chipdip.ru, ПЭТВ-2 0.71мм, 890₽/200г-катушка ~55м (https://www.chipdip.ru/product/petv-2-d-0.71-mm)"
|
"source": "REAL: chipdip.ru, ПЭТВ-2 0.71мм, 890₽/200г-катушка ~55м (https://www.chipdip.ru/product/petv-2-d-0.71-mm)",
|
||||||
|
"url": "https://www.chipdip.ru/product/petv-2-d-0.71-mm"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "cu-petv2-1.0mm",
|
"part_id": "cu-petv2-1.0mm",
|
||||||
@@ -27,7 +29,8 @@
|
|||||||
"resistivity_ohm_m": 1.68e-08,
|
"resistivity_ohm_m": 1.68e-08,
|
||||||
"max_current_a": 3.93,
|
"max_current_a": 3.93,
|
||||||
"price_per_m": 8.445,
|
"price_per_m": 8.445,
|
||||||
"source": "REAL: chipdip.ru, ПЭТВ-2 1.0мм, 1200₽/кг (https://www.chipdip.ru/product0/9000289279); ₽/м пересчитано нами из ₽/кг"
|
"source": "REAL: chipdip.ru, ПЭТВ-2 1.0мм, 1200₽/кг (https://www.chipdip.ru/product0/9000289279); ₽/м пересчитано нами из ₽/кг",
|
||||||
|
"url": "https://www.chipdip.ru/product0/9000289279"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "cu-petv2-1.4mm",
|
"part_id": "cu-petv2-1.4mm",
|
||||||
@@ -37,7 +40,8 @@
|
|||||||
"resistivity_ohm_m": 1.68e-08,
|
"resistivity_ohm_m": 1.68e-08,
|
||||||
"max_current_a": 7.7,
|
"max_current_a": 7.7,
|
||||||
"price_per_m": 45.266,
|
"price_per_m": 45.266,
|
||||||
"source": "REAL: chipdip.ru, ПЭТВ-2 1.4мм, 3610₽/1.1кг-упаковка (https://www.chipdip.ru/product0/8004178560); ₽/м пересчитано нами из ₽/кг"
|
"source": "REAL: chipdip.ru, ПЭТВ-2 1.4мм, 3610₽/1.1кг-упаковка (https://www.chipdip.ru/product0/8004178560); ₽/м пересчитано нами из ₽/кг",
|
||||||
|
"url": "https://www.chipdip.ru/product0/8004178560"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "cu-petv2-0.8mm",
|
"part_id": "cu-petv2-0.8mm",
|
||||||
@@ -47,7 +51,8 @@
|
|||||||
"resistivity_ohm_m": 1.68e-08,
|
"resistivity_ohm_m": 1.68e-08,
|
||||||
"max_current_a": 2.51,
|
"max_current_a": 2.51,
|
||||||
"price_per_m": 11.259,
|
"price_per_m": 11.259,
|
||||||
"source": "ОЦЕНКА: товар реален на chipdip.ru (https://www.chipdip.ru/product0/8004178423), но точная цена не подтверждена при парсинге; 2500₽/кг интерполировано между подтверждёнными 0.71мм (~4566₽/кг) и 1.0мм (1200₽/кг)"
|
"source": "ОЦЕНКА: товар реален на chipdip.ru (https://www.chipdip.ru/product0/8004178423), но точная цена не подтверждена при парсинге; 2500₽/кг интерполировано между подтверждёнными 0.71мм (~4566₽/кг) и 1.0мм (1200₽/кг)",
|
||||||
|
"url": "https://www.chipdip.ru/product0/8004178423"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "cu-petv2-2.0mm",
|
"part_id": "cu-petv2-2.0mm",
|
||||||
@@ -57,7 +62,8 @@
|
|||||||
"resistivity_ohm_m": 1.68e-08,
|
"resistivity_ohm_m": 1.68e-08,
|
||||||
"max_current_a": 15.71,
|
"max_current_a": 15.71,
|
||||||
"price_per_m": 30.964,
|
"price_per_m": 30.964,
|
||||||
"source": "ОЦЕНКА: chipdip.ru (https://www.chipdip.ru/product0/9000289280), цена частично 'по запросу', взята середина наблюдаемого диапазона 1000-1200₽/кг"
|
"source": "ОЦЕНКА: chipdip.ru (https://www.chipdip.ru/product0/9000289280), цена частично 'по запросу', взята середина наблюдаемого диапазона 1000-1200₽/кг",
|
||||||
|
"url": "https://www.chipdip.ru/product0/9000289280"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "al-0.3mm",
|
"part_id": "al-0.3mm",
|
||||||
@@ -67,7 +73,8 @@
|
|||||||
"resistivity_ohm_m": 2.82e-08,
|
"resistivity_ohm_m": 2.82e-08,
|
||||||
"max_current_a": 0.28,
|
"max_current_a": 0.28,
|
||||||
"price_per_m": 1.4,
|
"price_per_m": 1.4,
|
||||||
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)"
|
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)",
|
||||||
|
"url": "https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "al-0.71mm",
|
"part_id": "al-0.71mm",
|
||||||
@@ -77,7 +84,8 @@
|
|||||||
"resistivity_ohm_m": 2.82e-08,
|
"resistivity_ohm_m": 2.82e-08,
|
||||||
"max_current_a": 1.58,
|
"max_current_a": 1.58,
|
||||||
"price_per_m": 6.48,
|
"price_per_m": 6.48,
|
||||||
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)"
|
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)",
|
||||||
|
"url": "https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "al-1.0mm",
|
"part_id": "al-1.0mm",
|
||||||
@@ -87,7 +95,8 @@
|
|||||||
"resistivity_ohm_m": 2.82e-08,
|
"resistivity_ohm_m": 2.82e-08,
|
||||||
"max_current_a": 3.14,
|
"max_current_a": 3.14,
|
||||||
"price_per_m": 3.378,
|
"price_per_m": 3.378,
|
||||||
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)"
|
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)",
|
||||||
|
"url": "https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"part_id": "al-1.4mm",
|
"part_id": "al-1.4mm",
|
||||||
@@ -97,6 +106,7 @@
|
|||||||
"resistivity_ohm_m": 2.82e-08,
|
"resistivity_ohm_m": 2.82e-08,
|
||||||
"max_current_a": 6.16,
|
"max_current_a": 6.16,
|
||||||
"price_per_m": 18.106,
|
"price_per_m": 18.106,
|
||||||
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)"
|
"source": "ОЦЕНКА: алюминиевый эмальпровод в рознице РФ практически не продаётся поштучно (cable.ru и др. требуют запрос цены, см. https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php); цена оценена как 0.4x от цены медного провода того же сечения (типовое соотношение стоимости сырья Cu/Al)",
|
||||||
|
"url": "https://cable.ru/cable/kabel-alyuminij_obmotochnyj_v_razdel-9.php"
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
@@ -14,6 +14,7 @@ class WireSpec:
|
|||||||
max_current_a: float
|
max_current_a: float
|
||||||
price_per_m: float
|
price_per_m: float
|
||||||
source: str
|
source: str
|
||||||
|
url: str | None = None # прямая ссылка на магазин, где купить
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -25,6 +26,49 @@ class CapacitorSpec:
|
|||||||
max_current_a: float
|
max_current_a: float
|
||||||
price: float
|
price: float
|
||||||
source: str
|
source: str
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class CapacitorBank:
|
||||||
|
"""Батарея из одинаковых конденсаторов: n_series (напряжение) × n_parallel (ток/ёмкость).
|
||||||
|
|
||||||
|
Последовательное соединение складывает напряжение и ESR, делит ёмкость;
|
||||||
|
параллельное — складывает ёмкость, максимальный ток и делит ESR. Итоговая
|
||||||
|
батарея выставляет те же поля, что и одиночный конденсатор
|
||||||
|
(`capacitance_uf`, `voltage_v`, `esr_ohm`, `max_current_a`, `price`),
|
||||||
|
поэтому используется как drop-in замена `CapacitorSpec` в StageConfig.
|
||||||
|
"""
|
||||||
|
|
||||||
|
base_part_number: str
|
||||||
|
n_series: int
|
||||||
|
n_parallel: int
|
||||||
|
capacitance_uf: float
|
||||||
|
voltage_v: float
|
||||||
|
esr_ohm: float
|
||||||
|
max_current_a: float
|
||||||
|
price: float
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def count(self) -> int:
|
||||||
|
return self.n_series * self.n_parallel
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_spec(cls, spec: "CapacitorSpec", n_series: int, n_parallel: int) -> "CapacitorBank":
|
||||||
|
n_series = max(1, int(n_series))
|
||||||
|
n_parallel = max(1, int(n_parallel))
|
||||||
|
return cls(
|
||||||
|
base_part_number=spec.part_number,
|
||||||
|
n_series=n_series,
|
||||||
|
n_parallel=n_parallel,
|
||||||
|
capacitance_uf=spec.capacitance_uf * n_parallel / n_series,
|
||||||
|
voltage_v=spec.voltage_v * n_series,
|
||||||
|
esr_ohm=spec.esr_ohm * n_series / n_parallel,
|
||||||
|
max_current_a=spec.max_current_a * n_parallel,
|
||||||
|
price=spec.price * n_series * n_parallel,
|
||||||
|
url=spec.url,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -38,6 +82,10 @@ class SwitchSpec:
|
|||||||
turn_on_time_ns: float
|
turn_on_time_ns: float
|
||||||
price: float
|
price: float
|
||||||
source: str
|
source: str
|
||||||
|
url: str | None = None
|
||||||
|
# паспортный импульсный ток из даташита (ITSM тиристора / IDM MOSFET /
|
||||||
|
# ICM IGBT). Если None — применяется типовой множитель SWITCH_SURGE_FACTOR.
|
||||||
|
pulse_current_a: float | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -49,6 +97,7 @@ class SensorSpec:
|
|||||||
source: str
|
source: str
|
||||||
sensitivity_v_per_mps: float | None = None
|
sensitivity_v_per_mps: float | None = None
|
||||||
threshold_v: float | None = None
|
threshold_v: float | None = None
|
||||||
|
url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
@@ -59,3 +108,7 @@ class ProjectileMaterialSpec:
|
|||||||
b_sat_tesla: float
|
b_sat_tesla: float
|
||||||
price_per_kg: float
|
price_per_kg: float
|
||||||
source: str
|
source: str
|
||||||
|
# электрическое удельное сопротивление — для расчёта вихревых потерь в снаряде
|
||||||
|
# (сплошной проводник в импульсном поле). Дефолт ~ конструкционная сталь.
|
||||||
|
resistivity_ohm_m: float = 1.6e-7
|
||||||
|
url: str | None = None
|
||||||
|
|||||||
0
src/gausse/gpu/__init__.py
Normal file
0
src/gausse/gpu/__init__.py
Normal file
30
src/gausse/gpu/backend.py
Normal file
30
src/gausse/gpu/backend.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
"""Выбор массивного бэкенда: cupy (GPU), если доступен, иначе numpy (CPU).
|
||||||
|
|
||||||
|
Один и тот же код батч-интегратора работает на обоих. Разрабатывается и
|
||||||
|
валидируется на numpy без GPU; на сервере с CUDA автоматически подхватывает
|
||||||
|
cupy. Это гарантирует, что GPU-путь даёт те же числа, что и проверенный CPU.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def get_backend(prefer_gpu: bool = True) -> tuple[Any, str]:
|
||||||
|
"""Возвращает (модуль-как-numpy, имя_бэкенда)."""
|
||||||
|
if prefer_gpu:
|
||||||
|
try:
|
||||||
|
import cupy as cp
|
||||||
|
|
||||||
|
if cp.cuda.runtime.getDeviceCount() > 0:
|
||||||
|
return cp, "cupy"
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
return np, "numpy"
|
||||||
|
|
||||||
|
|
||||||
|
def to_cpu(xp, array):
|
||||||
|
"""Переносит массив в numpy (для записи в БД/сравнения), откуда бы он ни был."""
|
||||||
|
if xp.__name__ == "cupy":
|
||||||
|
return xp.asnumpy(array)
|
||||||
|
return array
|
||||||
410
src/gausse/gpu/batch_integrator.py
Normal file
410
src/gausse/gpu/batch_integrator.py
Normal file
@@ -0,0 +1,410 @@
|
|||||||
|
"""Батч-интегратор разряда: N конфигураций одновременно, фикс. шаг RK4.
|
||||||
|
|
||||||
|
Векторизует самую дорогую часть симуляции — 4-мерную ОДУ [Q, I, x, v]
|
||||||
|
разряда конденсатора через катушку с насыщающимся железом (та же физика,
|
||||||
|
что в `physics/circuit.py` + `physics/inductance.py`, но все параметры —
|
||||||
|
массивы формы (N,), а интегратор с фиксированным шагом вместо адаптивного
|
||||||
|
solve_ivp). Один код на numpy(CPU)/cupy(GPU) через `xp`.
|
||||||
|
|
||||||
|
Насыщение: λ(x,I) = L_air·I + L_iron·overlap(x)·g(I), g(I)=I_sat·tanh(I/I_sat).
|
||||||
|
I_sat кодируется большим КОНЕЧНЫМ числом (не inf), чтобы в линейном пределе
|
||||||
|
(mu_eff≈1) tanh(I/I_sat)→I/I_sat и всё сводилось к L_air·I без nan.
|
||||||
|
|
||||||
|
Валидируется против scipy-эталона в tests/test_batch_integrator.py — GPU-путь
|
||||||
|
обязан давать те же числа, что проверенный CPU (см. gausse-roadmap).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from gausse.physics.constants import MU_0
|
||||||
|
|
||||||
|
NO_SATURATION_I_SAT = 1e12 # «бесконечный» I_sat: линейный (ненасыщающийся) предел
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BatchDischargeParams:
|
||||||
|
"""Пер-конфигурационные массивы (все формы (N,)) для батч-разряда."""
|
||||||
|
|
||||||
|
capacitance_f: "any"
|
||||||
|
r_total_ohm: "any"
|
||||||
|
mass_kg: "any"
|
||||||
|
l_air_h: "any"
|
||||||
|
l_iron_coeff: "any" # L_air*(mu_eff-1)
|
||||||
|
coil_length_m: "any"
|
||||||
|
slug_length_m: "any"
|
||||||
|
smoothing_width_m: "any"
|
||||||
|
i_sat_a: "any" # ток насыщения; NO_SATURATION_I_SAT где железа нет
|
||||||
|
r_eddy_coeff_ohm: "any" # вихревое сопротивление снаряда (× overlap)
|
||||||
|
retard_const_n: "any" # сухое трение о трубку (Н, против движения)
|
||||||
|
drag_coeff_n: "any" # аэродинамика: сила = drag_coeff·v² (против движения)
|
||||||
|
|
||||||
|
|
||||||
|
def params_from_models(xp, models, circuit_params) -> BatchDischargeParams:
|
||||||
|
"""Собирает BatchDischargeParams из списков CoilInductanceModel и StageCircuitParams.
|
||||||
|
|
||||||
|
Один источник истины с CPU-моделью: l_iron_coeff и saturation_current_a
|
||||||
|
берутся из тех же `CoilInductanceModel`, что использует scipy-путь.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def col(vals):
|
||||||
|
return xp.asarray(vals, dtype=xp.float64)
|
||||||
|
|
||||||
|
i_sat = [
|
||||||
|
m.saturation_current_a if xp.isfinite(xp.asarray(m.saturation_current_a)) else NO_SATURATION_I_SAT
|
||||||
|
for m in models
|
||||||
|
]
|
||||||
|
return BatchDischargeParams(
|
||||||
|
capacitance_f=col([c.capacitance_f for c in circuit_params]),
|
||||||
|
r_total_ohm=col([c.r_total_ohm for c in circuit_params]),
|
||||||
|
mass_kg=col([c.mass_kg for c in circuit_params]),
|
||||||
|
l_air_h=col([m.l_air_h for m in models]),
|
||||||
|
l_iron_coeff=col([m.l_iron_coeff for m in models]),
|
||||||
|
coil_length_m=col([m.coil_length_m for m in models]),
|
||||||
|
slug_length_m=col([m.slug_length_m for m in models]),
|
||||||
|
smoothing_width_m=col([m.smoothing_width_m for m in models]),
|
||||||
|
i_sat_a=col(i_sat),
|
||||||
|
r_eddy_coeff_ohm=col([getattr(c, "r_eddy_coeff_ohm", 0.0) for c in circuit_params]),
|
||||||
|
retard_const_n=col([getattr(c, "retard_const_n", 0.0) for c in circuit_params]),
|
||||||
|
drag_coeff_n=col([getattr(c, "drag_coeff_n_per_mps2", 0.0) for c in circuit_params]),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _ln_cosh(xp, z):
|
||||||
|
az = xp.abs(z)
|
||||||
|
return az + xp.log1p(xp.exp(-2 * az)) - xp.log(xp.asarray(2.0))
|
||||||
|
|
||||||
|
|
||||||
|
def _overlap(xp, x, p: BatchDischargeParams):
|
||||||
|
half_span = (p.coil_length_m + p.slug_length_m) / 2
|
||||||
|
w = p.smoothing_width_m
|
||||||
|
s1 = 0.5 * (1 + xp.tanh((x + half_span) / w / 2))
|
||||||
|
s2 = 0.5 * (1 + xp.tanh((half_span - x) / w / 2))
|
||||||
|
return s1 * s2, (s1 * s2 / w) * (s2 - s1)
|
||||||
|
|
||||||
|
|
||||||
|
def _magnetic_energy(xp, x, i, p: BatchDischargeParams):
|
||||||
|
"""W = ½L_air·I² + L_iron·overlap·(I·g − G) — точная (с насыщением) энергия поля."""
|
||||||
|
overlap, _ = _overlap(xp, x, p)
|
||||||
|
z = i / p.i_sat_a
|
||||||
|
g = p.i_sat_a * xp.tanh(z)
|
||||||
|
g_integral = p.i_sat_a**2 * _ln_cosh(xp, z)
|
||||||
|
return 0.5 * p.l_air_h * i**2 + p.l_iron_coeff * overlap * (i * g - g_integral)
|
||||||
|
|
||||||
|
|
||||||
|
def _derivatives(xp, q, i, x, v, p: BatchDischargeParams):
|
||||||
|
overlap, d_overlap = _overlap(xp, x, p)
|
||||||
|
|
||||||
|
z = i / p.i_sat_a
|
||||||
|
tanhz = xp.tanh(z)
|
||||||
|
g = p.i_sat_a * tanhz
|
||||||
|
g_prime = 1.0 - tanhz**2
|
||||||
|
g_integral = p.i_sat_a**2 * _ln_cosh(xp, z)
|
||||||
|
|
||||||
|
dlambda_di = p.l_air_h + p.l_iron_coeff * overlap * g_prime
|
||||||
|
dlambda_dx = p.l_iron_coeff * d_overlap * g
|
||||||
|
force = p.l_iron_coeff * d_overlap * g_integral
|
||||||
|
|
||||||
|
r_eff = p.r_total_ohm + p.r_eddy_coeff_ohm * overlap # вихревые потери снаряда × overlap
|
||||||
|
v_c = q / p.capacitance_f
|
||||||
|
d_q = -i
|
||||||
|
d_i = (v_c - i * r_eff - dlambda_dx * v) / dlambda_di
|
||||||
|
# трение + воздух; tanh-сглаживание у v=0 — как в physics/circuit.py
|
||||||
|
retard = xp.tanh(v / 0.01) * (p.retard_const_n + p.drag_coeff_n * v * v)
|
||||||
|
d_v = (force - retard) / p.mass_kg
|
||||||
|
d_x = v
|
||||||
|
return d_q, d_i, d_x, d_v
|
||||||
|
|
||||||
|
|
||||||
|
_FUSED_STEP_CACHE = {}
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fused_step(cp):
|
||||||
|
"""Собирает (и кэширует) cupy.fuse-ядро полного RK4-шага разряда.
|
||||||
|
|
||||||
|
Весь шаг (4 вычисления производных + сборка + overlap) сливается в ОДНО
|
||||||
|
GPU-ядро вместо ~60 мелких — убирает накладные на запуск ядер, из-за
|
||||||
|
которых наивный порт был лишь ~1.3x к CPU.
|
||||||
|
"""
|
||||||
|
if "step" in _FUSED_STEP_CACHE:
|
||||||
|
return _FUSED_STEP_CACHE["step"]
|
||||||
|
LN2 = 0.6931471805599453
|
||||||
|
|
||||||
|
def _ovl(x, hs, w):
|
||||||
|
s1 = 0.5 * (1.0 + cp.tanh((x + hs) / w * 0.5))
|
||||||
|
s2 = 0.5 * (1.0 + cp.tanh((hs - x) / w * 0.5))
|
||||||
|
return s1 * s2, (s1 * s2 / w) * (s2 - s1)
|
||||||
|
|
||||||
|
def _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
|
||||||
|
ov, dov = _ovl(x, hs, w)
|
||||||
|
z = i / isat
|
||||||
|
tz = cp.tanh(z)
|
||||||
|
g = isat * tz
|
||||||
|
gp = 1.0 - tz * tz
|
||||||
|
az = cp.abs(z)
|
||||||
|
G = isat * isat * (az + cp.log1p(cp.exp(-2.0 * az)) - LN2)
|
||||||
|
dl_di = lair + liron * ov * gp
|
||||||
|
reff = rt + red * ov
|
||||||
|
dq = -i
|
||||||
|
di = (q / C - i * reff - liron * dov * g * v) / dl_di
|
||||||
|
dv = (liron * dov * G - cp.tanh(v * 100.0) * (fc + dc * v * v)) / m
|
||||||
|
return dq, di, v, dv
|
||||||
|
|
||||||
|
@cp.fuse()
|
||||||
|
def step(q, i, x, v, dt, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
|
||||||
|
a = _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
|
||||||
|
b = _der(q + dt * 0.5 * a[0], i + dt * 0.5 * a[1], x + dt * 0.5 * a[2], v + dt * 0.5 * a[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
|
||||||
|
c = _der(q + dt * 0.5 * b[0], i + dt * 0.5 * b[1], x + dt * 0.5 * b[2], v + dt * 0.5 * b[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
|
||||||
|
d = _der(q + dt * c[0], i + dt * c[1], x + dt * c[2], v + dt * c[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
|
||||||
|
qn = q + dt / 6.0 * (a[0] + 2 * b[0] + 2 * c[0] + d[0])
|
||||||
|
in_ = i + dt / 6.0 * (a[1] + 2 * b[1] + 2 * c[1] + d[1])
|
||||||
|
xn = x + dt / 6.0 * (a[2] + 2 * b[2] + 2 * c[2] + d[2])
|
||||||
|
vn = v + dt / 6.0 * (a[3] + 2 * b[3] + 2 * c[3] + d[3])
|
||||||
|
ov_old, _ = _ovl(x, hs, w)
|
||||||
|
ov_new, _ = _ovl(xn, hs, w)
|
||||||
|
return qn, in_, xn, vn, ov_old, ov_new
|
||||||
|
|
||||||
|
_FUSED_STEP_CACHE["step"] = step
|
||||||
|
return step
|
||||||
|
|
||||||
|
|
||||||
|
def _get_fused_pair(cp):
|
||||||
|
"""Два слитых ядра на шаг: RK4+физика и cut-логика.
|
||||||
|
|
||||||
|
Профиль на 1070: RK4 был слит, но бухгалтерия обрыва (_magnetic_energy,
|
||||||
|
_overlap, ~15 where на шаг) запускала десятки мелких ядер и съедала >90%
|
||||||
|
времени. Одним ядром не влезает в лимит параметров CUDA (4096 байт,
|
||||||
|
«Formal parameter space overflowed»), поэтому два сбалансированных:
|
||||||
|
step_phys — RK4 + потери шага + остаточная магнитная энергия;
|
||||||
|
step_cut — обрывы/пики/выходные состояния, вообще без физических параметров.
|
||||||
|
"""
|
||||||
|
if "pair" in _FUSED_STEP_CACHE:
|
||||||
|
return _FUSED_STEP_CACHE["pair"]
|
||||||
|
LN2 = 0.6931471805599453
|
||||||
|
|
||||||
|
def _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
|
||||||
|
s1 = 0.5 * (1.0 + cp.tanh((x + hs) / w * 0.5))
|
||||||
|
s2 = 0.5 * (1.0 + cp.tanh((hs - x) / w * 0.5))
|
||||||
|
ov = s1 * s2
|
||||||
|
dov = (s1 * s2 / w) * (s2 - s1)
|
||||||
|
z = i / isat
|
||||||
|
tz = cp.tanh(z)
|
||||||
|
g = isat * tz
|
||||||
|
gp = 1.0 - tz * tz
|
||||||
|
az = cp.abs(z)
|
||||||
|
G = isat * isat * (az + cp.log1p(cp.exp(-2.0 * az)) - LN2)
|
||||||
|
dl_di = lair + liron * ov * gp
|
||||||
|
reff = rt + red * ov
|
||||||
|
dq = -i
|
||||||
|
di = (q / C - i * reff - liron * dov * g * v) / dl_di
|
||||||
|
dv = (liron * dov * G - cp.tanh(v * 100.0) * (fc + dc * v * v)) / m
|
||||||
|
return dq, di, v, dv
|
||||||
|
|
||||||
|
@cp.fuse()
|
||||||
|
def step_phys(q, i, x, v, dt, hs, w, isat, lair, liron, C, rt, red, fc, dc, m):
|
||||||
|
a = _der(q, i, x, v, hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
|
||||||
|
b = _der(q + dt * 0.5 * a[0], i + dt * 0.5 * a[1], x + dt * 0.5 * a[2], v + dt * 0.5 * a[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
|
||||||
|
c = _der(q + dt * 0.5 * b[0], i + dt * 0.5 * b[1], x + dt * 0.5 * b[2], v + dt * 0.5 * b[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
|
||||||
|
d = _der(q + dt * c[0], i + dt * c[1], x + dt * c[2], v + dt * c[3], hs, w, isat, lair, liron, C, rt, red, fc, dc, m)
|
||||||
|
qn = q + dt / 6.0 * (a[0] + 2 * b[0] + 2 * c[0] + d[0])
|
||||||
|
in_ = i + dt / 6.0 * (a[1] + 2 * b[1] + 2 * c[1] + d[1])
|
||||||
|
xn = x + dt / 6.0 * (a[2] + 2 * b[2] + 2 * c[2] + d[2])
|
||||||
|
vn = v + dt / 6.0 * (a[3] + 2 * b[3] + 2 * c[3] + d[3])
|
||||||
|
s1o = 0.5 * (1.0 + cp.tanh((x + hs) / w * 0.5))
|
||||||
|
s2o = 0.5 * (1.0 + cp.tanh((hs - x) / w * 0.5))
|
||||||
|
ov_old = s1o * s2o
|
||||||
|
s1n = 0.5 * (1.0 + cp.tanh((xn + hs) / w * 0.5))
|
||||||
|
s2n = 0.5 * (1.0 + cp.tanh((hs - xn) / w * 0.5))
|
||||||
|
ov_new = s1n * s2n
|
||||||
|
# потери шага: I²R_eff (вихревые × overlap) + трение/воздух (трапеция)
|
||||||
|
step_diss = 0.5 * (i * i * (rt + red * ov_old) + in_ * in_ * (rt + red * ov_new)) * dt
|
||||||
|
fric = 0.5 * ((fc + dc * v * v) * cp.abs(v) + (fc + dc * vn * vn) * cp.abs(vn)) * dt
|
||||||
|
# остаточная магнитная энергия (с насыщением) в состоянии ДО шага
|
||||||
|
z = i / isat
|
||||||
|
g = isat * cp.tanh(z)
|
||||||
|
az = cp.abs(z)
|
||||||
|
G = isat * isat * (az + cp.log1p(cp.exp(-2.0 * az)) - LN2)
|
||||||
|
w_mag = 0.5 * lair * i * i + liron * ov_old * (i * g - G)
|
||||||
|
return qn, in_, xn, vn, step_diss + fric, w_mag
|
||||||
|
|
||||||
|
@cp.fuse()
|
||||||
|
def step_cut(q, i, x, v, qn, in_, xn, vn, sdiss, w_mag,
|
||||||
|
done, committed, past_peak, exit_v, exit_x, exit_q, peak_i, e_diss):
|
||||||
|
active = ~done
|
||||||
|
zero = 0.0 * q
|
||||||
|
e2 = e_diss + cp.where(active, sdiss, zero)
|
||||||
|
peak2 = cp.maximum(peak_i, cp.where(active, cp.abs(in_), zero))
|
||||||
|
past2 = past_peak | (active & (i > 1.0) & (in_ < i))
|
||||||
|
crossed = active & (i > 0) & (in_ <= 0)
|
||||||
|
decayed = active & past2 & (in_ < 0.1) & ~crossed # ток < удержания ключа
|
||||||
|
local_min = active & past2 & (in_ > i) & ~crossed & ~decayed
|
||||||
|
cut_state = local_min | decayed # обрыв в состоянии ДО шага
|
||||||
|
cut = crossed | cut_state
|
||||||
|
frac = cp.where(crossed, i / (i - in_ + 1e-30), zero)
|
||||||
|
exit_v2 = cp.where(crossed, v + frac * (vn - v), cp.where(cut_state, v, exit_v))
|
||||||
|
exit_x2 = cp.where(crossed, x + frac * (xn - x), cp.where(cut_state, x, exit_x))
|
||||||
|
exit_q2 = cp.where(crossed, q + frac * (qn - q), cp.where(cut_state, q, exit_q))
|
||||||
|
e3 = e2 + cp.where(cut_state, w_mag, zero) # freewheel-диод
|
||||||
|
committed2 = committed | cut
|
||||||
|
blew = active & ~cut & ~(cp.abs(in_) < 1e30) # inf/nan: стиффный взрыв
|
||||||
|
done2 = done | cut | blew
|
||||||
|
adv = active & ~blew
|
||||||
|
q2 = cp.where(adv, qn, q)
|
||||||
|
i2 = cp.where(adv, in_, i)
|
||||||
|
x2 = cp.where(adv, xn, x)
|
||||||
|
v2 = cp.where(adv, vn, v)
|
||||||
|
return q2, i2, x2, v2, done2, committed2, past2, exit_v2, exit_x2, exit_q2, peak2, e3
|
||||||
|
|
||||||
|
_FUSED_STEP_CACHE["pair"] = (step_phys, step_cut)
|
||||||
|
return _FUSED_STEP_CACHE["pair"]
|
||||||
|
|
||||||
|
|
||||||
|
def integrate_batch_discharge(
|
||||||
|
xp,
|
||||||
|
q0,
|
||||||
|
x0,
|
||||||
|
v0,
|
||||||
|
params: BatchDischargeParams,
|
||||||
|
dt: float = 2e-6,
|
||||||
|
max_steps: int = 12000,
|
||||||
|
):
|
||||||
|
"""Интегрирует разряд для батча конфигураций до нуля тока у каждой.
|
||||||
|
|
||||||
|
Возвращает dict с массивами (N,): exit_v, exit_x, exit_q, peak_current,
|
||||||
|
energy_dissipated_j, feasible (ток вернулся к нулю в пределах max_steps).
|
||||||
|
"""
|
||||||
|
n = q0.shape[0]
|
||||||
|
q = xp.array(q0, dtype=xp.float64)
|
||||||
|
i = xp.zeros(n, dtype=xp.float64)
|
||||||
|
x = xp.array(x0, dtype=xp.float64)
|
||||||
|
v = xp.array(v0, dtype=xp.float64)
|
||||||
|
|
||||||
|
done = xp.zeros(n, dtype=bool)
|
||||||
|
committed = xp.zeros(n, dtype=bool) # реально скоммутировал (валидный обрыв), а не «взорвался»
|
||||||
|
past_peak = xp.zeros(n, dtype=bool) # ток уже прошёл пик и начал спадать
|
||||||
|
exit_v = xp.array(v0, dtype=xp.float64)
|
||||||
|
exit_x = xp.array(x0, dtype=xp.float64)
|
||||||
|
exit_q = xp.array(q0, dtype=xp.float64)
|
||||||
|
peak_current = xp.zeros(n, dtype=xp.float64)
|
||||||
|
energy_diss = xp.zeros(n, dtype=xp.float64)
|
||||||
|
|
||||||
|
_old_err = xp.seterr(all="ignore") if hasattr(xp, "seterr") else None # стиффные конфиги переполняют fixed-step
|
||||||
|
# на cupy — ВЕСЬ шаг (RK4 + cut-логика) одним fused-ядром; numpy — обычный путь
|
||||||
|
is_cupy = xp.__name__ == "cupy"
|
||||||
|
fused_pair = None
|
||||||
|
if is_cupy:
|
||||||
|
try:
|
||||||
|
fused_pair = _get_fused_pair(xp)
|
||||||
|
except Exception:
|
||||||
|
fused_pair = None # честный fallback на пошаговый путь
|
||||||
|
fused = _get_fused_step(xp)
|
||||||
|
hs = (params.coil_length_m + params.slug_length_m) / 2
|
||||||
|
# проверку «все ли готовы» делаем НЕ каждый шаг: на GPU это device->host
|
||||||
|
# синхронизация, которая убивает конвейер. Раз в SYNC_EVERY шагов достаточно.
|
||||||
|
SYNC_EVERY = 256
|
||||||
|
for step in range(max_steps):
|
||||||
|
active = ~done
|
||||||
|
if step % SYNC_EVERY == 0 and not bool(xp.any(active)):
|
||||||
|
break
|
||||||
|
|
||||||
|
q_old, i_old, x_old, v_old = q, i, x, v
|
||||||
|
|
||||||
|
if fused_pair is not None:
|
||||||
|
# весь шаг = 2 ядра: RK4+физика, затем cut-логика (вместо ~20 мелких)
|
||||||
|
try:
|
||||||
|
qn, in_, xn, vn, sdiss, w_mag = fused_pair[0](
|
||||||
|
q_old, i_old, x_old, v_old, dt, hs, params.smoothing_width_m,
|
||||||
|
params.i_sat_a, params.l_air_h, params.l_iron_coeff, params.capacitance_f,
|
||||||
|
params.r_total_ohm, params.r_eddy_coeff_ohm,
|
||||||
|
params.retard_const_n, params.drag_coeff_n, params.mass_kg,
|
||||||
|
)
|
||||||
|
(q, i, x, v, done, committed, past_peak,
|
||||||
|
exit_v, exit_x, exit_q, peak_current, energy_diss) = fused_pair[1](
|
||||||
|
q_old, i_old, x_old, v_old, qn, in_, xn, vn, sdiss, w_mag,
|
||||||
|
done, committed, past_peak, exit_v, exit_x, exit_q, peak_current, energy_diss,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
except Exception:
|
||||||
|
if step == 0: # fuse не собрался — честный откат на пошаговый путь
|
||||||
|
fused_pair = None
|
||||||
|
else:
|
||||||
|
raise
|
||||||
|
|
||||||
|
if is_cupy:
|
||||||
|
q_new, i_new, x_new, v_new, overlap_old, overlap_new = fused(
|
||||||
|
q_old, i_old, x_old, v_old, dt, hs, params.smoothing_width_m,
|
||||||
|
params.i_sat_a, params.l_air_h, params.l_iron_coeff, params.capacitance_f,
|
||||||
|
params.r_total_ohm, params.r_eddy_coeff_ohm,
|
||||||
|
params.retard_const_n, params.drag_coeff_n, params.mass_kg,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
k1 = _derivatives(xp, q_old, i_old, x_old, v_old, params)
|
||||||
|
k2 = _derivatives(xp, q_old + dt / 2 * k1[0], i_old + dt / 2 * k1[1], x_old + dt / 2 * k1[2], v_old + dt / 2 * k1[3], params)
|
||||||
|
k3 = _derivatives(xp, q_old + dt / 2 * k2[0], i_old + dt / 2 * k2[1], x_old + dt / 2 * k2[2], v_old + dt / 2 * k2[3], params)
|
||||||
|
k4 = _derivatives(xp, q_old + dt * k3[0], i_old + dt * k3[1], x_old + dt * k3[2], v_old + dt * k3[3], params)
|
||||||
|
q_new = q_old + dt / 6 * (k1[0] + 2 * k2[0] + 2 * k3[0] + k4[0])
|
||||||
|
i_new = i_old + dt / 6 * (k1[1] + 2 * k2[1] + 2 * k3[1] + k4[1])
|
||||||
|
x_new = x_old + dt / 6 * (k1[2] + 2 * k2[2] + 2 * k3[2] + k4[2])
|
||||||
|
v_new = v_old + dt / 6 * (k1[3] + 2 * k2[3] + 2 * k3[3] + k4[3])
|
||||||
|
overlap_old, _ = _overlap(xp, x_old, params)
|
||||||
|
overlap_new, _ = _overlap(xp, x_new, params)
|
||||||
|
|
||||||
|
# потери I²·R_eff (с вихревыми × overlap) и пиковый ток — только для активных
|
||||||
|
r_eff_old = params.r_total_ohm + params.r_eddy_coeff_ohm * overlap_old
|
||||||
|
r_eff_new = params.r_total_ohm + params.r_eddy_coeff_ohm * overlap_new
|
||||||
|
step_diss = 0.5 * (i_old**2 * r_eff_old + i_new**2 * r_eff_new) * dt
|
||||||
|
# работа трения/воздуха: P = (F_тр + drag·v²)·|v|
|
||||||
|
fric_old = (params.retard_const_n + params.drag_coeff_n * v_old**2) * xp.abs(v_old)
|
||||||
|
fric_new = (params.retard_const_n + params.drag_coeff_n * v_new**2) * xp.abs(v_new)
|
||||||
|
step_diss = step_diss + 0.5 * (fric_old + fric_new) * dt
|
||||||
|
energy_diss = energy_diss + xp.where(active, step_diss, 0.0)
|
||||||
|
peak_current = xp.maximum(peak_current, xp.where(active, xp.abs(i_new), 0.0))
|
||||||
|
past_peak = past_peak | (active & (i_old > 1.0) & (i_new < i_old))
|
||||||
|
|
||||||
|
# ОДИН импульс: обрыв на нуле тока, ИЛИ на первом локальном минимуме
|
||||||
|
# (снаряд начал подкачивать ток обратно), ИЛИ при падении ниже тока
|
||||||
|
# удержания ключа (передемпфированный хвост; см. physics/circuit.py).
|
||||||
|
crossed = active & (i_old > 0) & (i_new <= 0)
|
||||||
|
decayed = active & past_peak & (i_new < 0.1) & ~crossed # I_hold=0.1А
|
||||||
|
local_min = active & past_peak & (i_new > i_old) & ~crossed & ~decayed
|
||||||
|
cut = crossed | local_min | decayed
|
||||||
|
# zero-crossing: интерполяция к I=0; local_min: берём состояние минимума (old)
|
||||||
|
frac = xp.where(crossed, i_old / (i_old - i_new + 1e-30), 0.0)
|
||||||
|
exit_v = xp.where(crossed, _interp(v_old, v_new, frac), exit_v)
|
||||||
|
exit_x = xp.where(crossed, _interp(x_old, x_new, frac), exit_x)
|
||||||
|
exit_q = xp.where(crossed, _interp(q_old, q_new, frac), exit_q)
|
||||||
|
cut_at_state = local_min | decayed # обрыв в текущем состоянии (не интерп.)
|
||||||
|
exit_v = xp.where(cut_at_state, v_old, exit_v)
|
||||||
|
exit_x = xp.where(cut_at_state, x_old, exit_x)
|
||||||
|
exit_q = xp.where(cut_at_state, q_old, exit_q)
|
||||||
|
# остаточная энергия катушки при обрыве -> в потери (freewheel)
|
||||||
|
residual = _magnetic_energy(xp, x_old, i_old, params)
|
||||||
|
energy_diss = energy_diss + xp.where(cut_at_state, residual, 0.0)
|
||||||
|
committed = committed | cut # валидная коммутация
|
||||||
|
|
||||||
|
# стиффный конфиг «взорвал» fixed-step (inf/nan) -> стоп, НЕ реализуем
|
||||||
|
blew_up = active & ~cut & ~xp.isfinite(i_new)
|
||||||
|
done = done | cut | blew_up
|
||||||
|
|
||||||
|
# продвигаем только ещё активные и не взорвавшиеся конфигурации
|
||||||
|
advance = active & ~blew_up
|
||||||
|
q = xp.where(advance, q_new, q_old)
|
||||||
|
i = xp.where(advance, i_new, i_old)
|
||||||
|
x = xp.where(advance, x_new, x_old)
|
||||||
|
v = xp.where(advance, v_new, v_old)
|
||||||
|
|
||||||
|
if _old_err is not None and hasattr(xp, "seterr"):
|
||||||
|
xp.seterr(**_old_err)
|
||||||
|
feasible = committed # реализуемо = реально скоммутировал, а не done-по-переполнению
|
||||||
|
return {
|
||||||
|
"exit_v": exit_v,
|
||||||
|
"exit_x": exit_x,
|
||||||
|
"exit_q": exit_q,
|
||||||
|
"peak_current": peak_current,
|
||||||
|
"energy_dissipated_j": energy_diss,
|
||||||
|
"feasible": feasible,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _interp(old, new, frac):
|
||||||
|
# значение в момент пересечения нуля тока между old и new
|
||||||
|
return old + frac * (new - old)
|
||||||
367
src/gausse/gpu/batch_sweep.py
Normal file
367
src/gausse/gpu/batch_sweep.py
Normal file
@@ -0,0 +1,367 @@
|
|||||||
|
"""GPU/CPU батч-sweep МНОГОСТУПЕНЧАТЫХ конфигураций (до max_stages).
|
||||||
|
|
||||||
|
Векторизует разряд сразу по многим конфигурациям через
|
||||||
|
`integrate_batch_discharge` (numpy CPU / cupy GPU). Многоступенчатость —
|
||||||
|
раунд за раундом: на раунде s все конфиги, у которых есть ступень s и
|
||||||
|
которые ещё «живы», считают свой разряд ОДНИМ батчем; состояние снаряда
|
||||||
|
(глобальная координата и скорость) переносится в следующий раунд.
|
||||||
|
Конфиги с меньшим числом ступеней просто не участвуют в поздних раундах.
|
||||||
|
|
||||||
|
Триггер датчика — аналитический при постоянной скорости подлёта (для
|
||||||
|
оптики/Холла точный; для индукционного — та же проверка порога, что на CPU).
|
||||||
|
Физика идентична CPU-пути (общий `sim.stage.build_stage_physics`), сверено
|
||||||
|
в tests/test_batch_sweep.py (0.0% расхождение exit_v).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass, field, replace
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.gpu.backend import get_backend, to_cpu
|
||||||
|
from gausse.gpu.batch_integrator import NO_SATURATION_I_SAT, BatchDischargeParams, integrate_batch_discharge
|
||||||
|
from gausse.optim.objective import MODEL_VERSION
|
||||||
|
from gausse.optim.progress_log import ProgressLogger, default_log_path
|
||||||
|
from gausse.optim.search_space import SearchBounds, decode, genome_to_dict, sample_genome
|
||||||
|
from gausse.physics.constants import switch_pulse_limit_a
|
||||||
|
from gausse.sim.stage import build_stage_physics
|
||||||
|
from gausse.storage.database import insert_runs, open_connection
|
||||||
|
from gausse.storage.schema import RunRecord
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StagePre:
|
||||||
|
"""Физика ступени, предвычисленная в ПЛОСКИЕ числа (пиклится дёшево).
|
||||||
|
|
||||||
|
Считается один раз на геном (в воркере пула, параллельно) через тот же
|
||||||
|
`build_stage_physics` — один источник истины с CPU-путём. Главному потоку
|
||||||
|
остаётся только склейка массивов и запуск GPU-ядер: раньше он строил
|
||||||
|
физобъекты 4000 геномов сам и душил GPU (карта ждала ~половину времени).
|
||||||
|
"""
|
||||||
|
|
||||||
|
l_air_h: float
|
||||||
|
l_iron_coeff: float
|
||||||
|
coil_length_m: float
|
||||||
|
slug_length_m: float
|
||||||
|
smoothing_width_m: float
|
||||||
|
i_sat_a: float
|
||||||
|
r_total_ohm: float
|
||||||
|
r_eddy_coeff_ohm: float
|
||||||
|
retard_const_n: float
|
||||||
|
drag_coeff_n: float
|
||||||
|
capacitance_f: float
|
||||||
|
q0: float
|
||||||
|
energy_in_j: float
|
||||||
|
sensor_to_coil_m: float
|
||||||
|
fire_delay_s: float
|
||||||
|
sensor_kind: str
|
||||||
|
sensor_sens_v_per_mps: float
|
||||||
|
sensor_threshold_v: float
|
||||||
|
pulse_limit_a: float
|
||||||
|
degenerate_reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class GenomePre:
|
||||||
|
stages: tuple # StagePre по ступеням
|
||||||
|
coil_centers: tuple # абсолютные центры катушек вдоль трубы
|
||||||
|
mass_kg: float
|
||||||
|
initial_x_m: float
|
||||||
|
initial_v_mps: float
|
||||||
|
|
||||||
|
|
||||||
|
def precompute_genome(genome, db: ComponentDatabase, bounds: SearchBounds) -> GenomePre:
|
||||||
|
"""decode + build_stage_physics -> плоские числа (годится для пула процессов)."""
|
||||||
|
cfg, ix, iv = decode(genome, db, bounds)
|
||||||
|
centers = [0.0]
|
||||||
|
for gap in cfg.inter_stage_gaps_m:
|
||||||
|
centers.append(centers[-1] + gap)
|
||||||
|
stages = []
|
||||||
|
for stage in cfg.stages:
|
||||||
|
phys = build_stage_physics(stage, cfg.projectile)
|
||||||
|
m = phys.inductance_model
|
||||||
|
cp_ = phys.circuit_params
|
||||||
|
tau_worst = m.l_air_h / (phys.r_total_ohm + phys.r_eddy_coeff_ohm + 1e-12)
|
||||||
|
i_sat = m.saturation_current_a
|
||||||
|
stages.append(StagePre(
|
||||||
|
l_air_h=m.l_air_h,
|
||||||
|
l_iron_coeff=m.l_iron_coeff,
|
||||||
|
coil_length_m=m.coil_length_m,
|
||||||
|
slug_length_m=m.slug_length_m,
|
||||||
|
smoothing_width_m=m.smoothing_width_m,
|
||||||
|
i_sat_a=i_sat if math.isfinite(i_sat) else NO_SATURATION_I_SAT,
|
||||||
|
r_total_ohm=phys.r_total_ohm,
|
||||||
|
r_eddy_coeff_ohm=phys.r_eddy_coeff_ohm,
|
||||||
|
retard_const_n=cp_.retard_const_n,
|
||||||
|
drag_coeff_n=cp_.drag_coeff_n_per_mps2,
|
||||||
|
capacitance_f=phys.capacitance_f,
|
||||||
|
q0=phys.capacitance_f * stage.charge_voltage_v,
|
||||||
|
energy_in_j=0.5 * phys.capacitance_f * stage.charge_voltage_v**2,
|
||||||
|
sensor_to_coil_m=stage.sensor_to_coil_distance_m,
|
||||||
|
fire_delay_s=(stage.sensor.propagation_delay_ns + stage.switch.turn_on_time_ns) * 1e-9,
|
||||||
|
sensor_kind=stage.sensor.kind,
|
||||||
|
sensor_sens_v_per_mps=getattr(stage.sensor, "sensitivity_v_per_mps", None) or 0.0,
|
||||||
|
sensor_threshold_v=getattr(stage.sensor, "threshold_v", None) or 0.0,
|
||||||
|
pulse_limit_a=switch_pulse_limit_a(stage.switch),
|
||||||
|
degenerate_reason=(
|
||||||
|
"вырожденная катушка: L/R < 100 нс (вне области модели)" if tau_worst < 1e-7 else None
|
||||||
|
),
|
||||||
|
))
|
||||||
|
return GenomePre(
|
||||||
|
stages=tuple(stages), coil_centers=tuple(centers),
|
||||||
|
mass_kg=cfg.projectile.mass_kg, initial_x_m=ix, initial_v_mps=iv,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def precompute_worker(genome) -> GenomePre:
|
||||||
|
"""Вариант для пула (ProcessPoolExecutor c worker_context.init_worker)."""
|
||||||
|
from gausse.optim import worker_context
|
||||||
|
|
||||||
|
return precompute_genome(genome, worker_context.db, worker_context.bounds)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _State:
|
||||||
|
genome: object
|
||||||
|
pre: GenomePre
|
||||||
|
cur_x: float # текущая глобальная координата снаряда
|
||||||
|
cur_v: float # текущая скорость
|
||||||
|
energy_in_j: float = 0.0
|
||||||
|
kinetic_delta_j: float = 0.0
|
||||||
|
alive: bool = True
|
||||||
|
reason: str | None = None
|
||||||
|
failed_stage_index: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _coast_velocity(v0: float, distance_m: float, retard_const_n: float, drag_coeff: float, mass_kg: float) -> float:
|
||||||
|
"""Скорость после подлёта на distance_m с трением и воздухом (точное решение).
|
||||||
|
|
||||||
|
m·v·dv/dx = −(a + b·v²) ⇒ v²(d) = (v0² + a/b)·e^{−2bd/m} − a/b (b>0),
|
||||||
|
либо v² = v0² − 2ad/m при b=0. Возвращает 0.0, если снаряд останавливается.
|
||||||
|
Та же физика, что численный подлёт в sim/stage.py (там ОДУ, тут квадратура).
|
||||||
|
"""
|
||||||
|
if distance_m <= 0:
|
||||||
|
return v0
|
||||||
|
a, b, m = retard_const_n, drag_coeff, mass_kg
|
||||||
|
if b <= 0:
|
||||||
|
u = v0 * v0 - 2.0 * a * distance_m / m
|
||||||
|
else:
|
||||||
|
u = (v0 * v0 + a / b) * math.exp(-2.0 * b * distance_m / m) - a / b
|
||||||
|
return math.sqrt(u) if u > 0 else 0.0
|
||||||
|
|
||||||
|
|
||||||
|
def _prepare_stage(sp: StagePre, mass_kg: float, cur_x_global, cur_v, coil_center_global):
|
||||||
|
"""Аналитический fire-state ступени из текущего (x,v) — только арифметика."""
|
||||||
|
if cur_v <= 1e-6:
|
||||||
|
return None, "снаряд остановился/пошёл назад"
|
||||||
|
# вырожденный контур (см. sim/stage.py) — тот же честный быстрый отказ
|
||||||
|
if sp.degenerate_reason:
|
||||||
|
return None, sp.degenerate_reason
|
||||||
|
sensor_global = coil_center_global - sp.sensor_to_coil_m
|
||||||
|
if sensor_global < cur_x_global - 1e-9:
|
||||||
|
return None, "датчик позади снаряда (не сработает)"
|
||||||
|
# подлёт до датчика — с трением о трубку и воздухом (та же физика, что на CPU)
|
||||||
|
v_at_sensor = _coast_velocity(
|
||||||
|
cur_v, sensor_global - cur_x_global, sp.retard_const_n, sp.drag_coeff_n, mass_kg
|
||||||
|
)
|
||||||
|
if v_at_sensor <= 1e-3:
|
||||||
|
return None, "снаряд остановлен трением о трубку, не долетев до датчика"
|
||||||
|
if sp.sensor_kind == "inductive":
|
||||||
|
if sp.sensor_sens_v_per_mps * v_at_sensor < sp.sensor_threshold_v:
|
||||||
|
return None, "инд. датчик: сигнал ниже порога"
|
||||||
|
x_fire_global = sensor_global + v_at_sensor * sp.fire_delay_s
|
||||||
|
x_fire_local = x_fire_global - coil_center_global
|
||||||
|
return {
|
||||||
|
"sp": sp, "x_fire": x_fire_local, "v_fire": v_at_sensor,
|
||||||
|
"coil_center": coil_center_global, "mass": mass_kg,
|
||||||
|
}, None
|
||||||
|
|
||||||
|
|
||||||
|
def _simulate_states(xp, states: list) -> None:
|
||||||
|
"""Раунд-за-раундом прогоняет разряды всех живых ступеней батчами (мутирует states)."""
|
||||||
|
if not states:
|
||||||
|
return
|
||||||
|
max_ns = max(len(s.pre.stages) for s in states)
|
||||||
|
for s_idx in range(max_ns):
|
||||||
|
prepared = [] # (state, prep)
|
||||||
|
for st in states:
|
||||||
|
if not st.alive or len(st.pre.stages) <= s_idx:
|
||||||
|
continue
|
||||||
|
p, reason = _prepare_stage(
|
||||||
|
st.pre.stages[s_idx], st.pre.mass_kg, st.cur_x, st.cur_v, st.pre.coil_centers[s_idx]
|
||||||
|
)
|
||||||
|
if p is None:
|
||||||
|
st.alive = False
|
||||||
|
st.reason = f"ступень {s_idx}: {reason}"
|
||||||
|
st.failed_stage_index = s_idx
|
||||||
|
else:
|
||||||
|
prepared.append((st, p))
|
||||||
|
if not prepared:
|
||||||
|
continue
|
||||||
|
|
||||||
|
def col(get):
|
||||||
|
return xp.asarray([get(p["sp"], p) for _, p in prepared], dtype=xp.float64)
|
||||||
|
|
||||||
|
params = BatchDischargeParams(
|
||||||
|
capacitance_f=col(lambda sp, p: sp.capacitance_f),
|
||||||
|
r_total_ohm=col(lambda sp, p: sp.r_total_ohm),
|
||||||
|
mass_kg=col(lambda sp, p: p["mass"]),
|
||||||
|
l_air_h=col(lambda sp, p: sp.l_air_h),
|
||||||
|
l_iron_coeff=col(lambda sp, p: sp.l_iron_coeff),
|
||||||
|
coil_length_m=col(lambda sp, p: sp.coil_length_m),
|
||||||
|
slug_length_m=col(lambda sp, p: sp.slug_length_m),
|
||||||
|
smoothing_width_m=col(lambda sp, p: sp.smoothing_width_m),
|
||||||
|
i_sat_a=col(lambda sp, p: sp.i_sat_a),
|
||||||
|
r_eddy_coeff_ohm=col(lambda sp, p: sp.r_eddy_coeff_ohm),
|
||||||
|
retard_const_n=col(lambda sp, p: sp.retard_const_n),
|
||||||
|
drag_coeff_n=col(lambda sp, p: sp.drag_coeff_n),
|
||||||
|
)
|
||||||
|
out = integrate_batch_discharge(
|
||||||
|
xp,
|
||||||
|
col(lambda sp, p: sp.q0),
|
||||||
|
col(lambda sp, p: p["x_fire"]),
|
||||||
|
col(lambda sp, p: p["v_fire"]),
|
||||||
|
params, dt=2e-6, max_steps=15000,
|
||||||
|
)
|
||||||
|
exit_v = to_cpu(xp, out["exit_v"]); exit_x = to_cpu(xp, out["exit_x"])
|
||||||
|
peak_i = to_cpu(xp, out["peak_current"]); feas = to_cpu(xp, out["feasible"])
|
||||||
|
for k, (st, p) in enumerate(prepared):
|
||||||
|
sp = p["sp"]
|
||||||
|
if not bool(feas[k]):
|
||||||
|
st.alive = False
|
||||||
|
st.reason = f"ступень {s_idx}: разряд не скоммутировался"
|
||||||
|
st.failed_stage_index = s_idx
|
||||||
|
continue
|
||||||
|
if float(peak_i[k]) > sp.pulse_limit_a:
|
||||||
|
st.alive = False
|
||||||
|
st.reason = f"ступень {s_idx}: пиковый ток {float(peak_i[k]):.0f}А > импульсного предела ключа ({sp.pulse_limit_a:.0f}А)"
|
||||||
|
st.failed_stage_index = s_idx
|
||||||
|
continue
|
||||||
|
ev = float(exit_v[k])
|
||||||
|
st.kinetic_delta_j += 0.5 * p["mass"] * (ev**2 - p["v_fire"] ** 2)
|
||||||
|
st.energy_in_j += sp.energy_in_j
|
||||||
|
st.cur_v = ev
|
||||||
|
st.cur_x = p["coil_center"] + float(exit_x[k])
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate_genomes_gpu(
|
||||||
|
xp, genomes: list, db: ComponentDatabase, bounds: SearchBounds, executor=None,
|
||||||
|
) -> list:
|
||||||
|
"""Оценка списка геномов ОДНИМ батчем (для эволюции): та же физика и та же
|
||||||
|
формула фитнеса, что в objective.evaluate (КПД либо -1+доля пройденных
|
||||||
|
ступеней), но разряды всех геномов интегрируются вместе на GPU/numpy.
|
||||||
|
|
||||||
|
Возвращает только то, что нужно фитнесу (без detail/стоимости): записи
|
||||||
|
для БД параллельно собирает пул процессов (`gpu_record_worker`).
|
||||||
|
executor — пул для ПАРАЛЛЕЛЬНОГО предвычисления физики ступеней
|
||||||
|
(precompute_worker): без него один поток Python готовит физику всей
|
||||||
|
популяции и GPU простаивает, ожидая данные.
|
||||||
|
"""
|
||||||
|
from gausse.optim.objective import EvaluationResult
|
||||||
|
|
||||||
|
if executor is not None:
|
||||||
|
pres = list(executor.map(precompute_worker, genomes, chunksize=64))
|
||||||
|
else:
|
||||||
|
pres = [precompute_genome(g, db, bounds) for g in genomes]
|
||||||
|
states = [
|
||||||
|
_State(g, pre, cur_x=pre.initial_x_m, cur_v=pre.initial_v_mps)
|
||||||
|
for g, pre in zip(genomes, pres)
|
||||||
|
]
|
||||||
|
|
||||||
|
_simulate_states(xp, states)
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for st in states:
|
||||||
|
ok = st.alive and st.energy_in_j > 0
|
||||||
|
if ok:
|
||||||
|
eff = st.kinetic_delta_j / st.energy_in_j
|
||||||
|
results.append(EvaluationResult(
|
||||||
|
feasible=True, cost_rub=None, fitness=eff, efficiency=eff,
|
||||||
|
exit_velocity_mps=st.cur_v,
|
||||||
|
energy_breakdown={
|
||||||
|
"total_energy_in_j": st.energy_in_j,
|
||||||
|
"total_kinetic_energy_delta_j": st.kinetic_delta_j,
|
||||||
|
},
|
||||||
|
))
|
||||||
|
else:
|
||||||
|
n_stages = len(st.pre.stages)
|
||||||
|
progress = (st.failed_stage_index or 0) / n_stages if n_stages else 0.0
|
||||||
|
results.append(EvaluationResult(
|
||||||
|
feasible=False, cost_rub=None, fitness=-1.0 + progress,
|
||||||
|
reason=st.reason or "нет ни одной сработавшей ступени",
|
||||||
|
failed_stage_index=st.failed_stage_index,
|
||||||
|
energy_breakdown={},
|
||||||
|
))
|
||||||
|
return results
|
||||||
|
|
||||||
|
|
||||||
|
def run_gpu_sweep(
|
||||||
|
db_path: Path,
|
||||||
|
n_runs: int,
|
||||||
|
bounds: SearchBounds = SearchBounds(),
|
||||||
|
data_dir: Path | None = None,
|
||||||
|
seed: int | None = None,
|
||||||
|
prefer_gpu: bool = True,
|
||||||
|
batch_size: int = 20000,
|
||||||
|
log_path: Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
xp, backend = get_backend(prefer_gpu=prefer_gpu)
|
||||||
|
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
|
||||||
|
rng = random.Random(seed if seed is not None else random.randrange(2**31))
|
||||||
|
logger = ProgressLogger(log_path or default_log_path(db_path), mode=f"gpu-sweep({backend})", total=n_runs)
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
|
||||||
|
n_feasible = 0
|
||||||
|
done = 0
|
||||||
|
try:
|
||||||
|
while done < n_runs:
|
||||||
|
n = min(batch_size, n_runs - done)
|
||||||
|
states = []
|
||||||
|
for _ in range(n):
|
||||||
|
g = sample_genome(db, bounds, rng)
|
||||||
|
pre = precompute_genome(g, db, bounds)
|
||||||
|
states.append(_State(g, pre, cur_x=pre.initial_x_m, cur_v=pre.initial_v_mps))
|
||||||
|
|
||||||
|
_simulate_states(xp, states)
|
||||||
|
|
||||||
|
records = []
|
||||||
|
for st in states:
|
||||||
|
if st.alive and st.energy_in_j > 0:
|
||||||
|
eff = st.kinetic_delta_j / st.energy_in_j
|
||||||
|
records.append(_record(st, db, backend, eff, st.cur_v))
|
||||||
|
n_feasible += 1
|
||||||
|
else:
|
||||||
|
records.append(_record(st, db, backend, None, None))
|
||||||
|
insert_runs(conn, records)
|
||||||
|
for r in records:
|
||||||
|
logger.update(r.feasible, r.efficiency)
|
||||||
|
done += n
|
||||||
|
finally:
|
||||||
|
logger.finish()
|
||||||
|
conn.close()
|
||||||
|
return {"n_runs": done, "n_feasible": n_feasible, "backend": backend}
|
||||||
|
|
||||||
|
|
||||||
|
def _record(st: _State, db, backend, efficiency, exit_v):
|
||||||
|
from gausse.optim.objective import decoded_summary
|
||||||
|
|
||||||
|
feasible = efficiency is not None
|
||||||
|
return RunRecord(
|
||||||
|
run_id=str(uuid.uuid4()),
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
search_mode=f"gpu-sweep-{backend}",
|
||||||
|
genome_json=json.dumps(genome_to_dict(st.genome)),
|
||||||
|
decoded_summary_json=json.dumps(decoded_summary(st.genome, db), ensure_ascii=False),
|
||||||
|
feasible=feasible,
|
||||||
|
model_version=MODEL_VERSION,
|
||||||
|
infeasible_reason=st.reason,
|
||||||
|
failed_stage_index=None,
|
||||||
|
efficiency=efficiency,
|
||||||
|
exit_velocity_mps=exit_v,
|
||||||
|
cost_rub=None,
|
||||||
|
energy_breakdown_json=None,
|
||||||
|
)
|
||||||
304
src/gausse/optim/evolutionary.py
Normal file
304
src/gausse/optim/evolutionary.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"""(μ+λ)-эволюционный поиск поверх той же базы SQLite, что и sweep.
|
||||||
|
|
||||||
|
Каждый оценённый геном каждого поколения пишется в общую таблицу `runs`
|
||||||
|
(search_mode="evolve") — включая неудачные, с той же честной причиной
|
||||||
|
отказа. После эволюции — локальная doводка (Nelder-Mead) непрерывной
|
||||||
|
части лучшего найденного генома при замороженных дискретных выборах
|
||||||
|
компонентов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import multiprocessing
|
||||||
|
import random
|
||||||
|
import signal
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.optimize import minimize
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim import worker_context
|
||||||
|
from gausse.optim.objective import EvaluationResult, build_run_record, compute_cost_rub, evaluate
|
||||||
|
from gausse.optim.progress_log import ProgressLogger, default_log_path
|
||||||
|
from gausse.optim.search_space import Genome, SearchBounds, crossover, decode, mutate, repair, sample_genome
|
||||||
|
from gausse.storage.database import StatsOnly, insert_run, open_connection, run_writer_process
|
||||||
|
from gausse.storage.schema import RunRecord
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_genome(genome: Genome) -> tuple[Genome, EvaluationResult]:
|
||||||
|
result = evaluate(genome, worker_context.db, worker_context.bounds)
|
||||||
|
return genome, result
|
||||||
|
|
||||||
|
|
||||||
|
def _best_record(genome: Genome, result: EvaluationResult, db, bounds, mode: str) -> RunRecord:
|
||||||
|
"""Полная строка БД для сохраняемого лучшего генома. GPU-путь не считает
|
||||||
|
стоимость для рядовых оценок — дочитываем её только здесь, для избранных."""
|
||||||
|
if result.cost_rub is None:
|
||||||
|
config, _, _ = decode(genome, db, bounds)
|
||||||
|
result.cost_rub = compute_cost_rub(config, db)
|
||||||
|
return build_run_record(genome, result, db, search_mode=mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _tournament_select(
|
||||||
|
pairs: list[tuple[Genome, EvaluationResult]], rng: random.Random, k: int = 3
|
||||||
|
) -> Genome:
|
||||||
|
contenders = rng.sample(pairs, min(k, len(pairs)))
|
||||||
|
winner = max(contenders, key=lambda pair: pair[1].fitness)
|
||||||
|
return winner[0]
|
||||||
|
|
||||||
|
|
||||||
|
def _continuous_vector(genome: Genome) -> list[float]:
|
||||||
|
vec = [genome.tube_inner_d_m, genome.tube_wall_m]
|
||||||
|
for stage in genome.stages:
|
||||||
|
vec.append(stage.sensor_to_coil_distance_m)
|
||||||
|
vec.append(stage.charge_voltage_fraction)
|
||||||
|
vec.extend(genome.inter_stage_gaps_m)
|
||||||
|
vec.append(genome.projectile.diameter_m)
|
||||||
|
vec.append(genome.projectile.length_m)
|
||||||
|
return vec
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_continuous_vector(template: Genome, vec: list[float]) -> Genome:
|
||||||
|
genome = copy.deepcopy(template)
|
||||||
|
idx = 0
|
||||||
|
genome.tube_inner_d_m = vec[idx]
|
||||||
|
idx += 1
|
||||||
|
genome.tube_wall_m = vec[idx]
|
||||||
|
idx += 1
|
||||||
|
for stage in genome.stages:
|
||||||
|
stage.sensor_to_coil_distance_m = vec[idx]
|
||||||
|
idx += 1
|
||||||
|
stage.charge_voltage_fraction = vec[idx]
|
||||||
|
idx += 1
|
||||||
|
n_gaps = len(genome.inter_stage_gaps_m)
|
||||||
|
genome.inter_stage_gaps_m = list(vec[idx : idx + n_gaps])
|
||||||
|
idx += n_gaps
|
||||||
|
genome.projectile.diameter_m = vec[idx]
|
||||||
|
idx += 1
|
||||||
|
genome.projectile.length_m = vec[idx]
|
||||||
|
idx += 1
|
||||||
|
return genome
|
||||||
|
|
||||||
|
|
||||||
|
def polish_best(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> tuple[Genome, EvaluationResult]:
|
||||||
|
"""Локальная доводка непрерывных параметров лучшего генома (дискретные выборы заморожены)."""
|
||||||
|
x0 = np.array(_continuous_vector(genome))
|
||||||
|
|
||||||
|
def neg_fitness(x: np.ndarray) -> float:
|
||||||
|
candidate = repair(_apply_continuous_vector(genome, list(x)), db, bounds)
|
||||||
|
return -evaluate(candidate, db, bounds).fitness
|
||||||
|
|
||||||
|
opt = minimize(neg_fitness, x0, method="Nelder-Mead", options={"maxiter": 200, "xatol": 1e-4, "fatol": 1e-5})
|
||||||
|
polished = repair(_apply_continuous_vector(genome, list(opt.x)), db, bounds)
|
||||||
|
return polished, evaluate(polished, db, bounds)
|
||||||
|
|
||||||
|
|
||||||
|
def run_evolution(
|
||||||
|
db_path: Path,
|
||||||
|
n_generations: int,
|
||||||
|
population_size: int,
|
||||||
|
bounds: SearchBounds = SearchBounds(),
|
||||||
|
data_dir: Path | None = None,
|
||||||
|
n_workers: int | None = None,
|
||||||
|
seed: int | None = None,
|
||||||
|
elitism: int = 2,
|
||||||
|
mutation_rate: float = 0.2,
|
||||||
|
polish: bool = True,
|
||||||
|
log_path: Path | None = None,
|
||||||
|
use_gpu: bool = False,
|
||||||
|
islands: int = 1,
|
||||||
|
migrate_every: int = 10,
|
||||||
|
) -> dict:
|
||||||
|
"""islands>1 — островная модель: K независимых популяций эволюционируют
|
||||||
|
параллельно, а их поколения оцениваются ОДНИМ батчем (GPU любит жирные
|
||||||
|
батчи: на 1070 батч 4000 давал 306/с, батч 32000 — 1211/с, потому что
|
||||||
|
маленькие ядра упираются в накладные на запуск). Раз в migrate_every
|
||||||
|
поколений лучший геном каждого острова мигрирует к соседу по кольцу —
|
||||||
|
разнообразие против скатывания всех в один локальный оптимум.
|
||||||
|
"""
|
||||||
|
n_workers = n_workers or multiprocessing.cpu_count()
|
||||||
|
rng = random.Random(seed)
|
||||||
|
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
|
||||||
|
|
||||||
|
# GPU-режим: всё поколение оценивается ОДНИМ батчем (gpu/batch_sweep) —
|
||||||
|
# та же физика через общий build_stage_physics, фикс.шаг RK4 вместо
|
||||||
|
# адаптивного solve_ivp (сверено в тестах, ~3%). Полировка остаётся на CPU.
|
||||||
|
gpu_xp = None
|
||||||
|
backend = "cpu-pool"
|
||||||
|
if use_gpu:
|
||||||
|
from gausse.gpu.backend import get_backend
|
||||||
|
|
||||||
|
gpu_xp, backend = get_backend(prefer_gpu=True)
|
||||||
|
|
||||||
|
mode = "evolve" if gpu_xp is None else f"evolve-gpu({backend})"
|
||||||
|
if islands > 1:
|
||||||
|
mode += f"-x{islands}остр"
|
||||||
|
logger = ProgressLogger(
|
||||||
|
log_path or default_log_path(db_path), mode=mode, total=n_generations * population_size * islands
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = multiprocessing.get_context("spawn")
|
||||||
|
queue = ctx.Queue()
|
||||||
|
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
|
||||||
|
writer.start()
|
||||||
|
|
||||||
|
# SIGTERM (остановка/рестарт systemd) -> исключение -> блок finally ниже
|
||||||
|
# штатно досылает None и ЖДЁТ писателя: всё уже оценённое дописывается в
|
||||||
|
# базу, а не теряется вместе с очередью в RAM («сохраняем всё»).
|
||||||
|
# ВАЖНО: после первого TERM остальные игнорируем — systemd шлёт сигнал
|
||||||
|
# каждому процессу группы, и повторный TERM ловился ВНУТРИ writer.join(),
|
||||||
|
# обрывая дописывание базы (реальный кейс с потерей хвоста очереди).
|
||||||
|
def _terminate(_sig, _frm):
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||||
|
raise KeyboardInterrupt("SIGTERM: дописываем очередь и выходим")
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, _terminate)
|
||||||
|
|
||||||
|
populations = [
|
||||||
|
[sample_genome(db, bounds, rng) for _ in range(population_size)] for _ in range(islands)
|
||||||
|
]
|
||||||
|
best_pair: tuple[Genome, EvaluationResult] | None = None
|
||||||
|
n_evaluated = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
# пул нужен в ОБОИХ режимах: на CPU он оценивает геномы, в GPU-режиме
|
||||||
|
# параллельно собирает записи (detail+JSON) — иначе один поток Python
|
||||||
|
# душит GPU (карта простаивала ~2/3 времени поколения)
|
||||||
|
with ProcessPoolExecutor(
|
||||||
|
max_workers=n_workers,
|
||||||
|
mp_context=ctx,
|
||||||
|
initializer=worker_context.init_worker,
|
||||||
|
initargs=(data_dir, bounds),
|
||||||
|
) as executor:
|
||||||
|
# фитнес лучшей УЖЕ ЗАПИСАННОЙ строки каждого острова: полные
|
||||||
|
# строки в базу идут только когда остров улучшился — иначе элитизм
|
||||||
|
# дублировал бы один и тот же геном в топ дашборда каждое поколение
|
||||||
|
written_best_fitness = [float("-inf")] * islands
|
||||||
|
# СКОРОСТЬ отслеживается отдельно: у дашборда два топа (КПД и
|
||||||
|
# скорость), а рекордсмены скорости — почти всегда «прожорливые»
|
||||||
|
# геномы с низким КПД, которые КПД-рекорд не улучшают. Без этого
|
||||||
|
# трекера топ скоростей замирал навсегда (реальный кейс: после
|
||||||
|
# перехода на выборочную запись он не обновлялся сутки, пока
|
||||||
|
# эволюция находила всё новые быстрые геномы)
|
||||||
|
written_best_velocity = [float("-inf")] * islands
|
||||||
|
|
||||||
|
for generation in range(n_generations):
|
||||||
|
# все острова — ОДНИМ плоским батчем (жирный батч кормит GPU)
|
||||||
|
flat = [g for island in populations for g in island]
|
||||||
|
if gpu_xp is not None:
|
||||||
|
from gausse.gpu.batch_sweep import evaluate_genomes_gpu
|
||||||
|
|
||||||
|
results = evaluate_genomes_gpu(gpu_xp, flat, db, bounds, executor=executor)
|
||||||
|
pairs_flat = list(zip(flat, results))
|
||||||
|
else:
|
||||||
|
pairs_flat = list(executor.map(_evaluate_genome, flat))
|
||||||
|
n_evaluated += len(pairs_flat)
|
||||||
|
|
||||||
|
for genome, result in pairs_flat:
|
||||||
|
logger.update(result.feasible, result.efficiency)
|
||||||
|
if best_pair is None or result.fitness > best_pair[1].fitness:
|
||||||
|
best_pair = (genome, result)
|
||||||
|
|
||||||
|
# селекция и скрещивание — НА КАЖДОМ ОСТРОВЕ независимо
|
||||||
|
island_bests: list[Genome] = []
|
||||||
|
next_populations = []
|
||||||
|
recorded = set() # id() результатов, ушедших полной строкой
|
||||||
|
for i in range(islands):
|
||||||
|
pairs = pairs_flat[i * population_size:(i + 1) * population_size]
|
||||||
|
pairs.sort(key=lambda pair: pair[1].fitness, reverse=True)
|
||||||
|
island_bests.append(pairs[0][0])
|
||||||
|
# улучшение лучшего на острове — полная строка в базу
|
||||||
|
# (топы дашборда, best за всё время, отчёты). Новый
|
||||||
|
# глобальный best — всегда чей-то островной best, так что
|
||||||
|
# он не теряется.
|
||||||
|
if pairs[0][1].fitness > written_best_fitness[i]:
|
||||||
|
written_best_fitness[i] = pairs[0][1].fitness
|
||||||
|
queue.put(_best_record(pairs[0][0], pairs[0][1], db, bounds, mode))
|
||||||
|
recorded.add(id(pairs[0][1]))
|
||||||
|
# рекорд СКОРОСТИ острова — тоже полной строкой
|
||||||
|
fastest = max(
|
||||||
|
(p for p in pairs if p[1].feasible and p[1].exit_velocity_mps is not None),
|
||||||
|
key=lambda p: p[1].exit_velocity_mps,
|
||||||
|
default=None,
|
||||||
|
)
|
||||||
|
if fastest is not None and fastest[1].exit_velocity_mps > written_best_velocity[i]:
|
||||||
|
written_best_velocity[i] = fastest[1].exit_velocity_mps
|
||||||
|
if id(fastest[1]) not in recorded:
|
||||||
|
queue.put(_best_record(fastest[0], fastest[1], db, bounds, mode))
|
||||||
|
recorded.add(id(fastest[1]))
|
||||||
|
next_population = [copy.deepcopy(pair[0]) for pair in pairs[:elitism]]
|
||||||
|
while len(next_population) < population_size:
|
||||||
|
parent_a = _tournament_select(pairs, rng)
|
||||||
|
parent_b = _tournament_select(pairs, rng)
|
||||||
|
child = crossover(parent_a, parent_b, rng)
|
||||||
|
child = mutate(child, db, bounds, rng, rate=mutation_rate)
|
||||||
|
next_population.append(child)
|
||||||
|
next_populations.append(next_population)
|
||||||
|
|
||||||
|
# рядовые оценки — в базу ТОЛЬКО агрегатами (счётчики/
|
||||||
|
# гистограмма честно считают все оценки, вставка полной строки
|
||||||
|
# выше сама инкрементирует их для избранных — без двойного
|
||||||
|
# счёта). Хранить миллионы проходных геномов незачем
|
||||||
|
# («сохраняй то, что нужно для эволюции»), а вставка каждого
|
||||||
|
# в HDD-базу с 6 индексами была узким местом конвейера:
|
||||||
|
# GPU считал цикл ~23 мин, дозапись хвоста шла часами.
|
||||||
|
for _genome, result in pairs_flat:
|
||||||
|
if id(result) not in recorded:
|
||||||
|
queue.put(StatsOnly(mode, result.feasible, result.efficiency, result.reason))
|
||||||
|
|
||||||
|
# миграция по кольцу: лучший острова i замещает слот у острова i+1
|
||||||
|
if islands > 1 and (generation + 1) % migrate_every == 0:
|
||||||
|
for i in range(islands):
|
||||||
|
next_populations[(i + 1) % islands][elitism] = copy.deepcopy(island_bests[i])
|
||||||
|
populations = next_populations
|
||||||
|
finally:
|
||||||
|
# с этого места нас нельзя прерывать: дописываем базу до конца
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||||
|
logger.finish()
|
||||||
|
logger._write("дописываю хвост очереди в базу (писатель)…")
|
||||||
|
queue.put(None)
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
writer.join()
|
||||||
|
break
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
continue # уже завершаемся; ждём писателя до конца
|
||||||
|
# явно закрыть очередь: иначе её семафоры финализируются в гонке с
|
||||||
|
# resource_tracker на выходе интерпретатора и сыплют в журнал
|
||||||
|
# «Exception ignored … sem_unlink FileNotFoundError» после каждого цикла
|
||||||
|
queue.close()
|
||||||
|
queue.join_thread()
|
||||||
|
# после дренажа возвращаем обработку SIGTERM по умолчанию: иначе
|
||||||
|
# процесс в фазе полировки нельзя остановить мягко (только SIGKILL)
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_DFL)
|
||||||
|
logger._write("база дописана полностью")
|
||||||
|
|
||||||
|
assert best_pair is not None
|
||||||
|
best_genome, best_result = best_pair
|
||||||
|
|
||||||
|
if polish and best_result.feasible:
|
||||||
|
logger._write("полировка лучшего генома (Nelder-Mead, CPU) — это НЕ остановка, новый цикл стартует после неё")
|
||||||
|
polished_genome, polished_result = polish_best(best_genome, db, bounds)
|
||||||
|
logger._write(f"полировка готова: КПД {best_result.fitness*100:.2f}% -> {polished_result.fitness*100:.2f}%")
|
||||||
|
if polished_result.fitness > best_result.fitness:
|
||||||
|
# ОДНУ запись пишем напрямую, без процесса-писателя и очереди:
|
||||||
|
# конкурентных писателей в этот момент нет (основной уже завершён),
|
||||||
|
# а редкая гонка spawn+Queue вешала главный процесс в join()
|
||||||
|
# НАВЕЧНО (22.5 часа простоя, пойман по /proc/*/stack: главный в
|
||||||
|
# do_wait, писатель в pipe_read — данные из очереди не доехали).
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
try:
|
||||||
|
insert_run(conn, build_run_record(polished_genome, polished_result, db, search_mode="evolve-polish"))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
logger._write("полированный геном записан в базу")
|
||||||
|
best_genome, best_result = polished_genome, polished_result
|
||||||
|
|
||||||
|
return {
|
||||||
|
"n_evaluated": n_evaluated,
|
||||||
|
"best_feasible": best_result.feasible,
|
||||||
|
"best_efficiency": best_result.efficiency,
|
||||||
|
"best_fitness": best_result.fitness,
|
||||||
|
"best_cost_rub": best_result.cost_rub,
|
||||||
|
}
|
||||||
317
src/gausse/optim/objective.py
Normal file
317
src/gausse/optim/objective.py
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
"""Целевая функция: КПД — основная цель, стоимость и скорость — вторичные метрики.
|
||||||
|
|
||||||
|
Нереализуемые геномы не отбрасываются — получают "мягкий" штраф фитнеса,
|
||||||
|
пропорциональный тому, на какой ступени всё сломалось, чтобы у ГА был
|
||||||
|
градиент, а не стена (см. PLAN.md).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import uuid
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim.search_space import Genome, SearchBounds, decode, genome_to_dict
|
||||||
|
from gausse.physics.ac_resistance import dowell_ac_factor
|
||||||
|
from gausse.physics.constants import switch_pulse_limit_a
|
||||||
|
from gausse.physics.inductance import air_core_inductance_wheeler, winding_geometry
|
||||||
|
from gausse.sim.coilgun import CoilgunResult, run_coilgun
|
||||||
|
from gausse.storage.schema import RunRecord
|
||||||
|
|
||||||
|
# v4: диффузия поля в снаряд — намагничивается только скин-кольцо глубиной δ,
|
||||||
|
# закрыт эксплойт «медленной» катушки (30%). v3: fill factor (эксплойт тонкого
|
||||||
|
# снаряда), полное отражение вихрей, гард вырожденных катушек. v2: Доуэлл,
|
||||||
|
# трение+воздух, паспортные импульсные токи ключей.
|
||||||
|
MODEL_VERSION = "gausse-physics-v4"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EvaluationResult:
|
||||||
|
feasible: bool
|
||||||
|
cost_rub: float
|
||||||
|
fitness: float
|
||||||
|
reason: str | None = None
|
||||||
|
failed_stage_index: int | None = None
|
||||||
|
efficiency: float | None = None
|
||||||
|
exit_velocity_mps: float | None = None
|
||||||
|
energy_breakdown: dict = None
|
||||||
|
|
||||||
|
|
||||||
|
def compute_cost_rub(config, db: ComponentDatabase) -> float:
|
||||||
|
total = 0.0
|
||||||
|
for stage in config.stages:
|
||||||
|
wire_od_m = stage.wire.insulation_od_mm / 1000
|
||||||
|
geometry = winding_geometry(stage.tube_od_m, wire_od_m, stage.turns_per_layer, stage.layers)
|
||||||
|
total += geometry.total_wire_length_m * stage.wire.price_per_m
|
||||||
|
total += stage.capacitor.price
|
||||||
|
total += stage.switch.price
|
||||||
|
total += stage.sensor.price
|
||||||
|
total += config.projectile.mass_kg * config.projectile.material.price_per_kg
|
||||||
|
return total
|
||||||
|
|
||||||
|
|
||||||
|
def _wire_resistance_ohm(resistivity_ohm_m: float, gauge_mm: float, wire_length_m: float) -> float:
|
||||||
|
bare_radius_m = gauge_mm / 1000 / 2
|
||||||
|
area_m2 = math.pi * bare_radius_m**2
|
||||||
|
return resistivity_ohm_m * wire_length_m / area_m2
|
||||||
|
|
||||||
|
|
||||||
|
def decoded_summary(genome: Genome, db: ComponentDatabase) -> dict:
|
||||||
|
"""Краткая сводка только по геному (без результатов симуляции) — это и
|
||||||
|
пишется в decoded_summary_json для каждого прогона: genome_json уже
|
||||||
|
достаточен для полного воспроизведения через build_detail() на лету,
|
||||||
|
так что хранить дорогую подробную запись для каждого из миллионов
|
||||||
|
прогонов незачем (см. web/stats.run_detail для просмотра одного прогона).
|
||||||
|
"""
|
||||||
|
material = db.projectile_materials[genome.projectile.material_idx % len(db.projectile_materials)]
|
||||||
|
stages_summary = []
|
||||||
|
for gene in genome.stages:
|
||||||
|
wire = db.wires[gene.wire_idx % len(db.wires)]
|
||||||
|
capacitor = db.capacitors[gene.capacitor_idx % len(db.capacitors)]
|
||||||
|
switch = db.switches[gene.switch_idx % len(db.switches)]
|
||||||
|
sensor = db.sensors[gene.sensor_idx % len(db.sensors)]
|
||||||
|
stages_summary.append(
|
||||||
|
{
|
||||||
|
"wire": wire.part_id,
|
||||||
|
"capacitor": capacitor.part_number,
|
||||||
|
"switch": switch.part_number,
|
||||||
|
"sensor": sensor.part_number,
|
||||||
|
"turns_per_layer": gene.turns_per_layer,
|
||||||
|
"layers": gene.layers,
|
||||||
|
"sensor_to_coil_distance_m": gene.sensor_to_coil_distance_m,
|
||||||
|
"charge_voltage_fraction": gene.charge_voltage_fraction,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"tube_od_m": genome.tube_od_m,
|
||||||
|
"projectile_material": material.name,
|
||||||
|
"projectile_diameter_m": genome.projectile.diameter_m,
|
||||||
|
"projectile_length_m": genome.projectile.length_m,
|
||||||
|
"stages": stages_summary,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, initial_x_m: float, initial_v_mps: float, tube_inner_d_m: float | None = None, tube_wall_m: float | None = None) -> dict:
|
||||||
|
"""Максимально подробная запись: параметры + вычисленная физика + результат каждой ступени.
|
||||||
|
|
||||||
|
Именно это пишется в SQLite (decoded_summary_json), чтобы по базе можно
|
||||||
|
было восстановить ВСЁ: расстояния между катушками и абсолютные позиции
|
||||||
|
вдоль трубы, номиналы всех деталей, геометрию намотки, посчитанные
|
||||||
|
индуктивность/сопротивление/пиковый ток и что произошло на каждой ступени.
|
||||||
|
"""
|
||||||
|
outcomes_by_index = {o.stage_index: o for o in (coilgun_result.stage_outcomes if coilgun_result else [])}
|
||||||
|
projectile = config.projectile
|
||||||
|
|
||||||
|
# абсолютные позиции центров катушек вдоль трубы (сквозная координата)
|
||||||
|
coil_centers_m = []
|
||||||
|
cursor = 0.0
|
||||||
|
for i in range(len(config.stages)):
|
||||||
|
if i > 0:
|
||||||
|
cursor += config.inter_stage_gaps_m[i - 1]
|
||||||
|
coil_centers_m.append(cursor)
|
||||||
|
|
||||||
|
stages_detail = []
|
||||||
|
for i, stage in enumerate(config.stages):
|
||||||
|
wire_od_m = stage.wire.insulation_od_mm / 1000
|
||||||
|
geometry = winding_geometry(stage.tube_od_m, wire_od_m, stage.turns_per_layer, stage.layers)
|
||||||
|
r_wire = _wire_resistance_ohm(stage.wire.resistivity_ohm_m, stage.wire.gauge_mm, geometry.total_wire_length_m)
|
||||||
|
l_air_h = air_core_inductance_wheeler(
|
||||||
|
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
|
||||||
|
)
|
||||||
|
_char_omega = 1.0 / math.sqrt(l_air_h * stage.capacitor.capacitance_uf * 1e-6)
|
||||||
|
r_ac_factor = dowell_ac_factor(
|
||||||
|
stage.wire.gauge_mm / 1000, wire_od_m, stage.layers, stage.wire.resistivity_ohm_m, _char_omega
|
||||||
|
)
|
||||||
|
|
||||||
|
cap = stage.capacitor
|
||||||
|
# cap может быть батареей (CapacitorBank) или одиночным конденсатором (CapacitorSpec)
|
||||||
|
cap_base = getattr(cap, "base_part_number", getattr(cap, "part_number", "?"))
|
||||||
|
n_series = getattr(cap, "n_series", 1)
|
||||||
|
n_parallel = getattr(cap, "n_parallel", 1)
|
||||||
|
|
||||||
|
entry = {
|
||||||
|
"stage_index": i,
|
||||||
|
"coil_center_position_m": coil_centers_m[i],
|
||||||
|
"gap_before_stage_m": (config.inter_stage_gaps_m[i - 1] if i > 0 else None),
|
||||||
|
"sensor_to_coil_distance_m": stage.sensor_to_coil_distance_m,
|
||||||
|
# абсолютная позиция ЭТОГО датчика вдоль трубы (у каждой катушки свой)
|
||||||
|
"sensor_position_m": coil_centers_m[i] - stage.sensor_to_coil_distance_m,
|
||||||
|
"components": {
|
||||||
|
"wire": stage.wire.part_id,
|
||||||
|
"wire_material": stage.wire.material,
|
||||||
|
"wire_gauge_mm": stage.wire.gauge_mm,
|
||||||
|
"wire_insulation_od_mm": stage.wire.insulation_od_mm,
|
||||||
|
"wire_url": stage.wire.url,
|
||||||
|
"switch": stage.switch.part_number,
|
||||||
|
"switch_kind": stage.switch.kind,
|
||||||
|
"switch_max_current_a": stage.switch.max_current_a,
|
||||||
|
"switch_max_voltage_v": stage.switch.max_voltage_v,
|
||||||
|
"switch_url": stage.switch.url,
|
||||||
|
"sensor": stage.sensor.part_number,
|
||||||
|
"sensor_kind": stage.sensor.kind,
|
||||||
|
"sensor_url": stage.sensor.url,
|
||||||
|
},
|
||||||
|
"capacitor_bank": {
|
||||||
|
"base_part": cap_base,
|
||||||
|
"n_series": n_series,
|
||||||
|
"n_parallel": n_parallel,
|
||||||
|
"count": n_series * n_parallel,
|
||||||
|
"single_capacitance_uf": getattr(cap, "capacitance_uf", 0) * n_series / max(n_parallel, 1),
|
||||||
|
"single_voltage_rating_v": getattr(cap, "voltage_v", 0) / max(n_series, 1),
|
||||||
|
"total_capacitance_uf": cap.capacitance_uf,
|
||||||
|
"total_voltage_rating_v": cap.voltage_v,
|
||||||
|
"total_esr_ohm": cap.esr_ohm,
|
||||||
|
"max_current_a": cap.max_current_a,
|
||||||
|
"url": getattr(cap, "url", None),
|
||||||
|
},
|
||||||
|
"winding": {
|
||||||
|
"turns_per_layer": stage.turns_per_layer,
|
||||||
|
"layers": stage.layers,
|
||||||
|
"total_turns": geometry.total_turns,
|
||||||
|
"coil_length_m": geometry.coil_length_m,
|
||||||
|
"coil_inner_diameter_m": stage.tube_od_m, # = внешний диаметр трубы
|
||||||
|
"coil_outer_diameter_m": stage.tube_od_m + 2 * geometry.radial_depth_m,
|
||||||
|
"mean_radius_m": geometry.mean_radius_m,
|
||||||
|
"radial_depth_m": geometry.radial_depth_m,
|
||||||
|
"total_wire_length_m": geometry.total_wire_length_m,
|
||||||
|
},
|
||||||
|
"electrical": {
|
||||||
|
"charge_voltage_v": stage.charge_voltage_v,
|
||||||
|
"wire_resistance_dc_ohm": r_wire,
|
||||||
|
# скин + эффект близости на частоте импульса (Доуэлл)
|
||||||
|
"wire_ac_resistance_factor": r_ac_factor,
|
||||||
|
"wire_resistance_ac_ohm": r_wire * r_ac_factor,
|
||||||
|
"air_inductance_h": l_air_h,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
outcome = outcomes_by_index.get(i)
|
||||||
|
if outcome is not None:
|
||||||
|
r = outcome.result
|
||||||
|
peak_current_a = (
|
||||||
|
float(np.max(np.abs(r.discharge_i))) if r.discharge_i is not None and len(r.discharge_i) else None
|
||||||
|
)
|
||||||
|
# Диагностика по току. Жёсткая граница для ключа — ИМПУЛЬСНЫЙ рейтинг
|
||||||
|
# (ITSM/IDM/ICM из даташита): её превышение уже даёт feasible=False в
|
||||||
|
# run_stage. Пик выше ПРОДОЛЖИТЕЛЬНОГО рейтинга при одиночном коротком
|
||||||
|
# импульсе — норма (так работают все coilgun'ы), это ИНФОРМАЦИОННЫЙ
|
||||||
|
# флаг, а не противоречие с feasible. Для батареи конденсаторов — тоже
|
||||||
|
# предупреждение (в базе паспортный ripple, не импульсный максимум).
|
||||||
|
switch_pulse_a = switch_pulse_limit_a(stage.switch)
|
||||||
|
bank_over = peak_current_a is not None and peak_current_a > cap.max_current_a
|
||||||
|
switch_over_cont = peak_current_a is not None and peak_current_a > stage.switch.max_current_a
|
||||||
|
switch_over_pulse = peak_current_a is not None and peak_current_a > switch_pulse_a
|
||||||
|
entry["outcome"] = {
|
||||||
|
"reached": True,
|
||||||
|
"feasible": r.feasible,
|
||||||
|
"reason": r.reason,
|
||||||
|
"entry_velocity_mps": outcome.entry_v_mps,
|
||||||
|
"exit_velocity_mps": r.exit_v_mps,
|
||||||
|
"t_sensor_s": r.t_sensor_s,
|
||||||
|
"t_fire_s": r.t_fire_s,
|
||||||
|
"peak_current_a": peak_current_a,
|
||||||
|
"switch_pulse_limit_a": switch_pulse_a,
|
||||||
|
"bank_current_over_limit": bank_over,
|
||||||
|
# пик > продолжительного рейтинга: НОРМА для одиночного импульса
|
||||||
|
"switch_current_over_continuous_rating": switch_over_cont,
|
||||||
|
# пик > импульсного (даташит): такой конфиг отбраковывается
|
||||||
|
"switch_current_over_pulse_limit": switch_over_pulse,
|
||||||
|
"peak_field_estimate_tesla": r.peak_field_estimate_tesla,
|
||||||
|
"saturation_warning": r.saturation_warning,
|
||||||
|
"energy_in_j": r.energy_in_j,
|
||||||
|
"energy_dissipated_j": r.energy_dissipated_j,
|
||||||
|
"kinetic_energy_delta_j": r.kinetic_energy_delta_j,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
entry["outcome"] = {"reached": False}
|
||||||
|
stages_detail.append(entry)
|
||||||
|
|
||||||
|
tube_od_m = config.stages[0].tube_od_m if config.stages else None
|
||||||
|
return {
|
||||||
|
"tube": {
|
||||||
|
"inner_diameter_m": tube_inner_d_m, # бор — снаряд летит внутри
|
||||||
|
"wall_thickness_m": tube_wall_m,
|
||||||
|
"outer_diameter_m": tube_od_m, # = внутренний диаметр катушки
|
||||||
|
},
|
||||||
|
"tube_od_m": tube_od_m, # для обратной совместимости
|
||||||
|
"tube_length_m": (coil_centers_m[-1] + 0.05) if coil_centers_m else None,
|
||||||
|
"projectile": {
|
||||||
|
"material": projectile.material.name,
|
||||||
|
"diameter_m": projectile.diameter_m,
|
||||||
|
"length_m": projectile.length_m,
|
||||||
|
"mass_kg": projectile.mass_kg,
|
||||||
|
"mass_g": projectile.mass_kg * 1000,
|
||||||
|
"mu_r": projectile.material.mu_r,
|
||||||
|
"b_sat_tesla": projectile.material.b_sat_tesla,
|
||||||
|
"resistivity_ohm_m": projectile.material.resistivity_ohm_m,
|
||||||
|
"fits_in_bore": (tube_inner_d_m is None) or (projectile.diameter_m < tube_inner_d_m),
|
||||||
|
"url": projectile.material.url,
|
||||||
|
},
|
||||||
|
"launch": {"initial_x_m": initial_x_m, "initial_v_mps": initial_v_mps},
|
||||||
|
"n_stages": len(config.stages),
|
||||||
|
"inter_stage_gaps_m": list(config.inter_stage_gaps_m),
|
||||||
|
"coil_center_positions_m": coil_centers_m,
|
||||||
|
"stages": stages_detail,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> EvaluationResult:
|
||||||
|
"""detail НЕ строится здесь: build_detail — дорогая (полная физика по
|
||||||
|
каждой ступени) и не нужна ни фитнесу, ни хранению — genome_json уже
|
||||||
|
достаточен, чтобы воспроизвести всё через decode()+build_detail() на
|
||||||
|
лету (см. web/stats.run_detail). build_run_record() кладёт в БД дешёвую
|
||||||
|
decoded_summary() вместо этого.
|
||||||
|
"""
|
||||||
|
config, initial_x_m, initial_v_mps = decode(genome, db, bounds)
|
||||||
|
cost_rub = compute_cost_rub(config, db)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
|
||||||
|
if not result.feasible:
|
||||||
|
n_stages = len(config.stages)
|
||||||
|
progress_fraction = (result.failed_stage_index or 0) / n_stages if n_stages else 0.0
|
||||||
|
fitness = -1.0 + progress_fraction
|
||||||
|
return EvaluationResult(
|
||||||
|
feasible=False,
|
||||||
|
cost_rub=cost_rub,
|
||||||
|
fitness=fitness,
|
||||||
|
reason=result.reason,
|
||||||
|
failed_stage_index=result.failed_stage_index,
|
||||||
|
energy_breakdown={},
|
||||||
|
)
|
||||||
|
|
||||||
|
energy_breakdown = {
|
||||||
|
"total_energy_in_j": result.total_energy_in_j,
|
||||||
|
"total_energy_dissipated_j": result.total_energy_dissipated_j,
|
||||||
|
"total_kinetic_energy_delta_j": result.total_kinetic_energy_delta_j,
|
||||||
|
"exit_kinetic_energy_j": result.exit_kinetic_energy_j,
|
||||||
|
}
|
||||||
|
return EvaluationResult(
|
||||||
|
feasible=True,
|
||||||
|
cost_rub=cost_rub,
|
||||||
|
fitness=result.efficiency,
|
||||||
|
efficiency=result.efficiency,
|
||||||
|
exit_velocity_mps=result.exit_v_mps,
|
||||||
|
energy_breakdown=energy_breakdown,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord:
|
||||||
|
detail = decoded_summary(genome, db)
|
||||||
|
return RunRecord(
|
||||||
|
run_id=str(uuid.uuid4()),
|
||||||
|
timestamp=datetime.now(timezone.utc).isoformat(),
|
||||||
|
search_mode=search_mode,
|
||||||
|
genome_json=json.dumps(genome_to_dict(genome)),
|
||||||
|
decoded_summary_json=json.dumps(detail, ensure_ascii=False),
|
||||||
|
feasible=result.feasible,
|
||||||
|
model_version=MODEL_VERSION,
|
||||||
|
infeasible_reason=result.reason,
|
||||||
|
failed_stage_index=result.failed_stage_index,
|
||||||
|
efficiency=result.efficiency,
|
||||||
|
exit_velocity_mps=result.exit_velocity_mps,
|
||||||
|
cost_rub=result.cost_rub,
|
||||||
|
energy_breakdown_json=json.dumps(result.energy_breakdown) if result.energy_breakdown else None,
|
||||||
|
)
|
||||||
69
src/gausse/optim/progress_log.py
Normal file
69
src/gausse/optim/progress_log.py
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
"""Лог хода эксперимента: пишет прогресс sweep/evolve в файл рядом с базой.
|
||||||
|
|
||||||
|
Формат — по строке на контрольную точку, читаемый и человеком, и хвостом в
|
||||||
|
веб-морде: время, сколько прогнано из скольких, доля реализуемых, скорость,
|
||||||
|
лучший найденный КПД. Файл дописывается (append), переживает перезапуски.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def default_log_path(db_path: Path) -> Path:
|
||||||
|
db_path = Path(db_path)
|
||||||
|
return db_path.with_suffix(db_path.suffix + ".log")
|
||||||
|
|
||||||
|
|
||||||
|
class ProgressLogger:
|
||||||
|
def __init__(self, log_path: Path, mode: str, total: int | None, log_every: int = 200):
|
||||||
|
self.log_path = Path(log_path)
|
||||||
|
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
self.mode = mode
|
||||||
|
self.total = total
|
||||||
|
self.log_every = max(log_every, 1)
|
||||||
|
self.start_time = time.monotonic()
|
||||||
|
self.completed = 0
|
||||||
|
self.n_feasible = 0
|
||||||
|
self.best_efficiency: float | None = None
|
||||||
|
self._write(f"=== старт {mode}, всего запланировано: {total} ===")
|
||||||
|
|
||||||
|
def _write(self, line: str) -> None:
|
||||||
|
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||||
|
with self.log_path.open("a", encoding="utf-8") as f:
|
||||||
|
f.write(f"{stamp} {line}\n")
|
||||||
|
|
||||||
|
def update(self, feasible: bool, efficiency: float | None) -> None:
|
||||||
|
self.completed += 1
|
||||||
|
if feasible:
|
||||||
|
self.n_feasible += 1
|
||||||
|
if efficiency is not None and (self.best_efficiency is None or efficiency > self.best_efficiency):
|
||||||
|
self.best_efficiency = efficiency
|
||||||
|
self._write(
|
||||||
|
f"новый лучший КПД: {efficiency * 100:.2f}% "
|
||||||
|
f"(на прогоне {self.completed})"
|
||||||
|
)
|
||||||
|
if self.completed % self.log_every == 0:
|
||||||
|
self._checkpoint()
|
||||||
|
|
||||||
|
def _checkpoint(self) -> None:
|
||||||
|
elapsed = time.monotonic() - self.start_time
|
||||||
|
rate = self.completed / elapsed if elapsed > 0 else 0.0
|
||||||
|
feas_pct = 100 * self.n_feasible / self.completed if self.completed else 0.0
|
||||||
|
best = f"{self.best_efficiency * 100:.2f}%" if self.best_efficiency is not None else "—"
|
||||||
|
total_str = str(self.total) if self.total else "?"
|
||||||
|
pct = f"{100 * self.completed / self.total:.1f}%" if self.total else "?"
|
||||||
|
eta = ""
|
||||||
|
if self.total and rate > 0:
|
||||||
|
remaining = (self.total - self.completed) / rate
|
||||||
|
eta = f" ETA={remaining:.0f}с"
|
||||||
|
self._write(
|
||||||
|
f"прогресс: {self.completed}/{total_str} ({pct}) "
|
||||||
|
f"реализуемо={self.n_feasible} ({feas_pct:.1f}%) "
|
||||||
|
f"скорость={rate:.1f}/с лучший_КПД={best}{eta}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def finish(self) -> None:
|
||||||
|
self._checkpoint()
|
||||||
|
elapsed = time.monotonic() - self.start_time
|
||||||
|
self._write(f"=== готово {self.mode}: {self.completed} прогонов за {elapsed:.0f}с ===")
|
||||||
374
src/gausse/optim/search_space.py
Normal file
374
src/gausse/optim/search_space.py
Normal file
@@ -0,0 +1,374 @@
|
|||||||
|
"""Пространство поиска: геном переменной длины (число ступеней эволюционирует).
|
||||||
|
|
||||||
|
Геном кодирует только ИНДЕКСЫ в базу реальных компонентов (не сами
|
||||||
|
параметры) + непрерывные величины (расстояния, напряжение, геометрия
|
||||||
|
снаряда). Это гарантирует, что что бы ни нашёл поиск, оно собрано из
|
||||||
|
реально продающихся деталей, а не из выдуманных чисел.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.components.schema import CapacitorBank
|
||||||
|
from gausse.sim.coilgun import CoilgunConfig
|
||||||
|
from gausse.sim.stage import ProjectileConfig, StageConfig
|
||||||
|
|
||||||
|
# зазор между снарядом и внутренней стенкой трубы (бором), чтобы снаряд летел свободно
|
||||||
|
PROJECTILE_BORE_CLEARANCE_M = 0.0005
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SearchBounds:
|
||||||
|
min_stages: int = 1
|
||||||
|
max_stages: int = 10
|
||||||
|
turns_per_layer_min: int = 5
|
||||||
|
turns_per_layer_max: int = 100
|
||||||
|
layers_min: int = 1
|
||||||
|
layers_max: int = 8
|
||||||
|
sensor_to_coil_distance_m_min: float = 0.005
|
||||||
|
sensor_to_coil_distance_m_max: float = 0.05
|
||||||
|
inter_stage_gap_m_min: float = 0.01
|
||||||
|
inter_stage_gap_m_max: float = 0.10
|
||||||
|
# ТРУБА: внутренний диаметр (бор — снаряд летит внутри) и толщина стенки.
|
||||||
|
# Внешний диаметр = внутренний + 2×стенка = внутренний диаметр КАТУШКИ.
|
||||||
|
tube_inner_d_m_min: float = 0.005
|
||||||
|
tube_inner_d_m_max: float = 0.030
|
||||||
|
tube_wall_m_min: float = 0.001
|
||||||
|
tube_wall_m_max: float = 0.003
|
||||||
|
# СНАРЯД: диаметр обязан быть < внутр. диаметра трубы; масса до 200 г.
|
||||||
|
projectile_diameter_m_min: float = 0.004
|
||||||
|
projectile_diameter_m_max: float = 0.028
|
||||||
|
projectile_length_m_min: float = 0.01
|
||||||
|
projectile_length_m_max: float = 0.15
|
||||||
|
projectile_mass_max_kg: float = 0.2
|
||||||
|
charge_voltage_fraction_min: float = 0.5
|
||||||
|
charge_voltage_fraction_max: float = 1.0
|
||||||
|
initial_launch_velocity_mps: float = 3.0
|
||||||
|
initial_approach_margin_m: float = 0.02
|
||||||
|
# батарея конденсаторов на ступень: последовательно (напряжение) × параллельно (ток/ёмкость)
|
||||||
|
cap_series_min: int = 1
|
||||||
|
cap_series_max: int = 6
|
||||||
|
cap_parallel_min: int = 1
|
||||||
|
cap_parallel_max: int = 8
|
||||||
|
|
||||||
|
|
||||||
|
def _max_length_for_mass(diameter_m: float, density_kg_m3: float, max_mass_kg: float) -> float:
|
||||||
|
"""Максимальная длина снаряда, при которой масса не превышает max_mass_kg."""
|
||||||
|
area = math.pi * (diameter_m / 2) ** 2
|
||||||
|
return max_mass_kg / (density_kg_m3 * area)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class StageGene:
|
||||||
|
wire_idx: int
|
||||||
|
capacitor_idx: int
|
||||||
|
switch_idx: int
|
||||||
|
sensor_idx: int
|
||||||
|
turns_per_layer: int
|
||||||
|
layers: int
|
||||||
|
sensor_to_coil_distance_m: float
|
||||||
|
charge_voltage_fraction: float
|
||||||
|
cap_series: int = 1
|
||||||
|
cap_parallel: int = 1
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectileGene:
|
||||||
|
material_idx: int
|
||||||
|
diameter_m: float
|
||||||
|
length_m: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Genome:
|
||||||
|
tube_inner_d_m: float # бор трубы (снаряд летит внутри)
|
||||||
|
tube_wall_m: float # толщина стенки трубы
|
||||||
|
stages: list[StageGene]
|
||||||
|
inter_stage_gaps_m: list[float]
|
||||||
|
projectile: ProjectileGene
|
||||||
|
|
||||||
|
@property
|
||||||
|
def tube_od_m(self) -> float:
|
||||||
|
"""Внешний диаметр трубы = внутренний диаметр катушки."""
|
||||||
|
return self.tube_inner_d_m + 2 * self.tube_wall_m
|
||||||
|
|
||||||
|
|
||||||
|
def _clip(value: float, lo: float, hi: float) -> float:
|
||||||
|
return max(lo, min(hi, value))
|
||||||
|
|
||||||
|
|
||||||
|
def sample_stage_gene(db: ComponentDatabase, bounds: SearchBounds, rng: random.Random) -> StageGene:
|
||||||
|
return StageGene(
|
||||||
|
wire_idx=rng.randrange(len(db.wires)),
|
||||||
|
capacitor_idx=rng.randrange(len(db.capacitors)),
|
||||||
|
switch_idx=rng.randrange(len(db.switches)),
|
||||||
|
sensor_idx=rng.randrange(len(db.sensors)),
|
||||||
|
turns_per_layer=rng.randint(bounds.turns_per_layer_min, bounds.turns_per_layer_max),
|
||||||
|
layers=rng.randint(bounds.layers_min, bounds.layers_max),
|
||||||
|
sensor_to_coil_distance_m=rng.uniform(
|
||||||
|
bounds.sensor_to_coil_distance_m_min, bounds.sensor_to_coil_distance_m_max
|
||||||
|
),
|
||||||
|
charge_voltage_fraction=rng.uniform(
|
||||||
|
bounds.charge_voltage_fraction_min, bounds.charge_voltage_fraction_max
|
||||||
|
),
|
||||||
|
cap_series=rng.randint(bounds.cap_series_min, bounds.cap_series_max),
|
||||||
|
cap_parallel=rng.randint(bounds.cap_parallel_min, bounds.cap_parallel_max),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_genome(db: ComponentDatabase, bounds: SearchBounds, rng: random.Random) -> Genome:
|
||||||
|
tube_inner_d_m = rng.uniform(bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
|
||||||
|
tube_wall_m = rng.uniform(bounds.tube_wall_m_min, bounds.tube_wall_m_max)
|
||||||
|
|
||||||
|
# диаметр снаряда < внутр. диаметра трубы минус зазор
|
||||||
|
max_diameter = min(bounds.projectile_diameter_m_max, tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M)
|
||||||
|
diameter_m = rng.uniform(bounds.projectile_diameter_m_min, max(max_diameter, bounds.projectile_diameter_m_min))
|
||||||
|
|
||||||
|
material_idx = rng.randrange(len(db.projectile_materials))
|
||||||
|
density = db.projectile_materials[material_idx].density_kg_m3
|
||||||
|
# длина ограничена и границами, и максимальной массой 200 г
|
||||||
|
len_cap = min(bounds.projectile_length_m_max, _max_length_for_mass(diameter_m, density, bounds.projectile_mass_max_kg))
|
||||||
|
length_m = rng.uniform(bounds.projectile_length_m_min, max(len_cap, bounds.projectile_length_m_min))
|
||||||
|
|
||||||
|
n_stages = rng.randint(bounds.min_stages, bounds.max_stages)
|
||||||
|
stages = [sample_stage_gene(db, bounds, rng) for _ in range(n_stages)]
|
||||||
|
gaps = [
|
||||||
|
rng.uniform(bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
|
||||||
|
for _ in range(n_stages - 1)
|
||||||
|
]
|
||||||
|
projectile = ProjectileGene(material_idx=material_idx, diameter_m=diameter_m, length_m=length_m)
|
||||||
|
genome = Genome(
|
||||||
|
tube_inner_d_m=tube_inner_d_m, tube_wall_m=tube_wall_m,
|
||||||
|
stages=stages, inter_stage_gaps_m=gaps, projectile=projectile,
|
||||||
|
)
|
||||||
|
# прогоняем через repair, чтобы применились все физические ограничения
|
||||||
|
# (в т.ч. датчик каждой ступени должен стоять в зазоре перед своей катушкой)
|
||||||
|
return repair(genome, db, bounds)
|
||||||
|
|
||||||
|
|
||||||
|
def repair(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Genome:
|
||||||
|
"""Приводит геном в границы после мутации/скрещивания (клэмп, не отбраковка)."""
|
||||||
|
genome.tube_inner_d_m = _clip(genome.tube_inner_d_m, bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
|
||||||
|
genome.tube_wall_m = _clip(genome.tube_wall_m, bounds.tube_wall_m_min, bounds.tube_wall_m_max)
|
||||||
|
genome.projectile.material_idx = genome.projectile.material_idx % len(db.projectile_materials)
|
||||||
|
|
||||||
|
# снаряд обязан влезать в бор трубы
|
||||||
|
max_diameter = max(genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M, bounds.projectile_diameter_m_min)
|
||||||
|
max_diameter = min(max_diameter, bounds.projectile_diameter_m_max)
|
||||||
|
genome.projectile.diameter_m = _clip(genome.projectile.diameter_m, bounds.projectile_diameter_m_min, max_diameter)
|
||||||
|
# длина в границах И под массой ≤ 200 г
|
||||||
|
density = db.projectile_materials[genome.projectile.material_idx].density_kg_m3
|
||||||
|
len_cap = min(bounds.projectile_length_m_max, _max_length_for_mass(genome.projectile.diameter_m, density, bounds.projectile_mass_max_kg))
|
||||||
|
genome.projectile.length_m = _clip(genome.projectile.length_m, bounds.projectile_length_m_min, max(len_cap, bounds.projectile_length_m_min))
|
||||||
|
|
||||||
|
for stage in genome.stages:
|
||||||
|
stage.wire_idx %= len(db.wires)
|
||||||
|
stage.capacitor_idx %= len(db.capacitors)
|
||||||
|
stage.switch_idx %= len(db.switches)
|
||||||
|
stage.sensor_idx %= len(db.sensors)
|
||||||
|
stage.turns_per_layer = int(_clip(stage.turns_per_layer, bounds.turns_per_layer_min, bounds.turns_per_layer_max))
|
||||||
|
stage.layers = int(_clip(stage.layers, bounds.layers_min, bounds.layers_max))
|
||||||
|
stage.sensor_to_coil_distance_m = _clip(
|
||||||
|
stage.sensor_to_coil_distance_m,
|
||||||
|
bounds.sensor_to_coil_distance_m_min,
|
||||||
|
bounds.sensor_to_coil_distance_m_max,
|
||||||
|
)
|
||||||
|
stage.charge_voltage_fraction = _clip(
|
||||||
|
stage.charge_voltage_fraction,
|
||||||
|
bounds.charge_voltage_fraction_min,
|
||||||
|
bounds.charge_voltage_fraction_max,
|
||||||
|
)
|
||||||
|
stage.cap_series = int(_clip(stage.cap_series, bounds.cap_series_min, bounds.cap_series_max))
|
||||||
|
stage.cap_parallel = int(_clip(stage.cap_parallel, bounds.cap_parallel_min, bounds.cap_parallel_max))
|
||||||
|
genome.inter_stage_gaps_m = [
|
||||||
|
_clip(g, bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
|
||||||
|
for g in genome.inter_stage_gaps_m[: len(genome.stages) - 1]
|
||||||
|
]
|
||||||
|
while len(genome.inter_stage_gaps_m) < len(genome.stages) - 1:
|
||||||
|
genome.inter_stage_gaps_m.append(
|
||||||
|
(bounds.inter_stage_gap_m_min + bounds.inter_stage_gap_m_max) / 2
|
||||||
|
)
|
||||||
|
# Датчик ступени i>0 должен стоять В ЗАЗОРЕ перед своей катушкой, а не
|
||||||
|
# залезать на предыдущую: расстояние датчик→катушка < зазор до пред.катушки.
|
||||||
|
# (у первой катушки датчик перед ней в свободном пространстве — без ограничения.)
|
||||||
|
for i in range(1, len(genome.stages)):
|
||||||
|
gap = genome.inter_stage_gaps_m[i - 1]
|
||||||
|
max_s2c = max(gap * 0.9, bounds.sensor_to_coil_distance_m_min)
|
||||||
|
genome.stages[i].sensor_to_coil_distance_m = min(
|
||||||
|
genome.stages[i].sensor_to_coil_distance_m, max_s2c
|
||||||
|
)
|
||||||
|
return genome
|
||||||
|
|
||||||
|
|
||||||
|
def decode(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> tuple[CoilgunConfig, float, float]:
|
||||||
|
"""Возвращает (CoilgunConfig, initial_x_m, initial_v_mps)."""
|
||||||
|
material = db.projectile_materials[genome.projectile.material_idx % len(db.projectile_materials)]
|
||||||
|
projectile = ProjectileConfig(
|
||||||
|
material=material,
|
||||||
|
diameter_m=genome.projectile.diameter_m,
|
||||||
|
length_m=genome.projectile.length_m,
|
||||||
|
)
|
||||||
|
|
||||||
|
stage_configs = []
|
||||||
|
for gene in genome.stages:
|
||||||
|
wire = db.wires[gene.wire_idx % len(db.wires)]
|
||||||
|
base_capacitor = db.capacitors[gene.capacitor_idx % len(db.capacitors)]
|
||||||
|
capacitor = CapacitorBank.from_spec(base_capacitor, gene.cap_series, gene.cap_parallel)
|
||||||
|
switch = db.switches[gene.switch_idx % len(db.switches)]
|
||||||
|
sensor = db.sensors[gene.sensor_idx % len(db.sensors)]
|
||||||
|
# напряжение батареи (base.V × n_series) может быть выше одиночного,
|
||||||
|
# но ключ обязан его выдержать — реальный ограничитель switch.max_voltage_v
|
||||||
|
max_voltage = min(capacitor.voltage_v, switch.max_voltage_v)
|
||||||
|
charge_voltage_v = gene.charge_voltage_fraction * max_voltage
|
||||||
|
stage_configs.append(
|
||||||
|
StageConfig(
|
||||||
|
wire=wire,
|
||||||
|
capacitor=capacitor,
|
||||||
|
switch=switch,
|
||||||
|
sensor=sensor,
|
||||||
|
tube_od_m=genome.tube_od_m, # внешний диаметр трубы = внутр. диаметр катушки
|
||||||
|
turns_per_layer=gene.turns_per_layer,
|
||||||
|
layers=gene.layers,
|
||||||
|
sensor_to_coil_distance_m=gene.sensor_to_coil_distance_m,
|
||||||
|
charge_voltage_v=charge_voltage_v,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
config = CoilgunConfig(
|
||||||
|
stages=stage_configs,
|
||||||
|
inter_stage_gaps_m=list(genome.inter_stage_gaps_m),
|
||||||
|
projectile=projectile,
|
||||||
|
initial_x_m=-(stage_configs[0].sensor_to_coil_distance_m + bounds.initial_approach_margin_m),
|
||||||
|
initial_v_mps=bounds.initial_launch_velocity_mps,
|
||||||
|
)
|
||||||
|
return config, config.initial_x_m, config.initial_v_mps
|
||||||
|
|
||||||
|
|
||||||
|
def mutate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds, rng: random.Random, rate: float = 0.2) -> Genome:
|
||||||
|
child = copy.deepcopy(genome)
|
||||||
|
|
||||||
|
if rng.random() < rate:
|
||||||
|
child.tube_inner_d_m = rng.uniform(bounds.tube_inner_d_m_min, bounds.tube_inner_d_m_max)
|
||||||
|
if rng.random() < rate:
|
||||||
|
child.tube_wall_m = rng.uniform(bounds.tube_wall_m_min, bounds.tube_wall_m_max)
|
||||||
|
|
||||||
|
for stage in child.stages:
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.wire_idx = rng.randrange(len(db.wires))
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.capacitor_idx = rng.randrange(len(db.capacitors))
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.switch_idx = rng.randrange(len(db.switches))
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.sensor_idx = rng.randrange(len(db.sensors))
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.turns_per_layer += rng.randint(-10, 10)
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.layers += rng.randint(-1, 1)
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.sensor_to_coil_distance_m *= rng.uniform(0.7, 1.3)
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.charge_voltage_fraction *= rng.uniform(0.9, 1.1)
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.cap_series += rng.randint(-1, 1)
|
||||||
|
if rng.random() < rate:
|
||||||
|
stage.cap_parallel += rng.randint(-1, 1)
|
||||||
|
|
||||||
|
for i in range(len(child.inter_stage_gaps_m)):
|
||||||
|
if rng.random() < rate:
|
||||||
|
child.inter_stage_gaps_m[i] *= rng.uniform(0.7, 1.3)
|
||||||
|
|
||||||
|
if rng.random() < rate:
|
||||||
|
child.projectile.material_idx = rng.randrange(len(db.projectile_materials))
|
||||||
|
if rng.random() < rate:
|
||||||
|
child.projectile.diameter_m *= rng.uniform(0.8, 1.2)
|
||||||
|
if rng.random() < rate:
|
||||||
|
child.projectile.length_m *= rng.uniform(0.8, 1.2)
|
||||||
|
|
||||||
|
# структурные операторы: число ступеней тоже эволюционирует
|
||||||
|
structural_roll = rng.random()
|
||||||
|
if structural_roll < rate / 3 and len(child.stages) < bounds.max_stages:
|
||||||
|
new_stage = sample_stage_gene(db, bounds, rng)
|
||||||
|
child.stages.append(new_stage)
|
||||||
|
child.inter_stage_gaps_m.append(
|
||||||
|
rng.uniform(bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
|
||||||
|
)
|
||||||
|
elif structural_roll < 2 * rate / 3 and len(child.stages) > bounds.min_stages:
|
||||||
|
idx = rng.randrange(len(child.stages))
|
||||||
|
child.stages.pop(idx)
|
||||||
|
if child.inter_stage_gaps_m:
|
||||||
|
child.inter_stage_gaps_m.pop(min(idx, len(child.inter_stage_gaps_m) - 1))
|
||||||
|
elif structural_roll < rate and len(child.stages) < bounds.max_stages:
|
||||||
|
idx = rng.randrange(len(child.stages))
|
||||||
|
child.stages.insert(idx, copy.deepcopy(child.stages[idx]))
|
||||||
|
child.inter_stage_gaps_m.append(
|
||||||
|
rng.uniform(bounds.inter_stage_gap_m_min, bounds.inter_stage_gap_m_max)
|
||||||
|
)
|
||||||
|
|
||||||
|
return repair(child, db, bounds)
|
||||||
|
|
||||||
|
|
||||||
|
def crossover(parent_a: Genome, parent_b: Genome, rng: random.Random) -> Genome:
|
||||||
|
"""Обмен целыми ступенями между родителями — ступень физически цельная единица."""
|
||||||
|
n = rng.choice([len(parent_a.stages), len(parent_b.stages)])
|
||||||
|
stages = []
|
||||||
|
for i in range(n):
|
||||||
|
source = parent_a if rng.random() < 0.5 else parent_b
|
||||||
|
if i < len(source.stages):
|
||||||
|
stages.append(copy.deepcopy(source.stages[i]))
|
||||||
|
else:
|
||||||
|
fallback = parent_a if source is parent_b else parent_b
|
||||||
|
stages.append(copy.deepcopy(fallback.stages[i % len(fallback.stages)]))
|
||||||
|
|
||||||
|
gaps = []
|
||||||
|
for i in range(n - 1):
|
||||||
|
candidates = [
|
||||||
|
g.inter_stage_gaps_m[i]
|
||||||
|
for g in (parent_a, parent_b)
|
||||||
|
if i < len(g.inter_stage_gaps_m)
|
||||||
|
]
|
||||||
|
# Оба родителя короче, чем ребёнок (например, у обоих 1 ступень, а
|
||||||
|
# n=2 из-за структурной мутации выше по цепочке) -- нет зазора,
|
||||||
|
# который можно унаследовать; repair() всё равно подожмёт в границы.
|
||||||
|
gaps.append(rng.choice(candidates) if candidates else 0.03)
|
||||||
|
|
||||||
|
projectile_source = parent_a if rng.random() < 0.5 else parent_b
|
||||||
|
tube_source = parent_a if rng.random() < 0.5 else parent_b
|
||||||
|
|
||||||
|
return Genome(
|
||||||
|
tube_inner_d_m=tube_source.tube_inner_d_m,
|
||||||
|
tube_wall_m=tube_source.tube_wall_m,
|
||||||
|
stages=stages,
|
||||||
|
inter_stage_gaps_m=gaps,
|
||||||
|
projectile=copy.deepcopy(projectile_source.projectile),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def genome_to_dict(genome: Genome) -> dict:
|
||||||
|
return {
|
||||||
|
"tube_inner_d_m": genome.tube_inner_d_m,
|
||||||
|
"tube_wall_m": genome.tube_wall_m,
|
||||||
|
"stages": [vars(s) for s in genome.stages],
|
||||||
|
"inter_stage_gaps_m": genome.inter_stage_gaps_m,
|
||||||
|
"projectile": vars(genome.projectile),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def genome_from_dict(data: dict) -> Genome:
|
||||||
|
# обратная совместимость: старые геномы в БД несли tube_od_m без стенки
|
||||||
|
if "tube_inner_d_m" in data:
|
||||||
|
tube_inner_d_m = data["tube_inner_d_m"]
|
||||||
|
tube_wall_m = data["tube_wall_m"]
|
||||||
|
else:
|
||||||
|
tube_wall_m = 0.0015
|
||||||
|
tube_inner_d_m = max(data.get("tube_od_m", 0.012) - 2 * tube_wall_m, 0.005)
|
||||||
|
return Genome(
|
||||||
|
tube_inner_d_m=tube_inner_d_m,
|
||||||
|
tube_wall_m=tube_wall_m,
|
||||||
|
stages=[StageGene(**s) for s in data["stages"]],
|
||||||
|
inter_stage_gaps_m=list(data["inter_stage_gaps_m"]),
|
||||||
|
projectile=ProjectileGene(**data["projectile"]),
|
||||||
|
)
|
||||||
81
src/gausse/optim/sweep.py
Normal file
81
src/gausse/optim/sweep.py
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
"""Массовый Monte Carlo sweep: честный широкий перебор, все прогоны в SQLite.
|
||||||
|
|
||||||
|
Воркеры (пул процессов) только сэмплируют и оценивают геномы; запись в
|
||||||
|
SQLite идёт через единственный writer-процесс поверх очереди
|
||||||
|
(`storage.database.run_writer_process`), чтобы не было конкурентной записи
|
||||||
|
в один файл базы.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import multiprocessing
|
||||||
|
import random
|
||||||
|
from concurrent.futures import ProcessPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Callable
|
||||||
|
|
||||||
|
from gausse.optim import worker_context
|
||||||
|
from gausse.optim.objective import MODEL_VERSION, build_run_record, evaluate
|
||||||
|
from gausse.optim.progress_log import ProgressLogger, default_log_path
|
||||||
|
from gausse.optim.search_space import SearchBounds, sample_genome
|
||||||
|
from gausse.storage.database import run_writer_process
|
||||||
|
from gausse.storage.schema import RunRecord
|
||||||
|
|
||||||
|
__all__ = ["MODEL_VERSION", "run_sweep"]
|
||||||
|
|
||||||
|
|
||||||
|
def _evaluate_one(seed: int) -> RunRecord:
|
||||||
|
rng = random.Random(seed)
|
||||||
|
genome = sample_genome(worker_context.db, worker_context.bounds, rng)
|
||||||
|
result = evaluate(genome, worker_context.db, worker_context.bounds)
|
||||||
|
return build_run_record(genome, result, worker_context.db, search_mode="sweep")
|
||||||
|
|
||||||
|
|
||||||
|
def run_sweep(
|
||||||
|
db_path: Path,
|
||||||
|
n_runs: int,
|
||||||
|
bounds: SearchBounds = SearchBounds(),
|
||||||
|
data_dir: Path | None = None,
|
||||||
|
n_workers: int | None = None,
|
||||||
|
seed: int | None = None,
|
||||||
|
progress_callback: Callable[[int, int], None] | None = None,
|
||||||
|
log_path: Path | None = None,
|
||||||
|
) -> dict:
|
||||||
|
"""Прогоняет n_runs случайных конфигураций параллельно, пишет каждую в SQLite.
|
||||||
|
|
||||||
|
Возвращает сводку {n_runs, n_feasible} — не для принятия решений (для
|
||||||
|
этого нужно читать саму базу), а как быстрая сверка "ничего не потерялось".
|
||||||
|
Ход эксперимента пишется в `log_path` (по умолчанию — <db>.log).
|
||||||
|
"""
|
||||||
|
n_workers = n_workers or multiprocessing.cpu_count()
|
||||||
|
base_seed = seed if seed is not None else random.randrange(2**31)
|
||||||
|
seeds = [base_seed + i for i in range(n_runs)]
|
||||||
|
|
||||||
|
logger = ProgressLogger(log_path or default_log_path(db_path), mode="sweep", total=n_runs)
|
||||||
|
|
||||||
|
ctx = multiprocessing.get_context("spawn")
|
||||||
|
queue = ctx.Queue()
|
||||||
|
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
|
||||||
|
writer.start()
|
||||||
|
|
||||||
|
completed = 0
|
||||||
|
n_feasible = 0
|
||||||
|
try:
|
||||||
|
with ProcessPoolExecutor(
|
||||||
|
max_workers=n_workers,
|
||||||
|
mp_context=ctx,
|
||||||
|
initializer=worker_context.init_worker,
|
||||||
|
initargs=(data_dir, bounds),
|
||||||
|
) as executor:
|
||||||
|
for record in executor.map(_evaluate_one, seeds):
|
||||||
|
queue.put(record)
|
||||||
|
completed += 1
|
||||||
|
if record.feasible:
|
||||||
|
n_feasible += 1
|
||||||
|
logger.update(record.feasible, record.efficiency)
|
||||||
|
if progress_callback:
|
||||||
|
progress_callback(completed, n_runs)
|
||||||
|
finally:
|
||||||
|
logger.finish()
|
||||||
|
queue.put(None)
|
||||||
|
writer.join()
|
||||||
|
|
||||||
|
return {"n_runs": completed, "n_feasible": n_feasible}
|
||||||
19
src/gausse/optim/worker_context.py
Normal file
19
src/gausse/optim/worker_context.py
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
"""Состояние процесса-воркера пула: база компонентов грузится один раз на процесс.
|
||||||
|
|
||||||
|
Используется и sweep.py (случайный перебор), и evolutionary.py (ГА) — общий
|
||||||
|
паттерн `ProcessPoolExecutor(initializer=init_worker, ...)`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim.search_space import SearchBounds
|
||||||
|
|
||||||
|
db: ComponentDatabase | None = None
|
||||||
|
bounds: SearchBounds | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def init_worker(data_dir: Path | None, worker_bounds: SearchBounds) -> None:
|
||||||
|
global db, bounds
|
||||||
|
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
|
||||||
|
bounds = worker_bounds
|
||||||
87
src/gausse/physics/ac_resistance.py
Normal file
87
src/gausse/physics/ac_resistance.py
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
"""AC-сопротивление обмотки: скин-эффект + эффект близости (метод Доуэлла).
|
||||||
|
|
||||||
|
Разряд конденсатора через катушку — импульс с характерной частотой
|
||||||
|
ω ≈ 1/√(L·C) (сотни Гц — единицы кГц). На этой частоте ток вытесняется
|
||||||
|
к поверхности провода (скин-эффект), а в многослойной намотке соседние
|
||||||
|
слои дополнительно вытесняют ток друг у друга (эффект близости). Для
|
||||||
|
толстого провода и многих слоёв эффективное сопротивление в 1.5–3 раза
|
||||||
|
выше DC — этот канал потерь раньше не учитывался и завышал КПД.
|
||||||
|
|
||||||
|
Модель — классический Доуэлл (Dowell, 1966) для m-слойной обмотки,
|
||||||
|
круглый провод сведён к эквивалентной фольге:
|
||||||
|
|
||||||
|
Δ = (π/4)^{3/4} · (d/δ) · √η, δ = √(2ρ/(ω·μ₀)) — глубина скина,
|
||||||
|
η = d_голый/d_изолир — коэффициент заполнения слоя,
|
||||||
|
F_R = Δ·[φ₁(Δ) + (2(m²−1)/3)·φ₂(Δ)],
|
||||||
|
φ₁ = (sh2Δ+sin2Δ)/(ch2Δ−cos2Δ), φ₂ = (shΔ−sinΔ)/(chΔ+cosΔ).
|
||||||
|
|
||||||
|
ЧЕСТНО об ограничениях: ток разряда — затухающий импульс, а не синусоида;
|
||||||
|
берём одну характерную частоту ω=1/√(LC) (первая гармоника импульса), без
|
||||||
|
разложения в спектр. Доуэлл предполагает плотную рядовую намотку и
|
||||||
|
пренебрегает кривизной. Точность — десятки процентов, но это несравнимо
|
||||||
|
честнее, чем DC-сопротивление, занижавшее потери в разы для толстого
|
||||||
|
провода во многих слоях.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from gausse.physics.constants import MU_0
|
||||||
|
|
||||||
|
|
||||||
|
def skin_depth_m(resistivity_ohm_m: float, omega_rad_s: float) -> float:
|
||||||
|
"""Глубина скин-слоя δ = √(2ρ/(ω·μ₀)) для немагнитного проводника (Cu/Al)."""
|
||||||
|
return math.sqrt(2.0 * resistivity_ohm_m / (omega_rad_s * MU_0))
|
||||||
|
|
||||||
|
|
||||||
|
def iron_penetration_fraction(
|
||||||
|
slug_radius_m: float,
|
||||||
|
slug_resistivity_ohm_m: float,
|
||||||
|
mu_r: float,
|
||||||
|
omega_rad_s: float,
|
||||||
|
) -> float:
|
||||||
|
"""Доля сечения снаряда, реально намагниченная за импульс (диффузия поля).
|
||||||
|
|
||||||
|
Переменное поле проникает в проводящее железо на глубину скин-слоя
|
||||||
|
δ = √(2ρ/(ω·μ₀·μᵣ)) — за миллисекундный импульс у стали это доли
|
||||||
|
миллиметра. Внутренность снаряда не успевает намагнититься и силы не
|
||||||
|
даёт. Работает кольцо глубиной δ: A_eff/A = 1−(1−δ/a)².
|
||||||
|
|
||||||
|
ЧЕСТНО: это одночастотная оценка (ω=1/√(LC)) решения уравнения диффузии,
|
||||||
|
а не само решение; ошибка — десятки процентов. Но без неё модель считала,
|
||||||
|
что намагничивается ВСЁ сечение, и оптимизатор строил «медленные»
|
||||||
|
катушки с нефизично высоким КПД (30%+).
|
||||||
|
"""
|
||||||
|
if omega_rad_s <= 0 or slug_resistivity_ohm_m <= 0 or mu_r < 1 or slug_radius_m <= 0:
|
||||||
|
return 1.0
|
||||||
|
delta = math.sqrt(2.0 * slug_resistivity_ohm_m / (omega_rad_s * MU_0 * mu_r))
|
||||||
|
if delta >= slug_radius_m:
|
||||||
|
return 1.0
|
||||||
|
return 1.0 - (1.0 - delta / slug_radius_m) ** 2
|
||||||
|
|
||||||
|
|
||||||
|
def dowell_ac_factor(
|
||||||
|
wire_bare_d_m: float,
|
||||||
|
wire_insulated_d_m: float,
|
||||||
|
n_layers: int,
|
||||||
|
resistivity_ohm_m: float,
|
||||||
|
omega_rad_s: float,
|
||||||
|
) -> float:
|
||||||
|
"""F_R = R_AC/R_DC ≥ 1 для m-слойной обмотки на частоте ω (Доуэлл)."""
|
||||||
|
if omega_rad_s <= 0.0 or n_layers < 1 or resistivity_ohm_m <= 0.0:
|
||||||
|
return 1.0 # ρ=0 (идеальный провод): R_DC=0, фактор не имеет смысла
|
||||||
|
delta = skin_depth_m(resistivity_ohm_m, omega_rad_s)
|
||||||
|
porosity = min(wire_bare_d_m / wire_insulated_d_m, 1.0)
|
||||||
|
d_eff = (math.pi / 4.0) ** 0.75 * wire_bare_d_m * math.sqrt(porosity)
|
||||||
|
big_delta = d_eff / delta
|
||||||
|
m = float(n_layers)
|
||||||
|
|
||||||
|
if big_delta < 0.25:
|
||||||
|
# ряд малых Δ (φ-формулы теряют точность из-за сокращения знаков):
|
||||||
|
# F_R ≈ 1 + (5m²−1)/45 · Δ⁴
|
||||||
|
return 1.0 + (5.0 * m * m - 1.0) / 45.0 * big_delta**4
|
||||||
|
|
||||||
|
two_d = 2.0 * big_delta
|
||||||
|
phi1 = (math.sinh(two_d) + math.sin(two_d)) / (math.cosh(two_d) - math.cos(two_d))
|
||||||
|
phi2 = (math.sinh(big_delta) - math.sin(big_delta)) / (math.cosh(big_delta) + math.cos(big_delta))
|
||||||
|
f_r = big_delta * (phi1 + (2.0 * (m * m - 1.0) / 3.0) * phi2)
|
||||||
|
return max(f_r, 1.0)
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
"""ОДУ разряда конденсатора через ключ и катушку, связанное с механикой снаряда.
|
"""ОДУ разряда конденсатора через ключ и катушку, связанное с механикой снаряда.
|
||||||
|
|
||||||
Состояние: [Q, I, x, v] — заряд конденсатора, ток в катушке, положение и
|
Состояние: [Q, I, x, v] — заряд конденсатора, ток в катушке, положение и
|
||||||
скорость снаряда. Индуктивность зависит от x (проход снаряда), поэтому
|
скорость снаряда. Индуктивность железа насыщается по току, поэтому и член
|
||||||
уравнение на dI/dt выведено из закона сохранения потокосцепления
|
dI/dt, и наведённая ЭДС, и сила выводятся из ОДНОГО потокосцепления
|
||||||
Ψ = L(x)*I:
|
λ(x, I) (см. `inductance.CoilInductanceModel`), что сохраняет энергию:
|
||||||
|
|
||||||
V_c = Q / C
|
V_c = Q / C
|
||||||
dQ/dt = -I
|
dQ/dt = -I
|
||||||
dI/dt = (V_c - I*R_total - I*(dL/dx)*v) / L(x)
|
dI/dt = (V_c - I*R_total - (∂λ/∂x)*v) / (∂λ/∂I)
|
||||||
dv/dt = F(x, I) / m
|
dv/dt = F(x, I) / m (F из coenergy того же λ)
|
||||||
dx/dt = v
|
dx/dt = v
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -16,7 +16,6 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
from gausse.physics.force import force_on_slug_newtons
|
|
||||||
from gausse.physics.inductance import CoilInductanceModel
|
from gausse.physics.inductance import CoilInductanceModel
|
||||||
|
|
||||||
Q, I, X, V = range(4)
|
Q, I, X, V = range(4)
|
||||||
@@ -27,10 +26,32 @@ class StageCircuitParams:
|
|||||||
capacitance_f: float
|
capacitance_f: float
|
||||||
r_total_ohm: float
|
r_total_ohm: float
|
||||||
mass_kg: float
|
mass_kg: float
|
||||||
mu_eff: float
|
# отражённое сопротивление вихревых токов снаряда; действует × overlap(x)
|
||||||
total_turns: int
|
# (только пока снаряд в катушке). См. physics/losses.py.
|
||||||
coil_length_m: float
|
r_eddy_coeff_ohm: float = 0.0
|
||||||
b_sat_tesla: float
|
# тормозящие силы на снаряд: сухое трение о трубку (константа, Н) и
|
||||||
|
# аэродинамическое сопротивление (× v², Н·с²/м²). Обе против движения.
|
||||||
|
retard_const_n: float = 0.0
|
||||||
|
drag_coeff_n_per_mps2: float = 0.0
|
||||||
|
|
||||||
|
|
||||||
|
# Сглаживание сухого трения около v=0: sign(v) -> tanh(v/ε). РАЗРЫВНАЯ сила
|
||||||
|
# (чистый sign) ломает адаптивный решатель: у v≈0 шаг дробится бесконечно
|
||||||
|
# (chattering), одна конфигурация считается десятки минут. tanh — стандартная
|
||||||
|
# регуляризация (модель залипания); при |v| >> ε неотличима от sign.
|
||||||
|
FRICTION_SMOOTHING_V_MPS = 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def retarding_force_n(params: StageCircuitParams, v: float) -> float:
|
||||||
|
"""Тормозящая сила (трение + воздух), гладкая по v, против скорости."""
|
||||||
|
return float(np.tanh(v / FRICTION_SMOOTHING_V_MPS)) * (
|
||||||
|
params.retard_const_n + params.drag_coeff_n_per_mps2 * v * v
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def effective_resistance(inductance_model: CoilInductanceModel, params: StageCircuitParams, x) -> float:
|
||||||
|
"""R контура = провод+ESR+ключ (r_total) плюс вихревые потери снаряда, когда он в катушке."""
|
||||||
|
return params.r_total_ohm + params.r_eddy_coeff_ohm * inductance_model.overlap_fraction(x)
|
||||||
|
|
||||||
|
|
||||||
def derivatives(
|
def derivatives(
|
||||||
@@ -40,14 +61,15 @@ def derivatives(
|
|||||||
params: StageCircuitParams,
|
params: StageCircuitParams,
|
||||||
) -> list[float]:
|
) -> list[float]:
|
||||||
q, current, x, v = state
|
q, current, x, v = state
|
||||||
l_x = inductance_model.l_of_x(x)
|
dlambda_di = inductance_model.dlambda_di(x, current)
|
||||||
dl_dx = inductance_model.dl_dx(x)
|
dlambda_dx = inductance_model.dlambda_dx(x, current)
|
||||||
|
r_eff = effective_resistance(inductance_model, params, x)
|
||||||
|
|
||||||
v_c = q / params.capacitance_f
|
v_c = q / params.capacitance_f
|
||||||
d_q = -current
|
d_q = -current
|
||||||
d_i = (v_c - current * params.r_total_ohm - current * dl_dx * v) / l_x
|
d_i = (v_c - current * r_eff - dlambda_dx * v) / dlambda_di
|
||||||
force = force_on_slug_newtons(current_a=current, dl_dx=dl_dx)
|
force = inductance_model.force_newtons(x, current)
|
||||||
d_v = force / params.mass_kg
|
d_v = (force - retarding_force_n(params, v)) / params.mass_kg
|
||||||
return [d_q, d_i, v, d_v]
|
return [d_q, d_i, v, d_v]
|
||||||
|
|
||||||
|
|
||||||
@@ -57,3 +79,49 @@ def zero_current_crossing_event(t: float, state: np.ndarray, *_args) -> float:
|
|||||||
|
|
||||||
zero_current_crossing_event.terminal = True
|
zero_current_crossing_event.terminal = True
|
||||||
zero_current_crossing_event.direction = -1.0
|
zero_current_crossing_event.direction = -1.0
|
||||||
|
|
||||||
|
|
||||||
|
def current_local_min_event(t: float, state: np.ndarray, inductance_model, params) -> float:
|
||||||
|
"""Первый локальный МИНИМУМ тока после пика — там снаряд начинает
|
||||||
|
«подкачивать» ток обратно (генераторный режим за центром катушки).
|
||||||
|
|
||||||
|
Тиристор проводит ОДИН импульс: как только ток, спадая, перестаёт падать
|
||||||
|
и пошёл бы вверх, ключ запирается (в реальности так и есть — за один
|
||||||
|
выстрел катушка стреляет один раз, конденсатор не перезаряжается за мкс).
|
||||||
|
Событие = dI/dt, ловим переход снизу вверх (direction=+1).
|
||||||
|
"""
|
||||||
|
q, current, x, v = state
|
||||||
|
dlambda_di = inductance_model.dlambda_di(x, current)
|
||||||
|
dlambda_dx = inductance_model.dlambda_dx(x, current)
|
||||||
|
r_eff = effective_resistance(inductance_model, params, x)
|
||||||
|
v_c = q / params.capacitance_f
|
||||||
|
return (v_c - current * r_eff - dlambda_dx * v) / dlambda_di
|
||||||
|
|
||||||
|
|
||||||
|
current_local_min_event.terminal = True
|
||||||
|
current_local_min_event.direction = 1.0
|
||||||
|
|
||||||
|
# Ток удержания ключа: тиристор сам закрывается, когда ток после пика падает
|
||||||
|
# ниже I_H (типично 30-100 мА; берём консервативно 0.1 А). Без этого события
|
||||||
|
# передемпфированный разряд (ток -> 0 асимптотически, нуля не пересекает)
|
||||||
|
# заставляет решатель молотить весь временной бюджет микросекундными шагами —
|
||||||
|
# одна конфигурация считалась минутами. Обрыв при I<I_hold — физика, не хак.
|
||||||
|
HOLDING_CURRENT_A = 0.1
|
||||||
|
|
||||||
|
|
||||||
|
def make_current_decay_event(i_hold_a: float = HOLDING_CURRENT_A):
|
||||||
|
"""Терминальное событие: ток после пика упал ниже тока удержания ключа."""
|
||||||
|
peak = 0.0
|
||||||
|
|
||||||
|
def event(t: float, state: np.ndarray, *_args) -> float:
|
||||||
|
nonlocal peak
|
||||||
|
i = float(state[I])
|
||||||
|
if i > peak:
|
||||||
|
peak = i
|
||||||
|
if peak < 2.0 * i_hold_a: # импульса ещё толком не было
|
||||||
|
return 1.0
|
||||||
|
return i - i_hold_a
|
||||||
|
|
||||||
|
event.terminal = True
|
||||||
|
event.direction = -1.0
|
||||||
|
return event
|
||||||
|
|||||||
@@ -4,3 +4,40 @@ MU_0 = 4e-7 * 3.141592653589793 # Гн/м, магнитная проницае
|
|||||||
|
|
||||||
RESISTIVITY_COPPER_OHM_M = 1.68e-8 # при ~20°C
|
RESISTIVITY_COPPER_OHM_M = 1.68e-8 # при ~20°C
|
||||||
RESISTIVITY_ALUMINUM_OHM_M = 2.82e-8 # при ~20°C
|
RESISTIVITY_ALUMINUM_OHM_M = 2.82e-8 # при ~20°C
|
||||||
|
|
||||||
|
# Импульсный (surge) ток намного выше паспортного среднего/непрерывного,
|
||||||
|
# который лежит в базе компонентов. Для одиночного коротко-импульсного
|
||||||
|
# выстрела coilgun применяем эти множители к max_current_a детали и
|
||||||
|
# отбраковываем конфигурацию, если пиковый ток разряда их превышает —
|
||||||
|
# иначе оптимизатор выдаёт "победителей", которые сожгли бы свой ключ.
|
||||||
|
# Значения — консервативные типовые (тиристор ITSM ~10× IT(AV), MOSFET
|
||||||
|
# импульсный ~4× непрерывного, IGBT ~3×, электролит surge ~5× ripple).
|
||||||
|
# Это оценки, а не паспортные surge-рейтинги конкретных деталей.
|
||||||
|
SWITCH_SURGE_FACTOR = {"SCR": 10.0, "MOSFET": 4.0, "IGBT": 3.0}
|
||||||
|
CAPACITOR_SURGE_FACTOR = 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def switch_pulse_limit_a(switch) -> float:
|
||||||
|
"""Импульсный предел тока ключа для одиночного выстрела.
|
||||||
|
|
||||||
|
Приоритет — паспортный импульсный рейтинг из даташита (ITSM тиристора,
|
||||||
|
IDM MOSFET, ICM IGBT), занесённый в базу компонентов. Только если его
|
||||||
|
нет — консервативный типовой множитель SWITCH_SURGE_FACTOR к
|
||||||
|
продолжительному току.
|
||||||
|
"""
|
||||||
|
pulse = getattr(switch, "pulse_current_a", None)
|
||||||
|
if pulse:
|
||||||
|
return float(pulse)
|
||||||
|
return switch.max_current_a * SWITCH_SURGE_FACTOR.get(switch.kind, 4.0)
|
||||||
|
|
||||||
|
|
||||||
|
G_ACCEL_M_S2 = 9.81
|
||||||
|
# Сухое трение скольжения сталь↔пластик (ПВХ/оргстекло): типично 0.3–0.4.
|
||||||
|
# Труба горизонтальна: нормальная сила = m·g. Оценка, вынесена для уточнения
|
||||||
|
# по реальному замеру (наклонная плоскость с реальной трубкой и снарядом).
|
||||||
|
FRICTION_COEFF_SLUG_TUBE = 0.35
|
||||||
|
AIR_DENSITY_KG_M3 = 1.2 # при ~20°C, уровень моря
|
||||||
|
# Cd плоского торца цилиндра в свободном потоке ~0.8–1.2. ЧЕСТНО: в узкой
|
||||||
|
# трубе есть ещё поршневой эффект (снаряд толкает столб воздуха) — он НЕ
|
||||||
|
# учтён и занижает потери на высоких скоростях в длинной трубе.
|
||||||
|
DRAG_COEFF_CYLINDER = 0.85
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ from dataclasses import dataclass
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from gausse.physics.constants import MU_0
|
||||||
|
|
||||||
INCH_M = 0.0254
|
INCH_M = 0.0254
|
||||||
|
|
||||||
|
|
||||||
@@ -76,12 +78,26 @@ def _sigmoid(z: np.ndarray) -> np.ndarray:
|
|||||||
return 0.5 * (1 + np.tanh(z / 2))
|
return 0.5 * (1 + np.tanh(z / 2))
|
||||||
|
|
||||||
|
|
||||||
|
def _ln_cosh(z: np.ndarray) -> np.ndarray:
|
||||||
|
"""Численно устойчивый ln(cosh z) = |z| + ln((1+e^{-2|z|})/2)."""
|
||||||
|
az = np.abs(z)
|
||||||
|
return az + np.log1p(np.exp(-2 * az)) - np.log(2.0)
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class CoilInductanceModel:
|
class CoilInductanceModel:
|
||||||
"""L(x) и аналитическая dL/dx для катушки с проходящим ферромагнитным снарядом.
|
"""Потокосцепление λ(x, I) катушки с ферромагнитным снарядом и насыщением.
|
||||||
|
|
||||||
x — положение центра снаряда относительно центра катушки (м), катушка
|
x — положение центра снаряда относительно центра катушки (м).
|
||||||
считается центрированной в x=0 в своей локальной системе координат.
|
|
||||||
|
Вклад железа в потокосцепление насыщается по току:
|
||||||
|
λ(x, I) = L_air·I + L_iron·overlap(x)·g(I),
|
||||||
|
g(I) = I_sat·tanh(I/I_sat), L_iron = L_air·(μ_eff−1)
|
||||||
|
где I_sat — ток, при котором поле в железе достигает B_sat. И сила, и
|
||||||
|
наведённая ЭДС в контуре выводятся из ОДНОГО и того же λ (метод
|
||||||
|
coenergy), поэтому энергия сохраняется, а поле не улетает за B_sat.
|
||||||
|
В пределе малого тока (g(I)≈I) всё сводится к линейной модели
|
||||||
|
F = 0.5·I²·dL/dx.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
l_air_h: float
|
l_air_h: float
|
||||||
@@ -89,6 +105,25 @@ class CoilInductanceModel:
|
|||||||
slug_length_m: float
|
slug_length_m: float
|
||||||
mu_eff: float
|
mu_eff: float
|
||||||
smoothing_width_m: float
|
smoothing_width_m: float
|
||||||
|
total_turns: int = 0
|
||||||
|
b_sat_tesla: float = 1e9 # по умолчанию насыщение отключено (для геом. тестов)
|
||||||
|
# Доля сечения катушки, занятая железом: A_снаряда/A_среднего витка (≤1).
|
||||||
|
# λ = n·(B_fe·A_fe + B_возд·(A−A_fe)) ⇒ L = L_air·(1+(μ−1)·fill·overlap).
|
||||||
|
# БЕЗ этого тонкий снаряд в толстой катушке получал усиление как будто
|
||||||
|
# железо заполняет ВСЁ сечение — оптимизатор эксплуатировал (КПД «82%»).
|
||||||
|
iron_fill_factor: float = 1.0
|
||||||
|
|
||||||
|
@property
|
||||||
|
def l_iron_coeff(self) -> float:
|
||||||
|
return self.l_air_h * (self.mu_eff - 1.0) * self.iron_fill_factor
|
||||||
|
|
||||||
|
@property
|
||||||
|
def saturation_current_a(self) -> float:
|
||||||
|
"""Ток, при котором поле в железе достигает B_sat. inf = без насыщения."""
|
||||||
|
if self.total_turns <= 0 or self.mu_eff <= 1.0 or self.b_sat_tesla >= 1e8:
|
||||||
|
return float("inf")
|
||||||
|
turns_per_m = self.total_turns / self.coil_length_m
|
||||||
|
return self.b_sat_tesla / (MU_0 * self.mu_eff * turns_per_m)
|
||||||
|
|
||||||
def _edges(self) -> tuple[float, float]:
|
def _edges(self) -> tuple[float, float]:
|
||||||
half_span = (self.coil_length_m + self.slug_length_m) / 2
|
half_span = (self.coil_length_m + self.slug_length_m) / 2
|
||||||
@@ -106,8 +141,55 @@ class CoilInductanceModel:
|
|||||||
s2 = _sigmoid((e2 - x) / w)
|
s2 = _sigmoid((e2 - x) / w)
|
||||||
return (s1 * s2 / w) * (s2 - s1)
|
return (s1 * s2 / w) * (s2 - s1)
|
||||||
|
|
||||||
|
# --- насыщающиеся токовые функции: g, g', G (интеграл g) ---
|
||||||
|
def _g(self, i: np.ndarray) -> np.ndarray:
|
||||||
|
i_sat = self.saturation_current_a
|
||||||
|
if not np.isfinite(i_sat):
|
||||||
|
return np.asarray(i, dtype=float)
|
||||||
|
return i_sat * np.tanh(i / i_sat)
|
||||||
|
|
||||||
|
def _g_prime(self, i: np.ndarray) -> np.ndarray:
|
||||||
|
i_sat = self.saturation_current_a
|
||||||
|
if not np.isfinite(i_sat):
|
||||||
|
return np.ones_like(np.asarray(i, dtype=float))
|
||||||
|
return 1.0 - np.tanh(i / i_sat) ** 2 # sech^2, устойчиво
|
||||||
|
|
||||||
|
def _g_integral(self, i: np.ndarray) -> np.ndarray:
|
||||||
|
i_sat = self.saturation_current_a
|
||||||
|
if not np.isfinite(i_sat):
|
||||||
|
return 0.5 * np.asarray(i, dtype=float) ** 2
|
||||||
|
return i_sat**2 * _ln_cosh(i / i_sat)
|
||||||
|
|
||||||
|
# --- линейные (ненасыщенные) L(x), dL/dx — для геометрических тестов ---
|
||||||
def l_of_x(self, x: np.ndarray) -> np.ndarray:
|
def l_of_x(self, x: np.ndarray) -> np.ndarray:
|
||||||
return self.l_air_h * (1 + (self.mu_eff - 1) * self.overlap_fraction(x))
|
return self.l_air_h * (1 + (self.mu_eff - 1) * self.overlap_fraction(x))
|
||||||
|
|
||||||
def dl_dx(self, x: np.ndarray) -> np.ndarray:
|
def dl_dx(self, x: np.ndarray) -> np.ndarray:
|
||||||
return self.l_air_h * (self.mu_eff - 1) * self.d_overlap_dx(x)
|
return self.l_iron_coeff * self.d_overlap_dx(x)
|
||||||
|
|
||||||
|
# --- динамика с насыщением (используется контуром) ---
|
||||||
|
def dlambda_di(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
|
||||||
|
"""Дифференциальная индуктивность ∂λ/∂I для члена dI/dt."""
|
||||||
|
return self.l_air_h + self.l_iron_coeff * self.overlap_fraction(x) * self._g_prime(i)
|
||||||
|
|
||||||
|
def dlambda_dx(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
|
||||||
|
"""Пространственный член ∂λ/∂x для наведённой ЭДС."""
|
||||||
|
return self.l_iron_coeff * self.d_overlap_dx(x) * self._g(i)
|
||||||
|
|
||||||
|
def force_newtons(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
|
||||||
|
"""Сила из coenergy: F = ∂/∂x ∫₀ᴵ λ dI' = L_iron·overlap'(x)·G(I)."""
|
||||||
|
return self.l_iron_coeff * self.d_overlap_dx(x) * self._g_integral(i)
|
||||||
|
|
||||||
|
def magnetic_energy(self, x: np.ndarray, i: np.ndarray) -> np.ndarray:
|
||||||
|
"""Энергия магнитного поля W = ∫₀ᴵ i·(∂λ/∂i) di (с учётом насыщения).
|
||||||
|
|
||||||
|
= ½·L_air·I² + L_iron·overlap(x)·(I·g(I) − G(I)). Нужна для точного
|
||||||
|
учёта остаточной энергии катушки при обрыве разряда (одиночный импульс).
|
||||||
|
"""
|
||||||
|
return 0.5 * self.l_air_h * i**2 + self.l_iron_coeff * self.overlap_fraction(x) * (
|
||||||
|
i * self._g(i) - self._g_integral(i)
|
||||||
|
)
|
||||||
|
|
||||||
|
def effective_field_current_a(self, i: float) -> float:
|
||||||
|
"""'Насыщенный' эффективный ток для оценки поля (поле ~ g(I), кап B_sat)."""
|
||||||
|
return float(self._g(np.asarray(float(i))))
|
||||||
|
|||||||
67
src/gausse/physics/losses.py
Normal file
67
src/gausse/physics/losses.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Потери в железе снаряда: вихревые токи + гистерезис (первого порядка).
|
||||||
|
|
||||||
|
Сплошной стальной снаряд в импульсном поле теряет энергию на вихревые токи
|
||||||
|
(греется). Модель: снаряд — короткозамкнутый виток (вторичка трансформатора),
|
||||||
|
резистивно-доминированный на частоте импульса. Отражённое в катушку
|
||||||
|
сопротивление R_eddy = (ω·M)²/R_e добавляется в контур, ПОКА снаряд в
|
||||||
|
катушке (× overlap(x)) — энергия честно уходит в нагрев снаряда, а не в
|
||||||
|
кинетику. Это и есть тот канал потерь, из-за отсутствия которого КПД был
|
||||||
|
нереально высоким.
|
||||||
|
|
||||||
|
ВАЖНО (честно): это оценка первого порядка. Точные вихревые потери требуют
|
||||||
|
решения уравнения диффузии поля в снаряде (скин-эффект, частотная
|
||||||
|
зависимость). Здесь — сосредоточенный виток + характерная частота импульса
|
||||||
|
ω=1/√(L·C). Геометрия вихревого контура (R_e) и коэффициент связи оценены
|
||||||
|
приближённо; величина может отличаться в разы. Коэффициенты вынесены, чтобы
|
||||||
|
их можно было уточнить по реальным замерам.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
from gausse.physics.constants import MU_0
|
||||||
|
|
||||||
|
# доля потока катушки, реально сцепленная с вихревым контуром снаряда (k<1).
|
||||||
|
# Снаряд у́же катушки и захватывает не весь поток — грубая оценка.
|
||||||
|
EDDY_COUPLING_FACTOR = 0.5
|
||||||
|
# гистерезис: энергия на площадь петли B-H за перемагничивание, Дж/м³/цикл.
|
||||||
|
# Для конструкционной стали ~сотни Дж/м³; берём ~300 (справочная оценка).
|
||||||
|
HYSTERESIS_LOSS_J_PER_M3 = 300.0
|
||||||
|
|
||||||
|
|
||||||
|
def eddy_reflected_resistance_ohm(
|
||||||
|
slug_radius_m: float,
|
||||||
|
slug_length_m: float,
|
||||||
|
slug_resistivity_ohm_m: float,
|
||||||
|
mu_eff: float,
|
||||||
|
total_turns: int,
|
||||||
|
coil_length_m: float,
|
||||||
|
char_omega_rad_s: float,
|
||||||
|
) -> float:
|
||||||
|
"""Отражённое в катушку сопротивление вихревого контура снаряда (Ом)."""
|
||||||
|
a_slug = math.pi * slug_radius_m**2
|
||||||
|
turns_per_m = total_turns / coil_length_m
|
||||||
|
# взаимная индуктивность катушка↔снаряд (снаряд как 1 виток)
|
||||||
|
mutual = EDDY_COUPLING_FACTOR * MU_0 * mu_eff * turns_per_m * a_slug
|
||||||
|
# сопротивление сосредоточенного вихревого контура (кольцевой путь в снаряде)
|
||||||
|
r_eddy_loop = 2 * math.pi * slug_resistivity_ohm_m / slug_length_m
|
||||||
|
if r_eddy_loop <= 0:
|
||||||
|
return 0.0
|
||||||
|
# собственная индуктивность вихревого контура (виток-соленоид длиной снаряда):
|
||||||
|
# полная формула отражения R = (ωM)²·R₂/(R₂²+(ωL₂)²) вместо резистивно-
|
||||||
|
# доминированного приближения (ωM)²/R₂ — иначе на высоких ω (вырожденные
|
||||||
|
# короткие катушки) отражённое сопротивление нефизично улетало в сотни кОм.
|
||||||
|
l_eddy_loop = MU_0 * a_slug / slug_length_m
|
||||||
|
om = char_omega_rad_s
|
||||||
|
return (om * mutual) ** 2 * r_eddy_loop / (r_eddy_loop**2 + (om * l_eddy_loop) ** 2)
|
||||||
|
|
||||||
|
|
||||||
|
def hysteresis_energy_j(slug_radius_m: float, slug_length_m: float, n_cycles: float = 1.0) -> float:
|
||||||
|
"""Энергия гистерезиса за выстрел (Дж) — вторичный канал, обычно << вихревых.
|
||||||
|
|
||||||
|
СОЗНАТЕЛЬНО не подключена в динамику: для типичных снарядов это
|
||||||
|
~0.005–0.05 Дж на выстрел (<0.1% энергии банки) — на порядки меньше
|
||||||
|
вихревых потерь, уже сидящих в контуре через r_eddy. Вкручивать её в
|
||||||
|
ОДУ значило бы изображать точность, которой у модели нет.
|
||||||
|
"""
|
||||||
|
volume = math.pi * slug_radius_m**2 * slug_length_m
|
||||||
|
return HYSTERESIS_LOSS_J_PER_M3 * volume * n_cycles
|
||||||
@@ -18,9 +18,18 @@ import numpy as np
|
|||||||
|
|
||||||
SensorEvent = Callable[[float, np.ndarray], float]
|
SensorEvent = Callable[[float, np.ndarray], float]
|
||||||
|
|
||||||
|
# cosh(z)**2 переполняет float64 при |z| > ~355; клэмпим до этого аргумент
|
||||||
|
# sech^2, а не сам bump -- при |z|>=20 бамп уже ~1e-17, дальнейший рост z
|
||||||
|
# физически ничего не меняет (снаряд всё равно "не виден" датчику).
|
||||||
|
_SECH2_ARG_CLIP = 20.0
|
||||||
|
|
||||||
|
|
||||||
|
def _sech2(z: np.ndarray) -> np.ndarray:
|
||||||
|
return 1.0 / np.cosh(np.clip(z, -_SECH2_ARG_CLIP, _SECH2_ARG_CLIP)) ** 2
|
||||||
|
|
||||||
|
|
||||||
def make_optical_sensor_event(x_sensor_m: float) -> SensorEvent:
|
def make_optical_sensor_event(x_sensor_m: float) -> SensorEvent:
|
||||||
def event(t: float, state: np.ndarray) -> float:
|
def event(t: float, state: np.ndarray, *_args) -> float:
|
||||||
return state[0] - x_sensor_m
|
return state[0] - x_sensor_m
|
||||||
|
|
||||||
event.terminal = True
|
event.terminal = True
|
||||||
@@ -34,9 +43,9 @@ def make_inductive_sensor_event(
|
|||||||
threshold_v: float,
|
threshold_v: float,
|
||||||
width_m: float,
|
width_m: float,
|
||||||
) -> SensorEvent:
|
) -> SensorEvent:
|
||||||
def event(t: float, state: np.ndarray) -> float:
|
def event(t: float, state: np.ndarray, *_args) -> float:
|
||||||
x, v = state
|
x, v = state
|
||||||
bump = 1.0 / np.cosh((x - x_sensor_m) / width_m) ** 2
|
bump = _sech2((x - x_sensor_m) / width_m)
|
||||||
return sensitivity_v_per_mps * v * bump - threshold_v
|
return sensitivity_v_per_mps * v * bump - threshold_v
|
||||||
|
|
||||||
event.terminal = True
|
event.terminal = True
|
||||||
|
|||||||
205
src/gausse/report/animate.py
Normal file
205
src/gausse/report/animate.py
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
"""Анимация одного прогона: снаряд в трубе + ток катушек + скорость, всё во времени.
|
||||||
|
|
||||||
|
Честность по времени: кадры берутся на РАВНОМЕРНОЙ сетке по физическому
|
||||||
|
времени (раньше — по индексам массива, из-за чего плотные точки разряда
|
||||||
|
съедали все кадры, а подлёт между катушками «вырезался»). Всё событие
|
||||||
|
показывается целиком в замедлении, множитель замедления написан в кадре.
|
||||||
|
|
||||||
|
Результат эксперимента — в самом кадре:
|
||||||
|
- шапка: выходная скорость, КПД, энергия банок, масса снаряда, ступени;
|
||||||
|
- средняя панель: ток каждой катушки во времени (окна разрядов подкрашены);
|
||||||
|
- нижняя панель: скорость снаряда во времени;
|
||||||
|
- бегущий курсор на обеих панелях синхронен с положением снаряда в трубе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.animation as animation
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from gausse.sim.coilgun import CoilgunConfig, CoilgunResult
|
||||||
|
|
||||||
|
_STAGE_COLORS = ["#d4a017", "#c0392b", "#2980b9", "#27ae60", "#8e44ad",
|
||||||
|
"#d35400", "#16a085", "#7f8c8d", "#2c3e50", "#e91e63"]
|
||||||
|
|
||||||
|
|
||||||
|
def _build_global_timeline(result: CoilgunResult, config: CoilgunConfig):
|
||||||
|
"""Непрерывные t/x/v + ток по каждой ступени + окна разрядов.
|
||||||
|
|
||||||
|
Строится последовательно: подлёт (скорость почти постоянна на сантиметрах
|
||||||
|
межкатушечного зазора) + точные траектории разрядов из решателя.
|
||||||
|
Глобальная координата = центр катушки ступени + локальный discharge_x.
|
||||||
|
"""
|
||||||
|
n_stages = len(config.stages)
|
||||||
|
seg_t, seg_x, seg_v = [], [], []
|
||||||
|
seg_i = [[] for _ in range(n_stages)]
|
||||||
|
discharge_windows = [] # (t_start, t_end, stage_idx)
|
||||||
|
|
||||||
|
t_cursor = 0.0
|
||||||
|
prev_global_x = None
|
||||||
|
prev_v = None
|
||||||
|
|
||||||
|
for outcome in result.stage_outcomes:
|
||||||
|
r = outcome.result
|
||||||
|
if r.discharge_t is None or r.discharge_x is None:
|
||||||
|
continue
|
||||||
|
disch_global_x = outcome.global_coil_center_m + r.discharge_x
|
||||||
|
start_global_x = float(disch_global_x[0])
|
||||||
|
|
||||||
|
# подлёт от конца прошлого разряда до момента поджига этого
|
||||||
|
if prev_global_x is not None and prev_v and prev_v > 1e-6:
|
||||||
|
coast_dist = max(start_global_x - prev_global_x, 0.0)
|
||||||
|
coast_dur = coast_dist / prev_v
|
||||||
|
n_coast = 16
|
||||||
|
ct = np.linspace(0.0, coast_dur, n_coast)
|
||||||
|
seg_t.append(t_cursor + ct)
|
||||||
|
seg_x.append(np.linspace(prev_global_x, start_global_x, n_coast))
|
||||||
|
seg_v.append(np.full(n_coast, prev_v))
|
||||||
|
for s in range(n_stages):
|
||||||
|
seg_i[s].append(np.zeros(n_coast))
|
||||||
|
t_cursor += coast_dur
|
||||||
|
|
||||||
|
# окно разряда: точная траектория + ток именно этой катушки
|
||||||
|
seg_t.append(t_cursor + r.discharge_t)
|
||||||
|
seg_x.append(disch_global_x)
|
||||||
|
seg_v.append(r.discharge_v)
|
||||||
|
for s in range(n_stages):
|
||||||
|
seg_i[s].append(r.discharge_i if s == outcome.stage_index else np.zeros_like(r.discharge_t))
|
||||||
|
discharge_windows.append((t_cursor, t_cursor + float(r.discharge_t[-1]), outcome.stage_index))
|
||||||
|
t_cursor += float(r.discharge_t[-1])
|
||||||
|
|
||||||
|
prev_global_x = float(disch_global_x[-1])
|
||||||
|
prev_v = float(r.discharge_v[-1]) if r.discharge_v is not None else outcome.entry_v_mps
|
||||||
|
|
||||||
|
if not seg_t:
|
||||||
|
raise ValueError("нет данных разряда для анимации")
|
||||||
|
|
||||||
|
times = np.concatenate(seg_t)
|
||||||
|
positions = np.concatenate(seg_x)
|
||||||
|
velocities = np.concatenate(seg_v)
|
||||||
|
currents = [np.concatenate(seg_i[s]) for s in range(n_stages)]
|
||||||
|
# страховка от немонотонности на стыках сегментов (float-складки)
|
||||||
|
order = np.argsort(times, kind="stable")
|
||||||
|
return times[order], positions[order], velocities[order], currents, order, discharge_windows
|
||||||
|
|
||||||
|
|
||||||
|
def animate_run(result: CoilgunResult, config: CoilgunConfig, out_path: str, fps: int = 30) -> str:
|
||||||
|
if not result.stage_outcomes:
|
||||||
|
raise ValueError("нечего анимировать: нет ни одной ступени в результате")
|
||||||
|
|
||||||
|
times, positions, velocities, currents_raw, order, windows = _build_global_timeline(result, config)
|
||||||
|
currents = [c[order] for c in currents_raw]
|
||||||
|
total_t = float(times[-1])
|
||||||
|
|
||||||
|
# РАВНОМЕРНАЯ сетка по физическому времени: ничего не вырезается,
|
||||||
|
# всё событие целиком в замедлении (множитель — в кадре)
|
||||||
|
wall_duration_s = 8.0
|
||||||
|
n_frames = int(wall_duration_s * fps)
|
||||||
|
t_grid = np.linspace(0.0, total_t, n_frames)
|
||||||
|
x_grid = np.interp(t_grid, times, positions)
|
||||||
|
v_grid = np.interp(t_grid, times, velocities)
|
||||||
|
i_grid = [np.interp(t_grid, times, c) for c in currents]
|
||||||
|
slowmo = wall_duration_s / max(total_t, 1e-9)
|
||||||
|
|
||||||
|
coil_centers = [o.global_coil_center_m for o in result.stage_outcomes]
|
||||||
|
t_ms = times * 1e3
|
||||||
|
|
||||||
|
fig, (ax_tube, ax_i, ax_v) = plt.subplots(
|
||||||
|
3, 1, figsize=(9, 7.5), gridspec_kw={"height_ratios": [1.1, 1.4, 1.4], "hspace": 0.45}
|
||||||
|
)
|
||||||
|
|
||||||
|
# --- шапка: РЕЗУЛЬТАТ эксперимента ---
|
||||||
|
eff = result.efficiency
|
||||||
|
header = (
|
||||||
|
f"выход {result.exit_v_mps:.1f} м/с | КПД {eff * 100:.1f}% | "
|
||||||
|
f"{len(config.stages)} ступ. | E_банок {result.total_energy_in_j:.0f} Дж | "
|
||||||
|
f"снаряд {config.projectile.mass_kg * 1e3:.0f} г"
|
||||||
|
if result.feasible and eff is not None
|
||||||
|
else f"НЕРЕАЛИЗУЕМО: {result.reason}"
|
||||||
|
)
|
||||||
|
fig.suptitle(header, fontsize=12, fontweight="bold")
|
||||||
|
|
||||||
|
# --- панель 1: труба ---
|
||||||
|
tube_min, tube_max = positions.min(), positions.max()
|
||||||
|
pad = max((tube_max - tube_min) * 0.05, 0.005)
|
||||||
|
ax_tube.set_xlim((tube_min - pad) * 1e3, (tube_max + pad) * 1e3)
|
||||||
|
ax_tube.set_ylim(-1, 1)
|
||||||
|
ax_tube.set_xlabel("положение, мм")
|
||||||
|
ax_tube.set_yticks([])
|
||||||
|
ax_tube.axhline(0, color="#888", linewidth=3, zorder=1) # труба
|
||||||
|
coil_dots = []
|
||||||
|
for k, c in enumerate(coil_centers):
|
||||||
|
color = _STAGE_COLORS[k % len(_STAGE_COLORS)]
|
||||||
|
dot = ax_tube.scatter([c * 1e3], [0], s=500, c="lightgray",
|
||||||
|
edgecolors=color, linewidths=2, zorder=2)
|
||||||
|
ax_tube.annotate(f"катушка {k}", (c * 1e3, 0), textcoords="offset points",
|
||||||
|
xytext=(0, 20), ha="center", fontsize=8, color=color)
|
||||||
|
# позиция датчика этой ступени (у каждой катушки свой)
|
||||||
|
sensor_x = (c - config.stages[k].sensor_to_coil_distance_m) * 1e3
|
||||||
|
ax_tube.plot([sensor_x], [-0.35], marker="v", color=color, markersize=6, zorder=2)
|
||||||
|
coil_dots.append(dot)
|
||||||
|
ax_tube.plot([], [], marker="v", linestyle="", color="#555", label="датчики")
|
||||||
|
ax_tube.legend(loc="upper left", fontsize=7, frameon=False)
|
||||||
|
slug_dot = ax_tube.scatter([], [], s=160, c="#1f4e8c", edgecolors="white", linewidths=1, zorder=3)
|
||||||
|
info_text = ax_tube.text(0.99, 1.25, "", transform=ax_tube.transAxes,
|
||||||
|
fontsize=9, ha="right", va="top")
|
||||||
|
|
||||||
|
# --- панель 2: ток каждой катушки во времени ---
|
||||||
|
for k in range(len(config.stages)):
|
||||||
|
color = _STAGE_COLORS[k % len(_STAGE_COLORS)]
|
||||||
|
ax_i.plot(t_ms, currents[k], color=color, linewidth=1.2, label=f"катушка {k}")
|
||||||
|
for (w0, w1, k) in windows:
|
||||||
|
ax_i.axvspan(w0 * 1e3, w1 * 1e3, color=_STAGE_COLORS[k % len(_STAGE_COLORS)], alpha=0.08)
|
||||||
|
ax_i.set_ylabel("ток, А")
|
||||||
|
ax_i.set_xlabel("время, мс")
|
||||||
|
ax_i.legend(loc="upper right", fontsize=7, frameon=False, ncols=2)
|
||||||
|
ax_i.grid(alpha=0.25)
|
||||||
|
cursor_i = ax_i.axvline(0.0, color="#333", linewidth=1)
|
||||||
|
|
||||||
|
# --- панель 3: скорость снаряда во времени ---
|
||||||
|
ax_v.plot(t_ms, velocities, color="#1f4e8c", linewidth=1.6)
|
||||||
|
for (w0, w1, k) in windows:
|
||||||
|
ax_v.axvspan(w0 * 1e3, w1 * 1e3, color=_STAGE_COLORS[k % len(_STAGE_COLORS)], alpha=0.08)
|
||||||
|
ax_v.set_ylabel("скорость, м/с")
|
||||||
|
ax_v.set_xlabel("время, мс")
|
||||||
|
ax_v.grid(alpha=0.25)
|
||||||
|
if result.feasible and result.exit_v_mps is not None:
|
||||||
|
ax_v.axhline(result.exit_v_mps, color="#27ae60", linewidth=0.8, linestyle="--")
|
||||||
|
ax_v.annotate(f"выход {result.exit_v_mps:.1f} м/с",
|
||||||
|
(t_ms[-1], result.exit_v_mps), ha="right", va="bottom",
|
||||||
|
fontsize=8, color="#27ae60")
|
||||||
|
cursor_v = ax_v.axvline(0.0, color="#333", linewidth=1)
|
||||||
|
v_dot = ax_v.scatter([], [], s=30, c="#1f4e8c", zorder=3)
|
||||||
|
|
||||||
|
max_i_per_stage = [max(float(np.max(np.abs(c))), 1e-9) for c in currents]
|
||||||
|
|
||||||
|
def _coil_color(intensity):
|
||||||
|
# выкл -> светло-серый; максимум тока -> яркое золото (свечение поля)
|
||||||
|
t = min(max(intensity, 0.0), 1.0)
|
||||||
|
off = np.array([0.83, 0.83, 0.83])
|
||||||
|
on = np.array([1.0, 0.78, 0.0])
|
||||||
|
return tuple(off + (on - off) * t)
|
||||||
|
|
||||||
|
def update(f):
|
||||||
|
slug_dot.set_offsets([[x_grid[f] * 1e3, 0]])
|
||||||
|
for k, dots in enumerate(coil_dots):
|
||||||
|
intensity = abs(i_grid[k][f]) / max_i_per_stage[k]
|
||||||
|
dots.set_color(_coil_color(intensity))
|
||||||
|
dots.set_edgecolor(_STAGE_COLORS[k % len(_STAGE_COLORS)])
|
||||||
|
dots.set_sizes([500 + 350 * min(intensity, 1.0)])
|
||||||
|
info_text.set_text(f"t = {t_grid[f] * 1e3:.2f} мс | v = {v_grid[f]:.1f} м/с | замедление ×{slowmo:.0f}")
|
||||||
|
cursor_i.set_xdata([t_grid[f] * 1e3])
|
||||||
|
cursor_v.set_xdata([t_grid[f] * 1e3])
|
||||||
|
v_dot.set_offsets([[t_grid[f] * 1e3, v_grid[f]]])
|
||||||
|
return [slug_dot, info_text, cursor_i, cursor_v, v_dot, *coil_dots]
|
||||||
|
|
||||||
|
anim = animation.FuncAnimation(fig, update, frames=n_frames, interval=1000 / fps, blit=False)
|
||||||
|
out_path = str(out_path)
|
||||||
|
anim.save(out_path, writer=animation.PillowWriter(fps=fps))
|
||||||
|
plt.close(fig)
|
||||||
|
return out_path
|
||||||
106
src/gausse/report/bom.py
Normal file
106
src/gausse/report/bom.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
"""Спецификация деталей (BOM) для найденной конфигурации — с ценами и итогом."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.physics.inductance import winding_geometry
|
||||||
|
from gausse.sim.coilgun import CoilgunConfig
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class BomLine:
|
||||||
|
stage_index: int | None # None для позиций, общих для всей пушки (снаряд)
|
||||||
|
part: str
|
||||||
|
quantity: str
|
||||||
|
unit_price_rub: float
|
||||||
|
total_price_rub: float
|
||||||
|
note: str = ""
|
||||||
|
url: str | None = None # ссылка на магазин, где купить
|
||||||
|
|
||||||
|
|
||||||
|
def build_bom(config: CoilgunConfig, db: ComponentDatabase) -> list[BomLine]:
|
||||||
|
lines: list[BomLine] = []
|
||||||
|
for i, stage in enumerate(config.stages):
|
||||||
|
wire_od_m = stage.wire.insulation_od_mm / 1000
|
||||||
|
geometry = winding_geometry(stage.tube_od_m, wire_od_m, stage.turns_per_layer, stage.layers)
|
||||||
|
wire_length_m = geometry.total_wire_length_m
|
||||||
|
lines.append(
|
||||||
|
BomLine(
|
||||||
|
stage_index=i,
|
||||||
|
part=f"Провод {stage.wire.part_id}",
|
||||||
|
quantity=f"{wire_length_m:.2f} м",
|
||||||
|
unit_price_rub=stage.wire.price_per_m,
|
||||||
|
total_price_rub=wire_length_m * stage.wire.price_per_m,
|
||||||
|
note=f"{geometry.total_turns} витков ({stage.turns_per_layer}x{stage.layers}), ⌀{stage.wire.gauge_mm}мм",
|
||||||
|
url=stage.wire.url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
cap = stage.capacitor
|
||||||
|
cap_base = getattr(cap, "base_part_number", getattr(cap, "part_number", "?"))
|
||||||
|
n_series = getattr(cap, "n_series", 1)
|
||||||
|
n_parallel = getattr(cap, "n_parallel", 1)
|
||||||
|
count = n_series * n_parallel
|
||||||
|
unit_price = cap.price / count if count else cap.price
|
||||||
|
lines.append(
|
||||||
|
BomLine(
|
||||||
|
stage_index=i,
|
||||||
|
part=f"Конденсатор {cap_base}",
|
||||||
|
quantity=f"{count} шт ({n_series}посл × {n_parallel}пар)",
|
||||||
|
unit_price_rub=unit_price,
|
||||||
|
total_price_rub=cap.price,
|
||||||
|
note=f"батарея {cap.capacitance_uf:.0f}мкФ {cap.voltage_v:.0f}В, заряд {stage.charge_voltage_v:.0f}В, макс.ток {cap.max_current_a:.0f}А",
|
||||||
|
url=getattr(cap, "url", None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
BomLine(
|
||||||
|
stage_index=i,
|
||||||
|
part=f"Ключ {stage.switch.part_number}",
|
||||||
|
quantity="1 шт",
|
||||||
|
unit_price_rub=stage.switch.price,
|
||||||
|
total_price_rub=stage.switch.price,
|
||||||
|
note=f"{stage.switch.kind}, {stage.switch.max_current_a:.0f}А / {stage.switch.max_voltage_v:.0f}В",
|
||||||
|
url=stage.switch.url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
BomLine(
|
||||||
|
stage_index=i,
|
||||||
|
part=f"Датчик {stage.sensor.part_number}",
|
||||||
|
quantity="1 шт",
|
||||||
|
unit_price_rub=stage.sensor.price,
|
||||||
|
total_price_rub=stage.sensor.price,
|
||||||
|
note=stage.sensor.kind,
|
||||||
|
url=stage.sensor.url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
mass_kg = config.projectile.mass_kg
|
||||||
|
lines.append(
|
||||||
|
BomLine(
|
||||||
|
stage_index=None,
|
||||||
|
part=f"Снаряд: {config.projectile.material.name}",
|
||||||
|
quantity=f"{mass_kg * 1000:.1f} г",
|
||||||
|
unit_price_rub=config.projectile.material.price_per_kg,
|
||||||
|
total_price_rub=mass_kg * config.projectile.material.price_per_kg,
|
||||||
|
note=f"⌀{config.projectile.diameter_m * 1000:.1f}мм x {config.projectile.length_m * 1000:.1f}мм",
|
||||||
|
url=config.projectile.material.url,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return lines
|
||||||
|
|
||||||
|
|
||||||
|
def total_cost_rub(lines: list[BomLine]) -> float:
|
||||||
|
return sum(line.total_price_rub for line in lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_bom_text(lines: list[BomLine]) -> str:
|
||||||
|
rows = ["Ступень\tДеталь\tКол-во\tЦена/ед\tИтого\tПримечание\tКупить"]
|
||||||
|
for line in lines:
|
||||||
|
stage_label = "-" if line.stage_index is None else str(line.stage_index)
|
||||||
|
rows.append(
|
||||||
|
f"{stage_label}\t{line.part}\t{line.quantity}\t{line.unit_price_rub:.2f}₽\t"
|
||||||
|
f"{line.total_price_rub:.2f}₽\t{line.note}\t{line.url or ''}"
|
||||||
|
)
|
||||||
|
rows.append(f"\t\t\t\tИТОГО: {total_cost_rub(lines):.2f}₽\t\t")
|
||||||
|
return "\n".join(rows)
|
||||||
105
src/gausse/report/plots.py
Normal file
105
src/gausse/report/plots.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Графики одного прогона: ток/поле каждой катушки по времени, скорость по положению.
|
||||||
|
|
||||||
|
Использует Agg-бэкенд matplotlib явно — код должен работать без дисплея
|
||||||
|
(в Docker/на сервере), а не только на машине разработчика.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from gausse.physics.force import solenoid_field_estimate_tesla
|
||||||
|
from gausse.sim.coilgun import CoilgunResult
|
||||||
|
|
||||||
|
|
||||||
|
def _stage_global_time(outcome) -> np.ndarray | None:
|
||||||
|
r = outcome.result
|
||||||
|
if r.discharge_t is None:
|
||||||
|
return None
|
||||||
|
return outcome.time_offset_s + (r.t_fire_s or 0.0) + r.discharge_t
|
||||||
|
|
||||||
|
|
||||||
|
def _legend_or_no_data_note(ax) -> None:
|
||||||
|
if ax.get_legend_handles_labels()[0]:
|
||||||
|
ax.legend()
|
||||||
|
else:
|
||||||
|
ax.text(
|
||||||
|
0.5, 0.5, "нет данных: прогон нереализуем ни на одной ступени",
|
||||||
|
ha="center", va="center", transform=ax.transAxes,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def plot_stage_currents(result: CoilgunResult):
|
||||||
|
fig, ax = plt.subplots()
|
||||||
|
for outcome in result.stage_outcomes:
|
||||||
|
t = _stage_global_time(outcome)
|
||||||
|
if t is None:
|
||||||
|
continue
|
||||||
|
ax.plot(t * 1e3, outcome.result.discharge_i, label=f"ступень {outcome.stage_index}")
|
||||||
|
ax.set_xlabel("время, мс")
|
||||||
|
ax.set_ylabel("ток катушки, А")
|
||||||
|
ax.set_title("Ток разряда по ступеням")
|
||||||
|
_legend_or_no_data_note(ax)
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_stage_fields(result: CoilgunResult):
|
||||||
|
"""Оценка магнитного поля катушки B(t) = MU_0*mu_eff*n*I(t) по ступеням."""
|
||||||
|
fig, ax = plt.subplots()
|
||||||
|
for outcome in result.stage_outcomes:
|
||||||
|
r = outcome.result
|
||||||
|
t = _stage_global_time(outcome)
|
||||||
|
if t is None or r.mu_eff is None:
|
||||||
|
continue
|
||||||
|
b_field = np.array(
|
||||||
|
[
|
||||||
|
solenoid_field_estimate_tesla(r.mu_eff, r.total_turns, r.coil_length_m, i)
|
||||||
|
for i in r.discharge_i
|
||||||
|
]
|
||||||
|
)
|
||||||
|
ax.plot(t * 1e3, b_field, label=f"ступень {outcome.stage_index}")
|
||||||
|
ax.set_xlabel("время, мс")
|
||||||
|
ax.set_ylabel("оценка поля в катушке, Тл")
|
||||||
|
ax.set_title("Магнитное поле по ступеням (грубая соленоидная оценка)")
|
||||||
|
_legend_or_no_data_note(ax)
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def plot_velocity_vs_position(result: CoilgunResult):
|
||||||
|
fig, ax = plt.subplots()
|
||||||
|
for outcome in result.stage_outcomes:
|
||||||
|
r = outcome.result
|
||||||
|
if r.discharge_x is None:
|
||||||
|
continue
|
||||||
|
global_x = outcome.global_coil_center_m + r.discharge_x
|
||||||
|
ax.plot(global_x * 1e3, r.discharge_v, label=f"ступень {outcome.stage_index}")
|
||||||
|
ax.set_xlabel("положение снаряда, мм (сквозная координата)")
|
||||||
|
ax.set_ylabel("скорость, м/с")
|
||||||
|
ax.set_title("Скорость снаряда по положению")
|
||||||
|
_legend_or_no_data_note(ax)
|
||||||
|
fig.tight_layout()
|
||||||
|
return fig
|
||||||
|
|
||||||
|
|
||||||
|
def save_all(result: CoilgunResult, out_dir) -> list[str]:
|
||||||
|
out_dir = Path(out_dir)
|
||||||
|
out_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
saved = []
|
||||||
|
for name, builder in (
|
||||||
|
("current.png", plot_stage_currents),
|
||||||
|
("field.png", plot_stage_fields),
|
||||||
|
("velocity_vs_position.png", plot_velocity_vs_position),
|
||||||
|
):
|
||||||
|
fig = builder(result)
|
||||||
|
path = out_dir / name
|
||||||
|
fig.savefig(path, dpi=120)
|
||||||
|
plt.close(fig)
|
||||||
|
saved.append(str(path))
|
||||||
|
return saved
|
||||||
76
src/gausse/report/summary.py
Normal file
76
src/gausse/report/summary.py
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
"""Человеко-читаемая + JSON сводка по прогону — включая явные ограничения модели."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.report.bom import build_bom, total_cost_rub
|
||||||
|
from gausse.sim.coilgun import CoilgunConfig, CoilgunResult
|
||||||
|
|
||||||
|
MODEL_LIMITATIONS = [
|
||||||
|
"Насыщение сердечника не встроено в динамику (только диагностика "
|
||||||
|
"StageResult.saturation_warning) -- см. историю разработки Этапа 3.",
|
||||||
|
"Вихревые токи, гистерезис и скин-эффект провода на высокой частоте не учтены.",
|
||||||
|
"Профиль перекрытия катушка/снаряд L(x) -- сглаженная аппроксимация "
|
||||||
|
"(tanh-функция), не точная картина поля из FEA-расчёта.",
|
||||||
|
"Часть цен в базе компонентов -- оценка, а не подтверждённая розничная "
|
||||||
|
"цена (см. поле `source` каждой записи в components/data/*.json).",
|
||||||
|
"Нагрев провода/конденсаторов при повторных выстрелах не моделируется "
|
||||||
|
"(рассчитан одиночный холодный выстрел).",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def build_summary(result: CoilgunResult, config: CoilgunConfig, db: ComponentDatabase) -> dict:
|
||||||
|
bom_lines = build_bom(config, db)
|
||||||
|
saturation_warnings = [
|
||||||
|
outcome.stage_index
|
||||||
|
for outcome in result.stage_outcomes
|
||||||
|
if outcome.result.saturation_warning
|
||||||
|
]
|
||||||
|
return {
|
||||||
|
"feasible": result.feasible,
|
||||||
|
"reason": result.reason,
|
||||||
|
"failed_stage_index": result.failed_stage_index,
|
||||||
|
"n_stages": len(config.stages),
|
||||||
|
"exit_velocity_mps": result.exit_v_mps,
|
||||||
|
"exit_kinetic_energy_j": result.exit_kinetic_energy_j,
|
||||||
|
"efficiency": result.efficiency,
|
||||||
|
"total_energy_in_j": result.total_energy_in_j,
|
||||||
|
"total_energy_dissipated_j": result.total_energy_dissipated_j,
|
||||||
|
"total_kinetic_energy_delta_j": result.total_kinetic_energy_delta_j,
|
||||||
|
"cost_rub": total_cost_rub(bom_lines),
|
||||||
|
"sensor_types_by_stage": [s.sensor.kind for s in config.stages],
|
||||||
|
"stages_with_saturation_warning": saturation_warnings,
|
||||||
|
"model_limitations": MODEL_LIMITATIONS,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def render_summary_text(summary: dict) -> str:
|
||||||
|
lines = []
|
||||||
|
if summary["feasible"]:
|
||||||
|
lines.append("Результат: РЕАЛИЗУЕМО")
|
||||||
|
lines.append(f" Ступеней: {summary['n_stages']}")
|
||||||
|
lines.append(f" Скорость на выходе: {summary['exit_velocity_mps']:.2f} м/с")
|
||||||
|
lines.append(f" КПД: {summary['efficiency'] * 100:.2f}%")
|
||||||
|
lines.append(f" Энергия (вход/потери/кинетика): "
|
||||||
|
f"{summary['total_energy_in_j']:.2f} / "
|
||||||
|
f"{summary['total_energy_dissipated_j']:.2f} / "
|
||||||
|
f"{summary['total_kinetic_energy_delta_j']:.2f} Дж")
|
||||||
|
else:
|
||||||
|
lines.append("Результат: НЕ РЕАЛИЗУЕМО")
|
||||||
|
lines.append(f" Провал на ступени {summary['failed_stage_index']}: {summary['reason']}")
|
||||||
|
lines.append(f" Стоимость деталей: {summary['cost_rub']:.2f}₽")
|
||||||
|
lines.append(f" Типы датчиков по ступеням: {summary['sensor_types_by_stage']}")
|
||||||
|
if summary["stages_with_saturation_warning"]:
|
||||||
|
lines.append(
|
||||||
|
f" ВНИМАНИЕ: возможное насыщение сердечника на ступенях "
|
||||||
|
f"{summary['stages_with_saturation_warning']} (см. ограничения модели)"
|
||||||
|
)
|
||||||
|
lines.append("")
|
||||||
|
lines.append("Ограничения модели:")
|
||||||
|
for item in summary["model_limitations"]:
|
||||||
|
lines.append(f" - {item}")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def render_summary_json(summary: dict) -> str:
|
||||||
|
return json.dumps(summary, ensure_ascii=False, indent=2)
|
||||||
@@ -34,6 +34,8 @@ class StageOutcome:
|
|||||||
result: StageResult
|
result: StageResult
|
||||||
global_coil_center_m: float
|
global_coil_center_m: float
|
||||||
time_offset_s: float
|
time_offset_s: float
|
||||||
|
entry_x_m: float
|
||||||
|
entry_v_mps: float
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -76,6 +78,8 @@ def run_coilgun(config: CoilgunConfig) -> CoilgunResult:
|
|||||||
result=result,
|
result=result,
|
||||||
global_coil_center_m=global_coil_center_m,
|
global_coil_center_m=global_coil_center_m,
|
||||||
time_offset_s=time_offset_s,
|
time_offset_s=time_offset_s,
|
||||||
|
entry_x_m=entry_x_m,
|
||||||
|
entry_v_mps=entry_v_mps,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -105,7 +109,13 @@ def run_coilgun(config: CoilgunConfig) -> CoilgunResult:
|
|||||||
entry_x_m, entry_v_mps = result.exit_x_m, result.exit_v_mps
|
entry_x_m, entry_v_mps = result.exit_x_m, result.exit_v_mps
|
||||||
|
|
||||||
exit_kinetic_energy_j = 0.5 * config.projectile.mass_kg * entry_v_mps**2
|
exit_kinetic_energy_j = 0.5 * config.projectile.mass_kg * entry_v_mps**2
|
||||||
efficiency = exit_kinetic_energy_j / total_energy_in_j if total_energy_in_j > 0 else None
|
# КПД = энергия, добавленная катушками (kinetic_energy_delta), а не вся
|
||||||
|
# exit-кинетическая энергия -- иначе фиксированный внешний "толчок"
|
||||||
|
# initial_v_mps (не учтённый в energy_in) может дать КПД > 100%, что
|
||||||
|
# физически бессмысленно. per-stage энергобаланс (energy_in =
|
||||||
|
# remaining_cap + dissipated + kinetic_delta) гарантирует
|
||||||
|
# kinetic_delta <= energy_in, так что этот КПД всегда <= 1.
|
||||||
|
efficiency = total_kinetic_energy_delta_j / total_energy_in_j if total_energy_in_j > 0 else None
|
||||||
|
|
||||||
return CoilgunResult(
|
return CoilgunResult(
|
||||||
feasible=True,
|
feasible=True,
|
||||||
|
|||||||
@@ -28,7 +28,16 @@ from gausse.physics.inductance import (
|
|||||||
effective_permeability,
|
effective_permeability,
|
||||||
winding_geometry,
|
winding_geometry,
|
||||||
)
|
)
|
||||||
|
from gausse.physics.ac_resistance import dowell_ac_factor, iron_penetration_fraction
|
||||||
|
from gausse.physics.constants import (
|
||||||
|
AIR_DENSITY_KG_M3,
|
||||||
|
DRAG_COEFF_CYLINDER,
|
||||||
|
FRICTION_COEFF_SLUG_TUBE,
|
||||||
|
G_ACCEL_M_S2,
|
||||||
|
switch_pulse_limit_a,
|
||||||
|
)
|
||||||
from gausse.physics.force import saturation_scale, solenoid_field_estimate_tesla
|
from gausse.physics.force import saturation_scale, solenoid_field_estimate_tesla
|
||||||
|
from gausse.physics.losses import eddy_reflected_resistance_ohm
|
||||||
from gausse.physics.sensors import (
|
from gausse.physics.sensors import (
|
||||||
inductive_signal_peak_v,
|
inductive_signal_peak_v,
|
||||||
make_inductive_sensor_event,
|
make_inductive_sensor_event,
|
||||||
@@ -83,6 +92,9 @@ class StageResult:
|
|||||||
kinetic_energy_delta_j: float | None = None
|
kinetic_energy_delta_j: float | None = None
|
||||||
saturation_warning: bool = False
|
saturation_warning: bool = False
|
||||||
peak_field_estimate_tesla: float | None = None
|
peak_field_estimate_tesla: float | None = None
|
||||||
|
mu_eff: float | None = None
|
||||||
|
total_turns: int | None = None
|
||||||
|
coil_length_m: float | None = None
|
||||||
discharge_t: np.ndarray | None = None
|
discharge_t: np.ndarray | None = None
|
||||||
discharge_q: np.ndarray | None = None
|
discharge_q: np.ndarray | None = None
|
||||||
discharge_i: np.ndarray | None = None
|
discharge_i: np.ndarray | None = None
|
||||||
@@ -109,8 +121,116 @@ def _switch_equivalent_resistance_ohm(
|
|||||||
return drop_v / max(i_ref, 1e-6)
|
return drop_v / max(i_ref, 1e-6)
|
||||||
|
|
||||||
|
|
||||||
def _ballistic_derivatives(t: float, state: np.ndarray) -> list[float]:
|
def _coast_derivatives(t: float, state: np.ndarray, retard_const_n: float, drag_coeff: float, mass_kg: float) -> list[float]:
|
||||||
return [state[1], 0.0]
|
"""Подлёт к датчику: не баллистика в вакууме, а с трением о трубку и воздухом.
|
||||||
|
|
||||||
|
Трение сглажено tanh'ом (см. circuit.FRICTION_SMOOTHING_V_MPS) — разрывный
|
||||||
|
sign(v) заставляет адаптивный решатель бесконечно дробить шаг у v≈0.
|
||||||
|
"""
|
||||||
|
v = state[1]
|
||||||
|
retard = (retard_const_n + drag_coeff * v * v) * math.tanh(v / circuit.FRICTION_SMOOTHING_V_MPS)
|
||||||
|
return [v, -retard / mass_kg]
|
||||||
|
|
||||||
|
|
||||||
|
def _stall_event(t: float, state: np.ndarray, *_args) -> float:
|
||||||
|
return state[1] - 1e-3 # снаряд практически остановился (трение съело скорость)
|
||||||
|
|
||||||
|
|
||||||
|
_stall_event.terminal = True
|
||||||
|
_stall_event.direction = -1.0
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StagePhysics:
|
||||||
|
"""Физика ступени: индуктивная модель + параметры контура + геометрия.
|
||||||
|
|
||||||
|
Один источник истины для CPU-пути (run_stage) и GPU-батча — оба строят
|
||||||
|
физику через `build_stage_physics`, чтобы числа не расходились.
|
||||||
|
"""
|
||||||
|
|
||||||
|
inductance_model: CoilInductanceModel
|
||||||
|
circuit_params: StageCircuitParams
|
||||||
|
geometry: object
|
||||||
|
r_total_ohm: float
|
||||||
|
r_eddy_coeff_ohm: float
|
||||||
|
capacitance_f: float
|
||||||
|
mu_eff: float
|
||||||
|
r_wire_dc_ohm: float = 0.0
|
||||||
|
r_ac_factor: float = 1.0 # R_AC/R_DC обмотки (скин + близость, Доуэлл)
|
||||||
|
|
||||||
|
|
||||||
|
def build_stage_physics(stage: StageConfig, projectile: ProjectileConfig) -> StagePhysics:
|
||||||
|
wire_od_m = stage.wire.insulation_od_mm / 1000
|
||||||
|
geometry = winding_geometry(stage.tube_od_m, wire_od_m, stage.turns_per_layer, stage.layers)
|
||||||
|
r_wire_dc = _wire_resistance_ohm(stage.wire, geometry.total_wire_length_m)
|
||||||
|
capacitance_f = stage.capacitor.capacitance_uf * 1e-6
|
||||||
|
l_air_h = air_core_inductance_wheeler(
|
||||||
|
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
|
||||||
|
)
|
||||||
|
char_omega = 1.0 / math.sqrt(l_air_h * capacitance_f)
|
||||||
|
# скин + эффект близости: на частоте импульса обмотка сопротивляется
|
||||||
|
# сильнее, чем по DC (для толстого провода во многих слоях — в разы)
|
||||||
|
r_ac_factor = dowell_ac_factor(
|
||||||
|
stage.wire.gauge_mm / 1000, wire_od_m, stage.layers, stage.wire.resistivity_ohm_m, char_omega
|
||||||
|
)
|
||||||
|
r_wire = r_wire_dc * r_ac_factor
|
||||||
|
r_switch = _switch_equivalent_resistance_ohm(
|
||||||
|
stage.switch, capacitance_f, l_air_h, stage.charge_voltage_v
|
||||||
|
)
|
||||||
|
r_total_ohm = r_wire + r_switch + stage.capacitor.esr_ohm
|
||||||
|
demag = demagnetizing_factor_prolate(projectile.aspect_ratio)
|
||||||
|
mu_eff = effective_permeability(projectile.material.mu_r, demag)
|
||||||
|
r_eddy_coeff = eddy_reflected_resistance_ohm(
|
||||||
|
slug_radius_m=projectile.diameter_m / 2,
|
||||||
|
slug_length_m=projectile.length_m,
|
||||||
|
slug_resistivity_ohm_m=projectile.material.resistivity_ohm_m,
|
||||||
|
mu_eff=mu_eff,
|
||||||
|
total_turns=geometry.total_turns,
|
||||||
|
coil_length_m=geometry.coil_length_m,
|
||||||
|
char_omega_rad_s=char_omega,
|
||||||
|
)
|
||||||
|
# доля сечения катушки, реально занятая железом (см. inductance.py):
|
||||||
|
# тонкий снаряд в толстой катушке не может усиливать ВЕСЬ поток;
|
||||||
|
# и из этого железа за импульс намагничивается только скин-кольцо
|
||||||
|
# глубиной δ (диффузия поля, см. ac_resistance.iron_penetration_fraction)
|
||||||
|
fill = min((projectile.diameter_m / 2) ** 2 / geometry.mean_radius_m**2, 1.0)
|
||||||
|
fill *= iron_penetration_fraction(
|
||||||
|
projectile.diameter_m / 2,
|
||||||
|
projectile.material.resistivity_ohm_m,
|
||||||
|
projectile.material.mu_r,
|
||||||
|
char_omega,
|
||||||
|
)
|
||||||
|
inductance_model = CoilInductanceModel(
|
||||||
|
l_air_h=l_air_h,
|
||||||
|
coil_length_m=geometry.coil_length_m,
|
||||||
|
slug_length_m=projectile.length_m,
|
||||||
|
mu_eff=mu_eff,
|
||||||
|
smoothing_width_m=wire_od_m,
|
||||||
|
total_turns=geometry.total_turns,
|
||||||
|
b_sat_tesla=projectile.material.b_sat_tesla,
|
||||||
|
iron_fill_factor=fill,
|
||||||
|
)
|
||||||
|
mass_kg = projectile.mass_kg
|
||||||
|
frontal_area_m2 = math.pi * (projectile.diameter_m / 2) ** 2
|
||||||
|
circuit_params = StageCircuitParams(
|
||||||
|
capacitance_f=capacitance_f,
|
||||||
|
r_total_ohm=r_total_ohm,
|
||||||
|
mass_kg=mass_kg,
|
||||||
|
r_eddy_coeff_ohm=r_eddy_coeff,
|
||||||
|
retard_const_n=FRICTION_COEFF_SLUG_TUBE * mass_kg * G_ACCEL_M_S2,
|
||||||
|
drag_coeff_n_per_mps2=0.5 * AIR_DENSITY_KG_M3 * DRAG_COEFF_CYLINDER * frontal_area_m2,
|
||||||
|
)
|
||||||
|
return StagePhysics(
|
||||||
|
inductance_model=inductance_model,
|
||||||
|
circuit_params=circuit_params,
|
||||||
|
geometry=geometry,
|
||||||
|
r_total_ohm=r_total_ohm,
|
||||||
|
r_eddy_coeff_ohm=r_eddy_coeff,
|
||||||
|
capacitance_f=capacitance_f,
|
||||||
|
mu_eff=mu_eff,
|
||||||
|
r_wire_dc_ohm=r_wire_dc,
|
||||||
|
r_ac_factor=r_ac_factor,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def run_stage(
|
def run_stage(
|
||||||
@@ -120,27 +240,28 @@ def run_stage(
|
|||||||
projectile: ProjectileConfig,
|
projectile: ProjectileConfig,
|
||||||
) -> StageResult:
|
) -> StageResult:
|
||||||
mass_kg = projectile.mass_kg
|
mass_kg = projectile.mass_kg
|
||||||
|
phys = build_stage_physics(stage, projectile)
|
||||||
|
geometry = phys.geometry
|
||||||
|
r_total_ohm = phys.r_total_ohm
|
||||||
|
r_eddy_coeff = phys.r_eddy_coeff_ohm
|
||||||
|
capacitance_f = phys.capacitance_f
|
||||||
|
mu_eff = phys.mu_eff
|
||||||
|
inductance_model = phys.inductance_model
|
||||||
wire_od_m = stage.wire.insulation_od_mm / 1000
|
wire_od_m = stage.wire.insulation_od_mm / 1000
|
||||||
geometry = winding_geometry(stage.tube_od_m, wire_od_m, stage.turns_per_layer, stage.layers)
|
|
||||||
|
|
||||||
r_wire = _wire_resistance_ohm(stage.wire, geometry.total_wire_length_m)
|
# Вырожденный контур — вне области применимости модели: при постоянной
|
||||||
capacitance_f = stage.capacitor.capacitance_uf * 1e-6
|
# времени L/R (с учётом вихревых при полном перекрытии) короче ~100 нс
|
||||||
l_air_h = air_core_inductance_wheeler(
|
# (например, катушка в несколько витков длиной пару мм) ОДУ становится
|
||||||
geometry.mean_radius_m, geometry.coil_length_m, geometry.radial_depth_m, geometry.total_turns
|
# неинтегрируемо жёсткой (решатель молотит минутами), а сама инженерная
|
||||||
)
|
# модель индуктивности/вихрей там уже не имеет смысла. Честный быстрый отказ.
|
||||||
r_switch = _switch_equivalent_resistance_ohm(
|
tau_worst_s = inductance_model.l_air_h / (r_total_ohm + r_eddy_coeff + 1e-12)
|
||||||
stage.switch, capacitance_f, l_air_h, stage.charge_voltage_v
|
if tau_worst_s < 1e-7:
|
||||||
)
|
return StageResult(
|
||||||
r_total_ohm = r_wire + r_switch + stage.capacitor.esr_ohm
|
feasible=False,
|
||||||
|
reason=(
|
||||||
demag = demagnetizing_factor_prolate(projectile.aspect_ratio)
|
f"вырожденная катушка: постоянная времени контура {tau_worst_s*1e9:.1f} нс "
|
||||||
mu_eff = effective_permeability(projectile.material.mu_r, demag)
|
f"(< 100 нс) — вне области применимости модели"
|
||||||
inductance_model = CoilInductanceModel(
|
),
|
||||||
l_air_h=l_air_h,
|
|
||||||
coil_length_m=geometry.coil_length_m,
|
|
||||||
slug_length_m=projectile.length_m,
|
|
||||||
mu_eff=mu_eff,
|
|
||||||
smoothing_width_m=wire_od_m,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
x_sensor_m = -stage.sensor_to_coil_distance_m
|
x_sensor_m = -stage.sensor_to_coil_distance_m
|
||||||
@@ -165,14 +286,21 @@ def run_stage(
|
|||||||
else:
|
else:
|
||||||
sensor_event = make_optical_sensor_event(x_sensor_m)
|
sensor_event = make_optical_sensor_event(x_sensor_m)
|
||||||
|
|
||||||
|
cp = phys.circuit_params
|
||||||
flight_sol = solve_ivp(
|
flight_sol = solve_ivp(
|
||||||
_ballistic_derivatives,
|
_coast_derivatives,
|
||||||
(0.0, stage.sensor_time_budget_s),
|
(0.0, stage.sensor_time_budget_s),
|
||||||
[entry_x_m, entry_v_mps],
|
[entry_x_m, entry_v_mps],
|
||||||
events=sensor_event,
|
args=(cp.retard_const_n, cp.drag_coeff_n_per_mps2, mass_kg),
|
||||||
|
events=(sensor_event, _stall_event),
|
||||||
rtol=1e-8,
|
rtol=1e-8,
|
||||||
atol=1e-10,
|
atol=1e-10,
|
||||||
)
|
)
|
||||||
|
if len(flight_sol.t_events[1]) > 0 and len(flight_sol.t_events[0]) == 0:
|
||||||
|
return StageResult(
|
||||||
|
feasible=False,
|
||||||
|
reason="снаряд остановлен трением о трубку, не долетев до датчика",
|
||||||
|
)
|
||||||
if len(flight_sol.t_events[0]) == 0:
|
if len(flight_sol.t_events[0]) == 0:
|
||||||
return StageResult(
|
return StageResult(
|
||||||
feasible=False,
|
feasible=False,
|
||||||
@@ -186,40 +314,57 @@ def run_stage(
|
|||||||
x_fire_m = x_at_sensor + v_at_sensor * fire_delay_s
|
x_fire_m = x_at_sensor + v_at_sensor * fire_delay_s
|
||||||
v_fire_mps = v_at_sensor
|
v_fire_mps = v_at_sensor
|
||||||
|
|
||||||
circuit_params = StageCircuitParams(
|
circuit_params = phys.circuit_params
|
||||||
capacitance_f=capacitance_f,
|
|
||||||
r_total_ohm=r_total_ohm,
|
|
||||||
mass_kg=mass_kg,
|
|
||||||
mu_eff=mu_eff,
|
|
||||||
total_turns=geometry.total_turns,
|
|
||||||
coil_length_m=geometry.coil_length_m,
|
|
||||||
b_sat_tesla=projectile.material.b_sat_tesla,
|
|
||||||
)
|
|
||||||
q0 = capacitance_f * stage.charge_voltage_v
|
q0 = capacitance_f * stage.charge_voltage_v
|
||||||
|
# Тиристор проводит ОДИН импульс: разряд обрывается на первом возврате тока
|
||||||
|
# к нулю ИЛИ на первом локальном минимуме тока (снаряд начал подкачивать ток
|
||||||
|
# обратно) — что раньше. Иначе модель гоняла бы катушку несколько раз за
|
||||||
|
# выстрел, что физически невозможно (конденсатор не перезарядить за мкс).
|
||||||
discharge_sol = solve_ivp(
|
discharge_sol = solve_ivp(
|
||||||
circuit.derivatives,
|
circuit.derivatives,
|
||||||
(0.0, stage.discharge_time_budget_s),
|
(0.0, stage.discharge_time_budget_s),
|
||||||
[q0, 0.0, x_fire_m, v_fire_mps],
|
[q0, 0.0, x_fire_m, v_fire_mps],
|
||||||
args=(inductance_model, circuit_params),
|
args=(inductance_model, circuit_params),
|
||||||
events=circuit.zero_current_crossing_event,
|
events=(
|
||||||
|
circuit.zero_current_crossing_event,
|
||||||
|
circuit.current_local_min_event,
|
||||||
|
# ключ закрывается сам при токе ниже удержания (см. circuit.py)
|
||||||
|
circuit.make_current_decay_event(),
|
||||||
|
),
|
||||||
rtol=1e-8,
|
rtol=1e-8,
|
||||||
atol=1e-11,
|
atol=1e-11,
|
||||||
)
|
)
|
||||||
if len(discharge_sol.t_events[0]) == 0:
|
# какое из событий оборвало разряд первым
|
||||||
|
term_candidates = []
|
||||||
|
for ev_idx in range(3):
|
||||||
|
if len(discharge_sol.t_events[ev_idx]) > 0:
|
||||||
|
term_candidates.append((discharge_sol.t_events[ev_idx][0], discharge_sol.y_events[ev_idx][0]))
|
||||||
|
if not term_candidates:
|
||||||
return StageResult(
|
return StageResult(
|
||||||
feasible=False,
|
feasible=False,
|
||||||
reason="разряд не скоммутировался (ток не вернулся к нулю) в пределах временного бюджета",
|
reason="разряд не скоммутировался (ток не вернулся к нулю) в пределах временного бюджета",
|
||||||
t_sensor_s=t_sensor_s,
|
t_sensor_s=t_sensor_s,
|
||||||
t_fire_s=t_sensor_s + fire_delay_s,
|
t_fire_s=t_sensor_s + fire_delay_s,
|
||||||
)
|
)
|
||||||
|
term_candidates.sort(key=lambda p: p[0])
|
||||||
q_final, i_final, x_final, v_final = discharge_sol.y_events[0][0]
|
q_final, i_final, x_final, v_final = term_candidates[0][1]
|
||||||
|
|
||||||
energy_in_j = 0.5 * capacitance_f * stage.charge_voltage_v**2
|
energy_in_j = 0.5 * capacitance_f * stage.charge_voltage_v**2
|
||||||
energy_remaining_cap_j = q_final**2 / (2 * capacitance_f)
|
energy_remaining_cap_j = q_final**2 / (2 * capacitance_f)
|
||||||
energy_dissipated_j = float(
|
# потери = I²·R_eff(x), где R_eff включает вихревые потери снаряда (× overlap)
|
||||||
np.trapezoid(discharge_sol.y[1] ** 2 * r_total_ohm, discharge_sol.t)
|
overlap_during = inductance_model.overlap_fraction(discharge_sol.y[2])
|
||||||
|
r_eff_during = r_total_ohm + r_eddy_coeff * overlap_during
|
||||||
|
resistive_diss = float(np.trapezoid(discharge_sol.y[1] ** 2 * r_eff_during, discharge_sol.t))
|
||||||
|
# при обрыве на минимуме тока в катушке остаётся энергия магнитного поля —
|
||||||
|
# её гасит обратный диод (freewheel), учитываем как потери, чтобы энергия
|
||||||
|
# сходилась. Считаем ТОЧНО (с насыщением), а не ½LI².
|
||||||
|
residual_inductor_j = float(inductance_model.magnetic_energy(x_final, i_final))
|
||||||
|
# работа трения о трубку и воздуха ЗА РАЗРЯД: P = (F_тр + F_возд)·|v|
|
||||||
|
v_during = discharge_sol.y[3]
|
||||||
|
friction_diss = float(
|
||||||
|
np.trapezoid((cp.retard_const_n + cp.drag_coeff_n_per_mps2 * v_during**2) * np.abs(v_during), discharge_sol.t)
|
||||||
)
|
)
|
||||||
|
energy_dissipated_j = resistive_diss + residual_inductor_j + friction_diss
|
||||||
kinetic_before_j = 0.5 * mass_kg * v_fire_mps**2
|
kinetic_before_j = 0.5 * mass_kg * v_fire_mps**2
|
||||||
kinetic_after_j = 0.5 * mass_kg * v_final**2
|
kinetic_after_j = 0.5 * mass_kg * v_final**2
|
||||||
|
|
||||||
@@ -228,11 +373,33 @@ def run_stage(
|
|||||||
# материала снаряда — численные КПД/скорость в этом случае, вероятно,
|
# материала снаряда — численные КПД/скорость в этом случае, вероятно,
|
||||||
# завышены относительно реального железа.
|
# завышены относительно реального железа.
|
||||||
peak_current_a = float(np.max(np.abs(discharge_sol.y[1])))
|
peak_current_a = float(np.max(np.abs(discharge_sol.y[1])))
|
||||||
|
# поле оцениваем по НАСЫЩЕННОМУ эффективному току g(I) — теперь оно не
|
||||||
|
# улетает за B_sat, а упирается в него (см. inductance.py).
|
||||||
|
effective_field_current_a = inductance_model.effective_field_current_a(peak_current_a)
|
||||||
peak_field_estimate_tesla = solenoid_field_estimate_tesla(
|
peak_field_estimate_tesla = solenoid_field_estimate_tesla(
|
||||||
mu_eff, geometry.total_turns, geometry.coil_length_m, peak_current_a
|
mu_eff, geometry.total_turns, geometry.coil_length_m, effective_field_current_a
|
||||||
)
|
)
|
||||||
saturation_warning = (
|
# предупреждение теперь означает реальный заход в насыщение (ток > I_sat)
|
||||||
saturation_scale(peak_field_estimate_tesla, projectile.material.b_sat_tesla) < 1.0
|
saturation_warning = peak_current_a > inductance_model.saturation_current_a
|
||||||
|
|
||||||
|
# Жёсткая отбраковка по импульсному току: полупроводниковый ключ реально
|
||||||
|
# выходит из строя за пределами surge-рейтинга, а электролит перегревается.
|
||||||
|
# Конфигурация, где пиковый ток разряда это превышает, — не пушка, а
|
||||||
|
# уничтоженная деталь на первом выстреле, поэтому feasible=False.
|
||||||
|
# Ключ — жёсткий предел: полупроводник реально выходит из строя за surge.
|
||||||
|
# Батарея конденсаторов — только предупреждение (см. build_detail):
|
||||||
|
# электролиты переносят высокий импульсный ток, а в базе — паспортный
|
||||||
|
# непрерывный/ripple рейтинг, поэтому жёстко браковать по нему нечестно.
|
||||||
|
switch_surge_a = switch_pulse_limit_a(stage.switch)
|
||||||
|
if peak_current_a > switch_surge_a:
|
||||||
|
return StageResult(
|
||||||
|
feasible=False,
|
||||||
|
reason=(
|
||||||
|
f"пиковый ток {peak_current_a:.0f} А превышает импульсный предел ключа "
|
||||||
|
f"{stage.switch.part_number} ({switch_surge_a:.0f} А, даташит/surge)"
|
||||||
|
),
|
||||||
|
t_sensor_s=t_sensor_s,
|
||||||
|
t_fire_s=t_sensor_s + fire_delay_s,
|
||||||
)
|
)
|
||||||
|
|
||||||
return StageResult(
|
return StageResult(
|
||||||
@@ -247,6 +414,9 @@ def run_stage(
|
|||||||
kinetic_energy_delta_j=kinetic_after_j - kinetic_before_j,
|
kinetic_energy_delta_j=kinetic_after_j - kinetic_before_j,
|
||||||
saturation_warning=saturation_warning,
|
saturation_warning=saturation_warning,
|
||||||
peak_field_estimate_tesla=peak_field_estimate_tesla,
|
peak_field_estimate_tesla=peak_field_estimate_tesla,
|
||||||
|
mu_eff=mu_eff,
|
||||||
|
total_turns=geometry.total_turns,
|
||||||
|
coil_length_m=geometry.coil_length_m,
|
||||||
discharge_t=discharge_sol.t,
|
discharge_t=discharge_sol.t,
|
||||||
discharge_q=discharge_sol.y[0],
|
discharge_q=discharge_sol.y[0],
|
||||||
discharge_i=discharge_sol.y[1],
|
discharge_i=discharge_sol.y[1],
|
||||||
|
|||||||
298
src/gausse/storage/database.py
Normal file
298
src/gausse/storage/database.py
Normal file
@@ -0,0 +1,298 @@
|
|||||||
|
"""SQLite-хранилище прогонов: WAL-режим + однопроцессный писатель.
|
||||||
|
|
||||||
|
Множество воркеров (Stage 6, `ProcessPoolExecutor`) не пишут в SQLite
|
||||||
|
напрямую — каждый кладёт `RunRecord` в `multiprocessing.Queue`, а
|
||||||
|
единственный процесс-писатель (`run_writer_process`) читает очередь и
|
||||||
|
пишет в базу последовательно. Это исключает конкурентную запись в один
|
||||||
|
файл SQLite, которая иначе могла бы повредить базу или потерять прогоны.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, dataclass, fields
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.storage.schema import CREATE_INDEXES_SQL, CREATE_RUNS_TABLE_SQL, CREATE_STATS_SQL, RunRecord
|
||||||
|
|
||||||
|
_COLUMNS = [f.name for f in fields(RunRecord)]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class StatsOnly:
|
||||||
|
"""Лёгкое сообщение писателю: учесть оценку в агрегатах дашборда
|
||||||
|
(счётчики/гистограмма/причины), НЕ сохраняя строку в runs.
|
||||||
|
|
||||||
|
Эволюция шлёт такое для РЯДОВЫХ геномов поколения, а полной строкой
|
||||||
|
пишет только улучшения лучших (см. optim/evolutionary.py): хранить
|
||||||
|
миллионы проходных геномов незачем, а вставка каждого в HDD-базу с
|
||||||
|
6 индексами была узким местом всего конвейера (GPU считал цикл за
|
||||||
|
~23 мин, дозапись хвоста очереди шла часами при ~150 строк/с).
|
||||||
|
Атрибуты названы как у RunRecord — _apply_stats работает с обоими.
|
||||||
|
"""
|
||||||
|
|
||||||
|
search_mode: str
|
||||||
|
feasible: bool
|
||||||
|
efficiency: float | None
|
||||||
|
infeasible_reason: str | None
|
||||||
|
|
||||||
|
|
||||||
|
def open_connection(db_path: Path) -> sqlite3.Connection:
|
||||||
|
db_path = Path(db_path)
|
||||||
|
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
conn.execute("PRAGMA journal_mode=WAL")
|
||||||
|
# не давать WAL расти безгранично: при потоке вставок 400/с и постоянных
|
||||||
|
# читателях (веб) авто-чекпоинт голодает, WAL раздувался до гигабайт и
|
||||||
|
# замедлял ВСЕ запросы. Лимит + периодический чекпоинт в писателе.
|
||||||
|
conn.execute("PRAGMA journal_size_limit=67108864") # 64МБ
|
||||||
|
conn.execute(CREATE_RUNS_TABLE_SQL)
|
||||||
|
for statement in CREATE_INDEXES_SQL:
|
||||||
|
conn.execute(statement)
|
||||||
|
for statement in CREATE_STATS_SQL:
|
||||||
|
conn.execute(statement)
|
||||||
|
conn.commit()
|
||||||
|
return conn
|
||||||
|
|
||||||
|
|
||||||
|
def short_reason(reason: str | None) -> str | None:
|
||||||
|
"""Причина отказа без деталей конкретного прогона (для группировки)."""
|
||||||
|
return reason.split(":", 1)[0] if reason else reason
|
||||||
|
|
||||||
|
|
||||||
|
def _efficiency_bucket(efficiency: float | None) -> int | None:
|
||||||
|
"""Корзина гистограммы КПД 0..9 (та же логика, что раньше в SQL-агрегате)."""
|
||||||
|
if efficiency is None:
|
||||||
|
return None
|
||||||
|
b = min(int(efficiency * 10), 9)
|
||||||
|
return b if 0 <= b <= 9 else None
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_stats(conn: sqlite3.Connection, records: list) -> None:
|
||||||
|
"""Инкремент агрегатов дашборда — вызывается внутри транзакции вставки.
|
||||||
|
|
||||||
|
Принимает и RunRecord, и StatsOnly (одинаковые имена атрибутов)."""
|
||||||
|
n_feasible = sum(1 for r in records if r.feasible)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO stats_counters(key, value) VALUES('total', ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = value + excluded.value",
|
||||||
|
(len(records),),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO stats_counters(key, value) VALUES('feasible', ?) "
|
||||||
|
"ON CONFLICT(key) DO UPDATE SET value = value + excluded.value",
|
||||||
|
(n_feasible,),
|
||||||
|
)
|
||||||
|
hist: dict[int, int] = {}
|
||||||
|
reasons: dict[str, int] = {}
|
||||||
|
modes: dict[str, int] = {}
|
||||||
|
for r in records:
|
||||||
|
modes[r.search_mode] = modes.get(r.search_mode, 0) + 1
|
||||||
|
if r.feasible:
|
||||||
|
b = _efficiency_bucket(r.efficiency)
|
||||||
|
if b is not None:
|
||||||
|
hist[b] = hist.get(b, 0) + 1
|
||||||
|
elif r.infeasible_reason:
|
||||||
|
key = short_reason(r.infeasible_reason)
|
||||||
|
reasons[key] = reasons.get(key, 0) + 1
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO stats_hist(bucket, count) VALUES(?, ?) "
|
||||||
|
"ON CONFLICT(bucket) DO UPDATE SET count = count + excluded.count",
|
||||||
|
list(hist.items()),
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO stats_reasons(reason, count) VALUES(?, ?) "
|
||||||
|
"ON CONFLICT(reason) DO UPDATE SET count = count + excluded.count",
|
||||||
|
list(reasons.items()),
|
||||||
|
)
|
||||||
|
conn.executemany(
|
||||||
|
"INSERT INTO stats_modes(mode, count) VALUES(?, ?) "
|
||||||
|
"ON CONFLICT(mode) DO UPDATE SET count = count + excluded.count",
|
||||||
|
list(modes.items()),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_stats(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Пересобирает агрегаты из таблицы runs (миграция старой базы; один раз)."""
|
||||||
|
conn.execute("DELETE FROM stats_counters")
|
||||||
|
conn.execute("DELETE FROM stats_hist")
|
||||||
|
conn.execute("DELETE FROM stats_reasons")
|
||||||
|
conn.execute("DELETE FROM stats_modes")
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
||||||
|
feasible = conn.execute("SELECT COUNT(*) FROM runs WHERE feasible=1").fetchone()[0]
|
||||||
|
conn.execute("INSERT INTO stats_counters VALUES('total', ?)", (total,))
|
||||||
|
conn.execute("INSERT INTO stats_counters VALUES('feasible', ?)", (feasible,))
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO stats_hist SELECT b, COUNT(*) FROM "
|
||||||
|
"(SELECT MIN(CAST(efficiency*10 AS INT), 9) AS b FROM runs WHERE feasible=1 AND efficiency IS NOT NULL) "
|
||||||
|
"WHERE b BETWEEN 0 AND 9 GROUP BY b"
|
||||||
|
)
|
||||||
|
for reason, cnt in conn.execute(
|
||||||
|
"SELECT infeasible_reason, COUNT(*) FROM runs WHERE feasible=0 AND infeasible_reason IS NOT NULL "
|
||||||
|
"GROUP BY infeasible_reason"
|
||||||
|
).fetchall():
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO stats_reasons VALUES(?, ?) ON CONFLICT(reason) DO UPDATE SET count = count + excluded.count",
|
||||||
|
(short_reason(reason), cnt),
|
||||||
|
)
|
||||||
|
conn.execute("INSERT INTO stats_modes SELECT search_mode, COUNT(*) FROM runs GROUP BY search_mode")
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_stats(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Если агрегаты пусты, а прогоны есть — база со старой схемой: пересобрать."""
|
||||||
|
has_stats = conn.execute("SELECT COUNT(*) FROM stats_counters").fetchone()[0] > 0
|
||||||
|
if not has_stats and conn.execute("SELECT EXISTS(SELECT 1 FROM runs)").fetchone()[0]:
|
||||||
|
rebuild_stats(conn)
|
||||||
|
|
||||||
|
|
||||||
|
def _record_to_params(record: RunRecord) -> dict:
|
||||||
|
row = asdict(record)
|
||||||
|
row["feasible"] = int(row["feasible"])
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _row_to_record(row: sqlite3.Row) -> RunRecord:
|
||||||
|
data = dict(row)
|
||||||
|
data["feasible"] = bool(data["feasible"])
|
||||||
|
return RunRecord(**data)
|
||||||
|
|
||||||
|
|
||||||
|
def insert_run(conn: sqlite3.Connection, record: RunRecord) -> None:
|
||||||
|
insert_runs(conn, [record])
|
||||||
|
|
||||||
|
|
||||||
|
def insert_runs(conn: sqlite3.Connection, records: list) -> None:
|
||||||
|
"""Пишет пачку RunRecord/StatsOnly одной транзакцией: строки runs — только
|
||||||
|
для RunRecord, агрегаты дашборда — по ВСЕМ элементам пачки (либо всё,
|
||||||
|
либо ничего — иначе цифры на морде разъедутся с таблицей)."""
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
rows = [r for r in records if isinstance(r, RunRecord)]
|
||||||
|
if rows:
|
||||||
|
placeholders = ", ".join(f":{name}" for name in _COLUMNS)
|
||||||
|
columns = ", ".join(_COLUMNS)
|
||||||
|
conn.executemany(
|
||||||
|
f"INSERT INTO runs ({columns}) VALUES ({placeholders})",
|
||||||
|
[_record_to_params(r) for r in rows],
|
||||||
|
)
|
||||||
|
_apply_stats(conn, records)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def count_runs(conn: sqlite3.Connection, feasible: bool | None = None) -> int:
|
||||||
|
if feasible is None:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM runs")
|
||||||
|
else:
|
||||||
|
cursor = conn.execute("SELECT COUNT(*) FROM runs WHERE feasible = ?", (int(feasible),))
|
||||||
|
return cursor.fetchone()[0]
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_run_by_id(conn: sqlite3.Connection, run_id: str) -> RunRecord | None:
|
||||||
|
"""Одна запись по PRIMARY KEY — быстро (без загрузки всей таблицы)."""
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
row = conn.execute("SELECT * FROM runs WHERE run_id = ?", (run_id,)).fetchone()
|
||||||
|
return _row_to_record(row) if row is not None else None
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_runs(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
feasible: bool | None = None,
|
||||||
|
min_efficiency: float | None = None,
|
||||||
|
order_by_efficiency_desc: bool = False,
|
||||||
|
order_by_velocity_desc: bool = False,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list[RunRecord]:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
# топы дашборда: принуждаем частичный DESC-индекс (INDEXED BY) — иначе
|
||||||
|
# планировщик берёт idx_runs_feasible и сортирует сотни тысяч строк
|
||||||
|
# TEMP B-TREE'ом (~2с на запрос). Условие feasible=1 обязано быть
|
||||||
|
# ЛИТЕРАЛОМ: частичный индекс не матчится с bound-параметром.
|
||||||
|
forced_index = ""
|
||||||
|
if feasible is True and min_efficiency is None:
|
||||||
|
if order_by_velocity_desc:
|
||||||
|
forced_index = " INDEXED BY idx_runs_feas_vel"
|
||||||
|
elif order_by_efficiency_desc:
|
||||||
|
forced_index = " INDEXED BY idx_runs_feas_eff"
|
||||||
|
query = f"SELECT * FROM runs{forced_index} WHERE 1=1"
|
||||||
|
params: list = []
|
||||||
|
if feasible is not None:
|
||||||
|
query += f" AND feasible = {int(feasible)}"
|
||||||
|
if min_efficiency is not None:
|
||||||
|
query += " AND efficiency >= ?"
|
||||||
|
params.append(min_efficiency)
|
||||||
|
if order_by_velocity_desc:
|
||||||
|
query += " ORDER BY exit_velocity_mps DESC"
|
||||||
|
elif order_by_efficiency_desc:
|
||||||
|
query += " ORDER BY efficiency DESC"
|
||||||
|
if limit is not None:
|
||||||
|
query += " LIMIT ?"
|
||||||
|
params.append(limit)
|
||||||
|
cursor = conn.execute(query, params)
|
||||||
|
return [_row_to_record(row) for row in cursor.fetchall()]
|
||||||
|
|
||||||
|
|
||||||
|
def run_writer_process(queue, db_path: Path) -> None:
|
||||||
|
"""Читает `RunRecord`/`StatsOnly` из очереди и пишет в SQLite до None.
|
||||||
|
|
||||||
|
Предназначен для запуска как отдельный `multiprocessing.Process` —
|
||||||
|
единственный writer на файл базы, пока воркеры sweep/evolve только
|
||||||
|
кладут результаты в очередь.
|
||||||
|
|
||||||
|
ПАЧКАМИ, не по одной: коммит SQLite = fsync (~5-10мс), и по-одному
|
||||||
|
писатель выдавал ~150/с — GPU-эволюция (380+/с) копила хвост очереди в
|
||||||
|
RAM на минуты, который терялся при рестарте сервиса («сохраняем всё»
|
||||||
|
нарушалось). Теперь копим до BATCH записей (или пока очередь не
|
||||||
|
опустела) и вставляем одной транзакцией — тысячи вставок в секунду,
|
||||||
|
хвост исчезает.
|
||||||
|
|
||||||
|
SIGTERM игнорируется: при остановке сервиса systemd шлёт TERM всей
|
||||||
|
группе; писатель обязан дожить до None от главного процесса и дописать
|
||||||
|
ВСЁ, что уже оценено, а не умереть с полной очередью.
|
||||||
|
|
||||||
|
Одна "плохая" пачка (например, коллизия run_id) не роняет писатель:
|
||||||
|
откатываемся и вставляем пачку по одной, пропуская только виновника.
|
||||||
|
"""
|
||||||
|
import queue as queue_mod
|
||||||
|
import signal
|
||||||
|
import time as time_mod
|
||||||
|
|
||||||
|
signal.signal(signal.SIGTERM, signal.SIG_IGN)
|
||||||
|
BATCH = 500
|
||||||
|
CHECKPOINT_EVERY_S = 60.0
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
# если база старая (без агрегатов) — пересобрать ДО первых вставок,
|
||||||
|
# иначе счётчики начнут вестись только с нуля и разойдутся с таблицей
|
||||||
|
ensure_stats(conn)
|
||||||
|
last_checkpoint = time_mod.monotonic()
|
||||||
|
try:
|
||||||
|
finished = False
|
||||||
|
while not finished:
|
||||||
|
batch = [queue.get()] # блокируемся на первой
|
||||||
|
while len(batch) < BATCH:
|
||||||
|
try:
|
||||||
|
batch.append(queue.get_nowait())
|
||||||
|
except queue_mod.Empty:
|
||||||
|
break
|
||||||
|
if batch[-1] is None:
|
||||||
|
finished = True
|
||||||
|
batch.pop()
|
||||||
|
if not batch:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
insert_runs(conn, batch)
|
||||||
|
except sqlite3.Error:
|
||||||
|
conn.rollback()
|
||||||
|
for record in batch: # ищем виновника, остальное сохраняем
|
||||||
|
try:
|
||||||
|
insert_run(conn, record)
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
rid = getattr(record, "run_id", "<stats-only>")
|
||||||
|
print(f"[storage] не удалось записать run_id={rid}: {exc}", file=sys.stderr)
|
||||||
|
# авто-чекпоинт голодает при постоянном потоке — двигаем WAL сами,
|
||||||
|
# PASSIVE не блокирует читателей
|
||||||
|
if time_mod.monotonic() - last_checkpoint > CHECKPOINT_EVERY_S:
|
||||||
|
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
||||||
|
last_checkpoint = time_mod.monotonic()
|
||||||
|
finally:
|
||||||
|
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
|
||||||
|
conn.close()
|
||||||
82
src/gausse/storage/schema.py
Normal file
82
src/gausse/storage/schema.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
"""Схема таблицы `runs` + агрегаты дашборда.
|
||||||
|
|
||||||
|
Честность цифр — через агрегаты: счётчики/гистограмма/причины (stats_*)
|
||||||
|
инкрементируются для КАЖДОЙ оценки, включая нереализуемые, в той же
|
||||||
|
транзакции писателя. А вот полные СТРОКИ в `runs` пишутся по-разному:
|
||||||
|
|
||||||
|
- sweep/manual: каждая оценка — строка (исследовательский режим);
|
||||||
|
- evolve (с 2026-07-12): строка — только когда лучший геном острова
|
||||||
|
УЛУЧШИЛСЯ (+ полировка); рядовые миллионы геномов поколения идут в базу
|
||||||
|
как StatsOnly-инкременты счётчиков. Хранить их незачем («сохраняй то,
|
||||||
|
что нужно для эволюции»), а вставка каждого в HDD-базу с 6 индексами
|
||||||
|
была узким местом: GPU считал цикл ~23 мин, дозапись шла часами.
|
||||||
|
|
||||||
|
Нереализуемые не приукрашиваются: у строк feasible=0 + честная причина,
|
||||||
|
у агрегатов — счётчик причин отказов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
CREATE_RUNS_TABLE_SQL = """
|
||||||
|
CREATE TABLE IF NOT EXISTS runs (
|
||||||
|
run_id TEXT PRIMARY KEY,
|
||||||
|
timestamp TEXT NOT NULL,
|
||||||
|
search_mode TEXT NOT NULL,
|
||||||
|
genome_json TEXT NOT NULL,
|
||||||
|
-- ДЕШЁВАЯ сводка (objective.decoded_summary) — НЕ полная физика по
|
||||||
|
-- ступеням. genome_json сам по себе достаточен, чтобы воспроизвести
|
||||||
|
-- всё через decode()+build_detail() на лету (см. web/stats.run_detail);
|
||||||
|
-- хранить дорогую детализацию для каждого из миллионов прогонов
|
||||||
|
-- бессмысленно — так база росла на пустом месте (70ГБ на 12.9М строк).
|
||||||
|
-- В базах, созданных ДО 2026-07-11, эта колонка содержит старый полный
|
||||||
|
-- build_detail (другая форма записей ступеней) — учитывать при чтении.
|
||||||
|
decoded_summary_json TEXT NOT NULL,
|
||||||
|
feasible INTEGER NOT NULL,
|
||||||
|
infeasible_reason TEXT,
|
||||||
|
failed_stage_index INTEGER,
|
||||||
|
efficiency REAL,
|
||||||
|
exit_velocity_mps REAL,
|
||||||
|
cost_rub REAL,
|
||||||
|
energy_breakdown_json TEXT,
|
||||||
|
model_version TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
|
||||||
|
CREATE_INDEXES_SQL = [
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_runs_feasible ON runs(feasible)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_runs_efficiency ON runs(efficiency)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_runs_velocity ON runs(exit_velocity_mps)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_runs_search_mode ON runs(search_mode)",
|
||||||
|
# частичные индексы под топы дашборда: ORDER BY ... DESC при feasible=1
|
||||||
|
# без них top-15 на сотнях тысяч строк занимал ~1.2с на каждый рефреш
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_runs_feas_eff ON runs(efficiency DESC) WHERE feasible=1",
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_runs_feas_vel ON runs(exit_velocity_mps DESC) WHERE feasible=1",
|
||||||
|
]
|
||||||
|
|
||||||
|
# Инкрементальные агрегаты для дашборда: счётчики/гистограмма/причины/режимы
|
||||||
|
# обновляются В ТОЙ ЖЕ транзакции, что и вставка прогонов (insert_runs), и
|
||||||
|
# читаются за O(1) — иначе каждый рефреш морды сканировал ВСЮ таблицу runs
|
||||||
|
# (5.2с на 630к строк, а база растёт годами).
|
||||||
|
CREATE_STATS_SQL = [
|
||||||
|
"CREATE TABLE IF NOT EXISTS stats_counters (key TEXT PRIMARY KEY, value INTEGER NOT NULL)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS stats_hist (bucket INTEGER PRIMARY KEY, count INTEGER NOT NULL)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS stats_reasons (reason TEXT PRIMARY KEY, count INTEGER NOT NULL)",
|
||||||
|
"CREATE TABLE IF NOT EXISTS stats_modes (mode TEXT PRIMARY KEY, count INTEGER NOT NULL)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class RunRecord:
|
||||||
|
run_id: str
|
||||||
|
timestamp: str
|
||||||
|
search_mode: str # "sweep" | "evolve" | "manual"
|
||||||
|
genome_json: str
|
||||||
|
decoded_summary_json: str
|
||||||
|
feasible: bool
|
||||||
|
model_version: str
|
||||||
|
infeasible_reason: str | None = None
|
||||||
|
failed_stage_index: int | None = None
|
||||||
|
efficiency: float | None = None
|
||||||
|
exit_velocity_mps: float | None = None
|
||||||
|
cost_rub: float | None = None
|
||||||
|
energy_breakdown_json: str | None = None
|
||||||
0
src/gausse/web/__init__.py
Normal file
0
src/gausse/web/__init__.py
Normal file
206
src/gausse/web/page.py
Normal file
206
src/gausse/web/page.py
Normal file
@@ -0,0 +1,206 @@
|
|||||||
|
"""Самодостаточная HTML-страница дашборда (inline CSS+JS, без внешних ресурсов)."""
|
||||||
|
|
||||||
|
PAGE_HTML = r"""<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>gausse — ход эксперимента</title>
|
||||||
|
<style>
|
||||||
|
:root { color-scheme: dark; }
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||||
|
background:#0e1116; color:#e6edf3; font-size:14px; }
|
||||||
|
header { padding:14px 20px; background:#161b22; border-bottom:1px solid #30363d;
|
||||||
|
display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
|
||||||
|
header h1 { font-size:16px; margin:0; font-weight:600; }
|
||||||
|
.muted { color:#8b949e; }
|
||||||
|
.wrap { padding:20px; max-width:1200px; margin:0 auto; }
|
||||||
|
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:20px; }
|
||||||
|
.card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:14px; }
|
||||||
|
.card .k { font-size:12px; color:#8b949e; text-transform:uppercase; letter-spacing:.04em; }
|
||||||
|
.card .v { font-size:26px; font-weight:700; margin-top:4px; }
|
||||||
|
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
||||||
|
@media (max-width:840px){ .grid2 { grid-template-columns:1fr; } }
|
||||||
|
section h2 { font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:#8b949e; margin:0 0 10px; }
|
||||||
|
table { width:100%; border-collapse:collapse; font-variant-numeric:tabular-nums; }
|
||||||
|
th,td { text-align:left; padding:7px 8px; border-bottom:1px solid #21262d; }
|
||||||
|
th { color:#8b949e; font-weight:600; font-size:12px; }
|
||||||
|
tr.clk:hover { background:#1c2230; cursor:pointer; }
|
||||||
|
.tgl { font-size:11px; padding:2px 8px; margin-left:6px; background:#21262d; color:#8b949e;
|
||||||
|
border:1px solid #30363d; border-radius:6px; cursor:pointer; text-transform:none; letter-spacing:0; }
|
||||||
|
.tgl.active { background:#238636; color:#fff; border-color:#238636; }
|
||||||
|
.bar { height:16px; background:#238636; border-radius:3px; }
|
||||||
|
.hist td { padding:3px 8px; }
|
||||||
|
.log { background:#0a0d12; border:1px solid #30363d; border-radius:8px; padding:12px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px;
|
||||||
|
white-space:pre-wrap; max-height:320px; overflow:auto; line-height:1.5; }
|
||||||
|
.pill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; background:#21262d; }
|
||||||
|
#detail { margin-top:20px; background:#161b22; border:1px solid #30363d; border-radius:8px; padding:16px; display:none; }
|
||||||
|
#detail pre { white-space:pre-wrap; font-family: ui-monospace, monospace; font-size:12px; margin:0;
|
||||||
|
max-height:340px; overflow:auto; background:#0a0d12; padding:10px; border-radius:6px; }
|
||||||
|
.close { float:right; cursor:pointer; color:#8b949e; }
|
||||||
|
a { color:#58a6ff; }
|
||||||
|
.plots { display:grid; grid-template-columns:repeat(auto-fit,minmax(280px,1fr)); gap:12px; margin:12px 0; }
|
||||||
|
.plots img { width:100%; border:1px solid #30363d; border-radius:6px; background:#fff; }
|
||||||
|
.btn { display:inline-block; margin:8px 0; padding:6px 12px; background:#238636; color:#fff;
|
||||||
|
border:none; border-radius:6px; cursor:pointer; font-size:13px; }
|
||||||
|
#gifbox img { max-width:100%; border:1px solid #30363d; border-radius:6px; margin-top:8px; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<h1>⚡ gausse — ход эксперимента</h1>
|
||||||
|
<span class="muted" id="updated">загрузка…</span>
|
||||||
|
<span class="muted" id="err" style="color:#f85149"></span>
|
||||||
|
</header>
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card"><div class="k">Всего прогонов</div><div class="v" id="c-total">—</div></div>
|
||||||
|
<div class="card"><div class="k">Реализуемо</div><div class="v" id="c-feas">—</div></div>
|
||||||
|
<div class="card"><div class="k">Нереализуемо</div><div class="v" id="c-infeas">—</div></div>
|
||||||
|
<div class="card"><div class="k">Доля реализуемых</div><div class="v" id="c-pct">—</div></div>
|
||||||
|
<div class="card"><div class="k">Лучший КПД за всё время / скорость</div><div class="v" id="c-best" style="font-size:20px">—</div></div>
|
||||||
|
<div class="card"><div class="k">Лучший КПД текущего цикла</div><div class="v" id="c-cycle" style="font-size:20px">—</div></div>
|
||||||
|
<div class="card"><div class="k">GPU (GTX 1070)</div><div class="v" id="c-gpu" style="font-size:20px">—</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<section>
|
||||||
|
<h2>Топ конфигураций
|
||||||
|
<button class="tgl" id="tab-eff" onclick="setRank('eff')">по КПД</button>
|
||||||
|
<button class="tgl" id="tab-vel" onclick="setRank('vel')">по скорости</button>
|
||||||
|
</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><th>КПД</th><th>Скор., м/с</th><th>Ступ.</th><th>Цена, ₽</th><th>Режим</th><th>Когда найден</th></tr></thead>
|
||||||
|
<tbody id="top"></tbody>
|
||||||
|
</table>
|
||||||
|
<p class="muted" style="font-size:12px">Клик по строке — полная детализация прогона (расстояния, номиналы, физика).</p>
|
||||||
|
</section>
|
||||||
|
<section>
|
||||||
|
<h2>Распределение КПД (реализуемые)</h2>
|
||||||
|
<table class="hist"><tbody id="hist"></tbody></table>
|
||||||
|
<h2 style="margin-top:18px">Причины отказа</h2>
|
||||||
|
<table><tbody id="reasons"></tbody></table>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section style="margin-top:22px">
|
||||||
|
<h2>Лог процесса</h2>
|
||||||
|
<div class="log" id="log">—</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div id="detail"><span class="close" onclick="document.getElementById('detail').style.display='none'">✕ закрыть</span>
|
||||||
|
<h2 id="d-title"></h2>
|
||||||
|
<div class="plots" id="d-plots"></div>
|
||||||
|
<button class="btn" id="d-gifbtn">▶ показать анимацию пролёта (GIF)</button>
|
||||||
|
<div id="gifbox"></div>
|
||||||
|
<h2 style="margin-top:14px">Полная детализация (расстояния, батарея, физика, результат)</h2>
|
||||||
|
<pre id="d-body"></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const fmtPct = x => x==null ? "—" : (x*100).toFixed(2)+"%";
|
||||||
|
const fmt = (x,d=1) => x==null ? "—" : Number(x).toFixed(d);
|
||||||
|
let rankMode = 'eff'; // 'eff' | 'vel'
|
||||||
|
let lastOverview = null;
|
||||||
|
function setRank(m){ rankMode=m; if(lastOverview) renderTop(lastOverview);
|
||||||
|
document.getElementById('tab-eff').classList.toggle('active', m==='eff');
|
||||||
|
document.getElementById('tab-vel').classList.toggle('active', m==='vel'); }
|
||||||
|
|
||||||
|
// «Когда найден»: без этой колонки обновление топа было НЕВИДИМО — новая
|
||||||
|
// строка 13.63% глазом неотличима от старой 13.44%, и казалось, что таблица
|
||||||
|
// застыла. Свежие записи (< 1 часа) подсвечиваются.
|
||||||
|
const fmtWhen = ts => {
|
||||||
|
if(!ts) return "—";
|
||||||
|
const d = new Date(ts), ageMs = Date.now()-d.getTime();
|
||||||
|
const t = d.toLocaleString([], {day:'2-digit',month:'2-digit',hour:'2-digit',minute:'2-digit'});
|
||||||
|
return ageMs < 3600e3 ? `<b style="color:#3fb950">${t}</b>` : t;
|
||||||
|
};
|
||||||
|
function renderTop(o){
|
||||||
|
const rows = rankMode==='vel' ? (o.top_by_velocity||[]) : (o.top||[]);
|
||||||
|
const top = document.getElementById('top'); top.innerHTML='';
|
||||||
|
rows.forEach(t=>{
|
||||||
|
const tr=document.createElement('tr'); tr.className='clk';
|
||||||
|
tr.innerHTML=`<td>${fmtPct(t.efficiency)}</td><td>${fmt(t.exit_velocity_mps,1)}</td>`+
|
||||||
|
`<td>${t.n_stages??'—'}</td><td>${fmt(t.cost_rub,0)}</td><td><span class="pill">${t.search_mode}</span></td>`+
|
||||||
|
`<td class="muted" style="white-space:nowrap">${fmtWhen(t.timestamp)}</td>`;
|
||||||
|
tr.onclick=()=>showDetail(t.run_id);
|
||||||
|
top.appendChild(tr);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refresh(){
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/overview'); const o = await r.json();
|
||||||
|
lastOverview = o;
|
||||||
|
document.getElementById('err').textContent = o.error ? ('ошибка: '+o.error) : '';
|
||||||
|
document.getElementById('c-total').textContent = o.total ?? 0;
|
||||||
|
document.getElementById('c-feas').textContent = o.feasible ?? 0;
|
||||||
|
document.getElementById('c-infeas').textContent = o.infeasible ?? 0;
|
||||||
|
document.getElementById('c-pct').textContent = (o.feasible_pct??0).toFixed(1)+"%";
|
||||||
|
let best = (o.top && o.top.length) ? o.top[0].efficiency : null;
|
||||||
|
let bestV = (o.top_by_velocity && o.top_by_velocity.length) ? o.top_by_velocity[0].exit_velocity_mps : null;
|
||||||
|
document.getElementById('c-best').textContent = fmtPct(best) + (bestV!=null ? ' / '+fmt(bestV,0)+' м/с' : '');
|
||||||
|
document.getElementById('c-cycle').textContent =
|
||||||
|
o.cycle_best_pct==null ? '—' : o.cycle_best_pct.toFixed(2)+'% (цикл идёт)';
|
||||||
|
const g = o.gpu;
|
||||||
|
document.getElementById('c-gpu').textContent = g
|
||||||
|
? `${g.util_pct}% · ${fmt(g.power_w,0)}Вт · ${g.temp_c}°C`
|
||||||
|
: 'нет данных';
|
||||||
|
renderTop(o);
|
||||||
|
|
||||||
|
const hist=document.getElementById('hist'); hist.innerHTML='';
|
||||||
|
const mx=Math.max(1,...(o.efficiency_histogram||[0]));
|
||||||
|
(o.efficiency_histogram||[]).forEach((c,i)=>{
|
||||||
|
const tr=document.createElement('tr');
|
||||||
|
tr.innerHTML=`<td class="muted" style="width:70px">${i*10}–${i*10+10}%</td>`+
|
||||||
|
`<td><div class="bar" style="width:${Math.max(2,100*c/mx)}%"></div></td>`+
|
||||||
|
`<td class="muted" style="width:60px;text-align:right">${c}</td>`;
|
||||||
|
hist.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
const rs=document.getElementById('reasons'); rs.innerHTML='';
|
||||||
|
(o.infeasible_reasons||[]).forEach(x=>{
|
||||||
|
const tr=document.createElement('tr');
|
||||||
|
tr.innerHTML=`<td class="muted">${x.reason}</td><td style="text-align:right">${x.count}</td>`;
|
||||||
|
rs.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('log').textContent = (o.log_tail||[]).join('\n') || '(лог пуст)';
|
||||||
|
document.getElementById('updated').textContent = 'обновлено '+new Date().toLocaleTimeString();
|
||||||
|
} catch(e){ document.getElementById('err').textContent='нет связи с сервером'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showDetail(runId){
|
||||||
|
const r = await fetch('/api/run/'+runId); const d = await r.json();
|
||||||
|
document.getElementById('d-title').textContent = 'Прогон '+runId.slice(0,8)+
|
||||||
|
' — '+(d.feasible?('КПД '+fmtPct(d.efficiency)+', '+fmt(d.exit_velocity_mps,1)+' м/с'):'НЕ реализуемо');
|
||||||
|
document.getElementById('d-body').textContent = JSON.stringify(d, null, 2);
|
||||||
|
|
||||||
|
// графики строятся на лету эндпоинтом /api/run/<id>/plot/<kind>.png
|
||||||
|
const plots = document.getElementById('d-plots'); plots.innerHTML='';
|
||||||
|
['velocity','field','current'].forEach(kind=>{
|
||||||
|
const img=document.createElement('img');
|
||||||
|
img.alt=kind; img.loading='lazy'; img.src='/api/run/'+runId+'/plot/'+kind+'.png?t='+Date.now();
|
||||||
|
plots.appendChild(img);
|
||||||
|
});
|
||||||
|
// GIF тяжелее — грузим только по кнопке
|
||||||
|
document.getElementById('gifbox').innerHTML='';
|
||||||
|
const gifBtn=document.getElementById('d-gifbtn');
|
||||||
|
gifBtn.onclick=()=>{
|
||||||
|
document.getElementById('gifbox').innerHTML='<p class="muted">рендер анимации…</p>';
|
||||||
|
const g=document.createElement('img'); g.src='/api/run/'+runId+'/anim.gif?t='+Date.now();
|
||||||
|
g.onload=()=>{ document.getElementById('gifbox').innerHTML=''; document.getElementById('gifbox').appendChild(g); };
|
||||||
|
g.onerror=()=>{ document.getElementById('gifbox').innerHTML='<p class="muted">не удалось построить анимацию</p>'; };
|
||||||
|
};
|
||||||
|
|
||||||
|
const el=document.getElementById('detail'); el.style.display='block'; el.scrollIntoView({behavior:'smooth'});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRank('eff'); refresh(); setInterval(refresh, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
60
src/gausse/web/rebuild.py
Normal file
60
src/gausse/web/rebuild.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
"""Восстановление симуляции прогона из genome_json — ОДИН общий путь для
|
||||||
|
детализации (stats.run_detail) и рендера графиков/анимации (render.py).
|
||||||
|
|
||||||
|
Раньше эти два места дублировали decode()+run_coilgun() независимо, причём
|
||||||
|
каждый клик по прогону запускал одну и ту же симуляцию до 5 раз (детализация
|
||||||
|
+ 3 графика + GIF), а run_detail ещё и перечитывал базу компонентов с диска
|
||||||
|
на каждый запрос. Здесь: кэш базы компонентов + маленький кэш последних
|
||||||
|
симуляций по run_id.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
|
||||||
|
from gausse.sim.coilgun import run_coilgun
|
||||||
|
from gausse.storage.database import fetch_run_by_id, open_connection
|
||||||
|
|
||||||
|
_db_cache: ComponentDatabase | None = None
|
||||||
|
_sim_lock = threading.Lock()
|
||||||
|
_sim_cache: dict[str, tuple] = {} # run_id -> (row, genome, config, result, x0, v0)
|
||||||
|
_SIM_CACHE_MAX = 8 # клик по прогону = detail + 3 графика + GIF того же run_id
|
||||||
|
|
||||||
|
|
||||||
|
def components_db() -> ComponentDatabase:
|
||||||
|
global _db_cache
|
||||||
|
if _db_cache is None:
|
||||||
|
_db_cache = ComponentDatabase.load()
|
||||||
|
return _db_cache
|
||||||
|
|
||||||
|
|
||||||
|
def rebuild_run(db_path: Path, run_id: str):
|
||||||
|
"""(row, genome, config, result, initial_x_m, initial_v_mps) либо None,
|
||||||
|
если run_id нет в базе. Ошибки пересимуляции пробрасываются наружу —
|
||||||
|
вызывающий решает, что показать (детализация не должна ронять сервер).
|
||||||
|
"""
|
||||||
|
with _sim_lock:
|
||||||
|
cached = _sim_cache.get(run_id)
|
||||||
|
if cached is not None:
|
||||||
|
return cached
|
||||||
|
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
try:
|
||||||
|
row = fetch_run_by_id(conn, run_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
genome = genome_from_dict(json.loads(row.genome_json))
|
||||||
|
config, initial_x_m, initial_v_mps = decode(genome, components_db(), SearchBounds())
|
||||||
|
result = run_coilgun(config)
|
||||||
|
|
||||||
|
entry = (row, genome, config, result, initial_x_m, initial_v_mps)
|
||||||
|
with _sim_lock:
|
||||||
|
if len(_sim_cache) >= _SIM_CACHE_MAX:
|
||||||
|
_sim_cache.clear()
|
||||||
|
_sim_cache[run_id] = entry
|
||||||
|
return entry
|
||||||
67
src/gausse/web/render.py
Normal file
67
src/gausse/web/render.py
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"""Рендер графиков/анимации прогона на лету для веб-морды.
|
||||||
|
|
||||||
|
По run_id достаём геном из базы, восстанавливаем конфигурацию, гоняем
|
||||||
|
симуляцию и строим картинку. Matplotlib (Agg) не потокобезопасен на уровне
|
||||||
|
своей глобальной машины состояний, а сервер многопоточный — поэтому весь
|
||||||
|
рендер сериализуется одним локом.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import io
|
||||||
|
import tempfile
|
||||||
|
import threading
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import matplotlib
|
||||||
|
|
||||||
|
matplotlib.use("Agg")
|
||||||
|
|
||||||
|
import matplotlib.pyplot as plt
|
||||||
|
|
||||||
|
from gausse.report.animate import animate_run
|
||||||
|
from gausse.report.plots import plot_stage_currents, plot_stage_fields, plot_velocity_vs_position
|
||||||
|
from gausse.web.rebuild import rebuild_run
|
||||||
|
|
||||||
|
_render_lock = threading.Lock()
|
||||||
|
|
||||||
|
_PLOT_BUILDERS = {
|
||||||
|
"velocity": plot_velocity_vs_position,
|
||||||
|
"field": plot_stage_fields,
|
||||||
|
"current": plot_stage_currents,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _rebuild_result(db_path: Path, run_id: str):
|
||||||
|
rebuilt = rebuild_run(db_path, run_id) # общий кэш симуляций с run_detail
|
||||||
|
if rebuilt is None:
|
||||||
|
return None
|
||||||
|
_row, _genome, config, result, _ix, _iv = rebuilt
|
||||||
|
return config, result
|
||||||
|
|
||||||
|
|
||||||
|
def render_plot_png(db_path: Path, run_id: str, kind: str) -> bytes | None:
|
||||||
|
builder = _PLOT_BUILDERS.get(kind)
|
||||||
|
if builder is None:
|
||||||
|
return None
|
||||||
|
with _render_lock:
|
||||||
|
rebuilt = _rebuild_result(db_path, run_id)
|
||||||
|
if rebuilt is None:
|
||||||
|
return None
|
||||||
|
_config, result = rebuilt
|
||||||
|
fig = builder(result)
|
||||||
|
buf = io.BytesIO()
|
||||||
|
fig.savefig(buf, format="png", dpi=110)
|
||||||
|
plt.close(fig)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def render_animation_gif(db_path: Path, run_id: str) -> bytes | None:
|
||||||
|
with _render_lock:
|
||||||
|
rebuilt = _rebuild_result(db_path, run_id)
|
||||||
|
if rebuilt is None:
|
||||||
|
return None
|
||||||
|
config, result = rebuilt
|
||||||
|
if not result.stage_outcomes:
|
||||||
|
return None
|
||||||
|
with tempfile.NamedTemporaryFile(suffix=".gif", delete=True) as tmp:
|
||||||
|
animate_run(result, config, tmp.name, fps=20)
|
||||||
|
return Path(tmp.name).read_bytes()
|
||||||
101
src/gausse/web/server.py
Normal file
101
src/gausse/web/server.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""Веб-морда `gausse serve`: дашборд хода эксперимента поверх SQLite.
|
||||||
|
|
||||||
|
Только stdlib (`http.server`) — без новых зависимостей. Read-only: сервер
|
||||||
|
лишь читает базу и отдаёт JSON + одну самодостаточную HTML-страницу, которая
|
||||||
|
опрашивает /api/overview раз в несколько секунд. Ничего не пишет в базу,
|
||||||
|
поэтому безопасно запускать параллельно с идущим sweep/evolve.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from functools import partial
|
||||||
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
|
from gausse.web.page import PAGE_HTML
|
||||||
|
from gausse.web.render import render_animation_gif, render_plot_png
|
||||||
|
from gausse.web.stats import overview, run_detail
|
||||||
|
|
||||||
|
|
||||||
|
class _Handler(BaseHTTPRequestHandler):
|
||||||
|
def __init__(self, *args, db_path: Path, log_path: Path, **kwargs):
|
||||||
|
self.db_path = db_path
|
||||||
|
self.log_path = log_path
|
||||||
|
super().__init__(*args, **kwargs)
|
||||||
|
|
||||||
|
def log_message(self, *args): # тише в stdout
|
||||||
|
pass
|
||||||
|
|
||||||
|
def _send(self, code: int, body: bytes, content_type: str) -> None:
|
||||||
|
self.send_response(code)
|
||||||
|
self.send_header("Content-Type", content_type)
|
||||||
|
self.send_header("Content-Length", str(len(body)))
|
||||||
|
self.end_headers()
|
||||||
|
self.wfile.write(body)
|
||||||
|
|
||||||
|
def _send_json(self, obj, code: int = 200) -> None:
|
||||||
|
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
|
||||||
|
|
||||||
|
def do_GET(self):
|
||||||
|
path = urlparse(self.path).path
|
||||||
|
if path in ("/", "/index.html"):
|
||||||
|
self._send(200, PAGE_HTML.encode("utf-8"), "text/html; charset=utf-8")
|
||||||
|
return
|
||||||
|
if path == "/api/overview":
|
||||||
|
try:
|
||||||
|
self._send_json(overview(self.db_path, self.log_path))
|
||||||
|
except Exception as exc: # база может ещё не существовать / быть пустой
|
||||||
|
self._send_json({"error": str(exc), "total": 0, "feasible": 0, "infeasible": 0}, code=200)
|
||||||
|
return
|
||||||
|
# /api/run/<id>/plot/<kind>.png — график прогона на лету
|
||||||
|
if path.startswith("/api/run/") and "/plot/" in path:
|
||||||
|
rest = path[len("/api/run/") :]
|
||||||
|
run_id, _, tail = rest.partition("/plot/")
|
||||||
|
kind = tail.removesuffix(".png")
|
||||||
|
try:
|
||||||
|
png = render_plot_png(self.db_path, run_id, kind)
|
||||||
|
except Exception as exc:
|
||||||
|
self._send(500, str(exc).encode("utf-8"), "text/plain; charset=utf-8")
|
||||||
|
return
|
||||||
|
if png is None:
|
||||||
|
self._send(404, b"no plot", "text/plain")
|
||||||
|
else:
|
||||||
|
self._send(200, png, "image/png")
|
||||||
|
return
|
||||||
|
# /api/run/<id>/anim.gif — анимация пролёта снаряда
|
||||||
|
if path.startswith("/api/run/") and path.endswith("/anim.gif"):
|
||||||
|
run_id = path[len("/api/run/") :].removesuffix("/anim.gif")
|
||||||
|
try:
|
||||||
|
gif = render_animation_gif(self.db_path, run_id)
|
||||||
|
except Exception as exc:
|
||||||
|
self._send(500, str(exc).encode("utf-8"), "text/plain; charset=utf-8")
|
||||||
|
return
|
||||||
|
if gif is None:
|
||||||
|
self._send(404, b"no animation", "text/plain")
|
||||||
|
else:
|
||||||
|
self._send(200, gif, "image/gif")
|
||||||
|
return
|
||||||
|
if path.startswith("/api/run/"):
|
||||||
|
run_id = path[len("/api/run/") :]
|
||||||
|
detail = run_detail(self.db_path, run_id)
|
||||||
|
if detail is None:
|
||||||
|
self._send_json({"error": "run_id не найден"}, code=404)
|
||||||
|
else:
|
||||||
|
self._send_json(detail)
|
||||||
|
return
|
||||||
|
self._send(404, b"not found", "text/plain")
|
||||||
|
|
||||||
|
|
||||||
|
def serve(db_path: Path, host: str = "0.0.0.0", port: int = 8000, log_path: Path | None = None) -> None:
|
||||||
|
db_path = Path(db_path)
|
||||||
|
if log_path is None:
|
||||||
|
log_path = db_path.with_suffix(db_path.suffix + ".log")
|
||||||
|
handler = partial(_Handler, db_path=db_path, log_path=log_path)
|
||||||
|
httpd = ThreadingHTTPServer((host, port), handler)
|
||||||
|
print(f"gausse web-морда: http://{host}:{port} (база: {db_path}, лог: {log_path})")
|
||||||
|
try:
|
||||||
|
httpd.serve_forever()
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
print("остановлено")
|
||||||
|
finally:
|
||||||
|
httpd.server_close()
|
||||||
167
src/gausse/web/stats.py
Normal file
167
src/gausse/web/stats.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""Агрегаты по таблице `runs` для веб-морды — считаются запросами к SQLite."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.storage.database import ensure_stats, fetch_run_by_id, fetch_runs, open_connection
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_log(log_path: Path, n_lines: int = 40) -> list[str]:
|
||||||
|
"""Последние строки лога чтением С КОНЦА файла (лог растёт годами —
|
||||||
|
перечитывать его целиком на каждый рефреш нельзя)."""
|
||||||
|
if not log_path.exists():
|
||||||
|
return []
|
||||||
|
with open(log_path, "rb") as f:
|
||||||
|
f.seek(0, 2)
|
||||||
|
size = f.tell()
|
||||||
|
f.seek(max(0, size - 64 * 1024))
|
||||||
|
chunk = f.read().decode("utf-8", errors="replace")
|
||||||
|
lines = chunk.splitlines()
|
||||||
|
if size > 64 * 1024 and lines:
|
||||||
|
lines = lines[1:] # первая строка чанка может быть обрезана
|
||||||
|
return lines[-n_lines:]
|
||||||
|
|
||||||
|
|
||||||
|
def _gpu_stat(db_path: Path) -> dict | None:
|
||||||
|
"""Загрузка GPU из gpustat.json рядом с базой (пишет gausse-gpustat на хосте).
|
||||||
|
|
||||||
|
Веб живёт в Docker без доступа к nvidia-smi, поэтому хост-сервис раз в
|
||||||
|
несколько секунд кладёт JSON в каталог результатов. Протухшие данные
|
||||||
|
(>30с — писатель умер/GPU нет) честно не показываем.
|
||||||
|
"""
|
||||||
|
p = db_path.parent / "gpustat.json"
|
||||||
|
try:
|
||||||
|
stat = json.loads(p.read_text(encoding="utf-8"))
|
||||||
|
if time.time() - float(stat.get("ts", 0)) > 30:
|
||||||
|
return None
|
||||||
|
return stat
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def overview(db_path: Path, log_path: Path | None = None, top_n: int = 15) -> dict:
|
||||||
|
"""Сводка для дашборда: счётчики, гистограмма КПД, топ конфигураций, хвост лога.
|
||||||
|
|
||||||
|
Счётчики/гистограмма/причины/режимы читаются из инкрементальных
|
||||||
|
агрегатов (stats_*, ведутся в транзакции вставки) за O(1) — раньше
|
||||||
|
каждый рефреш сканировал всю таблицу runs (5.2с на 630к строк).
|
||||||
|
"""
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
try:
|
||||||
|
ensure_stats(conn) # миграция базы со старой схемой (один раз)
|
||||||
|
counters = dict(conn.execute("SELECT key, value FROM stats_counters"))
|
||||||
|
total = counters.get("total", 0)
|
||||||
|
feasible = counters.get("feasible", 0)
|
||||||
|
infeasible = total - feasible
|
||||||
|
|
||||||
|
by_mode = dict(conn.execute("SELECT mode, count FROM stats_modes"))
|
||||||
|
|
||||||
|
reasons = [
|
||||||
|
{"reason": reason, "count": cnt}
|
||||||
|
for reason, cnt in conn.execute(
|
||||||
|
"SELECT reason, count FROM stats_reasons ORDER BY count DESC LIMIT 10"
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
buckets = [0] * 10 # 0-10%,...,90-100%
|
||||||
|
for bucket, cnt in conn.execute("SELECT bucket, count FROM stats_hist"):
|
||||||
|
if 0 <= bucket <= 9:
|
||||||
|
buckets[int(bucket)] = cnt
|
||||||
|
|
||||||
|
def _summarize(rows):
|
||||||
|
out = []
|
||||||
|
for r in rows:
|
||||||
|
summary = json.loads(r.decoded_summary_json) if r.decoded_summary_json else {}
|
||||||
|
stages = summary.get("stages")
|
||||||
|
out.append({
|
||||||
|
"run_id": r.run_id, "search_mode": r.search_mode, "efficiency": r.efficiency,
|
||||||
|
"exit_velocity_mps": r.exit_velocity_mps, "cost_rub": r.cost_rub,
|
||||||
|
# None (не 0), когда сводки нет — морда покажет «—»
|
||||||
|
"n_stages": len(stages) if stages is not None else None,
|
||||||
|
"timestamp": r.timestamp,
|
||||||
|
})
|
||||||
|
return out
|
||||||
|
|
||||||
|
top = _summarize(fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=top_n))
|
||||||
|
top_by_velocity = _summarize(fetch_runs(conn, feasible=True, order_by_velocity_desc=True, limit=top_n))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
return {
|
||||||
|
"total": total,
|
||||||
|
"feasible": feasible,
|
||||||
|
"infeasible": infeasible,
|
||||||
|
"feasible_pct": (100 * feasible / total) if total else 0.0,
|
||||||
|
"by_mode": by_mode,
|
||||||
|
"infeasible_reasons": reasons,
|
||||||
|
"efficiency_histogram": buckets,
|
||||||
|
"top": top,
|
||||||
|
"top_by_velocity": top_by_velocity,
|
||||||
|
"log_tail": (tail := _tail_log(log_path, 40) if log_path else []),
|
||||||
|
# лучший КПД ТЕКУЩЕГО цикла (из лога): каждый цикл стартует со свежей
|
||||||
|
# популяции и карабкается заново — это ДРУГОЕ число, чем best за всё
|
||||||
|
# время, и без подписи их путали («сервер и морда не связаны»)
|
||||||
|
"cycle_best_pct": _last_cycle_best(tail),
|
||||||
|
"gpu": _gpu_stat(db_path),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _last_cycle_best(log_lines: list[str]) -> float | None:
|
||||||
|
import re
|
||||||
|
|
||||||
|
for line in reversed(log_lines):
|
||||||
|
m = re.search(r"лучший_КПД=(-?[0-9.]+)%", line)
|
||||||
|
if m:
|
||||||
|
return float(m.group(1))
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def run_detail(db_path: Path, run_id: str) -> dict | None:
|
||||||
|
"""Подробности одного прогона. Шапка (feasible/КПД/скорость/энергобаланс)
|
||||||
|
— СОХРАНЁННЫЕ при прогоне значения: они посчитаны тем движком и той
|
||||||
|
версией физики, что записали строку, и только они сравнимы между собой.
|
||||||
|
Полная физика по ступеням в БД не хранится (см. schema.py) — она
|
||||||
|
пересимулируется на лету из genome_json через общий web/rebuild.py.
|
||||||
|
Пересчёт идёт ТЕКУЩЕЙ моделью на CPU и может расходиться с сохранёнными
|
||||||
|
числами (GPU-строки, старые model_version) — поэтому detail явно помечен
|
||||||
|
версией пересчёта, а его ошибка не роняет запрос (раньше /api/run был
|
||||||
|
единственным эндпоинтом без защиты)."""
|
||||||
|
from gausse.optim.objective import MODEL_VERSION, build_detail
|
||||||
|
|
||||||
|
from gausse.web.rebuild import components_db, rebuild_run
|
||||||
|
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
try:
|
||||||
|
row = fetch_run_by_id(conn, run_id)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
_, genome, config, result, initial_x_m, initial_v_mps = rebuild_run(db_path, run_id)
|
||||||
|
detail = build_detail(
|
||||||
|
config, result, components_db(), initial_x_m, initial_v_mps,
|
||||||
|
genome.tube_inner_d_m, genome.tube_wall_m,
|
||||||
|
)
|
||||||
|
detail["recomputed_with_model_version"] = MODEL_VERSION
|
||||||
|
except Exception as exc: # деталь — вспомогательная, шапка важнее
|
||||||
|
detail = {"error": f"пересимуляция не удалась: {exc}"}
|
||||||
|
|
||||||
|
return {
|
||||||
|
"run_id": row.run_id,
|
||||||
|
"search_mode": row.search_mode,
|
||||||
|
# версия физики обязана быть видна: прогоны разных моделей
|
||||||
|
# несравнимы, и «лучший» старой версии — не лучший вообще
|
||||||
|
"model_version": row.model_version,
|
||||||
|
"feasible": row.feasible,
|
||||||
|
"infeasible_reason": row.infeasible_reason,
|
||||||
|
"efficiency": row.efficiency,
|
||||||
|
"exit_velocity_mps": row.exit_velocity_mps,
|
||||||
|
"cost_rub": row.cost_rub,
|
||||||
|
"timestamp": row.timestamp,
|
||||||
|
"detail": detail,
|
||||||
|
# сохранённый при записи баланс — консистентен с feasible/КПД шапки
|
||||||
|
"energy_breakdown": json.loads(row.energy_breakdown_json) if row.energy_breakdown_json else {},
|
||||||
|
}
|
||||||
105
tests/test_batch_integrator.py
Normal file
105
tests/test_batch_integrator.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Валидация GPU-батч-интегратора против scipy-эталона.
|
||||||
|
|
||||||
|
Батч-путь (numpy/cupy, фикс. шаг RK4) обязан давать те же числа, что
|
||||||
|
проверенный scipy solve_ivp на той же физике — иначе ускорение бессмысленно.
|
||||||
|
Здесь backend всегда numpy (GPU для теста не нужен: код один и тот же).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
from scipy.integrate import solve_ivp
|
||||||
|
|
||||||
|
from gausse.gpu.backend import get_backend
|
||||||
|
from gausse.gpu.batch_integrator import integrate_batch_discharge, params_from_models
|
||||||
|
from gausse.physics import circuit
|
||||||
|
from gausse.physics.circuit import StageCircuitParams
|
||||||
|
from gausse.physics.inductance import (
|
||||||
|
CoilInductanceModel,
|
||||||
|
air_core_inductance_wheeler,
|
||||||
|
demagnetizing_factor_prolate,
|
||||||
|
effective_permeability,
|
||||||
|
winding_geometry,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _setup(turns_per_layer, layers, tube_od, slug_d, slug_len, mu_r, b_sat, cap_uf, esr, charge_v, wire_rho, wire_gauge):
|
||||||
|
"""Строит (model, circuit_params, q0, x_fire, v_fire) как это делает run_stage."""
|
||||||
|
wire_od = (wire_gauge + 0.05) / 1000
|
||||||
|
geo = winding_geometry(tube_od, wire_od, turns_per_layer, layers)
|
||||||
|
area = np.pi * (wire_gauge / 1000 / 2) ** 2
|
||||||
|
r_wire = wire_rho * geo.total_wire_length_m / area
|
||||||
|
l_air = air_core_inductance_wheeler(geo.mean_radius_m, geo.coil_length_m, geo.radial_depth_m, geo.total_turns)
|
||||||
|
aspect = slug_len / slug_d
|
||||||
|
mu_eff = effective_permeability(mu_r, demagnetizing_factor_prolate(aspect))
|
||||||
|
mass = np.pi * (slug_d / 2) ** 2 * slug_len * 7850.0
|
||||||
|
model = CoilInductanceModel(
|
||||||
|
l_air_h=l_air, coil_length_m=geo.coil_length_m, slug_length_m=slug_len,
|
||||||
|
mu_eff=mu_eff, smoothing_width_m=wire_od, total_turns=geo.total_turns, b_sat_tesla=b_sat,
|
||||||
|
)
|
||||||
|
cap_f = cap_uf * 1e-6
|
||||||
|
cp = StageCircuitParams(capacitance_f=cap_f, r_total_ohm=r_wire + esr, mass_kg=mass)
|
||||||
|
q0 = cap_f * charge_v
|
||||||
|
return model, cp, q0, -0.02, 5.0 # x_fire, v_fire
|
||||||
|
|
||||||
|
|
||||||
|
def _scipy_discharge(model, cp, q0, x_fire, v_fire):
|
||||||
|
sol = solve_ivp(
|
||||||
|
circuit.derivatives, (0.0, 0.03), [q0, 0.0, x_fire, v_fire],
|
||||||
|
args=(model, cp), events=circuit.zero_current_crossing_event, rtol=1e-9, atol=1e-12,
|
||||||
|
)
|
||||||
|
if len(sol.t_events[0]) == 0:
|
||||||
|
return None
|
||||||
|
q_f, i_f, x_f, v_f = sol.y_events[0][0]
|
||||||
|
energy_diss = float(np.trapezoid(sol.y[1] ** 2 * cp.r_total_ohm, sol.t))
|
||||||
|
return {"exit_v": v_f, "exit_x": x_f, "peak_current": float(np.max(np.abs(sol.y[1]))), "energy_diss": energy_diss}
|
||||||
|
|
||||||
|
|
||||||
|
CONFIGS = [
|
||||||
|
dict(turns_per_layer=20, layers=4, tube_od=0.01, slug_d=0.008, slug_len=0.02, mu_r=200, b_sat=1.9, cap_uf=100, esr=0.05, charge_v=300, wire_rho=1.68e-8, wire_gauge=1.0),
|
||||||
|
dict(turns_per_layer=30, layers=5, tube_od=0.012, slug_d=0.006, slug_len=0.018, mu_r=500, b_sat=1.8, cap_uf=220, esr=0.08, charge_v=400, wire_rho=1.68e-8, wire_gauge=0.8),
|
||||||
|
dict(turns_per_layer=15, layers=3, tube_od=0.009, slug_d=0.005, slug_len=0.015, mu_r=300, b_sat=2.0, cap_uf=68, esr=0.1, charge_v=250, wire_rho=2.82e-8, wire_gauge=1.4),
|
||||||
|
dict(turns_per_layer=25, layers=6, tube_od=0.011, slug_d=0.007, slug_len=0.025, mu_r=400, b_sat=1.9, cap_uf=150, esr=0.06, charge_v=350, wire_rho=1.68e-8, wire_gauge=0.71),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_discharge_matches_scipy_reference():
|
||||||
|
xp, _ = get_backend(prefer_gpu=False) # тест на CPU/numpy
|
||||||
|
models, cps, q0s, x0s, v0s = [], [], [], [], []
|
||||||
|
refs = []
|
||||||
|
for c in CONFIGS:
|
||||||
|
model, cp, q0, x0, v0 = _setup(**c)
|
||||||
|
ref = _scipy_discharge(model, cp, q0, x0, v0)
|
||||||
|
assert ref is not None, "scipy-эталон должен скоммутироваться"
|
||||||
|
refs.append(ref)
|
||||||
|
models.append(model); cps.append(cp); q0s.append(q0); x0s.append(x0); v0s.append(v0)
|
||||||
|
|
||||||
|
params = params_from_models(xp, models, cps)
|
||||||
|
out = integrate_batch_discharge(
|
||||||
|
xp, xp.asarray(q0s), xp.asarray(x0s), xp.asarray(v0s), params, dt=1e-6, max_steps=40000
|
||||||
|
)
|
||||||
|
|
||||||
|
assert bool(xp.all(out["feasible"])), "батч должен скоммутировать все конфигурации"
|
||||||
|
for k, ref in enumerate(refs):
|
||||||
|
# фикс. шаг RK4 против адаптивного solve_ivp — совпадение в пределах ~1%
|
||||||
|
assert out["exit_v"][k] == np.float64(ref["exit_v"]) or abs(out["exit_v"][k] - ref["exit_v"]) <= 0.02 * abs(ref["exit_v"]) + 0.05, \
|
||||||
|
f"конфиг {k}: exit_v батч {out['exit_v'][k]:.3f} vs scipy {ref['exit_v']:.3f}"
|
||||||
|
assert abs(out["peak_current"][k] - ref["peak_current"]) <= 0.02 * ref["peak_current"] + 0.5, \
|
||||||
|
f"конфиг {k}: пик тока батч {out['peak_current'][k]:.1f} vs scipy {ref['peak_current']:.1f}"
|
||||||
|
assert abs(out["energy_dissipated_j"][k] - ref["energy_diss"]) <= 0.03 * ref["energy_diss"] + 0.01, \
|
||||||
|
f"конфиг {k}: потери батч {out['energy_dissipated_j'][k]:.3f} vs scipy {ref['energy_diss']:.3f}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_energy_conservation_lossless():
|
||||||
|
"""С R=0 энергия конденсатора = кинетика + магнитное поле (в конце I=0 -> вся в КЭ+остаток)."""
|
||||||
|
xp, _ = get_backend(prefer_gpu=False)
|
||||||
|
c = dict(turns_per_layer=20, layers=4, tube_od=0.01, slug_d=0.008, slug_len=0.02, mu_r=200,
|
||||||
|
b_sat=1.9, cap_uf=100, esr=0.0, charge_v=300, wire_rho=0.0, wire_gauge=1.0)
|
||||||
|
model, cp, q0, x0, v0 = _setup(**c)
|
||||||
|
params = params_from_models(xp, [model], [cp])
|
||||||
|
out = integrate_batch_discharge(xp, xp.asarray([q0]), xp.asarray([x0]), xp.asarray([v0]), params, dt=1e-6, max_steps=40000)
|
||||||
|
assert bool(out["feasible"][0])
|
||||||
|
energy_in = 0.5 * cp.capacitance_f * (q0 / cp.capacitance_f) ** 2
|
||||||
|
ke_before = 0.5 * cp.mass_kg * v0**2
|
||||||
|
ke_after = 0.5 * cp.mass_kg * float(out["exit_v"][0]) ** 2
|
||||||
|
remaining = float(out["exit_q"][0]) ** 2 / (2 * cp.capacitance_f)
|
||||||
|
balance = remaining + (ke_after - ke_before)
|
||||||
|
assert abs(balance - energy_in) <= 0.01 * energy_in, f"баланс {balance:.3f} vs вход {energy_in:.3f}"
|
||||||
47
tests/test_batch_sweep.py
Normal file
47
tests/test_batch_sweep.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.gpu.batch_sweep import run_gpu_sweep
|
||||||
|
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
|
||||||
|
from gausse.sim.coilgun import run_coilgun
|
||||||
|
from gausse.storage.database import count_runs, fetch_runs, open_connection
|
||||||
|
|
||||||
|
DB = ComponentDatabase.load()
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_sweep_records_all_runs(tmp_path):
|
||||||
|
db_path = tmp_path / "gpu.sqlite3"
|
||||||
|
summary = run_gpu_sweep(db_path, n_runs=300, seed=3, prefer_gpu=False, batch_size=300)
|
||||||
|
assert summary["n_runs"] == 300
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert count_runs(conn) == 300
|
||||||
|
rows = fetch_runs(conn)
|
||||||
|
# многоступенчатые конфиги допустимы (до max_stages)
|
||||||
|
stage_counts = set()
|
||||||
|
for r in rows:
|
||||||
|
g = genome_from_dict(json.loads(r.genome_json))
|
||||||
|
stage_counts.add(len(g.stages))
|
||||||
|
assert r.search_mode.startswith("gpu-sweep")
|
||||||
|
assert max(stage_counts) >= 2, "должны встречаться многоступенчатые конфиги"
|
||||||
|
|
||||||
|
|
||||||
|
def test_gpu_sweep_matches_cpu_within_tolerance(tmp_path):
|
||||||
|
"""Честная сверка: GPU-батч (в т.ч. многоступ.) даёт те же exit_v, что CPU run_coilgun."""
|
||||||
|
db_path = tmp_path / "gpu.sqlite3"
|
||||||
|
run_gpu_sweep(db_path, n_runs=2000, seed=5, prefer_gpu=False, batch_size=2000)
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
bounds = SearchBounds()
|
||||||
|
feasible = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=12)
|
||||||
|
assert len(feasible) >= 3
|
||||||
|
checked = 0
|
||||||
|
for r in feasible:
|
||||||
|
g = genome_from_dict(json.loads(r.genome_json))
|
||||||
|
config, _, _ = decode(g, DB, bounds)
|
||||||
|
cpu = run_coilgun(config)
|
||||||
|
if not cpu.feasible:
|
||||||
|
continue
|
||||||
|
# фикс.шаг батча + аналитический триггер vs адаптивный solve_ivp: ~3%
|
||||||
|
assert abs(r.exit_velocity_mps - cpu.exit_v_mps) <= 0.03 * abs(cpu.exit_v_mps) + 0.2, \
|
||||||
|
f"GPU {r.exit_velocity_mps:.2f} vs CPU {cpu.exit_v_mps:.2f} ({len(g.stages)} ступ.)"
|
||||||
|
checked += 1
|
||||||
|
assert checked >= 3
|
||||||
80
tests/test_capacitor_bank.py
Normal file
80
tests/test_capacitor_bank.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.components.schema import CapacitorBank, CapacitorSpec
|
||||||
|
from gausse.optim.objective import build_detail
|
||||||
|
from gausse.optim.search_space import SearchBounds, decode, sample_genome
|
||||||
|
from gausse.sim.coilgun import run_coilgun
|
||||||
|
|
||||||
|
BASE = CapacitorSpec(
|
||||||
|
part_number="c-base", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.2,
|
||||||
|
max_current_a=20.0, price=150.0, source="test",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_multiplies_voltage_and_divides_capacitance():
|
||||||
|
bank = CapacitorBank.from_spec(BASE, n_series=3, n_parallel=1)
|
||||||
|
assert bank.voltage_v == pytest.approx(1200.0) # 400 * 3
|
||||||
|
assert bank.capacitance_uf == pytest.approx(100.0 / 3) # C / 3
|
||||||
|
assert bank.esr_ohm == pytest.approx(0.6) # ESR * 3
|
||||||
|
assert bank.count == 3
|
||||||
|
assert bank.price == pytest.approx(450.0) # 150 * 3
|
||||||
|
|
||||||
|
|
||||||
|
def test_parallel_multiplies_capacitance_current_and_divides_esr():
|
||||||
|
bank = CapacitorBank.from_spec(BASE, n_series=1, n_parallel=4)
|
||||||
|
assert bank.capacitance_uf == pytest.approx(400.0) # C * 4
|
||||||
|
assert bank.voltage_v == pytest.approx(400.0) # без изменения
|
||||||
|
assert bank.esr_ohm == pytest.approx(0.05) # ESR / 4
|
||||||
|
assert bank.max_current_a == pytest.approx(80.0) # 20 * 4 -- вот "на силу тока"
|
||||||
|
assert bank.count == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_series_parallel_combination():
|
||||||
|
bank = CapacitorBank.from_spec(BASE, n_series=2, n_parallel=3)
|
||||||
|
assert bank.voltage_v == pytest.approx(800.0)
|
||||||
|
assert bank.capacitance_uf == pytest.approx(100.0 * 3 / 2)
|
||||||
|
assert bank.esr_ohm == pytest.approx(0.2 * 2 / 3)
|
||||||
|
assert bank.count == 6
|
||||||
|
assert bank.price == pytest.approx(150.0 * 6)
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_scales_with_bank():
|
||||||
|
single = CapacitorBank.from_spec(BASE, 1, 1)
|
||||||
|
bank = CapacitorBank.from_spec(BASE, 2, 2) # V×2, C×1 (2 series, 2 parallel => C*2/2=C)
|
||||||
|
e_single = 0.5 * (single.capacitance_uf * 1e-6) * single.voltage_v**2
|
||||||
|
e_bank = 0.5 * (bank.capacitance_uf * 1e-6) * bank.voltage_v**2
|
||||||
|
# тот же C, но вдвое большее напряжение -> вчетверо больше энергии
|
||||||
|
assert e_bank == pytest.approx(4 * e_single)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decoded_record_contains_capacitor_bank_composition():
|
||||||
|
db = ComponentDatabase.load()
|
||||||
|
bounds = SearchBounds()
|
||||||
|
rng = random.Random(1)
|
||||||
|
genome = sample_genome(db, bounds, rng)
|
||||||
|
config, ix, iv = decode(genome, db, bounds)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
detail = build_detail(config, result, db, ix, iv)
|
||||||
|
bank = detail["stages"][0]["capacitor_bank"]
|
||||||
|
assert set(bank) >= {"base_part", "n_series", "n_parallel", "count", "total_voltage_rating_v", "max_current_a"}
|
||||||
|
assert bank["count"] == bank["n_series"] * bank["n_parallel"]
|
||||||
|
# база должна быть реальной деталью
|
||||||
|
assert bank["base_part"] in {c.part_number for c in db.capacitors}
|
||||||
|
|
||||||
|
|
||||||
|
def test_genome_carries_series_parallel_and_they_vary():
|
||||||
|
db = ComponentDatabase.load()
|
||||||
|
bounds = SearchBounds()
|
||||||
|
rng = random.Random(5)
|
||||||
|
series_vals, parallel_vals = set(), set()
|
||||||
|
for _ in range(40):
|
||||||
|
g = sample_genome(db, bounds, rng)
|
||||||
|
for st in g.stages:
|
||||||
|
assert bounds.cap_series_min <= st.cap_series <= bounds.cap_series_max
|
||||||
|
assert bounds.cap_parallel_min <= st.cap_parallel <= bounds.cap_parallel_max
|
||||||
|
series_vals.add(st.cap_series)
|
||||||
|
parallel_vals.add(st.cap_parallel)
|
||||||
|
assert len(series_vals) > 1 and len(parallel_vals) > 1
|
||||||
@@ -28,15 +28,12 @@ def _make_frozen_model() -> CoilInductanceModel:
|
|||||||
|
|
||||||
|
|
||||||
def _integrate(r_ohm: float, t_span: tuple[float, float], t_eval: np.ndarray):
|
def _integrate(r_ohm: float, t_span: tuple[float, float], t_eval: np.ndarray):
|
||||||
|
# mu_eff=1.0 -> вклад железа нулевой, λ = L_air·I, чистый RLC-контур
|
||||||
model = _make_frozen_model()
|
model = _make_frozen_model()
|
||||||
params = StageCircuitParams(
|
params = StageCircuitParams(
|
||||||
capacitance_f=C_F,
|
capacitance_f=C_F,
|
||||||
r_total_ohm=r_ohm,
|
r_total_ohm=r_ohm,
|
||||||
mass_kg=1.0,
|
mass_kg=1.0,
|
||||||
mu_eff=1.0,
|
|
||||||
total_turns=100,
|
|
||||||
coil_length_m=0.05,
|
|
||||||
b_sat_tesla=1e9, # эффективно отключает клэмп насыщения для этого теста
|
|
||||||
)
|
)
|
||||||
state0 = [Q0, 0.0, 0.0, 0.0]
|
state0 = [Q0, 0.0, 0.0, 0.0]
|
||||||
return solve_ivp(
|
return solve_ivp(
|
||||||
|
|||||||
55
tests/test_cli_smoke.py
Normal file
55
tests/test_cli_smoke.py
Normal file
@@ -0,0 +1,55 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.cli import main
|
||||||
|
from gausse.storage.database import fetch_runs, open_connection
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_sweep_writes_database(tmp_path):
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
rc = main(["sweep", "--db", str(db_path), "--n", "10", "--workers", "2", "--seed", "1", "--max-stages", "2"])
|
||||||
|
assert rc == 0
|
||||||
|
assert db_path.exists()
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert len(fetch_runs(conn)) == 10
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_sweep_then_report_end_to_end(tmp_path):
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
out_dir = tmp_path / "report"
|
||||||
|
rc = main(["sweep", "--db", str(db_path), "--n", "40", "--workers", "2", "--seed", "2"])
|
||||||
|
assert rc == 0
|
||||||
|
|
||||||
|
rc2 = main(["report", "--db", str(db_path), "--out", str(out_dir), "--top", "1"])
|
||||||
|
assert rc2 == 0
|
||||||
|
rank_dirs = list(out_dir.glob("rank1_*"))
|
||||||
|
assert len(rank_dirs) == 1
|
||||||
|
run_dir = rank_dirs[0]
|
||||||
|
assert (run_dir / "summary.txt").exists()
|
||||||
|
assert (run_dir / "summary.json").exists()
|
||||||
|
assert (run_dir / "bom.txt").exists()
|
||||||
|
assert (run_dir / "current.png").exists()
|
||||||
|
assert (run_dir / "velocity_vs_position.png").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_simulate_single_run_id(tmp_path):
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
out_dir = tmp_path / "sim_out"
|
||||||
|
main(["sweep", "--db", str(db_path), "--n", "5", "--workers", "1", "--seed", "3", "--max-stages", "2"])
|
||||||
|
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
row = fetch_runs(conn)[0]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
rc = main(["simulate", "--db", str(db_path), "--run-id", row.run_id, "--out", str(out_dir)])
|
||||||
|
assert rc == 0
|
||||||
|
assert (out_dir / "summary.txt").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cli_report_with_empty_database_reports_error_not_crash(tmp_path):
|
||||||
|
db_path = tmp_path / "empty.sqlite3"
|
||||||
|
from gausse.storage.database import open_connection as oc
|
||||||
|
|
||||||
|
oc(db_path).close() # создаёт пустую (но валидную) базу
|
||||||
|
rc = main(["report", "--db", str(db_path), "--out", str(tmp_path / "out")])
|
||||||
|
assert rc == 1
|
||||||
@@ -96,6 +96,27 @@ def test_infeasible_stage_stops_chain_with_honest_reason():
|
|||||||
assert len(result.stage_outcomes) == 2
|
assert len(result.stage_outcomes) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_efficiency_never_exceeds_one_even_with_large_initial_velocity():
|
||||||
|
"""Регрессия: КПД считался как exit_KE/energy_in, а не как ДОБАВЛЕННАЯ
|
||||||
|
катушками энергия/energy_in. Для лёгкого снаряда с большой заданной
|
||||||
|
initial_v_mps (внешний "толчок", не учтённый в energy_in) это давало
|
||||||
|
КПД > 100% -- физически бессмысленно, найдено реальным sweep на Этапе 8."""
|
||||||
|
light_projectile = ProjectileConfig(material=STEEL, diameter_m=0.003, length_m=0.005)
|
||||||
|
config = CoilgunConfig(
|
||||||
|
stages=[_stage()],
|
||||||
|
inter_stage_gaps_m=[],
|
||||||
|
projectile=light_projectile,
|
||||||
|
initial_x_m=-0.05,
|
||||||
|
initial_v_mps=50.0, # заведомо большой "бесплатный" толчок относительно массы снаряда
|
||||||
|
)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
assert result.feasible, result.reason
|
||||||
|
assert result.efficiency <= 1.0 + 1e-9
|
||||||
|
# и КПД действительно равен добавленной энергии, а не абсолютной exit-КЭ
|
||||||
|
expected = result.total_kinetic_energy_delta_j / result.total_energy_in_j
|
||||||
|
assert result.efficiency == pytest.approx(expected)
|
||||||
|
|
||||||
|
|
||||||
def test_mismatched_gap_count_raises():
|
def test_mismatched_gap_count_raises():
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
CoilgunConfig(
|
CoilgunConfig(
|
||||||
|
|||||||
117
tests/test_evolutionary.py
Normal file
117
tests/test_evolutionary.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim.evolutionary import polish_best, run_evolution
|
||||||
|
from gausse.optim.search_space import SearchBounds, sample_genome
|
||||||
|
from gausse.storage.database import count_runs, fetch_runs, open_connection
|
||||||
|
|
||||||
|
DB = ComponentDatabase.load()
|
||||||
|
|
||||||
|
|
||||||
|
def _stats_total(conn) -> int:
|
||||||
|
row = conn.execute("SELECT value FROM stats_counters WHERE key='total'").fetchone()
|
||||||
|
return row[0] if row else 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_evolution_counts_all_evaluations_but_stores_only_best_rows(tmp_path):
|
||||||
|
"""Контракт хранения evolve: агрегаты честно считают ВСЕ оценки,
|
||||||
|
а полные строки — только улучшения лучшего генома острова."""
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
bounds = SearchBounds(max_stages=2)
|
||||||
|
|
||||||
|
summary = run_evolution(
|
||||||
|
db_path,
|
||||||
|
n_generations=2,
|
||||||
|
population_size=6,
|
||||||
|
bounds=bounds,
|
||||||
|
n_workers=2,
|
||||||
|
seed=7,
|
||||||
|
polish=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
assert summary["n_evaluated"] == 12 # 2 поколения x 6 особей
|
||||||
|
assert "best_fitness" in summary
|
||||||
|
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert _stats_total(conn) == 12 # счётчики дашборда видят все оценки
|
||||||
|
n_rows = count_runs(conn)
|
||||||
|
# строк меньше, чем оценок: максимум 2 улучшения (КПД+скорость) на
|
||||||
|
# остров на поколение
|
||||||
|
assert 1 <= n_rows <= 4
|
||||||
|
rows = fetch_runs(conn)
|
||||||
|
assert all(r.search_mode == "evolve" for r in rows)
|
||||||
|
# лучший найденный обязан лежать в базе полной строкой
|
||||||
|
best_row_fitness = max((r.efficiency if r.feasible else -1.0) for r in rows)
|
||||||
|
if summary["best_feasible"]:
|
||||||
|
assert best_row_fitness == summary["best_efficiency"]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_evolution_is_reproducible_given_same_seed(tmp_path):
|
||||||
|
bounds = SearchBounds(max_stages=2)
|
||||||
|
summary_a = run_evolution(
|
||||||
|
tmp_path / "a.sqlite3", n_generations=2, population_size=6, bounds=bounds,
|
||||||
|
n_workers=1, seed=99, polish=False,
|
||||||
|
)
|
||||||
|
summary_b = run_evolution(
|
||||||
|
tmp_path / "b.sqlite3", n_generations=2, population_size=6, bounds=bounds,
|
||||||
|
n_workers=1, seed=99, polish=False,
|
||||||
|
)
|
||||||
|
assert summary_a["best_fitness"] == summary_b["best_fitness"]
|
||||||
|
assert summary_a["best_efficiency"] == summary_b["best_efficiency"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_polish_does_not_make_the_best_genome_worse():
|
||||||
|
rng = random.Random(15)
|
||||||
|
bounds = SearchBounds(max_stages=2)
|
||||||
|
# ищем реализуемый геном как стартовую точку доводки
|
||||||
|
genome = None
|
||||||
|
for _ in range(30):
|
||||||
|
candidate = sample_genome(DB, bounds, rng)
|
||||||
|
from gausse.optim.objective import evaluate
|
||||||
|
result = evaluate(candidate, DB, bounds)
|
||||||
|
if result.feasible:
|
||||||
|
genome = candidate
|
||||||
|
baseline_fitness = result.fitness
|
||||||
|
break
|
||||||
|
assert genome is not None, "не нашли реализуемый геном за 30 попыток"
|
||||||
|
|
||||||
|
_, polished_result = polish_best(genome, DB, bounds)
|
||||||
|
assert polished_result.fitness >= baseline_fitness - 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_evolution_gpu_batch_mode(tmp_path):
|
||||||
|
"""GPU-режим эволюции (батч-оценка поколения; здесь numpy-бэкенд):
|
||||||
|
агрегаты считают все оценки, строки — только улучшения лучшего."""
|
||||||
|
from gausse.storage.database import count_runs, fetch_runs, open_connection
|
||||||
|
|
||||||
|
db_path = tmp_path / "evo_gpu.sqlite3"
|
||||||
|
summary = run_evolution(
|
||||||
|
db_path, n_generations=3, population_size=40, seed=5, polish=False, use_gpu=True
|
||||||
|
)
|
||||||
|
assert summary["n_evaluated"] == 120
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert _stats_total(conn) == 120
|
||||||
|
# 1 остров x 3 поколения, максимум 2 улучшения (КПД+скорость) за поколение
|
||||||
|
assert 1 <= count_runs(conn) <= 6
|
||||||
|
rows = fetch_runs(conn, limit=5)
|
||||||
|
assert all(r.search_mode.startswith("evolve-gpu") for r in rows)
|
||||||
|
# у сохранённых лучших должна быть посчитана стоимость (GPU-путь
|
||||||
|
# не считает её для рядовых, но дочитывает для избранных)
|
||||||
|
assert all(r.cost_rub is not None for r in rows)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_evolution_islands(tmp_path):
|
||||||
|
"""Островная модель: K популяций одним батчем + миграция по кольцу."""
|
||||||
|
from gausse.storage.database import count_runs, open_connection
|
||||||
|
|
||||||
|
db_path = tmp_path / "evo_isl.sqlite3"
|
||||||
|
summary = run_evolution(
|
||||||
|
db_path, n_generations=4, population_size=15, seed=7, polish=False,
|
||||||
|
use_gpu=True, islands=3, migrate_every=2,
|
||||||
|
)
|
||||||
|
assert summary["n_evaluated"] == 4 * 15 * 3
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert _stats_total(conn) == 4 * 15 * 3
|
||||||
|
# максимум 2 улучшения (КПД+скорость) на остров на поколение
|
||||||
|
assert 3 <= count_runs(conn) <= 4 * 3 * 2
|
||||||
62
tests/test_objective.py
Normal file
62
tests/test_objective.py
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim.objective import compute_cost_rub, decoded_summary, evaluate
|
||||||
|
from gausse.optim.search_space import SearchBounds, decode, sample_genome
|
||||||
|
|
||||||
|
DB = ComponentDatabase.load()
|
||||||
|
BOUNDS = SearchBounds()
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_feasible_genome_has_fitness_equal_efficiency():
|
||||||
|
rng = random.Random(2)
|
||||||
|
found_feasible = False
|
||||||
|
for _ in range(50):
|
||||||
|
genome = sample_genome(DB, BOUNDS, rng)
|
||||||
|
result = evaluate(genome, DB, BOUNDS)
|
||||||
|
if result.feasible:
|
||||||
|
found_feasible = True
|
||||||
|
assert result.fitness == result.efficiency
|
||||||
|
# КПД <= 1 всегда; может быть СЛЕГКА отрицательным: трение о трубку
|
||||||
|
# за время разряда может съесть больше, чем слабая катушка добавила
|
||||||
|
assert -0.01 <= result.efficiency <= 1.0
|
||||||
|
assert result.cost_rub > 0
|
||||||
|
break
|
||||||
|
assert found_feasible, "ни один из 50 случайных геномов не оказался реализуемым"
|
||||||
|
|
||||||
|
|
||||||
|
def test_evaluate_infeasible_genome_has_negative_fitness_and_reason():
|
||||||
|
rng = random.Random(9)
|
||||||
|
genome = sample_genome(DB, BOUNDS, rng)
|
||||||
|
# индукционный датчик требует скорости >= порог/чувствительность = 5 м/с,
|
||||||
|
# а старт по умолчанию 3 м/с -- первая ступень обязана провалиться
|
||||||
|
inductive_idx = next(i for i, s in enumerate(DB.sensors) if s.kind == "inductive")
|
||||||
|
genome.stages[0].sensor_idx = inductive_idx
|
||||||
|
result = evaluate(genome, DB, BOUNDS)
|
||||||
|
assert not result.feasible
|
||||||
|
assert result.fitness < 0
|
||||||
|
assert result.reason is not None
|
||||||
|
assert result.failed_stage_index == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_compute_cost_rub_is_positive_and_scales_with_stage_count():
|
||||||
|
rng = random.Random(4)
|
||||||
|
genome = sample_genome(DB, BOUNDS, rng)
|
||||||
|
config, _, _ = decode(genome, DB, BOUNDS)
|
||||||
|
cost = compute_cost_rub(config, DB)
|
||||||
|
assert cost > 0
|
||||||
|
|
||||||
|
genome.stages.append(genome.stages[0])
|
||||||
|
genome.inter_stage_gaps_m.append(0.05)
|
||||||
|
config2, _, _ = decode(genome, DB, BOUNDS)
|
||||||
|
cost2 = compute_cost_rub(config2, DB)
|
||||||
|
assert cost2 > cost
|
||||||
|
|
||||||
|
|
||||||
|
def test_decoded_summary_uses_real_part_numbers():
|
||||||
|
rng = random.Random(6)
|
||||||
|
genome = sample_genome(DB, BOUNDS, rng)
|
||||||
|
summary = decoded_summary(genome, DB)
|
||||||
|
assert len(summary["stages"]) == len(genome.stages)
|
||||||
|
real_wire_ids = {w.part_id for w in DB.wires}
|
||||||
|
assert all(s["wire"] in real_wire_ids for s in summary["stages"])
|
||||||
238
tests/test_realism_v2.py
Normal file
238
tests/test_realism_v2.py
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
"""Физреализм v2: скин/близость (Доуэлл), трение+воздух, импульсные рейтинги ключей."""
|
||||||
|
|
||||||
|
import math
|
||||||
|
|
||||||
|
import numpy as np
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.components.schema import (
|
||||||
|
CapacitorSpec,
|
||||||
|
ProjectileMaterialSpec,
|
||||||
|
SensorSpec,
|
||||||
|
SwitchSpec,
|
||||||
|
WireSpec,
|
||||||
|
)
|
||||||
|
from gausse.physics.ac_resistance import dowell_ac_factor, skin_depth_m
|
||||||
|
from gausse.physics.constants import switch_pulse_limit_a
|
||||||
|
from gausse.sim.stage import ProjectileConfig, StageConfig, build_stage_physics, run_stage
|
||||||
|
|
||||||
|
DB = ComponentDatabase.load()
|
||||||
|
|
||||||
|
|
||||||
|
def make_projectile() -> ProjectileConfig:
|
||||||
|
steel = ProjectileMaterialSpec(
|
||||||
|
name="steel", density_kg_m3=7850.0, mu_r=200.0, b_sat_tesla=1.8, price_per_kg=100.0, source="test"
|
||||||
|
)
|
||||||
|
return ProjectileConfig(material=steel, diameter_m=0.008, length_m=0.02)
|
||||||
|
|
||||||
|
|
||||||
|
def make_stage() -> StageConfig:
|
||||||
|
wire = WireSpec(
|
||||||
|
part_id="w1", material="copper", gauge_mm=0.8, insulation_od_mm=0.85,
|
||||||
|
resistivity_ohm_m=1.68e-8, max_current_a=10.0, price_per_m=3.0, source="test",
|
||||||
|
)
|
||||||
|
capacitor = CapacitorSpec(
|
||||||
|
part_number="c1", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.05, max_current_a=300.0,
|
||||||
|
price=300.0, source="test",
|
||||||
|
)
|
||||||
|
switch = SwitchSpec(
|
||||||
|
part_number="sw1", kind="MOSFET", max_current_a=500.0, max_voltage_v=500.0,
|
||||||
|
on_resistance_ohm=0.02, on_voltage_drop_v=None, turn_on_time_ns=50.0, price=50.0, source="test",
|
||||||
|
)
|
||||||
|
sensor = SensorSpec(part_number="s1", kind="optical", propagation_delay_ns=500.0, price=20.0, source="test")
|
||||||
|
return StageConfig(
|
||||||
|
wire=wire, capacitor=capacitor, switch=switch, sensor=sensor,
|
||||||
|
tube_od_m=0.01, turns_per_layer=20, layers=4,
|
||||||
|
sensor_to_coil_distance_m=0.02, charge_voltage_v=350.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- Доуэлл ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_skin_depth_copper_50hz():
|
||||||
|
# медь на 50 Гц: δ ≈ 9.3 мм (классическое справочное значение)
|
||||||
|
delta = skin_depth_m(1.68e-8, 2 * math.pi * 50)
|
||||||
|
assert abs(delta - 9.2e-3) < 0.5e-3
|
||||||
|
|
||||||
|
|
||||||
|
def test_dowell_limits():
|
||||||
|
# низкая частота -> фактор стремится к 1
|
||||||
|
assert dowell_ac_factor(0.001, 0.00108, 8, 1.68e-8, 2 * math.pi * 1.0) < 1.001
|
||||||
|
# больше слоёв -> сильнее эффект близости (монотонность)
|
||||||
|
omega = 2 * math.pi * 2000
|
||||||
|
f = [dowell_ac_factor(0.002, 0.00208, m, 1.68e-8, omega) for m in (1, 4, 10)]
|
||||||
|
assert f[0] < f[1] < f[2]
|
||||||
|
# всегда >= 1
|
||||||
|
assert all(x >= 1.0 for x in f)
|
||||||
|
|
||||||
|
|
||||||
|
def test_dowell_series_matches_formula_at_boundary():
|
||||||
|
# непрерывность на стыке ряда и полной формулы (Δ≈0.25)
|
||||||
|
omega_lo, omega_hi = None, None
|
||||||
|
# подберём частоты чуть ниже/выше границы Δ=0.25 для d=1мм
|
||||||
|
d, rho = 0.001, 1.68e-8
|
||||||
|
for omega in np.logspace(2, 6, 4000):
|
||||||
|
delta = skin_depth_m(rho, omega)
|
||||||
|
big = (math.pi / 4) ** 0.75 * d / delta # porosity~1
|
||||||
|
if big < 0.25:
|
||||||
|
omega_lo = omega
|
||||||
|
elif omega_hi is None:
|
||||||
|
omega_hi = omega
|
||||||
|
break
|
||||||
|
f_lo = dowell_ac_factor(d, d, 6, rho, omega_lo)
|
||||||
|
f_hi = dowell_ac_factor(d, d, 6, rho, omega_hi)
|
||||||
|
assert abs(f_lo - f_hi) < 0.01
|
||||||
|
|
||||||
|
|
||||||
|
def test_thick_wire_many_layers_significant_ac_factor():
|
||||||
|
# толстый провод (2мм) в 10 слоёв на кГц — эффект близости должен быть
|
||||||
|
# существенным (>1.3), иначе канал потерь не работает
|
||||||
|
f = dowell_ac_factor(0.002, 0.00208, 10, 1.68e-8, 2 * math.pi * 1500)
|
||||||
|
assert f > 1.3
|
||||||
|
|
||||||
|
|
||||||
|
# --- трение и воздух ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_with_friction_slower_than_ideal():
|
||||||
|
"""Выходная скорость с трением/воздухом ниже, чем была бы без них."""
|
||||||
|
stage = make_stage()
|
||||||
|
projectile = make_projectile()
|
||||||
|
res = run_stage(-0.06, 3.0, stage, projectile)
|
||||||
|
assert res.feasible, res.reason
|
||||||
|
# подлёт 3 м/с на ~3 см: только трение уже отъедает заметную скорость
|
||||||
|
phys = build_stage_physics(stage, projectile)
|
||||||
|
assert phys.circuit_params.retard_const_n > 0
|
||||||
|
assert phys.circuit_params.drag_coeff_n_per_mps2 > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_slow_projectile_stalls_before_sensor():
|
||||||
|
"""Медленный снаряд далеко от датчика останавливается трением — честный отказ."""
|
||||||
|
stage = make_stage()
|
||||||
|
projectile = make_projectile()
|
||||||
|
res = run_stage(-1.0, 0.15, stage, projectile) # 15 см/с за метр до катушки
|
||||||
|
assert not res.feasible
|
||||||
|
assert "трением" in res.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_energy_balance_with_friction():
|
||||||
|
"""Баланс: E_in = E_ост.конденсатора + E_потерь(включая трение) + ΔE_кин."""
|
||||||
|
stage = make_stage()
|
||||||
|
projectile = make_projectile()
|
||||||
|
res = run_stage(-0.06, 3.0, stage, projectile)
|
||||||
|
assert res.feasible, res.reason
|
||||||
|
balance = res.energy_remaining_cap_j + res.energy_dissipated_j + res.kinetic_energy_delta_j
|
||||||
|
assert abs(balance - res.energy_in_j) < 0.02 * res.energy_in_j
|
||||||
|
|
||||||
|
|
||||||
|
# --- импульсные рейтинги ключей ---
|
||||||
|
|
||||||
|
|
||||||
|
def test_pulse_ratings_loaded_from_datasheets():
|
||||||
|
by_pn = {s.part_number: s for s in DB.switches}
|
||||||
|
assert by_pn["IRFP254PBF"].pulse_current_a == 92.0 # IDM из даташита
|
||||||
|
assert by_pn["BT151-650R"].pulse_current_a == 100.0 # ITSM
|
||||||
|
assert by_pn["IRG4PC50F"].pulse_current_a == 280.0 # ICM
|
||||||
|
for s in DB.switches:
|
||||||
|
limit = switch_pulse_limit_a(s)
|
||||||
|
assert limit >= s.max_current_a # импульсный >= продолжительного
|
||||||
|
|
||||||
|
|
||||||
|
def test_pulse_limit_fallback_without_datasheet():
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
sw = replace(DB.switches[0], pulse_current_a=None)
|
||||||
|
# без даташита — консервативный множитель по типу ключа
|
||||||
|
assert switch_pulse_limit_a(sw) == sw.max_current_a * {"SCR": 10.0, "MOSFET": 4.0, "IGBT": 3.0}[sw.kind]
|
||||||
|
|
||||||
|
|
||||||
|
def test_friction_force_smooth_at_zero_velocity():
|
||||||
|
"""Регрессия на завис решателя: сила трения обязана быть ГЛАДКОЙ в v=0.
|
||||||
|
|
||||||
|
Разрывный sign(v) заставлял solve_ivp бесконечно дробить шаг у v≈0
|
||||||
|
(одна конфигурация считалась 35+ минут на сервере).
|
||||||
|
"""
|
||||||
|
from gausse.physics.circuit import StageCircuitParams, retarding_force_n
|
||||||
|
|
||||||
|
p = StageCircuitParams(
|
||||||
|
capacitance_f=1e-4, r_total_ohm=0.1, mass_kg=0.01,
|
||||||
|
retard_const_n=0.034, drag_coeff_n_per_mps2=2e-5,
|
||||||
|
)
|
||||||
|
# непрерывность: около нуля сила ~ 0, нечётная, без скачка
|
||||||
|
assert abs(retarding_force_n(p, 1e-6)) < 1e-5
|
||||||
|
assert abs(retarding_force_n(p, 1e-6) + retarding_force_n(p, -1e-6)) < 1e-12
|
||||||
|
# вдали от нуля выходит на полное трение
|
||||||
|
assert retarding_force_n(p, 1.0) > 0.9 * p.retard_const_n
|
||||||
|
|
||||||
|
|
||||||
|
def test_iron_fill_factor_punishes_thin_slug_in_fat_coil():
|
||||||
|
"""Регрессия на эксплойт «КПД 82%»: тонкий снаряд в толстой катушке
|
||||||
|
не может усиливать весь поток — только долю сечения, которую занимает."""
|
||||||
|
from gausse.physics.inductance import CoilInductanceModel
|
||||||
|
|
||||||
|
base = dict(l_air_h=1e-3, coil_length_m=0.05, slug_length_m=0.02,
|
||||||
|
mu_eff=40.0, smoothing_width_m=0.001, total_turns=300)
|
||||||
|
full = CoilInductanceModel(**base, iron_fill_factor=1.0)
|
||||||
|
thin = CoilInductanceModel(**base, iron_fill_factor=0.02) # ⌀4мм в ⌀29мм
|
||||||
|
# сила при том же токе в 50 раз меньше — халявы больше нет
|
||||||
|
f_full = float(full.force_newtons(-0.03, 100.0))
|
||||||
|
f_thin = float(thin.force_newtons(-0.03, 100.0))
|
||||||
|
assert f_thin < 0.03 * f_full
|
||||||
|
|
||||||
|
|
||||||
|
def test_degenerate_coil_fails_fast():
|
||||||
|
"""Регрессия на завис 540с: вырожденная катушка бракуется мгновенно."""
|
||||||
|
import time
|
||||||
|
from dataclasses import replace
|
||||||
|
|
||||||
|
stage = make_stage()
|
||||||
|
degenerate = replace(stage, turns_per_layer=2, layers=2) # 4 витка, ~2мм
|
||||||
|
t0 = time.time()
|
||||||
|
res = run_stage(-0.06, 3.0, degenerate, make_projectile())
|
||||||
|
assert time.time() - t0 < 5.0, "вырожденный конфиг должен отваливаться быстро"
|
||||||
|
# либо честно отбракован гардом, либо честно посчитан быстро — оба исхода ок
|
||||||
|
if not res.feasible:
|
||||||
|
assert res.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_eddy_reflection_bounded_at_high_frequency():
|
||||||
|
"""Полная формула отражения (R₂²+(ωL₂)² в знаменателе): на высоких ω
|
||||||
|
заметно ниже старого резистивно-доминированного приближения, на низких —
|
||||||
|
сходится к нему. Совсем вырожденные катушки добивает гард L/R в run_stage."""
|
||||||
|
import math as _m
|
||||||
|
|
||||||
|
from gausse.physics.constants import MU_0
|
||||||
|
from gausse.physics.losses import EDDY_COUPLING_FACTOR, eddy_reflected_resistance_ohm
|
||||||
|
|
||||||
|
kwargs = dict(slug_radius_m=0.002, slug_length_m=0.03,
|
||||||
|
slug_resistivity_ohm_m=1.6e-7, mu_eff=259.0,
|
||||||
|
total_turns=5, coil_length_m=0.002)
|
||||||
|
|
||||||
|
def resistive_approx(om):
|
||||||
|
a = _m.pi * kwargs["slug_radius_m"] ** 2
|
||||||
|
m = EDDY_COUPLING_FACTOR * MU_0 * kwargs["mu_eff"] * (5 / 0.002) * a
|
||||||
|
r2 = 2 * _m.pi * kwargs["slug_resistivity_ohm_m"] / kwargs["slug_length_m"]
|
||||||
|
return (om * m) ** 2 / r2
|
||||||
|
|
||||||
|
om_hi, om_lo = 1.25e5, 1e2
|
||||||
|
r_hi = eddy_reflected_resistance_ohm(**kwargs, char_omega_rad_s=om_hi)
|
||||||
|
r_lo = eddy_reflected_resistance_ohm(**kwargs, char_omega_rad_s=om_lo)
|
||||||
|
assert r_hi < 0.5 * resistive_approx(om_hi) # высокие ω: ограничена
|
||||||
|
assert abs(r_lo - resistive_approx(om_lo)) < 0.05 * resistive_approx(om_lo) # низкие: сходится
|
||||||
|
|
||||||
|
|
||||||
|
def test_iron_penetration_fraction_limits():
|
||||||
|
"""Диффузия поля: тонкий снаряд/медленный импульс — всё сечение; толстый
|
||||||
|
снаряд на быстром импульсе — только скин-кольцо (эксплойт «30%» закрыт)."""
|
||||||
|
from gausse.physics.ac_resistance import iron_penetration_fraction
|
||||||
|
|
||||||
|
# очень низкая частота: поле успевает пропитать всё сечение
|
||||||
|
assert iron_penetration_fraction(0.005, 1.4e-7, 500.0, 1.0) == 1.0
|
||||||
|
# сталь ⌀9.3мм на ω~1350 рад/с (медленная катушка из эксплойта):
|
||||||
|
# намагничивается лишь внешнее кольцо — заметно меньше половины сечения
|
||||||
|
frac = iron_penetration_fraction(0.00465, 1.4e-7, 500.0, 1350.0)
|
||||||
|
assert 0.02 < frac < 0.5
|
||||||
|
# быстрее импульс -> тоньше кольцо (монотонность по ω)
|
||||||
|
frac_fast = iron_penetration_fraction(0.00465, 1.4e-7, 500.0, 13500.0)
|
||||||
|
assert frac_fast < frac
|
||||||
114
tests/test_report.py
Normal file
114
tests/test_report.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.report.animate import animate_run
|
||||||
|
from gausse.report.bom import build_bom, render_bom_text, total_cost_rub
|
||||||
|
from gausse.report.plots import (
|
||||||
|
plot_stage_currents,
|
||||||
|
plot_stage_fields,
|
||||||
|
plot_velocity_vs_position,
|
||||||
|
save_all,
|
||||||
|
)
|
||||||
|
from gausse.report.summary import build_summary, render_summary_json, render_summary_text
|
||||||
|
from gausse.sim.coilgun import CoilgunConfig, run_coilgun
|
||||||
|
from gausse.sim.stage import ProjectileConfig, StageConfig
|
||||||
|
|
||||||
|
DB = ComponentDatabase.load()
|
||||||
|
|
||||||
|
|
||||||
|
def _feasible_config() -> CoilgunConfig:
|
||||||
|
wire = next(w for w in DB.wires if w.part_id == "cu-petv2-1.0mm")
|
||||||
|
capacitor = next(c for c in DB.capacitors if c.part_number == "cap-470uf-400v")
|
||||||
|
# мощный ключ (180А, surge ~720А) — конфигурация с реальным пиковым током
|
||||||
|
switch = next(s for s in DB.switches if s.part_number == "IRFP4468PBF")
|
||||||
|
sensor = next(s for s in DB.sensors if s.part_number == "TCST2103")
|
||||||
|
steel = next(m for m in DB.projectile_materials if m.name == "Ст3 (конструкционная сталь)")
|
||||||
|
|
||||||
|
projectile = ProjectileConfig(material=steel, diameter_m=0.008, length_m=0.02)
|
||||||
|
stage = StageConfig(
|
||||||
|
wire=wire, capacitor=capacitor, switch=switch, sensor=sensor, tube_od_m=0.01,
|
||||||
|
turns_per_layer=20, layers=4, sensor_to_coil_distance_m=0.02, charge_voltage_v=200.0,
|
||||||
|
)
|
||||||
|
return CoilgunConfig(
|
||||||
|
stages=[stage, stage], inter_stage_gaps_m=[0.05], projectile=projectile,
|
||||||
|
initial_x_m=-0.05, initial_v_mps=5.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_feasible_fixture_is_actually_feasible():
|
||||||
|
result = run_coilgun(_feasible_config())
|
||||||
|
assert result.feasible, result.reason
|
||||||
|
|
||||||
|
|
||||||
|
def test_plots_render_without_error():
|
||||||
|
config = _feasible_config()
|
||||||
|
result = run_coilgun(config)
|
||||||
|
assert result.feasible
|
||||||
|
|
||||||
|
fig1 = plot_stage_currents(result)
|
||||||
|
fig2 = plot_stage_fields(result)
|
||||||
|
fig3 = plot_velocity_vs_position(result)
|
||||||
|
for fig in (fig1, fig2, fig3):
|
||||||
|
assert fig is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_save_all_writes_nonempty_png_files(tmp_path):
|
||||||
|
config = _feasible_config()
|
||||||
|
result = run_coilgun(config)
|
||||||
|
paths = save_all(result, tmp_path)
|
||||||
|
assert len(paths) == 3
|
||||||
|
for p in paths:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
assert Path(p).stat().st_size > 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_bom_totals_are_positive_and_match_sum():
|
||||||
|
config = _feasible_config()
|
||||||
|
lines = build_bom(config, DB)
|
||||||
|
assert len(lines) > 0
|
||||||
|
total = total_cost_rub(lines)
|
||||||
|
assert total == sum(line.total_price_rub for line in lines)
|
||||||
|
assert total > 0
|
||||||
|
text = render_bom_text(lines)
|
||||||
|
assert "ИТОГО" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_reports_feasible_run_honestly():
|
||||||
|
config = _feasible_config()
|
||||||
|
result = run_coilgun(config)
|
||||||
|
summary = build_summary(result, config, DB)
|
||||||
|
assert summary["feasible"] is True
|
||||||
|
assert summary["efficiency"] == result.efficiency
|
||||||
|
assert "model_limitations" in summary
|
||||||
|
assert len(summary["model_limitations"]) > 0
|
||||||
|
|
||||||
|
text = render_summary_text(summary)
|
||||||
|
assert "РЕАЛИЗУЕМО" in text
|
||||||
|
json_text = render_summary_json(summary)
|
||||||
|
assert "efficiency" in json_text
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_reports_infeasible_run_honestly():
|
||||||
|
import dataclasses
|
||||||
|
|
||||||
|
config = _feasible_config()
|
||||||
|
# индукционный датчик при скорости заведомо ниже порога -> провал ступени 0
|
||||||
|
weak_sensor = next(s for s in DB.sensors if s.kind == "inductive")
|
||||||
|
broken_stage = dataclasses.replace(config.stages[0], sensor=weak_sensor)
|
||||||
|
config = dataclasses.replace(
|
||||||
|
config, stages=[broken_stage, config.stages[1]], initial_v_mps=2.0
|
||||||
|
)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
summary = build_summary(result, config, DB)
|
||||||
|
if not result.feasible:
|
||||||
|
text = render_summary_text(summary)
|
||||||
|
assert "НЕ РЕАЛИЗУЕМО" in text
|
||||||
|
assert summary["reason"] is not None
|
||||||
|
|
||||||
|
|
||||||
|
def test_animate_run_produces_nonempty_gif(tmp_path):
|
||||||
|
config = _feasible_config()
|
||||||
|
result = run_coilgun(config)
|
||||||
|
assert result.feasible
|
||||||
|
out_path = tmp_path / "run.gif"
|
||||||
|
animate_run(result, config, str(out_path), fps=10)
|
||||||
|
assert out_path.stat().st_size > 0
|
||||||
113
tests/test_search_space.py
Normal file
113
tests/test_search_space.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim.search_space import (
|
||||||
|
PROJECTILE_BORE_CLEARANCE_M,
|
||||||
|
SearchBounds,
|
||||||
|
crossover,
|
||||||
|
decode,
|
||||||
|
genome_from_dict,
|
||||||
|
genome_to_dict,
|
||||||
|
mutate,
|
||||||
|
repair,
|
||||||
|
sample_genome,
|
||||||
|
)
|
||||||
|
|
||||||
|
DB = ComponentDatabase.load()
|
||||||
|
BOUNDS = SearchBounds()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sample_genome_respects_bounds():
|
||||||
|
rng = random.Random(42)
|
||||||
|
for _ in range(50):
|
||||||
|
genome = sample_genome(DB, BOUNDS, rng)
|
||||||
|
assert BOUNDS.min_stages <= len(genome.stages) <= BOUNDS.max_stages
|
||||||
|
assert len(genome.inter_stage_gaps_m) == len(genome.stages) - 1
|
||||||
|
assert BOUNDS.tube_inner_d_m_min <= genome.tube_inner_d_m <= BOUNDS.tube_inner_d_m_max
|
||||||
|
assert genome.tube_od_m == pytest.approx(genome.tube_inner_d_m + 2 * genome.tube_wall_m)
|
||||||
|
# снаряд влезает в бор трубы
|
||||||
|
assert genome.projectile.diameter_m <= genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M + 1e-9
|
||||||
|
# масса снаряда не больше 200 г
|
||||||
|
import math as _m
|
||||||
|
mat = DB.projectile_materials[genome.projectile.material_idx]
|
||||||
|
mass = _m.pi * (genome.projectile.diameter_m / 2) ** 2 * genome.projectile.length_m * mat.density_kg_m3
|
||||||
|
assert mass <= BOUNDS.projectile_mass_max_kg + 1e-6
|
||||||
|
for stage in genome.stages:
|
||||||
|
assert 0 <= stage.wire_idx < len(DB.wires)
|
||||||
|
assert BOUNDS.turns_per_layer_min <= stage.turns_per_layer <= BOUNDS.turns_per_layer_max
|
||||||
|
assert BOUNDS.layers_min <= stage.layers <= BOUNDS.layers_max
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_produces_valid_config():
|
||||||
|
rng = random.Random(1)
|
||||||
|
genome = sample_genome(DB, BOUNDS, rng)
|
||||||
|
config, initial_x_m, initial_v_mps = decode(genome, DB, BOUNDS)
|
||||||
|
assert len(config.stages) == len(genome.stages)
|
||||||
|
assert len(config.inter_stage_gaps_m) == len(genome.stages) - 1
|
||||||
|
assert initial_x_m < -config.stages[0].sensor_to_coil_distance_m
|
||||||
|
assert initial_v_mps == BOUNDS.initial_launch_velocity_mps
|
||||||
|
for stage_config, gene in zip(config.stages, genome.stages):
|
||||||
|
max_voltage = min(stage_config.capacitor.voltage_v, stage_config.switch.max_voltage_v)
|
||||||
|
assert stage_config.charge_voltage_v <= max_voltage + 1e-9
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutate_keeps_genome_within_bounds():
|
||||||
|
rng = random.Random(7)
|
||||||
|
genome = sample_genome(DB, BOUNDS, rng)
|
||||||
|
for _ in range(200):
|
||||||
|
genome = mutate(genome, DB, BOUNDS, rng, rate=0.5)
|
||||||
|
assert BOUNDS.min_stages <= len(genome.stages) <= BOUNDS.max_stages
|
||||||
|
assert len(genome.inter_stage_gaps_m) == len(genome.stages) - 1
|
||||||
|
assert BOUNDS.tube_inner_d_m_min <= genome.tube_inner_d_m <= BOUNDS.tube_inner_d_m_max
|
||||||
|
assert genome.projectile.diameter_m <= genome.tube_inner_d_m - PROJECTILE_BORE_CLEARANCE_M + 1e-9
|
||||||
|
# decode должен всегда успевать без исключений после repair
|
||||||
|
decode(genome, DB, BOUNDS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mutate_can_change_stage_count():
|
||||||
|
rng = random.Random(3)
|
||||||
|
small_bounds = SearchBounds(min_stages=1, max_stages=3)
|
||||||
|
genome = sample_genome(DB, small_bounds, rng)
|
||||||
|
genome.stages = genome.stages[:1]
|
||||||
|
genome.inter_stage_gaps_m = []
|
||||||
|
counts = set()
|
||||||
|
for _ in range(100):
|
||||||
|
genome = mutate(genome, DB, small_bounds, rng, rate=0.6)
|
||||||
|
counts.add(len(genome.stages))
|
||||||
|
assert len(counts) > 1 # число ступеней реально меняется, не застряло
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossover_produces_decodable_child():
|
||||||
|
rng = random.Random(11)
|
||||||
|
a = sample_genome(DB, BOUNDS, rng)
|
||||||
|
b = sample_genome(DB, BOUNDS, rng)
|
||||||
|
for _ in range(20):
|
||||||
|
child = crossover(a, b, rng)
|
||||||
|
child = repair(child, DB, BOUNDS)
|
||||||
|
config, _, _ = decode(child, DB, BOUNDS)
|
||||||
|
assert len(config.stages) == len(child.stages)
|
||||||
|
|
||||||
|
|
||||||
|
def test_crossover_of_two_single_stage_genomes_does_not_crash():
|
||||||
|
"""Регрессия: оба родителя с 1 ступенью (0 зазоров) роняли crossover
|
||||||
|
с IndexError при попытке взять запасной зазор из пустого списка."""
|
||||||
|
rng = random.Random(13)
|
||||||
|
a = sample_genome(DB, BOUNDS, rng)
|
||||||
|
b = sample_genome(DB, BOUNDS, rng)
|
||||||
|
a.stages = a.stages[:1]
|
||||||
|
a.inter_stage_gaps_m = []
|
||||||
|
b.stages = b.stages[:1]
|
||||||
|
b.inter_stage_gaps_m = []
|
||||||
|
for _ in range(20):
|
||||||
|
child = crossover(a, b, rng)
|
||||||
|
child = repair(child, DB, BOUNDS)
|
||||||
|
decode(child, DB, BOUNDS)
|
||||||
|
|
||||||
|
|
||||||
|
def test_genome_dict_roundtrip():
|
||||||
|
rng = random.Random(5)
|
||||||
|
genome = sample_genome(DB, BOUNDS, rng)
|
||||||
|
restored = genome_from_dict(genome_to_dict(genome))
|
||||||
|
assert restored == genome
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import warnings
|
||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.integrate import solve_ivp
|
from scipy.integrate import solve_ivp
|
||||||
|
|
||||||
@@ -48,3 +50,17 @@ def test_inductive_sensor_never_fires_below_threshold_speed():
|
|||||||
event = make_inductive_sensor_event(x_sensor, sensitivity, threshold, width)
|
event = make_inductive_sensor_event(x_sensor, sensitivity, threshold, width)
|
||||||
sol = solve_ivp(_ballistic, (0, 1.0), [-0.05, 1.0], events=event)
|
sol = solve_ivp(_ballistic, (0, 1.0), [-0.05, 1.0], events=event)
|
||||||
assert len(sol.t_events[0]) == 0
|
assert len(sol.t_events[0]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_inductive_event_far_from_sensor_is_finite_and_does_not_warn():
|
||||||
|
"""Регрессия: cosh(z)**2 переполнял float64 для больших |x - x_sensor| /
|
||||||
|
width_m (типично при интегрировании на широком временном бюджете) —
|
||||||
|
результат физически верный (bump -> 0), но с RuntimeWarning на каждый
|
||||||
|
вызов, что при миллионах прогонов sweep превращается в лавину спама."""
|
||||||
|
event = make_inductive_sensor_event(x_sensor_m=0.02, sensitivity_v_per_mps=0.05, threshold_v=0.3, width_m=0.005)
|
||||||
|
far_state = np.array([-5.0, 20.0]) # 5 м от датчика при ширине чувствительности 5мм
|
||||||
|
with warnings.catch_warnings():
|
||||||
|
warnings.simplefilter("error")
|
||||||
|
value = event(0.0, far_state)
|
||||||
|
assert np.isfinite(value)
|
||||||
|
assert value == -0.3 # bump практически 0 -> событие = 0 - порог
|
||||||
|
|||||||
@@ -30,14 +30,15 @@ def _wire(resistivity_ohm_m: float = 1.68e-8) -> WireSpec:
|
|||||||
|
|
||||||
|
|
||||||
def _switch(on_resistance_ohm: float = 0.02) -> SwitchSpec:
|
def _switch(on_resistance_ohm: float = 0.02) -> SwitchSpec:
|
||||||
|
# высокий рейтинг тока: эти тесты про энергобаланс/отдачу, а не про токовый предел
|
||||||
return SwitchSpec(
|
return SwitchSpec(
|
||||||
part_number="sw1", kind="MOSFET", max_current_a=200.0, max_voltage_v=500.0,
|
part_number="sw1", kind="MOSFET", max_current_a=500.0, max_voltage_v=500.0,
|
||||||
on_resistance_ohm=on_resistance_ohm, on_voltage_drop_v=None, turn_on_time_ns=50.0,
|
on_resistance_ohm=on_resistance_ohm, on_voltage_drop_v=None, turn_on_time_ns=50.0,
|
||||||
price=50.0, source="test",
|
price=50.0, source="test",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run(wire, switch, capacitor=CAPACITOR):
|
def _run(wire, switch, capacitor=CAPACITOR, projectile=PROJECTILE):
|
||||||
# turns_per_layer выбран так, чтобы длина катушки (~1.7см) была сравнима
|
# turns_per_layer выбран так, чтобы длина катушки (~1.7см) была сравнима
|
||||||
# со снарядом (2см) — иначе снаряд, войдя глубоко внутрь длинной катушки,
|
# со снарядом (2см) — иначе снаряд, войдя глубоко внутрь длинной катушки,
|
||||||
# оказывается в плоской зоне перекрытия (dL/dx=0) и сила не действует.
|
# оказывается в плоской зоне перекрытия (dL/dx=0) и сила не действует.
|
||||||
@@ -52,7 +53,7 @@ def _run(wire, switch, capacitor=CAPACITOR):
|
|||||||
sensor_to_coil_distance_m=0.02,
|
sensor_to_coil_distance_m=0.02,
|
||||||
charge_voltage_v=350.0,
|
charge_voltage_v=350.0,
|
||||||
)
|
)
|
||||||
return run_stage(entry_x_m=-0.05, entry_v_mps=5.0, stage=stage, projectile=PROJECTILE)
|
return run_stage(entry_x_m=-0.05, entry_v_mps=5.0, stage=stage, projectile=projectile)
|
||||||
|
|
||||||
|
|
||||||
def test_stage_is_feasible_with_realistic_components():
|
def test_stage_is_feasible_with_realistic_components():
|
||||||
@@ -60,17 +61,27 @@ def test_stage_is_feasible_with_realistic_components():
|
|||||||
assert result.feasible, result.reason
|
assert result.feasible, result.reason
|
||||||
|
|
||||||
|
|
||||||
def test_energy_conserved_exactly_when_lossless():
|
def test_energy_conserved_exactly_when_electrically_lossless():
|
||||||
lossless_capacitor = CapacitorSpec(
|
lossless_capacitor = CapacitorSpec(
|
||||||
part_number="c-lossless", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.0,
|
part_number="c-lossless", capacitance_uf=100.0, voltage_v=400.0, esr_ohm=0.0,
|
||||||
max_current_a=300.0, price=300.0, source="test",
|
max_current_a=300.0, price=300.0, source="test",
|
||||||
)
|
)
|
||||||
|
# снаряд без вихревых потерь: огромное удельное сопротивление -> R_eddy≈0.
|
||||||
|
# Электрических потерь нет, но МЕХАНИЧЕСКИЕ (трение о трубку + воздух)
|
||||||
|
# есть всегда — они и составляют весь energy_dissipated_j.
|
||||||
|
no_eddy_steel = ProjectileMaterialSpec(
|
||||||
|
name="steel-no-eddy", density_kg_m3=7850.0, mu_r=200.0, b_sat_tesla=1.8,
|
||||||
|
price_per_kg=100.0, source="test", resistivity_ohm_m=1e12,
|
||||||
|
)
|
||||||
|
no_eddy_projectile = ProjectileConfig(material=no_eddy_steel, diameter_m=0.008, length_m=0.02)
|
||||||
result = _run(
|
result = _run(
|
||||||
_wire(resistivity_ohm_m=0.0), _switch(on_resistance_ohm=0.0), capacitor=lossless_capacitor
|
_wire(resistivity_ohm_m=0.0), _switch(on_resistance_ohm=0.0),
|
||||||
|
capacitor=lossless_capacitor, projectile=no_eddy_projectile,
|
||||||
)
|
)
|
||||||
assert result.feasible, result.reason
|
assert result.feasible, result.reason
|
||||||
assert result.energy_dissipated_j == pytest.approx(0.0, abs=1e-9)
|
# только трение/воздух: заметно меньше 1% энергии банки за миллисекунды разряда
|
||||||
balance = result.energy_remaining_cap_j + result.kinetic_energy_delta_j
|
assert 0.0 <= result.energy_dissipated_j < 0.01 * result.energy_in_j
|
||||||
|
balance = result.energy_remaining_cap_j + result.energy_dissipated_j + result.kinetic_energy_delta_j
|
||||||
assert balance == pytest.approx(result.energy_in_j, rel=1e-4)
|
assert balance == pytest.approx(result.energy_in_j, rel=1e-4)
|
||||||
|
|
||||||
|
|
||||||
@@ -87,29 +98,42 @@ def test_kinetic_energy_increases_for_approaching_slug():
|
|||||||
result = _run(_wire(), _switch())
|
result = _run(_wire(), _switch())
|
||||||
assert result.feasible, result.reason
|
assert result.feasible, result.reason
|
||||||
assert result.kinetic_energy_delta_j > 0
|
assert result.kinetic_energy_delta_j > 0
|
||||||
# рост кинетической энергии тут должен означать разгон ВПЕРЁД, а не
|
# рост кинетической энергии тут должен означать разгон ВПЕРЁД
|
||||||
# отброс назад (см. test_overpowered_discharge_can_fling_slug_backward)
|
|
||||||
assert result.exit_v_mps > 0
|
assert result.exit_v_mps > 0
|
||||||
|
|
||||||
|
|
||||||
def test_overpowered_discharge_can_fling_slug_backward():
|
def test_energy_conserved_under_strong_discharge():
|
||||||
"""Честная фиксация реального режима отказа, а не только "успешных" случаев.
|
"""Энергобаланс должен оставаться точным даже при сильном разряде большой
|
||||||
|
батареей (нелинейное насыщение железа не должно рвать сохранение энергии —
|
||||||
Слишком большая ёмкость (медленный разряд относительно скорости снаряда)
|
и сила, и ЭДС выведены из одного λ(x, I), см. inductance.py).
|
||||||
держит ток высоким уже после того, как снаряд проходит центр катушки —
|
|
||||||
зона x>0 тянет назад (dL/dx<0), и снаряд может выйти с отрицательной
|
|
||||||
скоростью. Энергобаланс при этом всё ещё точен (КЭ ~ v^2 не зависит от
|
|
||||||
знака) — это подтверждает, что находка отражает реальную физику плохо
|
|
||||||
настроенного расстояния/ёмкости, а не ошибку в уравнениях.
|
|
||||||
"""
|
"""
|
||||||
overpowered_capacitor = CapacitorSpec(
|
big_capacitor = CapacitorSpec(
|
||||||
part_number="c-big", capacitance_uf=1000.0, voltage_v=400.0, esr_ohm=0.05,
|
part_number="c-big", capacitance_uf=1000.0, voltage_v=400.0, esr_ohm=0.05,
|
||||||
max_current_a=300.0, price=300.0, source="test",
|
max_current_a=300.0, price=300.0, source="test",
|
||||||
)
|
)
|
||||||
result = _run(_wire(), _switch(), capacitor=overpowered_capacitor)
|
result = _run(_wire(), _switch(), capacitor=big_capacitor)
|
||||||
assert result.feasible, result.reason
|
assert result.feasible, result.reason
|
||||||
assert result.exit_v_mps < 0
|
|
||||||
balance = (
|
balance = (
|
||||||
result.energy_remaining_cap_j + result.energy_dissipated_j + result.kinetic_energy_delta_j
|
result.energy_remaining_cap_j + result.energy_dissipated_j + result.kinetic_energy_delta_j
|
||||||
)
|
)
|
||||||
assert balance == pytest.approx(result.energy_in_j, rel=1e-2)
|
assert balance == pytest.approx(result.energy_in_j, rel=1e-2)
|
||||||
|
|
||||||
|
|
||||||
|
def test_field_is_capped_near_saturation():
|
||||||
|
"""Регрессия на баг '13 Тесла': поле не должно улетать за B_sat железа."""
|
||||||
|
strong_capacitor = CapacitorSpec(
|
||||||
|
part_number="c-strong", capacitance_uf=200.0, voltage_v=600.0, esr_ohm=0.05,
|
||||||
|
max_current_a=500.0, price=400.0, source="test",
|
||||||
|
)
|
||||||
|
high_v_switch = SwitchSpec(
|
||||||
|
part_number="sw-hi", kind="MOSFET", max_current_a=1000.0, max_voltage_v=800.0,
|
||||||
|
on_resistance_ohm=0.01, on_voltage_drop_v=None, turn_on_time_ns=50.0, price=50.0, source="test",
|
||||||
|
)
|
||||||
|
stage = StageConfig(
|
||||||
|
wire=_wire(), capacitor=strong_capacitor, switch=high_v_switch, sensor=OPTICAL_SENSOR,
|
||||||
|
tube_od_m=0.01, turns_per_layer=40, layers=5, sensor_to_coil_distance_m=0.02, charge_voltage_v=550.0,
|
||||||
|
)
|
||||||
|
result = run_stage(entry_x_m=-0.05, entry_v_mps=5.0, stage=stage, projectile=PROJECTILE)
|
||||||
|
assert result.feasible, result.reason
|
||||||
|
# поле упирается в B_sat снаряда (1.8 Тл в фикстуре), а не улетает в десятки Тл
|
||||||
|
assert result.peak_field_estimate_tesla <= STEEL.b_sat_tesla * 1.05
|
||||||
|
|||||||
156
tests/test_storage_database.py
Normal file
156
tests/test_storage_database.py
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import multiprocessing
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.storage.database import (
|
||||||
|
count_runs,
|
||||||
|
fetch_runs,
|
||||||
|
insert_run,
|
||||||
|
insert_runs,
|
||||||
|
open_connection,
|
||||||
|
run_writer_process,
|
||||||
|
)
|
||||||
|
from gausse.storage.schema import RunRecord
|
||||||
|
|
||||||
|
|
||||||
|
def _record(run_id: str, feasible: bool = True, efficiency: float | None = 0.1, reason=None) -> RunRecord:
|
||||||
|
return RunRecord(
|
||||||
|
run_id=run_id,
|
||||||
|
timestamp="2026-07-06T00:00:00",
|
||||||
|
search_mode="sweep",
|
||||||
|
genome_json="{}",
|
||||||
|
decoded_summary_json="{}",
|
||||||
|
feasible=feasible,
|
||||||
|
model_version="v1",
|
||||||
|
infeasible_reason=reason,
|
||||||
|
efficiency=efficiency,
|
||||||
|
exit_velocity_mps=20.0 if feasible else None,
|
||||||
|
cost_rub=500.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_insert_and_fetch_roundtrip(tmp_path):
|
||||||
|
conn = open_connection(tmp_path / "runs.sqlite3")
|
||||||
|
insert_run(conn, _record("r1"))
|
||||||
|
rows = fetch_runs(conn)
|
||||||
|
assert len(rows) == 1
|
||||||
|
assert rows[0].run_id == "r1"
|
||||||
|
assert rows[0].feasible is True
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_infeasible_runs_are_stored_not_dropped(tmp_path):
|
||||||
|
conn = open_connection(tmp_path / "runs.sqlite3")
|
||||||
|
insert_run(conn, _record("r-fail", feasible=False, efficiency=None, reason="датчик не сработал"))
|
||||||
|
assert count_runs(conn) == 1
|
||||||
|
assert count_runs(conn, feasible=False) == 1
|
||||||
|
assert count_runs(conn, feasible=True) == 0
|
||||||
|
row = fetch_runs(conn, feasible=False)[0]
|
||||||
|
assert row.infeasible_reason == "датчик не сработал"
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_batch_insert_and_filters(tmp_path):
|
||||||
|
conn = open_connection(tmp_path / "runs.sqlite3")
|
||||||
|
records = [
|
||||||
|
_record("r1", efficiency=0.05),
|
||||||
|
_record("r2", efficiency=0.30),
|
||||||
|
_record("r3", feasible=False, efficiency=None, reason="разряд не скоммутировался"),
|
||||||
|
]
|
||||||
|
insert_runs(conn, records)
|
||||||
|
assert count_runs(conn) == 3
|
||||||
|
best = fetch_runs(conn, feasible=True, min_efficiency=0.1)
|
||||||
|
assert [r.run_id for r in best] == ["r2"]
|
||||||
|
ranked = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True)
|
||||||
|
assert [r.run_id for r in ranked] == ["r2", "r1"]
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reopening_database_preserves_previous_runs(tmp_path):
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
insert_run(conn, _record("r1"))
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
conn2 = open_connection(db_path)
|
||||||
|
assert count_runs(conn2) == 1
|
||||||
|
conn2.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_writer_survives_duplicate_run_id_and_keeps_draining_queue(tmp_path):
|
||||||
|
"""Регрессия: одна коллизия run_id раньше роняла writer-процесс и молча
|
||||||
|
останавливала осушение очереди (см. историю разработки Этапа 5)."""
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
ctx = multiprocessing.get_context("spawn")
|
||||||
|
queue = ctx.Queue()
|
||||||
|
|
||||||
|
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
|
||||||
|
writer.start()
|
||||||
|
|
||||||
|
queue.put(_record("dup"))
|
||||||
|
queue.put(_record("dup")) # коллизия PRIMARY KEY
|
||||||
|
queue.put(_record("after-collision"))
|
||||||
|
queue.put(None)
|
||||||
|
writer.join(timeout=10)
|
||||||
|
|
||||||
|
assert not writer.is_alive()
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert count_runs(conn) == 2 # "dup" один раз + "after-collision"
|
||||||
|
assert {r.run_id for r in fetch_runs(conn)} == {"dup", "after-collision"}
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_writer_stats_only_bumps_counters_without_rows(tmp_path):
|
||||||
|
"""StatsOnly-сообщения (эволюция шлёт их для рядовых геномов): агрегаты
|
||||||
|
дашборда растут, а строк в runs не появляется."""
|
||||||
|
from gausse.storage.database import StatsOnly
|
||||||
|
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
ctx = multiprocessing.get_context("spawn")
|
||||||
|
queue = ctx.Queue()
|
||||||
|
|
||||||
|
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
|
||||||
|
writer.start()
|
||||||
|
|
||||||
|
queue.put(_record("best-one")) # одна полная строка
|
||||||
|
queue.put(StatsOnly("evolve", True, 0.42, None))
|
||||||
|
queue.put(StatsOnly("evolve", False, None, "датчик: сигнал ниже порога"))
|
||||||
|
queue.put(StatsOnly("evolve", False, None, None))
|
||||||
|
queue.put(None)
|
||||||
|
writer.join(timeout=10)
|
||||||
|
|
||||||
|
assert not writer.is_alive()
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert count_runs(conn) == 1 # строка только у полной записи
|
||||||
|
counters = dict(conn.execute("SELECT key, value FROM stats_counters"))
|
||||||
|
assert counters["total"] == 4 # но посчитаны все четыре
|
||||||
|
assert counters["feasible"] == 2
|
||||||
|
reasons = dict(conn.execute("SELECT reason, count FROM stats_reasons"))
|
||||||
|
assert reasons.get("датчик") == 1
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _writer_worker(queue, worker_id, n):
|
||||||
|
for i in range(n):
|
||||||
|
queue.put(_record(f"proc-{worker_id}-run-{i}"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_multiprocess_writer_receives_all_records_from_multiple_workers(tmp_path):
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
ctx = multiprocessing.get_context("spawn")
|
||||||
|
queue = ctx.Queue()
|
||||||
|
|
||||||
|
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
|
||||||
|
writer.start()
|
||||||
|
|
||||||
|
workers = [ctx.Process(target=_writer_worker, args=(queue, worker_id, 20)) for worker_id in range(3)]
|
||||||
|
for w in workers:
|
||||||
|
w.start()
|
||||||
|
for w in workers:
|
||||||
|
w.join()
|
||||||
|
|
||||||
|
queue.put(None)
|
||||||
|
writer.join()
|
||||||
|
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert count_runs(conn) == 60
|
||||||
|
conn.close()
|
||||||
53
tests/test_sweep.py
Normal file
53
tests/test_sweep.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import json
|
||||||
|
|
||||||
|
from gausse.optim.search_space import SearchBounds, genome_from_dict
|
||||||
|
from gausse.optim.sweep import MODEL_VERSION, run_sweep
|
||||||
|
from gausse.storage.database import count_runs, fetch_runs, open_connection
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_runs_all_seeds_and_records_everything(tmp_path):
|
||||||
|
db_path = tmp_path / "runs.sqlite3"
|
||||||
|
bounds = SearchBounds(max_stages=2) # маленькое пространство -> быстрый тест
|
||||||
|
|
||||||
|
summary = run_sweep(db_path, n_runs=12, bounds=bounds, n_workers=2, seed=123)
|
||||||
|
|
||||||
|
assert summary["n_runs"] == 12
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
assert count_runs(conn) == 12
|
||||||
|
# хотя бы какие-то честно попали и в успех, и в провал -- иначе тест
|
||||||
|
# пространства слишком узкий/широкий, чтобы быть показательным
|
||||||
|
rows = fetch_runs(conn)
|
||||||
|
assert len(rows) == 12
|
||||||
|
for row in rows:
|
||||||
|
assert row.search_mode == "sweep"
|
||||||
|
assert row.model_version == MODEL_VERSION
|
||||||
|
# genome_json должен быть валидным и декодируемым геномом
|
||||||
|
genome = genome_from_dict(json.loads(row.genome_json))
|
||||||
|
assert len(genome.stages) >= 1
|
||||||
|
summary_dict = json.loads(row.decoded_summary_json)
|
||||||
|
assert "stages" in summary_dict
|
||||||
|
if row.feasible:
|
||||||
|
assert row.efficiency is not None
|
||||||
|
assert row.infeasible_reason is None
|
||||||
|
else:
|
||||||
|
assert row.infeasible_reason is not None
|
||||||
|
assert row.efficiency is None
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_sweep_is_deterministic_given_same_seed(tmp_path):
|
||||||
|
bounds = SearchBounds(max_stages=2)
|
||||||
|
db_path_a = tmp_path / "a.sqlite3"
|
||||||
|
db_path_b = tmp_path / "b.sqlite3"
|
||||||
|
|
||||||
|
run_sweep(db_path_a, n_runs=8, bounds=bounds, n_workers=1, seed=42)
|
||||||
|
run_sweep(db_path_b, n_runs=8, bounds=bounds, n_workers=1, seed=42)
|
||||||
|
|
||||||
|
conn_a = open_connection(db_path_a)
|
||||||
|
conn_b = open_connection(db_path_b)
|
||||||
|
rows_a = sorted(fetch_runs(conn_a), key=lambda r: r.genome_json)
|
||||||
|
rows_b = sorted(fetch_runs(conn_b), key=lambda r: r.genome_json)
|
||||||
|
assert [r.genome_json for r in rows_a] == [r.genome_json for r in rows_b]
|
||||||
|
assert [r.efficiency for r in rows_a] == [r.efficiency for r in rows_b]
|
||||||
|
conn_a.close()
|
||||||
|
conn_b.close()
|
||||||
115
tests/test_web.py
Normal file
115
tests/test_web.py
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
import json
|
||||||
|
import threading
|
||||||
|
import urllib.request
|
||||||
|
from http.server import ThreadingHTTPServer
|
||||||
|
|
||||||
|
from gausse.optim.sweep import run_sweep
|
||||||
|
from gausse.web.page import PAGE_HTML
|
||||||
|
from gausse.web.stats import overview, run_detail
|
||||||
|
|
||||||
|
|
||||||
|
def _make_db(tmp_path):
|
||||||
|
db = tmp_path / "runs.sqlite3"
|
||||||
|
run_sweep(db, n_runs=60, n_workers=2, seed=11)
|
||||||
|
return db
|
||||||
|
|
||||||
|
|
||||||
|
def test_overview_reports_counts_and_top(tmp_path):
|
||||||
|
db = _make_db(tmp_path)
|
||||||
|
o = overview(db, db.with_suffix(".sqlite3.log"))
|
||||||
|
assert o["total"] == 60
|
||||||
|
assert o["feasible"] + o["infeasible"] == 60
|
||||||
|
assert len(o["efficiency_histogram"]) == 10
|
||||||
|
# лог процесса должен существовать и попасть в хвост
|
||||||
|
assert len(o["log_tail"]) > 0
|
||||||
|
if o["top"]:
|
||||||
|
# топ отсортирован по КПД по убыванию
|
||||||
|
effs = [t["efficiency"] for t in o["top"]]
|
||||||
|
assert effs == sorted(effs, reverse=True)
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_detail_has_full_physics_and_distances(tmp_path):
|
||||||
|
db = _make_db(tmp_path)
|
||||||
|
o = overview(db, None)
|
||||||
|
assert o["top"], "нужен хотя бы один реализуемый прогон"
|
||||||
|
d = run_detail(db, o["top"][0]["run_id"])
|
||||||
|
assert d is not None
|
||||||
|
detail = d["detail"]
|
||||||
|
# ключевое, что просил пользователь: расстояния между катушками и позиции
|
||||||
|
assert "inter_stage_gaps_m" in detail
|
||||||
|
assert "coil_center_positions_m" in detail
|
||||||
|
stage0 = detail["stages"][0]
|
||||||
|
assert "electrical" in stage0 and "wire_resistance_ac_ohm" in stage0["electrical"]
|
||||||
|
# v2: скин/близость — AC-сопротивление обмотки выше DC
|
||||||
|
assert stage0["electrical"]["wire_ac_resistance_factor"] >= 1.0
|
||||||
|
assert "winding" in stage0 and "total_turns" in stage0["winding"]
|
||||||
|
assert "sensor_to_coil_distance_m" in stage0
|
||||||
|
|
||||||
|
|
||||||
|
def test_run_detail_missing_id_returns_none(tmp_path):
|
||||||
|
db = _make_db(tmp_path)
|
||||||
|
assert run_detail(db, "нет-такого-id") is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_http_server_serves_page_and_api(tmp_path):
|
||||||
|
from functools import partial
|
||||||
|
|
||||||
|
from gausse.web.server import _Handler
|
||||||
|
|
||||||
|
db = _make_db(tmp_path)
|
||||||
|
handler = partial(_Handler, db_path=db, log_path=db.with_suffix(".sqlite3.log"))
|
||||||
|
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||||
|
port = httpd.server_address[1]
|
||||||
|
t = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||||
|
t.start()
|
||||||
|
try:
|
||||||
|
base = f"http://127.0.0.1:{port}"
|
||||||
|
page = urllib.request.urlopen(base + "/", timeout=5).read().decode("utf-8")
|
||||||
|
assert "<!doctype html>" in page.lower()
|
||||||
|
api = json.loads(urllib.request.urlopen(base + "/api/overview", timeout=5).read())
|
||||||
|
assert api["total"] == 60
|
||||||
|
finally:
|
||||||
|
httpd.shutdown()
|
||||||
|
httpd.server_close()
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_is_self_contained():
|
||||||
|
# никаких внешних ресурсов (CSP-безопасно): ни http-ссылок в src/href, ни CDN
|
||||||
|
assert "src=\"http" not in PAGE_HTML
|
||||||
|
assert "href=\"http" not in PAGE_HTML
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_plot_png_returns_image_bytes(tmp_path):
|
||||||
|
from gausse.web.render import render_plot_png
|
||||||
|
from gausse.web.stats import overview
|
||||||
|
|
||||||
|
db = _make_db(tmp_path)
|
||||||
|
o = overview(db, None)
|
||||||
|
assert o["top"], "нужен реализуемый прогон для графика"
|
||||||
|
run_id = o["top"][0]["run_id"]
|
||||||
|
for kind in ("velocity", "field", "current"):
|
||||||
|
png = render_plot_png(db, run_id, kind)
|
||||||
|
assert png is not None and png[:8] == b"\x89PNG\r\n\x1a\n", f"{kind} не PNG"
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_plot_unknown_kind_returns_none(tmp_path):
|
||||||
|
from gausse.web.render import render_plot_png
|
||||||
|
|
||||||
|
db = _make_db(tmp_path)
|
||||||
|
o = _overview_top_id(db)
|
||||||
|
assert render_plot_png(db, o, "nonsense") is None
|
||||||
|
|
||||||
|
|
||||||
|
def _overview_top_id(db):
|
||||||
|
from gausse.web.stats import overview
|
||||||
|
|
||||||
|
return overview(db, None)["top"][0]["run_id"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_render_animation_gif_returns_gif_bytes(tmp_path):
|
||||||
|
from gausse.web.render import render_animation_gif
|
||||||
|
|
||||||
|
db = _make_db(tmp_path)
|
||||||
|
run_id = _overview_top_id(db)
|
||||||
|
gif = render_animation_gif(db, run_id)
|
||||||
|
assert gif is not None and gif[:6] in (b"GIF87a", b"GIF89a")
|
||||||
Reference in New Issue
Block a user