From c232f1d38759b70102e069c6fcc21ab42c070dcb Mon Sep 17 00:00:00 2001 From: jze9 Date: Sun, 12 Jul 2026 12:31:37 +0500 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B2=D1=8C=D1=8E=20=D1=85=D1=80?= =?UTF-8?q?=D0=B0=D0=BD=D0=B5=D0=BD=D0=B8=D1=8F:=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D0=B0=D1=8F=20=D0=B4=D0=B5=D1=82=D0=B0=D0=BB=D0=B8?= =?UTF-8?q?=D0=B7=D0=B0=D1=86=D0=B8=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B3=D0=BE?= =?UTF-8?q?=D0=BD=D0=B0=20+=20=D0=BE=D0=B1=D1=89=D0=B8=D0=B9=20rebuild=20+?= =?UTF-8?q?=20=D1=87=D0=B8=D1=81=D1=82=D0=BA=D0=B0=20=D0=BC=D1=91=D1=80?= =?UTF-8?q?=D1=82=D0=B2=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=B4=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit По итогам код-ревью коммита 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), отмечена старая форма записей. --- PLAN.md | 8 +++-- src/gausse/gpu/batch_sweep.py | 36 ++++++------------- src/gausse/optim/evolutionary.py | 4 +-- src/gausse/optim/objective.py | 5 ++- src/gausse/storage/schema.py | 2 ++ src/gausse/web/rebuild.py | 60 ++++++++++++++++++++++++++++++++ src/gausse/web/render.py | 27 +++----------- src/gausse/web/stats.py | 54 ++++++++++++++-------------- 8 files changed, 113 insertions(+), 83 deletions(-) create mode 100644 src/gausse/web/rebuild.py diff --git a/PLAN.md b/PLAN.md index 017da29..94955c6 100644 --- a/PLAN.md +++ b/PLAN.md @@ -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 БЕЗ потерь в железе, скина и трения — после их добавления такие КПД невозможны, см. «хронологию честности» ниже.)* - **Честное сравнение датчиков** (запрос из первого обсуждения плана) — на 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. +- [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.)* Ход эксперимента пишется в `.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()). - **Ссылки на магазины** (`url`) у каждого компонента: в JSON-базе, BOM («Купить») и в детализации прогона. -- **Максимально детальная запись** в БД (`build_detail`): труба +- **Максимально детальная запись** (`build_detail`): труба внутр/стенка/внеш, масса снаряда в граммах и влезает-ли-в-бор, внутр/внеш диаметры катушки, состав банки конденсаторов, DC/AC сопротивление обмотки, все позиции датчиков и катушек вдоль трубы, - энергобаланс каждой ступени. + энергобаланс каждой ступени. *(С 2026-07-11 НЕ хранится в БД построчно, + а строится на лету из genome_json при просмотре прогона/отчёте — + хранение полной детализации на 12.9М строк раздуло базу до 70ГБ.)* - **Анимация переделана** (была «время вырезается, результата не видно»): кадры на равномерной сетке ФИЗИЧЕСКОГО времени (а не по индексам массива, из-за чего плотные точки разряда съедали все кадры и подлёт выпадал), diff --git a/src/gausse/gpu/batch_sweep.py b/src/gausse/gpu/batch_sweep.py index ad2d334..03212d3 100644 --- a/src/gausse/gpu/batch_sweep.py +++ b/src/gausse/gpu/batch_sweep.py @@ -24,17 +24,14 @@ from pathlib import Path from gausse.components.database import ComponentDatabase 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.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.search_space import SearchBounds, decode, genome_to_dict, sample_genome 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.storage.database import insert_runs, open_connection from gausse.storage.schema import RunRecord -_FEASIBLE = CoilgunResult(feasible=True, stage_outcomes=[]) - @dataclass(frozen=True) class StagePre: @@ -252,20 +249,19 @@ def _simulate_states(xp, states: list) -> None: def evaluate_genomes_gpu( - xp, genomes: list, db: ComponentDatabase, bounds: SearchBounds, - build_details: bool = True, executor=None, + xp, genomes: list, db: ComponentDatabase, bounds: SearchBounds, executor=None, ) -> list: """Оценка списка геномов ОДНИМ батчем (для эволюции): та же физика и та же формула фитнеса, что в objective.evaluate (КПД либо -1+доля пройденных ступеней), но разряды всех геномов интегрируются вместе на GPU/numpy. - build_details=False — не строить detail/стоимость (фитнесу они не нужны): - их параллельно собирает пул процессов (`gpu_record_worker`). + Возвращает только то, что нужно фитнесу (без detail/стоимости): записи + для БД параллельно собирает пул процессов (`gpu_record_worker`). executor — пул для ПАРАЛЛЕЛЬНОГО предвычисления физики ступеней (precompute_worker): без него один поток Python готовит физику всей популяции и GPU простаивает, ожидая данные. """ - from gausse.optim.objective import EvaluationResult, compute_cost_rub + from gausse.optim.objective import EvaluationResult if executor is not None: pres = list(executor.map(precompute_worker, genomes, chunksize=64)) @@ -280,35 +276,25 @@ def evaluate_genomes_gpu( results = [] 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 - 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: eff = st.kinetic_delta_j / st.energy_in_j 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, energy_breakdown={ "total_energy_in_j": st.energy_in_j, "total_kinetic_energy_delta_j": st.kinetic_delta_j, }, - detail=detail, )) else: n_stages = len(st.pre.stages) progress = (st.failed_stage_index or 0) / n_stages if n_stages else 0.0 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 "нет ни одной сработавшей ступени", failed_stage_index=st.failed_stage_index, - energy_breakdown={}, detail=detail, + energy_breakdown={}, )) return results @@ -383,10 +369,10 @@ def run_gpu_sweep( for st in states: if st.alive and st.energy_in_j > 0: 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 else: - records.append(_record(st, db, bounds, backend, None, None)) + records.append(_record(st, db, backend, None, None)) insert_runs(conn, records) for r in records: logger.update(r.feasible, r.efficiency) @@ -397,7 +383,7 @@ def run_gpu_sweep( 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 feasible = efficiency is not None diff --git a/src/gausse/optim/evolutionary.py b/src/gausse/optim/evolutionary.py index f49101d..e86a220 100644 --- a/src/gausse/optim/evolutionary.py +++ b/src/gausse/optim/evolutionary.py @@ -168,9 +168,7 @@ def run_evolution( if gpu_xp is not None: from gausse.gpu.batch_sweep import evaluate_genomes_gpu, gpu_record_worker - results = evaluate_genomes_gpu( - gpu_xp, flat, db, bounds, build_details=False, executor=executor - ) + results = evaluate_genomes_gpu(gpu_xp, flat, db, bounds, executor=executor) pairs_flat = list(zip(flat, results)) payload = [ (g, { diff --git a/src/gausse/optim/objective.py b/src/gausse/optim/objective.py index 9279028..3d9b89e 100644 --- a/src/gausse/optim/objective.py +++ b/src/gausse/optim/objective.py @@ -38,7 +38,6 @@ 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: @@ -261,7 +260,7 @@ def build_detail(config, coilgun_result: CoilgunResult, db: ComponentDatabase, i def evaluate(genome: Genome, db: ComponentDatabase, bounds: SearchBounds) -> EvaluationResult: """detail НЕ строится здесь: build_detail — дорогая (полная физика по - каждой ступени) и не нужна ни фитнесу, ни хранению — geneome_json уже + каждой ступени) и не нужна ни фитнесу, ни хранению — genome_json уже достаточен, чтобы воспроизвести всё через decode()+build_detail() на лету (см. web/stats.run_detail). build_run_record() кладёт в БД дешёвую 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: - detail = result.detail if result.detail is not None else decoded_summary(genome, db) + detail = decoded_summary(genome, db) return RunRecord( run_id=str(uuid.uuid4()), timestamp=datetime.now(timezone.utc).isoformat(), diff --git a/src/gausse/storage/schema.py b/src/gausse/storage/schema.py index 4997136..8959465 100644 --- a/src/gausse/storage/schema.py +++ b/src/gausse/storage/schema.py @@ -19,6 +19,8 @@ CREATE TABLE IF NOT EXISTS runs ( -- всё через decode()+build_detail() на лету (см. web/stats.run_detail); -- хранить дорогую детализацию для каждого из миллионов прогонов -- бессмысленно — так база росла на пустом месте (70ГБ на 12.9М строк). + -- В базах, созданных ДО 2026-07-11, эта колонка содержит старый полный + -- build_detail (другая форма записей ступеней) — учитывать при чтении. decoded_summary_json TEXT NOT NULL, feasible INTEGER NOT NULL, infeasible_reason TEXT, diff --git a/src/gausse/web/rebuild.py b/src/gausse/web/rebuild.py new file mode 100644 index 0000000..ff58167 --- /dev/null +++ b/src/gausse/web/rebuild.py @@ -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 diff --git a/src/gausse/web/render.py b/src/gausse/web/render.py index a736253..ac5cc7f 100644 --- a/src/gausse/web/render.py +++ b/src/gausse/web/render.py @@ -7,7 +7,6 @@ """ import io -import json import tempfile import threading from pathlib import Path @@ -18,15 +17,11 @@ matplotlib.use("Agg") 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.plots import plot_stage_currents, plot_stage_fields, plot_velocity_vs_position -from gausse.sim.coilgun import run_coilgun -from gausse.storage.database import fetch_run_by_id, open_connection +from gausse.web.rebuild import rebuild_run _render_lock = threading.Lock() -_db_cache: ComponentDatabase | None = None _PLOT_BUILDERS = { "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): - conn = open_connection(db_path) - try: - row = fetch_run_by_id(conn, run_id) - finally: - conn.close() - if row is None: + rebuilt = rebuild_run(db_path, run_id) # общий кэш симуляций с run_detail + if rebuilt is None: return None - genome = genome_from_dict(json.loads(row.genome_json)) - config, _ix, _iv = decode(genome, _components_db(), SearchBounds()) - return config, run_coilgun(config) + _row, _genome, config, result, _ix, _iv = rebuilt + return config, result def render_plot_png(db_path: Path, run_id: str, kind: str) -> bytes | None: diff --git a/src/gausse/web/stats.py b/src/gausse/web/stats.py index 7ef2ebd..b792140 100644 --- a/src/gausse/web/stats.py +++ b/src/gausse/web/stats.py @@ -73,10 +73,13 @@ def overview(db_path: Path, log_path: Path | None = None, top_n: int = 15) -> di out = [] for r in rows: summary = json.loads(r.decoded_summary_json) if r.decoded_summary_json else {} + stages = summary.get("stages") out.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": 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 @@ -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: - """Подробности одного прогона — НЕ читаются из БД (там только дешёвая - decoded_summary), а пересчитываются на лету из genome_json тем же - decode()+build_detail(), что и остальной рендер веб-морды (см. - web/render.py). Это единичный запрос по клику, не миллионы прогонов — - пересимулировать дешевле, чем хранить полную физику для каждого.""" - from gausse.components.database import ComponentDatabase - from gausse.optim.objective import build_detail - from gausse.optim.search_space import SearchBounds, decode, genome_from_dict - from gausse.sim.coilgun import run_coilgun + """Подробности одного прогона. Шапка (feasible/КПД/скорость/энергобаланс) + — СОХРАНЁННЫЕ при прогоне значения: они посчитаны тем движком и той + версией физики, что записали строку, и только они сравнимы между собой. + Полная физика по ступеням в БД не хранится (см. schema.py) — она + пересимулируется на лету из genome_json через общий web/rebuild.py. + Пересчёт идёт ТЕКУЩЕЙ моделью на CPU и может расходиться с сохранёнными + числами (GPU-строки, старые model_version) — поэтому detail явно помечен + версией пересчёта, а его ошибка не роняет запрос (раньше /api/run был + единственным эндпоинтом без защиты).""" + from gausse.optim.objective import MODEL_VERSION, build_detail + + from gausse.web.rebuild import components_db, rebuild_run conn = open_connection(db_path) try: @@ -133,22 +139,15 @@ def run_detail(db_path: Path, run_id: str) -> dict | None: if row is None: return None - genome = genome_from_dict(json.loads(row.genome_json)) - db = ComponentDatabase.load() - bounds = SearchBounds() - config, initial_x_m, initial_v_mps = decode(genome, db, bounds) - result = run_coilgun(config) - detail = build_detail(config, result, db, initial_x_m, initial_v_mps, genome.tube_inner_d_m, genome.tube_wall_m) - energy_breakdown = ( - { - "total_energy_in_j": result.total_energy_in_j, - "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 {} - ) + try: + _, genome, config, result, initial_x_m, initial_v_mps = rebuild_run(db_path, run_id) + detail = build_detail( + config, result, components_db(), initial_x_m, initial_v_mps, + genome.tube_inner_d_m, genome.tube_wall_m, + ) + detail["recomputed_with_model_version"] = MODEL_VERSION + except Exception as exc: # деталь — вспомогательная, шапка важнее + detail = {"error": f"пересимуляция не удалась: {exc}"} return { "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, "timestamp": row.timestamp, "detail": detail, - "energy_breakdown": energy_breakdown, + # сохранённый при записи баланс — консистентен с feasible/КПД шапки + "energy_breakdown": json.loads(row.energy_breakdown_json) if row.energy_breakdown_json else {}, }