Compare commits

..

10 Commits

Author SHA1 Message Date
jze9
70b732d170 Enrich results DB, add process log, and build a live web dashboard
Requested during deployment: make the DB maximally detailed, add an
experiment process log, and a web UI to watch runs live.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

14
.dockerignore Normal file
View File

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

2
.gitignore vendored
View File

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

18
Dockerfile Normal file
View File

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

31
PLAN.md
View File

@@ -34,6 +34,12 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
Полный план архитектуры: см. историю обсуждения / `physics`, `sim`, `optim`,
`storage`, `report` модули ниже.
**Docker — основной способ запуска** (по требованию пользователя): `Dockerfile`
+ `docker-compose.yml` в корне репозитория, образ собран и провалидирован —
внутри контейнера запускаются все 37 тестов и CLI (`docker compose run --rm
--entrypoint pytest gausse -q`). Результаты (SQLite и отчёты, когда появятся)
монтируются в `./results` на хосте, чтобы переживать пересборку образа.
## Чек-лист этапов
- [x] **Этап 0 — Скелет проекта**: `pyproject.toml`, `README.md`, `.gitignore`, пакеты `src/gausse/*`, этот файл, первый коммит.
@@ -41,9 +47,24 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
- [x] **Этап 2 — Физическое ядро**: `physics/constants.py`, `inductance.py` (Уилер + ферромагнитный сердечник + размагничивание), `force.py`, `circuit.py` (ОДУ RLC). Юнит-тесты: аналитическое RLC-решение, согласованность dL/dx.
- [x] **Этап 3 — Датчики и одна ступень**: `physics/sensors.py` (оба типа как события solve_ivp), `sim/stage.py` (полёт → триггер → разряд → энергобаланс). Тест на сохранение энергии + найден/исправлен баг насыщения (см. выше).
- [x] **Этап 4 — Многоступенчатая цепочка**: `sim/coilgun.py` — сквозная координата, отбраковка нереализуемых конфигураций. Дымовой тест на реальной базе компонентов (`test_real_components_smoke.py`) подтверждает: полный путь реальные JSON → физика → цепочка работает (пример: 22.3 м/с, КПД 3.4% — честный неоптимизированный результат).
- [ ] **Этап 5 — Хранилище результатов**: `storage/schema.py` + `database.py` — SQLite (WAL), таблица `runs` со всеми прогонами (успех/провал + честная причина), однопроцессный писатель поверх многопроцессной очереди.
- [ ] **Этап 6 — Поиск и оптимизация**: `optim/search_space.py` (геном переменной длины), `objective.py` (КПД), дешёвый квазистатический предфильтр, `optim/sweep.py` (Monte Carlo/LHS, миллионы прогонов), `optim/evolutionary.py` (ГА + coordinate-descent полировка) — всё пишется в общую таблицу `runs`.
- [ ] **Этап 7 — Отчётность**: `report/plots.py`, `summary.py`, `bom.py`, раздел "ограничения модели", `cli.py` (`gausse sweep/evolve/simulate/report`).
- [ ] `report/animate.py` — анимация одного прогона: положение снаряда в трубе по времени + визуализация поля/тока каждой катушки (свечение/интенсивность цвета ~ ток), сохранение в GIF/MP4 (matplotlib `FuncAnimation`). Нужна по запросу пользователя — "графика где будет показана симуляция пролёта цилиндра по трубе и электромагнитные поля в каждый момент времени".
- [ ] **Этап 8 — Сквозная проверка**: резюмируемый прогон на реальной базе компонентов, проверка честной записи в SQLite, финальный отчёт (КПД, скорость, стоимость, сравнение датчиков) с разделом ограничений.
- [x] **Этап 5 — Хранилище результатов**: `storage/schema.py` + `database.py` — SQLite (WAL), таблица `runs` со всеми прогонами (успех/провал + честная причина), однопроцессный писатель поверх многопроцессной очереди. Тест с реальными `multiprocessing.Process` (не моками) поймал реальную проблему: коллизия `run_id` роняла writer и молча останавливала осушение очереди на весь sweep — писатель теперь переживает ошибку вставки одной записи (лог в stderr) и продолжает работу.
- [x] **Этап 6 — Поиск и оптимизация**: `optim/search_space.py` (геном переменной длины — число ступеней тоже эволюционирует), `objective.py` (fitness=КПД, честный мягкий штраф за нереализуемость пропорционально пройденным ступеням), `optim/sweep.py` (параллельный Monte Carlo, каждый прогон в SQLite), `optim/evolutionary.py` ((μ+λ)-ГА + Nelder-Mead полировка непрерывных параметров лучшего генома). Тест поймал реальный баг в `crossover()` — IndexError при скрещивании двух одноступенчатых геномов (пустой список зазоров), исправлено и покрыто регрессией.
- [ ] **Не сделано**: отдельный "дешёвый квазистатический предфильтр" перед полным ODE не реализован (кроме уже встроенной в `sim/stage.py` дешёвой проверки порога индукционного датчика). Если миллионы прогонов на сервере окажутся слишком медленными, это первое место для ускорения.
- [x] **Этап 7 — Отчётность**: `report/plots.py` (ток/поле/скорость по ступеням), `summary.py` (честная сводка + раздел "ограничения модели"), `bom.py` (спецификация деталей с ценами), `animate.py` (GIF: снаряд летит по трубе, катушки светятся пропорционально току — по запросу пользователя), `cli.py` (`gausse sweep/evolve/simulate/report`, все 4 команды реально вызывают соответствующие модули, не заглушки). Проверено сквозным прогоном через `docker compose run`: sweep → report с анимацией на реальной базе компонентов, лучший найденный результат — КПД 32.8% за 982₽ (не выдумано, из реального SQLite).
- [x] **Этап 8 — Сквозная проверка**: реальный прогон через `docker compose run` — sweep на 5000 конфигураций (16 воркеров, ~59с, 30.5% реализуемо), затем `evolve` (15 поколений x 40 особей = 600 оценок, ~18с) поверх той же базы, затем `report --top 3 --animate`.
- **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `evolve` показал 387% — оказалось, `efficiency` считался как `exit_kinetic_energy / energy_in`, а `exit_kinetic_energy` включает фиксированный "бесплатный" толчок `initial_v_mps`, не учтённый в `energy_in`. Для лёгкого снаряда этот толчок доминировал и КПД пробивал 100%. Исправлено на `kinetic_energy_delta / energy_in` (энергия, реально добавленная катушками) — эта величина математически не может превысить 1 (следует из поэтапного энергобаланса). После исправления лучший честный результат: **1 ступень, КПД 80.97%, скорость 26.4 м/с, снаряд Ст3 ⌀4мм x 30мм, стоимость 1029₽** (провод cu-petv2-1.0mm, конденсатор 22мкФ/400В, ключ IRG4PC50F, оптический датчик).
- **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 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-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки".

