From 70b732d170a1ad22d2adec42fa3860d268e99f7d Mon Sep 17 00:00:00 2001 From: jze9 Date: Tue, 7 Jul 2026 00:27:50 +0500 Subject: [PATCH] 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 .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 --- PLAN.md | 12 +++ docker-compose.yml | 14 +++ src/gausse/cli.py | 12 +++ src/gausse/optim/evolutionary.py | 8 ++ src/gausse/optim/objective.py | 133 +++++++++++++++++++++++++++-- src/gausse/optim/progress_log.py | 69 +++++++++++++++ src/gausse/optim/sweep.py | 7 ++ src/gausse/web/__init__.py | 0 src/gausse/web/page.py | 141 +++++++++++++++++++++++++++++++ src/gausse/web/server.py | 72 ++++++++++++++++ src/gausse/web/stats.py | 96 +++++++++++++++++++++ tests/test_web.py | 77 +++++++++++++++++ 12 files changed, 636 insertions(+), 5 deletions(-) create mode 100644 src/gausse/optim/progress_log.py create mode 100644 src/gausse/web/__init__.py create mode 100644 src/gausse/web/page.py create mode 100644 src/gausse/web/server.py create mode 100644 src/gausse/web/stats.py create mode 100644 tests/test_web.py diff --git a/PLAN.md b/PLAN.md index 1eed659..1b862fc 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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`. Ход эксперимента пишется в `.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-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки". diff --git a/docker-compose.yml b/docker-compose.yml index 859b1c3..ddb2994 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/src/gausse/cli.py b/src/gausse/cli.py index 9b282d0..ce8ccc4 100644 --- a/src/gausse/cli.py +++ b/src/gausse/cli.py @@ -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) diff --git a/src/gausse/optim/evolutionary.py b/src/gausse/optim/evolutionary.py index ad7ae06..1c26053 100644 --- a/src/gausse/optim/evolutionary.py +++ b/src/gausse/optim/evolutionary.py @@ -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() diff --git a/src/gausse/optim/objective.py b/src/gausse/optim/objective.py index 7174da0..0fca746 100644 --- a/src/gausse/optim/objective.py +++ b/src/gausse/optim/objective.py @@ -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, diff --git a/src/gausse/optim/progress_log.py b/src/gausse/optim/progress_log.py new file mode 100644 index 0000000..c185f9e --- /dev/null +++ b/src/gausse/optim/progress_log.py @@ -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}с ===") diff --git a/src/gausse/optim/sweep.py b/src/gausse/optim/sweep.py index 3055aea..f894b18 100644 --- a/src/gausse/optim/sweep.py +++ b/src/gausse/optim/sweep.py @@ -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` (по умолчанию — .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() diff --git a/src/gausse/web/__init__.py b/src/gausse/web/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/gausse/web/page.py b/src/gausse/web/page.py new file mode 100644 index 0000000..13f1198 --- /dev/null +++ b/src/gausse/web/page.py @@ -0,0 +1,141 @@ +"""Самодостаточная HTML-страница дашборда (inline CSS+JS, без внешних ресурсов).""" + +PAGE_HTML = r""" + + + + +gausse — ход эксперимента + + + +
+

⚡ gausse — ход эксперимента

+ загрузка… + +
+
+
+
Всего прогонов
+
Реализуемо
+
Нереализуемо
+
Доля реализуемых
+
Лучший КПД
+
+ +
+
+

Топ конфигураций по КПД

+ + + +
КПДСкор., м/сСтуп.Цена, ₽Режим
+

Клик по строке — полная детализация прогона (расстояния, номиналы, физика).

+
+
+

Распределение КПД (реализуемые)

+
+

Причины отказа

+
+
+
+ +
+

Лог процесса

+
+
+ +
✕ закрыть +

+
+ + + + +""" diff --git a/src/gausse/web/server.py b/src/gausse/web/server.py new file mode 100644 index 0000000..e08e7ff --- /dev/null +++ b/src/gausse/web/server.py @@ -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() diff --git a/src/gausse/web/stats.py b/src/gausse/web/stats.py new file mode 100644 index 0000000..b80011a --- /dev/null +++ b/src/gausse/web/stats.py @@ -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() diff --git a/tests/test_web.py b/tests/test_web.py new file mode 100644 index 0000000..041bba8 --- /dev/null +++ b/tests/test_web.py @@ -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 "" 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