Compare commits
10 Commits
f22fd89612
...
70b732d170
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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"]
|
||||||
31
PLAN.md
31
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,24 @@ 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`.
|
||||||
|
- **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `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, оптический датчик).
|
||||||
|
- **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 5000 прогонах: индукционный датчик (`inductive-pickup-lm393`) дал **0.2% реализуемых** конфигураций (6 из 2965, где он стоял хотя бы на одной ступени), тогда как оптический (TCST2103) и Холла (A3144E) — **~45-47%** реализуемых каждый. Это количественно подтверждает опасение, высказанное в самом начале обсуждения плана: индукционная катушка-датчик ненадёжна именно потому, что требует скорости выше её порога (здесь — фиксированный старт 3 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости.
|
||||||
|
- Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат.
|
||||||
|
- [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. Ход эксперимента пишется в `<db>.log` (`optim/progress_log.py`): прогресс, доля реализуемых, скорость, ETA, отметки нового лучшего КПД. Веб-дашборд `gausse serve` (`src/gausse/web/`, только stdlib `http.server`, self-contained HTML) — счётчики, гистограмма КПД, топ конфигураций с drill-down, причины отказа, хвост лога, автообновление 3с. В `docker-compose.yml` сервис `web` на порту 8000. 77 тестов, проверено в Docker.
|
||||||
|
|
||||||
|
## Развёртывание на сервере (Proxmox 192.168.20.254 / VM 106 "test-math" = 192.168.20.45)
|
||||||
|
|
||||||
|
Обнаружено при осмотре: GTX 1070 (`10de:1b81`+аудио `10de:10f0`) стоит в Proxmox-хосте, **уже привязана к vfio-pci**, blacklist'ы nouveau/nvidia прописаны, IOMMU включён (`intel_iommu=on`), карта одна в IOMMU-группе 1. То есть хост заранее подготовлен под проброс — можно пробросить в VM **без перезагрузки хоста** (не заденет другие VM: nextcloud/minecraft/web-player). Целевая VM 106 = 5 ядер, 2ГБ RAM (мало для CUDA+Docker, поднять). GPU ей ещё не назначен.
|
||||||
|
|
||||||
|
- [ ] Пуш на gitea (`gitea.jze9.ru/jze9/gausse.git`) или rsync прямо в VM.
|
||||||
|
- [ ] `hostpci0: 0000:01:00,pcie=1` в конфиг VM 106, поднять RAM, перезагрузить VM 106.
|
||||||
|
- [ ] В VM: NVIDIA-драйвер + CUDA + Docker + nvidia-container-toolkit.
|
||||||
|
- [ ] Развернуть проект, запустить sweep (пока CPU!), поднять `gausse serve` (порт 8000).
|
||||||
|
- [ ] Дать доступ: веб-морда http://192.168.20.45:8000, SQLite `results/gausse.sqlite3`, лог `results/gausse.sqlite3.log`.
|
||||||
|
|
||||||
- [ ] **Этап 9 — GPU-ускорение массового sweep (сервер с GTX 1070)**: после того как CPU/`scipy.solve_ivp`-модель провалидирована тестами (Этап 2-4) — батч-версия интегратора с ФИКСИРОВАННЫМ шагом (RK4/полу-неявная схема), считающая сразу N траекторий параллельно как один тензор (`cupy`, если доступна CUDA, иначе векторизованный `numpy`/`numba`), для прогона по-настоящему миллионов конфигураций на сервере. Важно: сначала корректность на CPU, потом скорость на GPU — численные результаты GPU-пути должны сверяться с CPU-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки".
|
- [ ] **Этап 9 — GPU-ускорение массового sweep (сервер с GTX 1070)**: после того как CPU/`scipy.solve_ivp`-модель провалидирована тестами (Этап 2-4) — батч-версия интегратора с ФИКСИРОВАННЫМ шагом (RK4/полу-неявная схема), считающая сразу N траекторий параллельно как один тензор (`cupy`, если доступна CUDA, иначе векторизованный `numpy`/`numba`), для прогона по-настоящему миллионов конфигураций на сервере. Важно: сначала корректность на CPU, потом скорость на GPU — численные результаты GPU-пути должны сверяться с CPU-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки".
|
||||||
|
|||||||
19
README.md
19
README.md
@@ -5,16 +5,23 @@
|
|||||||
|
|
||||||
Полный план и чек-лист этапов — в [PLAN.md](PLAN.md).
|
Полный план и чек-лист этапов — в [PLAN.md](PLAN.md).
|
||||||
|
|
||||||
## Установка
|
## Установка (Docker — основной способ)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose build
|
||||||
|
docker compose run --rm --entrypoint pytest gausse -q # тесты
|
||||||
|
docker compose run --rm gausse sweep --n 1000 # CLI (после реализации Этапа 6)
|
||||||
|
```
|
||||||
|
|
||||||
|
Результаты (SQLite, отчёты) должны сохраняться в `./results`, примонтированный
|
||||||
|
в контейнер как `/app/results` (см. `docker-compose.yml`), чтобы переживать
|
||||||
|
пересборку образа.
|
||||||
|
|
||||||
|
## Установка без 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
|
||||||
```
|
```
|
||||||
|
|||||||
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,152 @@
|
|||||||
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)
|
||||||
|
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.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__":
|
||||||
|
|||||||
167
src/gausse/optim/evolutionary.py
Normal file
167
src/gausse/optim/evolutionary.py
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
"""(μ+λ)-эволюционный поиск поверх той же базы SQLite, что и sweep.
|
||||||
|
|
||||||
|
Каждый оценённый геном каждого поколения пишется в общую таблицу `runs`
|
||||||
|
(search_mode="evolve") — включая неудачные, с той же честной причиной
|
||||||
|
отказа. После эволюции — локальная doводка (Nelder-Mead) непрерывной
|
||||||
|
части лучшего найденного генома при замороженных дискретных выборах
|
||||||
|
компонентов.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import multiprocessing
|
||||||
|
import random
|
||||||
|
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, evaluate
|
||||||
|
from gausse.optim.progress_log import ProgressLogger, default_log_path
|
||||||
|
from gausse.optim.search_space import Genome, SearchBounds, crossover, mutate, repair, sample_genome
|
||||||
|
from gausse.storage.database import run_writer_process
|
||||||
|
from gausse.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 _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_od_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_od_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,
|
||||||
|
) -> dict:
|
||||||
|
n_workers = n_workers or multiprocessing.cpu_count()
|
||||||
|
rng = random.Random(seed)
|
||||||
|
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
|
||||||
|
|
||||||
|
logger = ProgressLogger(
|
||||||
|
log_path or default_log_path(db_path), mode="evolve", total=n_generations * population_size
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx = multiprocessing.get_context("spawn")
|
||||||
|
queue = ctx.Queue()
|
||||||
|
writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
|
||||||
|
writer.start()
|
||||||
|
|
||||||
|
population = [sample_genome(db, bounds, rng) for _ in range(population_size)]
|
||||||
|
best_pair: tuple[Genome, EvaluationResult] | None = None
|
||||||
|
n_evaluated = 0
|
||||||
|
|
||||||
|
try:
|
||||||
|
with ProcessPoolExecutor(
|
||||||
|
max_workers=n_workers,
|
||||||
|
mp_context=ctx,
|
||||||
|
initializer=worker_context.init_worker,
|
||||||
|
initargs=(data_dir, bounds),
|
||||||
|
) as executor:
|
||||||
|
for generation in range(n_generations):
|
||||||
|
pairs = list(executor.map(_evaluate_genome, population))
|
||||||
|
n_evaluated += len(pairs)
|
||||||
|
|
||||||
|
for genome, result in pairs:
|
||||||
|
record: RunRecord = build_run_record(genome, result, db, search_mode="evolve")
|
||||||
|
queue.put(record)
|
||||||
|
logger.update(result.feasible, result.efficiency)
|
||||||
|
if best_pair is None or result.fitness > best_pair[1].fitness:
|
||||||
|
best_pair = (genome, result)
|
||||||
|
|
||||||
|
pairs.sort(key=lambda pair: pair[1].fitness, reverse=True)
|
||||||
|
next_population = [copy.deepcopy(pair[0]) for pair in pairs[:elitism]]
|
||||||
|
while len(next_population) < population_size:
|
||||||
|
parent_a = _tournament_select(pairs, rng)
|
||||||
|
parent_b = _tournament_select(pairs, rng)
|
||||||
|
child = crossover(parent_a, parent_b, rng)
|
||||||
|
child = mutate(child, db, bounds, rng, rate=mutation_rate)
|
||||||
|
next_population.append(child)
|
||||||
|
population = next_population
|
||||||
|
finally:
|
||||||
|
logger.finish()
|
||||||
|
queue.put(None)
|
||||||
|
writer.join()
|
||||||
|
|
||||||
|
assert best_pair is not None
|
||||||
|
best_genome, best_result = best_pair
|
||||||
|
|
||||||
|
if polish and best_result.feasible:
|
||||||
|
polished_genome, polished_result = polish_best(best_genome, db, bounds)
|
||||||
|
if polished_result.fitness > best_result.fitness:
|
||||||
|
conn_queue = ctx.Queue()
|
||||||
|
polish_writer = ctx.Process(target=run_writer_process, args=(conn_queue, db_path))
|
||||||
|
polish_writer.start()
|
||||||
|
conn_queue.put(build_run_record(polished_genome, polished_result, db, search_mode="evolve-polish"))
|
||||||
|
conn_queue.put(None)
|
||||||
|
polish_writer.join()
|
||||||
|
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,
|
||||||
|
}
|
||||||
249
src/gausse/optim/objective.py
Normal file
249
src/gausse/optim/objective.py
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
"""Целевая функция: КПД — основная цель, стоимость и скорость — вторичные метрики.
|
||||||
|
|
||||||
|
Нереализуемые геномы не отбрасываются — получают "мягкий" штраф фитнеса,
|
||||||
|
пропорциональный тому, на какой ступени всё сломалось, чтобы у ГА был
|
||||||
|
градиент, а не стена (см. 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.inductance import air_core_inductance_wheeler, winding_geometry
|
||||||
|
from gausse.sim.coilgun import CoilgunResult, run_coilgun
|
||||||
|
from gausse.storage.schema import RunRecord
|
||||||
|
|
||||||
|
MODEL_VERSION = "gausse-physics-v1"
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
detail: 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:
|
||||||
|
"""Краткая сводка только по геному (без результатов симуляции).
|
||||||
|
|
||||||
|
Оставлена для обратной совместимости; подробную запись со всей физикой
|
||||||
|
и результатами по каждой ступени строит `build_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) -> dict:
|
||||||
|
"""Максимально подробная запись: параметры + вычисленная физика + результат каждой ступени.
|
||||||
|
|
||||||
|
Именно это пишется в SQLite (decoded_summary_json), чтобы по базе можно
|
||||||
|
было восстановить ВСЁ: расстояния между катушками и абсолютные позиции
|
||||||
|
вдоль трубы, номиналы всех деталей, геометрию намотки, посчитанные
|
||||||
|
индуктивность/сопротивление/пиковый ток и что произошло на каждой ступени.
|
||||||
|
"""
|
||||||
|
outcomes_by_index = {o.stage_index: o for o in coilgun_result.stage_outcomes}
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
"components": {
|
||||||
|
"wire": stage.wire.part_id,
|
||||||
|
"wire_material": stage.wire.material,
|
||||||
|
"wire_gauge_mm": stage.wire.gauge_mm,
|
||||||
|
"capacitor": stage.capacitor.part_number,
|
||||||
|
"capacitance_uf": stage.capacitor.capacitance_uf,
|
||||||
|
"capacitor_voltage_rating_v": stage.capacitor.voltage_v,
|
||||||
|
"switch": stage.switch.part_number,
|
||||||
|
"switch_kind": stage.switch.kind,
|
||||||
|
"sensor": stage.sensor.part_number,
|
||||||
|
"sensor_kind": stage.sensor.kind,
|
||||||
|
},
|
||||||
|
"winding": {
|
||||||
|
"turns_per_layer": stage.turns_per_layer,
|
||||||
|
"layers": stage.layers,
|
||||||
|
"total_turns": geometry.total_turns,
|
||||||
|
"coil_length_m": geometry.coil_length_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_ohm": r_wire,
|
||||||
|
"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
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
"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)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"tube_od_m": config.stages[0].tube_od_m if config.stages else None,
|
||||||
|
"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,
|
||||||
|
"mu_r": projectile.material.mu_r,
|
||||||
|
"b_sat_tesla": projectile.material.b_sat_tesla,
|
||||||
|
},
|
||||||
|
"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:
|
||||||
|
config, initial_x_m, initial_v_mps = decode(genome, db, bounds)
|
||||||
|
cost_rub = compute_cost_rub(config, db)
|
||||||
|
result = run_coilgun(config)
|
||||||
|
detail = build_detail(config, result, db, initial_x_m, initial_v_mps)
|
||||||
|
|
||||||
|
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={},
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
detail=detail,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord:
|
||||||
|
detail = result.detail if result.detail is not None else decoded_summary(genome, db)
|
||||||
|
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}с ===")
|
||||||
301
src/gausse/optim/search_space.py
Normal file
301
src/gausse/optim/search_space.py
Normal file
@@ -0,0 +1,301 @@
|
|||||||
|
"""Пространство поиска: геном переменной длины (число ступеней эволюционирует).
|
||||||
|
|
||||||
|
Геном кодирует только ИНДЕКСЫ в базу реальных компонентов (не сами
|
||||||
|
параметры) + непрерывные величины (расстояния, напряжение, геометрия
|
||||||
|
снаряда). Это гарантирует, что что бы ни нашёл поиск, оно собрано из
|
||||||
|
реально продающихся деталей, а не из выдуманных чисел.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import copy
|
||||||
|
import math
|
||||||
|
import random
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.sim.coilgun import CoilgunConfig
|
||||||
|
from gausse.sim.stage import ProjectileConfig, StageConfig
|
||||||
|
|
||||||
|
TUBE_WALL_CLEARANCE_M = 0.001
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SearchBounds:
|
||||||
|
min_stages: int = 1
|
||||||
|
max_stages: int = 4
|
||||||
|
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
|
||||||
|
tube_od_m_min: float = 0.009
|
||||||
|
tube_od_m_max: float = 0.02
|
||||||
|
projectile_diameter_m_min: float = 0.004
|
||||||
|
projectile_diameter_m_max: float = 0.008
|
||||||
|
projectile_length_m_min: float = 0.01
|
||||||
|
projectile_length_m_max: float = 0.03
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
@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
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ProjectileGene:
|
||||||
|
material_idx: int
|
||||||
|
diameter_m: float
|
||||||
|
length_m: float
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class Genome:
|
||||||
|
tube_od_m: float
|
||||||
|
stages: list[StageGene]
|
||||||
|
inter_stage_gaps_m: list[float]
|
||||||
|
projectile: ProjectileGene
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def sample_genome(db: ComponentDatabase, bounds: SearchBounds, rng: random.Random) -> Genome:
|
||||||
|
tube_od_m = rng.uniform(bounds.tube_od_m_min, bounds.tube_od_m_max)
|
||||||
|
max_diameter = min(bounds.projectile_diameter_m_max, tube_od_m - TUBE_WALL_CLEARANCE_M)
|
||||||
|
diameter_m = rng.uniform(bounds.projectile_diameter_m_min, max(max_diameter, bounds.projectile_diameter_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=rng.randrange(len(db.projectile_materials)),
|
||||||
|
diameter_m=diameter_m,
|
||||||
|
length_m=rng.uniform(bounds.projectile_length_m_min, bounds.projectile_length_m_max),
|
||||||
|
)
|
||||||
|
return Genome(tube_od_m=tube_od_m, stages=stages, inter_stage_gaps_m=gaps, projectile=projectile)
|
||||||
|
|
||||||
|
|
||||||
|
def repair(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Genome:
|
||||||
|
"""Приводит геном в границы после мутации/скрещивания (клэмп, не отбраковка)."""
|
||||||
|
genome.tube_od_m = _clip(genome.tube_od_m, bounds.tube_od_m_min, bounds.tube_od_m_max)
|
||||||
|
max_diameter = max(genome.tube_od_m - TUBE_WALL_CLEARANCE_M, bounds.projectile_diameter_m_min)
|
||||||
|
genome.projectile.diameter_m = _clip(
|
||||||
|
genome.projectile.diameter_m, bounds.projectile_diameter_m_min, max_diameter
|
||||||
|
)
|
||||||
|
genome.projectile.length_m = _clip(
|
||||||
|
genome.projectile.length_m, bounds.projectile_length_m_min, bounds.projectile_length_m_max
|
||||||
|
)
|
||||||
|
genome.projectile.material_idx = genome.projectile.material_idx % len(db.projectile_materials)
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
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
|
||||||
|
)
|
||||||
|
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)]
|
||||||
|
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)]
|
||||||
|
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_od_m = rng.uniform(bounds.tube_od_m_min, bounds.tube_od_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)
|
||||||
|
|
||||||
|
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_od_m=tube_source.tube_od_m,
|
||||||
|
stages=stages,
|
||||||
|
inter_stage_gaps_m=gaps,
|
||||||
|
projectile=copy.deepcopy(projectile_source.projectile),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def genome_to_dict(genome: Genome) -> dict:
|
||||||
|
return {
|
||||||
|
"tube_od_m": genome.tube_od_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:
|
||||||
|
return Genome(
|
||||||
|
tube_od_m=data["tube_od_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
|
||||||
@@ -18,6 +18,15 @@ 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) -> float:
|
||||||
@@ -36,7 +45,7 @@ def make_inductive_sensor_event(
|
|||||||
) -> SensorEvent:
|
) -> SensorEvent:
|
||||||
def event(t: float, state: np.ndarray) -> float:
|
def event(t: float, state: np.ndarray) -> 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
|
||||||
|
|||||||
105
src/gausse/report/animate.py
Normal file
105
src/gausse/report/animate.py
Normal file
@@ -0,0 +1,105 @@
|
|||||||
|
"""Анимация одного прогона: снаряд летит по трубе, катушки светятся по току.
|
||||||
|
|
||||||
|
Строит СПЛОШНУЮ траекторию (баллистический подлёт к датчику + разряд по
|
||||||
|
каждой ступени, сшитые по глобальному времени/координате) и рендерит кадр
|
||||||
|
за кадром: положение снаряда в трубе + "свечение" каждой катушки
|
||||||
|
пропорционально её текущему току (яркая -- сильное поле, тусклая -- нет).
|
||||||
|
Сохраняется как GIF через встроенный в matplotlib PillowWriter.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
def _build_global_timeline(result: CoilgunResult, config: CoilgunConfig, fps: int):
|
||||||
|
"""Возвращает (times, positions, per_stage_current_at_time) с равномерным шагом."""
|
||||||
|
segments_t = []
|
||||||
|
segments_x = []
|
||||||
|
segments_i = [] # ток КАЖДОЙ катушки в момент времени (список массивов той же длины, что t)
|
||||||
|
n_stages = len(config.stages)
|
||||||
|
|
||||||
|
for outcome in result.stage_outcomes:
|
||||||
|
r = outcome.result
|
||||||
|
# Баллистический подлёт к датчику: постоянная скорость, восстанавливается аналитически
|
||||||
|
# (в модели v1 нет трения на этом участке — см. sim/stage.py::_ballistic_derivatives).
|
||||||
|
t_sensor = r.t_sensor_s if r.t_sensor_s is not None else 0.0
|
||||||
|
flight_t = np.linspace(0.0, t_sensor, max(int(t_sensor * fps) + 2, 2))
|
||||||
|
flight_x = outcome.entry_x_m + outcome.entry_v_mps * flight_t
|
||||||
|
flight_global_t = outcome.time_offset_s + flight_t
|
||||||
|
flight_global_x = outcome.global_coil_center_m + flight_x
|
||||||
|
flight_currents = [np.zeros_like(flight_t) for _ in range(n_stages)]
|
||||||
|
flight_currents[outcome.stage_index] = np.zeros_like(flight_t)
|
||||||
|
segments_t.append(flight_global_t)
|
||||||
|
segments_x.append(flight_global_x)
|
||||||
|
segments_i.append(flight_currents)
|
||||||
|
|
||||||
|
if r.discharge_t is not None:
|
||||||
|
discharge_global_t = outcome.time_offset_s + (r.t_fire_s or 0.0) + r.discharge_t
|
||||||
|
discharge_global_x = outcome.global_coil_center_m + r.discharge_x
|
||||||
|
discharge_currents = [np.zeros_like(r.discharge_t) for _ in range(n_stages)]
|
||||||
|
discharge_currents[outcome.stage_index] = r.discharge_i
|
||||||
|
segments_t.append(discharge_global_t)
|
||||||
|
segments_x.append(discharge_global_x)
|
||||||
|
segments_i.append(discharge_currents)
|
||||||
|
|
||||||
|
times = np.concatenate(segments_t)
|
||||||
|
positions = np.concatenate(segments_x)
|
||||||
|
currents = [np.concatenate([seg[stage] for seg in segments_i]) for stage in range(n_stages)]
|
||||||
|
|
||||||
|
order = np.argsort(times)
|
||||||
|
times = times[order]
|
||||||
|
positions = positions[order]
|
||||||
|
currents = [c[order] for c in currents]
|
||||||
|
return times, positions, currents
|
||||||
|
|
||||||
|
|
||||||
|
def animate_run(result: CoilgunResult, config: CoilgunConfig, out_path: str, fps: int = 30) -> str:
|
||||||
|
if not result.stage_outcomes:
|
||||||
|
raise ValueError("нечего анимировать: нет ни одной ступени в результате")
|
||||||
|
|
||||||
|
times, positions, currents = _build_global_timeline(result, config, fps)
|
||||||
|
n_frames = min(len(times), fps * 10) # ограничение на длину анимации
|
||||||
|
frame_idx = np.linspace(0, len(times) - 1, n_frames).astype(int)
|
||||||
|
|
||||||
|
coil_centers = [o.global_coil_center_m for o in result.stage_outcomes]
|
||||||
|
max_current_per_stage = [max(np.max(np.abs(c)), 1e-9) for c in currents]
|
||||||
|
|
||||||
|
fig, ax = plt.subplots(figsize=(8, 3))
|
||||||
|
tube_min, tube_max = positions.min(), positions.max()
|
||||||
|
ax.set_xlim(tube_min * 1e3 - 5, tube_max * 1e3 + 5)
|
||||||
|
ax.set_ylim(-1, 1)
|
||||||
|
ax.set_xlabel("положение, мм")
|
||||||
|
ax.set_yticks([])
|
||||||
|
ax.set_title("Пролёт снаряда через ступени")
|
||||||
|
|
||||||
|
tube_line = ax.axhline(0, color="gray", linewidth=2, zorder=1)
|
||||||
|
coil_dots = [
|
||||||
|
ax.scatter([c * 1e3], [0], s=400, c="lightgray", edgecolors="black", zorder=2)
|
||||||
|
for c in coil_centers
|
||||||
|
]
|
||||||
|
slug_dot = ax.scatter([], [], s=150, c="red", zorder=3)
|
||||||
|
time_text = ax.text(0.02, 0.9, "", transform=ax.transAxes)
|
||||||
|
|
||||||
|
def update(frame_i):
|
||||||
|
idx = frame_idx[frame_i]
|
||||||
|
slug_dot.set_offsets([[positions[idx] * 1e3, 0]])
|
||||||
|
for stage_idx, dots in enumerate(coil_dots):
|
||||||
|
intensity = abs(currents[stage_idx][idx]) / max_current_per_stage[stage_idx]
|
||||||
|
dots.set_color(plt.cm.autumn(1.0 - min(intensity, 1.0)))
|
||||||
|
time_text.set_text(f"t = {times[idx] * 1e3:.3f} мс")
|
||||||
|
return [slug_dot, time_text, *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
|
||||||
94
src/gausse/report/bom.py
Normal file
94
src/gausse/report/bom.py
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
"""Спецификация деталей (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 = ""
|
||||||
|
|
||||||
|
|
||||||
|
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})",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
lines.append(
|
||||||
|
BomLine(
|
||||||
|
stage_index=i,
|
||||||
|
part=f"Конденсатор {stage.capacitor.part_number}",
|
||||||
|
quantity="1 шт",
|
||||||
|
unit_price_rub=stage.capacitor.price,
|
||||||
|
total_price_rub=stage.capacitor.price,
|
||||||
|
note=f"{stage.capacitor.capacitance_uf}мкФ {stage.capacitor.voltage_v}В, заряд {stage.charge_voltage_v:.0f}В",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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=stage.switch.kind,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
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}мм",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
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Примечание"]
|
||||||
|
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}"
|
||||||
|
)
|
||||||
|
rows.append(f"\t\t\t\tИТОГО: {total_cost_rub(lines):.2f}₽\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,
|
||||||
|
|||||||
@@ -83,6 +83,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
|
||||||
@@ -247,6 +250,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],
|
||||||
|
|||||||
116
src/gausse/storage/database.py
Normal file
116
src/gausse/storage/database.py
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
"""SQLite-хранилище прогонов: WAL-режим + однопроцессный писатель.
|
||||||
|
|
||||||
|
Множество воркеров (Stage 6, `ProcessPoolExecutor`) не пишут в SQLite
|
||||||
|
напрямую — каждый кладёт `RunRecord` в `multiprocessing.Queue`, а
|
||||||
|
единственный процесс-писатель (`run_writer_process`) читает очередь и
|
||||||
|
пишет в базу последовательно. Это исключает конкурентную запись в один
|
||||||
|
файл SQLite, которая иначе могла бы повредить базу или потерять прогоны.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sqlite3
|
||||||
|
import sys
|
||||||
|
from dataclasses import asdict, fields
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.storage.schema import CREATE_INDEXES_SQL, CREATE_RUNS_TABLE_SQL, RunRecord
|
||||||
|
|
||||||
|
_COLUMNS = [f.name for f in fields(RunRecord)]
|
||||||
|
|
||||||
|
|
||||||
|
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")
|
||||||
|
conn.execute(CREATE_RUNS_TABLE_SQL)
|
||||||
|
for statement in CREATE_INDEXES_SQL:
|
||||||
|
conn.execute(statement)
|
||||||
|
conn.commit()
|
||||||
|
return 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[RunRecord]) -> None:
|
||||||
|
if not records:
|
||||||
|
return
|
||||||
|
placeholders = ", ".join(f":{name}" for name in _COLUMNS)
|
||||||
|
columns = ", ".join(_COLUMNS)
|
||||||
|
conn.executemany(
|
||||||
|
f"INSERT INTO runs ({columns}) VALUES ({placeholders})",
|
||||||
|
[_record_to_params(r) for r in records],
|
||||||
|
)
|
||||||
|
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_runs(
|
||||||
|
conn: sqlite3.Connection,
|
||||||
|
feasible: bool | None = None,
|
||||||
|
min_efficiency: float | None = None,
|
||||||
|
order_by_efficiency_desc: bool = False,
|
||||||
|
limit: int | None = None,
|
||||||
|
) -> list[RunRecord]:
|
||||||
|
conn.row_factory = sqlite3.Row
|
||||||
|
query = "SELECT * FROM runs WHERE 1=1"
|
||||||
|
params: list = []
|
||||||
|
if feasible is not None:
|
||||||
|
query += " AND feasible = ?"
|
||||||
|
params.append(int(feasible))
|
||||||
|
if min_efficiency is not None:
|
||||||
|
query += " AND efficiency >= ?"
|
||||||
|
params.append(min_efficiency)
|
||||||
|
if 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` из очереди и пишет в SQLite, пока не придёт None.
|
||||||
|
|
||||||
|
Предназначен для запуска как отдельный `multiprocessing.Process` —
|
||||||
|
единственный writer на файл базы, пока воркеры sweep/evolve только
|
||||||
|
кладут результаты в очередь.
|
||||||
|
|
||||||
|
Один "плохой" прогон (например, коллизия run_id) не должен уронить
|
||||||
|
писатель и остановить осушение очереди на много часов sweep — при
|
||||||
|
ошибке вставки конкретная запись пропускается с сообщением в stderr,
|
||||||
|
а не молча и не ценой падения всего процесса.
|
||||||
|
"""
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
try:
|
||||||
|
while True:
|
||||||
|
record = queue.get()
|
||||||
|
if record is None:
|
||||||
|
break
|
||||||
|
try:
|
||||||
|
insert_run(conn, record)
|
||||||
|
except sqlite3.Error as exc:
|
||||||
|
print(f"[storage] не удалось записать run_id={record.run_id}: {exc}", file=sys.stderr)
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
50
src/gausse/storage/schema.py
Normal file
50
src/gausse/storage/schema.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
"""Схема таблицы `runs` — ВСЕ прогоны (успешные и нет), без прикрас.
|
||||||
|
|
||||||
|
Нереализуемые конфигурации хранятся наравне с успешными: `feasible=0` +
|
||||||
|
`infeasible_reason` с честной причиной. Ничего не отбрасывается перед
|
||||||
|
записью — отбор "хороших" результатов делается запросом к этой таблице
|
||||||
|
постфактум, а не фильтрацией на входе.
|
||||||
|
"""
|
||||||
|
|
||||||
|
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,
|
||||||
|
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_search_mode ON runs(search_mode)",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@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
141
src/gausse/web/page.py
Normal file
141
src/gausse/web/page.py
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
"""Самодостаточная 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; }
|
||||||
|
.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; }
|
||||||
|
.close { float:right; cursor:pointer; color:#8b949e; }
|
||||||
|
a { color:#58a6ff; }
|
||||||
|
</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">—</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid2">
|
||||||
|
<section>
|
||||||
|
<h2>Топ конфигураций по КПД</h2>
|
||||||
|
<table>
|
||||||
|
<thead><tr><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><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);
|
||||||
|
|
||||||
|
async function refresh(){
|
||||||
|
try {
|
||||||
|
const r = await fetch('/api/overview'); const o = await r.json();
|
||||||
|
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;
|
||||||
|
document.getElementById('c-best').textContent = fmtPct(best);
|
||||||
|
|
||||||
|
const top = document.getElementById('top'); top.innerHTML='';
|
||||||
|
(o.top||[]).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>`;
|
||||||
|
tr.onclick=()=>showDetail(t.run_id);
|
||||||
|
top.appendChild(tr);
|
||||||
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
const el=document.getElementById('detail'); el.style.display='block'; el.scrollIntoView({behavior:'smooth'});
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh(); setInterval(refresh, 3000);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
"""
|
||||||
72
src/gausse/web/server.py
Normal file
72
src/gausse/web/server.py
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
"""Веб-морда `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.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
|
||||||
|
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()
|
||||||
96
src/gausse/web/stats.py
Normal file
96
src/gausse/web/stats.py
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"""Агрегаты по таблице `runs` для веб-морды — считаются запросами к SQLite."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from gausse.storage.database import fetch_runs, open_connection
|
||||||
|
|
||||||
|
|
||||||
|
def _tail_log(log_path: Path, n_lines: int = 40) -> list[str]:
|
||||||
|
if not log_path.exists():
|
||||||
|
return []
|
||||||
|
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||||
|
return lines[-n_lines:]
|
||||||
|
|
||||||
|
|
||||||
|
def overview(db_path: Path, log_path: Path | None = None, top_n: int = 15) -> dict:
|
||||||
|
"""Сводка для дашборда: счётчики, гистограмма КПД, топ конфигураций, хвост лога."""
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
try:
|
||||||
|
total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
||||||
|
feasible = conn.execute("SELECT COUNT(*) FROM runs WHERE feasible=1").fetchone()[0]
|
||||||
|
infeasible = total - feasible
|
||||||
|
|
||||||
|
# распределение по типу датчика ступени 0 (реализуемо / всего)
|
||||||
|
by_mode = {}
|
||||||
|
for mode, cnt in conn.execute("SELECT search_mode, COUNT(*) FROM runs GROUP BY search_mode"):
|
||||||
|
by_mode[mode] = cnt
|
||||||
|
|
||||||
|
# причины отказа (топ)
|
||||||
|
reasons = []
|
||||||
|
for reason, cnt in conn.execute(
|
||||||
|
"SELECT infeasible_reason, COUNT(*) c FROM runs WHERE feasible=0 AND infeasible_reason IS NOT NULL "
|
||||||
|
"GROUP BY infeasible_reason ORDER BY c DESC LIMIT 10"
|
||||||
|
):
|
||||||
|
short = reason.split(":", 1)[0] if reason else reason
|
||||||
|
reasons.append({"reason": short, "count": cnt})
|
||||||
|
|
||||||
|
# гистограмма КПД (реализуемые)
|
||||||
|
buckets = [0] * 10 # 0-10%,...,90-100%
|
||||||
|
for (eff,) in conn.execute("SELECT efficiency FROM runs WHERE feasible=1 AND efficiency IS NOT NULL"):
|
||||||
|
idx = min(int(eff * 10), 9)
|
||||||
|
if idx >= 0:
|
||||||
|
buckets[idx] += 1
|
||||||
|
|
||||||
|
best_rows = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=top_n)
|
||||||
|
top = []
|
||||||
|
for r in best_rows:
|
||||||
|
detail = json.loads(r.decoded_summary_json) if r.decoded_summary_json else {}
|
||||||
|
top.append(
|
||||||
|
{
|
||||||
|
"run_id": r.run_id,
|
||||||
|
"search_mode": r.search_mode,
|
||||||
|
"efficiency": r.efficiency,
|
||||||
|
"exit_velocity_mps": r.exit_velocity_mps,
|
||||||
|
"cost_rub": r.cost_rub,
|
||||||
|
"n_stages": detail.get("n_stages"),
|
||||||
|
"timestamp": r.timestamp,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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,
|
||||||
|
"log_tail": _tail_log(log_path, 40) if log_path else [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def run_detail(db_path: Path, run_id: str) -> dict | None:
|
||||||
|
conn = open_connection(db_path)
|
||||||
|
try:
|
||||||
|
rows = fetch_runs(conn)
|
||||||
|
row = next((r for r in rows if r.run_id == run_id), None)
|
||||||
|
if row is None:
|
||||||
|
return None
|
||||||
|
return {
|
||||||
|
"run_id": row.run_id,
|
||||||
|
"search_mode": row.search_mode,
|
||||||
|
"feasible": row.feasible,
|
||||||
|
"infeasible_reason": row.infeasible_reason,
|
||||||
|
"efficiency": row.efficiency,
|
||||||
|
"exit_velocity_mps": row.exit_velocity_mps,
|
||||||
|
"cost_rub": row.cost_rub,
|
||||||
|
"timestamp": row.timestamp,
|
||||||
|
"detail": json.loads(row.decoded_summary_json) if row.decoded_summary_json else {},
|
||||||
|
"energy_breakdown": json.loads(row.energy_breakdown_json) if row.energy_breakdown_json else {},
|
||||||
|
}
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
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(
|
||||||
|
|||||||
65
tests/test_evolutionary.py
Normal file
65
tests/test_evolutionary.py
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
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 test_run_evolution_writes_all_evaluations_and_returns_summary(tmp_path):
|
||||||
|
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 count_runs(conn) == 12
|
||||||
|
rows = fetch_runs(conn)
|
||||||
|
assert all(r.search_mode == "evolve" for r in rows)
|
||||||
|
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
|
||||||
60
tests/test_objective.py
Normal file
60
tests/test_objective.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
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
|
||||||
|
assert 0.0 <= 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"])
|
||||||
113
tests/test_report.py
Normal file
113
tests/test_report.py
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
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")
|
||||||
|
switch = next(s for s in DB.switches if s.part_number == "IRFP250PBF")
|
||||||
|
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=350.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
|
||||||
106
tests/test_search_space.py
Normal file
106
tests/test_search_space.py
Normal file
@@ -0,0 +1,106 @@
|
|||||||
|
import random
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from gausse.components.database import ComponentDatabase
|
||||||
|
from gausse.optim.search_space import (
|
||||||
|
TUBE_WALL_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_od_m_min <= genome.tube_od_m <= BOUNDS.tube_od_m_max
|
||||||
|
assert genome.projectile.diameter_m <= genome.tube_od_m - TUBE_WALL_CLEARANCE_M
|
||||||
|
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_od_m_min <= genome.tube_od_m <= BOUNDS.tube_od_m_max
|
||||||
|
assert genome.projectile.diameter_m <= genome.tube_od_m - TUBE_WALL_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 - порог
|
||||||
|
|||||||
126
tests/test_storage_database.py
Normal file
126
tests/test_storage_database.py
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
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 _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()
|
||||||
77
tests/test_web.py
Normal file
77
tests/test_web.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
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_ohm" in stage0["electrical"]
|
||||||
|
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
|
||||||
Reference in New Issue
Block a user