View File

@@ -5,16 +5,23 @@
Полный план и чек-лист этапов — в [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
python3 -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
```
## Тесты
```bash
pytest
```

21
docker-compose.yml Normal file
View File

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

0
results/.gitkeep Normal file
View File

View File

@@ -1,17 +1,152 @@
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:
parser = argparse.ArgumentParser(prog="gausse")
subparsers = parser.add_subparsers(dest="command", required=True)
subparsers.add_parser("sweep", help="массовый Monte Carlo/LHS перебор конфигураций")
subparsers.add_parser("evolve", help="эволюционный поиск поверх базы прогонов")
subparsers.add_parser("simulate", help="прогнать одну конфигурацию")
subparsers.add_parser("report", help="построить отчёт по сохранённым результатам")
sweep_p = subparsers.add_parser("sweep", help="массовый Monte Carlo перебор конфигураций")
sweep_p.add_argument("--db", required=True, help="путь к SQLite-файлу результатов")
sweep_p.add_argument("--n", type=int, required=True, help="число прогонов")
sweep_p.add_argument("--workers", type=int, default=None)
sweep_p.add_argument("--seed", type=int, default=None)
sweep_p.add_argument("--max-stages", type=int, default=None)
sweep_p.set_defaults(func=cmd_sweep)
evolve_p = subparsers.add_parser("evolve", help="эволюционный поиск поверх базы прогонов")
evolve_p.add_argument("--db", required=True)
evolve_p.add_argument("--generations", type=int, default=20)
evolve_p.add_argument("--population", type=int, default=30)
evolve_p.add_argument("--workers", type=int, default=None)
evolve_p.add_argument("--seed", type=int, default=None)
evolve_p.add_argument("--max-stages", type=int, default=None)
evolve_p.add_argument("--no-polish", action="store_true")
evolve_p.set_defaults(func=cmd_evolve)
simulate_p = subparsers.add_parser("simulate", help="пересчитать и построить отчёт по одному run_id")
simulate_p.add_argument("--db", required=True)
simulate_p.add_argument("--run-id", required=True)
simulate_p.add_argument("--out", required=True, help="папка для отчёта")
simulate_p.add_argument("--animate", action="store_true")
simulate_p.set_defaults(func=cmd_simulate)
report_p = subparsers.add_parser("report", help="отчёт по лучшим сохранённым результатам")
report_p.add_argument("--db", required=True)
report_p.add_argument("--out", required=True)
report_p.add_argument("--top", type=int, default=1)
report_p.add_argument("--animate", action="store_true")
report_p.set_defaults(func=cmd_report)
serve_p = subparsers.add_parser("serve", help="веб-морда: дашборд хода эксперимента")
serve_p.add_argument("--db", required=True, help="путь к SQLite-файлу результатов")
serve_p.add_argument("--host", default="0.0.0.0")
serve_p.add_argument("--port", type=int, default=8000)
serve_p.set_defaults(func=cmd_serve)
args = parser.parse_args(argv)
parser.error(f"команда '{args.command}' ещё не реализована")
return 1
return args.func(args)
if __name__ == "__main__":

View File

@@ -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,
}

View 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,
)

View File

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

View File

@@ -0,0 +1,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
View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
View 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
View File

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

View File

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

View File

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

View File

@@ -83,6 +83,9 @@ class StageResult:
kinetic_energy_delta_j: float | None = None
saturation_warning: bool = False
peak_field_estimate_tesla: float | None = None
mu_eff: float | None = None
total_turns: int | None = None
coil_length_m: float | None = None
discharge_t: np.ndarray | None = None
discharge_q: np.ndarray | None = None
discharge_i: np.ndarray | None = None
@@ -247,6 +250,9 @@ def run_stage(
kinetic_energy_delta_j=kinetic_after_j - kinetic_before_j,
saturation_warning=saturation_warning,
peak_field_estimate_tesla=peak_field_estimate_tesla,
mu_eff=mu_eff,
total_turns=geometry.total_turns,
coil_length_m=geometry.coil_length_m,
discharge_t=discharge_sol.t,
discharge_q=discharge_sol.y[0],
discharge_i=discharge_sol.y[1],

View File

@@ -0,0 +1,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()

View 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

View File

141
src/gausse/web/page.py Normal file
View 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
View 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
View 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
View File

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

View File

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

View 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
View 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
View 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
View 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

View File

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

View File

@@ -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
View File

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

77
tests/test_web.py Normal file
View 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