Ревью хранения: честная детализация прогона + общий rebuild + чистка мёртвого кода

По итогам код-ревью коммита 5c11999:
- run_detail: шапка и энергобаланс — СОХРАНЁННЫЕ при прогоне значения
  (пересчитанный на лету detail мог противоречить им: GPU-строки и старые
  model_version считались другим движком/моделью, а energy_breakdown
  прятался за feasible пересчёта). Пересчёт теперь явно помечен
  recomputed_with_model_version и обёрнут в try/except — /api/run был
  единственным эндпоинтом, где падение пересимуляции роняло ответ.
- web/rebuild.py: один общий путь «строка БД -> симуляция» для детализации
  и рендера (раньше два дубля; клик по прогону гонял одну и ту же
  симуляцию до 5 раз и перечитывал базу компонентов с диска на каждый
  запрос — теперь кэш компонентов + кэш последних симуляций по run_id).
- Мёртвый код после 5c11999: ветка build_details=True (единственный
  вызов передавал False; дефолт True возвращал бы как раз ту дорогую
  запись, от которой уходили), _FEASIBLE, параметр bounds у _record,
  поле EvaluationResult.detail и его мёртвая ветка в build_run_record.
- overview: n_stages=None (морда покажет «—»), а не 0, когда сводки нет.
- PLAN.md/schema.py: убраны утверждения, что полный build_detail хранится
  в каждой записи (устарело с 5c11999), отмечена старая форма записей.
This commit is contained in:
jze9
2026-07-12 12:31:37 +05:00
parent 5c119990b7
commit c232f1d387
8 changed files with 113 additions and 83 deletions

View File

