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>
This commit is contained in:
jze9
2026-07-07 00:27:50 +05:00
parent d487021c2f
commit 70b732d170
12 changed files with 636 additions and 5 deletions

12
PLAN.md
View File

@@ -55,4 +55,16 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
- **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `evolve` показал 387% — оказалось, `efficiency` считался как `exit_kinetic_energy / energy_in`, а `exit_kinetic_energy` включает фиксированный "бесплатный" толчок `initial_v_mps`, не учтённый в `energy_in`. Для лёгкого снаряда этот толчок доминировал и КПД пробивал 100%. Исправлено на `kinetic_energy_delta / energy_in` (энергия, реально добавленная катушками) — эта величина математически не может превысить 1 (следует из поэтапного энергобаланса). После исправления лучший честный результат: **1 ступень, КПД 80.97%, скорость 26.4 м/с, снаряд Ст3 ⌀4мм x 30мм, стоимость 1029₽** (провод cu-petv2-1.0mm, конденсатор 22мкФ/400В, ключ IRG4PC50F, оптический датчик). - **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `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 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости. - **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 5000 прогонах: индукционный датчик (`inductive-pickup-lm393`) дал **0.2% реализуемых** конфигураций (6 из 2965, где он стоял хотя бы на одной ступени), тогда как оптический (TCST2103) и Холла (A3144E) — **~45-47%** реализуемых каждый. Это количественно подтверждает опасение, высказанное в самом начале обсуждения плана: индукционная катушка-датчик ненадёжна именно потому, что требует скорости выше её порога (здесь — фиксированный старт 3 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости.
- Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат. - Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат.
- [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. Ход эксперимента пишется в `<db>.log` (`optim/progress_log.py`): прогресс, доля реализуемых, скорость, ETA, отметки нового лучшего КПД. Веб-дашборд `gausse serve` (`src/gausse/web/`, только stdlib `http.server`, self-contained HTML) — счётчики, гистограмма КПД, топ конфигураций с drill-down, причины отказа, хвост лога, автообновление 3с. В `docker-compose.yml` сервис `web` на порту 8000. 77 тестов, проверено в Docker.
## Развёртывание на сервере (Proxmox 192.168.20.254 / VM 106 "test-math" = 192.168.20.45)
Обнаружено при осмотре: GTX 1070 (`10de:1b81`+аудио `10de:10f0`) стоит в Proxmox-хосте, **уже привязана к vfio-pci**, blacklist'ы nouveau/nvidia прописаны, IOMMU включён (`intel_iommu=on`), карта одна в IOMMU-группе 1. То есть хост заранее подготовлен под проброс — можно пробросить в VM **без перезагрузки хоста** (не заденет другие VM: nextcloud/minecraft/web-player). Целевая VM 106 = 5 ядер, 2ГБ RAM (мало для CUDA+Docker, поднять). GPU ей ещё не назначен.
- [ ] Пуш на gitea (`gitea.jze9.ru/jze9/gausse.git`) или rsync прямо в VM.
- [ ] `hostpci0: 0000:01:00,pcie=1` в конфиг VM 106, поднять RAM, перезагрузить VM 106.
- [ ] В VM: NVIDIA-драйвер + CUDA + Docker + nvidia-container-toolkit.
- [ ] Развернуть проект, запустить sweep (пока CPU!), поднять `gausse serve` (порт 8000).
- [ ] Дать доступ: веб-морда http://192.168.20.45:8000, SQLite `results/gausse.sqlite3`, лог `results/gausse.sqlite3.log`.
- [ ] **Этап 9 — GPU-ускорение массового sweep (сервер с GTX 1070)**: после того как CPU/`scipy.solve_ivp`-модель провалидирована тестами (Этап 2-4) — батч-версия интегратора с ФИКСИРОВАННЫМ шагом (RK4/полу-неявная схема), считающая сразу N траекторий параллельно как один тензор (`cupy`, если доступна CUDA, иначе векторизованный `numpy`/`numba`), для прогона по-настоящему миллионов конфигураций на сервере. Важно: сначала корректность на CPU, потом скорость на GPU — численные результаты GPU-пути должны сверяться с CPU-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки". - [ ] **Этап 9 — GPU-ускорение массового sweep (сервер с GTX 1070)**: после того как CPU/`scipy.solve_ivp`-модель провалидирована тестами (Этап 2-4) — батч-версия интегратора с ФИКСИРОВАННЫМ шагом (RK4/полу-неявная схема), считающая сразу N траекторий параллельно как один тензор (`cupy`, если доступна CUDA, иначе векторизованный `numpy`/`numba`), для прогона по-настоящему миллионов конфигураций на сервере. Важно: сначала корректность на CPU, потом скорость на GPU — численные результаты GPU-пути должны сверяться с CPU-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки".

View File

@@ -1,4 +1,18 @@
services: 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: gausse:
build: . build: .
volumes: volumes:

View File

@@ -12,6 +12,7 @@ from gausse.report.plots import save_all
from gausse.report.summary import build_summary, render_summary_json, render_summary_text from gausse.report.summary import build_summary, render_summary_json, render_summary_text
from gausse.sim.coilgun import run_coilgun from gausse.sim.coilgun import run_coilgun
from gausse.storage.database import fetch_runs, open_connection from gausse.storage.database import fetch_runs, open_connection
from gausse.web.server import serve as web_serve
def _bounds_from_args(args) -> SearchBounds: def _bounds_from_args(args) -> SearchBounds:
@@ -66,6 +67,11 @@ def _write_full_report(run_id: str, genome_json: str, out_dir: Path, animate: bo
print(f"run_id={run_id}: отчёт записан в {out_dir}") 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: def cmd_report(args) -> int:
conn = open_connection(Path(args.db)) conn = open_connection(Path(args.db))
rows = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=args.top) rows = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=args.top)
@@ -133,6 +139,12 @@ def main(argv: list[str] | None = None) -> int:
report_p.add_argument("--animate", action="store_true") report_p.add_argument("--animate", action="store_true")
report_p.set_defaults(func=cmd_report) report_p.set_defaults(func=cmd_report)
serve_p = subparsers.add_parser("serve", help="веб-морда: дашборд хода эксперимента")
serve_p.add_argument("--db", required=True, help="путь к SQLite-файлу результатов")
serve_p.add_argument("--host", default="0.0.0.0")
serve_p.add_argument("--port", type=int, default=8000)
serve_p.set_defaults(func=cmd_serve)
args = parser.parse_args(argv) args = parser.parse_args(argv)
return args.func(args) return args.func(args)

View File

@@ -19,6 +19,7 @@ from scipy.optimize import minimize
from gausse.components.database import ComponentDatabase from gausse.components.database import ComponentDatabase
from gausse.optim import worker_context from gausse.optim import worker_context
from gausse.optim.objective import EvaluationResult, build_run_record, evaluate from gausse.optim.objective import EvaluationResult, build_run_record, 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.optim.search_space import Genome, SearchBounds, crossover, mutate, repair, sample_genome
from gausse.storage.database import run_writer_process from gausse.storage.database import run_writer_process
from gausse.storage.schema import RunRecord from gausse.storage.schema import RunRecord
@@ -92,11 +93,16 @@ def run_evolution(
elitism: int = 2, elitism: int = 2,
mutation_rate: float = 0.2, mutation_rate: float = 0.2,
polish: bool = True, polish: bool = True,
log_path: Path | None = None,
) -> dict: ) -> dict:
n_workers = n_workers or multiprocessing.cpu_count() n_workers = n_workers or multiprocessing.cpu_count()
rng = random.Random(seed) rng = random.Random(seed)
db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load() db = ComponentDatabase.load(data_dir) if data_dir else ComponentDatabase.load()
logger = ProgressLogger(
log_path or default_log_path(db_path), mode="evolve", total=n_generations * population_size
)
ctx = multiprocessing.get_context("spawn") ctx = multiprocessing.get_context("spawn")
queue = ctx.Queue() queue = ctx.Queue()
writer = ctx.Process(target=run_writer_process, args=(queue, db_path)) writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
@@ -120,6 +126,7 @@ def run_evolution(
for genome, result in pairs: for genome, result in pairs:
record: RunRecord = build_run_record(genome, result, db, search_mode="evolve") record: RunRecord = build_run_record(genome, result, db, search_mode="evolve")
queue.put(record) queue.put(record)
logger.update(result.feasible, result.efficiency)
if best_pair is None or result.fitness > best_pair[1].fitness: if best_pair is None or result.fitness > best_pair[1].fitness:
best_pair = (genome, result) best_pair = (genome, result)
@@ -133,6 +140,7 @@ def run_evolution(
next_population.append(child) next_population.append(child)
population = next_population population = next_population
finally: finally:
logger.finish()
queue.put(None) queue.put(None)
writer.join() writer.join()

View File

@@ -6,14 +6,17 @@
""" """
import json import json
import math
import uuid import uuid
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime, timezone from datetime import datetime, timezone
import numpy as np
from gausse.components.database import ComponentDatabase from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import Genome, SearchBounds, decode, genome_to_dict from gausse.optim.search_space import Genome, SearchBounds, decode, genome_to_dict
from gausse.physics.inductance import winding_geometry from gausse.physics.inductance import air_core_inductance_wheeler, winding_geometry
from gausse.sim.coilgun import run_coilgun from gausse.sim.coilgun import CoilgunResult, run_coilgun
from gausse.storage.schema import RunRecord from gausse.storage.schema import RunRecord
MODEL_VERSION = "gausse-physics-v1" MODEL_VERSION = "gausse-physics-v1"
@@ -29,6 +32,7 @@ class EvaluationResult:
efficiency: float | None = None efficiency: float | None = None
exit_velocity_mps: float | None = None exit_velocity_mps: float | None = None
energy_breakdown: dict = None energy_breakdown: dict = None
detail: dict = None
def compute_cost_rub(config, db: ComponentDatabase) -> float: def compute_cost_rub(config, db: ComponentDatabase) -> float:
@@ -44,7 +48,18 @@ def compute_cost_rub(config, db: ComponentDatabase) -> float:
return total 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: def decoded_summary(genome: Genome, db: ComponentDatabase) -> dict:
"""Краткая сводка только по геному (без результатов симуляции).
Оставлена для обратной совместимости; подробную запись со всей физикой
и результатами по каждой ступени строит `build_detail` (её и пишем в БД).
"""
material = db.projectile_materials[genome.projectile.material_idx % len(db.projectile_materials)] material = db.projectile_materials[genome.projectile.material_idx % len(db.projectile_materials)]
stages_summary = [] stages_summary = []
for gene in genome.stages: for gene in genome.stages:
@@ -73,10 +88,116 @@ def decoded_summary(genome: Genome, db: ComponentDatabase) -> dict:
} }
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: def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> EvaluationResult:
config, _initial_x_m, _initial_v_mps = decode(genome, db, bounds) config, initial_x_m, initial_v_mps = decode(genome, db, bounds)
cost_rub = compute_cost_rub(config, db) cost_rub = compute_cost_rub(config, db)
result = run_coilgun(config) result = run_coilgun(config)
detail = build_detail(config, result, db, initial_x_m, initial_v_mps)
if not result.feasible: if not result.feasible:
n_stages = len(config.stages) n_stages = len(config.stages)
@@ -89,6 +210,7 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
reason=result.reason, reason=result.reason,
failed_stage_index=result.failed_stage_index, failed_stage_index=result.failed_stage_index,
energy_breakdown={}, energy_breakdown={},
detail=detail,
) )
energy_breakdown = { energy_breakdown = {
@@ -104,17 +226,18 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
efficiency=result.efficiency, efficiency=result.efficiency,
exit_velocity_mps=result.exit_v_mps, exit_velocity_mps=result.exit_v_mps,
energy_breakdown=energy_breakdown, energy_breakdown=energy_breakdown,
detail=detail,
) )
def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord: def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord:
summary = decoded_summary(genome, db) detail = result.detail if result.detail is not None else decoded_summary(genome, db)
return RunRecord( return RunRecord(
run_id=str(uuid.uuid4()), run_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(), timestamp=datetime.now(timezone.utc).isoformat(),
search_mode=search_mode, search_mode=search_mode,
genome_json=json.dumps(genome_to_dict(genome)), genome_json=json.dumps(genome_to_dict(genome)),
decoded_summary_json=json.dumps(summary, ensure_ascii=False), decoded_summary_json=json.dumps(detail, ensure_ascii=False),
feasible=result.feasible, feasible=result.feasible,
model_version=MODEL_VERSION, model_version=MODEL_VERSION,
infeasible_reason=result.reason, infeasible_reason=result.reason,

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

@@ -14,6 +14,7 @@ from typing import Callable
from gausse.optim import worker_context from gausse.optim import worker_context
from gausse.optim.objective import MODEL_VERSION, build_run_record, evaluate 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.optim.search_space import SearchBounds, sample_genome
from gausse.storage.database import run_writer_process from gausse.storage.database import run_writer_process
from gausse.storage.schema import RunRecord from gausse.storage.schema import RunRecord
@@ -36,16 +37,20 @@ def run_sweep(
n_workers: int | None = None, n_workers: int | None = None,
seed: int | None = None, seed: int | None = None,
progress_callback: Callable[[int, int], None] | None = None, progress_callback: Callable[[int, int], None] | None = None,
log_path: Path | None = None,
) -> dict: ) -> dict:
"""Прогоняет n_runs случайных конфигураций параллельно, пишет каждую в SQLite. """Прогоняет n_runs случайных конфигураций параллельно, пишет каждую в SQLite.
Возвращает сводку {n_runs, n_feasible} — не для принятия решений (для Возвращает сводку {n_runs, n_feasible} — не для принятия решений (для
этого нужно читать саму базу), а как быстрая сверка "ничего не потерялось". этого нужно читать саму базу), а как быстрая сверка "ничего не потерялось".
Ход эксперимента пишется в `log_path` (по умолчанию — <db>.log).
""" """
n_workers = n_workers or multiprocessing.cpu_count() n_workers = n_workers or multiprocessing.cpu_count()
base_seed = seed if seed is not None else random.randrange(2**31) base_seed = seed if seed is not None else random.randrange(2**31)
seeds = [base_seed + i for i in range(n_runs)] 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") ctx = multiprocessing.get_context("spawn")
queue = ctx.Queue() queue = ctx.Queue()
writer = ctx.Process(target=run_writer_process, args=(queue, db_path)) writer = ctx.Process(target=run_writer_process, args=(queue, db_path))
@@ -65,9 +70,11 @@ def run_sweep(
completed += 1 completed += 1
if record.feasible: if record.feasible:
n_feasible += 1 n_feasible += 1
logger.update(record.feasible, record.efficiency)
if progress_callback: if progress_callback:
progress_callback(completed, n_runs) progress_callback(completed, n_runs)
finally: finally:
logger.finish()
queue.put(None) queue.put(None)
writer.join() writer.join()

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()

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