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, оптический датчик).
- **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 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

@@ -1,4 +1,18 @@
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:

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.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:
@@ -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}")
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)
@@ -133,6 +139,12 @@ def main(argv: list[str] | None = None) -> int:
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)
return args.func(args)

View File

@@ -19,6 +19,7 @@ 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
@@ -92,11 +93,16 @@ def run_evolution(
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))
@@ -120,6 +126,7 @@ def run_evolution(
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)
@@ -133,6 +140,7 @@ def run_evolution(
next_population.append(child)
population = next_population
finally:
logger.finish()
queue.put(None)
writer.join()

View File

@@ -6,14 +6,17 @@
"""
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 winding_geometry
from gausse.sim.coilgun import run_coilgun
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"
@@ -29,6 +32,7 @@ class EvaluationResult:
efficiency: float | None = None
exit_velocity_mps: float | None = None
energy_breakdown: dict = None
detail: dict = None
def compute_cost_rub(config, db: ComponentDatabase) -> float:
@@ -44,7 +48,18 @@ def compute_cost_rub(config, db: ComponentDatabase) -> float:
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:
@@ -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:
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)
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)
@@ -89,6 +210,7 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
reason=result.reason,
failed_stage_index=result.failed_stage_index,
energy_breakdown={},
detail=detail,
)
energy_breakdown = {
@@ -104,17 +226,18 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
efficiency=result.efficiency,
exit_velocity_mps=result.exit_v_mps,
energy_breakdown=energy_breakdown,
detail=detail,
)
def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord:
summary = decoded_summary(genome, db)
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(summary, ensure_ascii=False),
decoded_summary_json=json.dumps(detail, ensure_ascii=False),
feasible=result.feasible,
model_version=MODEL_VERSION,
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.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
@@ -36,16 +37,20 @@ def run_sweep(
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))
@@ -65,9 +70,11 @@ def run_sweep(
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()

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