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:
12
PLAN.md
12
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`. Ход эксперимента пишется в `<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-эталоном на контрольной выборке, чтобы ускорение не подменило точность честными числами "для галочки".
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
69
src/gausse/optim/progress_log.py
Normal file
69
src/gausse/optim/progress_log.py
Normal file
@@ -0,0 +1,69 @@
|
||||
"""Лог хода эксперимента: пишет прогресс sweep/evolve в файл рядом с базой.
|
||||
|
||||
Формат — по строке на контрольную точку, читаемый и человеком, и хвостом в
|
||||
веб-морде: время, сколько прогнано из скольких, доля реализуемых, скорость,
|
||||
лучший найденный КПД. Файл дописывается (append), переживает перезапуски.
|
||||
"""
|
||||
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def default_log_path(db_path: Path) -> Path:
|
||||
db_path = Path(db_path)
|
||||
return db_path.with_suffix(db_path.suffix + ".log")
|
||||
|
||||
|
||||
class ProgressLogger:
|
||||
def __init__(self, log_path: Path, mode: str, total: int | None, log_every: int = 200):
|
||||
self.log_path = Path(log_path)
|
||||
self.log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
self.mode = mode
|
||||
self.total = total
|
||||
self.log_every = max(log_every, 1)
|
||||
self.start_time = time.monotonic()
|
||||
self.completed = 0
|
||||
self.n_feasible = 0
|
||||
self.best_efficiency: float | None = None
|
||||
self._write(f"=== старт {mode}, всего запланировано: {total} ===")
|
||||
|
||||
def _write(self, line: str) -> None:
|
||||
stamp = datetime.now(timezone.utc).isoformat(timespec="seconds")
|
||||
with self.log_path.open("a", encoding="utf-8") as f:
|
||||
f.write(f"{stamp} {line}\n")
|
||||
|
||||
def update(self, feasible: bool, efficiency: float | None) -> None:
|
||||
self.completed += 1
|
||||
if feasible:
|
||||
self.n_feasible += 1
|
||||
if efficiency is not None and (self.best_efficiency is None or efficiency > self.best_efficiency):
|
||||
self.best_efficiency = efficiency
|
||||
self._write(
|
||||
f"новый лучший КПД: {efficiency * 100:.2f}% "
|
||||
f"(на прогоне {self.completed})"
|
||||
)
|
||||
if self.completed % self.log_every == 0:
|
||||
self._checkpoint()
|
||||
|
||||
def _checkpoint(self) -> None:
|
||||
elapsed = time.monotonic() - self.start_time
|
||||
rate = self.completed / elapsed if elapsed > 0 else 0.0
|
||||
feas_pct = 100 * self.n_feasible / self.completed if self.completed else 0.0
|
||||
best = f"{self.best_efficiency * 100:.2f}%" if self.best_efficiency is not None else "—"
|
||||
total_str = str(self.total) if self.total else "?"
|
||||
pct = f"{100 * self.completed / self.total:.1f}%" if self.total else "?"
|
||||
eta = ""
|
||||
if self.total and rate > 0:
|
||||
remaining = (self.total - self.completed) / rate
|
||||
eta = f" ETA={remaining:.0f}с"
|
||||
self._write(
|
||||
f"прогресс: {self.completed}/{total_str} ({pct}) "
|
||||
f"реализуемо={self.n_feasible} ({feas_pct:.1f}%) "
|
||||
f"скорость={rate:.1f}/с лучший_КПД={best}{eta}"
|
||||
)
|
||||
|
||||
def finish(self) -> None:
|
||||
self._checkpoint()
|
||||
elapsed = time.monotonic() - self.start_time
|
||||
self._write(f"=== готово {self.mode}: {self.completed} прогонов за {elapsed:.0f}с ===")
|
||||
@@ -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()
|
||||
|
||||
|
||||
0
src/gausse/web/__init__.py
Normal file
0
src/gausse/web/__init__.py
Normal file
141
src/gausse/web/page.py
Normal file
141
src/gausse/web/page.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Самодостаточная HTML-страница дашборда (inline CSS+JS, без внешних ресурсов)."""
|
||||
|
||||
PAGE_HTML = r"""<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>gausse — ход эксперимента</title>
|
||||
<style>
|
||||
:root { color-scheme: dark; }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font-family: system-ui, -apple-system, Segoe UI, Roboto, sans-serif;
|
||||
background:#0e1116; color:#e6edf3; font-size:14px; }
|
||||
header { padding:14px 20px; background:#161b22; border-bottom:1px solid #30363d;
|
||||
display:flex; align-items:center; gap:16px; flex-wrap:wrap; }
|
||||
header h1 { font-size:16px; margin:0; font-weight:600; }
|
||||
.muted { color:#8b949e; }
|
||||
.wrap { padding:20px; max-width:1200px; margin:0 auto; }
|
||||
.cards { display:grid; grid-template-columns:repeat(auto-fit,minmax(150px,1fr)); gap:12px; margin-bottom:20px; }
|
||||
.card { background:#161b22; border:1px solid #30363d; border-radius:8px; padding:14px; }
|
||||
.card .k { font-size:12px; color:#8b949e; text-transform:uppercase; letter-spacing:.04em; }
|
||||
.card .v { font-size:26px; font-weight:700; margin-top:4px; }
|
||||
.grid2 { display:grid; grid-template-columns:1fr 1fr; gap:20px; }
|
||||
@media (max-width:840px){ .grid2 { grid-template-columns:1fr; } }
|
||||
section h2 { font-size:13px; text-transform:uppercase; letter-spacing:.04em; color:#8b949e; margin:0 0 10px; }
|
||||
table { width:100%; border-collapse:collapse; font-variant-numeric:tabular-nums; }
|
||||
th,td { text-align:left; padding:7px 8px; border-bottom:1px solid #21262d; }
|
||||
th { color:#8b949e; font-weight:600; font-size:12px; }
|
||||
tr.clk:hover { background:#1c2230; cursor:pointer; }
|
||||
.bar { height:16px; background:#238636; border-radius:3px; }
|
||||
.hist td { padding:3px 8px; }
|
||||
.log { background:#0a0d12; border:1px solid #30363d; border-radius:8px; padding:12px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size:12px;
|
||||
white-space:pre-wrap; max-height:320px; overflow:auto; line-height:1.5; }
|
||||
.pill { display:inline-block; padding:1px 7px; border-radius:10px; font-size:11px; background:#21262d; }
|
||||
#detail { margin-top:20px; background:#161b22; border:1px solid #30363d; border-radius:8px; padding:16px; display:none; }
|
||||
#detail pre { white-space:pre-wrap; font-family: ui-monospace, monospace; font-size:12px; margin:0; }
|
||||
.close { float:right; cursor:pointer; color:#8b949e; }
|
||||
a { color:#58a6ff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<h1>⚡ gausse — ход эксперимента</h1>
|
||||
<span class="muted" id="updated">загрузка…</span>
|
||||
<span class="muted" id="err" style="color:#f85149"></span>
|
||||
</header>
|
||||
<div class="wrap">
|
||||
<div class="cards">
|
||||
<div class="card"><div class="k">Всего прогонов</div><div class="v" id="c-total">—</div></div>
|
||||
<div class="card"><div class="k">Реализуемо</div><div class="v" id="c-feas">—</div></div>
|
||||
<div class="card"><div class="k">Нереализуемо</div><div class="v" id="c-infeas">—</div></div>
|
||||
<div class="card"><div class="k">Доля реализуемых</div><div class="v" id="c-pct">—</div></div>
|
||||
<div class="card"><div class="k">Лучший КПД</div><div class="v" id="c-best">—</div></div>
|
||||
</div>
|
||||
|
||||
<div class="grid2">
|
||||
<section>
|
||||
<h2>Топ конфигураций по КПД</h2>
|
||||
<table>
|
||||
<thead><tr><th>КПД</th><th>Скор., м/с</th><th>Ступ.</th><th>Цена, ₽</th><th>Режим</th></tr></thead>
|
||||
<tbody id="top"></tbody>
|
||||
</table>
|
||||
<p class="muted" style="font-size:12px">Клик по строке — полная детализация прогона (расстояния, номиналы, физика).</p>
|
||||
</section>
|
||||
<section>
|
||||
<h2>Распределение КПД (реализуемые)</h2>
|
||||
<table class="hist"><tbody id="hist"></tbody></table>
|
||||
<h2 style="margin-top:18px">Причины отказа</h2>
|
||||
<table><tbody id="reasons"></tbody></table>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section style="margin-top:22px">
|
||||
<h2>Лог процесса</h2>
|
||||
<div class="log" id="log">—</div>
|
||||
</section>
|
||||
|
||||
<div id="detail"><span class="close" onclick="document.getElementById('detail').style.display='none'">✕ закрыть</span>
|
||||
<h2 id="d-title"></h2><pre id="d-body"></pre></div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const fmtPct = x => x==null ? "—" : (x*100).toFixed(2)+"%";
|
||||
const fmt = (x,d=1) => x==null ? "—" : Number(x).toFixed(d);
|
||||
|
||||
async function refresh(){
|
||||
try {
|
||||
const r = await fetch('/api/overview'); const o = await r.json();
|
||||
document.getElementById('err').textContent = o.error ? ('ошибка: '+o.error) : '';
|
||||
document.getElementById('c-total').textContent = o.total ?? 0;
|
||||
document.getElementById('c-feas').textContent = o.feasible ?? 0;
|
||||
document.getElementById('c-infeas').textContent = o.infeasible ?? 0;
|
||||
document.getElementById('c-pct').textContent = (o.feasible_pct??0).toFixed(1)+"%";
|
||||
let best = (o.top && o.top.length) ? o.top[0].efficiency : null;
|
||||
document.getElementById('c-best').textContent = fmtPct(best);
|
||||
|
||||
const top = document.getElementById('top'); top.innerHTML='';
|
||||
(o.top||[]).forEach(t=>{
|
||||
const tr=document.createElement('tr'); tr.className='clk';
|
||||
tr.innerHTML=`<td>${fmtPct(t.efficiency)}</td><td>${fmt(t.exit_velocity_mps,1)}</td>`+
|
||||
`<td>${t.n_stages??'—'}</td><td>${fmt(t.cost_rub,0)}</td><td><span class="pill">${t.search_mode}</span></td>`;
|
||||
tr.onclick=()=>showDetail(t.run_id);
|
||||
top.appendChild(tr);
|
||||
});
|
||||
|
||||
const hist=document.getElementById('hist'); hist.innerHTML='';
|
||||
const mx=Math.max(1,...(o.efficiency_histogram||[0]));
|
||||
(o.efficiency_histogram||[]).forEach((c,i)=>{
|
||||
const tr=document.createElement('tr');
|
||||
tr.innerHTML=`<td class="muted" style="width:70px">${i*10}–${i*10+10}%</td>`+
|
||||
`<td><div class="bar" style="width:${Math.max(2,100*c/mx)}%"></div></td>`+
|
||||
`<td class="muted" style="width:60px;text-align:right">${c}</td>`;
|
||||
hist.appendChild(tr);
|
||||
});
|
||||
|
||||
const rs=document.getElementById('reasons'); rs.innerHTML='';
|
||||
(o.infeasible_reasons||[]).forEach(x=>{
|
||||
const tr=document.createElement('tr');
|
||||
tr.innerHTML=`<td class="muted">${x.reason}</td><td style="text-align:right">${x.count}</td>`;
|
||||
rs.appendChild(tr);
|
||||
});
|
||||
|
||||
document.getElementById('log').textContent = (o.log_tail||[]).join('\n') || '(лог пуст)';
|
||||
document.getElementById('updated').textContent = 'обновлено '+new Date().toLocaleTimeString();
|
||||
} catch(e){ document.getElementById('err').textContent='нет связи с сервером'; }
|
||||
}
|
||||
|
||||
async function showDetail(runId){
|
||||
const r = await fetch('/api/run/'+runId); const d = await r.json();
|
||||
document.getElementById('d-title').textContent = 'Прогон '+runId.slice(0,8)+
|
||||
' — '+(d.feasible?('КПД '+fmtPct(d.efficiency)+', '+fmt(d.exit_velocity_mps,1)+' м/с'):'НЕ реализуемо');
|
||||
document.getElementById('d-body').textContent = JSON.stringify(d, null, 2);
|
||||
const el=document.getElementById('detail'); el.style.display='block'; el.scrollIntoView({behavior:'smooth'});
|
||||
}
|
||||
|
||||
refresh(); setInterval(refresh, 3000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
72
src/gausse/web/server.py
Normal file
72
src/gausse/web/server.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Веб-морда `gausse serve`: дашборд хода эксперимента поверх SQLite.
|
||||
|
||||
Только stdlib (`http.server`) — без новых зависимостей. Read-only: сервер
|
||||
лишь читает базу и отдаёт JSON + одну самодостаточную HTML-страницу, которая
|
||||
опрашивает /api/overview раз в несколько секунд. Ничего не пишет в базу,
|
||||
поэтому безопасно запускать параллельно с идущим sweep/evolve.
|
||||
"""
|
||||
|
||||
import json
|
||||
from functools import partial
|
||||
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from gausse.web.page import PAGE_HTML
|
||||
from gausse.web.stats import overview, run_detail
|
||||
|
||||
|
||||
class _Handler(BaseHTTPRequestHandler):
|
||||
def __init__(self, *args, db_path: Path, log_path: Path, **kwargs):
|
||||
self.db_path = db_path
|
||||
self.log_path = log_path
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def log_message(self, *args): # тише в stdout
|
||||
pass
|
||||
|
||||
def _send(self, code: int, body: bytes, content_type: str) -> None:
|
||||
self.send_response(code)
|
||||
self.send_header("Content-Type", content_type)
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
|
||||
def _send_json(self, obj, code: int = 200) -> None:
|
||||
self._send(code, json.dumps(obj, ensure_ascii=False).encode("utf-8"), "application/json; charset=utf-8")
|
||||
|
||||
def do_GET(self):
|
||||
path = urlparse(self.path).path
|
||||
if path in ("/", "/index.html"):
|
||||
self._send(200, PAGE_HTML.encode("utf-8"), "text/html; charset=utf-8")
|
||||
return
|
||||
if path == "/api/overview":
|
||||
try:
|
||||
self._send_json(overview(self.db_path, self.log_path))
|
||||
except Exception as exc: # база может ещё не существовать / быть пустой
|
||||
self._send_json({"error": str(exc), "total": 0, "feasible": 0, "infeasible": 0}, code=200)
|
||||
return
|
||||
if path.startswith("/api/run/"):
|
||||
run_id = path[len("/api/run/") :]
|
||||
detail = run_detail(self.db_path, run_id)
|
||||
if detail is None:
|
||||
self._send_json({"error": "run_id не найден"}, code=404)
|
||||
else:
|
||||
self._send_json(detail)
|
||||
return
|
||||
self._send(404, b"not found", "text/plain")
|
||||
|
||||
|
||||
def serve(db_path: Path, host: str = "0.0.0.0", port: int = 8000, log_path: Path | None = None) -> None:
|
||||
db_path = Path(db_path)
|
||||
if log_path is None:
|
||||
log_path = db_path.with_suffix(db_path.suffix + ".log")
|
||||
handler = partial(_Handler, db_path=db_path, log_path=log_path)
|
||||
httpd = ThreadingHTTPServer((host, port), handler)
|
||||
print(f"gausse web-морда: http://{host}:{port} (база: {db_path}, лог: {log_path})")
|
||||
try:
|
||||
httpd.serve_forever()
|
||||
except KeyboardInterrupt:
|
||||
print("остановлено")
|
||||
finally:
|
||||
httpd.server_close()
|
||||
96
src/gausse/web/stats.py
Normal file
96
src/gausse/web/stats.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Агрегаты по таблице `runs` для веб-морды — считаются запросами к SQLite."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from gausse.storage.database import fetch_runs, open_connection
|
||||
|
||||
|
||||
def _tail_log(log_path: Path, n_lines: int = 40) -> list[str]:
|
||||
if not log_path.exists():
|
||||
return []
|
||||
lines = log_path.read_text(encoding="utf-8", errors="replace").splitlines()
|
||||
return lines[-n_lines:]
|
||||
|
||||
|
||||
def overview(db_path: Path, log_path: Path | None = None, top_n: int = 15) -> dict:
|
||||
"""Сводка для дашборда: счётчики, гистограмма КПД, топ конфигураций, хвост лога."""
|
||||
conn = open_connection(db_path)
|
||||
try:
|
||||
total = conn.execute("SELECT COUNT(*) FROM runs").fetchone()[0]
|
||||
feasible = conn.execute("SELECT COUNT(*) FROM runs WHERE feasible=1").fetchone()[0]
|
||||
infeasible = total - feasible
|
||||
|
||||
# распределение по типу датчика ступени 0 (реализуемо / всего)
|
||||
by_mode = {}
|
||||
for mode, cnt in conn.execute("SELECT search_mode, COUNT(*) FROM runs GROUP BY search_mode"):
|
||||
by_mode[mode] = cnt
|
||||
|
||||
# причины отказа (топ)
|
||||
reasons = []
|
||||
for reason, cnt in conn.execute(
|
||||
"SELECT infeasible_reason, COUNT(*) c FROM runs WHERE feasible=0 AND infeasible_reason IS NOT NULL "
|
||||
"GROUP BY infeasible_reason ORDER BY c DESC LIMIT 10"
|
||||
):
|
||||
short = reason.split(":", 1)[0] if reason else reason
|
||||
reasons.append({"reason": short, "count": cnt})
|
||||
|
||||
# гистограмма КПД (реализуемые)
|
||||
buckets = [0] * 10 # 0-10%,...,90-100%
|
||||
for (eff,) in conn.execute("SELECT efficiency FROM runs WHERE feasible=1 AND efficiency IS NOT NULL"):
|
||||
idx = min(int(eff * 10), 9)
|
||||
if idx >= 0:
|
||||
buckets[idx] += 1
|
||||
|
||||
best_rows = fetch_runs(conn, feasible=True, order_by_efficiency_desc=True, limit=top_n)
|
||||
top = []
|
||||
for r in best_rows:
|
||||
detail = json.loads(r.decoded_summary_json) if r.decoded_summary_json else {}
|
||||
top.append(
|
||||
{
|
||||
"run_id": r.run_id,
|
||||
"search_mode": r.search_mode,
|
||||
"efficiency": r.efficiency,
|
||||
"exit_velocity_mps": r.exit_velocity_mps,
|
||||
"cost_rub": r.cost_rub,
|
||||
"n_stages": detail.get("n_stages"),
|
||||
"timestamp": r.timestamp,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"feasible": feasible,
|
||||
"infeasible": infeasible,
|
||||
"feasible_pct": (100 * feasible / total) if total else 0.0,
|
||||
"by_mode": by_mode,
|
||||
"infeasible_reasons": reasons,
|
||||
"efficiency_histogram": buckets,
|
||||
"top": top,
|
||||
"log_tail": _tail_log(log_path, 40) if log_path else [],
|
||||
}
|
||||
|
||||
|
||||
def run_detail(db_path: Path, run_id: str) -> dict | None:
|
||||
conn = open_connection(db_path)
|
||||
try:
|
||||
rows = fetch_runs(conn)
|
||||
row = next((r for r in rows if r.run_id == run_id), None)
|
||||
if row is None:
|
||||
return None
|
||||
return {
|
||||
"run_id": row.run_id,
|
||||
"search_mode": row.search_mode,
|
||||
"feasible": row.feasible,
|
||||
"infeasible_reason": row.infeasible_reason,
|
||||
"efficiency": row.efficiency,
|
||||
"exit_velocity_mps": row.exit_velocity_mps,
|
||||
"cost_rub": row.cost_rub,
|
||||
"timestamp": row.timestamp,
|
||||
"detail": json.loads(row.decoded_summary_json) if row.decoded_summary_json else {},
|
||||
"energy_breakdown": json.loads(row.energy_breakdown_json) if row.energy_breakdown_json else {},
|
||||
}
|
||||
finally:
|
||||
conn.close()
|
||||
77
tests/test_web.py
Normal file
77
tests/test_web.py
Normal file
@@ -0,0 +1,77 @@
|
||||
import json
|
||||
import threading
|
||||
import urllib.request
|
||||
from http.server import ThreadingHTTPServer
|
||||
|
||||
from gausse.optim.sweep import run_sweep
|
||||
from gausse.web.page import PAGE_HTML
|
||||
from gausse.web.stats import overview, run_detail
|
||||
|
||||
|
||||
def _make_db(tmp_path):
|
||||
db = tmp_path / "runs.sqlite3"
|
||||
run_sweep(db, n_runs=60, n_workers=2, seed=11)
|
||||
return db
|
||||
|
||||
|
||||
def test_overview_reports_counts_and_top(tmp_path):
|
||||
db = _make_db(tmp_path)
|
||||
o = overview(db, db.with_suffix(".sqlite3.log"))
|
||||
assert o["total"] == 60
|
||||
assert o["feasible"] + o["infeasible"] == 60
|
||||
assert len(o["efficiency_histogram"]) == 10
|
||||
# лог процесса должен существовать и попасть в хвост
|
||||
assert len(o["log_tail"]) > 0
|
||||
if o["top"]:
|
||||
# топ отсортирован по КПД по убыванию
|
||||
effs = [t["efficiency"] for t in o["top"]]
|
||||
assert effs == sorted(effs, reverse=True)
|
||||
|
||||
|
||||
def test_run_detail_has_full_physics_and_distances(tmp_path):
|
||||
db = _make_db(tmp_path)
|
||||
o = overview(db, None)
|
||||
assert o["top"], "нужен хотя бы один реализуемый прогон"
|
||||
d = run_detail(db, o["top"][0]["run_id"])
|
||||
assert d is not None
|
||||
detail = d["detail"]
|
||||
# ключевое, что просил пользователь: расстояния между катушками и позиции
|
||||
assert "inter_stage_gaps_m" in detail
|
||||
assert "coil_center_positions_m" in detail
|
||||
stage0 = detail["stages"][0]
|
||||
assert "electrical" in stage0 and "wire_resistance_ohm" in stage0["electrical"]
|
||||
assert "winding" in stage0 and "total_turns" in stage0["winding"]
|
||||
assert "sensor_to_coil_distance_m" in stage0
|
||||
|
||||
|
||||
def test_run_detail_missing_id_returns_none(tmp_path):
|
||||
db = _make_db(tmp_path)
|
||||
assert run_detail(db, "нет-такого-id") is None
|
||||
|
||||
|
||||
def test_http_server_serves_page_and_api(tmp_path):
|
||||
from functools import partial
|
||||
|
||||
from gausse.web.server import _Handler
|
||||
|
||||
db = _make_db(tmp_path)
|
||||
handler = partial(_Handler, db_path=db, log_path=db.with_suffix(".sqlite3.log"))
|
||||
httpd = ThreadingHTTPServer(("127.0.0.1", 0), handler)
|
||||
port = httpd.server_address[1]
|
||||
t = threading.Thread(target=httpd.serve_forever, daemon=True)
|
||||
t.start()
|
||||
try:
|
||||
base = f"http://127.0.0.1:{port}"
|
||||
page = urllib.request.urlopen(base + "/", timeout=5).read().decode("utf-8")
|
||||
assert "<!doctype html>" in page.lower()
|
||||
api = json.loads(urllib.request.urlopen(base + "/api/overview", timeout=5).read())
|
||||
assert api["total"] == 60
|
||||
finally:
|
||||
httpd.shutdown()
|
||||
httpd.server_close()
|
||||
|
||||
|
||||
def test_page_is_self_contained():
|
||||
# никаких внешних ресурсов (CSP-безопасно): ни http-ссылок в src/href, ни CDN
|
||||
assert "src=\"http" not in PAGE_HTML
|
||||
assert "href=\"http" not in PAGE_HTML
|
||||
Reference in New Issue
Block a user