@@ -55,7 +55,7 @@ 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, оптический датчик). *(Историческая запись: это физика v1 БЕЗ потерь в железе, скина и трения — после их добавления такие КПД невозможны, см. «хронологию честности» ниже.)* - **Найден и исправлен реальный баг именно на этом этапе**: КПД лучшего генома после `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, оптический датчик). *(Историческая запись: это физика v1 БЕЗ потерь в железе, скина и трения — после их добавления такие КПД невозможны, см. «хронологию честности» ниже.)*
- **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 5000 прогонах: индукционный датчик (`inductive-pickup-lm393`) дал **0.2% реализуемых** конфигураций (6 из 2965, где он стоял хотя бы на одной ступени), тогда как оптический (TCST2103) и Холла (A3144E) — **~45-47%** реализуемых каждый. Это количественно подтверждает опасение, высказанное в самом начале обсуждения плана: индукционная катушка-датчик ненадёжна именно потому, что требует скорости выше её порога (здесь — фиксированный старт 3 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости. - **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 5000 прогонах: индукционный датчик (`inductive-pickup-lm393`) дал **0.2% реализуемых** конфигураций (6 из 2965, где он стоял хотя бы на одной ступени), тогда как оптический (TCST2103) и Холла (A3144E) — **~45-47%** реализуемых каждый. Это количественно подтверждает опасение, высказанное в самом начале обсуждения плана: индукционная катушка-датчик ненадёжна именно потому, что требует скорости выше её порога (здесь — фиксированный старт 3 м/с как раз ниже порога срабатывания реального компонента), тогда как оптика/Холл не зависят от скорости.
- Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат. - Итог: сквозная цепочка (реальные компоненты → физика → поиск → SQLite → отчёт с графиками/BOM/анимацией) работает и произвела не выдуманный, а посчитанный и перепроверенный результат.
- [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. Ход эксперимента пишется в `<db>.log` (`optim/progress_log.py`): прогресс, доля реализуемых, скорость, ETA, отметки нового лучшего КПД. Веб-дашборд `gausse serve` (`src/gausse/web/`, только stdlib `http.server`, self-contained HTML) — счётчики, гистограмма КПД, топ конфигураций с drill-down, причины отказа, хвост лога, автообновление 3с. В `docker-compose.yml` сервис `web` на порту 8000. 77 тестов, проверено в Docker. - [x] **Обогащённая БД + лог процесса + веб-морда** (по запросу пользователя при деплое): каждая запись `runs.decoded_summary_json` теперь содержит расстояния между катушками (`inter_stage_gaps_m`), абсолютные позиции катушек вдоль трубы, все номиналы деталей, геометрию намотки, вычисленную физику (индуктивность/сопротивление/пиковый ток/поле) и результат каждой ступени (вход/выход скорость, тайминги датчика, энергобаланс) — см. `optim/objective.py::build_detail`. *(УСТАРЕЛО с 2026-07-11, коммит 5c11999: полный build_detail больше НЕ хранится на каждый прогон — база раздувалась до 70ГБ; в decoded_summary_json теперь дешёвая сводка decoded_summary(), а полная детализация пересимулируется на лету из genome_json при просмотре прогона — web/rebuild.py + web/stats.run_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.
## Батареи конденсаторов + графики в веб-морде (по запросу пользователя) ## Батареи конденсаторов + графики в веб-морде (по запросу пользователя)
@@ -168,11 +168,13 @@ L(x, I)-модель с coenergy-выводом силы — в разделе "
катушку — repair()). катушку — repair()).
- **Ссылки на магазины** (`url`) у каждого компонента: в JSON-базе, BOM - **Ссылки на магазины** (`url`) у каждого компонента: в JSON-базе, BOM
(«Купить») и в детализации прогона. («Купить») и в детализации прогона.
- **Максимально детальная запись** в БД (`build_detail`): труба - **Максимально детальная запись** (`build_detail`): труба
внутр/стенка/внеш, масса снаряда в граммах и влезает-ли-в-бор, внутр/стенка/внеш, масса снаряда в граммах и влезает-ли-в-бор,
внутр/внеш диаметры катушки, состав банки конденсаторов, DC/AC внутр/внеш диаметры катушки, состав банки конденсаторов, DC/AC
сопротивление обмотки, все позиции датчиков и катушек вдоль трубы, сопротивление обмотки, все позиции датчиков и катушек вдоль трубы,
энергобаланс каждой ступени. энергобаланс каждой ступени. *(С 2026-07-11 НЕ хранится в БД построчно,
а строится на лету из genome_json при просмотре прогона/отчёте —
хранение полной детализации на 12.9М строк раздуло базу до 70ГБ.)*
- **Анимация переделана** (была «время вырезается, результата не видно»): - **Анимация переделана** (была «время вырезается, результата не видно»):
кадры на равномерной сетке ФИЗИЧЕСКОГО времени (а не по индексам массива, кадры на равномерной сетке ФИЗИЧЕСКОГО времени (а не по индексам массива,
из-за чего плотные точки разряда съедали все кадры и подлёт выпадал), из-за чего плотные точки разряда съедали все кадры и подлёт выпадал),

View File

@@ -24,17 +24,14 @@ from pathlib import Path
from gausse.components.database import ComponentDatabase from gausse.components.database import ComponentDatabase
from gausse.gpu.backend import get_backend, to_cpu from gausse.gpu.backend import get_backend, to_cpu
from gausse.gpu.batch_integrator import NO_SATURATION_I_SAT, BatchDischargeParams, integrate_batch_discharge from gausse.gpu.batch_integrator import NO_SATURATION_I_SAT, BatchDischargeParams, integrate_batch_discharge
from gausse.optim.objective import MODEL_VERSION, build_detail from gausse.optim.objective import MODEL_VERSION
from gausse.optim.progress_log import ProgressLogger, default_log_path from gausse.optim.progress_log import ProgressLogger, default_log_path
from gausse.optim.search_space import SearchBounds, decode, genome_to_dict, sample_genome from gausse.optim.search_space import SearchBounds, decode, genome_to_dict, sample_genome
from gausse.physics.constants import switch_pulse_limit_a from gausse.physics.constants import switch_pulse_limit_a
from gausse.sim.coilgun import CoilgunResult
from gausse.sim.stage import build_stage_physics from gausse.sim.stage import build_stage_physics
from gausse.storage.database import insert_runs, open_connection from gausse.storage.database import insert_runs, open_connection
from gausse.storage.schema import RunRecord from gausse.storage.schema import RunRecord
_FEASIBLE = CoilgunResult(feasible=True, stage_outcomes=[])
@dataclass(frozen=True) @dataclass(frozen=True)
class StagePre: class StagePre:
@@ -252,20 +249,19 @@ def _simulate_states(xp, states: list) -> None:
def evaluate_genomes_gpu( def evaluate_genomes_gpu(
xp, genomes: list, db: ComponentDatabase, bounds: SearchBounds, xp, genomes: list, db: ComponentDatabase, bounds: SearchBounds, executor=None,
build_details: bool = True, executor=None,
) -> list: ) -> list:
"""Оценка списка геномов ОДНИМ батчем (для эволюции): та же физика и та же """Оценка списка геномов ОДНИМ батчем (для эволюции): та же физика и та же
формула фитнеса, что в objective.evaluate (КПД либо -1+доля пройденных формула фитнеса, что в objective.evaluate (КПД либо -1+доля пройденных
ступеней), но разряды всех геномов интегрируются вместе на GPU/numpy. ступеней), но разряды всех геномов интегрируются вместе на GPU/numpy.
build_details=False — не строить detail/стоимость (фитнесу они не нужны): Возвращает только то, что нужно фитнесу (без detail/стоимости): записи
их параллельно собирает пул процессов (`gpu_record_worker`). для БД параллельно собирает пул процессов (`gpu_record_worker`).
executor — пул для ПАРАЛЛЕЛЬНОГО предвычисления физики ступеней executor — пул для ПАРАЛЛЕЛЬНОГО предвычисления физики ступеней
(precompute_worker): без него один поток Python готовит физику всей (precompute_worker): без него один поток Python готовит физику всей
популяции и GPU простаивает, ожидая данные. популяции и GPU простаивает, ожидая данные.
""" """
from gausse.optim.objective import EvaluationResult, compute_cost_rub from gausse.optim.objective import EvaluationResult
if executor is not None: if executor is not None:
pres = list(executor.map(precompute_worker, genomes, chunksize=64)) pres = list(executor.map(precompute_worker, genomes, chunksize=64))
@@ -280,35 +276,25 @@ def evaluate_genomes_gpu(
results = [] results = []
for st in states: for st in states:
if build_details:
cfg, _, _ = decode(st.genome, db, bounds)
cost = compute_cost_rub(cfg, db)
ok = st.alive and st.energy_in_j > 0 ok = st.alive and st.energy_in_j > 0
detail = build_detail(
cfg, _FEASIBLE if ok else None, db, st.pre.initial_x_m, st.pre.initial_v_mps,
st.genome.tube_inner_d_m, st.genome.tube_wall_m,
) if build_details else None
if not build_details:
cost = None
if ok: if ok:
eff = st.kinetic_delta_j / st.energy_in_j eff = st.kinetic_delta_j / st.energy_in_j
results.append(EvaluationResult( results.append(EvaluationResult(
feasible=True, cost_rub=cost, fitness=eff, efficiency=eff, feasible=True, cost_rub=None, fitness=eff, efficiency=eff,
exit_velocity_mps=st.cur_v, exit_velocity_mps=st.cur_v,
energy_breakdown={ energy_breakdown={
"total_energy_in_j": st.energy_in_j, "total_energy_in_j": st.energy_in_j,
"total_kinetic_energy_delta_j": st.kinetic_delta_j, "total_kinetic_energy_delta_j": st.kinetic_delta_j,
}, },
detail=detail,
)) ))
else: else:
n_stages = len(st.pre.stages) n_stages = len(st.pre.stages)
progress = (st.failed_stage_index or 0) / n_stages if n_stages else 0.0 progress = (st.failed_stage_index or 0) / n_stages if n_stages else 0.0
results.append(EvaluationResult( results.append(EvaluationResult(
feasible=False, cost_rub=cost, fitness=-1.0 + progress, feasible=False, cost_rub=None, fitness=-1.0 + progress,
reason=st.reason or "нет ни одной сработавшей ступени", reason=st.reason or "нет ни одной сработавшей ступени",
failed_stage_index=st.failed_stage_index, failed_stage_index=st.failed_stage_index,
energy_breakdown={}, detail=detail, energy_breakdown={},
)) ))
return results return results
@@ -383,10 +369,10 @@ def run_gpu_sweep(
for st in states: for st in states:
if st.alive and st.energy_in_j > 0: if st.alive and st.energy_in_j > 0:
eff = st.kinetic_delta_j / st.energy_in_j eff = st.kinetic_delta_j / st.energy_in_j
records.append(_record(st, db, bounds, backend, eff, st.cur_v)) records.append(_record(st, db, backend, eff, st.cur_v))
n_feasible += 1 n_feasible += 1
else: else:
records.append(_record(st, db, bounds, backend, None, None)) records.append(_record(st, db, backend, None, None))
insert_runs(conn, records) insert_runs(conn, records)
for r in records: for r in records:
logger.update(r.feasible, r.efficiency) logger.update(r.feasible, r.efficiency)
@@ -397,7 +383,7 @@ def run_gpu_sweep(
return {"n_runs": done, "n_feasible": n_feasible, "backend": backend} return {"n_runs": done, "n_feasible": n_feasible, "backend": backend}
def _record(st: _State, db, bounds: SearchBounds, backend, efficiency, exit_v): def _record(st: _State, db, backend, efficiency, exit_v):
from gausse.optim.objective import decoded_summary from gausse.optim.objective import decoded_summary
feasible = efficiency is not None feasible = efficiency is not None

View File

@@ -168,9 +168,7 @@ def run_evolution(
if gpu_xp is not None: if gpu_xp is not None:
from gausse.gpu.batch_sweep import evaluate_genomes_gpu, gpu_record_worker from gausse.gpu.batch_sweep import evaluate_genomes_gpu, gpu_record_worker
results = evaluate_genomes_gpu( results = evaluate_genomes_gpu(gpu_xp, flat, db, bounds, executor=executor)
gpu_xp, flat, db, bounds, build_details=False, executor=executor
)
pairs_flat = list(zip(flat, results)) pairs_flat = list(zip(flat, results))
payload = [ payload = [
(g, { (g, {

View File

@@ -38,7 +38,6 @@ class EvaluationResult:
efficiency: float | None = None efficiency: float | None = None
exit_velocity_mps: float | None = None exit_velocity_mps: float | None = None
energy_breakdown: dict = None energy_breakdown: dict = None
detail: dict = None
def compute_cost_rub(config, db: ComponentDatabase) -> float: def compute_cost_rub(config, db: ComponentDatabase) -> float:
@@ -261,7 +260,7 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i
def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> EvaluationResult: def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> EvaluationResult:
"""detail НЕ строится здесь: build_detail — дорогая (полная физика по """detail НЕ строится здесь: build_detail — дорогая (полная физика по
каждой ступени) и не нужна ни фитнесу, ни хранению — geneome_json уже каждой ступени) и не нужна ни фитнесу, ни хранению — genome_json уже
достаточен, чтобы воспроизвести всё через decode()+build_detail() на достаточен, чтобы воспроизвести всё через decode()+build_detail() на
лету (см. web/stats.run_detail). build_run_record() кладёт в БД дешёвую лету (см. web/stats.run_detail). build_run_record() кладёт в БД дешёвую
decoded_summary() вместо этого. decoded_summary() вместо этого.
@@ -300,7 +299,7 @@ def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> Eva
def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord: def build_run_record(genome: Genome, result: EvaluationResult, db: ComponentDatabase, search_mode: str) -> RunRecord:
detail = result.detail if result.detail is not None else decoded_summary(genome, db) detail = decoded_summary(genome, db)
return RunRecord( return RunRecord(
run_id=str(uuid.uuid4()), run_id=str(uuid.uuid4()),
timestamp=datetime.now(timezone.utc).isoformat(), timestamp=datetime.now(timezone.utc).isoformat(),

View File

@@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS runs (
-- всё через decode()+build_detail() на лету (см. web/stats.run_detail); -- всё через decode()+build_detail() на лету (см. web/stats.run_detail);
-- хранить дорогую детализацию для каждого из миллионов прогонов -- хранить дорогую детализацию для каждого из миллионов прогонов
-- бессмысленно — так база росла на пустом месте (70ГБ на 12.9М строк). -- бессмысленно — так база росла на пустом месте (70ГБ на 12.9М строк).
-- В базах, созданных ДО 2026-07-11, эта колонка содержит старый полный
-- build_detail (другая форма записей ступеней) — учитывать при чтении.
decoded_summary_json TEXT NOT NULL, decoded_summary_json TEXT NOT NULL,
feasible INTEGER NOT NULL, feasible INTEGER NOT NULL,
infeasible_reason TEXT, infeasible_reason TEXT,

60
src/gausse/web/rebuild.py Normal file
View File

@@ -0,0 +1,60 @@
"""Восстановление симуляции прогона из genome_json — ОДИН общий путь для
детализации (stats.run_detail) и рендера графиков/анимации (render.py).
Раньше эти два места дублировали decode()+run_coilgun() независимо, причём
каждый клик по прогону запускал одну и ту же симуляцию до 5 раз (детализация
+ 3 графика + GIF), а run_detail ещё и перечитывал базу компонентов с диска
на каждый запрос. Здесь: кэш базы компонентов + маленький кэш последних
симуляций по run_id.
"""
import json
import threading
from pathlib import Path
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
from gausse.sim.coilgun import run_coilgun
from gausse.storage.database import fetch_run_by_id, open_connection
_db_cache: ComponentDatabase | None = None
_sim_lock = threading.Lock()
_sim_cache: dict[str, tuple] = {} # run_id -> (row, genome, config, result, x0, v0)
_SIM_CACHE_MAX = 8 # клик по прогону = detail + 3 графика + GIF того же run_id
def components_db() -> ComponentDatabase:
global _db_cache
if _db_cache is None:
_db_cache = ComponentDatabase.load()
return _db_cache
def rebuild_run(db_path: Path, run_id: str):
"""(row, genome, config, result, initial_x_m, initial_v_mps) либо None,
если run_id нет в базе. Ошибки пересимуляции пробрасываются наружу —
вызывающий решает, что показать (детализация не должна ронять сервер).
"""
with _sim_lock:
cached = _sim_cache.get(run_id)
if cached is not None:
return cached
conn = open_connection(db_path)
try:
row = fetch_run_by_id(conn, run_id)
finally:
conn.close()
if row is None:
return None
genome = genome_from_dict(json.loads(row.genome_json))
config, initial_x_m, initial_v_mps = decode(genome, components_db(), SearchBounds())
result = run_coilgun(config)
entry = (row, genome, config, result, initial_x_m, initial_v_mps)
with _sim_lock:
if len(_sim_cache) >= _SIM_CACHE_MAX:
_sim_cache.clear()
_sim_cache[run_id] = entry
return entry

View File

@@ -7,7 +7,6 @@
""" """
import io import io
import json
import tempfile import tempfile
import threading import threading
from pathlib import Path from pathlib import Path
@@ -18,15 +17,11 @@ matplotlib.use("Agg")
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
from gausse.components.database import ComponentDatabase
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict
from gausse.report.animate import animate_run from gausse.report.animate import animate_run
from gausse.report.plots import plot_stage_currents, plot_stage_fields, plot_velocity_vs_position from gausse.report.plots import plot_stage_currents, plot_stage_fields, plot_velocity_vs_position
from gausse.sim.coilgun import run_coilgun from gausse.web.rebuild import rebuild_run
from gausse.storage.database import fetch_run_by_id, open_connection
_render_lock = threading.Lock() _render_lock = threading.Lock()
_db_cache: ComponentDatabase | None = None
_PLOT_BUILDERS = { _PLOT_BUILDERS = {
"velocity": plot_velocity_vs_position, "velocity": plot_velocity_vs_position,
@@ -35,24 +30,12 @@ _PLOT_BUILDERS = {
} }
def _components_db() -> ComponentDatabase:
global _db_cache
if _db_cache is None:
_db_cache = ComponentDatabase.load()
return _db_cache
def _rebuild_result(db_path: Path, run_id: str): def _rebuild_result(db_path: Path, run_id: str):
conn = open_connection(db_path) rebuilt = rebuild_run(db_path, run_id) # общий кэш симуляций с run_detail
try: if rebuilt is None:
row = fetch_run_by_id(conn, run_id)
finally:
conn.close()
if row is None:
return None return None
genome = genome_from_dict(json.loads(row.genome_json)) _row, _genome, config, result, _ix, _iv = rebuilt
config, _ix, _iv = decode(genome, _components_db(), SearchBounds()) return config, result
return config, run_coilgun(config)
def render_plot_png(db_path: Path, run_id: str, kind: str) -> bytes | None: def render_plot_png(db_path: Path, run_id: str, kind: str) -> bytes | None:

View File

@@ -73,10 +73,13 @@ def overview(db_path: Path, log_path: Path | None = None, top_n: int = 15) -> di
out = [] out = []
for r in rows: for r in rows:
summary = json.loads(r.decoded_summary_json) if r.decoded_summary_json else {} summary = json.loads(r.decoded_summary_json) if r.decoded_summary_json else {}
stages = summary.get("stages")
out.append({ out.append({
"run_id": r.run_id, "search_mode": r.search_mode, "efficiency": r.efficiency, "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, "exit_velocity_mps": r.exit_velocity_mps, "cost_rub": r.cost_rub,
"n_stages": len(summary.get("stages", [])), "timestamp": r.timestamp, # None (не 0), когда сводки нет — морда покажет «—»
"n_stages": len(stages) if stages is not None else None,
"timestamp": r.timestamp,
}) })
return out return out
@@ -115,15 +118,18 @@ def _last_cycle_best(log_lines: list[str]) -> float | None:
def run_detail(db_path: Path, run_id: str) -> dict | None: def run_detail(db_path: Path, run_id: str) -> dict | None:
"""Подробности одного прогонаНЕ читаются из БД (там только дешёвая """Подробности одного прогона. Шапка (feasible/КПД/скорость/энергобаланс)
decoded_summary), а пересчитываются на лету из genome_json тем же — СОХРАНЁННЫЕ при прогоне значения: они посчитаны тем движком и той
decode()+build_detail(), что и остальной рендер веб-морды (см. версией физики, что записали строку, и только они сравнимы между собой.
web/render.py). Это единичный запрос по клику, не миллионы прогонов — Полная физика по ступеням в БД не хранится (см. schema.py) — она
пересимулировать дешевле, чем хранить полную физику для каждого.""" пересимулируется на лету из genome_json через общий web/rebuild.py.
from gausse.components.database import ComponentDatabase Пересчёт идёт ТЕКУЩЕЙ моделью на CPU и может расходиться с сохранёнными
from gausse.optim.objective import build_detail числами (GPU-строки, старые model_version) — поэтому detail явно помечен
from gausse.optim.search_space import SearchBounds, decode, genome_from_dict версией пересчёта, а его ошибка не роняет запрос (раньше /api/run был
from gausse.sim.coilgun import run_coilgun единственным эндпоинтом без защиты)."""
from gausse.optim.objective import MODEL_VERSION, build_detail
from gausse.web.rebuild import components_db, rebuild_run
conn = open_connection(db_path) conn = open_connection(db_path)
try: try:
@@ -133,22 +139,15 @@ def run_detail(db_path: Path, run_id: str) -> dict | None:
if row is None: if row is None:
return None return None
genome = genome_from_dict(json.loads(row.genome_json)) try:
db = ComponentDatabase.load() _, genome, config, result, initial_x_m, initial_v_mps = rebuild_run(db_path, run_id)
bounds = SearchBounds() detail = build_detail(
config, initial_x_m, initial_v_mps = decode(genome, db, bounds) config, result, components_db(), initial_x_m, initial_v_mps,
result = run_coilgun(config) genome.tube_inner_d_m, genome.tube_wall_m,
detail = build_detail(config, result, db, initial_x_m, initial_v_mps, genome.tube_inner_d_m, genome.tube_wall_m) )
energy_breakdown = ( detail["recomputed_with_model_version"] = MODEL_VERSION
{ except Exception as exc: # деталь — вспомогательная, шапка важнее
"total_energy_in_j": result.total_energy_in_j, detail = {"error": f"пересимуляция не удалась: {exc}"}
"total_energy_dissipated_j": result.total_energy_dissipated_j,
"total_kinetic_energy_delta_j": result.total_kinetic_energy_delta_j,
"exit_kinetic_energy_j": result.exit_kinetic_energy_j,
}
if result.feasible
else {}
)
return { return {
"run_id": row.run_id, "run_id": row.run_id,
@@ -163,5 +162,6 @@ def run_detail(db_path: Path, run_id: str) -> dict | None:
"cost_rub": row.cost_rub, "cost_rub": row.cost_rub,
"timestamp": row.timestamp, "timestamp": row.timestamp,
"detail": detail, "detail": detail,
"energy_breakdown": energy_breakdown, # сохранённый при записи баланс — консистентен с feasible/КПД шапки
"energy_breakdown": json.loads(row.energy_breakdown_json) if row.energy_breakdown_json else {},
} }