# #REC-12: Pull-стрим сегментов водопада + шов на лету в единый `.aswf`
## Cs-137 662 кэВ, PC-клиент `wf_recorder_app.py`, 11.00 ч непрерывной записи

**Дата:** 2026-07-04
**Задача:** #REC-12 (task #33)
**Статус:** ЗАВЕРШЁН. Стрим стабилен, склейка без швов, потерь нет.
**Веб-отчёт (с оформлением):** [rec12_report.html](https://vibeengineering-llc.github.io/atomspectra-waterfall-esp32/docs/rec12_report.html)

---

## 1. Цель

После `#REC-11` (pull-эндпоинт `/api/waterfall/segments` + `.../segment` + `.../segment/delete`)
на плате появилась возможность забирать финализированные сегменты водопада по HTTP. Однако:

- Каждый сегмент — отдельный `.aswf` с собственным заголовком (60 файлов после 11-часовой записи).
- Просмотр — только по одному файлу за раз, без общей временной оси.
- Долгая запись (> `ring_capacity`) не восстанавливается постфактум — сегменты нужно забирать
  **до** заполнения flash-кольца (см. #FW-19, `stab2_report.md` §6).

`#REC-12` замыкает pull-модель: PC периодически опрашивает плату, забирает новые сегменты и
**на лету** склеивает их в один растущий `.aswf`. Тест — 11 часов непрерывной записи с
детектором Cs-137 662 кэВ, обычные условия (комнатная температура, WiFi).

Валидируется:

1. Sticher **не портит границы сегментов** (нет полос/дыр в водопаде на стыках).
2. `delete` на плате идёт **только после fsync** склеенного файла — при разрыве сети сегмент
   переезжает в следующий проход, а не теряется.
3. Идемпотентность: `state.json` хранит ingested-сегменты по имени+размеру, повторные заходы
   не дублируют данные.
4. Артефакт `.aswf` открывается штатным вьюером (`waterfall_viewer.html`) как единая запись.

---

## 2. Схема эксперимента

```
[AtomSpectra детектор]
        │  USB (внутри платы)
        ▼
[ESP32-S3 atomspectra-gw]                  [PC Windows 11]
  /api/waterfall/segments  ── HTTP ──►     wf_recorder_app.py (UI поверх wf_pull_client.py)
  /api/waterfall/segment                          │
  /api/waterfall/segment/delete                   ├─ spectrogram_04-07-2026.aswf   (единый файл, растёт)
  /api/status (t1/t2/t3)                          ├─ ...aswf.state.json            (ingested по seg-name+size)
                                                  └─ ...aswf.temps.csv             (телеметрия температуры)
```

Плата пишет водопад в flash-кольцо, финализирует сегменты по возрасту (age-ролловер) и
по объёму. PC-клиент раз в `--interval` секунд:

1. Тянет `/api/status` — записывает t1/t2/t3 в `temps.csv`.
2. Тянет список сегментов `/api/waterfall/segments`.
3. Для каждого нового (по `state.json`): `GET /api/waterfall/segment?name=...`, приписывает
   в конец растущего `.aswf` (только строки, заголовок сегмента отбрасывается — первый
   заголовок остаётся как заголовок склеенного файла), `fsync`, обновляет `state.json`.
4. Только после успешного `fsync` — `POST /api/waterfall/segment/delete` для этого сегмента
   на плате.

Сеть, файл или плата отвалились посреди шага — сегмент останется в списке платы, попадёт
в следующий проход. Дубликаты режутся `state.json` по `(name, size)`.

---

## 3. Параметры теста

| | |
|---|---|
| Прошивка платы | `main` после `#WF-1` (`CONFIG_SPI_FLASH_AUTO_SUSPEND` выкл) |
| PC-клиент | `scripts/wf_recorder_app.py` (UI поверх `scripts/wf_pull_client.py`) |
| Интервал опроса | 60 с |
| Источник | Cs-137 662 кэВ, комнатная температура |
| Соединение | WiFi (плата ↔ PC) |

Запуск:
```
scripts\wf_recorder.bat
# или напрямую:
python scripts\wf_recorder_app.py --host http://atomspectra.local --interval 60
```

### 3.1 Готовый Windows exe (#PKG-1)

Для пользователей без Python — самодостаточный `wf_recorder.exe`
(~12 МБ, tkinter + `requests` + `wf_pull_client` внутри):

- **Скачать:**
  [Release `wf-recorder-v0.1.0`](https://github.com/VibeEngineering-LLC/atomspectra-waterfall-esp32/releases/tag/wf-recorder-v0.1.0)
- **Запуск:** двойной клик → GUI откроется. По умолчанию файл записи —
  `received/spectrogram.aswf` рядом с exe (создастся автоматически).
- **Пересборка из исходников** (Python 3.10+, PyInstaller):
  ```
  cd scripts
  python -m PyInstaller --onefile --windowed --name wf_recorder \
    --hidden-import wf_pull_client --paths . wf_recorder_app.py
  # результат: scripts/dist/wf_recorder.exe
  ```

Логика внутри exe идентична `.py`-версии (тот же `wf_pull_client.Stitcher`,
тот же `state.json`, тот же порядок «сшить → fsync → delete на плате»),
включая fix `_default_output_path()` для frozen-запуска.

> **Asset обновлён 2026-07-05 (commit `85eadb7`):** исправлен старт exe — режим `--windowed`
> PyInstaller обнуляет `sys.stdout`/`sys.stderr`; вызов `.reconfigure()` в обоих файлах
> (`wf_recorder_app.py`, `wf_pull_client.py`) обёрнут guard-ом `if sys.stdout is not None`.

---

## 4. Результаты

### Общая длительность

| | UTC |
|---|---|
| Первый сегмент склеен | 2026-07-04 09:30:03 |
| Последний сегмент склеен | 2026-07-04 20:30:05 |
| **Продолжительность** | **11.00 ч (39 601 с dur_sum)** |
| Строк спектра всего | **660** (по 60.00 с/строка) |
| Сегментов забрано | **60** (`seg_00000.aswf` … `seg_00059.aswf`) |
| Размер каждого сегмента на плате | 184 350 байт (константно) |

Разброс интервала строки (`dur_sum / rows = 39 601 / 660 = 60.001 с`) на уровне единиц
миллисекунд — попадает точно в номинал `--interval-sec 60` без дрейфа.

### Склеенный `.aswf`

| Метрика | Значение |
|---|---|
| Файл | `received/spectrogram_04-07-2026.aswf` (локально, не в git) |
| Размер | 10 818 864 байт (~10.32 МБ) |
| Строк | 660 (из шапки v2) |
| Формула проверки | `60 × 184 350 − 59 × 4104 = 11 061 000 − 242 136 = 10 818 864` ✔ |

`4104 байт` — размер заголовка сегмента. Stitcher оставляет заголовок первого сегмента как
шапку итогового файла и отбрасывает шапки 59 последующих (тело каждого сегмента, только
строки, приписывается в конец). Совпадение до байта — прямое доказательство, что склейка
не добавляет и не теряет ни одного байта данных.

### Стабильность стрима

| Метрика | Значение |
|---|---|
| Провалов опроса (`error:...` в логе клиента) | 0 |
| Сегментов, потерянных кольцом до забора | 0 (`seg_dropped = 0`) |
| Ребутов платы | 0 |
| Пропусков строк на границах сегментов | 0 (визуально по 3D-водопаду) |
| Скачков центроида на стыках | нет (пик Cs-137 стабилен по всей высоте, 2D-карта) |

### Телеметрия температуры прибора (`t1`)

| Параметр | Значение |
|---|---|
| Замеров | 603 (по одному на проход опроса) |
| Диапазон | 25.5 … 27.0 °C |
| Разброс | 1.5 °C за 10.7 ч (комнатная эксплуатация) |

`t2`, `t3` = 0 (не задействованы этой сборкой прошивки) — норма.

---

## 5. Артефакт: pull-путь без пина сегмента vs. keep-last кольцо

Кольцо keep-last (`make_room()`, `main/spectrogram.c:304-321`) удаляет самый старый
завершённый сегмент при нехватке места на flash — кроме текущего открытого и **запиненного**
(`s_seg_pinned`). Пин ставит только push-выгрузка (#REC-11-A2, `wf_offload.c`), pull-путь
(`GET /api/waterfall/segment`) сегмент во время скачивания **не пинит**.

При интервале опроса 60 с и типовой скорости записи (сегмент ~10-11 мин) запас
многократный — за час плата успевает добавить ~5-6 сегментов, клиент забирает 60 проходов.
На данном тесте кольцо ни разу не приблизилось к границе (`seg_dropped = 0`, все 60
сегментов забраны).

Но при аварийно длинном интервале клиента или очень быстрой сегментации кольцо может
съесть непрочитанный сегмент. Это зафиксировано как **известное ограничение**
в [KNOWN_ISSUES.md](../KNOWN_ISSUES.md) (раздел «Pull-опрос (`wf_pull_client.py`, #REC-12):
нет пина сегмента от кольца keep-last»). Обходной путь — держать `--interval` заметно
короче времени съедания кольцом непрочитанного сегмента при текущей скорости записи.

Автоматической защиты (аналог `s_seg_pinned` для pull-скачивания) пока не реализовано —
на текущей длительности теста (11 ч) она не требуется.

---

## 6. Артефакт: `started_at` vs. `dur` — разные источники времени

`started_at` в шапке склеенного файла = `time(NULL)` платы на момент открытия **первого**
сегмента (SNTP-синк через `pool.ntp.org`). Абсолютная метка — только если у платы был
интернет на старте сегмента. Полное описание — в [WATERFALL.md](../WATERFALL.md), раздел
«Семантика per-row длительности (`dur`) и модель времени».

`dur` каждой строки — с живых часов **прибора** (`total_time_sec`), от интернета не
зависит. Реляционные интервалы между строками честны всегда — и на этом тесте
подтверждено: 660 строк × 60.001 с = 39 601 с, ровно совпадает с независимой оценкой
по `unix_ts` из `temps.csv` (10.75 ч между первым и последним замером).

---

## 7. Артефакты (файлы)

| Файл | |
|---|---|
| Склеенный `.aswf` (11 ч записи) | `received/spectrogram_04-07-2026.aswf` (локально, ~10.3 МБ, gitignored) |
| Ingested-state | `received/spectrogram_04-07-2026.aswf.state.json` |
| Телеметрия температуры | `received/spectrogram_04-07-2026.aswf.temps.csv` (603 строки) |
| PC-клиент (ядро) | `scripts/wf_pull_client.py` |
| PC-клиент (UI-сборщик) | `scripts/wf_recorder_app.py` |
| Launcher | `scripts/wf_recorder.bat` |
| Firmware pull-эндпоинты (плата) | `main/web_waterfall.c` (`h_segments`, `h_segment`, `h_segment_delete`) |

Артефакты записи (`.aswf`, `.state.json`, `.temps.csv`) — локально, не публикуются
(gitignored: `*.aswf`, `*.n42`; каталог `received/` игнорируется отдельно).

---

## 8. Импликации

### #REC-12 → закрыт
Pull-стрим доказан на железе: 11 ч непрерывной записи, 60 сегментов, 660 строк —
0 потерь, 0 швов, 0 дубликатов, склеенный файл байт-в-байт совпадает с ожидаемым размером
(60 × сегмент − 59 × шапка). Открывается штатным вьюером как единая запись.

Схема покрывает основной сценарий длинных записей, обходя ограничение #FW-19 (экспорт
n42 обрезан кольцом на ~4.25 ч) — pull-клиент забирает сегменты **до** заполнения кольца,
восстанавливать хвост постфактум не требуется.

### Незакрытые связанные

- **#FW-19** (task #32, pending) — увеличение `ring_capacity` и/или документирование
  требования периодического pull. `#REC-12` смягчает симптом, но не устраняет причину.
- **#REC-13** (не заведён, потенциально) — пин сегмента для pull-скачивания (аналог
  `s_seg_pinned` push-пути). На текущей длительности и типовых интервалах не критично,
  документировано в `KNOWN_ISSUES.md`.

---

## 9. Итог

> **Pull-стрим `wf_recorder_app.py` подтверждён: 11.00 ч непрерывной записи, 60 сегментов
> склеены на лету в единый 10.32 МБ `.aswf`. 0 потерь, 0 швов, 0 ребутов, склеенный файл
> байт-в-байт совпадает с ожидаемым размером. Пик Cs-137 662 кэВ стабилен по всей высоте
> 3D-водопада и 2D-карты. Схема закрывает основной сценарий длинных записей, обходя
> ограничение #FW-19 без изменения прошивки.**
