#!/usr/bin/env python3
"""Assemble reports/gallery.html: the figure set with reading notes, PNGs embedded as data URIs."""
import base64, json
from pathlib import Path
FIG = Path("reports/figures")
def img(name):
return "data:image/png;base64," + base64.b64encode((FIG / name).read_bytes()).decode()
scan = json.load(open("runs/scan_pilot/scan_summary.json"))
n_ok = sum(r["M_total_drift"] < 1e-2 for r in scan)
PLATES = [
("fig1_outcome_map.png", "Карта исходов пилотного скана",
"Оси каждой панели: начальная компактность ν = 2M/R и относительная тангенциальная скорость L_rms. Панели различаются плоскостью профиля и скоростью распада Γ·t_dyn. Цвет кружка — исход, число внутри — максимум 2m/r, достигнутый за 5 t_dyn. Крестик означает, что ADM-масса за прогон уплыла больше чем на 1 %, и точке верить нельзя.",
f"Тёплые облака (L_rms = 0.5) при ν ≤ 0.15 отскакивают, распад снижает максимум компактности в 2–3 раза. Плоские компактные профили (ν = 0.3, flatness 3) доходят до 2m/r ≈ 0.99 при любых L и Γ. Все холодные точки с L = 0 либо недостоверны, либо пограничны: холодная пыль сходится в каустику, которую 300 ячеек не разрешают. Достоверных точек {n_ok} из {len(scan)}.",
"Исход «BH_approach» — не найденный apparent horizon. Это достижение порога 2m/r = 0.99 при коллапсе лапса, после которого полярно-ареальная калибровка не продолжается."),
("fig2_timeseries.png", "Четыре сценария во времени",
"Слева вверху — максимум компактности 2m/r по радиусу; штриховая линия 0.99 — порог срыва калибровки. Справа вверху — минимальный лапс e^μ: темп собственного времени в самой глубокой точке относительно далёкого наблюдателя. Внизу — масса Мизнера–Шарпа внутри расчётной области и число макрочастиц.",
"Отскок (синий): компактность растёт до 0.33 и спадает, лапс почти не меняется. Плоский профиль (оранжевый): компактность выходит на 0.99, лапс падает на пять порядков — далёкий наблюдатель видит «замерзание». Холодная пыль (красный): тот же порог, но масса внутри области растёт до 1.28 — это численный артефакт каустики, а не физика. Распад (зелёный): число частиц скачет из-за рождения и вылета дочерних пар.",
None),
("fig3_spacetime.png", "Пространственно-временные диаграммы",
"По горизонтали радиус в единицах начального R, по вертикали время в динамических временах t_dyn = √(R³/M). Цвет — 2m(r)/r; светлая полоса — область, где метрика близка к горизонту. Голубые кривые — мировые линии 25 макрочастиц X.",
"Отскок: линии сходятся к центру, пересекаются и расходятся обратно, компактность остаётся тёмной. Плоский профиль: все линии стягиваются к r ≈ 0.3R и там останавливаются в координатном времени — светлая вертикальная полоса и есть «замороженная» область у порога горизонта. Холодная пыль: линии сходятся в точку r → 0 около t ≈ 1.5 t_dyn, дальше расчёт недостоверен.",
None),
("fig4_phase_space.png", "Фазовое пространство (r, w)",
"Каждая точка — макрочастица X: радиус и радиальный импульс w = p^r̂ в ортонормированном репере статичного наблюдателя. Цвет — √L/R, то есть доля тангенциального движения. Верхний ряд — холодная пыль, нижний — тёплое облако с L_rms = 0.5.",
"Холодная пыль остаётся однопоточной линией, пока не дойдёт до центра при t ≈ 1.5 t_dyn; после этого w у центра уходит к −25, что и разрушает расчёт. Тёплое облако с t ≈ 2.5 t_dyn имеет два и более значения w при одном r — это пересечение потоков, которое гидродинамика пыли описать не может, а решатель Власова описывает штатно. К t = 5 t_dyn видна спиральная структура фазового перемешивания.",
None),
("fig5_profiles.png", "Радиальные профили при приближении к горизонту",
"Шесть моментов времени для плоского профиля ν = 0.3. Плотность энергии ρ(r), масса Мизнера–Шарпа m(r), компактность 2m/r и лапс e^μ.",
"Масса стягивается внутрь r ≈ 0.3R, плотность вырастает на два порядка, 2m/r касается единицы (штрих) на радиусе ≈ 0.3R, а лапс внутри этого радиуса падает к нулю. Снаружи профиль m(r) и лапс не меняются — по теореме Биркгофа внешний наблюдатель видит ту же массу.",
"Максимум 2m/r останавливается на 0.99 не потому что горизонт не образуется, а потому что расчёт остановлен по порогу калибровки."),
("fig6_decay.png", "Распад X и дочернее излучение",
"Сценарий тёплого облака с Γ·t_dyn = 0.3. Вверху слева — нераспавшаяся масса X против exp(−Γt); вверху справа — баланс ADM-массы: масса внутри области плюс энергия Киллинга, унесённая через границу. Внизу — число макрочастиц по видам и фазовое пространство X и безмассовых дочерних частиц.",
"Масса X убывает медленнее exp(−Γt) там, где лапс меньше единицы и частицы движутся: распад идёт по собственному времени. Чёрная линия ADM-баланса горизонтальна — энергия распада не теряется, а переходит в дочерние пары и уходит через границу. Дочерние частицы (оранжевые) заполняют w ≈ +0.5: это релятивистские кванты, летящие наружу.",
"Дочерняя компонента здесь бесстолкновительная. Предел тесно связанной радиационной жидкости ещё не реализован, поэтому давление излучения на X не учитывается."),
("fig7_validation.png", "Валидация на аналитических решениях",
"Слева: коллапс пылевого шара, каждая оболочка сравнивается с точной геодезикой Леметра–Толмана–Бонди в своём собственном времени. В центре: радиальное падение пробной частицы в метрике Шварцшильда, координатное время расходится у r = 2M. Справа: сходимость ошибок по числу ячеек.",
"Точки решателя лежат на аналитических кривых; ошибки метрики и орбиты убывают как K⁻², что подтверждает второй порядок схемы. Это выполненные пункты обязательных тестов спецификации: вакуумная регрессия, однородный пылевой коллапс, сходимость.",
None),
("fig8_resources.png", "Что упирается в текущий VPS",
"Время одного шага RK4 и пиковая память в зависимости от числа макрочастиц; измерено на 2 vCPU / 3.7 GB.",
"Время линейно, 3.8 мкс на частицу и шаг. Память — около 280 байт на частицу, и при 2·10⁷ частиц процесс убит OOM-killer'ом. Один сходящийся прогон на 10⁶ частиц здесь стоит около 39 часов, скан с конвергенцией — порядка 100 дней; на CCX43 около двух недель.",
None),
]
def plate(i, fn, title, how, what, caveat):
cav = f'<div class="note caveat"><span class="lbl">Оговорка</span><p>{caveat}</p></div>' if caveat else ""
return f'''
<section class="plate" id="fig{i}">
<div class="plate-head"><span class="eyebrow">Рис. {i}</span><h2>{title}</h2></div>
<figure><img src="{img(fn)}" alt="{title}"></figure>
<div class="notes">
<div class="note"><span class="lbl">Как читать</span><p>{how}</p></div>
<div class="note"><span class="lbl">Что видно</span><p>{what}</p></div>
{cav}
</div>
</section>'''
toc = "".join(f'<a href="#fig{i}"><span class="n">{i}</span>{t}</a>' for i, (_, t, *_) in enumerate(PLATES, 1))
body = "".join(plate(i, *p) for i, p in enumerate(PLATES, 1))
html = f'''<title>Сферический коллапс X</title>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Newsreader:opsz,wght@6..72,400;6..72,500&family=Source+Sans+3:wght@400;600&family=JetBrains+Mono:wght@400&display=swap">
<style>
:root {{
--bg:#F2F4F7; --surface:#FFFFFF; --ink:#17202B; --muted:#5B6778; --line:#D7DCE4;
--accent:#5A3FD1; --bh:#C43C2E; --bounce:#2F6DB5; --code-bg:#E9ECF2;
}}
@media (prefers-color-scheme: dark) {{ :root:not([data-theme="light"]) {{
--bg:#0F1218; --surface:#171B24; --ink:#E6E9EF; --muted:#98A3B5; --line:#2A3140;
--accent:#A996FF; --bh:#F0705F; --bounce:#7FB2F0; --code-bg:#1F2532;
}} }}
:root[data-theme="dark"] {{
--bg:#0F1218; --surface:#171B24; --ink:#E6E9EF; --muted:#98A3B5; --line:#2A3140;
--accent:#A996FF; --bh:#F0705F; --bounce:#7FB2F0; --code-bg:#1F2532;
}}
body {{ background:var(--bg); color:var(--ink); font-family:"Source Sans 3", "Segoe UI", system-ui, sans-serif; font-size:16px; line-height:1.55; }}
.wrap {{ max-width:1000px; margin:0 auto; padding:40px 24px 80px; }}
h1, h2 {{ font-family:"Newsreader", Georgia, "Times New Roman", serif; font-weight:500; text-wrap:balance; margin:0; }}
h1 {{ font-size:2.4rem; line-height:1.1; }}
h2 {{ font-size:1.5rem; line-height:1.2; }}
.mast {{ display:grid; grid-template-columns: 1fr; gap:14px; border-bottom:1px solid var(--line); padding-bottom:24px; margin-bottom:28px; }}
.mast p {{ margin:0; max-width:66ch; color:var(--muted); }}
.eyebrow, .lbl {{ font-family:"JetBrains Mono", ui-monospace, monospace; font-size:0.72rem; letter-spacing:0.08em; text-transform:uppercase; color:var(--accent); }}
.legend {{ display:flex; flex-wrap:wrap; gap:8px 20px; font-size:0.9rem; }}
.legend span::before {{ content:""; display:inline-block; width:10px; height:10px; border-radius:50%; margin-right:6px; vertical-align:middle; }}
.legend .bh::before {{ background:var(--bh); }} .legend .bounce::before {{ background:var(--bounce); }} .legend .unres::before {{ background:#8a8f99; }}
.primer {{ display:grid; grid-template-columns:repeat(auto-fit, minmax(210px, 1fr)); gap:18px 28px; margin:0 0 36px; }}
.primer div {{ font-size:0.95rem; }}
.primer dt {{ font-family:"JetBrains Mono", ui-monospace, monospace; font-size:0.85rem; color:var(--ink); margin-bottom:4px; }}
.primer dd {{ margin:0; color:var(--muted); }}
.toc {{ display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:6px 24px; margin:0 0 40px; font-size:0.95rem; }}
.toc a {{ color:var(--ink); text-decoration:none; border-bottom:1px solid transparent; }}
.toc a:hover, .toc a:focus-visible {{ border-bottom-color:var(--accent); outline:none; }}
.toc .n {{ font-family:"JetBrains Mono", ui-monospace, monospace; color:var(--accent); margin-right:10px; font-size:0.8rem; }}
.plate {{ margin:0 0 48px; padding-top:24px; border-top:1px solid var(--line); }}
.plate-head {{ display:flex; flex-direction:column; gap:4px; margin-bottom:14px; }}
figure {{ margin:0; background:var(--surface); border:1px solid var(--line); padding:8px; overflow-x:auto; }}
figure img {{ display:block; width:100%; height:auto; }}
@media (prefers-color-scheme: dark) {{ :root:not([data-theme="light"]) figure img {{ filter:invert(0.92) hue-rotate(180deg); }} }}
:root[data-theme="dark"] figure img {{ filter:invert(0.92) hue-rotate(180deg); }}
.notes {{ display:grid; grid-template-columns:repeat(auto-fit, minmax(280px, 1fr)); gap:16px 28px; margin-top:14px; }}
.note p {{ margin:4px 0 0; font-size:0.95rem; max-width:62ch; }}
.caveat {{ grid-column:1 / -1; border-left:3px solid var(--bh); padding-left:12px; }}
.caveat .lbl {{ color:var(--bh); }}
code {{ font-family:"JetBrains Mono", ui-monospace, monospace; font-size:0.85em; background:var(--code-bg); padding:1px 5px; border-radius:3px; }}
footer {{ color:var(--muted); font-size:0.85rem; border-top:1px solid var(--line); padding-top:16px; }}
@media (prefers-reduced-motion: no-preference) {{ .toc a {{ transition:border-color .15s; }} }}
</style>
<div class="wrap">
<header class="mast">
<span class="eyebrow">pbhgr · Stage A · шаг 1 · 13.09.2026</span>
<h1>Сферический коллапс холодного сектора X</h1>
<p>Восемь рисунков, по которым видно, что делает решатель Einstein–Vlasov с распадом: где облако отскакивает, где стягивается к порогу горизонта, как пересекаются потоки и куда уходит энергия распада. Единицы G = c = 1, масса облака M = 1, время в t_dyn = √(R³/M).</p>
<div class="legend"><span class="bh">BH_approach — 2m/r дошло до 0.99</span><span class="bounce">bounce — максимум компактности пройден</span><span class="unres">unresolved — исход не определён за 5 t_dyn</span></div>
</header>
<dl class="primer">
<div><dt>2m(r)/r</dt><dd>компактность: отношение удвоенной массы Мизнера–Шарпа внутри радиуса r к этому радиусу. Единица означает горизонт.</dd></div>
<div><dt>e^μ (лапс)</dt><dd>во сколько раз медленнее идёт собственное время на радиусе r по сравнению с далёким наблюдателем.</dd></div>
<div><dt>w = p^r̂, L</dt><dd>радиальный импульс и квадрат углового момента макрочастицы в репере статичного наблюдателя.</dd></div>
<div><dt>ν, L_rms, Γ·t_dyn</dt><dd>оси скана: начальная компактность, тангенциальная скорость в единицах √(M/R), число распадов за динамическое время.</dd></div>
</dl>
<nav class="toc">{toc}</nav>
{body}
<footer>Данные: <code>runs/viz/*.pkl</code>, <code>runs/scan_pilot/</code>, <code>runs/resource_probe.json</code>. Сборка: <code>scripts/run_showcase.py</code>, <code>scripts/make_figures.py</code>, <code>scripts/build_gallery.py</code>. Разрешение показательных прогонов: 2·10⁴ частиц, 300 ячеек — иллюстрация механики, не сходящийся результат.</footer>
</div>
'''
Path("reports/gallery.html").write_text(html, encoding="utf-8")
print("gallery.html", len(html) // 1024, "KB")