diff --git a/.gitignore b/.gitignore
index 1a911db..4685461 100644
--- a/.gitignore
+++ b/.gitignore
@@ -252,7 +252,6 @@ AGENTS.md
.claude/
.graphifyignore
pyrightconfig.json
-/docs/
test_end_2/
# --- device_firmware: PlatformIO / STM32G431 cart remote ---
diff --git a/README.md b/README.md
index d7c47d5..1c840fe 100644
--- a/README.md
+++ b/README.md
@@ -1,599 +1,84 @@
# Radar System
-A real-time radar acquisition and imaging system for vector-network-analyzer (VNA)
-and ADC front-ends. It drives the radar through a frequency sweep, calibrates and
-preprocesses the measured S21 traces, runs a selectable imaging/detection
-processor, and presents the live result in a desktop GUI and an in-process web
-interface. The same application runs interactively on a workstation or unattended
-as a headless Raspberry Pi appliance.
+Программа радиолокационной системы. Она управляет радаром, снимает развёртки
+коэффициента передачи S21 по всем парам антенн, калибрует их, строит изображения сцены
+и показывает их в окне программы и в браузере. Найденные объекты она отдаёт внешним
+программам по TCP. Программа работает на рабочей станции с экраном и на Raspberry Pi 5
+без оператора.
-## Overview
+Радары, с которыми она работает:
-The system uses **Python acquisition producers**, **C++ preprocessing and processing**,
-and a **Python application** for supervision, visualization and control.
-
-The pipeline runs as three independent processes connected by lock-free POSIX
-shared-memory ring buffers. Each stage advances on its own, and a slow consumer
-never blocks a producer — the rings are latest-wins: a full ring overwrites its oldest
-unread frame, a reader that falls behind continues with the frames still retained, and
-one overtaken during a read resumes at the newest frame:
-
-1. **Acquisition** — Python producers configure the radar, acquire frequency sweeps,
- and publish raw S21 traces. Both single and multi-device LibreVNA use
- `matrix_raw_producer`; RadioPhotonic radar (RPH) uses `rph_radar_raw_producer` with its C++
- collector (E-502) or the RFG board's UDP stream.
-2. **Preprocessing** — `data_preprocessor` applies through calibration,
- reference subtraction, optional frequency-domain notch filtering, and an
- optional object-based MIMO correction of S21.
-3. **Processing** — `data_processor` runs the selected processing mode and emits
- results (pass-through traces, B-scan images, GPR images with detected objects,
- or 3D SAR views). Detected objects are additionally broadcast to subscribers
- over a TCP locator server.
-
-The **Python application** (`python_app`) starts and monitors the producer and C++ processes,
-reads the result rings, renders the live view with pyqtgraph, and exposes acquisition
-controls, processing settings and a copy of the live plot through an embedded web UI.
-
-```
- radar hardware
- │
- ▼
- Acquisition ──[raw ring]──▶ Preprocessing ──[preprocessed ring]──▶ Processing ──[result ring]──▶ GUI / Web
- │
- └──▶ TCP locator (detected objects)
-```
-
-## Supported hardware
-
-- **librevna** — single LibreVNA.
-- **librevna_multi** — synchronized multi-device LibreVNA.
-- **rph_radar** — [RadioPhotonic radar (RPH) collector](data_acq_and_processing/rph_radar_collector/README.md),
- with manual laser setpoints or LD1 current variation via **Apply Radar**.
- `radar.rph_radar.source` selects the ADC:
- - `e502` (default) — the application owns the collector and exchanges complete
- sweeps over a PTY. Switching to a different antenna pair starts a fresh ADC session
- after settling, discarding queued samples and an initial partial sweep.
- - `rfg_udp` — the RFG board sends averaged sweep points to `udp_bind`
- (`run_config_rph_radar_rfg.example.json`); main = ch0 − ch1, reference = ch2. It
- needs `laser_control.mode: variation` (even when the laser is started elsewhere):
- a sweep must have exactly the task's step count, `ceil((max − min) / step)` in DAC
- codes, with no lost points; others are dropped (the board loses a few percent).
- Capture windows come from kernel receive timestamps. Only one antenna pair (both
- switches with one position): without a source-side marker a sweep after a switch
- cannot be proven fresh.
-- **mock / simulator** — driver-less mode for development without hardware.
-
-## Processing modes
-
-- **pass_through** — magnitude and phase of S21 for each combo.
-- **bscan** — depth-versus-sweep B-scan heatmap. A band selecting fewer than two
- sweep points gives no range profile, and such sweeps are left out of the image.
-- **gpr** — coherent back-projection on the ground plane, as in `Horns_motion_4x4_ground.py`,
- with object detection. Radar height and look angle define the slice in radar X/Z axes;
- the wave speed is 3×10⁸ m/s (air).
-- **sar3d** — 3D SAR of the reference notebook `Multi_depthnorm_clean.ipynb`
- on an NVIDIA GPU through CUDA: front (X-Y), side (Z-Y), top (X-Z)
- and oblique maximum projections of the imaged
- volume. Its settings carry the notebook's parameter names; the scene window is
- set by the selected antennas with a 2 m X margin and height −0.5…2 m above the ground; its range extent follows
- `MIN_DEPTH`/`MAX_DEPTH`, and the voxel steps `SAR_VOXEL_M` X, Y and Z default to the
- notebook's 2.5, 2 and 2 cm. The radar motion is corrected from each trace's capture
- timestamps, so the sweep timing needs no parameters.
-
-Both coherent GPR and SAR apply `PAIR_DEPTH_EQUALIZE` (on by default) before the
-per-pair median normalization. The notebook's 1.5 m sliding median estimates each
-pair's depth-dependent level; positive gains bring the pairs to their geometric
-mean while preserving phase and the common depth envelope. **Subtract mean of previous
-collections** subtracts up to **Mean count** preceding spectra for each pair before
-motion correction and the IFFT; zero count disables it. SAR uses a causal aperture:
-`SAR_FRAME_RADIUS` previous frames plus the current frame, with the current scene origin.
-The processor retains enough history for the aperture and each frame's preceding
-mean; transport ring capacity does not shorten this background window.
-Scene width `SAR_X_SIZE_M` and length `SAR_Z_SIZE_M` use 0 for the automatic notebook
-bounds; positive values resize the axes about their automatic centres. `SAR_Y_RANGE_M`
-sets the height limits. The depth gate remains independent of these scene bounds.
-
-SAR **Zoom (%)** shows the full scene at 100%; increasing it shrinks all four
-views uniformly towards the brightest response. **SAR_CLOUD_THRESHOLD** hides
-values below the selected fraction of the normalization maximum. Both controls
-redraw the existing result without recalculating it. Tx/Rx markers appear on the
-front, side and top projections in ground coordinates. A hollow square marks the
-peak; its size follows the contiguous half-maximum width along the volume axes
-(FWHM, at least one voxel). This measures the imaged response rather than the
-reflector's physical dimensions. The square is left out when CLEAN objects are shown
-or when the background gate empties the frame.
-
-**BG_GATE_ENABLE** shows a frame relative to its background, the median of the volume
-over its working voxels: below 6 backgrounds the frame is empty, at 6 its maximum
-appears at the cloud threshold, and from 8.5 the scale is full. **SAR_CLEAN_ENABLE**
-runs the notebook's CLEAN on a one-frame aperture: always with `SAR_FRAME_RADIUS` 0,
-otherwise while no previous frame is available, as after Start. It subtracts the strongest point response
-from the A-scans of every pair, step by step on a 4 × 10 × 4 cm grid over the whole scene
-region, until the residual is background. The search ignores `SAR_X_SIZE_M` and
-`SAR_Z_SIZE_M`, so reflectors just outside a narrowed scene are subtracted too. Each object is then imaged apart and drawn in its own 0–1
-scale over the residual, so a weak object is not hidden by the sidelobes of a strong
-one. **SAR_MARK_ENABLE** draws boxes around groups of CLEAN objects: red for very low
-objects (like a mine), orange on the ground, blue above it, dashed grey at the scene
-boundary, where the response may lie partly outside. The legend is in the title of the
-3D view. Without boxes the objects are numbered with their level in backgrounds. All
-three switches are on by default, like in the notebook, and the status line reports the
-background ratio and the CLEAN result. The gate and CLEAN are processing settings; boxes
-only redraw. Their thresholds and rules are the notebook's fixed values. Both cost time:
-on a GTX 1060 with the default scene of 10 million voxels, the gate adds about 0.1 s per
-frame and CLEAN about 0.4 s more.
-
-The desktop uses a light theme with white plot canvases. **Mode** stays below the
-**Actions** buttons while the settings sections scroll; the settings panel and
-runtime log can be collapsed with their arrow buttons. SAR3D status, peak
-coordinates and level appear above the projections.
-
-For B-scans on every radar model, **B-scan pair** selects a pair from the displayed
-history independently of the run combinations. **Next Combination** advances output
-first, then input, and wraps to the first recorded pair; it is enabled with at least
-two pairs. Desktop and browser selectors redraw the cached B-scan immediately.
-
-Tx/Rx antenna positions are edited in tables with switch position and X/Y/Z in
-metres, on both the desktop and web interface. Coordinate changes apply to the
-running processor and recompute its retained result. Profiles
-preserve all three coordinates. A new table row becomes active after X is entered;
-desktop, web and profile geometry use one Python validator; C++ validates its own
-input. GPR/SAR views wait for a result marked with the current settings revision,
-so an older in-flight result cannot overwrite new geometry.
-
-## Repository layout
-
-| Path | Contents |
+| Радар | Что это |
| --- | --- |
-| `data_acq_and_processing/` | C++ pipeline: `preprocessing/`, `processing/` (including `locator/`), `rph_radar_collector/`, shared `common_cpp/` (config, IPC rings, H7992 driver), and vendored `third_party/` (Eigen, nlohmann/json). |
-| `python_app/` | Python application: `gui/`, `orchestration/` (process supervisor, shared-memory readers, config), `hardware_full/` (device drivers and services), `webui/`, `storage/`, `models/`, `workflows/`, `scripts/` (producers and tools), and `tests/`. |
-| `run_config_examples/` | Ready-made configuration profiles, one per hardware setup. |
-| `deploy/` | systemd daemon installer, and the USB update kit for the Pi and Jetson (`pack-usb.sh`, `update.sh`). |
-| `build/bin/` | Compiled C++ binaries. |
-| `device_firmware/` | Device firmware and protocol documentation. |
+| одиночный LibreVNA | один векторный анализатор цепей и ВЧ-ключи на антеннах |
+| многоприборная схема | три синхронных LibreVNA; с контроллером ключей — матрица 4 × 4 пар за одну развёртку |
+| радиофотонный радар | СВЧ-сигнал формируют лазеры, данные оцифровывает АЦП L-Card E-502 или плата RFG |
+| симулятор | работа без железа: один движущийся точечный отражатель |
-## Requirements
+Режимы обработки: pass-through (S21 по частоте), B-scan (дальность во времени), GPR
+(карта земли перед радаром с поиском объектов), 3D SAR (трёхмерное изображение на
+видеокарте NVIDIA).
-- Linux (POSIX shared memory; GPIO on Raspberry Pi). The deployment target is a
- **Raspberry Pi 5**.
-- A C++20 compiler and `make`; Eigen and nlohmann/json are vendored.
-- `libusb-1.0` runtime and `libatomic.so.1` (`libatomic1` on Debian/Ubuntu).
- Python ring readers/writers use lock-free atomic loads, stores and cursor CAS
- through libatomic, including memory barriers for ARM. Slot sizes must be multiples
- of 8 bytes, and the complete ring mapping must not exceed 1 TiB.
-- **Python 3.10 or newer**, in a virtual environment.
-- For `sar3d`: an NVIDIA GPU with its driver (`libcuda`). The
- kernels are compiled at run time by NVRTC, so there is no nvcc step; `make`
- needs only `nvrtc.h`, `cuda.h` and `libnvrtc`, taken from the pip
- `nvidia-cuda-*` packages in `.venv` (`requirements.txt`, x86_64) or from
- a system toolkit in `/usr/local/cuda` (Jetson).
- Without these dependencies or a usable GPU, SAR3D shows a warning above the
- plots and does not calculate an image. Other processing modes remain available.
+## Быстрый старт без железа
-## Build and run
-
-From the repository root:
-
-```bash
-python3 -m venv .venv
-.venv/bin/python -m pip install --upgrade pip
-.venv/bin/python -m pip install -r requirements.txt
-make # data_preprocessor and data_processor in build/bin/
-make rph_radar_collector # RadioPhotonic radar (RPH) with radar.rph_radar.source: e502
+```sh
+./start.sh
```
-`make` builds the sar3d GPU code when it finds CUDA (see Requirements) and
-prints a note when it does not. After installing the CUDA requirements into an
-existing venv, run `make` again: it notices the change and rebuilds that part.
-CUDA 13 is installed from `requirements.txt` on x86_64. For GPUs older than
-sm_75, install NVRTC 12 outside `.venv` and build with
-`make BUILD_DIR=/tmp/build-nvrtc12 CUDA_ROOTS="
/nvidia/cuda_nvrtc /nvidia/cuda_runtime"`.
-A separate build directory keeps the two CUDA builds independent.
+`start.sh` создаёт окружение Python, ставит пакеты и собирает C++. Корневой профиль
+`run_config.json` — симулятор. В окне нажмите **Preprocessing…** →
+**Create Neutral S21 Sets**, затем **Start** и выберите режим в списке **Mode**.
-Run the desktop GUI through the launcher for build and single-instance handling:
+Установка, запуск с железом и служба для работы без экрана — в главе
+[Установка и запуск](docs/installation.md).
-```bash
-./start.sh --profile run_config.json # explicit local mock profile
-# or choose a hardware profile from run_config_examples/
-```
+## Документация
-`start.sh` also supports `--headless` (offscreen, auto-start), `--auto-start`,
-`--producer-only`, `--skip-build`, `--build-only`, and `--clean-shm`; see
-`./start.sh --help`. Pass `--profile PATH` so launcher provisioning and the GUI
-use the same profile. The producer is selected from `radar.model`; there is no
-per-device flag. Provision dependencies
-before unattended use: headless startup does not install missing packages.
+Для работы с радаром:
-For development, `.venv/bin/python -m python_app.gui.main` starts the GUI directly,
-without the launcher's build step or single-instance lock.
+1. [Как работает радар](docs/principles.md) — физика измерения и обработки для тех,
+ кто не работал с векторными анализаторами и радарами.
+2. [Оборудование](docs/hardware.md) — приборы, подключение, прошивки.
+3. [Установка и запуск](docs/installation.md) — установка, `start.sh`, служба,
+ обновление с флешки.
+4. [Типовые сценарии](docs/workflow.md) — порядок действий от подготовки до
+ сохранения данных.
+5. [Окно программы](docs/desktop.md) — каждая кнопка, поле и сообщение окна.
+6. [Режимы обработки](docs/processing.md) — что показывает каждый режим и что делает
+ каждая его настройка.
+7. [Калибровка и предобработка](docs/preprocessing.md) — наборы, съём, калибровка по
+ объекту.
+8. [Веб-интерфейс, кнопка и локатор](docs/web.md) — управление по сети, GPIO-кнопка,
+ протокол локатора, работа без экрана.
+9. [Файл конфигурации](docs/configuration.md) — каждый ключ профиля.
+10. [Данные на диске](docs/data.md) — форматы наборов, снимков, журналов.
+11. [Неполадки](docs/troubleshooting.md) — признак, причина, что делать.
+12. [Словарь](docs/glossary.md).
-## Jetson (aarch64) bring-up
+Для разработки:
-`requirements.txt` installs PyQt6 from wheels only, so pip picks the newest release
-the system glibc can run: 6.7 on Jetson's glibc 2.35, current releases on Debian 13. CUDA comes from JetPack in `/usr/local/cuda`; pip CUDA packages
-are installed only on x86_64. Use the same build commands and launcher as above.
+- [Устройство программы](docs/architecture.md) — процессы, кольца, кадры, состав кода,
+ где что править.
+- [Разработка](docs/development.md) — сборка, тесты, эталоны, правила.
+- [Протокол LibreVNA и кадры колец](device_firmware/librevna_protocol.md),
+ [переключение стимула](device_firmware/per_point_switching.md),
+ [контроллер ключей](device_firmware/switch_controller/README.md),
+ [сборка прошивок](device_firmware/multi_libreVNA_firmware/reproduce/README.md),
+ [протокол пульта тележки](device_firmware/cart_firmware/docs/protocol.md),
+ [коллектор E-502](data_acq_and_processing/rph_radar_collector/README.md).
-The USB updater can create a fresh venv with an already installed interpreter,
-for example `bash radar_update/update.sh --python 3.13`. The system Python does
-not need to be replaced.
+## Состав репозитория
-## Running without hardware (emulation)
-
-The default `run_config.json` already sets `radar.driver_mode: "mock"`, so the
-producer generates synthetic S-parameters and no radar is needed. Mock acquisition
-supports the LibreVNA models; RadioPhotonic radar (RPH) needs native acquisition.
-Both LibreVNA modes share one simulator: a point reflector moves back and forth
-along the antenna array and between depths 3.25–4.25 m over an eight-second cycle.
-Each S21 uses the actual transmitter–target–receiver distance from the configured
-XYZ coordinates; coincident antennas produce identical signals. All combinations
-share the same target position and measured capture window, with collections paced
-at 50 ms intervals. The root mock profile explicitly selects a 0–8 m B-scan
-range. Both shipped mock profiles use a 0.445 m vertical antenna baseline
-(Rx1/Rx3 and, for the four-output matrix, Tx2/Tx3), avoiding coincident pairs.
-The general defaults are 0.824–1.0 m; stored profiles retain their range. The
-simulator models a stationary radar, so radar speed should be zero.
-The scene uses the antenna positions from its startup profile; live geometry edits
-change the reconstruction of captured sweeps, whose samples remain unchanged.
-The preprocessor still requires S21 calibration/reference sets. Start the GUI:
-
-```bash
-./start.sh --profile run_config.json
-```
-
-Open **Preprocessing…**, use **Create Neutral S21 Sets**, then **Start**. This makes
-identity calibration + zero reference, preserving the simulated S21 samples.
-
-SAR3D work grows inversely with the product of the three `SAR_VOXEL_M` steps. Increase them when
-a faster preview matters more than spatial resolution. Processing failures appear
-above the plots and a failed single capture
-finishes with its explanation while retaining the acquired data.
-
-## Web interface
-
-The embedded server listens at `http://:8080`; set
-`RADAR_SYSTEM_WEBUI_PORT` to use another port. It is available in desktop and
-headless mode. Browser actions are:
-
-- **Start**, **Single Capture**, **Stop**, **Capture Temporary Reference** and **Remove Last Measurement**;
-- load a profile from `run_configs/*.json` using **Load**;
-- **Save Last 1000** and **Start + Record to Disk** with a path, optional name and recording count;
-- edit processor settings and antenna tables with **Apply**, or clear history with **Clear Runtime History**;
-- select a **B-scan pair** or advance with **Next Combination**.
-
-Copy profiles from `run_config_examples/` into `run_configs/` to offer them in the
-browser. JSON export and calibration dialogs are desktop actions. PNG capture
-runs only while a browser is connected and the plot has changed, including zoom
-and resize, at most five times per second. Each client retains the latest frame,
-status and settings independently; reconnecting refreshes settings, and **Apply**
-sends only edited fields. Field labels, units and checkboxes match the desktop form.
-
-## Data and preprocessing
-
-### Snapshots and recording
-
-**Save Last 1000** and **Save S21 JSON** keep up to 1000 available collections from
-each stage independently, including raw or preprocessed sweeps without a result.
-The limit is fixed; legacy profile `save_count` values are ignored. Saving
-keeps an active disk recording running.
-Without raw/preprocessed taps, result snapshots remain available, but S21 JSON
-requires retained traces.
-**Start + Record to Disk** has a separate count
-for future results. It keeps collections received after arming until that count,
-Stop, or exhaustion of the bounded writer queue. If storage falls behind,
-recording stops accepting data, drains the queue and final chunk, and reports the limit.
-Stop and closing the window also flush accepted data without blocking the GUI.
-Pausing acquisition for a capture or history action ends an active recording:
-the log reports the accepted count, requested count and stop reason before draining
-the writer. Latest-wins rings cannot recover sweeps overwritten before the GUI
-receives them. NumPy datasets preserve S21, capture
-windows, every supported result payload, and processing time. Failed exports remove
-their staging files, leaving existing datasets intact. Companion profiles retain
-the radar, antenna layout and preprocessing used for acquisition, including when
-another profile is loaded after Stop. Dataset name prefixes also describe the acquisition;
-a manual save is cancelled if acquisition is restarted during its final history drain.
-
-Each NumPy dataset includes `processing_settings.json`, mapping result revisions to
-the live settings used for processing. Results retain their revision, capture window,
-and actual `speed_m_s` (including speed supplied by the locator). Settings
-changed while acquisition is stopped are recorded for the reprocessed results as well.
-**Remove Last Measurement** uses capture order and removes matching collections
-from every stage; the processor suppresses that sweep if it is still pending.
-**Clear Runtime History** clears all retained
-stages and rejects late results from before the cutoff. These actions leave saved
-files intact. A sweep is identified by `(collection_id, capture_start_ns)`; all
-stages preserve the producer's capture window. `monotonic_ns` is diagnostic only.
-
-**Save S21 JSON** can add exports to an existing NumPy dataset. Existing JSON files
-are never overwritten; missing raw/preprocessed counterparts produce empty arrays,
-and stages pair only by the exact acquisition key. Its `vna_config_profile.json` companion
-leaves the NumPy dataset's `config_profile.json` intact. A streaming write failure
-keeps all collections already published; only the incomplete collection is discarded.
-Legacy snapshots without capture windows retain repeated IDs through one-to-one
-time/index matching; processing completion timestamps are not compared with
-acquisition timestamps.
-
-### Calibration and reference sets
-
-Calibration and reference sets keep their trace arrays and metadata together in one
-atomically replaced NPZ archive, with a readable JSON sidecar. Both files and their
-directory entries are synced before successful completion. Existing file permissions
-are retained; new files and directories respect the process umask. Earlier NPZ/JSON pairs
-remain supported. Invalid trace shapes, duplicate combinations and non-finite
-calibration/reference values are rejected before a saved set is replaced.
-Median captures also require identical frequency axes and channel coverage.
-Open **Preprocessing…** to create or select the ordinary S21 calibration and
-empty-scene reference. The processing order is through calibration → calibrated
-reference subtraction → notch → object correction → the selected processor.
-
-### Object calibration
-
-**Create…** in the **Object calibration** row opens a separate dialog. Use
-**Capture Data** to take fresh preprocessed sweeps, then enter the measured
-reflector centre X/Y/Z in metres and use **Calculate and Save**. The optional
-**Calculation Settings** section expands inside the scrollable dialog body;
-the calculation, cancellation and close buttons remain visible below it.
-Coordinates follow the notebook: X across the array, Y above the ground, Z along
-the ground.
-Height above ground, radar pitch and sphere radius follow `MIMO_calibration.ipynb`;
-several reflector positions may contribute to one calibration. The only diagnostic
-plot shows the phase of each antenna pair's contribution at the target point,
-before and after correction, as in cell 5 of `MIMO_calibration.ipynb`: Tx rows,
-Rx columns, phase relative to the complex sum of each map, with cell values and
-standard deviation in degrees on a cyclic ±180° scale.
-With partial antenna geometry, only the specified Tx × Rx pairs contribute to
-the calculation and phase maps. Acquisition still retains every physical stage;
-pairs outside that geometry receive unit correction and appear in `unchanged_combos`.
-The selected reference is already subtracted, and the previous object correction
-is disabled while these sweeps are acquired. A capture owns an exclusive pipeline
-pause until it finishes or is cancelled, including during automatic-restart backoff.
-An explicit **Stop** cancels automatic resumption.
-
-Object corrections use the same NPZ collection format as other preprocessing sets:
-S21 holds each pair's multiplier on the captured frequency grid. Metadata retain
-the capture radar key, antenna geometry, through-set name and content hash, target
-positions, estimation settings and phase diagnostics. Saving uses the capture's
-configuration even if the current profile changes during calculation. The next
-**Start** checks the selected through set's S21 and frequency arrays, geometry and
-coverage of the requested combinations before applying the correction after notch
-filtering and before any processor. **Off** disables it. Changing antenna geometry
-disables an incompatible correction and reloads acquisition without restarting
-the application. Changing relative permittivity only updates processing settings.
-
-Legacy `cal_*.npy` / `cal_meta.json` folders are not application calibration sets.
-Capture a set through **Create…** to use object correction; the legacy files on
-disk are left intact. Ordinary through/reference NPZ sets need no conversion.
-
-Older NPZ sets and snapshots remain readable: extra S11 arrays are ignored;
-new trace files contain only frequency and S21.
-
-## Runtime interfaces
-
-Tap rings are optional: an empty `rings.raw_tap.name` or
-`rings.preprocessed_tap.name` disables that copy and its GUI history. Core processing
-and single capture still work without taps; object calibration needs the preprocessed
-tap. All producers share raw publication and hardware retry handling.
-
-Raw and preprocessed IPC use **RAW3** and **PRP3** respectively: each trace contains
-float32 frequencies followed by complex64 S21 (12 bytes per point). The complete
-frame requires collection and per-trace capture windows; its size is `44 + combos * (28 + 12 * points)` bytes.
-Older RAW2/PPR2 bundles are incompatible and are rebuilt from NPZ sets at Start;
-update Python and C++ together. Results use RSL3; older RSL2 frames are rejected.
-
-RF switches use H7992 with 1–4 positions and two GPIO control lines. Profiles use
-`driver_mode`, GPIO settings and the position count. An old `driver: "h7992"` is accepted;
-other explicit driver values or enabled inversion are rejected before GPIO opens.
-Two positions use the existing H7992 mapping `(A,B) = (0,0), (0,1)`; physical
-wiring and channel numbers follow the profile.
-
-Single-device LibreVNA acquires the configured `run.combos` in order. Multi-device
-acquisition keeps the full matrix: SAR3D derives intra-sweep stage timing from
-the complete list of outputs sharing a capture window.
-
-## Configuration
-
-A profile selects the radar model and sweep, switches and acquisition combos,
-preprocessing sets, antenna geometry, ring sizes and GUI settings. The GUI loads
-`RADAR_SYSTEM_PROFILE` (set by `start.sh --profile`), otherwise the last profile in
-`python_app/runtime/gui_session_state.json`, otherwise root `run_config.json`.
-If a selected external profile cannot be loaded, it reports the error and falls
-back to the root profile. **Load Config** and **Save Config** manage desktop profiles.
-
-Hardware examples are in `run_config_examples/`. The root `run_config.json` is
-intentionally mock; select a hardware profile explicitly on a real device. A
-change of model, serials, sweep parameters or multi-device switch positions changes
-the `radar_key` used to find calibration/reference sets; select or capture matching
-sets before **Start**. Changing the multi-device reference-clock source also resets
-display history. For RadioPhotonic radar (RPH), identity follows the phase anchors, output
-band and acquisition/laser settings; executable/TTY paths and LibreVNA-only sweep
-fields do not affect it. RPH processing frequency controls use its output band.
-Phase, band and timeout errors are rejected before a running pipeline is stopped.
-
-Profiles with model `kamil_adc` are rejected with an explicit error: use
-`rph_radar` and the `radar.rph_radar` section. An inactive `radar.kamil_adc`
-section left in older saved profiles is ignored; there are no aliases or automatic
-data migrations. Old RPH calibration/reference sets use a different
-measurement key and are not selected by the renamed mode.
-
-### RPH phase calibration
-
-Use `run_config_examples/run_config_rph_radar.example.json` (E-502) or
-`run_config_rph_radar_rfg.example.json` (RFG board) for **RadioPhotonic radar (RPH)**.
-Apply the desired laser settings with **Apply Radar**, then stop acquisition before
-running the calibration tool:
-
-```bash
-.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config run_config.json --sweeps 200
-.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config run_config.json --sweeps 200 --apply
-```
-
-Pass the path to your active RPH profile. The first command reports measured phase
-anchors; `--apply` captures again and writes only `phase0_rad` and `phase1_rad` in
-`radar.rph_radar.phase_calibration`. Frequency anchors must already describe the
-known sweep endpoints. The tool uses current acquisition settings, sends no laser
-commands, preserves every other JSON byte, and refuses to overwrite a profile
-edited during capture. Band and point count stay unchanged. `--warmup` controls
-how many initial sweeps to discard (default 10).
-
-## Deployment
-
-On a Raspberry Pi, `deploy/install-daemon.sh` installs a systemd service that runs
-the application through `start.sh --headless --skip-build` and restarts it on
-failure. The launcher holds a single-instance lock for the radar, rings and locator
-port; an interactive launch first asks systemd to stop an active daemon.
-
-**Updating a device from a USB flash drive.** On the development machine
-`deploy/pack-usb.sh /path/to/drive` writes the host part of the **last commit**
-and its update script into `radar_update/` on the flash drive. Uncommitted changes,
-venv, build outputs, recorded data and local configs are excluded.
-On the Pi or Jetson, close the program and run, from the drive's folder:
-
-```bash
-bash radar_update/update.sh # or: --python 3.13 for a fresh venv
-```
-
-Run the updater as the normal device user. It installs the newest archive into
-`~/Documents/radar_system` (or `RADAR_DIR`), renaming the current version to the
-next free `radar_system`. It preserves:
-
-- `run_config.json` and changed/added `run_config_examples/*.json` by independent
- copy; a conflicting release profile is placed beside the device profile as `.new`;
-- `python_app/runtime/` by copy, excluding logs and `*.pids`;
-- `python_app/data*` and extra non-hidden top-level entries absent from the release
- by hard links, excluding `build`, `graphify-out`, `device_firmware`, `docs` and `RELEASE`;
-- the venv by independent copy, or a fresh one when `--python` is supplied.
-
-Saved data is written through replacement or new files, preserving the backup's
-hard links. `RELEASE` gets a new inode. The updater builds the C++ pipeline and ADC
-collector, then restarts `radar.service` only if it was running before the update.
-Keep the device's real hardware profile: the release's root profile is mock.
-
-For rollback, stop `radar.service` and close the GUI, rename the new installation
-aside to preserve measurements taken since the update, then rename the chosen
-backup to `radar_system`. Restart the service if it is normally used.
-
-## Tests
-
-```bash
-QT_QPA_PLATFORM=offscreen .venv/bin/python -m unittest discover -s python_app/tests
-make check-native
-make check-sar3d PYTHON=.venv/bin/python
-make check-gpr
-ASAN_OPTIONS=detect_leaks=0 make check-native NATIVE_SANITIZERS=address,undefined
-```
-
-The tests cover failed writes and settings commits, recordings during manual Save,
-repeated Start/Stop/Close, web-action cancellation, malformed IPC frames, hardware
-resource cleanup with mock failures, and an isolated deployment update. Native tests
-also exercise mutated frames, ring concurrency, locator input and ADC whole-sweep PTY overflow recovery.
-Firmware host tests check cart timer rollover and SBUS packing; they do not validate
-physical timing. Real devices, GPIO and the CUDA backend need their own hardware run.
-
-`check-sar3d` runs the real CUDA processor (`data_acq_and_processing/tests/test_backprojection.py`).
-It compares it with `sar3d_reference.npz`: compact float32/complex64 inputs, the three
-maximum projections of the notebook's displayed volume, its peak/level, response
-widths and background ratio for ten cases. Six cover compensation/normalization,
-equalization, background, scene and timing. Four are frames of point reflectors in noise
-for CLEAN, which also compare the objects and their boxes: with and without the background
-gate, with a scene where the stop at each range's background adds steps, and with a
-narrowed scene. The box rules are also checked
-one by one on synthetic objects. The expected values come from unchanged functions in cells 1 and 5
-of the original notebook, with each frame's previous-collection mean subtracted
-from its input spectra by NumPy. The same test checks the published
-status for invalid input, a volume beyond float32, a result too large for the ring slot,
-an empty and a very weak signal, and that the views are normalized at their peak on
-finer grids. Without a usable GPU it checks only the CUDA warning and the box rules and
-skips the rest; the notebook itself is not needed to run it. To regenerate the references,
-pass the original notebook and the Horns script (both are kept outside the repository):
-
-```bash
-.venv/bin/python data_acq_and_processing/tests/generate_backprojection_reference.py \
- --notebook /path/to/Multi_depthnorm_clean.ipynb \
- --gpr-script /path/to/Horns_motion_4x4_ground.py
-```
-
-The generator also writes `gpr_reference.npz` from the unchanged Horns coherent BP
-functions, with the notebook equalizer inserted before scalar normalization.
-`make check-gpr` compares the CPU processor with those images, including motion and
-previous-collection subtraction. Neither source file is needed to run the tests.
-
-The archives record source SHA256 hashes and NumPy/SciPy versions. Another build
-can be checked with `make check-sar3d BUILD_DIR=/path/to/build` or by running
-`test_backprojection.py Sar3dTest` with
-`BACKPROJECTION_FIXTURE=/path/to/build/native_tests/backprojection_fixture`.
-For older GPUs, pass the same `BUILD_DIR` and NVRTC 12 `CUDA_ROOTS` used to build.
-
-For development, install `pyright` into the venv, then run
-`.venv/bin/pyright python_app` to check all Python modules and tests.
-
-## Application architecture
-
-Invalid device/switch settings and undersized raw/preprocessed slots fail before
-acquisition starts. Oversized processed results report the required slot size in
-the log; switching to a mode that fits resumes processing without a restart.
-
-`AppWindow` assembles controllers and shuts them down. `WindowView` owns layout
-and typed sections; controllers receive concrete dependencies, without GUI mixins
-or duplicate window state. `ProfileController` owns startup selection, session
-state and profile persistence. `RadarSettingsController` applies device limits,
-while `LiveSettingsController` publishes revisions and updates plots.
-
-GUI scalar settings use dataclass metadata in `models/gui_profile_schema.py`:
-profile validation, desktop values, live processing fields, processing log summaries
-and web forms share those bindings. Hardware fields and geometry have their own
-typed mappings and validators. Section builders return typed widget groups; their
-controllers connect signals directly, with no string table of action callbacks.
-`gui/controllers/config_controller.py` reads and applies scalar metadata without owning
-the window or acquisition processes. Web batches roll back together when validation
-or live-file publication fails; desktop settings also revert to the last publication.
-Profile loading blocks editor signals until all settings are populated.
-
-`HistoryController` owns retained acquisitions; `PlotController` selects the view
-and supplies independent B-scan, GPR, trace and SAR renderers with typed settings.
-Renderers own their plots and display caches, without access to the acquisition
-processes. Every plot change passes through `PlotController`, which counts changes
-of the shown image for the browser stream. The display shows the newest acquisition,
-including results read while stopping. Stopped B-scan replay uses the last run's
-ring, matches exact acquisition keys and the requested settings revision, and
-cancels when that run changes; sweeps the processor does not return are left out
-rather than mixing settings in one image. With no retained preprocessed sweeps
-(including disabled taps), the processor recomputes its own retained history.
-Capture previews preserve the selected processing view.
-`PipelineController` owns processes, ring readers, polling/recovery, Single Capture
-and replay. Its pause prevents recovery from starting a producer over a hardware
-capture; Stop or Close cancels any pending resumption, including during a drain.
-Validation rejects Start before stopping existing processes. Pauses resume only
-continuous acquisition, retry failed resumption with the usual backoff, and release
-ownership before presenting capture errors. GPIO errors are logged without dialogs.
-Rendering failures leave acquisition and the locator running and log each cause once;
-repeated ring-read failures retain their separate recovery and stop policy.
-`PreprocessController` owns calibration/reference sessions and asset selection.
-One `SequentialCaptureSession` handles both current settings and a list of sweep
-variants; a single variant adds no extra acquisition or reconfiguration. Multi-device
-collection always captures one complete synchronized cycle. The native
-`DataPreprocessor` owns all three bundles in `preprocessing/bundles`: through,
-through-calibrated reference and object correction, applied in that order with notch
-filtering before object correction.
-Object capture checks both acquisition processes and releases its pause only after
-completion or cancellation; losing the producer reports a failure immediately.
-`SnapshotController` coordinates manual exports, paused Clear/Remove operations
-and Start + Record to Disk.
-History deletion is retained before publishing it to the processor, so a failed
-settings write can be retried without restoring deleted sweeps. `RecordingController`
-owns a bounded writer session; its disk thread receives data and storage only.
-A processor restart during Start + Record to Disk does not finish the newly armed session.
-Temporary references and neutral sets clear retained processing history without
-restarting the locator while the pipeline is paused.
-
-`WebBridge` queues browser actions onto Qt and exposes immutable status/frame
-snapshots. Clear and Remove use separate action endpoints; live settings only edit
-settings. Trace display options (including its Y range) do not publish processing
-revisions. A timeout cancels queued actions; an action already running reports that
-state without inviting a duplicate request. The GPIO controller invokes the same
-reference-capture action as desktop and web, and ignores queued presses after Close.
-`LogController` batches worker logs and returns web-action errors to the browser
-without opening a desktop modal. Stop/Close also cancels delayed launcher startup.
-
-GUI profiles, run configs, session state and live processing settings use shared
-atomic writers with unique temporary files. Profiles and run/session settings sync
-their file and directory; frequent live settings avoid extra disk syncs. A failed
-write before replacement leaves the previous file intact. The launcher and supervisor
-share acquisition producer selection.
+| Каталог | Что |
+| --- | --- |
+| `python_app/` | окно программы, драйверы приборов, веб-интерфейс, хранение данных, тесты |
+| `data_acq_and_processing/` | C++: препроцессор, процессор с режимами обработки и локатором, коллектор E-502, тесты |
+| `run_config_examples/` | профили под каждое железо |
+| `run_configs/` | профили для загрузки из веб-интерфейса |
+| `deploy/` | служба systemd и обновление с флешки |
+| `device_firmware/` | прошивки LibreVNA, контроллера ключей и пульта тележки |
+| `docs/` | документация |
+| `start.sh` | запуск: окружение Python, сборка, программа |
+| `run_config.json` | профиль по умолчанию — симулятор |
+| `Makefile` | сборка и тесты C++ |
+| `requirements.txt` | Python-пакеты |
diff --git a/data_acq_and_processing/rph_radar_collector/README.md b/data_acq_and_processing/rph_radar_collector/README.md
index ceffbbb..b29e674 100644
--- a/data_acq_and_processing/rph_radar_collector/README.md
+++ b/data_acq_and_processing/rph_radar_collector/README.md
@@ -1,101 +1,110 @@
-# RadioPhotonic radar (RPH) collector
+# Коллектор радиофотонного радара (RPH)
-Application-owned acquisition binary for the **L-Card E-502** ADC. It captures the
-dual-channel main + reference IQ stream and publishes complete `RPH1` sweeps over
-a PTY. `RphRadarService` in `python_app/hardware_full/rph_radar` reads and
-processes that stream for `python_app.scripts.rph_radar_raw_producer`.
+Программа сбора для АЦП L-Card E-502, которой владеет приложение. Она принимает
+двухканальный поток IQ (основной и опорный сигналы) и передаёт целые развёртки в
+пакетах `RPH1` через псевдотерминал. Поток читает и обрабатывает `RphRadarService` из
+`python_app/hardware_full/rph_radar` для процесса сбора
+`python_app.scripts.rph_radar_raw_producer`.
-This is the data *source* for `radar.model == "rph_radar"` with
-`radar.rph_radar.source: "e502"`, paired with the Python `rph_radar_raw_producer`.
-The `rfg_udp` source reads the RFG board's UDP stream directly and does not use this
-binary. LibreVNA acquisition uses Python drivers.
+Это источник данных для `radar.model: "rph_radar"` с `radar.rph_radar.source: "e502"`.
+Источник `rfg_udp` читает UDP-поток платы RFG напрямую и эту программу не использует;
+LibreVNA работает через драйверы на Python. Как радар устроен целиком — в главе
+[Оборудование](../../docs/hardware.md).
-## Layout
+## Состав
-| Path | Contents |
-|------|----------|
-| `src/` | Collector implementation (`main.cpp`) and the PTY writer. |
-| `include/` | PTY header and `switch_sequencer.h`. |
-| `vendor/lcard/` | L-Card X502/E502 SDK headers (`x502api.h`, `e502api.h`, `lcard_pstdint.h`), vendored verbatim so the collector builds with no external include path. |
+| Путь | Что |
+| --- | --- |
+| `src/` | коллектор (`main.cpp`) и запись в псевдотерминал |
+| `include/` | заголовок псевдотерминала и `switch_sequencer.h` |
+| `vendor/lcard/` | заголовки SDK L-Card X502/E502 (`x502api.h`, `e502api.h`, `lcard_pstdint.h`) без изменений, чтобы коллектор собирался без внешних путей |
-In switch-aware mode, `SwitchSequencer` drives the H7992 switches at actual
-DI_SYN2 sweep ends. Duration limits and Stop never advance the switches. Every
-physical combination change discards the remaining decoded ADC/DIN block and
-recreates the source session after `max(1 ms, run.settling_ms)` has elapsed.
-Timestamps never decide whether a sweep belongs to the new combination. The
-collector completes a cycle only after a full, clean sweep for each combination.
-An unchanged combination keeps the existing session.
+Коллектор использует общий драйвер ВЧ-ключей H7992 со своим набором заголовков и
+каталогом объектов `build/rph/`.
-A restart stops cyclic DOUT with `WAIT_DONE`, stops and disables the streams,
-restores DO2 high / DO1 and DO8 low, then closes and frees the SDK descriptor.
-After settling, a new descriptor is created, opened and configured. The collector
-reloads the DO1/DO8 pattern, waits for `OutCycleSetup(WAIT_DONE)`, and starts
-ADC/DIN/DOUT together. Pending samples and phase accumulators are cleared;
-source sweep and cycle IDs remain continuous. The initial high DI_SYN2 portion
-is dirty: acquisition waits for a complete rising-to-falling sweep. This can
-skip a variable number of physical sweeps while reopening; no fixed skip count
-or firmware-pause estimate is used. Hardware validation must measure reopen
-latency, USB resource release and multi-combination throughput.
+## Переключение пар
-The application uses the internal ADC clock. ADC and DIN start together,
-continuously; DI_SYN2 gates sweeps in software. Source times are the monotonic
-clock immediately before `StreamsStart` plus the ADC sample index divided by the
-configured sample rate. These are source-clock estimates, not hardware timestamps;
-startup latency shifts the initial epoch earlier. USB and PTY queueing do not
-shift timestamps to the time Python reads a sweep. An estimated source timestamp
-ahead of receipt stops acquisition instead of publishing a future capture window.
-ADC/host drift can trigger this guard during long single-combination runs; its
-bound and the accuracy of capture windows need measurement on hardware. Drift
-cannot make data from an old SDK session clean after a physical switch.
+Если ключами управляет коллектор, `SwitchSequencer` переключает ключи H7992 по
+настоящим концам развёрток (спаду DI_SYN2). Ограничение длительности и остановка
+ключи не переключают. При каждой смене пары коллектор отбрасывает оставшийся
+декодированный блок АЦП и цифрового входа и после `max(1 ms, run.settling_ms)`
+создаёт сеанс источника заново. Принадлежность развёртки новой паре по времени не
+определяется. Цикл считается законченным, только когда для каждой пары есть целая
+чистая развёртка. Если пара не меняется, сеанс сохраняется.
-The collector uses the common H7992 driver, with its own include set and
-`build/rph/` object tree.
+Перезапуск сеанса: остановка циклического вывода DOUT с `WAIT_DONE`, остановка и
+отключение потоков, возврат DO2 в высокий уровень, DO1 и DO8 — в низкий, закрытие и
+освобождение
+дескриптора SDK. После паузы установления создаётся, открывается и настраивается новый
+дескриптор, загружается шаблон DO1/DO8, коллектор ждёт `OutCycleSetup(WAIT_DONE)` и
+запускает АЦП, цифровой вход и вывод вместе. Накопленные отсчёты и фазовые
+накопители сбрасываются; номера развёрток и циклов идут без разрыва. Начальный
+высокий участок DI_SYN2 считается испорченным: сбор ждёт целую развёртку от фронта до
+спада. Поэтому при переоткрытии пропускается переменное число развёрток; постоянного
+числа пропусков или оценки паузы прошивки нет. Время переоткрытия, освобождение
+ресурсов USB и скорость при многих парах нужно измерить на железе.
-## PTY format
+## Время съёма
-Each packet is a 48-byte little-endian header (`<4sIQQQQHHI`) followed by the
-payload: `RPH1`, payload bytes (u32), source sweep ID, cycle ID, start/end ns
-(four u64), input/output positions (two u16), flags (u32; bit 0 means dirty).
-Without collector switching both positions are `0xffff`, and cycle equals sweep
-ID. Payload points remain four u16 words: marker (`0x000a` main, `0x00a8`
-reference), step index, signed I, signed Q. There are no boundary or combo frames.
-The queue drops complete sweeps on overflow; a packet already being written is
-never truncated. Maximum payload is 1,048,544 bytes. Python matches complete
-cycles. Python-controlled switching closes the previous collector and PTY reader,
-then starts a new collector after settling; phase-processing state is preserved.
+Коллектор работает от внутренних часов АЦП. АЦП и цифровой вход запускаются вместе и
+работают непрерывно; развёртки выделяет программа по линии DI_SYN2. Время отсчёта —
+монотонные часы компьютера непосредственно перед `StreamsStart` плюс номер отсчёта,
+делённый на частоту выборки. Это оценка по часам источника, а не аппаратная метка:
+задержка запуска сдвигает начало отсчёта раньше. Очереди USB и псевдотерминала время
+не сдвигают. Если оценка времени отсчёта оказалась позже момента его приёма, сбор
+останавливается, чтобы не публиковать окно съёма из будущего. Расхождение часов АЦП и
+компьютера может сработать на этой проверке при долгой работе на одной паре; её
+предел и точность окон съёма нужно измерить на железе. Данные старого сеанса SDK
+после переключения ключа чистыми не становятся ни при каком расхождении часов.
-## Build
+## Формат псевдотерминала
+
+Пакет — 48-байтный заголовок в порядке байтов little-endian (`<4sIQQQQHHI`) и данные:
+`RPH1`, длина данных (u32), номер развёртки источника, номер цикла, начало и конец
+съёма в нс (четыре u64), положения входа и выхода (два u16), флаги (u32; бит 0 —
+развёртка испорчена). Без переключения коллектором оба положения равны `0xffff`, а
+номер цикла совпадает с номером развёртки. Точка данных — четыре слова u16: метка
+(`0x000a` основной сигнал, `0x00a8` опорный), номер ступени, I и Q со знаком. Кадров
+границ и пар нет. При переполнении очередь выбрасывает целые развёртки; пакет, который
+уже пишется, не обрезается. Наибольший размер данных — 1 048 544 байта. Целые циклы
+собирает Python. При переключении ключей из Python прежний коллектор и чтение
+псевдотерминала закрываются, а после паузы установления запускается новый коллектор;
+состояние фазовой обработки сохраняется.
+
+## Сборка
```bash
make rph_radar_collector # -> build/bin/rph_radar_collector
```
-It is **not** part of `make all`; `start.sh` builds it automatically when the
-active config selects `radar.model == "rph_radar"`, unless `--skip-build` is set.
-The Linux build uses the repository's common sources and vendored headers and
-links with `-pthread -ldl -lutil`; no installed L-Card SDK is needed to build it.
+В `make all` коллектор не входит; `start.sh` собирает его сам, если в профиле
+`radar.model: "rph_radar"`, кроме запуска с `--skip-build`. Сборка в Linux берёт общие
+исходники и заголовки из репозитория и линкуется с `-pthread -ldl -lutil`;
+установленный SDK L-Card для сборки не нужен.
-## Runtime
+## Работа
-The collector loads the proprietary `libx502api.so` / `libe502api.so` at run time
-via `dlopen`, so those libraries must be discoverable by the dynamic loader.
-The RPH radar service prepends `~/.local/lib` (their default install location)
-unless the config pins `LD_LIBRARY_PATH` in `radar.rph_radar.env`.
+Закрытые библиотеки `libx502api.so` и `libe502api.so` коллектор загружает при запуске
+через `dlopen`, поэтому их должен находить загрузчик. Сервис RPH ставит
+`~/.local/lib` (их обычное место) в начало пути поиска, если профиль не задаёт
+`LD_LIBRARY_PATH` в `radar.rph_radar.env`.
-There is one acquisition path: USB, differential ADC channels 2/3, maximum
-internal-clock rate, rising/falling DI_SYN2 gating, DO1 pair subtraction and DO8
-reference overlay. `radar.rph_radar.args` sets ADC range, internal reference,
-maximum sweep duration and DO8 period. The example's fixed-path options remain
-accepted as guards; incompatible modes and unused diagnostic options are rejected.
-The service appends `tty:` from `radar.rph_radar.tty_path`.
+Путь съёма один: USB, дифференциальные каналы АЦП 2 и 3, наибольшая частота от
+внутренних часов, выделение развёрток по фронту и спаду DI_SYN2, вычитание соседних
+отсчётов с разными уровнями DO1 с усреднением и наложение опорного сигнала по DO8.
+`radar.rph_radar.args` задаёт диапазон АЦП, внутреннюю опорную
+частоту, наибольшую длительность развёртки и период DO8. Остальные параметры из
+примера принимаются только как подтверждение этого пути; несовместимые режимы и
+неиспользуемые отладочные параметры отвергаются. Параметр `tty:<путь>` из
+`radar.rph_radar.tty_path` добавляет сервис.
-When both RF switches use `driver_mode: "native"`, Python passes
-`switching:` with the already decoded input/output switch settings, combos
-and settling time. The collector reads no profile file. Otherwise, Python selects
-the switches and starts a new collector after settling when the combination
-changes. Calibration captures also use this Python-controlled path.
+Если оба ВЧ-ключа в `driver_mode: "native"`, Python передаёт коллектору
+`switching:` с уже прочитанными настройками ключей, парами и паузой
+установления; профиль коллектор не читает. Иначе ключи переключает Python и при смене
+пары после паузы запускает новый коллектор. Съём наборов калибровки тоже идёт этим
+путём.
-The launching service owns the collector process and forwards its output to the
-application log. On Linux, parent death sends SIGKILL to the collector; normal
-Stop drains and closes the service's own child. No global process-name kill is
-used.
+Процессом коллектора владеет запустивший его сервис; вывод коллектора уходит в журнал
+программы. В Linux смерть родителя завершает коллектор сигналом SIGKILL; обычный
+**Stop** дочитывает и закрывает собственный дочерний процесс сервиса. Завершения
+процессов по имени нет.
diff --git a/docs/architecture.md b/docs/architecture.md
new file mode 100644
index 0000000..01ab8f7
--- /dev/null
+++ b/docs/architecture.md
@@ -0,0 +1,203 @@
+# Устройство программы
+
+## Процессы и кольца
+
+Программа — четыре процесса (с АЦП E-502 — пять: добавляется коллектор), связанные
+кольцевыми буферами в разделяемой памяти POSIX:
+
+```text
+ raw preprocessed results
+железо ─► процесс сбора ─────► data_preprocessor ─────────► data_processor ─────► окно
+ │ │ │
+ │ raw_tap │ preprocessed_tap └─► TCP-локатор
+ └─────► окно └─────► окно
+```
+
+| Процесс | Что делает | Код |
+| --- | --- | --- |
+| окно программы (Python, PyQt6) | запускает и перезапускает остальные процессы, читает результаты и копии, рисует, обслуживает веб-интерфейс и GPIO-кнопку | `python_app/gui/` |
+| процесс сбора (Python) | управляет железом, публикует коллекции в `raw` | `python_app/scripts/matrix_raw_producer.py`, `rph_radar_raw_producer.py` |
+| препроцессор (C++) | калибровка, вычитание референса, режекторный фильтр, калибровка по объекту | `data_acq_and_processing/preprocessing/` |
+| процессор (C++) | режимы обработки, локатор | `data_acq_and_processing/processing/` |
+
+Процесс сбора выбирается по `radar.model` в
+`python_app/orchestration/producer_selection.py`; той же функцией пользуются окно и
+`start.sh --producer-only`. У радиофотонного радара с АЦП E-502 процесс сбора
+запускает ещё и C++-коллектор `rph_radar_collector`, который передаёт развёртки через
+псевдотерминал.
+
+Кольца работают по правилу «побеждает новое»: писатель никогда не ждёт читателя, при
+переполнении затирается самый старый непрочитанный кадр, а читатель, которого
+обогнали во время чтения, продолжает с самого свежего. Окно читает только копии
+`raw_tap` и `preprocessed_tap`: если бы оно читало основные кольца, оно отбирало бы
+кадры у препроцессора и процессора. Пустое имя копии отключает её.
+
+Раскладка кольца — `RDRRING2`, версия 1: заголовок 64 байта, у каждого слота заголовок
+16 байт. Код: Python — `python_app/orchestration/shm/ring_layout.py`, C++ —
+`common_cpp/ipc/` (`ShmRing`). Владелец заменяет несовместимый сегмент через unlink, не
+обрезая отображения читателей.
+
+## Форматы кадров
+
+| Кадр | Где | Содержимое |
+| --- | --- | --- |
+| RAW3 | `raw`, `raw_tap`, файлы наборов `python_app/runtime/*_bundle.bin` | коллекция: окно съёма, затем для каждой трассы пара, окно съёма, частоты float32 и S21 complex64. Размер — 44 + пары × (28 + 12 × точки) байт |
+| PRP3 | `preprocessed`, `preprocessed_tap` | то же после предобработки |
+| RSL3 | `results` | заголовок 64 байта (номер коллекции, метка стадии, время обработки, `settings_revision`, окно съёма, использованная скорость, число результатов) и результаты: трасса, число, изображение или таблица, для всей коллекции и для пар |
+
+Окно съёма обязательно; старые форматы RAW2, PRP2 и RSL2 отвергаются. Python и C++
+обновляются только вместе. Подробное описание полей — §6
+[librevna_protocol.md](../device_firmware/librevna_protocol.md).
+
+## Идентичность и порядок коллекций
+
+- Коллекция определяется парой `(collection_id, capture_start_ns)`. Номер после
+ **Stop** и **Start** снова начинается с 1, поэтому одного номера мало.
+- Окно съёма ставит процесс сбора по часам CLOCK_MONOTONIC; дальше оно не меняется.
+ У каждой трассы своё окно: при переключении пар окно коллекции про отдельную пару
+ ничего не говорит.
+- История упорядочена по `capture_start_ns`. `monotonic_ns` — диагностическая метка
+ стадии.
+- Сопоставление стадий, **Single Capture**, **Remove Last Measurement**, кэш 3D SAR и
+ фильтр приближения GPR используют точный ключ (`python_app/models/collection_identity.py`),
+ без эвристик по близости времён.
+
+Результаты штатно приходят не по порядку: процессор пересчитывает сохранённые
+коллекции при смене настроек, а остановленное окно в режиме B-scan отправляет свои
+коллекции обратно в `preprocessed`. Поэтому:
+
+- коллекция не новее последней принятой обрабатывается и публикуется, но не
+ попадает в историю процессора и не уходит локатору;
+- при новой ревизии процессор пересчитывает новейшую коллекцию, в B-scan — всю
+ историю; локатору уходит только новейшая;
+- на время такого пересчёта окно пишет `reprocess_current_result: false`, чтобы
+ процессор не пересчитывал параллельно;
+- история окна вставляет результат по времени съёма; на диск пишутся только новые
+ коллекции;
+- удалённые коллекции (`history_clear_before_ns`, `history_removed`) отбрасываются и
+ процессором, и окном, даже если их результаты придут позже.
+
+## Окно программы
+
+`gui/app_window.py` только создаёт контроллеры, связывает их и закрывает окно.
+Раскладку строит `gui/window_view.py`; секции `gui/controllers/sections/` — наборы
+виджетов, сигналы подключают контроллеры. Миксинов и фасадов нет.
+
+| Контроллер (`gui/controllers/`) | Отвечает за |
+| --- | --- |
+| `profile_controller` | профиль: выбор при запуске, загрузка, сохранение, профиль рядом с данными |
+| `config_controller` | перевод полей окна в модели и обратно по метаданным; состояния не хранит |
+| `radar_settings_controller` | развёртка, АЦП и лазер, пары сбора, пределы прибора |
+| `live_settings_controller` | режим обработки, ревизия, live-файл, пакет настроек из веба |
+| `pipeline_controller` | процессы, кольца, опрос и восстановление, **Single Capture**, приостановка, пересчёт B-scan |
+| `history_controller` | истории стадий, удаления |
+| `plot_controller` и `renderers/` | графики, выбор пары B-scan, проверка ревизии, счётчик изменений картинки для веба |
+| `snapshot_controller`, `recording_controller` | сохранение данных, очистка и удаление, запись на диск |
+| `preprocess_controller` | наборы, окно предобработки, съём, нейтральные наборы, временный референс |
+| `object_calibration_controller`, `object_calibration_capture` | калибровка по объекту |
+| `web_controller` | веб-сервер, очередь действий, снимки состояния и картинки |
+| `control_button_controller`, `log_controller` | GPIO-кнопка; журнал и окна ошибок |
+
+Поля настроек описаны один раз — метаданными dataclass-моделей в
+`python_app/models/gui_profile_schema.py` (их разворачивает `setting_fields.py`).
+Из этого описания берутся ключ профиля, виджет, ключ live-файла, поле веб-формы и
+проверка при чтении. Подписи веб-формы берутся из подписей полей окна. Qt в моделях и
+кодеках нет. Оформление окна — `gui/theme.py`, цвета серий и маркеров —
+`gui/controllers/renderers/common.py`, оформление веба — `webui/static/style.css`.
+
+### Правила жизненного цикла
+
+- Приостановка (`gui/runtime/pipeline_pause.py`). Конвейер берёт владение до
+ остановки и дочитывания колец, потому что дочитывание прокачивает события Qt.
+ Асинхронный съём держит приостановку до конца. Автоперезапуск при удержанной
+ приостановке запрещён; продолжается только то, что шло до неё.
+- **Stop** и закрытие окна. Пользовательский **Stop** сразу увеличивает счётчик остановок
+ и отменяет ожидающие продолжения; техническая остановка приостановку не отменяет.
+ Закрытие сразу запрещает новые запуски; если идёт дочитывание, его владелец
+ заканчивает и вызывает отложенное закрытие.
+- **Start** проверяет всё до того, как что-то остановить. Отказ не трогает работающий
+ процессор, локатор и историю.
+- Ошибка отрисовки не останавливает сбор: она пишется один раз и видна в строке
+ состояния. Сбор останавливают только ошибки чтения колец.
+- Показывается новейшая коллекция: опрос рисует последний результат истории, даже
+ если последним пришёл пересчёт более старой.
+- Графики меняются только через `PlotController`. Он считает изменения картинки, и
+ веб снимает кадр по этому счётчику. Сигнал `QGraphicsScene.changed` не подключать:
+ подписка переводит Qt в старый режим обновления, при котором на экране остаются
+ следы прежних кривых (это проверяет тест
+ `test_redrawn_views_leave_no_stale_pixels_on_screen`).
+- Без экрана и при нажатии GPIO-кнопки окна ошибок не показываются; приостановка
+ освобождается до показа ошибки.
+- Одно действие — один код для кнопки окна, веба и GPIO, с общей проверкой
+ занятости. Веб-поток трогает Qt только через сигналы с очередью и снимки состояния.
+- Синхронный съём (калибровка, референс, нейтральные наборы) идёт в потоке окна
+ при приостановленном сборе; лишняя развёртка внутри такого нажатия — это зависшее
+ окно. Съём для калибровки по объекту асинхронный, через обычный конвейер, а расчёт
+ — в рабочем потоке без Qt.
+
+## Состав кода
+
+| Каталог | Что |
+| --- | --- |
+| `python_app/gui/` | окно, диалоги, контроллеры, отрисовка |
+| `python_app/hardware_full/` | сервисы приборов: `librevna_service.py`, `multi_device_service.py`, `matrix_radar_service.py`, `switched_matrix_radar_service.py`, `point_target_mock_service.py` (симулятор), `switch_service.py` |
+| `python_app/hardware_full/librevna_driver/` | USB-драйвер одиночного LibreVNA, протокол версии 14 |
+| `python_app/hardware_full/librevna_multi_device_driver/` | синхронная многоприборная развёртка (`cycle_collection.py` — один цикл) |
+| `python_app/hardware_full/rph_radar/`, `laser_control/` | радиофотонный радар: коллектор и плата RFG, плата лазеров |
+| `python_app/hardware_full/switch_drivers/` | ВЧ-ключи H7992 через GPIO (`gpio_uapi.py` — ioctl ядра без внешних пакетов) |
+| `python_app/models/` | профиль: схема, чтение и запись, проверки; модель данных (`dataset_model.py`) |
+| `python_app/orchestration/` | запуск процессов (`process_supervisor.py`, `restart_policy.py`), рабочие файлы, кольца (`shm/`) |
+| `python_app/storage/npz/` | наборы и наборы данных (`store.py`, `serialize.py`, `snapshot_numpy.py`, `vna_history_json.py`) |
+| `python_app/webui/` | веб-сервер FastAPI в процессе окна, страница `static/` |
+| `python_app/workflows/` | сценарии съёма и калибровки по объекту |
+| `python_app/scripts/` | процессы сбора и утилиты |
+| `python_app/tests/` | тесты Python |
+| `data_acq_and_processing/common_cpp/` | общий C++: конфигурация (`config/`), кадры и кольца (`ipc/`), драйвер H7992 (`hardware/`) |
+| `data_acq_and_processing/preprocessing/` | препроцессор и загрузка наборов |
+| `data_acq_and_processing/processing/` | процессор, режимы (`processors/src/`), локатор (`locator/`) |
+| `data_acq_and_processing/rph_radar_collector/` | коллектор L-Card E-502 |
+| `data_acq_and_processing/tests/` | тесты C++ и эталоны 3D SAR и GPR |
+| `data_acq_and_processing/third_party/` | nlohmann/json и Eigen |
+| `deploy/` | служба и обновление с флешки |
+| `device_firmware/` | прошивки и описания протоколов |
+
+## Где что править
+
+| Задача | Куда смотреть |
+| --- | --- |
+| ключи профиля и их проверка | `python_app/models/run_config_schema.py`, `run_config_codec.py`, `run_config_validation.py`; то же на стороне C++ — `data_acq_and_processing/common_cpp/config/` |
+| поля окна, live-файла и веб-формы | `python_app/models/gui_profile_schema.py`, секции `gui/controllers/sections/`, C++ — `processing/data_processor/src/processing_live_config.cpp` |
+| формат кадров | синхронно: C++ `common_cpp/ipc/` (`shared_types.hpp/.cpp`), Python-писатель `storage/npz/serialize.py`, Python-читатель `orchestration/shm/decoder.py`, описание — §6 `librevna_protocol.md` |
+| модель данных | `python_app/models/dataset_model.py` |
+| драйверы приборов | `python_app/hardware_full/` |
+| запуск процессов | `orchestration/process_supervisor.py`, `restart_policy.py`; из окна — `pipeline_controller.py` |
+| хранение данных | `python_app/storage/npz/` |
+| режимы обработки | `data_acq_and_processing/processing/processors/src/`: `bscan`, `gpr`, `passthrough`, `sar3d`; общий код GPR и 3D SAR — `backprojection.hpp`; отрисовка — `gui/controllers/renderers/` |
+| 3D SAR | `sar3d_processor.cpp` (алгоритм), `sar3d_clean.{hpp,cpp}` (фон кадра, CLEAN, рамки), `sar3d_gpu.{hpp,cpp}` (CUDA), `renderers/sar3d.py` |
+| сценарии съёма | `gui/controllers/preprocess_controller.py`, `python_app/workflows/` |
+| веб-интерфейс | `python_app/webui/`, мост к окну — `gui/controllers/web_controller.py` |
+
+Изменив поведение, поправьте главу документации, где оно описано: ключи профиля —
+[configuration.md](configuration.md), поля окна — [desktop.md](desktop.md) и
+[processing.md](processing.md), форматы данных — [data.md](data.md), кадры колец и
+USB-протокол — `librevna_protocol.md`, переключение стимула —
+`per_point_switching.md`.
+
+## 3D SAR и эталон
+
+Алгоритм 3D SAR — порт исследовательского ноутбука `Multi_depthnorm_clean.ipynb`
+(ячейки 1 и 5; хранится вне репозитория). Комментарии в `sar3d_processor.cpp` и
+`sar3d_clean.cpp` называют функции и константы ноутбука, поля окна — ровно его имена.
+Величины ноутбука, не вынесенные в окно, — константы в начале этих файлов.
+
+Решения CLEAN и рамок зависят от точных равенств (углы сетки лежат на границе окна
+дальности, полуразмеры окон кратны шагам), поэтому граница на видеокарте считается в
+double без FMA (`__dmul_rn`, `__dadd_rn`), а хост-код 3D SAR собирается с
+`-ffp-contract=off` (`SAR3D_HOST_FLAGS` в `Makefile`). Иначе на машинах с FMA (aarch64,
+x86 с `-mfma`) результат расходился бы с ноутбуком.
+
+Сверка с ноутбуком автоматизирована (глава [Разработка](development.md)): генератор
+выполняет неизменённые функции ноутбука и пишет эталон `sar3d_reference.npz`, тест
+гоняет настоящий CUDA-бэкенд и сравнивает с ним. Без видеокарты проверяются только
+предупреждение и правила рамок, поэтому после правки 3D SAR сверку нужно прогнать на
+настоящей карте. GPR сверяется с `gpr_reference.npz` на CPU, без видеокарты.
diff --git a/docs/configuration.md b/docs/configuration.md
new file mode 100644
index 0000000..eca7a29
--- /dev/null
+++ b/docs/configuration.md
@@ -0,0 +1,428 @@
+# Файл конфигурации
+
+Все настройки программы хранятся в профиле — файле JSON. В профиле две части:
+конфигурация сбора и обработки (разделы `radar`, `switches`, `control_button`,
+`logging`, `run`, `preprocess`, `gpr`, `rings`) и состояние окна (раздел `gui`).
+Незнакомые термины (полоса ПЧ, дБм, пара, коллекция) — в [словаре](glossary.md).
+
+| Где | Что |
+| --- | --- |
+| `run_config.json` в корне | профиль для разработки: симулятор многоприборной схемы, `driver_mode: "mock"`, уровень журнала `debug` |
+| `run_config_examples/` | готовые профили под каждое железо (таблица ниже) |
+| `run_configs/` | профили, которые можно загрузить из веб-интерфейса |
+| `python_app/runtime/run_config.json` | рабочая копия, которую программа пишет при каждом **Start**; по ней работают процессы конвейера |
+
+> **Внимание.** Корневой `run_config.json` — профиль симулятора: с ним программа не
+> обращается к железу. На машине с радаром работайте с профилем из
+> `run_config_examples/`, скопированным и настроенным под установку.
+
+## Как профиль загружается и сохраняется
+
+При запуске программа берёт профиль по порядку:
+
+1. путь из переменной окружения `RADAR_SYSTEM_PROFILE` (её задаёт
+ `./start.sh --profile ПУТЬ`);
+2. последний использованный профиль из `python_app/runtime/gui_session_state.json`;
+3. корневой `run_config.json`.
+
+Относительный путь отсчитывается от каталога проекта. Если выбранный профиль не
+читается, программа пишет
+`Failed to load the last selected config profile; falling back to root run_config.json.`
+и загружает корневой; если не читается файл сеанса —
+`Failed to read GUI session-state; using root run_config.json.` Если не читается и
+корневой профиль, программа не запускается. Профиль без раздела `gui` загружается со
+значениями окна по умолчанию; при запуске (кроме корневого профиля) об этом пишется
+`Loaded legacy run config without GUI defaults: <путь>`, при **Load Config…** —
+`kind=legacy run config`
+в строке `Config profile loaded: …`.
+
+**Load Config…** загружает другой профиль; это возможно только при остановленном
+сборе (`Stop all pipeline processes before loading a config profile`) и не во время
+съёма наборов.
+**Save Config…** записывает текущие значения всех полей окна, выбранные наборы и
+геометрию антенн. Загруженный или сохранённый профиль становится стартовым для
+следующего запуска.
+
+> **Внимание.** Изменения в окне и в веб-интерфейсе, в том числе выбор наборов и
+> временный референс, в профиль сами не записываются. После перезапуска программы они
+> пропадут, если не нажать **Save Config…**.
+
+Правила чтения:
+
+- нет раздела или ключа — берётся значение по умолчанию. Умолчания схемы — минимальные
+ заглушки (`points: 1`, `if_bandwidth_hz: 1.0`, пустые имена колец), а не рабочие
+ настройки: начинайте с профиля-примера;
+- типы строгие: целое число нельзя записать как `5.0`, логическое — как `1`; ошибка
+ называет ключ (`points must be a JSON integer`);
+- неизвестные ключи пропускаются и при сохранении исчезают;
+- раздел `gui` с неизвестным номером версии отвергается (`Unsupported gui.version: N`).
+
+## Когда изменение вступает в силу
+
+| Обозначение | Когда |
+| --- | --- |
+| сразу | сразу, в том числе во время сбора |
+| Apply Radar | при нажатии **Apply Radar** (без экрана — автоматически при запуске) |
+| Start | при следующем **Start** или **Single Capture**: программа заново пишет рабочую копию профиля и перезапускает процессы сбора и предобработки |
+| Start, процессор | как Start, но перезапускается и процессор, а с ним TCP-локатор: его клиенты отключаются, история обработки очищается |
+| профиль | ключа нет в окне: правьте файл и загрузите его через **Load Config…** при остановленном сборе |
+
+Процессор перезапускается, если изменились: ключ радара (`radar_key`), опорная частота
+многоприборной схемы, `driver_mode` радара и ключей, число положений ключей, список
+пар, любой ключ раздела `rings`, любой ключ `run.locator_server`, путь к live-файлу
+или `run.idle_sleep_ms`. Смена наборов предобработки процессор не перезапускает
+(исключение — нейтральные наборы).
+
+## Ключ радара
+
+`radar_key` — строка, по которой программа находит наборы калибровки и референса:
+они лежат в каталогах `python_app/data/<вид набора>//`. Смена любой
+величины, из которой строится ключ, скрывает наборы, снятые при старых значениях.
+
+| Радар | Из чего строится | Пример (из профилей-примеров) |
+| --- | --- | --- |
+| `librevna` | модель, серийный номер (`no_serial`, если пуст), начало и конец развёртки, число точек, полоса ПЧ, мощность | `librevna_no_serial_st1000000_sp6000000000_p201_if50000_pw-10` |
+| `librevna_multi` | то же для мастера и обоих ведомых, плюс положения ключей `_out`, `_in`, `_stim` | `librevna_multi_209E307A5532_20A1307D5532_2072306C5532_st500000000_sp6000000000_p1031_if50000_pw-3_out1_in1_stim2` |
+| `rph_radar` | первые 16 знаков хеша SHA-256 от выходной полосы, фазовой калибровки, параметров коллектора (`args`) или источника `rfg_udp`, числа положений ключей и настроек лазера (если плата лазеров включена или источник — RFG) | `rph_radar_2afdaa32e154b122` |
+
+`dwell_us`, путь к программам и тайм-ауты в ключ не входят.
+
+## Профили-примеры
+
+| Файл | Радар | Что в нём |
+| --- | --- | --- |
+| `run_config.json` (корневой) | `librevna_multi`, `mock` | симулятор схемы с переключением стимула: 16 пар, 1 МГц–6 ГГц, 201 точка, вертикальная база антенн 0,445 м, журнал `debug` |
+| `run_config_librevna.example.json` | `librevna`, `native` | одиночный LibreVNA, выходной ключ на 2 положения (линии 17, 27), входной на 4 (линии 22, 23), 8 пар, 1 МГц–6 ГГц, 201 точка |
+| `run_config_librevna_multi.example.json` | `librevna_multi`, `native` | приборы `209E307A5532` (мастер), `20A1307D5532`, `2072306C5532`; 8 пар, 1 МГц–6 ГГц, 201 точка |
+| `run_config_librevna_multi.example2.json` | `librevna_multi`, `native` | то же для второго комплекта: `206830985532`, `206930965532`, `206930A15532` |
+| `run_config_librevna_multi_per_point.example.json` | `librevna_multi`, `native` | переключение стимула, комплект `209E307A5532`; 16 пар, 0,5–6 ГГц, 1031 точка, −3 дБм, `dwell_us: 2`, измеренная геометрия антенн; режим `sar3d` |
+| `run_config_librevna_multi_per_point_lowband.example.json` | `librevna_multi`, `native` | переключение стимула, комплект `206830985532`; 30–200 МГц, 201 точка — проверка ключей на низких частотах; у антенн y = z = 0, Tx2 и Tx3 совпадают с Tx0 и Tx1 |
+| `run_config_rph_radar.example.json` | `rph_radar`, `e502` | АЦП E-502, плата лазеров на `/dev/ttyUSB0` в режиме `variation`, ключи на 2 и 4 положения, 8 пар |
+| `run_config_rph_radar_rfg.example.json` | `rph_radar`, `rfg_udp` | плата RFG на `192.168.2.1:5000`, плата лазеров на `/dev/serial/by-id/usb-Silicon_Labs_CP2102_…`, одна пара, измеренные фазовые якоря |
+| `run_config_simulator.example.json` | `librevna`, `mock` | симулятор одиночного LibreVNA: 8 пар, 1 МГц–6 ГГц, 201 точка; режим `gpr` |
+
+`run_configs/run_config.json` — профиль симулятора одиночного LibreVNA для
+веб-интерфейса, близкий к `run_config_simulator.example.json`.
+
+## radar
+
+| Ключ | Тип, по умолчанию | Что делает | Когда |
+| --- | --- | --- | --- |
+| `radar.model` | строка, `"librevna"` | тип радара: `"librevna"`, `"librevna_multi"` или `"rph_radar"`. Старое имя `"kamil_adc"` отвергается с подсказкой перейти на `rph_radar` | профиль; Start, процессор |
+| `radar.serial` | строка, `""` | серийный номер прибора; у многоприборной схемы — мастера (обязателен). Пустой у одиночного — первый найденный прибор | профиль; Start, процессор |
+| `radar.driver_mode` | строка, `"mock"` | `"native"` — настоящее железо, `"mock"` — симулятор. Радиофотонному радару нужен `"native"` | профиль; Start, процессор |
+
+### radar.sweep
+
+Развёртка LibreVNA. Радиофотонный радар эти ключи не использует (у него
+`radar.rph_radar.band`), а `points` у него в профиль не записывается. Поля редактируются в секции
+**Radar**; с подключённым прибором они подрезаются под его пределы.
+
+| Ключ | Тип, по умолчанию | Поле | Что делает | Ключ радара |
+| --- | --- | --- | --- | --- |
+| `start_hz` | число, `1000000.0` | **Start (Hz)** | начало развёртки, Гц, не меньше 0 | да |
+| `stop_hz` | число, `6000000000.0` | **Stop (Hz)** | конец развёртки, Гц, больше начала | да |
+| `points` | целое, `1` | **Points** | число частотных точек | да |
+| `if_bandwidth_hz` | число, `1.0` | **IF bandwidth (Hz)** | полоса ПЧ, Гц, больше 0 | да |
+| `stimulus_power_dbm` | число, `-30.0` | **Stimulus power (dBm)** | мощность излучения, дБм | да |
+| `dwell_us` | целое, `0` | — | дополнительная задержка на каждой стадии прибора, мкс; при отправке значение молча ограничивается диапазоном 0–65535, а прибор — 10 239 мкс. В режиме переключения стимула в это время контроллер переключает ключ; в примерах `2` | нет |
+
+Изменения применяются при **Start**; у одиночного LibreVNA — ещё и при
+**Apply Radar**.
+
+### radar.multi_device
+
+Только для `librevna_multi`; другие радары эти ключи игнорируют. В окне не
+редактируются.
+
+| Ключ | Тип, по умолчанию | Что делает |
+| --- | --- | --- |
+| `slave_serials` | список строк, `[]` | серийные номера двух ведомых, ровно два. Первый даёт входы 0 и 1, второй — 2 и 3; порядок важен. Можно записать строкой через запятую; старое имя `slave_serial_numbers` тоже читается |
+| `force_external_reference` | логическое, `false` | внешняя опорная частота 10 МГц на всех трёх приборах. Включайте, только если опора подана на все приборы (глава [Оборудование](hardware.md)) |
+| `recovery_attempts` | целое, `3` | сколько раз переподключать приборы после сбоя, прежде чем передать ошибку процессу сбора |
+| `output_switch_positions` | целое, `1` | положения настоящего ВЧ-ключа на выходах мастера, которым управляет компьютер (линии из `switches.port1`). 1 — ключа нет. Ни один пример его не использует |
+| `input_switch_positions` | целое, `1` | то же для входов ведомых (линии из `switches.port2`) |
+| `stimulus_switch_positions` | целое, `1` | положения ключа переключения стимула внутри частотной точки; `2` — режим переключения стимула. Прошивка мастера поддерживает 1, 2 и 4, контроллер STM32 — 2 |
+
+Все ключи, кроме `recovery_attempts`, входят в ключ радара или в условие перезапуска
+процессора.
+
+### radar.rph_radar
+
+Только для `rph_radar`. Поля без пометки «—» редактируются в секции **Radar**.
+
+| Ключ | Тип, по умолчанию | Поле | Что делает |
+| --- | --- | --- | --- |
+| `source` | строка, `"e502"` | — | источник: `"e502"` — АЦП L-Card через `rph_radar_collector`, `"rfg_udp"` — плата RFG по UDP |
+| `udp_bind` | строка, `"0.0.0.0:5000"` | — | только RFG: адрес `IPv4:порт`, на котором принимаются пакеты |
+| `project_dir` | строка, `""` | **Project dir** | рабочий каталог коллектора; пусто — каталог проекта |
+| `executable_path` | строка, `""` | **Executable path** | путь к коллектору, обычно `build/bin/rph_radar_collector` |
+| `tty_path` | строка, `""` | **TTY path** | путь псевдотерминала, через который коллектор передаёт развёртки; старый файл по этому пути удаляется перед запуском |
+| `args` | список строк, `[]` | **Arguments** (по строке на параметр) | параметры коллектора (глава [Оборудование](hardware.md)); `tty:` здесь задавать нельзя |
+| `env` | объект строк, `{}` | **Environment JSON** | переменные окружения коллектора; `~` и `$ПЕРЕМЕННАЯ` раскрываются. Если `env` не задаёт `LD_LIBRARY_PATH`, в начало унаследованного `LD_LIBRARY_PATH` добавляется `~/.local/lib` |
+| `startup_timeout_s` | число, `5.0` | **Startup timeout (s)** | сколько ждать появления псевдотерминала после запуска коллектора |
+| `sweep_timeout_s` | число, `5.0` | **Read timeout (s)** | сколько ждать пригодной развёртки, покрывающей полосу, прежде чем переподключиться |
+| `stop_timeout_s` | число, `2.0` | **Stop timeout (s)** | ожидание остановки коллектора; на деле не больше 0,5 с, затем он завершается принудительно |
+| `phase_calibration.phase0_rad`, `freq0_hz` | `0.0`, `2046000000.0` | — | первая опорная точка «фаза опорного сигнала — частота» |
+| `phase_calibration.phase1_rad`, `freq1_hz` | `300.0`, `5612000000.0` | — | вторая опорная точка; фазы и частоты точек должны различаться |
+| `band.start_hz`, `band.stop_hz` | `2100000000.0`, `5500000000.0` | — | выходная полоса: развёртка пересчитывается на её сетку; развёртка, не покрывающая полосу, отбрасывается |
+| `band.points` | `2048` | — | число точек выходной сетки, не меньше 2 |
+
+Для `source: "e502"` поля `executable_path` и `tty_path` обязательны, а `project_dir`, если
+задан, должен быть каталогом. Все три тайм-аута должны быть конечными положительными
+числами (`radar.rph_radar.<ключ> must be finite and positive`).
+
+Фазы `phase0_rad` и `phase1_rad` записывает программа фазовой калибровки
+(`rph_radar_calibrate --apply`). Полоса и якоря входят в ключ радара, поэтому после
+новой фазовой калибровки старые наборы не видны.
+
+### radar.laser_control
+
+Плата лазеров радиофотонного радара. Поля — в секции **Radar**, применяются кнопкой
+**Apply Radar**.
+
+| Ключ | По умолчанию | Что делает |
+| --- | --- | --- |
+| `enabled` | `false` | программировать плату при **Apply Radar** |
+| `port` | `""` | последовательный порт платы, 115 200 бод |
+| `mode` | `"manual"` | `"manual"` — постоянные токи и температуры, `"variation"` — развёртка тока LD1 (плате RFG нужен этот режим) |
+| `pi_coeff1_p`, `pi_coeff1_i` | `1280`, `25` | коэффициенты ПИ-регулятора первого канала |
+| `pi_coeff2_p`, `pi_coeff2_i` | `1280`, `179` | коэффициенты ПИ-регулятора второго канала |
+| `manual.temp1`, `manual.temp2` | `24.0`, `24.9` | температуры лазеров, °C, 15–40 |
+| `manual.current1`, `manual.current2` | `97.3`, `102.0` | токи лазеров, мА, 15–110 |
+| `variation.variation_type` | `"CHANGE_CURRENT_LD1"` | что меняется; другого варианта нет |
+| `variation.static_temp1`, `static_temp2` | `24.0`, `24.9` | температуры во время развёртки |
+| `variation.static_current1`, `static_current2` | `97.3`, `102.0` | постоянные токи во время развёртки |
+| `variation.min_value`, `max_value` | `97.3`, `107.0` | пределы развёртки тока LD1, мА, начало меньше конца |
+| `variation.step` | `0.05` | шаг тока, мА, 0,002–0,5 |
+| `variation.time_step` | `50` | длительность ступени, мкс, 20–100 |
+| `variation.delay_time` | `10` | пауза между развёртками, мс, 3–10 |
+
+## switches
+
+Два ВЧ-ключа: `port1` — выходной (передающие антенны), `port2` — входной (приёмные).
+Таблица положений — в главе [Оборудование](hardware.md).
+
+| Ключ | Тип, по умолчанию | Что делает |
+| --- | --- | --- |
+| `name` | строка, `""` | имя ключа в сообщениях и в GPIO (`radar_<имя>`) |
+| `driver_mode` | `"mock"` | `"native"` — управлять линиями GPIO, `"mock"` — только помнить положение |
+| `radar_port` | целое, `0` | порт прибора, к которому подключён ключ: 1 или 2. Задайте его явно: `0` не проходит проверку при **Start** (`switches.<имя>.radar_port must be 1 or 2`) |
+| `positions` | `1` | число положений, у настоящего ключа 1–4. Задаёт число выходов или входов матрицы |
+| `default_position` | `0` | положение после открытия ключа, меньше `positions` |
+| `gpio_chip` | `""` | GPIO-чип, например `/dev/gpiochip0`; для `native` обязателен |
+| `pin_a`, `pin_b` | `-1` | номера линий A и B на чипе; для `native` — разные и неотрицательные |
+
+Поле `driver` допускается только со значением `"h7992"`, `invert_logic` — только
+`false`; оба при сохранении убираются. Другие значения отвергаются ещё до обращения к
+GPIO. У многоприборной схемы программа сама переписывает в этих разделах `driver_mode`
+(`"mock"`), `radar_port`, `positions` (положения настоящего ключа × выходы или входы
+приборов) и `default_position` (`0`): значения из файла не используются. Читаются
+только `name`, `gpio_chip`, `pin_a` и `pin_b` — для настоящих ключей на выходах мастера
+или входах ведомых, если `multi_device.output_switch_positions` или
+`input_switch_positions` больше 1; их режим берётся из `radar.driver_mode`. Изменения
+применяются при **Start**;
+число положений и режим перезапускают процессор.
+
+## control_button
+
+GPIO-кнопка, которая снимает временный референс. Настройки читаются один раз при
+запуске программы.
+
+| Ключ | По умолчанию | Что делает |
+| --- | --- | --- |
+| `enabled` | `false` | включить кнопку |
+| `gpio_chip` | `"/dev/gpiochip0"` | GPIO-чип |
+| `pin` | `-1` | номер линии на чипе (не номер контакта разъёма); отрицательный — кнопка не включается |
+| `active_low` | `true` | `true` — нажатие опускает линию в 0 (кнопка на землю), `false` — поднимает в 1 |
+| `bias` | `""` | подтяжка: `"pull_up"`, `"pull_down"`, `"disabled"`; пусто — к питанию при `active_low`, к земле иначе |
+| `debounce_ms` | `50` | подавление дребезга средствами ядра, мс; 0 — без подавления |
+| `action` | `"capture_tmp_reference"` | действие по нажатию; другого нет |
+
+## logging
+
+| Ключ | По умолчанию | Что делает |
+| --- | --- | --- |
+| `level` | `"info"` | наименьший уровень записей журнала: `debug`, `info`, `warning` или `error`. Меняется списком **Level** над журналом окна; записывается при **Save Config…** |
+
+На уровне `debug` в журнал попадают трассы тайминга переключений; корневой профиль
+работает с ним.
+
+## run
+
+| Ключ | Тип, по умолчанию | Что делает | Когда |
+| --- | --- | --- | --- |
+| `settling_ms` | целое, `0` | пауза после переключения ВЧ-ключей перед развёрткой, мс. Поле **Settling (ms)** | Start |
+| `idle_sleep_ms` | целое, `2` | сколько процессы предобработки и обработки спят, когда нет входных данных, мс | Start, процессор |
+| `continuous` | логическое, `false` | непрерывный сбор или одна коллекция. Программа сама ставит `true` при **Start** и `false` при **Single Capture**; значение из профиля важно только для `./start.sh --producer-only` | — |
+| `processing_live_config_path` | строка, `""` | путь к live-файлу. Программа всегда подставляет `python_app/runtime/processing_live.json` своего каталога | — |
+| `combos` | список `{"input", "output"}`, `[]` | пары сбора в порядке обхода (ниже) | Start, процессор |
+| `locator_server` | объект | TCP-локатор (ниже) | Start, процессор |
+
+### run.combos
+
+Список пар `{"input": вход, "output": выход}`; пустой список — полная матрица:
+выходы по порядку, внутри каждого — все входы. Пар не больше 4096, повторы и номера
+вне матрицы отвергаются.
+
+- Одиночный LibreVNA и радиофотонный радар: при **Start** список берётся из окна —
+ **Run combos** (`вход:выход` через запятую, пусто — полная матрица) или
+ **Single combo**. В режимах pass-through и B-scan, если оба ключа `mock` или у обоих
+ не больше одного положения, программа снимает только пару `0:0`.
+- Многоприборная схема: список всегда равен полной матрице; в окне он показан, но не
+ редактируется.
+
+### run.locator_server
+
+TCP-сервер, который отдаёт объекты GPR внешним программам и принимает от них
+скорость (протокол — в главе [Веб-интерфейс, кнопка и локатор](web.md)). В окне не
+редактируется. Старое имя раздела `run.locator` тоже читается.
+
+| Ключ | По умолчанию | Что делает |
+| --- | --- | --- |
+| `host` | `"0.0.0.0"` | адрес, на котором сервер ждёт клиентов; `0.0.0.0` — все интерфейсы |
+| `port` | `8888` | TCP-порт, 1–65535 |
+| `device_id` | `3` | номер устройства в заголовке каждого пакета |
+| `protocol_version` | `1` | поле `ver` в пакетах |
+| `max_payload_bytes` | `65536` | наибольший размер входящего пакета; клиент с большим пакетом отключается |
+| `client_queue_size` | `32` | очередь пакетов на клиента; при переполнении выбрасываются старые |
+| `logger_name` | `"locator_runtime"` | не используется |
+
+Ключ `enabled` в этом разделе программа не сохраняет: локатор включён всегда.
+
+## preprocess
+
+| Ключ | Что делает |
+| --- | --- |
+| `s21.calibration.set_name` | имя выбранного набора калибровки (through) |
+| `s21.reference.set_name` | имя выбранного набора референса |
+| `s21.object_calibration.set_name` | имя выбранной калибровки по объекту; пусто — не применять |
+| `s21.*.bundle_path` | путь к файлу набора для препроцессора; программа заполняет его сама при **Start**, в профиле — пусто |
+| `notch.enabled` | включить режекторный фильтр, по умолчанию `false` |
+| `notch.bands_hz` | полосы подавления `[нижняя, верхняя]`, Гц, по умолчанию `[]` |
+| `notch.taper_width_hz` | ширина плавного края, Гц, не меньше 0, по умолчанию `40000000.0` |
+| `notch.taper_type` | форма края: `"cosine"` (по умолчанию) или `"hard"`; как работает фильтр — в главе [Калибровка и предобработка](preprocessing.md) |
+
+Наборы выбираются в окне **Preprocessing Setup** и записываются в профиль при
+**Save Config…**. Изменения применяются при **Start**.
+
+## gpr
+
+Геометрия антенн для GPR и 3D SAR и для симулятора. Редактируется таблицами
+**Tx geometry** и **Rx geometry** на страницах GPR и 3D SAR; правка таблицы сразу
+уходит процессору.
+
+| Ключ | Запись | Что делает |
+| --- | --- | --- |
+| `tx_geometry` | `{"output_pos", "x_m", "y_m", "z_m"}` | положения передающих антенн: номер выхода и координаты в осях радара, м |
+| `rx_geometry` | `{"input_pos", "x_m", "y_m", "z_m"}` | положения приёмных антенн: номер входа и координаты, м |
+
+Записей может быть меньше, чем выходов или входов: антенны без записи в GPR и 3D SAR
+не участвуют. Номер должен быть меньше числа выходов (входов), без повторов, иначе
+`gpr.tx_geometry output_pos is out of range [0, N)`. При сужении матрицы лишние
+записи нужно удалить. Отсутствующие `y_m` и `z_m` считаются нулями. Оси — в главе
+[Как работает радар](principles.md).
+
+## rings
+
+Пять колец разделяемой памяти, через которые процессы передают данные
+(глава [Устройство программы](architecture.md)):
+
+| Кольцо | Кто пишет | Кто читает |
+| --- | --- | --- |
+| `raw` | процесс сбора | препроцессор |
+| `raw_tap` | процесс сбора, копия | окно программы |
+| `preprocessed` | препроцессор | процессор |
+| `preprocessed_tap` | препроцессор, копия | окно программы |
+| `results` | процессор | окно программы |
+
+| Ключ | По умолчанию | Что делает |
+| --- | --- | --- |
+| `name` | `""` | имя сегмента разделяемой памяти, например `/radar_raw`. Пустое имя у `raw_tap` или `preprocessed_tap` отключает копию и соответствующую историю окна |
+| `capacity` | `1` | сколько кадров помещается в кольцо; при переполнении затирается самый старый |
+| `slot_size_bytes` | `4096` | наибольший размер кадра, байт, кратный 8 |
+
+Слоты `raw`, `preprocessed` и включённых копий должны вмещать коллекцию:
+44 + пары × (28 + 12 × точки) байт. Например, 16 пар по 1031 точке требуют 198 444
+байт. Программа проверяет это до запуска
+(`rings.raw.slot_size_bytes must be at least N for this sweep`). Слот `results` должен
+вмещать результат; изображения 3D SAR при настройках по умолчанию занимают около
+1 МБ. Во всех примерах слоты по 2 МиБ (`2097152`) и ёмкость 50.
+
+Ёмкость влияет и на историю: процессор хранит до 50 коллекций, но не больше
+ёмкости `preprocessed` и `results`, а B-scan во время сбора показывает столько же. В
+GPR и 3D SAR процессор при этом хранит не меньше, чем нужно обработке: 1 +
+**Mean count** (если включено вычитание среднего) + **SAR_FRAME_RADIUS** (в 3D SAR),
+независимо от ёмкости колец.
+Калибровке по объекту нужно кольцо `preprocessed_tap`.
+
+## gui
+
+Состояние окна: значения всех полей, которые программа запоминает. Раздел
+записывается целиком при **Save Config…**. Если его нет, программа берёт значения по
+умолчанию, а часть вычисляет из профиля: номера антенн — из геометрии и списка пар,
+окно видимости GPR — из положения антенн, каталог сохранения —
+`python_app/data/snapshots`.
+
+| Ключ | Поле в окне | Смысл |
+| --- | --- | --- |
+| `version` | — | версия раздела, всегда `1` |
+| `switches.combo_mode` | кнопки **Select** у **Run combos** и **Single combo** | откуда брать пары сбора: `"text"` или `"single"` |
+| `switches.combos_text` | **Run combos** | список пар `вход:выход` |
+| `switches.single_input`, `single_output` | **Input**, **Output** в строке **Single combo** | одна пара |
+| `processing.selected_mode` | **Mode** | режим обработки: `pass_through`, `bscan`, `gpr`, `sar3d` |
+| `processing.pass_through.*` | страница pass-through | `show_magnitude`, `show_phase`, `unwrap_phase`, `combo_filter`, `fixed_y_enabled`, `y_min_db`, `y_max_db` |
+| `processing.bscan.*` | страница B-scan | `axis`, `cut_m`, `max_depth_m`, `gain`, `start_freq_mhz`, `stop_freq_mhz`, `subtract_mean_ascan`, `history_window_scans` |
+| `processing.gpr.*` | страница GPR | `input_positions`, `output_positions`, `min_depth_m`, `max_depth_m`, `range_comp_power`, `object_min_frac`, `score_mode`, `motion_mode`, `look_angle_deg`, `direction_sign`, `speed_m_s`, `ignore_socket_speed_enabled`, `max_detected_objects_to_draw`, `draw_top_m_objects`, `object_approach_min_frames`, `start_freq_mhz`, `stop_freq_mhz`, `background_subtract_enabled`, `background_mean_count`, `remove_sidelobe_objects_enabled`, `bp_ground_depth_m`, `pair_depth_equalize`, `render_mode`, `visible_x_min_m`, `visible_x_max_m`, `visible_z_min_m`, `visible_z_max_m` |
+| `processing.sar3d.*` | страница 3D SAR | `input_positions`, `output_positions`, `min_depth_m`, `max_depth_m`, `start_freq_mhz`, `stop_freq_mhz`, `speed_m_s`, `ignore_socket_speed_enabled`, `look_angle_deg`, `frame_radius`, `voxel_x_m`, `voxel_y_m`, `voxel_z_m`, `bp_ground_depth_m`, `comp_range_power`, `use_compensation`, `normalize_each_frame`, `pair_depth_equalize`, `background_subtract_enabled`, `background_mean_count`, `bg_gate_enabled`, `clean_enabled`, `x_size_m`, `y_min_m`, `y_max_m`, `z_size_m`, `cloud_threshold`, `zoom_percent`, `mark_enabled` |
+| `data_actions.save_path` | **Path** | каталог для сохранения данных; относительный путь — от рабочего каталога процесса |
+| `data_actions.save_name` | **Name** | имя сохраняемого набора данных, по умолчанию `snapshot_manual` |
+| `data_actions.record_count` | **Sweeps to record** | сколько коллекций записать на диск, по умолчанию 100 |
+| `preprocess_dialog.set_name` | **Set name** | имя нового набора, по умолчанию `set_001` |
+| `preprocess_dialog.radar_config_dir` | **Directory** | каталог вариантов радара |
+| `preprocess_dialog.use_all_radar_configs` | **Capture current combo for all found radar configs** | съём по всем вариантам |
+| `preprocess_dialog.median_sweep_count` | **Sweeps per combo (median)** | развёрток на пару, по умолчанию 5 |
+
+Ключи страниц обработки названы по полям окна; что делает каждое поле, сколько оно по
+умолчанию и в каких пределах — в главе [Режимы обработки](processing.md). Типы,
+варианты выбора, объявленные в схеме границы и версия проверяются при чтении, и
+нарушение отвергает весь профиль: `gui.processing.gpr.score_mode must be one of: peak, combined`,
+`gui.processing.sar3d.zoom_percent must be <= 1000`. Значение в пределах схемы, но вне
+пределов поля окна поле молча подрезает к своей границе (например, `frame_radius: 1000`
+станет 24), и подрезанное значение запишется при следующем **Save Config…**.
+`preprocess_dialog.median_sweep_count` меньше 1 поднимается до 1.
+
+## processing_live.json
+
+Живые настройки процессора. Файл `python_app/runtime/processing_live.json` пишет
+программа при каждом изменении настройки обработки, смене режима, правке геометрии,
+очистке истории и удалении последнего измерения; читает его только процессор. Править
+его вручную не нужно: программа перезаписывает его целиком.
+
+В файле 53 ключа. Ключи настроек обработки соответствуют полям окна:
+
+| Ключ | Поле |
+| --- | --- |
+| `processor_mode` | **Mode** |
+| `bscan_axis`, `bscan_cut_m`, `bscan_max_depth_m`, `bscan_gain`, `bscan_start_freq_mhz`, `bscan_stop_freq_mhz` | поля B-scan |
+| `gpr_input_positions`, `gpr_output_positions`, `gpr_min_depth_m`, `gpr_max_depth_m`, `gpr_start_freq_mhz`, `gpr_stop_freq_mhz`, `gpr_speed_m_s`, `gpr_look_angle_deg`, `ignore_socket_speed`, `gpr_background_subtract_enabled`, `gpr_background_mean_count`, `pair_depth_equalize` | общие поля GPR и 3D SAR — со страницы выбранного режима |
+| `gpr_range_comp_power`, `gpr_object_min_frac`, `gpr_score_mode`, `gpr_motion_mode`, `gpr_direction_sign`, `gpr_max_detected_objects_to_draw`, `gpr_draw_top_m_objects`, `gpr_object_approach_min_frames`, `gpr_remove_sidelobe_objects_enabled`, `gpr_bp_ground_depth_m`, `gpr_visible_x_min_m`, `gpr_visible_x_max_m`, `gpr_visible_z_min_m`, `gpr_visible_z_max_m` | поля GPR |
+| `sar3d_frame_radius`, `sar3d_voxel_x_m`, `sar3d_voxel_y_m`, `sar3d_voxel_z_m`, `sar3d_bp_ground_depth_m`, `sar3d_comp_range_power`, `sar3d_use_compensation`, `sar3d_normalize_each_frame`, `sar3d_x_size_m`, `sar3d_y_min_m`, `sar3d_y_max_m`, `sar3d_z_size_m`, `sar3d_bg_gate_enabled`, `sar3d_clean_enabled` | поля 3D SAR |
+| `tx_geometry`, `rx_geometry` | таблицы геометрии; заменяют `gpr` профиля в процессоре |
+
+Служебные ключи:
+
+| Ключ | Что делает |
+| --- | --- |
+| `settings_revision` | номер ревизии настроек (время записи по монотонным часам, нс). Процессор возвращает его в каждом результате, и окно рисует GPR и 3D SAR только текущей ревизии |
+| `reprocess_current_result` | пересчитывать ли сохранённые коллекции при новой ревизии; `false` — пока окно само пересчитывает остановленный B-scan |
+| `history_clear_before_ns` | граница **Clear Runtime History**: коллекции, начатые не позже этого момента, удаляются из истории процессора |
+| `history_removed` | пары `{"collection_id", "capture_start_ns"}`, удалённые **Remove Last Measurement**; список очищается при **Clear Runtime History** |
+
+Процессор отвергает файл целиком, если в нём ошибка JSON, число не того типа или вне
+float32, неизвестное значение `processor_mode`, `gpr_score_mode` или
+`gpr_motion_mode`, геометрия с номером вне матрицы или с повтором, отрицательные
+`sar3d_x_size_m` или `sar3d_z_size_m`, `sar3d_y_max_m` не больше `sar3d_y_min_m`.
+Тогда он работает с последними принятыми настройками и пишет
+`data_processor warning: failed to refresh live processing config: …` в
+`python_app/runtime/logs/data_processor.err.log`. Неизвестные ключи пропускаются.
diff --git a/docs/data.md b/docs/data.md
new file mode 100644
index 0000000..d05c017
--- /dev/null
+++ b/docs/data.md
@@ -0,0 +1,225 @@
+# Данные на диске
+
+Программа хранит данные в трёх местах: наборы предобработки и наборы данных — в
+`python_app/data/` (или в каталоге, выбранном в **Path**), своё рабочее состояние — в
+`python_app/runtime/`, обмен между процессами — в разделяемой памяти `/dev/shm`.
+Каталоги `python_app/data*` и `python_app/runtime` в git не попадают.
+
+> **Внимание.** Наборы калибровки и референса в `python_app/data/` — незаменимые
+> измерения. При обновлении программы их переносит `update.sh`; при ручном переносе
+> копируйте этот каталог целиком.
+
+## Где что лежит
+
+| Путь | Что | Кто пишет |
+| --- | --- | --- |
+| `python_app/data/s21_calibration//<имя>.npz`, `.json` | наборы калибровки | окно предобработки |
+| `python_app/data/s21_reference//<имя>.npz`, `.json` | наборы референса, в том числе `tmp_reference` | окно предобработки, временный референс |
+| `python_app/data/s21_object_calibration//<имя>.npz`, `.json` | калибровки по объекту | окно калибровки по объекту |
+| `python_app/data/preview_png/<вид>/<имя>//i<вход>_o<выход>.png` | картинки развёрток при съёме по вариантам радара | окно предобработки |
+| `/<набор данных>/` | снимки и записи на диск; по умолчанию `python_app/data/snapshots` | **Save Last 1000**, **Save S21 JSON**, **Start + Record to Disk** |
+| `python_app/runtime/` | рабочее состояние программы (ниже) | программа |
+| `/dev/shm/<имя кольца>` | кольца разделяемой памяти | процессы конвейера |
+| `/tmp/radar_system.lock` | блокировка единственного экземпляра | `start.sh` |
+| `radar.rph_radar.tty_path`, например `/tmp/ttyADC_data` | ссылка на псевдотерминал коллектора E-502 | `rph_radar_collector` |
+| `/etc/udev/rules.d/99-radar-librevna.rules` | доступ к LibreVNA по USB | `start.sh` |
+| `/etc/systemd/system/radar.service`, `/etc/sudoers.d/radar` | служба и право её перезапускать | `deploy/install-daemon.sh` |
+
+Все файлы JSON и наборы программа записывает через временный файл и замену, а важные
+ещё и сбрасывает на диск (fsync). Оборванная запись оставляет прежний файл целым;
+от неё может остаться временный файл вида `.<имя>.<32 знака>.tmp`, а от оборванного
+сохранения набора данных — каталог `.<имя набора>.<32 знака>`; их можно удалить.
+
+## Наборы предобработки
+
+Набор лежит в каталоге своего вида и ключа радара (глава
+[Файл конфигурации](configuration.md)) и состоит из двух файлов:
+
+| Файл | Содержимое |
+| --- | --- |
+| `<имя>.npz` | архив NumPy без сжатия: для каждой пары `freq_i<вход>_o<выход>` (частоты, float32) и `s21_i<вход>_o<выход>` (S21, complex64), плюс `metadata_json` — описание набора |
+| `<имя>.json` | то же описание в читаемом виде |
+
+Описание: `collection_id`, `monotonic_ns`, `capture_start_ns`, `capture_end_ns`,
+список `combos` (вход, выход, окно съёма, имена массивов) и `metadata`. У калибровки
+по объекту в `metadata` записано, при каких условиях она снята: ключ радара,
+геометрия, имя и хеш набора калибровки, настройки расчёта, координаты отражателя,
+параметры антенн, диагностика фаз и пары без поправки (`unchanged_combos`).
+
+Старые наборы без `metadata_json` читаются по файлу `.json`; лишние массивы (например,
+S11) пропускаются.
+
+Имя набора — любая строка, кроме пустой, `.`, `..` и строк с `/` или `\`. Существующее
+имя программа не перезаписывает; исключение — `tmp_reference`, который заменяется при
+каждом съёме. Перед записью проверяется, что пары не повторяются, массивы одной длины
+и значения конечны.
+
+## Наборы данных
+
+Набор данных — каталог со снимком истории (**Save Last 1000**) или записью на диск
+(**Start + Record to Disk**). Имя каталога — `<префикс>_`, например
+`librevna_multi_0.001-6GHz_201pts_-10dBm_run_A`; символы кроме латиницы, цифр, `.`,
+`_` и `-` заменяются на `_`. Префикс: модель, полоса в единицах большей границы,
+число точек (у радиофотонного радара — `adc`) и мощность. Пустое имя —
+`snapshot_<ГГГГММДД>_<ЧЧММСС>` по UTC. Существующий каталог программа не трогает.
+
+```text
+<набор данных>/
+ config_profile.json профиль, с которым сняты данные
+ processing_settings.json ревизии настроек обработки
+ raw/ сырые коллекции
+ 0000_id<номер>_ns<время>/
+ preprocessed/ предобработанные коллекции
+ results/ результаты обработки
+```
+
+Коллекции внутри стадии нумеруются с нуля. Время в имени каталога — метка своей
+стадии, поэтому у одной коллекции оно разное в `raw`, `preprocessed` и `results`.
+Сопоставляйте стадии по паре `collection_id` и `capture_start_ns` из `meta.json`.
+
+Каталог коллекции в `raw/` и `preprocessed/`:
+
+| Файл | Содержимое |
+| --- | --- |
+| `i<вход>_o<выход>_freq.npy` | частоты, float32 |
+| `i<вход>_o<выход>_s21.npy` | S21, complex64 |
+| `meta.json` | `collection_id`, `monotonic_ns`, `capture_start_ns`, `capture_end_ns`, `trace_count`, список трасс с их окнами съёма и именами файлов |
+
+Каталог коллекции в `results/` содержит `meta.json` (номер, время обработки,
+`settings_revision`, окно съёма, использованная скорость `speed_m_s`, список
+результатов) и массивы результатов. Результаты всей коллекции — файлы
+`collection__<имя>_kind<вид>_<поле>.npy`, результаты пары — в подкаталоге
+`block__i<вход>_o<выход>/`.
+
+| Вид | Поля |
+| --- | --- |
+| 1 — трасса | `freq` float32, `trace` complex64 |
+| 2 — число | `scalar` float32 |
+| 3 — изображение | `x_axis`, `y_axis` float32, `image` float32 размером (строки по y, столбцы по x) |
+| 4 — таблица | `table` float32 (строки, столбцы) |
+
+| Режим | Что сохраняется |
+| --- | --- |
+| pass-through | для каждой пары `pass_through` (вид 1): частота и S21 |
+| B-scan | для каждой пары `bscan` (вид 1): в поле `freq` — дальность в метрах, в `trace` — значение (мнимая часть 0) |
+| GPR | `gpr_accumulator` (вид 3): карта, оси X и Z; `gpr_points` (вид 4): строки `[x_m, z_m, score]` |
+| 3D SAR | `sar3d_front`, `sar3d_side`, `sar3d_top`, `sar3d_oblique` (вид 3); `sar3d_oblique_basis`; `sar3d_peak` `[уровень, X, Y, Z]`; `sar3d_background` `[отношение, 6, 8.5]`; `sar3d_objects` `[X, Y, Z, уровень]` и `sar3d_marks` `[класс, X0, X1, Y0, Y1, Z0, Z1]` или `sar3d_target_width` `[X, Y, Z]`; `sar3d_status` `[код, байты текста UTF-8]` |
+
+Классы рамок 3D SAR: 0 — очень низко, 1 — на земле, 2 — над землёй, 3 — у края. Коды
+состояния 3D SAR: 0 — посчитано, 1 — нет сигнала, 2 — ошибка, 3 — нет CUDA.
+
+`processing_settings.json` — словарь «номер ревизии → настройки обработки» в формате
+`processing_live.json`: по `settings_revision` из `meta.json` результата видно, с
+какими настройками он посчитан. При ручном сохранении туда попадают все ревизии
+текущего сеанса, при записи — только использованные. `config_profile.json` — полный
+профиль; разделы радара, ключей, пар, предобработки и геометрии взяты из конфигурации,
+с которой шёл сбор, даже если потом загрузили другой профиль.
+
+### Запись на диск
+
+**Start + Record to Disk** создаёт каталог сразу и пишет в него коллекции, снятые
+после нажатия; счётчик считает результаты. Данные копятся порциями по 200 коллекций и
+уходят в очередь записи на 8 порций; каждая коллекция появляется на диске целиком.
+Если диск не успевает и очередь заполнилась, запись останавливается с
+`Disk recording stopped because the writer could not keep up`; записанное остаётся.
+Если сбор так и не начался и ничего не пришло, каталог удаляется.
+
+### Экспорт S21 JSON
+
+**Save S21 JSON** создаёт в `/<набор данных>/` по файлу на пару
+`<имя набора>_i<вход>_o<выход>_s21_vna_bscan_history.json` и профиль
+`vna_config_profile.json`. Основные поля файла:
+
+| Поле | Что |
+| --- | --- |
+| `format` | `"vna-system-history-v1"` |
+| `input_index`, `output_index`, `channel` | пара и канал (`"s21"`) |
+| `primary_stage` | `"preprocessed"` |
+| `sweep_history` | список коллекций: `timestamp`, `sweep_points` (сырая развёртка, пары `[re, im]`), `calibrated_points` (предобработанная), `reference_points` (всегда пусто), `vna_config` (начало, конец, число точек) |
+
+`timestamp` — время конца съёма развёртки этой пары (если его нет — коллекции) в
+секундах монотонных часов компьютера (отсчёт от его загрузки), а не дата и время. Сырая и
+предобработанная развёртки
+сопоставляются по паре номер — начало съёма; если пары нет, массив пуст.
+
+### Как открыть данные в Python
+
+```python
+import json
+from pathlib import Path
+
+import numpy as np
+
+# Набор предобработки
+with np.load("python_app/data/s21_reference//<имя>.npz", allow_pickle=False) as archive:
+ meta = json.loads(str(archive["metadata_json"].item()))
+ for combo in meta["combos"]:
+ frequency, s21 = archive[combo["freq_key"]], archive[combo["s21_key"]]
+
+# Предобработанные коллекции набора данных
+from python_app.storage.npz.vna_history_json import collection_directories
+
+for directory in collection_directories(Path("<набор данных>") / "preprocessed"):
+ meta = json.loads((directory / "meta.json").read_text())
+ for trace in meta["traces"]:
+ frequency = np.load(directory / trace["freq_file"])
+ s21 = np.load(directory / trace["s21_file"])
+
+# Результаты (например, карта GPR)
+for directory in collection_directories(Path("<набор данных>") / "results"):
+ meta = json.loads((directory / "meta.json").read_text())
+ for payload in meta["collection_payloads"]:
+ if payload["name"] == "gpr_accumulator":
+ image = np.load(directory / payload["image_file"])
+```
+
+`collection_directories` возвращает каталоги коллекций по порядку номеров. Загрузить
+набор данных обратно в окно программы нельзя: он предназначен для обработки
+отдельно.
+
+## Рабочие файлы
+
+Каталог `python_app/runtime/` программа создаёт и ведёт сама.
+
+| Файл | Что | Можно удалить |
+| --- | --- | --- |
+| `gui_session_state.json` | путь последнего профиля: с него программа стартует | да; программа стартует с корневого профиля. Это и способ выйти из ситуации, когда запомненный профиль не даёт программе запуститься |
+| `processing_live.json` | живые настройки процессора (глава [Файл конфигурации](configuration.md)) | да; программа запишет его заново |
+| `run_config.json` | рабочая копия профиля последнего **Start** | при остановленном сборе |
+| `s21_calibration_bundle.bin`, `s21_reference_bundle.bin`, `s21_object_calibration_bundle.bin` | выбранные наборы в формате для препроцессора, пересобираются при каждом **Start** | при остановленном сборе |
+| `supervisor_children.pids` | номера процессов конвейера: если программа упала, следующий запуск завершает оставшиеся | когда процессы конвейера не работают |
+| `logs/` | журналы (ниже) | при закрытой программе |
+
+## Журналы
+
+| Где | Что |
+| --- | --- |
+| `python_app/runtime/logs/radar.log` и `radar.log.1` … `radar.log.5` | журнал программы: то же, что в окне журнала, строки `дата время \| уровень \| источник \| сообщение`. Файл до 2 МиБ, хранятся пять предыдущих |
+| `python_app/runtime/logs/acquisition.out.log`, `acquisition.err.log` | вывод процесса сбора, в том числе ожидание приборов и сообщения коллектора E-502 (`Collector: …`) |
+| `python_app/runtime/logs/data_preprocessor.*.log`, `data_processor.*.log` | вывод препроцессора и процессора: отброшенные коллекции, отклонённый live-файл, сообщения 3D SAR (`sar3d: …`) и локатора (`locator: …`) |
+| `*.prev` рядом | вывод прошлого запуска процесса или начало текущего, если файл превысил 8 МиБ |
+| `journalctl -u radar.service` | при работе службой: вывод программы, включая строки `[start.sh] …` |
+
+Уровень записей задаёт `logging.level` профиля или список **Level** в окне.
+Подробности ошибки, показанной окном (например, трассировка вызовов), видны в окне
+ошибки и в окне журнала, а в `radar.log` попадает само сообщение. Если процесс конвейера
+завершился сам,
+отчёт с последними 16 КиБ его вывода попадает в журнал программы. Раз в 50 замеров
+программа пишет строку `metrics: <стадия> n=… avg=…ms min=…ms max=…ms` со временем
+сбора, обработки и отрисовки.
+
+## Разделяемая память
+
+Кольца лежат в `/dev/shm` под именами из раздела `rings` профиля (без начальной `/`).
+При ёмкости 50 и слоте 2 МиБ кольцо занимает около 100 МиБ виртуальной памяти, но
+страницы выделяются только по мере записи. Кольца создаёт первый запустивший их
+процесс с правами 0660 и при выходе не удаляет. `./start.sh --clean-shm` удаляет
+кольца `/radar_raw`, `/radar_raw_tap`, `/radar_preprocessed`, `/radar_preprocessed_tap`,
+`/radar_results` и те же имена с окончанием `_rph_radar`. Кольца с окончанием
+`_simulator` он не трогает, а их используют корневой профиль, оба профиля симулятора и
+оба примера с переключением стимула (`run_config_librevna_multi_per_point*.example.json`).
+Их удаляйте вручную при закрытой программе: `rm /dev/shm/radar_*_simulator`.
+
+> **Внимание.** Если программу хоть раз запустили от root (например, тесты через
+> `sudo`), кольца станут принадлежать root, и обычный пользователь получит отказ в
+> доступе. Удалите их при закрытой программе: `sudo rm -f /dev/shm/radar_*`.
diff --git a/docs/desktop.md b/docs/desktop.md
new file mode 100644
index 0000000..a82042b
--- /dev/null
+++ b/docs/desktop.md
@@ -0,0 +1,341 @@
+# Окно программы
+
+Окно называется **Radar System Control** и открывается развёрнутым на весь экран.
+Слева — графики выбранного режима обработки, справа — панель управления. Работу ведут
+несколько процессов — конвейер: процесс сбора управляет железом, препроцессор
+калибрует данные, процессор их обрабатывает и держит TCP-локатор для внешних программ;
+между собой они передают данные через кольца в разделяемой памяти (главы
+[Устройство программы](architecture.md) и [Словарь](glossary.md)).
+
+```text
+┌──────────────────────────────────────┬─┬──────────────────────────────────┐
+│ │ │ Actions (не прокручивается)│
+│ │ │ ┌──────────────────────────────┐ │
+│ графики │›│ │ Data Actions │ │
+│ (вид зависит от режима) │ │ │ Switches │ │
+│ │ │ │ Selected Preprocess Sets │ │
+│ │ │ │ Processing │ │
+│ │ │ │ Radar │ │
+│ │ │ └──────────────── прокрутка ───┘ │
+│ │ │ Status: … History: … │
+│ │ │ Runtime Log Level ▾ │
+│ │ │ [журнал] │
+└──────────────────────────────────────┴─┴──────────────────────────────────┘
+```
+
+Стрелка между графиками и панелью (подсказка **Hide settings panel** или
+**Show settings panel**) прячет и показывает всю панель; графики тогда занимают всю ширину.
+Положение
+стрелки не запоминается. Секция **Actions** со списком **Mode** всегда на месте,
+остальные секции прокручиваются. Меню, панели инструментов и сочетаний клавиш нет.
+
+Многие действия приостанавливают сбор: останавливают процесс сбора и препроцессор, но
+оставляют работать процессор и локатор, а по окончании продолжают сбор, если он шёл.
+Так работают **Apply Radar**, **Clear Runtime History**, **Remove Last Measurement**,
+**Capture Temporary Reference** и съём наборов. Приостановка завершает и запись на
+диск. **Stop** отменяет ожидающее продолжение. Если в это время идёт другое такое
+действие, программа отвечает `Wait for the current pipeline operation to finish`.
+
+## Actions
+
+| Кнопка | Что делает |
+| --- | --- |
+| **Start** | запускает непрерывный сбор и обработку |
+| **Single Capture** | снимает одну коллекцию, показывает результат и останавливает сбор |
+| **Stop** | останавливает сбор |
+| **Apply Radar** | отправляет настройки радара в железо, не запуская сбор |
+| **Load Config…** | загружает профиль из файла |
+| **Save Config…** | сохраняет текущие настройки в профиль |
+| **Preprocessing…** | открывает окно **Preprocessing Setup**: наборы калибровки и референса, калибровка по объекту ([Калибровка и предобработка](preprocessing.md)) |
+| **Clear Runtime History** | очищает историю в памяти |
+| **Mode** | режим обработки: `pass_through`, `bscan`, `gpr`, `sar3d` ([Режимы обработки](processing.md)) |
+
+### Start
+
+Перед запуском программа проверяет всё и при любой ошибке отказывает, ничего не
+остановив; ошибка показывается окном `Failed to start pipeline: <тип>: <текст>`.
+Проверяются:
+
+- значения полей (например, буквы в поле **Points**: `invalid literal for int()…`);
+- профиль сбора: режим драйвера, модель, ключи, линии GPIO, размер слотов колец;
+- требования режима обработки (B-scan, GPR, 3D SAR — в главе
+ [Режимы обработки](processing.md));
+- выбранные наборы калибровки и референса (глава
+ [Калибровка и предобработка](preprocessing.md)).
+
+Если проверка прошла, программа:
+
+1. перезапускает процессор, если изменились настройки, от которых он зависит
+ (глава [Файл конфигурации](configuration.md));
+2. записывает файлы наборов и рабочую копию профиля в `python_app/runtime/`;
+3. очищает историю, если процессор запускается заново (первый **Start**, перезапуск на
+ шаге 1) или изменились профиль сбора, наборы или режекторный фильтр (тогда в
+ журнале `History reset because run settings changed`);
+4. запускает процесс сбора и препроцессор и ждёт до 3 с, пока они поднимутся;
+5. начинает читать кольца; строка состояния — **Status: running**.
+
+Если во время работы процесс конвейера завершился, программа пишет отчёт с хвостом
+его вывода и перезапускает конвейер: первый раз сразу, следующие подряд — не раньше
+чем через 6, 12, 24, 48, затем 60 с после предыдущей попытки, пока не получится. Повторное
+нажатие **Start** при работающем сборе отвечает
+`Pipeline is already running`.
+
+### Single Capture
+
+Делает то же, что **Start**, но процесс сбора снимает одну коллекцию и завершается.
+Если шёл непрерывный сбор, он сначала останавливается. Программа ждёт результат
+коллекции, снятой после нажатия, рисует его и останавливает сбор
+(`Single capture completed`). Строка состояния — **Status: single capture running**.
+Если результата нет 300 с, сбор останавливается с
+`Single capture timed out after 300s with no result; stopping.` В режиме 3D SAR, если
+результат не посчитался, строка состояния показывает **Status: no signal**,
+**Status: error** или **Status: warning**.
+
+### Stop
+
+Останавливает процесс сбора, дочитывает кольца (до 0,35 с), останавливает
+препроцессор и дочитывает результаты (до 0,25 с). Процессор и локатор продолжают
+работать: так остановленный B-scan можно пересчитать, а клиенты локатора остаются
+подключёнными. Запись на диск завершается. **Stop** отменяет и ожидающие действия:
+автозапуск, продолжение после приостановки, съём для калибровки по объекту,
+пересчёт B-scan.
+
+### Apply Radar
+
+Приостанавливает сбор, проверяет профиль и:
+
+- одиночный LibreVNA (`native`) — открывает прибор, отправляет настройки развёртки,
+ закрывает;
+- многоприборная схема — приборы не трогает (их настраивает процесс сбора), только
+ перечитывает пределы мастера;
+- радиофотонный радар — программирует плату лазеров, если она включена в профиле;
+- режим `mock` у любой модели — ничего (`Radar pre-configuration skipped (mock mode)`).
+
+Если работает только процессор, а настройки, от которых он зависит, изменились,
+**Apply Radar** перезапускает процессор и очищает историю.
+
+В журнал пишется `Radar settings applied: start=…, stop=…, points=…, ifbw=…, power=…`.
+Профиль кнопка не меняет.
+
+### Load Config… и Save Config…
+
+**Load Config…** открывает выбор файла (**Load Config Profile**) и загружает профиль:
+все поля, таблицы геометрии, выбранные наборы, режим. Сбор должен быть остановлен,
+иначе — `Stop all pipeline processes before loading a config profile`. Ошибка чтения
+показывается как `Failed to load config profile: …`, текущий профиль при этом не
+меняется.
+
+**Save Config…** (**Save Config Profile**) записывает профиль; имя без расширения
+получает `.json`, существующий файл перезаписывается. Записываются все поля окна,
+выбранные наборы и геометрия. Загруженный или сохранённый профиль становится
+стартовым для следующего запуска.
+
+### Clear Runtime History
+
+Приостанавливает сбор, отбрасывает непрочитанные данные колец, очищает историю
+окна и процессора, очищает графики и продолжает сбор. Файлы на диске не трогает;
+подтверждения не спрашивает. Поздние результаты коллекций, снятых до очистки,
+отбрасываются.
+
+## Data Actions
+
+| Элемент | Что делает |
+| --- | --- |
+| **Save Last 1000** | сохраняет до 1000 последних коллекций каждой стадии (сырые, предобработанные, результаты) в новый каталог набора данных |
+| **Save S21 JSON** | сохраняет S21 предобработанной стадии в файлы JSON, по файлу на пару |
+| **Remove Last Measurement** | удаляет новейшую коллекцию из истории всех стадий |
+| **Capture Temporary Reference** | снимает временный референс и сразу его применяет ([Калибровка и предобработка](preprocessing.md)) |
+| **Start + Record to Disk** | запускает сбор, если он остановлен, и записывает на диск следующие **Sweeps to record** коллекций |
+| **Sweeps to record** | сколько коллекций записать, от 1 до 1 000 000 |
+| **Path**, **Browse…** | каталог, в котором создаются наборы данных |
+| **Name** | имя набора данных; пусто — `snapshot_<дата>_<время>` по UTC |
+
+Каталог набора данных — `/<префикс>_`. Префикс описывает снятое:
+модель, полосу, число точек (у радиофотонного радара — `adc`) и мощность. Каталог
+не должен существовать (`Snapshot directory already exists`). Рядом с данными
+кладутся профиль, с которым они сняты (`config_profile.json`), и все ревизии
+настроек обработки (`processing_settings.json`). Состав файлов — в главе
+[Данные на диске](data.md).
+
+Перед сохранением программа дочитывает данные из колец; идущий сбор и запись при
+этом не прерываются. Если в памяти нет ни одной коллекции, сохранение отказывает:
+`No runtime data is available for save`. Окно хранит в памяти до 1000 коллекций каждой
+стадии; что старее,
+не сохранить. Без колец-копий (`raw_tap`, `preprocessed_tap`) сохраняются только
+результаты.
+
+**Save S21 JSON** пишет по файлу на пару: для каждой коллекции — сырая и
+предобработанная развёртка, рядом профиль `vna_config_profile.json`. Его можно
+направить в существующий каталог набора данных: существующие файлы данных JSON не
+перезаписываются, а `vna_config_profile.json` заменяется.
+
+**Remove Last Measurement** приостанавливает сбор и удаляет из истории окна и
+процессора самую новую коллекцию; если её результат ещё в пути, процессор его не
+выдаст. Повторное нажатие удаляет следующую.
+
+**Start + Record to Disk** записывает коллекции, которые придут после нажатия. Запись
+заканчивается, когда записано заданное число, по **Stop**, при приостановке сбора
+(очистка истории, временный референс, съём) или если диск не успевает и очередь
+записи переполнилась; причина пишется в журнал. Одновременно идёт только одна запись
+(`A recording is already in progress…`), а каталог должен быть новым. Закрытие окна
+дописывает принятые данные.
+
+## Switches
+
+| Элемент | Что делает |
+| --- | --- |
+| **Settling (ms)** | пауза после переключения ВЧ-ключей перед развёрткой, мс (`run.settling_ms`) |
+| **Run combos** и **Select** | список пар сбора `вход:выход` через запятую; пусто — полная матрица. **Select** делает этот список источником пар |
+| **Single combo**: **Input**, **Output** и **Select** | одна пара сбора; **Select** делает её источником пар |
+| **B-scan pair** | какую пару показывать в B-scan; доступно в режиме B-scan |
+| **Next Combination** | следующая пара B-scan; доступно, когда пар в истории хотя бы две |
+
+Пары сбора применяются при **Start**. У многоприборной схемы список всегда равен
+полной матрице и не редактируется. В режимах pass-through и B-scan, если оба ключа
+`mock` или у обоих одно положение, снимается только пара `0:0`.
+
+## Selected Preprocess Sets
+
+Показывает выбранные наборы: **S21 calibration**, **S21 reference**,
+**Object calibration**; `` — набор не выбран. Выбираются наборы в окне
+**Preprocessing Setup**.
+
+## Processing
+
+Страница настроек выбранного режима. Каждое поле описано в главе
+[Режимы обработки](processing.md). В режимах GPR и 3D SAR над настройками — таблицы
+геометрии антенн.
+
+### Таблицы геометрии
+
+**Tx geometry** — передающие антенны: **Output** (номер выхода), **X (m)**, **Y (m)**,
+**Z (m)**. **Rx geometry** — приёмные: **Input**, **X (m)**, **Y (m)**, **Z (m)**.
+Координаты — в осях радара (глава [Как работает радар](principles.md)).
+
+- **Add Antenna** добавляет строку со следующим свободным номером; строка начинает
+ действовать после ввода X, и до этого кнопка недоступна. Недоступна она и тогда,
+ когда строк столько же, сколько выходов или входов.
+- **Remove Selected** удаляет выделенные строки.
+- Каждая правка проверяется: номер — целый, в пределах матрицы, без повторов;
+ координаты — числа; требования GPR и 3D SAR выполняются. Неверная правка
+ откатывается с сообщением **Invalid Antenna Geometry**.
+- Принятая правка сразу уходит процессору и пересчитывает результат. Если она не
+ совпадает с геометрией выбранной калибровки по объекту, выбор этой калибровки
+ снимается.
+
+## Radar
+
+Для LibreVNA и симулятора секция показывает развёртку:
+
+| Поле | Ключ профиля |
+| --- | --- |
+| **Start (Hz)** | `radar.sweep.start_hz` |
+| **Stop (Hz)** | `radar.sweep.stop_hz` |
+| **Points** | `radar.sweep.points` |
+| **IF bandwidth (Hz)** | `radar.sweep.if_bandwidth_hz` |
+| **Stimulus power (dBm)** | `radar.sweep.stimulus_power_dbm` |
+
+Под полями — пределы прибора: `Limits: freq=…, points=1..N, ifbw=…, power=…`. В режиме
+`mock` и у радиофотонного радара пределов нет: `Device limits are not available.` (с
+`driver_mode: "native"` без подключённого прибора окно не открывается — глава
+[Неполадки](troubleshooting.md)). С известными пределами подпись поля дополняется
+диапазоном, а значения вне его подрезаются с предупреждением
+`Radar fields were adjusted to satisfy device limits.` Поле применяется, когда из него
+уходит фокус или нажат Enter. Если
+изменение меняет ключ радара, выбор наборов сбрасывается, а границы частот обработки
+подрезаются под новую развёртку.
+
+Для радиофотонного радара вместо развёртки показаны поля коллектора и платы
+лазеров:
+
+| Поле | Ключ профиля |
+| --- | --- |
+| **Project dir**, **Executable path**, **TTY path** | `radar.rph_radar.project_dir`, `executable_path`, `tty_path` |
+| **Arguments** (по строке на параметр) | `radar.rph_radar.args` |
+| **Environment JSON** | `radar.rph_radar.env` |
+| **Startup timeout (s)**, **Read timeout (s)**, **Stop timeout (s)** | `startup_timeout_s`, `sweep_timeout_s`, `stop_timeout_s` |
+| **Board enabled** | `radar.laser_control.enabled` |
+| **Board port**, **Board mode** | `port`, `mode` (`manual` или `variation`) |
+| **PI 1 P**, **PI 1 I**, **PI 2 P**, **PI 2 I** | `pi_coeff1_p`, `pi_coeff1_i`, `pi_coeff2_p`, `pi_coeff2_i` |
+| **Temperature 1**, **Temperature 2**, **Current 1**, **Current 2** (режим `manual`) | `manual.temp1`, `temp2`, `current1`, `current2` |
+| **Static temp 1**, **Static temp 2**, **Static current 1**, **Static current 2**, **Min value**, **Max value**, **Step**, **Time step**, **Delay time** (режим `variation`) | `variation.static_temp1` … `delay_time` |
+
+Поля платы доступны при включённой **Board enabled**; видны поля только выбранного
+режима. Единицы и пределы — в главе [Файл конфигурации](configuration.md). Поля
+коллектора применяются при **Start**, поля лазера — кнопкой **Apply Radar**.
+
+## Строка состояния и журнал
+
+Под секциями — строка состояния и счётчик истории
+`History: raw=0, preprocessed=0, results=0` (сколько коллекций каждой стадии в памяти).
+
+| Состояние | Что значит |
+| --- | --- |
+| **Status: idle** | сбор остановлен |
+| **Status: running** | идёт непрерывный сбор |
+| **Status: single capture running** | идёт однократный съём |
+| **Status: error** | в журнал записана ошибка; состояние держится до следующего изменения |
+| **Status: result display error (acquisition continues)** | не удалось нарисовать результат; сбор продолжается |
+| **Status: runtime display/recording error (acquisition continues)** | ошибка отображения или записи на диск; сбор продолжается |
+| **Status: no signal**, **Status: warning** | итог однократного съёма в режиме 3D SAR |
+
+**Runtime Log** — журнал программы. Список **Level** (**Debug**, **Info**,
+**Warning**, **Error**) задаёт наименьший уровень записей; он применяется сразу и
+сохраняется в профиль при **Save Config…**. Стрелка справа прячет и показывает журнал.
+В окне хранится до 2000 последних записей; при переполнении появляется
+`Log panel overflow: N record(s) dropped (they are still in the log file).` Полный
+журнал — в `python_app/runtime/logs/radar.log` (глава [Данные на диске](data.md)).
+Повторяющиеся ошибки отрисовки показываются один раз.
+
+## Графики
+
+| Режим | Что на экране |
+| --- | --- |
+| pass-through | **Pass-Through S21**: модуль (**Magnitude (dB)**) и фаза (**Phase (deg)**) по частоте, кривая на пару, легенда `in<вход>/out<выход>` |
+| B-scan | **B-scan S21 in<вход>/out<выход> \| sweeps=N**: номер развёртки — дальность |
+| GPR | **GPR coherent BP — ground plane**: карта плоскости земли и объекты |
+| 3D SAR | **Front (X-Y)**, **Side (Z-Y)**, **Top (X-Z)**, **3D Projection** и строка состояния 3D SAR над ними |
+
+Подробно виды описаны в главе [Режимы обработки](processing.md). Приставку единиц на
+осях (Гц, МГц, ГГц, м, мм) график подбирает сам.
+
+Графики управляются мышью так, как принято в библиотеке pyqtgraph: колесо меняет
+масштаб, перетаскивание сдвигает, правая кнопка открывает меню (**View All** —
+вернуть весь график, экспорт картинки). Щелчок по строке легенды прячет и показывает
+кривую. Графики окна видны и в веб-интерфейсе.
+
+Последняя развёртка снятого набора (калибровка, референс, временный референс)
+рисуется на графике pass-through с подписью вида `Tmp S21 Reference last trace`.
+
+## Сообщения
+
+Ошибка действия открывает окно с ошибкой и пишется в журнал. Без экрана, для
+действий из веб-интерфейса и для GPIO-кнопки окон нет: ошибка уходит в журнал, а
+веб-интерфейсу — в ответ на запрос. Причины частых ошибок — в главе
+[Неполадки](troubleshooting.md).
+
+Окно ошибки называется по действию и обычно показывает
+`<заголовок>: <тип ошибки>: <текст>` (у записи на диск — `<заголовок>: <текст>`):
+
+| Заголовок | Действие |
+| --- | --- |
+| `Failed to start pipeline` | **Start**, **Single Capture** |
+| `Failed to stop pipeline` | **Stop** |
+| `Failed to apply radar settings` | **Apply Radar** |
+| `Failed to load config profile`, `Failed to save config profile` | **Load Config…**, **Save Config…** |
+| `Failed to open preprocessing panel` | **Preprocessing…** |
+| `Failed to clear runtime history`, `Failed to remove last runtime measurement` | **Clear Runtime History**, **Remove Last Measurement** |
+| `Failed to save snapshot`, `Failed to save VNA history JSON` | **Save Last 1000**, **Save S21 JSON** |
+| `Failed to start disk recording` | **Start + Record to Disk** |
+| `Failed to capture tmp reference` | **Capture Temporary Reference** |
+| `Cannot switch processing mode to <режим>` | **Mode** |
+| `Failed to update live processing settings`, `Failed to update plot display settings`, `Failed to render processing settings` | правка настроек обработки и вида |
+| `Failed to recompute the B-scan` | пересчёт остановленного B-scan |
+| `Failed to start <вид> sequence`, `Failed to capture combo; previous captures kept, retry when ready`, `Failed to undo last preprocess capture`, `Failed to save preprocess set`, `Failed to abort capture sequence`, `Failed to refresh preprocessing sets`, `Failed to create neutral S21 sets` | окно **Preprocessing Setup** |
+| `Failed to capture object-calibration data`, `Failed to calculate object calibration`, `Failed to save object calibration`, `Failed to update object calibration after geometry change` | калибровка по объекту |
+| `Cannot perform data action`, `Cannot modify runtime history during active capture sequence` | действие с данными во время съёма или остановки |
+| `Shutdown operation failed` | закрытие окна |
+
+Пока идёт съём наборов, часть действий недоступна, и программа объясняет почему:
+`Finish the active capture sequence first`, `Wait for preprocessing capture to finish`,
+`Wait for object-calibration capture to finish`, `Wait for pipeline shutdown to finish`.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..ea28945
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,160 @@
+# Разработка
+
+## Сборка
+
+C++ собирается командой `make` из корня проекта; Eigen и nlohmann/json лежат в
+`data_acq_and_processing/third_party/`, других внешних библиотек нет.
+
+| Цель | Что собирает или делает |
+| --- | --- |
+| `all` (по умолчанию) | `build/bin/data_preprocessor`, `build/bin/data_processor` |
+| `rph_radar_collector` | `build/bin/rph_radar_collector`; в `all` не входит |
+| `check-native` | тесты C++: кадры, кольца, конфигурация, наборы, режекторный фильтр, локатор, псевдотерминал коллектора (на поддельной библиотеке L-Card) |
+| `check-sar3d` | 3D SAR на настоящем CUDA-бэкенде против эталона |
+| `check-gpr` | GPR на CPU, без видеокарты, против эталона |
+| `clean` | удаляет каталог сборки |
+
+| Переменная | По умолчанию | Что задаёт |
+| --- | --- | --- |
+| `BUILD_DIR` | `build` | каталог сборки. Программа запускает процессы только из `build/bin/`, другой каталог нужен для проверок |
+| `CUDA_ROOTS` | пакеты `nvidia/*` в `.venv` и `/usr/local/cuda` | где искать `nvrtc.h`, `cuda.h`, `libnvrtc` |
+| `PYTHON` | `.venv/bin/python` | интерпретатор для `check-sar3d` и `check-gpr` |
+| `NATIVE_SANITIZERS` | не задана | санитайзеры для тестов C++, например `address,undefined` |
+| `CXX`, `CXXFLAGS`, `LDFLAGS` | `g++`, `-std=c++20 -O2 …`, `-pthread -lrt` | компилятор и флаги |
+
+`BUILD_DIR`, `CUDA_ROOTS`, `CXX`, `CXXFLAGS` и `LDFLAGS` задаются в командной строке
+`make`, а не через окружение; `PYTHON` и `NATIVE_SANITIZERS` можно задать и переменной
+окружения. `CXXFLAGS` в командной строке заменяет весь набор флагов, вместе с
+`-std=c++20` и отслеживанием зависимостей `-MMD -MP`. `make` сам пересобирает нужное, если
+изменились найденная CUDA или
+санитайзеры. Сборка с CUDA и без неё описана в главе [Установка и запуск](installation.md).
+
+## Тесты
+
+```sh
+QT_QPA_PLATFORM=offscreen .venv/bin/python -m unittest discover -s python_app/tests
+make check-native
+ASAN_OPTIONS=detect_leaks=0 make check-native NATIVE_SANITIZERS=address,undefined
+make check-sar3d
+make check-gpr
+```
+
+| Набор | Что нужно |
+| --- | --- |
+| Python, `python_app/tests` | `.venv` с пакетами, `QT_QPA_PLATFORM=offscreen` (дисплея у тестов нет), запись в `/dev/shm`, псевдотерминалы, UDP на loopback. Железо не нужно |
+| C++, `make check-native` | g++ с C++20 |
+| 3D SAR, `make check-sar3d` | сборка с CUDA и пригодная видеокарта; без неё проверяются только предупреждение и правила рамок, остальное пропускается |
+| GPR, `make check-gpr` | numpy |
+
+Особые случаи:
+
+- `test_processor_history` запускает настоящий `build/bin/data_processor` и
+ пропускается, если он не собран.
+- Тесты окна строят настоящее окно на временном профиле из
+ `run_config_examples/run_config_simulator.example.json` и не зависят от корневого
+ `run_config.json` (`python_app/tests/gui_test_support.py`).
+- `test_deploy_scripts` гоняет `deploy/update.sh` во временном каталоге с заглушками
+ `systemctl` и `make`.
+- `test_cart_firmware` компилирует прошивку тележки на компьютере и пропускается без
+ g++.
+- `data_acq_and_processing/tests/test_backprojection.py` в общий прогон не входит:
+ его запускают `make check-sar3d` и `make check-gpr`.
+- `test_documentation` проверяет документацию: `docs/configuration.md` называет каждый
+ ключ профиля и `processing_live.json`, главы называют каждую подпись окна и диалогов
+ (жирным, кодом или заголовком), а относительные ссылки ведут на существующие файлы.
+ Новый ключ или кнопка без описания роняет этот тест.
+
+> **Внимание.** Не запускайте тесты от root: созданные ими кольца в `/dev/shm`
+> станут принадлежать root, и программа от обычного пользователя получит отказ в
+> доступе.
+
+Проверка на старой видеокарте (NVRTC 13 не компилирует под карты старее sm_75):
+
+```sh
+make check-sar3d BUILD_DIR=/tmp/radar_build_nvrtc12 CUDA_ROOTS="$HOME/.cache/radar_nvrtc12/nvidia/cuda_nvrtc $HOME/.cache/radar_nvrtc12/nvidia/cuda_runtime"
+```
+
+## Эталоны 3D SAR и GPR
+
+`data_acq_and_processing/tests/sar3d_reference.npz` и `gpr_reference.npz` строит
+генератор, который выполняет неизменённые функции ноутбука
+`Multi_depthnorm_clean.ipynb` (ячейки 1 и 5) и скрипта `Horns_motion_4x4_ground.py` с
+заменёнными параметрами. В эталоне 3D SAR десять случаев: шесть объёмов (ширина
+отклика, вычитание среднего предыдущих коллекций для каждого кадра апертуры,
+отношение к фону) и четыре сцены точечных отражателей в шуме для CLEAN с объектами и
+рамками. Правила рамок проверяются по одному на синтетических объектах. Архивы
+записывают хеши SHA-256 исходников и версии NumPy и SciPy.
+
+Перегенерировать эталоны нужно только при правке алгоритма; ноутбук и скрипт
+хранятся вне репозитория:
+
+```sh
+.venv/bin/python data_acq_and_processing/tests/generate_backprojection_reference.py \
+ --notebook /путь/к/Multi_depthnorm_clean.ipynb \
+ --gpr-script /путь/к/Horns_motion_4x4_ground.py
+```
+
+Другую сборку можно проверить так: `make check-sar3d BUILD_DIR=/путь/к/сборке` или
+`BACKPROJECTION_FIXTURE=/путь/к/сборке/native_tests/backprojection_fixture` при запуске
+`test_backprojection.py Sar3dTest`.
+
+## Утилиты
+
+| Команда | Что делает |
+| --- | --- |
+| `.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config <профиль> --sweeps 200 [--warmup 10] [--apply]` | фазовая калибровка радиофотонного радара (глава [Оборудование](hardware.md)) |
+| `.venv/bin/python -m python_app.scripts.per_point_switching_check --config <профиль> [--collections 3] [--switch-connected]` | проверка переключения стимула на железе; выход 0 — норма, 1 — сбой |
+| `./start.sh --producer-only --profile <профиль>` | только процесс сбора с выводом в терминал — для отладки железа |
+
+## Правила кода
+
+- Обязателен только функциональный контракт: программа в правильном порядке
+ собирает коллекции, предобрабатывает, обрабатывает и показывает результат в окне и в
+ вебе, даёт выбрать настройки предобработки и выполнить действия пользователя.
+ Внутреннее устройство можно менять под этот контракт.
+- Код — читаемый и без лишнего: понятные имена, без мёртвых опций и «умных» трюков.
+- Наименьшая версия Python — 3.10 (код использует `dataclass(slots=True)` и `match`).
+- `.venv/bin/pyright python_app` — 0 ошибок, без подавлений и расширения исключений
+ (режим `basic`).
+- Нумерацию антенн и портов задаёт профиль: она уточняется по железу, менять её
+ самостоятельно нельзя.
+- Корневой `run_config.json` остаётся профилем симулятора.
+- Изменив поведение, исправьте документацию в том же изменении (таблица «Где что
+ править» в главе [Устройство программы](architecture.md)); полноту ключей и подписей
+ проверяет `test_documentation`. Документ описывает, как устроено сейчас; числа
+ перепроверяются по коду.
+
+## Прошивки
+
+> **Внимание.** `top_master.bin` и `combined.vnafw` — единственные копии рабочего
+> битстрима мастера и рабочего образа; битстрим заново не воспроизводится. Не
+> перезаписывайте их, новые сборки кладите под новыми именами.
+
+- VHDL — только ASCII: любой другой символ, даже длинное тире в комментарии, ломает
+ GHDL (`invalid character, even in a comment`).
+- В `VNA.xise` закреплено `Starting Placer Cost Table (1-100) Map spartan6 = 2`; не
+ сбрасывайте его в 1: ограничение `MCU_MOSI OFFSET hold` и в стоковой прошивке
+ проходит с запасом всего +0,458 нс.
+- После правки FPGA прогоните тестбенч и мутационный тест — внесите ошибку и
+ убедитесь, что тестбенч падает:
+
+ ```sh
+ ghdl -a --std=93 Sweep.vhd Test_Sweep.vhd && ghdl -r --std=93 Test_Sweep --assert-level=error
+ ```
+
+- Изменение объёма логики FPGA сравнивайте по Timing Score с контрольной сборкой
+ стоковой прошивки.
+
+Порядок сборки прошивок —
+[reproduce/README.md](../device_firmware/multi_libreVNA_firmware/reproduce/README.md).
+
+## Git
+
+- Основная ветка — `main`.
+- Правила `build*` и `build/` в корневом `.gitignore` скрывают и файлы комплекта сборки
+ прошивок (в том числе `build.sh` и эталонные бинарники STM32); для них добавлены
+ исключения, а часть файлов скрывает ещё и вложенный `.gitignore` LibreVNA. Добавляя
+ файлы в `device_firmware/`, проверяйте `git status --ignored` и при необходимости
+ `git add -f`.
+- В репозиторий не входят: `.venv`, `build/`, `python_app/data*`, `python_app/runtime`,
+ ноутбуки `*.ipynb`, локальные инструменты разработчиков.
diff --git a/docs/glossary.md b/docs/glossary.md
new file mode 100644
index 0000000..b67d0ab
--- /dev/null
+++ b/docs/glossary.md
@@ -0,0 +1,64 @@
+# Словарь
+
+Русские термины — по алфавиту, за ними термины, которые пишутся латиницей.
+
+| Термин | Значение |
+| --- | --- |
+| А-скан | профиль дальности одной пары: амплитуда отражения в зависимости от расстояния; получается обратным преобразованием Фурье развёртки S21 |
+| апертура | набор точек, из которых снята сцена. В 3D SAR — текущая коллекция и до `SAR_FRAME_RADIUS` предыдущих |
+| боковой лепесток | ложный слабый пик рядом с настоящим на А-скане или изображении; появляется из-за того, что полоса частот ограничена |
+| ВАЦ (векторный анализатор цепей, VNA) | прибор, который выдаёт синусоиду заданной частоты и измеряет амплитуду и фазу прошедшего сигнала; в системе — LibreVNA |
+| ведомый прибор (slave) | LibreVNA многоприборной схемы, который только принимает |
+| воксель | ячейка трёхмерной сетки 3D SAR |
+| временный референс | референс, снятый одной кнопкой во время работы; хранится как набор `tmp_reference` |
+| вход (input) | приёмная сторона пары: номер приёмной антенны |
+| ВЧ-ключ | коммутатор H7992: подключает порт прибора к одной из антенн (их до четырёх) по двум логическим линиям |
+| выход (output) | передающая сторона пары: номер передающей антенны; у LibreVNA совпадает с номером стадии прибора |
+| геометрия антенн | координаты передающих и приёмных антенн в осях радара, таблицы **Tx geometry** и **Rx geometry** |
+| дальность | половина пути волны от передающей антенны через отражатель к приёмной; в GPR и 3D SAR — расстояние вдоль оси обзора радара |
+| дБ | логарифмическая мера отношения: для амплитуд 20·lg, для мощностей 10·lg |
+| дБм | мощность в децибелах относительно 1 мВт |
+| калибровка (through) | эталонное измерение тракта; каждая развёртка делится на него |
+| калибровка по объекту | поправка пар по известному отражателю, выравнивающая их по фазе |
+| ключ радара (`radar_key`) | строка из настроек железа; по ней находятся наборы, снятые при тех же настройках |
+| когерентное суммирование | сложение комплексных значений с учётом фазы: вклады настоящего отражателя складываются, остальные гасятся |
+| коллекция (collection) | все пары, снятые за один обход; единица данных в программе |
+| кольцо (ring) | кольцевой буфер в разделяемой памяти, через который процессы передают кадры |
+| копия кольца (tap) | кольца `raw_tap` и `preprocessed_tap`: копии для окна программы |
+| локатор | TCP-сервер процессора: отдаёт объекты GPR и принимает скорость |
+| мастер (master) | LibreVNA многоприборной схемы, который излучает и задаёт запуск |
+| набор (предобработки) | сохранённое измерение для предобработки: калибровка, референс или калибровка по объекту |
+| набор данных | каталог с сохранёнными коллекциями и результатами |
+| нейтральные наборы | калибровка, равная 1, и референс, равный 0: данные проходят предобработку без изменений |
+| обратная проекция | построение изображения: для каждой точки сумма значений А-сканов всех пар на задержке до этой точки |
+| обратное преобразование Фурье | пересчёт значений по частотам в значения по времени (задержке); переводит развёртку S21 в А-скан |
+| окно Ханна | плавное взвешивание частот перед преобразованием Фурье, снижающее боковые лепестки |
+| окно съёма | время начала и конца измерения по монотонным часам компьютера; есть у коллекции и у каждой трассы |
+| пара (combo) | сочетание передающей и приёмной антенны, записывается `вход:выход` |
+| переключение стимула внутри частотной точки | режим многоприборной схемы, в котором каждый порт мастера на каждой частоте излучает через оба положения ключа; даёт матрицу 4 × 4 |
+| полоса ПЧ (IF bandwidth) | полоса фильтра приёмника ВАЦ; чем уже, тем меньше шум и медленнее развёртка |
+| предельная дальность | дальность, после которой А-скан повторяется: c / (2Δf) |
+| препроцессор | процесс `data_preprocessor`: калибровка, вычитание референса, режекторный фильтр, калибровка по объекту |
+| проекция максимума | плоская картинка объёма: в каждой её точке — наибольшее значение вдоль направления взгляда |
+| профиль | файл JSON со всеми настройками программы |
+| процесс сбора | процесс, который управляет железом и публикует коллекции |
+| процессор | процесс `data_processor`: режимы обработки и локатор |
+| развёртка (sweep) | один проход прибора по списку частот |
+| разрешение по дальности | наименьшее расстояние между двумя отражателями, при котором они видны раздельно: c / (2B) |
+| ревизия настроек | номер набора настроек обработки; процессор возвращает его в каждом результате |
+| режекторный фильтр (notch) | обнуление заданных полос частот |
+| режим обработки | что процессор делает с данными: pass-through, B-scan, GPR, 3D SAR |
+| референс | измерение пустой сцены; вычитается как фон |
+| симулятор | работа без железа (`driver_mode: "mock"`): программа моделирует один движущийся отражатель |
+| стадия прибора | одно излучение на частоте: на каждой частоте прибор проходит все стадии по очереди |
+| угол обзора | наклон радара вниз от горизонтали |
+| фон кадра | медиана рабочей части объёма 3D SAR (ненулевые воксели опорных плоскостей); без объектов CLEAN кадр показывается, когда его максимум превышает фон в 6 раз |
+| частотная точка | одна частота развёртки |
+| ширина на половине максимума | ширина отклика на уровне половины его пика; её размер имеет квадрат, которым 3D SAR отмечает максимум |
+| B-scan | изображение «номер развёртки — дальность» для одной пары |
+| CLEAN | алгоритм, который по шагам вычитает самый сильный отклик, чтобы разделить объекты |
+| GPR | режим обработки: изображение плоскости земли перед радаром и поиск объектов на нём |
+| live-файл | `python_app/runtime/processing_live.json`: настройки обработки, которые программа передаёт процессору на ходу |
+| RPH (радиофотонный радар) | радар, в котором СВЧ-сигнал формируется лазерами; данные оцифровывает АЦП E-502 или плата RFG |
+| S21 | комплексный коэффициент передачи из порта 1 в порт 2: отношение принятой волны к поданной, то есть ослабление и сдвиг фазы |
+| SAR (3D SAR) | радар с синтезированной апертурой; режим, строящий трёхмерное изображение сцены |
diff --git a/docs/hardware.md b/docs/hardware.md
new file mode 100644
index 0000000..20f157b
--- /dev/null
+++ b/docs/hardware.md
@@ -0,0 +1,382 @@
+# Оборудование
+
+Тип радара задаёт поле `radar.model` профиля, а `radar.driver_mode` — работать с
+настоящим железом (`"native"`) или с симулятором (`"mock"`). Ключи профиля описаны
+в главе [Файл конфигурации](configuration.md), принцип измерения — в главе
+[Как работает радар](principles.md), термины — в [словаре](glossary.md).
+
+| Радар | `radar.model` | Что подключается | Процесс сбора |
+| --- | --- | --- | --- |
+| Одиночный LibreVNA | `librevna` | один LibreVNA по USB, два ВЧ-ключа на GPIO | `python_app.scripts.matrix_raw_producer` |
+| Многоприборная схема | `librevna_multi` | три LibreVNA по USB, петля триггера; в режиме переключения стимула — контроллер STM32 и ключи на портах мастера | `python_app.scripts.matrix_raw_producer` |
+| Радиофотонный радар с АЦП | `rph_radar`, `radar.rph_radar.source: "e502"` | АЦП L-Card E-502 по USB, плата лазеров по последовательному порту, два ВЧ-ключа на GPIO | `python_app.scripts.rph_radar_raw_producer` и программа `rph_radar_collector` |
+| Радиофотонный радар с платой RFG | `rph_radar`, `radar.rph_radar.source: "rfg_udp"` | плата RFG по Ethernet, плата лазеров по последовательному порту; одна пара антенн | `python_app.scripts.rph_radar_raw_producer` |
+| Симулятор | `librevna` или `librevna_multi` с `driver_mode: "mock"` | ничего | `python_app.scripts.matrix_raw_producer` |
+
+Для радиофотонного радара симулятора нет: он работает только с `driver_mode: "native"`.
+
+Процесс сбора открывает приборы и при неудаче повторяет попытки бесконечно, с паузой
+от 1 до 10 с: прибор можно подключить и после запуска. Его сообщения пишутся в
+`python_app/runtime/logs/acquisition.err.log`, а в журнал окна попадают, только если
+процесс завершился. Ошибки сбора и их признаки собраны в главе
+[Неполадки](troubleshooting.md).
+
+## LibreVNA
+
+LibreVNA — двухпортовый векторный анализатор цепей (ВАЦ) с USB. В системе используются платы
+ревизии B. Прошивки — изменённая версия 1.6.5, протокол версии 14 (программа
+проверяет версию при подключении одиночного прибора).
+
+| Предел | Значение |
+| --- | --- |
+| Частота | 0–6 ГГц |
+| Полоса ПЧ | 6 Гц–50 кГц |
+| Число точек | до 4501 |
+| Мощность | от −40 до 0 дБм |
+| Задержка на стадии `dwell_us` | до 10 239 мкс |
+
+Ниже 25 МГц генератор прибора переключается на другую микросхему, и развёртка идёт
+с неравномерными длинными паузами: если развёртка должна быть быстрой, начинайте её не
+ниже 30 МГц (так сделано в `run_config_librevna_multi_per_point_lowband.example.json`).
+
+С `driver_mode: "native"` программа при запуске окна, загрузке профиля и нажатии
+**Apply Radar** читает пределы прибора и подрезает поля секции **Radar** под них
+(`Radar fields were adjusted to satisfy device limits.` в журнале). У многоприборной
+схемы пределы читаются с мастера. Если прибор с `driver_mode: "native"` не
+подключён, окно программы не открывается: чтение пределов при старте завершается
+ошибкой.
+
+USB-доступ к прибору даёт правило udev `/etc/udev/rules.d/99-radar-librevna.rules`
+(группа `plugdev`). Его ставит `./start.sh` при обычном запуске с профилем LibreVNA;
+при запуске без экрана (`--headless`) правило не ставится.
+
+## Одиночный LibreVNA
+
+Прибор подключается по USB; если `radar.serial` пуст, берётся первый найденный.
+Порт 1 излучает, порт 2 принимает. Выходной ВЧ-ключ (`switches.port1`) подключает
+порт 1 к одной из передающих антенн, входной (`switches.port2`) — порт 2 к одной из
+приёмных. Ключами управляет компьютер через GPIO.
+
+Сбор одной коллекции:
+
+1. Для каждой пары из списка сбора программа выставляет выходной ключ, затем входной,
+ и ждёт время установления `run.settling_ms`.
+2. Запускает развёртку и принимает все точки. Если какая-то точка не пришла,
+ коллекция не публикуется, а прибор открывается заново.
+3. Публикует коллекцию в кольцо `raw` и начинает следующую.
+
+S21 одиночного прибора — отношение сигнала порта 2 к сигналу опорного приёмника.
+**Apply Radar** открывает прибор, отправляет настройки развёртки и закрывает его.
+
+## Многоприборная схема
+
+Три LibreVNA: мастер (`radar.serial`) и два ведомых
+(`radar.multi_device.slave_serials`, ровно два). Мастер на каждой частоте излучает по
+очереди с порта 1 и порта 2, ведомые не излучают и принимают каждый на оба порта.
+S21 — сигнал приёмника ведомого, делённый на сигнал опорного приёмника мастера на той
+же стадии.
+
+| Номер | Что это |
+| --- | --- |
+| вход 0, 1 | первый ведомый из `slave_serials`, порт 1 и порт 2 |
+| вход 2, 3 | второй ведомый, порт 1 и порт 2 |
+| выход 0, 1 (без переключения стимула) | мастер, порт 1 и порт 2 |
+| выход 0, 1, 2, 3 (переключение стимула, два положения) | порт 1 положение A, порт 1 положение B, порт 2 положение A, порт 2 положение B |
+
+Номер выхода совпадает с номером стадии прибора. Многоприборная схема всегда снимает
+полную матрицу: список пар `run.combos` у неё заменяется всеми парами, и в окне он не
+редактируется.
+
+Приборы синхронизированы аппаратным сигналом запуска. Петля триггера:
+`TRIGGER_OUT` мастера → первый ведомый → второй ведомый → `TRIGGER_IN` мастера.
+Вернувшийся сигнал означает «все готовы». Мастер измеряет по вернувшемуся сигналу,
+поэтому задержки в петле одинаковы для всех приборов и в отношении «приёмник/опорный»
+сокращаются.
+
+Опорная частота: поле `radar.multi_device.force_external_reference` включает внешнюю
+опору 10 МГц сразу на всех трёх приборах (раздельно задать нельзя). Во всех профилях
+оно выключено. Включайте его, только если опора 10 МГц действительно подана на все
+приборы. Признак отсутствующей опоры при включённом поле — мастер не отдаёт точки:
+`Packet not received from <серийный номер> within 1.0 s`.
+
+Сбор: перед первой коллекцией программа настраивает опору на всех приборах,
+затем отправляет настройки развёртки сначала ведомым, потом мастеру, чтобы приёмники
+были готовы раньше, чем мастер выдаст запуск. Развёртка идёт непрерывно. Каждый
+прибор передаёт свои точки по отдельному USB; программа ждёт начала развёртки на всех
+трёх и принимает цикл, только если начала совпали по времени. Если пропало не больше
+5 % точек трассы, они заменяются нулями (`Zero-filled dropped sweep point(s) to keep the cycle…`),
+иначе цикл отбрасывается.
+
+| Сбой | Предел |
+| --- | --- |
+| от прибора нет ни одной точки | 1,5 с |
+| нет начала развёртки | 6 с |
+| цикл не закончился | 8 с |
+| повторные попытки без переподключения | 2 |
+| переподключение всех приборов | `radar.multi_device.recovery_attempts` раз (по умолчанию 3), не дольше 10 с |
+
+После этого процесс сбора пересоздаёт подключение и ждёт приборы бесконечно. Если
+не пришло ни одной точки ни от одного прибора, в журнале процесса сбора будет
+`No datapoints received from at least one device; hardware trigger sync did not start. Check Trigger Out/In loop and 10 MHz reference wiring.`
+
+**Apply Radar** в этой схеме приборы не трогает: их настраивает процесс сбора
+(`Multi-device raw producer will configure all LibreVNA devices`), кнопка только
+перечитывает пределы мастера.
+
+Прошивки: мастер работает с образом `combined.vnafw` (обычная раскладка стадий с
+аппаратным триггером) или `combined_per_point_switching.vnafw` (переключение стимула),
+ведомые — с `combined_external_lo_slave.vnafw`. Образы и правила их сборки — в
+разделе «Прошивки» ниже.
+
+## Переключение стимула внутри частотной точки
+
+В этом режиме одна развёртка даёт матрицу 4 × 4 вместо 2 × 4: на каждом порту
+мастера стоит ВЧ-ключ, и на каждой частоте каждый порт излучает дважды, через оба
+положения ключа. Все 16 пар одной частоты измеряются за доли миллисекунды, поэтому
+движение радара между ними пренебрежимо мало. Полное описание —
+[per_point_switching.md](../device_firmware/per_point_switching.md).
+
+Что нужно:
+
+- прошить мастер образом `combined_per_point_switching.vnafw`; ведомые не трогать;
+- поставить контроллер ключей (плата WeAct STM32G431CB с прошивкой
+ `device_firmware/switch_controller`) в разрыв петли триггера между
+ `TRIGGER_OUT` мастера и `TRIGGER_IN` первого ведомого;
+- в профиле задать `radar.multi_device.stimulus_switch_positions: 2` и
+ `radar.sweep.dwell_us: 2`. Ключами на портах мастера управляет контроллер, а не
+ компьютер; разделы `switches` у многоприборной схемы программа сама ставит в `mock`.
+
+| Вывод STM32 | Куда |
+| --- | --- |
+| PB0 (вход) | `TRIGGER_OUT` мастера |
+| PB1 (выход, повторяет PB0) | `TRIGGER_IN` первого ведомого |
+| PB2 | ключ порта 1 мастера |
+| PB6 | ключ порта 2 мастера |
+| PB5 | отладка: импульс 2 мкс на каждую поправку чётности |
+
+Контроллер переключает оба ключа по каждому спаду сигнала запуска (положение A —
+логический 0, B — 1). Если пауза между стадиями в 1,5 раза длиннее наименьшей из
+последних 16 пауз, то есть на границе развёртки, он принудительно возвращает положение
+A и считает это поправкой чётности. Задержка, которую он вносит в петлю (около 50 нс),
+одинакова для всех
+приборов и на фазу не влияет. Через 200 мс простоя триггера контроллер печатает
+отчёт по USB (115 200 бод): `trigger cycles`, `resyncs (sweep ends)`, `parity corrections`
+(должно быть `0 (ok)`), `stages per resync` (ожидается число точек × число стадий),
+паузы и их гистограмма.
+
+Проверка на железе — `python_app/scripts/per_point_switching_check.py --config <профиль>`:
+она отказывает, если медиана модуля S21 какого-то выхода ниже −80 дБ (признак старой
+прошивки мастера) или если два положения одного порта без подключённого ключа
+различаются больше чем на 6 дБ. С ключом нужен флаг `--switch-connected`.
+
+> **Внимание.** Полоса ПЧ 1 кГц в этом режиме воспроизводимо роняет приборы
+> (`LIBUSB_ERROR_IO`). Работают 5, 10, 20 и 50 кГц; рабочее значение — 50 кГц.
+
+## ВЧ-ключи H7992
+
+Ключ подключает порт прибора к одной из до четырёх антенн. Положение задают две
+линии GPIO, A (`pin_a`) и B (`pin_b`); линии нужны обе даже при двух положениях.
+
+| Положение | A | B |
+| --- | --- | --- |
+| 0 | 0 | 0 |
+| 1 | 0 | 1 |
+| 2 | 1 | 0 |
+| 3 | 1 | 1 |
+
+Номера линий в профилях — номера линий GPIO-чипа (`gpio_chip`, обычно
+`/dev/gpiochip0`), а не номера контактов разъёма. Во всех профилях-примерах, кроме
+примера с платой RFG (там ключей нет), выходной ключ на линиях 17 (A) и 27 (B),
+входной — на 22 (A) и 23 (B). При открытии программа
+выставляет ключ в `default_position`. Ключ с `driver_mode: "mock"` никуда не
+переключается, программа только помнит его положение.
+
+Кто управляет ключами:
+
+| Радар | Кто переключает |
+| --- | --- |
+| одиночный LibreVNA | процесс сбора на Python |
+| многоприборная схема | процесс сбора, только если `multi_device.output_switch_positions` или `input_switch_positions` больше 1; в режиме переключения стимула — контроллер STM32 |
+| радиофотонный с E-502 | программа `rph_radar_collector`, если оба ключа `native`; иначе процесс сбора |
+
+> **Внимание.** На Raspberry Pi 5 номер GPIO-чипа 40-контактного разъёма менялся от
+> версии ядра к версии. Перед работой проверьте его на самой машине: `gpiodetect` и
+> `gpioinfo -c gpiochip0`. Неверный чип означает ключи, которые молча не
+> переключаются.
+
+## Радиофотонный радар
+
+Плата лазеров меняет ток лазера LD1 ступенями, при этом ток второго лазера и
+температуры обоих постоянны. На каждой ступени оцифровываются основной сигнал и
+опорный. Опорное плечо имеет постоянную задержку, поэтому его фаза растёт с частотой
+линейно; по двум опорным точкам `phase_calibration` (фаза — частота) программа
+определяет частоту каждой ступени. S21 — основной сигнал, делённый на модуль
+опорного. Развёртки, которые не покрывают выходную полосу `radar.rph_radar.band`,
+отбрасываются, остальные пересчитываются на её равномерную сетку (по умолчанию
+2,1–5,5 ГГц, 2048 точек). Поля `radar.sweep` для этого радара не используются, в
+окне вместо секции LibreVNA показывается секция RPH.
+
+### Плата лазеров
+
+Плата подключается по последовательному порту (115 200 бод) и программируется
+кнопкой **Apply Radar**, если в профиле `laser_control.enabled: true`. Программа
+проверяет все значения до отправки первого байта, затем сбрасывает плату и отправляет
+постоянные токи и температуры, а в режиме `variation` — ещё задачу развёртки тока
+LD1. Каждая команда должна получить ответ «состояние 0»; иначе
+`Device rejected command: STATE 0x…`. После закрытия порта развёртка лазера
+продолжается.
+
+| Величина | Пределы | По умолчанию |
+| --- | --- | --- |
+| температура лазеров | 15–40 °C | 24,0 и 24,9 °C |
+| ток лазеров | 15–110 мА | 97,3 и 102,0 мА |
+| развёртка тока LD1 | 15–110 мА, начало меньше конца | 97,3 → 107,0 мА |
+| шаг тока | 0,002–0,5 мА | 0,05 мА |
+| длительность ступени | 20–100 мкс | 50 мкс |
+| пауза между развёртками | 3–10 мс | 10 мс |
+
+При запуске без экрана **Apply Radar** выполняется автоматически через 0,5 с после
+старта, а через 1 с после него — **Start**, даже если **Apply Radar** не удался.
+В веб-интерфейсе **Apply Radar** нет.
+
+### АЦП L-Card E-502
+
+Данные с АЦП читает отдельная программа `build/bin/rph_radar_collector` (собирается
+командой `make rph_radar_collector`, в обычный `make` не входит). Её запускает и
+останавливает процесс сбора; программа живёт, только пока жив её запустивший
+процесс. Библиотеки L-Card `libx502api.so` и `libe502api.so` она загружает при
+запуске, по умолчанию из `~/.local/lib`. Развёртки она передаёт процессу сбора через
+псевдотерминал `rph_radar.tty_path` (в примере `/tmp/ttyADC_data`). Устройство
+выбирается первое найденное. Подробности — в
+[README коллектора](../data_acq_and_processing/rph_radar_collector/README.md).
+
+Параметры коллектора задаются списком `radar.rph_radar.args`:
+
+| Параметр | Что задаёт | По умолчанию |
+| --- | --- | --- |
+| `range:<В>` | диапазон входа ±В: `10`, `5`, `2`, `1`, `0.5` или `0.2` | ±2 В |
+| `internal_ref_hz:<Гц>` | внутренняя опорная частота, она же наибольшая частота АЦП: `2000000` или `1500000` | 2 МГц |
+| `duration_ms:<мс>` | наибольшая длительность развёртки, больше 0 и не больше 60 000 мс; более длинная считается испорченной | 100 мс |
+| `do8_cycle_period:` | период выхода DO8 в словах цифрового вывода, 4–262144, причём НОК(4, n) не больше 262144 (нечётные n больше 65 536 не подходят) | 8 |
+
+Остальные слова в `args` (`profile:phase`, `clock:internal`, `start:di_syn2_rise`,
+`stop:di_syn2_fall`, `sample_clock_hz:max`, `packet_limit:0`, `do1_toggle_per_frame`,
+`do1_pair_subtract_avg`, `do8_freq_ref`) только подтверждают единственный
+поддерживаемый режим; любое другое слово отвергается.
+
+Как коллектор использует цифровые линии АЦП:
+
+| Линия | Что делает коллектор |
+| --- | --- |
+| DI_SYN2 | фронт открывает развёртку, спад закрывает |
+| DI1 | смена уровня отмечает новую ступень |
+| DI2 | делит отсчёты ступени на две группы; значение ступени — среднее разности групп |
+| DI8 | отсчёты с высоким DI8 идут в опорный канал |
+| DO1 | меняется на каждом кадре |
+| DO2 | высокий, пока идёт поток |
+| DO8 | высокий на двух последних словах каждого периода `do8_cycle_period` |
+
+Если ключами управляет коллектор, он переключает их сам между развёртками, ждёт
+`run.settling_ms` (не меньше 1 мс) и открывает новый сеанс АЦП; первая развёртка
+после переключения отбрасывается.
+
+### Плата RFG
+
+Плата RFG шлёт по UDP точки развёртки на адрес `radar.rph_radar.udp_bind`. В примере
+это проводной адрес компьютера `192.168.2.1:5000`: привязка к проводному интерфейсу
+отсекает чужие широковещательные пакеты (например, из Wi-Fi), а любой непонятный пакет
+программа считает ошибкой. В каждой точке три канала; основной сигнал — разность
+каналов 0 и 1 (синхронный детектор), опорный — канал 2.
+
+Требования профиля:
+
+- `laser_control.mode: "variation"` — даже если лазер запускают из другой программы:
+ по его настройкам вычисляется длина развёртки;
+- у обоих ключей одно положение, то есть одна пара антенн: без метки от источника
+ нельзя доказать, что развёртка после переключения свежая.
+
+Длина развёртки — ⌈(конец − начало) / шаг⌉ ступеней в кодах ЦАП тока; при настройках
+по умолчанию 199 ступеней по 50 мкс. Развёртка принимается, только если пришли все
+ступени подряд. Плата теряет несколько процентов точек, такие развёртки
+отбрасываются. Время каждой точки — метка ядра о приёме пакета.
+
+### Фазовая калибровка
+
+Фазовые якоря `radar.rph_radar.phase_calibration` измеряются программой
+`python_app/scripts/rph_radar_calibrate.py`. Перед ней примените настройки лазера
+кнопкой **Apply Radar** и остановите сбор.
+
+```sh
+.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config run_config.json --sweeps 200
+.venv/bin/python -m python_app.scripts.rph_radar_calibrate --config run_config.json --sweeps 200 --apply
+```
+
+Первая команда снимает развёртки и печатает измеренные фазы начала и конца, вторая
+снимает заново и записывает в профиль только `phase0_rad` и `phase1_rad`, не трогая
+остальные байты файла. Частотные якоря `freq0_hz` и `freq1_hz` должны заранее
+описывать известные концы развёртки. `--warmup` задаёт, сколько первых развёрток
+отбросить (по умолчанию 10). Команды лазеру программа не отправляет. Если профиль
+изменился во время съёма, она его не перезаписывает.
+
+## Симулятор
+
+С `driver_mode: "mock"` процесс сбора не трогает железо и моделирует один точечный
+отражатель. Отражатель движется по восьмисекундному циклу: вдоль ряда антенн — вокруг
+его середины (размах — половина ширины ряда плюс 0,25 м, но не меньше 0,75 м), по
+дальности — между 3,25 и 4,25 м, на высоте 0,25 м. S21 каждой пары
+считается по настоящим расстояниям от антенн из профиля; коллекции идут не чаще чем
+раз в 50 мс. Шума, помех, переключения ключей и движения радара симулятор не
+моделирует; скорость радара при работе с ним должна быть нулевой. Предобработке
+по-прежнему нужны наборы; для симулятора подходят нейтральные (глава
+[Калибровка и предобработка](preprocessing.md)).
+
+## GPIO-кнопка
+
+К компьютеру можно подключить кнопку, которая снимает временный референс — то же,
+что **Capture Temporary Reference**. По умолчанию она выключена (раздел
+`control_button` профиля, ни в одном примере не включена). Схема, на которую
+рассчитаны умолчания: кнопка между линией GPIO и землёй, внутренняя подтяжка к
+питанию, нажатие — спад уровня. Дребезг подавляет ядро (`debounce_ms`, по умолчанию
+50 мс). Настройки кнопки читаются при запуске окна; после загрузки другого профиля
+нужно перезапустить программу.
+
+## Тележка
+
+`device_firmware/cart_firmware` — прошивка платы WeAct STM32G431CB, которая заменяет
+пульт тележки: читает джойстик и выдаёт команды движения в формате SBUS. С
+программой радара она не связана. Скорость для поправки на движение радар получает
+от клиента TCP-локатора (глава [Веб-интерфейс, кнопка и локатор](web.md)).
+Протокол пульта — [protocol.md](../device_firmware/cart_firmware/docs/protocol.md).
+
+## Прошивки
+
+| Где | Что | На чём работает |
+| --- | --- | --- |
+| `device_firmware/multi_libreVNA_firmware/combined.vnafw` | образ мастера: обычные стадии и аппаратный триггер | LibreVNA-мастер |
+| `device_firmware/multi_libreVNA_firmware/combined_per_point_switching.vnafw` | образ мастера с переключением стимула | LibreVNA-мастер |
+| `device_firmware/multi_libreVNA_firmware/combined_external_lo_slave.vnafw` | образ ведомого | LibreVNA-ведомые |
+| `device_firmware/multi_libreVNA_firmware/reproduce/` | полное дерево LibreVNA 1.6.5 с доработками и комплект воспроизводимой сборки | ПК разработчика |
+| `device_firmware/switch_controller/` | контроллер ключей для переключения стимула | WeAct STM32G431CB |
+| `device_firmware/cart_firmware/` | пульт тележки | WeAct STM32G431CB |
+
+Образ `.vnafw` заливается в LibreVNA программой LibreVNA-GUI (Device → Firmware
+Update). Платы STM32 прошиваются через PlatformIO и ST-Link: `pio run -t upload`.
+
+> **Внимание.** `top_master.bin` и `combined.vnafw` — единственные копии рабочего
+> битстрима мастера и рабочего образа прошивки, битстрим заново не воспроизводится.
+> Никогда их не перезаписывайте; новые сборки сохраняйте под новыми именами.
+
+Подробно:
+
+- [reproduce/README.md](../device_firmware/multi_libreVNA_firmware/reproduce/README.md) — как
+ устроен комплект сборки, отличия от LibreVNA 1.6.5, что воспроизводится побайтово;
+- [install_windows/README.txt](../device_firmware/multi_libreVNA_firmware/reproduce/install_windows/README.txt)
+ —
+ сборка мастера с переключением стимула на Windows;
+- [per_point_switching.md](../device_firmware/per_point_switching.md) — переключение
+ стимула: раскладка стадий, изменения FPGA и микроконтроллера, порядок ввода в
+ строй;
+- [switch_controller/README.md](../device_firmware/switch_controller/README.md) —
+ контроллер ключей: разводка, отчёт, отладка;
+- [librevna_protocol.md](../device_firmware/librevna_protocol.md) — USB-протокол
+ LibreVNA и форматы кадров между процессами программы.
diff --git a/docs/installation.md b/docs/installation.md
new file mode 100644
index 0000000..3fdbdf1
--- /dev/null
+++ b/docs/installation.md
@@ -0,0 +1,238 @@
+# Установка и запуск
+
+Программа работает в Linux: на рабочей станции с экраном и на Raspberry Pi 5 без
+оператора. Python-часть ставится в виртуальное окружение `.venv` внутри каталога
+проекта, C++-часть собирается командой `make` в `build/bin/`.
+
+## Что нужно
+
+| Что | Зачем |
+| --- | --- |
+| Linux, bash, coreutils, `flock` | запуск через `start.sh` |
+| Python 3.10 или новее с модулем `venv` | программа и окружение |
+| компилятор C++20 (g++), `make` | процессы конвейера |
+| библиотеки `libusb-1.0` и `libatomic.so.1` | USB-доступ к LibreVNA и кольца разделяемой памяти |
+| `libxcb-cursor0`, `libxkbcommon-x11-0` (Debian/Ubuntu) | окно Qt на рабочем столе |
+| видеокарта NVIDIA с драйвером, NVRTC и заголовки CUDA | только для режима 3D SAR |
+| библиотеки L-Card `libx502api.so`, `libe502api.so` | только для радиофотонного радара с АЦП E-502 |
+
+Python-пакеты перечислены в `requirements.txt`:
+
+| Пакет | Для чего |
+| --- | --- |
+| `numpy` | массивы данных |
+| `scipy` | оптимизация в калибровке по объекту |
+| `libusb1` | USB-драйвер LibreVNA |
+| `pyserial` | последовательный порт платы лазеров |
+| `PyQt6` | окно программы (без экрана — тоже, в режиме `offscreen`) |
+| `pyqtgraph` | графики |
+| `fastapi`, `uvicorn[standard]` | веб-интерфейс |
+| `nvidia-cuda-nvrtc`, `nvidia-cuda-runtime` | NVRTC и заголовки CUDA для 3D SAR; ставятся только на x86_64 |
+
+PyQt6 ставится только из готовых сборок (wheel), поэтому pip выбирает самую новую
+версию, которая работает с системной glibc.
+
+## Установка
+
+Из каталога проекта:
+
+```sh
+python3 -m venv .venv
+.venv/bin/python -m pip install --upgrade pip
+.venv/bin/python -m pip install -r requirements.txt
+make # build/bin/data_preprocessor и build/bin/data_processor
+make rph_radar_collector # только для радиофотонного радара с АЦП E-502
+```
+
+`./start.sh` при обычном запуске делает это сам: создаёт `.venv`, если его нет, ставит
+недостающие пакеты, ставит системные библиотеки через менеджер пакетов (apt, dnf, yum,
+pacman, zypper или brew, через `sudo`), правило udev для LibreVNA и собирает C++. Без
+экрана (`--headless`) он ничего не ставит: всё должно быть установлено заранее.
+
+## Запуск
+
+```sh
+./start.sh --profile run_config_examples/run_config_librevna_multi_per_point.example.json
+```
+
+Без `--profile` программа берёт последний использованный профиль, а если его нет —
+корневой `run_config.json` (симулятор). Порядок выбора профиля — в главе
+[Файл конфигурации](configuration.md). Но сам `start.sh` без `--profile` определяет
+модель радара, решает, собирать ли коллектор E-502, и проверяет программы по
+корневому `run_config.json`, а `--producer-only` берёт именно его. Поэтому с профилем
+радиофотонного радара и для `--producer-only` всегда указывайте `--profile`.
+
+| Ключ `start.sh` | Что делает |
+| --- | --- |
+| `--profile ПУТЬ` | профиль для запуска; файл должен существовать. Относительный путь — от каталога проекта |
+| `--headless` | без окна: окно не показывается, через 0,5 с выполняется **Apply Radar**, ещё через 1 с — **Start**; если **Start** не удался, программа завершается с кодом 1 (`Headless auto-start did not bring the pipeline up`). Ничего не устанавливает |
+| `--auto-start` | с окном, но **Start** выполняется сам через 0,5 с после запуска |
+| `--producer-only` | запустить только процесс сбора для профиля, без окна; вывод — в терминал |
+| `--skip-build` | не собирать C++ (наличие программ всё равно проверяется) |
+| `--build-only` | подготовить окружение, собрать и выйти |
+| `--clean-shm` | удалить известные кольца в `/dev/shm` перед запуском (глава [Данные на диске](data.md)) |
+| `-h`, `--help` | справка |
+
+Порядок работы `start.sh`: проверка окружения → определение модели радара по профилю
+→ системные библиотеки и правило udev (не для `--headless`) → Python-пакеты → сборка
+C++ (для радиофотонного радара — и коллектора) → проверка программ в `build/bin/` →
+остановка службы `radar.service`, если она работает → блокировка
+`/tmp/radar_system.lock` → запуск программы. Если блокировку держит другой
+экземпляр, `start.sh` завершается с
+`Another radar instance already holds /tmp/radar_system.lock; aborting.`
+
+Для разработки окно можно запустить и напрямую:
+`.venv/bin/python -m python_app.gui.main`. Тогда нет ни сборки, ни блокировки.
+
+### Первый запуск без железа
+
+1. Запустите `./start.sh` (корневой профиль — симулятор).
+2. Нажмите **Preprocessing…**, затем **Create Neutral S21 Sets**: предобработке
+ нужны наборы калибровки и референса, нейтральные данные не меняют.
+3. Нажмите **Start**. Симулятор показывает точечный отражатель, который движется
+ по сцене; режим обработки меняется списком **Mode**.
+
+Режим 3D SAR без видеокарты NVIDIA показывает только предупреждение.
+
+### Первый запуск с железом
+
+1. Скопируйте нужный профиль из `run_config_examples/` и проверьте в нём серийные
+ номера, порты, линии GPIO и геометрию антенн (глава
+ [Файл конфигурации](configuration.md)).
+2. Запустите `./start.sh --profile <профиль>`. При первом запуске он может спросить
+ пароль `sudo`, чтобы поставить системные библиотеки и правило udev для LibreVNA.
+3. Нажмите **Apply Radar**, снимите калибровку и референс (глава
+ [Калибровка и предобработка](preprocessing.md)) и нажмите **Start**.
+4. Нажмите **Save Config…**: выбор наборов и правки в окне сохраняются только так.
+
+Порядок работы по шагам — в главе [Типовые сценарии](workflow.md).
+
+## Видеокарта для 3D SAR
+
+Ядра 3D SAR компилируются при запуске библиотекой NVRTC под установленную карту.
+`make` ищет `nvrtc.h`, `cuda.h` и `libnvrtc` в пакетах `nvidia-cuda-*` из `.venv` и в
+`/usr/local/cuda`, а `libcuda.so.1` (драйвер) — в системных каталогах. Если чего-то
+нет, `make` пишет `note: CUDA not found, selecting sar3d will show a warning` и
+собирает процессор без 3D SAR. После установки CUDA снова запустите `make`: он
+заметит изменение и пересоберёт нужную часть.
+
+- На x86_64 CUDA 13 ставится из `requirements.txt`. Целевая карта — RTX 5070.
+- NVRTC 13 не компилирует под карты старее поколения sm_75 (например, GTX 1060,
+ sm_61). Для них поставьте NVRTC 12 отдельно от `.venv` и соберите в отдельный
+ каталог:
+
+ ```sh
+ pip install --target ~/.cache/radar_nvrtc12 nvidia-cuda-nvrtc-cu12 nvidia-cuda-runtime-cu12
+ make BUILD_DIR=/tmp/build-nvrtc12 CUDA_ROOTS="$HOME/.cache/radar_nvrtc12/nvidia/cuda_nvrtc $HOME/.cache/radar_nvrtc12/nvidia/cuda_runtime"
+ ```
+
+ Программа запускает процессор только из `build/bin/`, поэтому такая сборка нужна
+ для проверки на старой карте (глава [Разработка](development.md)).
+- На Jetson CUDA берётся из JetPack (`/usr/local/cuda`); pip-пакеты CUDA там не
+ ставятся. PyQt6 на Jetson с glibc 2.35 — версии 6.7.
+
+## Служба без экрана
+
+На машине без оператора программу запускает служба systemd. Установка — один раз,
+от обычного пользователя через `sudo`:
+
+```sh
+sudo bash deploy/install-daemon.sh
+```
+
+Скрипт создаёт `/etc/systemd/system/radar.service`, разрешает пользователю без пароля
+`sudo systemctl stop|start|restart radar.service` (`/etc/sudoers.d/radar`) и включает
+службу при загрузке, но не запускает её. Служба работает от имени этого пользователя
+в каталоге проекта, запускает `start.sh --headless --skip-build`, перезапускается через
+3 с после сбоя, а при остановке ждёт программу до 25 с.
+
+```sh
+sudo systemctl start radar.service # запустить
+systemctl status radar.service # состояние
+journalctl -u radar.service -f # журнал
+sudo systemctl disable --now radar.service && sudo rm /etc/systemd/system/radar.service /etc/sudoers.d/radar && sudo systemctl daemon-reload # удалить
+```
+
+Служба берёт последний использованный профиль. Перед её запуском откройте окно
+программы на этой машине, загрузите профиль с железом, выберите наборы и нажмите
+**Save Config…**. Работа со службой — в разделе «Работа без экрана» главы
+[Веб-интерфейс, кнопка и локатор](web.md).
+
+## Машина развёртывания
+
+Raspberry Pi 5 Model B (Cortex-A76 ×4, 4 ГиБ), Debian 13, Python 3.13. Проект лежит в
+`~/Documents/radar_system`. На машине собираются `.venv` и C++; копировать их с
+рабочей станции нельзя: там другая архитектура (x86_64).
+
+```sh
+python3 -m venv .venv && .venv/bin/python -m pip install -r requirements.txt
+make -j4
+QT_QPA_PLATFORM=offscreen .venv/bin/python -m unittest discover -s python_app/tests
+```
+
+На Pi нужна только часть проекта: `data_acq_and_processing`, `deploy`, `python_app`,
+`run_config_examples`, `Makefile`, `requirements.txt`, `run_config.json`, `start.sh`.
+Каталог `device_firmware/` и документация не нужны.
+
+> **Внимание.** Номер GPIO-чипа 40-контактного разъёма на Pi 5 зависит от версии
+> ядра. После установки и обновления ядра проверьте его: `gpiodetect` и
+> `gpioinfo -c gpiochip0`. Неверный чип в профиле — ключи, которые молча не
+> переключаются.
+
+## Обновление с флешки
+
+На рабочей станции:
+
+```sh
+deploy/pack-usb.sh /media/<пользователь>/<флешка>
+```
+
+Без аргумента скрипт берёт единственную подключённую съёмную флешку. В каталог
+`radar_update/` на ней он кладёт архив `radar_system-<дата коммита>-<хеш>.tar.gz`
+с частью проекта для Pi из **последнего коммита** (незакоммиченные изменения в него не
+попадают) и скрипт `update.sh`.
+
+На машине, от обычного пользователя (не через `sudo`), из каталога, где лежит
+`radar_update`:
+
+```sh
+bash radar_update/update.sh # перенести текущий .venv
+bash radar_update/update.sh --python 3.13 # собрать .venv заново этим Python
+```
+
+Скрипту можно передать путь к архиву: `bash radar_update/update.sh <архив>`; без него
+берётся последний по имени `radar_system-*.tar.gz` рядом со скриптом.
+
+Перед обновлением закройте окно программы, иначе скрипт откажется и покажет, какие
+процессы работают в каталоге. Службу он остановит сам и запустит снова, если она
+работала.
+
+Что делает `update.sh`:
+
+1. проверяет архив и переименовывает текущую версию в `radar_system` — следующий
+ свободный номер; это копия для отката;
+2. разворачивает новую версию в `~/Documents/radar_system` (другой каталог задаёт
+ переменная `RADAR_DIR`);
+3. переносит из прежней версии: `run_config.json`; профили из
+ `run_config_examples/`, изменённые на машине (версия из архива ложится рядом с
+ окончанием `.new`); `python_app/runtime/` без журналов; данные
+ `python_app/data*` и чужие каталоги верхнего уровня — жёсткими ссылками, без
+ копирования и места на диске; `.venv` — копией;
+4. ставит пакеты из `requirements.txt` и собирает C++ (`make all rph_radar_collector`);
+5. записывает имя архива в файл `RELEASE` и запускает службу, если она работала.
+
+Если что-то пошло не так, скрипт печатает команду отката. Вручную:
+
+```sh
+sudo systemctl stop radar.service
+mv ~/Documents/radar_system ~/Documents/radar_system_failed
+mv ~/Documents/radar_system ~/Documents/radar_system
+sudo systemctl start radar.service
+```
+
+Новую версию при откате лучше переименовать, а не удалять: в ней могут быть
+измерения, снятые после обновления.
+
+На свежую машину (без прежней версии) `update.sh` спрашивает
+`Install a fresh one there? [y/N]` и ставит корневой `run_config.json` из архива, то есть
+симулятор: профиль с железом нужно выбрать после установки.
diff --git a/docs/preprocessing.md b/docs/preprocessing.md
new file mode 100644
index 0000000..7673a00
--- /dev/null
+++ b/docs/preprocessing.md
@@ -0,0 +1,300 @@
+# Калибровка и предобработка
+
+Препроцессор (`data_preprocessor`) по очереди применяет к каждой коллекции четыре
+шага и только потом отдаёт её на обработку. Зачем нужен каждый шаг, объяснено в
+главе [Как работает радар](principles.md).
+
+## Цепочка предобработки
+
+Для каждой пары и каждой частотной точки:
+
+| Шаг | Формула | Набор |
+| --- | --- | --- |
+| 1. Калибровка | S21 ← S21 / S21_through | набор калибровки (through) |
+| 2. Вычитание референса | S21 ← S21 − S21_ref / S21_through | набор референса |
+| 3. Режекторный фильтр | S21 ← S21 · m(f) | настройки `preprocess.notch` в профиле |
+| 4. Калибровка по объекту | S21 ← S21 · F | набор калибровки по объекту, если выбран |
+
+Референс делится на through один раз при загрузке, поэтому вычитается уже
+откалиброванный фон. Точки, где |S21_through|² не больше 10⁻¹², на шаге 1 остаются как
+есть, без деления. Окно съёма и номер коллекции не меняются.
+
+У каждого набора должна быть каждая пара сбора, а сетка частот — совпадать с
+развёрткой: то же число точек, а каждая частота отличается от своей не больше чем на
+10⁻⁵ её значения, но допуск не меньше 1 мГц (на 6 ГГц — 60 кГц). Наборы проверяются при запуске
+препроцессора, и неподходящий набор его останавливает. Если это случилось, пока
+программа ждёт готовности процессов, **Start** отказывает с сообщением
+``Failed to start pipeline: RuntimeError: Process `data_preprocessor` exited with code 1.``
+и текстом ошибки. Если позже, в журнале окна появляется отчёт о завершении
+процесса, а программа перезапускает конвейер с нарастающей паузой.
+
+Коллекцию, которая не прошла проверку уже во время
+работы, препроцессор отбрасывает и пишет об этом в свой журнал
+`python_app/runtime/logs/data_preprocessor.err.log`. В окне программы это видно
+только по тому, что изображение перестаёт обновляться.
+
+## Наборы
+
+| Набор | Каталог | Обязателен |
+| --- | --- | --- |
+| S21 Calibration — калибровка (through) | `python_app/data/s21_calibration/` | да |
+| S21 Reference — референс | `python_app/data/s21_reference/` | да |
+| Object Calibration — калибровка по объекту | `python_app/data/s21_object_calibration/` | нет |
+
+Набор — архив NPZ с массивами частот и S21 по парам и JSON-файл рядом с описанием
+(формат — в главе [Данные на диске](data.md)). Наборы лежат в подкаталогах с именем
+`radar_key` — ключа конфигурации радара:
+
+- у LibreVNA ключ складывается из модели, серийных номеров, начала и конца
+ развёртки, числа точек, полосы ПЧ и мощности, а у многоприборной схемы ещё из
+ положений ключей;
+- у радиофотонного радара ключ — хеш выходной полосы, фазовой калибровки, параметров
+ источника, положений ключей и настроек лазера; лазер входит в ключ, если плата
+ лазеров включена в профиле или источник — плата RFG.
+
+Программа показывает только наборы текущего ключа. Если изменить любую из этих
+величин (например, число точек), старые наборы пропадают из списков, а выбор
+сбрасывается с записью `Preprocess selection reset: <старый ключ> -> <новый ключ>; cleared {...}`
+в журнале. Так программа не даёт применить калибровку, снятую при других
+настройках. Если вернуть прежние настройки, наборы снова появятся в списках, но
+выбрать их придётся заново.
+
+Наборы применяются при запуске сбора: программа пересобирает из выбранных наборов
+файлы `python_app/runtime/*_bundle.bin` и заново запускает препроцессор. Запуск — это
+**Start**, а также продолжение сбора после приостановки: после съёма набора,
+временного референса или нейтральных наборов сбор, если он шёл, продолжается уже с
+новыми наборами. Смена выбора в списке во время сбора применится при следующем
+запуске.
+
+Перед запуском программа проверяет наборы и, если что-то не так, отказывается
+запускаться, ничего не остановив. Окно ошибки показывает
+`Failed to start pipeline: <тип ошибки>: <текст>`, где текст — один из этих:
+
+| Условие | Сообщение |
+| --- | --- |
+| не выбраны калибровка или референс | `Select all required preprocess sets in Preprocessing Panel before Start: S21 Calibration, S21 Reference` |
+| файла набора нет для текущего ключа | `Missing set files for <вид>//<имя>` |
+| в наборе нет какой-то пары сбора | `Selected <набор> set does not cover requested run combos` |
+| архив повреждён или содержит не числа | `Corrupted preprocess set <вид>/<ключ>/<имя>: …` |
+| калибровка по объекту не подходит | см. раздел «Калибровка по объекту» |
+
+## Окно Preprocessing Setup
+
+Открывается кнопкой **Preprocessing…**. Окно не модальное: пока оно открыто, главным
+окном можно пользоваться.
+
+Группа **Preprocess Sets**:
+
+| Элемент | Что делает |
+| --- | --- |
+| **Set name** | имя нового набора; по умолчанию `set_001`. Для этого вида набора и этого ключа радара имя должно быть новым |
+| **Create Neutral S21 Sets** | создаёт нейтральные наборы (см. ниже). Кнопка есть только у радиофотонного радара, у многоприборной схемы и у одиночного LibreVNA в режиме `mock` |
+| **Refresh Sets** | перечитывает списки наборов с диска и каталог вариантов радара |
+| **Sweeps per combo (median)** | сколько развёрток снимать на каждую пару, от 1 до 99, по умолчанию 5. В набор идёт медиана по каждой частоте, отдельно по вещественной и мнимой части: при трёх развёртках и больше одиночный сбой развёртки в набор не попадает. Это же число развёрток берут временный референс и калибровка по объекту |
+| **Radar Config Variants** → **Directory**, **Browse…** | каталог с профилями JSON для съёма наборов сразу под несколько настроек развёртки (см. ниже). Под полем — итог просмотра: `Directory: <каталог> \| json=…, valid=…, skipped=…, duplicates=…` (файлов JSON, подходящих вариантов, пропущенных, повторов) или `No radar config directory selected.` |
+| **Capture current combo for all found radar configs** | включает съём по всем вариантам из каталога |
+| **S21 calibration**, **S21 reference** | выбор набора калибровки и референса для следующего **Start** |
+| **Object calibration** | выбор калибровки по объекту; **Off** — не применять |
+| **Create…** | открывает окно калибровки по объекту |
+
+Группа **Capture All Antenna Pairs**:
+
+| Элемент | Что делает |
+| --- | --- |
+| **Active type**, **Progress**, **Current combo** | вид снимаемого набора, сколько пар снято из скольких, какая пара следующая; без съёма — ``, `0 / 0`, после последней пары — `` |
+| **Start S21 Calibration**, **Start S21 Reference** | начинают съём набора |
+| **Capture Current Combo** | снимает следующую пару |
+| **Capture All Remaining** | снимает подряд все оставшиеся пары |
+| **Undo Last Capture** | отменяет последний съём, чтобы переснять пару |
+| **Save Captured Set** | сохраняет набор и выбирает его; доступна, когда сняты все пары |
+| **Abort Sequence** | прерывает съём и отбрасывает снятое |
+
+Ниже — журнал съёма (`S21 Calibration: 1/8 | input=0 output=0` на каждую пару),
+строка состояния (сначала `Ready`) и графики модуля и фазы последней снятой развёртки
+(до первого съёма — `Preview will appear after the first successful capture.`).
+Последняя развёртка рисуется и в главном окне.
+
+## Съём калибровки и референса
+
+1. Выберите профиль и настройки радара. Наборы привязаны к ключу радара, поэтому
+ снимайте их при тех настройках развёртки, с которыми будете работать.
+2. Подготовьте установку. Для калибровки — эталонный тракт для текущей пары; для
+ референса — пустую сцену: всё, что должно остаться в фоне, на месте, целей нет.
+3. Нажмите **Preprocessing…**, задайте **Set name** и
+ **Sweeps per combo (median)**.
+4. Нажмите **Start S21 Calibration** или **Start S21 Reference**. Сбор
+ приостанавливается, и на время съёма прибором управляет окно предобработки.
+5. Снимайте пары кнопкой **Capture Current Combo** или все сразу кнопкой
+ **Capture All Remaining**. Проверяйте каждую развёртку по графикам; неудачную
+ переснимите через **Undo Last Capture**. При ошибке съёма уже снятые пары
+ сохраняются, и съём можно повторить.
+6. Когда сняты все пары, нажмите **Save Captured Set**. Набор сохраняется и
+ выбирается, приостановленный сбор продолжается.
+
+Съём всегда идёт по полной матрице положений ключей, а не только по парам сбора.
+
+- Одиночный LibreVNA: для каждой пары программа выставляет ключи, ждёт время
+ установления (`run.settling_ms`, в окне — **Settling (ms)**) и снимает нужное
+ число развёрток этой пары.
+- Многоприборная схема при калибровке: прибор снимает всю матрицу, а в набор идёт
+ только текущая пара, по одной за нажатие. **Capture All Remaining** здесь
+ недоступна.
+- Многоприборная схема при съёме референса: одно нажатие снимает всю матрицу нужное
+ число раз, и набор сразу готов.
+
+### Варианты радара
+
+Иногда наборы нужны сразу под несколько развёрток — например, под разные полосы
+частот. Для этого в **Directory** указывают каталог с профилями JSON. Профиль
+считается вариантом, если в его `radar.sweep` есть хотя бы одно из полей
+`start_hz`, `stop_hz`, `points`, `if_bandwidth_hz`, `stimulus_power_dbm`; эти поля
+накладываются на текущий профиль, повторы одного ключа пропускаются. С включённой
+**Capture current combo for all found radar configs** каждое нажатие снимает пару во
+всех вариантах подряд, с перенастройкой прибора. **Save Captured Set** сохраняет
+по набору на каждый вариант под одним именем, а картинки развёрток — в
+`python_app/data/preview_png/<вид>/<имя>//i<вход>_o<выход>.png`.
+
+## Временный референс
+
+Кнопка **Capture Temporary Reference** в секции **Data Actions** снимает референс
+одним нажатием, не открывая окно предобработки. То же действие есть в
+веб-интерфейсе и на GPIO-кнопке.
+
+Программа приостанавливает сбор, снимает полную матрицу с текущими настройками
+(медиана **Sweeps per combo (median)** развёрток) и сохраняет её как набор
+`tmp_reference`, заменяя прежний. Затем она выбирает этот набор, очищает историю
+обработки, рисует последнюю развёртку с подписью `Tmp S21 Reference last trace` и
+продолжает сбор. Процессор и локатор при этом не перезапускаются, клиенты локатора
+не отключаются.
+
+Временный референс нужен, когда сцена или положение радара изменились: после
+перестановки, на новом месте, после заметного изменения обстановки.
+
+## Нейтральные наборы
+
+**Create Neutral S21 Sets** сохраняет под именем из **Set name** два набора:
+калибровку, равную 1, и референс, равный 0, для всех пар на текущей сетке частот.
+С ними данные проходят предобработку без изменений. Они нужны, чтобы запустить
+конвейер, пока настоящей калибровки нет: с симулятором, при первой проверке прибора.
+Если набор с таким именем уже есть, программа откажется. После создания наборы
+выбираются, история очищается, а процессор перезапускается: сразу, если сбор шёл,
+иначе при следующем **Start**.
+
+## Режекторный фильтр
+
+Фильтр настраивается только в профиле, в разделе `preprocess.notch`; в окне
+программы его нет.
+
+| Ключ | По умолчанию | Что делает |
+| --- | --- | --- |
+| `enabled` | `false` | включает фильтр |
+| `bands_hz` | `[]` | полосы подавления: список пар `[нижняя, верхняя]` в герцах, верхняя не меньше нижней |
+| `taper_width_hz` | `40e6` | ширина плавного края w, Гц, не меньше 0 |
+| `taper_type` | `"cosine"` | форма края: `"cosine"` — косинусный спад, `"hard"` — без спада |
+
+Внутри полосы [L, H] множитель m(f) равен нулю. При `"cosine"` по краям полосы он
+плавно спадает от 1 до 0 по закону 0,5·(1 + cos(…)) на участках [L − w, L] и
+[H, H + w]. При `"hard"` (или w = 0) зануляется вся полоса [L − w, H + w]. Края
+соседних полос перемножаются.
+
+Во всех профилях-примерах `enabled: true` и `bands_hz: []`, то есть фильтр ничего не
+подавляет. Изменение фильтра применяется при следующем **Start** и сбрасывает
+историю.
+
+## Калибровка по объекту
+
+Калибровка по объекту находит для каждой пары комплексную поправку по частоте так,
+чтобы отклик известного отражателя у всех пар совпадал по фазе. Эталон — ноутбук
+`MIMO_calibration.ipynb`, он хранится вне репозитория. Поправку можно строить по
+нескольким положениям отражателя: они уточняют друг друга.
+
+Что нужно заранее:
+
+- кольцо `rings.preprocessed_tap` в профиле: калибровка снимает данные из него;
+- таблицы геометрии антенн: в калибровку входят только пары, у которых есть
+ геометрия обеих антенн, остальные получают поправку 1;
+- выбранный набор калибровки (through): поправка запоминает его имя и содержимое и
+ годится только вместе с ним;
+- металлический шар известного радиуса (или другой точечный отражатель) и способ
+ измерить положение его центра.
+
+Окно **MIMO Object Calibration** открывается кнопкой **Create…** в окне
+предобработки. Вверху — подсказка о порядке работы
+(`Capture data, then enter the measured reflector centre: …`), внизу — строка
+состояния (сначала `Ready to capture`), а на месте карт фаз до расчёта —
+`Pair phases at the target will appear after calculation.`
+
+| Элемент | Что делает |
+| --- | --- |
+| **Set name** | имя набора поправки |
+| таблица **Target**, **Sweeps**, **X (m)**, **Y (m)**, **Z (m)** | снятые положения отражателя: сколько коллекций снято и измеренный центр |
+| **Capture Data** | снимает данные для нового положения отражателя |
+| **Remove Selected Target** | удаляет выбранное положение |
+| **Sphere radius (m, 0 = point)** | радиус шара, по умолчанию 0,16 м; 0 — точечный отражатель |
+| **Antenna origin height above ground (m)** | высота начала осей антенн над землёй, по умолчанию 0,92 м |
+| **Radar downward tilt (°)** | наклон радара вниз, по умолчанию 10° |
+| **Calculation Settings** | раскрывает остальные параметры расчёта (таблица ниже) |
+| **Calculate and Save** | считает поправку в фоне и сохраняет набор |
+| **Cancel Operation** | прерывает съём или расчёт |
+| **Close** | закрывает окно |
+
+Параметры в **Calculation Settings**:
+
+| Параметр | По умолчанию | Что делает |
+| --- | --- | --- |
+| **Estimation band start (MHz)**, **Estimation band stop (MHz)** | 2000 и 6000 | полоса, по которой оценивается поправка; в ней должно быть не меньше 4 точек |
+| **Echo path gate half-width (m)** | 0,15 | полуширина окна вокруг ожидаемого пути до отражателя, в котором ищется его эхо |
+| **Gate taper width (m)** | 0,03 | ширина плавного края этого окна |
+| **Coarse path search (±m, 0 = off)** | 0,6 | предварительный поиск: на сколько можно сдвинуть отражатель по дальности до самого сильного эха |
+| **Coordinate refinement (±m)** | 0,1 | в каких пределах уточнять координаты отражателя |
+| **Refine coordinates** X, Y, Z | все три | по каким осям уточнять координаты |
+| **Antenna phase model** | `linear` | модель фазы антенны по частоте: `const` — постоянная, `linear` — линейная (задержка), `poly3` — многочлен третьей степени, `raw` — без сглаживания |
+| **Equalize amplitudes** | выключен | выравнивать и амплитуды, а не только фазы |
+
+Порядок:
+
+1. Поставьте отражатель в сцену и измерьте положение его центра в осях сцены: X —
+ вдоль ряда антенн, Y — высота над землёй, Z — вдоль земли от радара.
+2. Нажмите **Capture Data**. Программа приостанавливает текущий сбор и запускает
+ конвейер с этим же профилем, но без калибровки по объекту и по всем парам из
+ таблиц геометрии (многоприборная схема — по полной матрице). Она собирает
+ столько новых коллекций, сколько задано в
+ **Sweeps per combo (median)**, и возвращает прежний режим работы. Предел ожидания —
+ 300 с.
+3. Введите X, Y, Z в строке нового положения.
+4. Повторите для других положений, если нужно. Все положения должны быть сняты при
+ одних и тех же настройках радара, геометрии и предобработке.
+5. Нажмите **Calculate and Save**. Расчёт идёт в фоне. После него окно показывает
+ карты фаз пар в точке отражателя до и после поправки (**Before**, **After**):
+ строки — передающие антенны, столбцы — приёмные, фаза — относительно суммы по
+ всем парам, по шкале ±180°, с разбросом в градусах.
+ **Capture Data** отказывает, если идёт однократный съём или запись на диск
+ (`Finish the single capture or disk recording before taking calibration data`), если
+ нет таблиц геометрии (`Configure Tx/Rx antenna positions before taking calibration data`)
+ или кольца копии
+ (`Object calibration requires rings.preprocessed_tap; enable it in the run profile`).
+6. Если набор подходит к текущим настройкам, он выбирается и применяется при
+ следующем **Start** (`Applies on the next Start.`). Иначе он сохраняется
+ невыбранным с объяснением `Not selected for the current configuration: <причина>`.
+
+Коллекции одного положения усредняются. По каждой частоте поправка раскладывается на
+множители передающих и приёмных антенн; фазы сглаживаются выбранной моделью и
+продолжаются на всю сетку частот. Поправка пары — сопряжённая фаза произведения
+множителей, а с **Equalize amplitudes** — обратное произведение.
+
+Перед каждым **Start** программа проверяет, что поправка снята при тех же настройках
+радара, той же геометрии и с тем же набором калибровки (по имени и содержимому), что
+она и калибровка покрывают все пары сбора, не содержит нулей и имеет ту же сетку
+частот. Сообщения при отказе:
+
+- `Object calibration has no capture provenance; capture it again`;
+- `Object calibration radar configuration differs from the captured state` (а также
+ `antenna geometry`, `through calibration name`, `through calibration content`);
+- `Object calibration and through must cover input=i, output=o`;
+- `Object calibration corrections must be nonzero`;
+- `Object calibration frequency grid differs for input=i, output=o`.
+
+Если изменить таблицу геометрии так, что она перестала совпадать с геометрией
+выбранной поправки, выбор поправки снимается, а работающий с ней сбор
+приостанавливается и продолжается уже без неё.
+
diff --git a/docs/principles.md b/docs/principles.md
new file mode 100644
index 0000000..3da4d6b
--- /dev/null
+++ b/docs/principles.md
@@ -0,0 +1,240 @@
+# Как работает радар
+
+Радар закреплён над землёй с наклоном вниз и смотрит вперёд, на участок земли в
+нескольких метрах от себя. Он излучает радиоволны, принимает отражённые и по ним
+строит изображение этого участка: где лежат или стоят предметы, отражающие волну.
+Незнакомые термины — в [словаре](glossary.md).
+
+## Отражение и задержка
+
+Передающая антенна излучает синусоидальную волну частоты f. Волна идёт в воздухе со
+скоростью c ≈ 3·10⁸ м/с, отражается от предметов и частично доходит до приёмной
+антенны. Длина волны λ = c/f; на частоте 3 ГГц это 10 см. Сильнее всего отражают
+металлические предметы; вообще отражает всё, чьи электрические свойства отличаются
+от воздуха.
+
+Передающая и приёмная антенны стоят в разных местах, поэтому путь волны складывается
+из двух отрезков: от передающей антенны до предмета (R_tx) и от предмета до приёмной
+(R_rx). Принятая волна запаздывает на
+
+τ = (R_tx + R_rx) / c.
+
+Предмет в 4 м от антенн даёт путь около 8 м и задержку около 27 нс.
+
+Для синусоиды задержка проявляется как сдвиг фазы: принятый сигнал
+A·cos(2πf(t − τ)) отстаёт от излучённого на φ = 2πfτ. Фаза известна только с
+точностью до целого числа периодов, поэтому по одной частоте расстояние не
+определить. Его определяют по тому, как фаза меняется с частотой.
+
+## Векторный анализатор цепей и S21
+
+Векторный анализатор цепей (ВАЦ, англ. VNA) выдаёт на один свой порт синусоиду
+заданной частоты и измеряет амплитуду и фазу сигнала, пришедшего на другой порт,
+относительно выданного. Результат измерения — комплексный коэффициент передачи S21
+из порта 1 в порт 2: отношение волны, пришедшей на порт 2, к волне, поданной в порт 1.
+По смыслу он похож на отношение комплексных амплитуд выходного и входного напряжений
+в расчёте цепей переменного тока и совпадает с ним, когда оба порта согласованы:
+модуль |S21| показывает ослабление, аргумент — сдвиг фазы.
+
+В радаре к излучающему порту прибора подключена передающая антенна, к принимающему —
+приёмная. Цепью между портами служит сцена, и S21 показывает, что сцена передаёт от
+одной антенны к другой на данной частоте.
+
+В системе работает LibreVNA — двухпортовый ВАЦ с открытой прошивкой, частотами до
+6 ГГц и подключением по USB. Его основные величины:
+
+| Величина | Что это | У LibreVNA |
+| --- | --- | --- |
+| Модуль S21 в дБ | 20·lg\|S21\|: −20 дБ — амплитуда в 10 раз меньше, −60 дБ — в 1000 раз | — |
+| Мощность излучения, дБм | децибелы относительно 1 мВт: 10·lg(P / 1 мВт); 0 дБм = 1 мВт, −3 дБм ≈ 0,5 мВт, −40 дБм = 0,1 мкВт | от −40 до 0 дБм |
+| Полоса ПЧ (IF bandwidth, IFBW) | полоса фильтра приёмника; на каждой частоте прибор накапливает сигнал около 1/IFBW (при 50 кГц — 20 мкс). Узкая полоса снижает шум, но замедляет измерение | от 6 Гц до 50 кГц |
+| Число частотных точек | сколько частот в одной развёртке | до 4501 |
+
+## Развёртка по частоте и дальность
+
+Развёртка (sweep) — проход прибора по списку частот: S21 измеряется в N частотных
+точках, равномерно расставленных от f_start до f_stop с шагом
+
+Δf = (f_stop − f_start) / (N − 1).
+
+Если в сцене один отражатель с задержкой τ, то S21(f) = a·e^(−j2πfτ): модуль
+постоянен, а фаза линейно убывает с частотой, тем быстрее, чем дальше отражатель.
+Несколько отражателей дают сумму таких слагаемых.
+
+Обратное преобразование Фурье по частоте переводит набор значений S21(f) в функцию
+времени с пиками в моменты τ каждого отражателя — так же, как частотная
+характеристика цепи переводится в импульсную. Время пересчитывается в путь L = c·t.
+Получается А-скан (профиль дальности): амплитуда отражения в зависимости от
+расстояния. Радары, которые так измеряют дальность, называют радарами со ступенчатым
+изменением частоты. Дальность здесь — половина пути, L/2: при близко стоящих антеннах
+это и есть расстояние до отражателя.
+
+Что способен показать А-скан, определяют две величины:
+
+| Величина | Формула | Пример: развёртка 0,5–6 ГГц, 1031 точка, обработка в полосе 1,5–6 ГГц |
+| --- | --- | --- |
+| Разрешение по дальности — на каком расстоянии друг от друга два отражателя ещё видны раздельно | ΔR ≈ c / (2B), B — ширина полосы, по которой строится А-скан | B = 4,5 ГГц, ΔR ≈ 3,3 см; с окном Ханна (ниже) пик вдвое шире |
+| Предельная дальность — дальше неё картина повторяется | R_max = c / (2Δf) | Δf = 5,34 МГц, R_max ≈ 28 м |
+
+Отражение от предмета дальше R_max не исчезает: оно появляется на неверной, меньшей
+дальности. Поэтому число точек выбирают так, чтобы R_max с запасом превышала самую
+дальнюю интересующую дальность. Развёртка 1 МГц–6 ГГц из 201 точки даёт Δf ≈ 30 МГц и
+R_max ≈ 5 м.
+
+У каждого пика А-скана есть боковые лепестки — ложные слабые пики по сторонам.
+При резких краях полосы первый лепесток всего на 13 дБ ниже главного пика, и рядом с
+сильным отражателем видны призраки. Окно Ханна плавно уменьшает вклад крайних частот:
+лепестки опускаются примерно до −31 дБ, а главный пик становится примерно вдвое шире.
+Окно Ханна применяют режимы GPR и 3D SAR и калибровка по объекту; B-scan строится
+без окна.
+
+## Антенны, пары и матрица
+
+Каждое сочетание передающей и приёмной антенны — пара (в программе combo) — даёт
+свою развёртку S21. M передающих и N приёмных антенн дают M × N пар. В программе
+передающая сторона пары называется выходом (output, с него прибор излучает),
+приёмная — входом (input). Пара записывается как `вход:выход` или `in0/out1`.
+Положение каждой антенны задаётся в таблицах геометрии: номер выхода или входа и
+координаты X, Y, Z в метрах.
+
+Пары смотрят на сцену из разных точек. Сложенные с правильными задержками, они дают
+изображение, чёткость которого поперёк сцены определяется размером всей группы
+антенн, а не одной антенны.
+
+Пар больше, чем портов у прибора. Их набирают двумя способами.
+
+ВЧ-ключ (коммутатор H7992) подключает порт прибора к одной из антенн, до четырёх.
+Положение ключа задают два логических сигнала с GPIO компьютера. Программа выставляет
+ключи для очередной пары, ждёт, пока сигнал установится, и снимает развёртку.
+
+Многоприборная схема — три LibreVNA, работающие синхронно. Ведущий прибор (мастер)
+на каждой частоте излучает по очереди с двух портов, два ведомых принимают каждый на
+два порта, и за
+одну развёртку снимаются 2 × 4 = 8 пар. В режиме переключения стимула внутри
+частотной точки на каждом порту мастера стоит ключ, которым управляет контроллер
+STM32: на каждой частоте каждый порт излучает дважды, через оба положения ключа, и
+развёртка даёт 4 × 4 = 16 пар. Все 16 измеряются на одной частоте за доли
+миллисекунды, поэтому движение радара почти не сдвигает их друг относительно друга.
+
+Обход всех пар называется коллекцией (collection). Программа обрабатывает данные
+коллекциями. У коллекции и у каждой её развёртки есть окно съёма — время начала и
+конца измерения по часам компьютера; по нему программа учитывает движение радара.
+
+## Калибровка и вычитание фона
+
+Измеренный S21 описывает не только сцену. Кабели, ключи, тракты антенн и сам прибор
+по-своему меняют амплитуду и фазу на каждой частоте, а антенны «видят» друг друга
+напрямую. Эти составляющие программа убирает наборами, снятыми заранее (подробно — в
+главе [Калибровка и предобработка](preprocessing.md)):
+
+1. Набор калибровки (through) — эталонное измерение тракта. Каждая развёртка
+ делится на него, и частотная характеристика аппаратуры сокращается.
+2. Набор референса — измерение пустой сцены. В нём всё, что не меняется: прямая
+ связь между антеннами, земля, неподвижные предметы. Референс вычитается, и
+ остаётся то, что появилось в сцене после его съёма. Временный референс снимается
+ одной кнопкой прямо во время работы, например после перестановки радара.
+3. Режекторный фильтр (notch) обнуляет заданные полосы частот, например полосу
+ постоянной помехи; края полос сглаживаются.
+4. Калибровка по объекту — поправка для каждой пары, найденная по известному
+ отражателю (металлическому шару) в измеренной точке. Она выравнивает пары по фазе,
+ чтобы в этой точке они складывались синфазно.
+
+## Изображения
+
+### B-scan
+
+B-scan — А-сканы одной пары за последовательные развёртки, поставленные рядом:
+по горизонтали номер развёртки, по вертикали дальность, цветом — амплитуда.
+Неподвижный отражатель рисует горизонтальную линию, приближающийся — наклонную.
+
+### Когерентная обратная проекция
+
+Изображение по всем парам строится обратной проекцией. Для каждой точки P
+изображения программа вычисляет для каждой пары задержку
+τ = (|P − Tx| + |P − Rx|) / c, берёт значение А-скана этой пары в момент τ и
+складывает комплексные значения всех пар. Если в P есть отражатель, слагаемые
+совпадают по фазе и сумма велика. В остальных точках фазы разные, и слагаемые
+взаимно гасятся. Яркость точки — модуль суммы. Сложение с учётом фазы называется
+когерентным.
+
+Режим GPR считает такую сумму в точках плоскости земли перед радаром (сетка 300 × 300:
+поперёк и вдоль дальности), находит на карте яркие пятна — объекты — и отдаёт их
+внешним программам через TCP-локатор.
+
+### 3D SAR
+
+Режим 3D SAR считает ту же сумму в каждой ячейке (вокселе) трёхмерной сетки над
+землёй. К текущей коллекции можно добавить несколько предыдущих: если радар едет,
+каждая снята из другой точки, и вместе они образуют синтезированную апертуру
+(synthetic aperture radar, SAR) — как будто антенн больше и они расставлены шире.
+Объём показывается проекциями максимума: спереди, сбоку, сверху и в наклонном виде.
+
+### Что программа делает перед суммированием
+
+Развёртка длится заметное время, а радар может ехать. Программа знает время каждой
+частотной точки и скорость радара и поправляет фазу так, будто вся коллекция снята в
+один момент. Скорость задают в настройках или передаёт внешний клиент локатора.
+
+Пары различаются усилением: у них разные кабели, антенны и углы обзора. Программа
+выравнивает уровни пар по дальности и нормирует каждую пару на медиану её А-скана.
+Фаза при этом не меняется.
+
+### Фон кадра и CLEAN
+
+В 3D SAR программа сравнивает максимум кадра с фоном — медианой значений рабочей
+части объёма, типичным уровнем помех. Если объектов CLEAN нет, кадр появляется на
+экране, когда максимум превышает фон в 6 раз, и показывается в полную яркость начиная
+с 8,5 раза: кадр без явной цели не выглядит целью.
+
+Сильный отражатель окружён боковыми лепестками, которые закрывают слабые. Алгоритм
+CLEAN находит самую сильную точку, вычитает её отклик из данных и повторяет, пока в
+изображении есть точки в 4,5–5,5 раза выше фона, но не больше 15 раз. Затем каждый
+найденный объект рисуется в своей шкале, и слабый объект виден рядом с сильным;
+найденные объекты видны всегда, а порог фона прячет только остаток. Рамки вокруг групп
+объектов показывают, где
+объект по высоте: очень низко, на земле, над землёй или у края сцены.
+
+## Радиофотонный радар
+
+Второй тип радара в системе формирует СВЧ-сигнал с помощью лазеров (радиофотонный
+радар, RPH). Плата лазеров меняет ток лазера LD1 ступенями — по умолчанию от 97,3 до
+107 мА с шагом 0,05 мА, 50 мкс на ступень, — и вместе с током меняется частота
+сигнала. Принимаются два сигнала: основной, прошедший через сцену, и опорный,
+прошедший постоянную задержку. Их оцифровывает АЦП L-Card E-502 или плата RFG.
+
+Фаза опорного сигнала растёт с частотой линейно, поэтому по ней программа узнаёт
+частоту каждой ступени: две опорные точки «фаза — частота» задаются фазовой
+калибровкой. Затем развёртка пересчитывается на равномерную сетку частот (по
+умолчанию 2,1–5,5 ГГц, 2048 точек), и дальше данные обрабатываются так же, как
+развёртки ВАЦ.
+
+## Координаты
+
+Положения антенн задаются в осях радара: X — вдоль ряда антенн (поперёк направления
+обзора), Y — вверх
+относительно радара, Z — вдоль оси, куда радар смотрит. Начало осей находится на
+высоте h над землёй, и радар наклонён вниз на угол обзора p.
+
+Изображения 3D SAR, рамки объектов и положение отражателя при калибровке по объекту
+задаются в осях сцены: X — та же ось вдоль ряда антенн, Y — высота над землёй
+(Y = 0 — земля), Z — вдоль
+земли от радара.
+
+```text
+ Y
+ │
+ h ●- - - - - - - - - - - - горизонталь
+ │ \ p — угол обзора
+ │ \
+ │ \ ось обзора радара (Z радара)
+ │ \
+ ──┴─────────\────────────────────► Z сцены, вдоль земли
+ земля, Y = 0 ось обзора пересекает землю при Z = h / tg p
+```
+
+Настройки «глубины» — **Min depth (m)** и **Max depth (m)** в GPR, **MIN_DEPTH** и
+**MAX_DEPTH** в 3D SAR —
+ограничивают расстояние вдоль оси обзора радара, а не глубину под землёй. Высота h
+задаётся настройкой **Radar height (m)** в GPR и **BP_GROUND_DEPTH_M** в 3D SAR, угол
+обзора — **Look angle (°)** и **LOOK_ANGLE_DEG**. Формулы перехода между осями — в
+главе [Режимы обработки](processing.md).
diff --git a/docs/processing.md b/docs/processing.md
new file mode 100644
index 0000000..f59f5c9
--- /dev/null
+++ b/docs/processing.md
@@ -0,0 +1,475 @@
+# Режимы обработки
+
+Процессор (`data_processor`) превращает предобработанные коллекции в результат
+выбранного режима. Режимов четыре: pass-through, B-scan, GPR и 3D SAR; в списке
+**Mode** под кнопками управления они называются `pass_through`, `bscan`, `gpr` и `sar3d`.
+Режим и его настройки меняются
+на ходу, сбор при этом не перезапускается. Физический смысл обработки объяснён в
+главе [Как работает радар](principles.md), здесь — что делает каждый режим и каждая
+его настройка.
+
+## Когда применяются настройки
+
+Изменённая настройка обработки сразу уходит процессору. Программа записывает живые
+настройки в `python_app/runtime/processing_live.json` с новым номером ревизии.
+Процессор перечитывает файл примерно раз в 100 мс и пересчитывает новейшую
+сохранённую коллекцию, а в режиме B-scan — все сохранённые.
+
+Если программа не смогла проверить или записать настройки, поля возвращаются к
+последним опубликованным значениям и появляется сообщение
+`Failed to update live processing settings: …` (в веб-интерфейсе —
+`Failed to apply web live settings: …`). Неверную правку таблицы антенн окно
+отклоняет сразу, сообщением **Invalid Antenna Geometry**. Если файл не принял сам процессор,
+он продолжает работать с последними принятыми настройками и пишет предупреждение в
+свой журнал `python_app/runtime/logs/data_processor.err.log`.
+
+GPR и 3D SAR рисуют только результаты текущей ревизии. После изменения программа до
+0,8 с ждёт пересчитанный результат, поэтому старая картинка на экране не мелькает.
+Если процессор не работает, вид остаётся пустым до следующего результата.
+
+Часть настроек только перерисовывает готовый результат: параметры вида pass-through,
+**Subtract mean A-scan** и **Scans to show (stopped)** в B-scan, **Render mode** в GPR,
+**SAR_CLOUD_THRESHOLD**, **Zoom (%)** и **SAR_MARK_ENABLE** в 3D SAR. Они сохраняются в
+профиль, но процессору не передаются.
+
+У страниц GPR и 3D SAR свои поля для одинаковых по смыслу настроек: номера антенн,
+границы дальности, полоса частот, скорость, угол обзора, вычитание среднего,
+выравнивание пар. Процессор получает значения со страницы выбранного режима, а в
+pass-through и B-scan — со страницы GPR. Поэтому переключение между GPR и 3D SAR
+меняет и эти значения: угол обзора по умолчанию — 0° в GPR и 19,5° в 3D SAR.
+
+Границы полосы частот (**Start (MHz)**, **Stop (MHz)**, **F_START MHz**,
+**F_STOP MHz**) не выходят за развёртку прибора, а у радиофотонного радара — за его
+выходную полосу. Значения за пределами программа подрезает к границе; если это
+случилось после изменения развёртки, в журнале появляется
+`Processing frequency limits were clamped to the active radar sweep.`
+
+## Pass-through
+
+Показывает предобработанный S21 каждой пары по частоте: модуль в дБ (20·lg|S21|) и
+фазу в градусах. Обработки нет; режим нужен, чтобы проверить сигнал, калибровку и
+референс. Подпись кривой — `in<вход>/out<выход>`. У одиночного LibreVNA и
+радиофотонного радара в режимах pass-through и B-scan снимается только пара `0:0`,
+если оба ВЧ-ключа в `mock` или у обоих одно положение. Результаты других режимов здесь не
+рисуются: после переключения на pass-through экран ждёт первого результата этого
+режима.
+
+Все параметры этого режима только перерисовывают график.
+
+| Параметр | По умолчанию | Что делает |
+| --- | --- | --- |
+| **Show magnitude** | включён | показывает график модуля |
+| **Show phase** | включён | показывает график фазы |
+| **Unwrap phase** | выключен | убирает скачки фазы на 360° (развёртывает её); шкала фазы подбирается по данным, без развёртки она закреплена на ±180° |
+| **Switch combos** | пусто — все пары | список пар `вход:выход` через запятую, например `0:0,1:0`. При ошибке в записи показываются все пары, а в журнале — `Invalid pass-through switch-combo filter.` |
+| **Fix magnitude Y range** | выключен | закрепляет шкалу модуля; без неё шкала подбирается по данным |
+| **Y min (dB)**, **Y max (dB)** | −100 и 0 | границы закреплённой шкалы, от −240 до 240 дБ; доступны при включённой **Fix magnitude Y range** |
+
+## B-scan
+
+Для каждой пары строит А-скан и укладывает А-сканы последовательных развёрток в
+изображение: по горизонтали номер развёртки (**Sweep #**), по вертикали дальность
+(**Range (m)**). На экране — одна пара; её выбирают в списке **B-scan pair** в
+секции ключей (пары записаны как `вход : выход`), а кнопка **Next Combination**
+переходит к следующей: сначала меняется выход, затем вход, после последней пары снова
+первая. В списке все пары, которые есть в истории, независимо от текущего списка пар
+сбора. Список и кнопка доступны только в режиме B-scan, кнопка — когда пар хотя бы
+две.
+
+Как строится А-скан:
+
+1. Из развёртки берутся точки в полосе от **Start (MHz)** до **Stop (MHz)**. Если их
+ меньше двух, профиля нет, и развёртка в изображение не попадает.
+2. Обратное преобразование Фурье без окна.
+3. Отсчёт с временем t соответствует пути c·t (c = 299 792 458 м/с). Дальность
+ отсчитывается от **Cut (m)**: d = (c·t − 2·Cut) / 2, показываются отсчёты с
+ 0 ≤ d ≤ **Max depth (m)**.
+4. Значение — модуль, вещественная часть или фаза отсчёта (**Axis**), умноженное на
+ d в степени **Gain**.
+
+| Параметр | По умолчанию | Пределы | Что делает |
+| --- | --- | --- | --- |
+| **Axis** | `abs` | `abs`, `real`, `phase` | что рисовать: модуль, вещественную часть или фазу (в радианах). Для `abs` — шкала от тёмного к светлому, для остальных — сине-бело-красная, симметричная относительно нуля |
+| **Cut (m)** | 0,824 | 0–2 м | дальность, с которой начинается изображение; всё ближе отрезается, а ось дальности начинается с нуля в этой точке |
+| **Max depth (m)** | 1,0 | 0,1–20 м | сколько метров дальности показывать после **Cut (m)** |
+| **Gain** | 1,0 | 0–3 | усиление с дальностью: значение умножается на d^Gain, чтобы далёкие слабые отражения были видны рядом с близкими |
+| **Start (MHz)**, **Stop (MHz)** | 100 и 8800, подрезаются по развёртке | в пределах развёртки | полоса частот, по которой строится А-скан |
+| **Scans to show (stopped)** | 50 | 1–1000 | сколько последних развёрток показывать и пересчитывать, когда сбор остановлен |
+| **Subtract mean A-scan** | выключен | — | вычитает из каждого столбца средний по показанным развёрткам А-скан: неподвижные отражения (горизонтальные полосы) пропадают, остаётся то, что меняется |
+
+Во время сбора изображение показывает последние развёртки в пределах истории: не
+больше 50 и не больше ёмкости колец `preprocessed` и `results`. Если истории меньше,
+чем задано, в журнале появляется предупреждение
+`B-scan is set to show W sweeps but only R are retained; the image shows what history there is.`
+Если ось дальности изменилась (другие **Cut (m)**, **Max depth (m)** или полоса),
+изображение начинается заново.
+
+Когда сбор остановлен, изменение настройки B-scan пересчитывает всё изображение:
+через 0,3 с после последнего изменения программа заново отправляет процессору
+сохранённые развёртки (до **Scans to show (stopped)** штук) и собирает картинку
+из ответов. Так одно изображение никогда не смешивает разные настройки. Пересчёт
+возможен, только если конвейер в этом сеансе запускала эта же программа и процессор
+ещё работает. Развёртки, которые процессор не вернул, в картинку не попадают, и в
+журнале появляется `B-scan replay recovered R of S re-sent sweeps; the image leaves the others out.`
+Если не вернулась ни одна, остаётся прежнее изображение из истории.
+
+Если хотя бы один ВЧ-ключ работает с настоящим железом (`driver_mode: "native"`),
+B-scan требует ровно одну пару сбора:
+`B-scan with native switches requires exactly one run combo (now N)`.
+
+## GPR
+
+Строит изображение плоскости земли перед радаром когерентной обратной проекцией по
+всем выбранным парам и находит на нём объекты. Объекты отображаются на картинке и
+уходят клиентам TCP-локатора.
+
+Порядок обработки:
+
+1. Выбор антенн: номера из **Input positions** и **Output positions**, у которых есть
+ строка в таблице геометрии. Пустое поле — все антенны с геометрией.
+2. Вычитание среднего предыдущих коллекций и поправка на движение (раздел «Общие
+ шаги GPR и 3D SAR»).
+3. А-скан каждой пары в полосе **Start (MHz)**–**Stop (MHz)** с окном Ханна.
+4. Выравнивание пар по дальности (**PAIR_DEPTH_EQUALIZE**) и нормировка каждой пары
+ на медиану её А-скана в пределах **Min depth (m)**–**Max depth (m)**; нормировка
+ выполняется всегда.
+5. Сетка 300 × 300 точек на плоскости земли: поперёк — от крайних выбранных антенн
+ с запасом 2 м в обе стороны, вдоль — дальность от 0,1 м до **Max depth (m)**.
+ Положение плоскости земли относительно радара задают **Radar height (m)** и
+ **Look angle (°)**.
+6. В каждой точке — сумма вкладов всех пар с весом компенсации дальности (степень
+ **Range compensation power**, вес не больше 5). Яркость — модуль суммы; точки ближе
+ **Min depth (m)** остаются нулевыми.
+7. Карта сглаживается и нормируется к максимуму, затем на ней ищутся объекты.
+
+Поиск объектов. Программа берёт самый яркий пик и выделяет вокруг него связную
+область ярче 0,75 пика. Область меньше 10 см² отбрасывается. Положение объекта —
+взвешенный центр яркой части. Затем окрестность объекта гасится, и поиск
+повторяется. Он останавливается, когда очередной пик слабее **Object threshold fraction**
+от максимума карты, и не идёт дальше 10 шагов. Для каждого объекта считается оценка:
+
+- `peak` — яркость пика;
+- `combined` — 0,45 × яркость + 0,25 × когерентность (насколько вклады пар совпадают
+ по фазе) + 0,20 × превышение над местным фоном + 0,10 × контраст (отношение к
+ фону). Каждое слагаемое приведено к диапазону 0–1.
+
+Объект считается боковым лепестком более сильного, если он не ярче 0,85 его пика,
+сдвинут поперёк не меньше чем на 0,35 м, по дальности — не больше чем на 0,70 м, а
+среднеквадратичное по парам расхождение путей волны до обоих объектов не больше 0,2 м.
+С включённой **Remove sidelobe objects** такие объекты удаляются из списка и
+стираются с карты.
+
+Какие объекты показываются и уходят локатору:
+
+1. Только внутри окна **Visible X min (m)**–**Visible X max (m)** и
+ **Visible Z min (m)**–**Visible Z max (m)**; то же окно задаёт границы графика.
+2. Отбор по числу: если **Max detected objects** (N) и **Draw top M objects** (M)
+ больше нуля, то при числе объектов больше N не показывается ни один (сцена считается
+ засорённой), иначе показываются M лучших по оценке.
+3. Фильтр приближения: объект показывается, только если он виден в
+ **Approach min frames** коллекциях подряд и смещается так, как должен смещаться
+ неподвижный предмет при движении радара к нему: между соседними коллекциями не
+ больше 2 с, сдвиг поперёк не больше 0,45 м, сдвиг по дальности отличается от
+ ожидаемого (скорость × cos угла обзора × время) не больше чем на 0,12 м или на
+ 0,85 ожидаемого, смотря что больше. При значении 1 фильтр выключен.
+
+| Параметр | По умолчанию | Пределы | Что делает |
+| --- | --- | --- | --- |
+| **Tx geometry**, **Rx geometry** | из профиля | — | таблицы положений антенн, см. [Окно программы](desktop.md) |
+| **Input positions** | номера с геометрией, которые есть в парах сбора (если таких нет — все с геометрией) | номера через запятую | приёмные антенны в обработке; пустое поле — все с геометрией |
+| **Output positions** | так же для выходов | номера через запятую | передающие антенны в обработке; пустое поле — все с геометрией |
+| **Min depth (m)** | 2,0 | 0–50 м | ближняя граница дальности изображения и нормировки. Если она не меньше **Max depth (m)**, изображение пустое |
+| **Max depth (m)** | 14,0 | 0,1–50 м | дальняя граница сетки и изображения |
+| **Range compensation power** | 0,1 | 0–5 | степень компенсации ослабления с расстоянием: вклад умножается на ((R_tx·R_rx) / (R_tx,оп·R_rx,оп))^p, где R_tx,оп и R_rx,оп — расстояния от антенн пары до опорной точки на земле под серединой пары, на дальности 5 м; вес не больше 5. 0 — без компенсации |
+| **Object threshold fraction** | 0,7 | 0–1 | поиск объектов останавливается на пике слабее этой доли максимума |
+| **Score mode** | `combined` | `peak`, `combined` | как считать оценку объекта (см. выше) |
+| **Motion mode** | `int_minus` | `int_minus`, `int_focus` | поправка на движение: полная или только расфокусирующая часть, без сдвига по дальности |
+| **Look angle (°)** | 0,0 | от −90 до 90° | наклон радара вниз: задаёт плоскость земли, поправку на движение и фильтр приближения. При ±90° плоскость не определена (`Ground BP is undefined at a look angle of 90 degrees`) |
+| **Direction sign** | 1 | −1 или 1 | знак поправки фазы на движение. Подсказка в программе: +1 — более поздние частоты дальше. В 3D SAR не используется |
+| **Ignore socket speed** | выключен | — | включена — скорость берётся из поля **Speed (m/s)**; выключена — из клиента локатора, а поле подставляется, пока клиент 5 с не присылал скорость |
+| **Speed (m/s)** | 0,0 | от −100 до 100 м/с | скорость радара вдоль земли, положительная — к сцене. 0 — поправки нет. Редактируется при включённой **Ignore socket speed** |
+| **Render mode** | `heatmap` | `heatmap`, `objects_only` | вид: карта с объектами или только объекты (без пересчёта) |
+| **Max detected objects** | 5 | 0–10000 | N в отборе по числу; 0 — без отбора |
+| **Draw top M objects** | 2 | 0–10000 | M в отборе по числу; 0 — без отбора |
+| **Approach min frames** | 3 | 1–100 | длина цепочки коллекций для фильтра приближения; 1 — фильтр выключен |
+| **Start (MHz)**, **Stop (MHz)** | 3000 и 6000 | в пределах развёртки | полоса частот А-сканов |
+| **Radar height (m)** | 1,25 | 0–50 м | высота начала осей радара над землёй |
+| **Visible X min (m)**, **Visible X max (m)** | крайние антенны ± 2 м | от −100 до 100 м | окно объектов и графика поперёк; перепутанные min и max программа меняет местами, ширина не меньше 0,1 м |
+| **Visible Z min (m)**, **Visible Z max (m)** | 0 и 14 | 0–100 м | окно объектов и графика по дальности; так же не уже 0,1 м |
+| **Subtract mean of previous collections** | включён | — | см. «Общие шаги GPR и 3D SAR» |
+| **Mean count** | 10 | 0–10000 | сколько предыдущих коллекций усреднять; 0 — не вычитать |
+| **PAIR_DEPTH_EQUALIZE** | включён | — | выравнивание уровней пар по дальности |
+| **Remove sidelobe objects** | включён | — | удалять объекты, похожие на боковые лепестки |
+
+Изменение **Max detected objects**, **Draw top M objects** и окна видимости
+передаётся процессору, только пока выбран режим GPR.
+
+Изображение (**GPR coherent BP — ground plane**): по горизонтали **X (m)**, по
+вертикали **Range (m)**. В режиме `heatmap` — цветная карта и объекты белыми ромбами
+с оценкой (`0.83`); в режиме `objects_only` — только объекты красными кругами с
+подписью `0.83 | x=1.2 | z=5.4`, а к заголовку добавляется `Objects Only`. Передающие
+антенны показаны красными треугольниками, приёмные —
+синими; они видны, если **Visible Z min (m)** не больше нуля (тогда нижний край
+графика немного опускается ниже нуля).
+
+GPR требует хотя бы двух разных пар сбора и таблиц геометрии, а в парах сбора должны
+быть все сочетания выбранных входов и выходов: для входов 0, 1 и выходов 0, 1 — пары
+`0:0`, `0:1`, `1:0`, `1:1`. Те же требования и сообщения — у 3D SAR. Сообщения при
+отказе:
+`GPR requires at least 2 distinct run combos (now N)`,
+`GPR requires non-empty Tx/Rx geometry in run_config`,
+`GPR input positions are missing from geometry config: [...]`,
+`GPR output positions are missing from geometry config: [...]`,
+`GPR run combos do not cover selected input/output positions: [...]`.
+Трассы коллекции должны идти в порядке списка пар сбора, иначе процессор отбрасывает
+коллекцию с ошибкой в своём журнале.
+
+На рабочей станции с процессором Intel i7-8700 кадр GPR из 16 пар по 1031 точке
+считается примерно за 0,13 с.
+
+## 3D SAR
+
+Считает трёхмерное изображение объёма над землёй перед радаром на видеокарте NVIDIA
+(CUDA). Алгоритм и имена настроек взяты из исследовательского ноутбука
+`Multi_depthnorm_clean.ipynb` (ячейки 1 и 5); ноутбук хранится вне репозитория.
+
+Порядок обработки:
+
+1. Выбор антенн (**INPUT_IDX**, **OUTPUT_IDX**), вычитание среднего предыдущих
+ коллекций и поправка на движение (раздел «Общие шаги GPR и 3D SAR»).
+2. А-скан каждой пары в полосе **F_START MHz**–**F_STOP MHz** с окном Ханна; у всех
+ пар коллекции сетка частот должна совпадать.
+3. Выравнивание пар (**PAIR_DEPTH_EQUALIZE**) и нормировка на медиану
+ (**SAR_NORMALIZE_EACH_FRAME**).
+4. Апертура: текущая коллекция и до **SAR_FRAME_RADIUS** предыдущих. Антенны
+ предыдущих коллекций сдвигаются на путь, который радар проехал со скоростью
+ **SPEED_M_S**; более старые коллекции входят с меньшим весом (от 1 у текущей до 0,8 у
+ самой старой). Коллекции, которые не удалось обработать, пропускаются.
+5. Сетка вокселей с шагами **SAR_VOXEL_M X**, **SAR_VOXEL_M Y**, **SAR_VOXEL_M Z** в осях
+ сцены: поперёк — от
+ крайних выбранных антенн с запасом 2 м, по высоте — **SAR_Y_RANGE_M min**–**SAR_Y_RANGE_M max**,
+ вдоль земли — все точки, расстояние которых по оси обзора лежит в пределах
+ **MIN_DEPTH**–**MAX_DEPTH**. **SAR_X_SIZE_M** и **SAR_Z_SIZE_M** больше нуля
+ меняют ширину и длину сетки относительно того же центра.
+6. Обратная проекция в каждом вокселе с компенсацией дальности
+ (**SAR_USE_COMPENSATION**, степень **COMP_RANGE_POWER**, вес не больше 5).
+ Воксели вне **MIN_DEPTH**–**MAX_DEPTH** по оси обзора остаются нулевыми.
+7. Сглаживание по Z и X (гауссово, 1,5 вокселя) и поиск максимума на опорных
+ плоскостях — тех, что не ближе 0,3 м к верхней и нижней границе высоты.
+8. Фон кадра и CLEAN (см. ниже) и проекции.
+
+Объём при настройках по умолчанию и антеннах примера с переключением стимула — около
+10,6 млн вокселей (232 × 126 × 364). Объём работы растёт обратно пропорционально
+произведению трёх шагов вокселя: если уменьшить все три вдвое, работы станет в восемь
+раз больше.
+
+### Фон кадра (BG_GATE_ENABLE)
+
+Фон — медиана объёма по рабочим вокселям (ненулевым, на опорных плоскостях). Отношение
+максимума кадра к фону показывает, насколько самый сильный отклик выделяется над
+типичным уровнем помех. Пороги 6 и 8,5 — постоянные ноутбука.
+
+Если объектов CLEAN нет, **BG_GATE_ENABLE** управляет яркостью всего кадра: при
+отношении меньше 6 кадр не показывается, при 6 его максимум появляется на уровне
+**SAR_CLOUD_THRESHOLD**, с 8,5 кадр показывается в полную яркость, а между 6 и 8,5
+яркость растёт линейно.
+
+Если CLEAN нашёл объекты, они видны всегда, каждый в своей шкале, при любом отношении
+кадра к фону. **BG_GATE_ENABLE** тогда прячет только остаток после вычитания, если его
+максимум меньше 6 фонов.
+
+### CLEAN (SAR_CLEAN_ENABLE)
+
+CLEAN разделяет сцену на объекты. На каждом шаге он строит изображение остатка на
+отдельной сетке поиска (4 × 10 × 4 см по X, Y и Z, на всю область сцены), находит
+точку, которая сильнее всего превышает порог, и вычитает её отклик из А-сканов всех
+пар. Порог для вокселя — меньшее из двух: 5,5 медианы положительных вокселей
+изображения и 4,5 медианы на той же дальности (по кольцам дальности шириной 0,25 м).
+Вычитание идёт, пока ни один воксель не превышает порог, но не больше 15 шагов.
+Шаги, точки которых лежат не дальше 0,35 м по X, 0,50 м по Y и 0,40 м по Z от первой
+точки группы, объединяются в один объект. Затем каждый объект строится отдельно и
+рисуется в своей шкале от 0 до 1 поверх остатка: слабый объект виден рядом с
+сильным. Уровень объекта — отношение его пика к фону кадра.
+
+CLEAN работает только на апертуре из одного кадра: всегда при
+**SAR_FRAME_RADIUS** = 0, а при большем значении — пока пригодных предыдущих
+коллекций нет (сразу после **Start**, после очистки истории). Иначе статус сообщает,
+что CLEAN пропущен.
+
+Сетка поиска CLEAN охватывает всю область сцены, даже если **SAR_X_SIZE_M** и
+**SAR_Z_SIZE_M** сужают сетку кадра: отражатель сразу за краем кадра вычитается и не
+оставляет внутри кадра боковой лепесток, похожий на объект.
+
+### Рамки (SAR_MARK_ENABLE)
+
+С **SAR_MARK_ENABLE** объекты CLEAN, которые не дальше 0,8 м друг от друга поперёк и
+0,5 м вдоль земли (в том числе цепочкой), объединяются в группу, и вокруг группы рисуется
+рамка. Центр группы — средняя высота её объектов, взвешенная по уровню (учитываются
+объекты не слабее 0,4 сильнейшего), верх — верхняя граница их откликов. Классы
+проверяются по порядку строк таблицы, рамка получает первый подходящий:
+
+| Цвет | Класс | Условие |
+| --- | --- | --- |
+| серый пунктир | at the boundary — у края сцены | сильнейший объект группы у края: по оси обзора не дальше 0,25 м от **MAX_DEPTH** или 0,10 м от **MIN_DEPTH**, поперёк не дальше 0,2 м от края сетки или по высоте не дальше 0,4 м от её верха. Отклик может частично лежать за сценой, класс пока не определить |
+| красный | very low — очень низко (как мина) | центр не выше 0,15 м и верх не выше 0,45 м |
+| оранжевый | on the ground — на земле | центр не выше 0,35 м |
+| синий | above the ground — над землёй | остальные |
+
+Легенда рамок — строкой под видами; она видна, когда включены и
+**SAR_MARK_ENABLE**, и **SAR_CLEAN_ENABLE**. Без рамок объекты пронумерованы и
+подписаны уровнем в фонах: `1: 12 bg`.
+
+### Виды
+
+Четыре вида: **Front (X-Y)**, **Side (Z-Y)**, **Top (X-Z)** — проекции максимума
+объёма спереди, сбоку и сверху — и **3D Projection**, наклонный вид. На видах с осью
+высоты пунктиром показана земля (Y = 0), на трёх плоских видах — положения выбранных
+антенн. Пустой квадрат отмечает максимум; его сторона — наибольшая из ширин отклика
+на половине максимума вдоль осей вида (не меньше одного вокселя). Это размер
+отклика, а не размер предмета. С объектами CLEAN и при пустом кадре квадрата нет.
+
+**SAR_CLOUD_THRESHOLD** скрывает пиксели слабее заданной доли максимума, не меняя
+шкалу. **Zoom (%)** от 100 до 1000 уменьшает окно всех четырёх видов и плавно сдвигает
+его центр к самому яркому отклику; при 100% видна вся сцена.
+
+Строка над видами показывает состояние:
+
+| Текст | Когда |
+| --- | --- |
+| `SAR3D: waiting for measurements` | результатов ещё не было |
+| `SAR3D: waiting for projection data` | результат без изображений |
+| `SAR3D: ready — CUDA …` | изображение посчитано; разбор строки — ниже |
+| `SAR3D: no signal — No signal inside the selected depth range` | в пределах **MIN_DEPTH**–**MAX_DEPTH** нет сигнала |
+| `SAR3D: error — …` | ошибка настроек, данных или расчёта; текст ошибки |
+| `SAR3D: warning — …` | нет подходящей видеокарты или программа собрана без CUDA |
+
+Строка `ready` складывается из частей через `|`, например:
+
+```text
+SAR3D: ready — CUDA | peak 7.2 backgrounds (appears from 6, full scale from 8.5): scale ×0.83 | max X +0.12, Y +0.30, Z 4.85 m | level 0.0412 | zoom 100%
+```
+
+| Часть | Что значит |
+| --- | --- |
+| `CUDA`, `CUDA; CLEAN: …` | расчёт на видеокарте и итог CLEAN: `CLEAN: K objects, each in its own scale (S subtractions, residual maximum R backgrounds)`, `CLEAN: no objects (…)` или `CLEAN skipped: it needs a one-frame aperture, this one has N frames` |
+| `peak N backgrounds` | отношение максимума кадра к фону; без объектов CLEAN дальше — состояние порога: `below the appearance level, frame empty`, `scale ×K` (кадр показан с множителем яркости K) или `full scale` |
+| `boxes: …` | число рамок каждого класса, например `boxes: 1 very low, 0 on the ground, 2 above the ground` (`at the boundary` — только если такие есть) |
+| `max X …, Y …, Z … m` | положение максимума в осях сцены; у X и Y указан знак |
+| `level …` | уровень, на который нормированы виды: максимум объёма до нормировки |
+| `zoom N%` | масштаб из **Zoom (%)** |
+
+### Настройки 3D SAR
+
+| Параметр | По умолчанию | Пределы | Что делает |
+| --- | --- | --- | --- |
+| **INPUT_IDX**, **OUTPUT_IDX** | номера с геометрией, которые есть в парах сбора (если таких нет — все с геометрией) | номера через запятую | приёмные и передающие антенны в обработке; пустое поле — все с геометрией |
+| **F_START MHz**, **F_STOP MHz** | 1500 и 6000 | в пределах развёртки | полоса частот А-сканов |
+| **MIN_DEPTH**, **MAX_DEPTH** | 2,0 и 8,0 | 0–50 и 0,1–50 м | границы расстояния по оси обзора: задают длину сцены вдоль земли и обнуляют воксели вне себя |
+| **SAR_VOXEL_M X**, **SAR_VOXEL_M Y**, **SAR_VOXEL_M Z** | 0,025, 0,02, 0,02 | 0,01–0,1 м | шаги сетки. Сглаживание задано в вокселях, поэтому от шагов зависит и оно, а с ним фон кадра и изображения объектов CLEAN; сетка поиска CLEAN от них не зависит |
+| **LOOK_ANGLE_DEG** | 19,5 | от −90 до 90° | наклон радара вниз: оси сцены, поправка на движение |
+| **Ignore socket speed** | выключен | — | как в GPR |
+| **SPEED_M_S** | 0,0 | от −100 до 100 м/с | скорость радара вдоль земли для поправки на движение и сдвига апертуры |
+| **SAR_FRAME_RADIUS** | 0 | 0–24 | сколько предыдущих коллекций добавлять в апертуру. 0 — только текущая (нужно разнесение антенн по высоте); больше нуля — нужна точная скорость |
+| **BP_GROUND_DEPTH_M** | 1,25 | 0–10 м | высота начала осей радара (строки Y = 0 таблиц антенн) над землёй |
+| **COMP_RANGE_POWER** | 0,3 | 0–2 | степень компенсации ослабления с расстоянием |
+| **SAR_USE_COMPENSATION** | включён | — | включает компенсацию дальности |
+| **SAR_NORMALIZE_EACH_FRAME** | включён | — | нормирует каждую пару на медиану её А-скана в пределах **MIN_DEPTH**–**MAX_DEPTH** |
+| **PAIR_DEPTH_EQUALIZE** | включён | — | выравнивание уровней пар по дальности |
+| **Subtract mean of previous collections**, **Mean count** | включён, 10 | 0–10000 | вычитание среднего предыдущих коллекций |
+| **BG_GATE_ENABLE** | включён | — | фон кадра (см. выше) |
+| **SAR_CLEAN_ENABLE** | включён | — | CLEAN (см. выше) |
+| **SAR_X_SIZE_M (0 = auto)** | 0 | 0–100 м | ширина сетки кадра поперёк; 0 — от антенн с запасом 2 м |
+| **SAR_Y_RANGE_M min**, **SAR_Y_RANGE_M max** | −0,5 и 2,0 | от −50 до 50 м | границы высоты; max должно быть больше min |
+| **SAR_Z_SIZE_M (0 = auto)** | 0 | 0–100 м | длина сетки кадра вдоль земли; 0 — по MIN_DEPTH–MAX_DEPTH |
+| **SAR_CLOUD_THRESHOLD** | 0,65 | 0–1 | только вид: скрыть пиксели слабее этой доли |
+| **Zoom (%)** | 100 | 100–1000 | только вид: приближение к самому яркому отклику |
+| **SAR_MARK_ENABLE** | включён | — | только вид: рамки вместо номеров |
+
+Время расчёта на GTX 1060 при сцене по умолчанию: около 0,1–0,2 с на кадр, фон кадра
+добавляет около 0,1 с, CLEAN — ещё 0,4–0,5 с в зависимости от числа шагов.
+
+Результат 3D SAR должен поместиться в слот кольца `results`. Если не помещается,
+статус сообщает, сколько нужно места, и советует увеличить шаги вокселя, сузить
+дальность или увеличить `rings.results.slot_size_bytes`.
+
+### Видеокарта
+
+3D SAR нужна видеокарта NVIDIA с драйвером. Ядра расчёта компилируются при запуске
+библиотекой NVRTC под установленную карту. Если при сборке не нашлись заголовки и
+библиотеки CUDA, программа собирается без 3D SAR, и в этом режиме над видами висит
+предупреждение
+`SAR3D requires an NVIDIA GPU with CUDA; this program was built without nvrtc.h, cuda.h, libnvrtc or libcuda. Install CUDA and rebuild.`
+Для карты старее, чем умеет установленная NVRTC, предупреждение советует собрать
+программу с NVRTC 12 (как это сделать — в главе [Разработка](development.md)).
+Остальные режимы видеокарты не требуют.
+
+## Общие шаги GPR и 3D SAR
+
+### Вычитание среднего предыдущих коллекций
+
+Из S21 каждой пары вычитается
+комплексное среднее этой пары за до **Mean count** предыдущих коллекций. Остаётся то,
+что изменилось: неподвижные отражения и прямая связь антенн гасятся, движущиеся
+относительно радара предметы остаются. Процессор хранит столько коллекций, сколько
+нужно для среднего и апертуры, независимо от ёмкости колец.
+
+### Поправка на движение
+
+Время каждой частотной точки программа берёт из окна съёма
+развёртки: точки идут равномерно от начала окна до конца, а в режиме переключения
+стимула стадии одной частоты идут по очереди в порядке номеров выходов. Фаза каждой
+точки поворачивается на 4π·f·v·cos(p)·t / c (v — скорость, p — угол обзора, t —
+время точки относительно середины коллекции; в GPR ещё со знаком
+**Direction sign**), и вся коллекция приводится к одному моменту. При нулевой
+скорости поправки нет. В GPR с **Motion mode** = `int_focus` из поправки убирается
+её постоянная и линейная по частоте часть — чистый сдвиг по дальности. Скорость берётся из
+клиента локатора,
+если он присылал её в последние 5 с и флажок **Ignore socket speed** выключен,
+иначе — из поля скорости.
+
+### Выравнивание пар по дальности (PAIR_DEPTH_EQUALIZE)
+
+Для каждой пары считается
+плавный уровень А-скана по дальности: медиана в окне 1,5 м по блокам 0,10 м. Каждая
+пара умножается на положительный коэффициент, зависящий от дальности, так что её
+уровень становится средним геометрическим уровней всех пар. Различия между парами
+уходят, общий спад с дальностью и фаза сохраняются. Нужно хотя бы две пары.
+
+### Нормировка на медиану
+
+Каждая пара делится на медиану модуля своего А-скана в
+пределах границ дальности. В GPR выполняется всегда, в 3D SAR — с
+**SAR_NORMALIZE_EACH_FRAME**.
+
+### Компенсация дальности
+
+Отражение слабеет с расстоянием примерно как 1/(R_tx·R_rx). Вклад пары в точку
+умножается на ((R_tx·R_rx) / (R_tx,оп·R_rx,оп))^p, где R_tx,оп и R_rx,оп — расстояния от
+антенн пары до опорной точки на земле под серединой пары, на дальности 5 м; вес
+ограничен пятью.
+
+## Геометрия
+
+Антенны задаются в осях радара (X, Y, Z), изображения 3D SAR — в осях сцены (X, Y —
+высота над землёй, Z — вдоль земли); см. рисунок в главе
+[Как работает радар](principles.md). Ось X у обеих систем общая. Переход из осей
+сцены в оси радара при высоте h и угле обзора p:
+
+Y_радар = (Y − h)·cos p + Z·sin p,
+Z_радар = −(Y − h)·sin p + Z·cos p.
+
+Плоскость земли (Y = 0) в осях радара: Y_радар = (−h + Z_радар·sin p) / cos p. На ней
+лежит сетка GPR; при p = ±90° плоскость не определена, и программа отказывает
+(`Ground BP is undefined at a look angle of 90 degrees`).
+
+«Глубина» в разных режимах означает разное:
+
+| Где | Что означает |
+| --- | --- |
+| B-scan, **Cut (m)** и **Max depth (m)** | половина пути волны, отсчитанная от **Cut (m)** |
+| GPR, **Min depth (m)** и **Max depth (m)** | координата Z_радар точки на плоскости земли |
+| 3D SAR, **MIN_DEPTH** и **MAX_DEPTH** | координата Z_радар вокселя |
+| Нормировка А-скана | половина пути волны |
+
+Волна в GPR и 3D SAR считается идущей со скоростью 3·10⁸ м/с (воздух), в B-scan —
+299 792 458 м/с.
diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md
new file mode 100644
index 0000000..a74e3d7
--- /dev/null
+++ b/docs/troubleshooting.md
@@ -0,0 +1,90 @@
+# Неполадки
+
+Сначала посмотрите журнал окна (секция **Runtime Log**) и строку состояния. Если там
+пусто, а картинка не обновляется, причина обычно в журналах процессов в
+`python_app/runtime/logs/`:
+
+| Файл | Что в нём искать |
+| --- | --- |
+| `acquisition.err.log` | приборы не открываются, ожидание приборов, ошибки коллектора E-502 |
+| `data_preprocessor.err.log` | отброшенные коллекции: не та сетка частот, нет пары в наборе |
+| `data_processor.err.log` | ошибки обработки, отклонённый live-файл, 3D SAR, локатор |
+| `radar.log` | всё, что было в окне журнала |
+
+У каждого процесса есть ещё `<процесс>.out.log` (обычный вывод), а перед каждым
+запуском прежние журналы переименовываются в `*.prev`: после автоматического
+перезапуска причина падения лежит в `<процесс>.err.log.prev`. Без экрана журнал
+программы — `journalctl -u radar.service -f`.
+
+## Программа не запускается
+
+| Признак | Причина | Что делать |
+| --- | --- | --- |
+| окно не открывается, в выводе ошибка о LibreVNA (`No compatible LibreVNA USB device found…`) | профиль с `driver_mode: "native"`, а прибор не подключён: при запуске окно читает пределы прибора | подключите прибор или запустите с профилем симулятора: `./start.sh --profile run_config.json` |
+| окно открывается с ошибкой `Cannot switch processing mode to …` и закрывается, и так при каждом запуске | запомненный профиль читается, но его настройки нельзя применить (например, `SAR_Y_RANGE_M max` не больше min) | удалите `python_app/runtime/gui_session_state.json` и исправьте профиль |
+| `Another radar instance already holds /tmp/radar_system.lock; aborting.` | программа уже запущена: в другом окне или службой | закройте другой экземпляр или `sudo systemctl stop radar.service` |
+| служба перезапускается каждые 3 с | без экрана не удаётся **Start** (часто — не выбраны наборы) или нет программ в `build/bin/` | `journalctl -u radar.service`; выберите наборы и сохраните профиль, запустив окно на машине |
+| `[start.sh] … Run an interactive './start.sh' once (online)…` | без экрана не хватает Python-пакетов | запустите `./start.sh` один раз с экраном и сетью |
+
+## Start отказывает
+
+| Сообщение | Причина | Что делать |
+| --- | --- | --- |
+| `Select all required preprocess sets in Preprocessing Panel before Start: …` | не выбраны калибровка или референс | выберите или снимите их в **Preprocessing…**; для симулятора — **Create Neutral S21 Sets** |
+| наборы пропали из списков, в журнале `Preprocess selection reset: …` | изменилась развёртка, серийные номера или положения ключей — другой ключ радара | верните прежние настройки или снимите наборы заново |
+| `Selected … set does not cover requested run combos` | набор снят для другой матрицы | снимите набор заново при текущих ключах |
+| `rings.<имя>.slot_size_bytes must be at least N for this sweep` | слот кольца меньше коллекции | увеличьте `slot_size_bytes` в профиле |
+| `B-scan with native switches requires exactly one run combo (now N)` | B-scan с настоящими ключами показывает одну пару | задайте одну пару в **Single combo** |
+| `GPR requires at least 2 distinct run combos (now N)` и другие `GPR …` | для GPR и 3D SAR нужны пары и геометрия | проверьте пары сбора и таблицы геометрии |
+| `Object calibration … differs from the captured state` | калибровка по объекту снята при других настройках, геометрии или наборе калибровки | выберите **Off** или снимите калибровку по объекту заново |
+| `Stop all pipeline processes before loading a config profile` | профиль загружают при работающем сборе | нажмите **Stop** |
+
+## Нет данных от приборов
+
+| Признак | Причина | Что делать |
+| --- | --- | --- |
+| сбор идёт, приборы не опрашиваются, на графиках один движущийся точечный отражатель | профиль симулятора: `driver_mode: "mock"` (например, корневой `run_config.json`) | загрузите профиль с `driver_mode: "native"`; если в журнале `Failed to load the last selected config profile…`, запомненный профиль не прочитался — исправьте его и загрузите снова |
+| `No datapoints received from at least one device; hardware trigger sync did not start. Check Trigger Out/In loop and 10 MHz reference wiring.` | многоприборная схема: не работает петля триггера | проверьте кабели петли и контроллер STM32 в режиме переключения стимула |
+| `Packet not received from <серийный номер> within 1.0 s` | включена внешняя опора (`force_external_reference: true`), а 10 МГц не подана | выключите опору в профиле или проверьте, что 10 МГц доходит до всех приборов |
+| `<прибор> unavailable (attempt N); retrying until present` | прибор не открывается; процесс сбора ждёт его бесконечно | проверьте USB и серийный номер в профиле; правило udev ставит `./start.sh` при обычном запуске |
+| в режиме переключения стимула приборы падают с `LIBUSB_ERROR_IO` | полоса ПЧ 1 кГц | используйте 5–50 кГц, рабочее значение — 50 кГц |
+| ключи не переключаются, ошибок нет | неверный GPIO-чип или ключ в `mock` | проверьте `gpiodetect`, `gpioinfo -c gpiochip0` и `driver_mode` ключей |
+| `Failed to open GPIO chip '…'` или `Failed to request GPIO lines…` | чипа нет или линии заняты другой программой | проверьте чип и что линии свободны (`gpioinfo`) |
+
+## Радиофотонный радар
+
+| Сообщение | Причина | Что делать |
+| --- | --- | --- |
+| `…sweep rejected (count=n): covered [a, b] Hz does not span band [c, d] Hz…` | развёртка лазера не покрывает выходную полосу | проверьте фазовую калибровку (`rph_radar_calibrate`) и `radar.rph_radar.band` |
+| `Timed out waiting for a RadioPhotonic radar (RPH) sweep covering the configured band` | за **Read timeout (s)** не пришло ни одной пригодной развёртки | проверьте, что лазер запущен (**Apply Radar**), и сигналы на входах |
+| `Cannot load libx502api.so: …` | нет библиотек L-Card | положите `libx502api.so` и `libe502api.so` в `~/.local/lib` или задайте `LD_LIBRARY_PATH` в **Environment JSON** |
+| `Timed out waiting for RadioPhotonic radar (RPH) TTY …` | коллектор не запустился | смотрите строки `Collector: …` в `acquisition.err.log`; проверьте **Executable path** (`make rph_radar_collector`) |
+| `ADC source clock leads host monotonic clock; restart acquisition` | часы АЦП ушли вперёд часов компьютера | перезапустите сбор |
+| `Serial port '…' is busy — another program is already using the laser controller…` | порт платы лазеров занят другой программой | закройте её |
+| `Device rejected command: STATE 0x…` | плата лазеров отвергла команду | проверьте значения токов и температур и питание платы |
+| `RFG protocol violation: …` | на порт пришёл чужой пакет | привяжите `udp_bind` к проводному адресу, на котором работает плата |
+
+## Картинка не обновляется или пустая
+
+| Признак | Причина | Что делать |
+| --- | --- | --- |
+| сбор идёт, счётчик `History` растёт только у `raw` | препроцессор отбрасывает коллекции | `data_preprocessor.err.log`: обычно не совпадает сетка частот набора; снимите наборы заново |
+| GPR пустой | нет антенн с геометрией среди выбранных или **Min depth (m)** не меньше **Max depth (m)** | проверьте поля и таблицы; ошибки — в `data_processor.err.log` |
+| **Single Capture** в режиме GPR ждёт 300 с и останавливается | результат GPR пустой, и съём его не дождался | проверьте настройки GPR, как выше |
+| `SAR3D: warning — …` | нет видеокарты NVIDIA или программа собрана без CUDA | глава [Установка и запуск](installation.md), раздел о видеокарте |
+| `SAR3D: error — the views need N KiB but a results ring slot holds M KiB…` | изображения 3D SAR не помещаются в слот | увеличьте шаги вокселя, сузьте дальность или увеличьте `rings.results.slot_size_bytes` |
+| `SAR3D: no signal — No signal inside the selected depth range` | в пределах MIN_DEPTH–MAX_DEPTH нет сигнала | проверьте границы дальности и полосу |
+| кадр 3D SAR пустой, в строке `frame empty` | максимум ниже 6 фонов при включённой **BG_GATE_ENABLE** | это норма для пустой сцены; для проверки выключите **BG_GATE_ENABLE** |
+| `Stopping pipeline after 400 consecutive reader poll failures` | конвейер остановлен из-за ошибок чтения колец | нажмите **Start**; повторяется — проверьте `/dev/shm` |
+| `Status: result display error (acquisition continues)` | ошибка отрисовки; сбор продолжается | текст ошибки — в журнале |
+
+## Прочее
+
+| Признак | Причина | Что делать |
+| --- | --- | --- |
+| после перезапуска пропали настройки и выбор наборов | изменения в окне и вебе не пишутся в профиль сами | **Save Config…** |
+| отказ в доступе к кольцам (`Permission denied`) | программу или тесты запускали от root, и кольца в `/dev/shm` принадлежат root | при закрытой программе удалите их: `sudo rm -f /dev/shm/radar_*` |
+| веб-страница не открывается | порт занят или не тот адрес | проверьте вывод программы (`journalctl`) на `address already in use`; другой порт — `RADAR_SYSTEM_WEBUI_PORT` |
+| `Disk recording stopped because the writer could not keep up` | диск не успевает за записью | пишите на более быстрый диск или меньше коллекций; записанное сохранено |
+| `Snapshot directory already exists: …` | каталог набора данных уже есть | задайте другое **Name** |
+| `Log panel overflow: N record(s) dropped…` | журнал в окне переполнился | всё есть в `radar.log` |
diff --git a/docs/web.md b/docs/web.md
new file mode 100644
index 0000000..78644a9
--- /dev/null
+++ b/docs/web.md
@@ -0,0 +1,226 @@
+# Веб-интерфейс, кнопка и локатор
+
+Кроме окна, с программой работают из браузера, GPIO-кнопкой и через TCP-локатор: он
+отдаёт внешним программам найденные объекты и принимает скорость радара. Без экрана
+(раздел «Работа без экрана») других способов нет.
+
+## Веб-интерфейс
+
+Веб-сервер работает внутри программы, и с окном, и без экрана. Откройте в браузере
+`http://<адрес машины>:8080/`. Порт меняется переменной окружения
+`RADAR_SYSTEM_WEBUI_PORT`; неверное значение заменяется на 8080. Если порт занят,
+программа работает без веб-интерфейса, а ошибка (`address already in use`) видна
+только в выводе процесса. Если сервер не смог запуститься по другой причине, в журнале
+появляется `Failed to start web UI`.
+
+> **Внимание.** У веб-интерфейса нет пароля и шифрования: любой, кто достаёт до
+> порта по сети, управляет радаром. По адресам `/docs`, `/redoc` и `/openapi.json`
+> открыто описание API. Подключайте машину только к доверенной сети.
+
+### Страница
+
+Верхняя строка — кнопки управления: **Start**, **Single Capture**, **Stop**,
+**Capture Temporary Reference**, **Remove Last Measurement**. Они делают то же, что
+одноимённые кнопки окна (глава [Окно программы](desktop.md)).
+
+**Processor Output** — картинка с графиков окна программы. Масштабировать её в
+браузере нельзя: она повторяет то, что показано в окне. В режиме B-scan над ней
+список пар (**Input i / Output o**) и кнопка **Next Combination**, в режиме 3D SAR —
+строка состояния 3D SAR.
+
+Боковая панель:
+
+| Блок | Элемент | Что делает |
+| --- | --- | --- |
+| **Config Profile** | список и **Load** | загружает профиль из каталога `run_configs/`, как **Load Config…**; доступна при остановленном сборе. Список читается при открытии страницы; новые файлы видны после её перезагрузки |
+| | `current: <файл>` | текущий профиль |
+| **Save / Record Dataset** | **Save path**, **Name (optional)** | каталог и имя набора данных; при открытии страницы берутся из окна, а сохранение и запись из браузера переписывают ими **Path** и **Name** окна (запись — и **Sweeps to record**) |
+| | **Save Last 1000** | сохраняет до 1000 последних коллекций каждой стадии, как одноимённая кнопка окна |
+| | **Sweeps to record** и **Start + Record to Disk** | запускает сбор и записывает на диск заданное число следующих коллекций; ниже — ход записи `recording N / M sweep(s)…` |
+| **Processor Settings** | форма | настройки текущего режима обработки (ниже); заголовок сворачивает форму |
+| | таблицы **Tx geometry**, **Rx geometry** | геометрия антенн в режимах GPR и 3D SAR: **−** удаляет антенну, **Add Antenna** добавляет следующую свободную |
+| | **Apply** | отправляет изменённые поля |
+| | **Clear Runtime History** | очищает историю, как одноимённая кнопка окна |
+
+Нижняя строка: `running` — идёт ли сбор, `processor` — работает ли процессор,
+`ring` — имя кольца результатов, `scans` — сколько коллекций в истории каждой стадии,
+и признак картинки: `live` — кадры приходят, `stale` — новых кадров нет дольше 2 с
+(так бывает и тогда, когда картинка просто не меняется), `no data` — кадров ещё не
+было. Сообщения об успехе и ошибках всплывают на 2,6 с; ошибки — красным.
+
+Если окно браузера уже 760 пикселей, панель уходит под картинку.
+
+### Как выполняются действия
+
+Каждая кнопка вызывает тот же код, что и кнопка окна, с теми же проверками. Действия
+из нескольких браузеров выполняются по очереди. Если окно программы не начало
+действие за 30 с, запрос отменяется
+(`The desktop did not start the action in time; the request was cancelled`). Если
+начало, но не закончило, браузер получает
+`The desktop action is still running; check its status before retrying`, а действие
+доводится до конца. Ошибки, которые возникают позже (например, на середине
+однократного съёма), видны только в журнале.
+
+### Форма настроек
+
+Форма строится из полей окна: те же подписи, пределы и группы. В ней поле **Mode** и
+поля текущего режима; смена режима сразу перестраивает форму. **Apply** отправляет
+только изменённые поля, поэтому два человека, правящие разные поля, не мешают друг
+другу. Порядок полей может отличаться от окна. Значения проверяются так же, как в
+окне: вне пределов — `Numeric setting is outside its allowed range`, неизвестный
+вариант — `Unknown setting option: …`; при ошибке не применяется ни одно поле из
+отправленных. Дробное число округляется до точности поля окна. Изменения из окна
+приходят в форму раз в секунду.
+
+Только в окне программы:
+
+- **Apply Radar**, **Load Config…** из произвольного места, **Save Config…**;
+- окно **Preprocessing…**: съём и выбор наборов, калибровка по объекту;
+- **Save S21 JSON**;
+- секции **Radar** и **Switches**: развёртка, лазер, ключи, пары сбора;
+- **Show magnitude**, **Show phase**, **Unwrap phase**, **Scans to show (stopped)**;
+- журнал и его уровень, строка состояния;
+- масштаб и сдвиг графиков мышью.
+
+### Картинка и обновление
+
+Программа снимает картинку графиков, только когда подключён хотя бы один браузер и
+изображение изменилось, и не чаще 5 раз в секунду. Каждый браузер получает самую
+свежую картинку, состояние и настройки; медленное соединение пропускает
+промежуточные кадры. Пока окно занято синхронным съёмом (калибровка, референс),
+картинка не обновляется. Браузер переподключается сам через 1,5 с после обрыва.
+
+### API
+
+Страница работает через HTTP API, которым можно пользоваться и из своих программ.
+Тело запросов и ответов — JSON; ошибка действия — код 400 и
+`{"detail": "<сообщение>"}`. Тело неверного вида (поле не того типа, нет обязательного
+поля) даёт код 422 со списком ошибок в `detail`.
+
+| Запрос | Тело | Что делает |
+| --- | --- | --- |
+| `GET /api/status` | — | состояние (то же, что в нижней строке страницы) |
+| `POST /api/start` | — | **Start** |
+| `POST /api/single_capture` | — | **Single Capture** |
+| `POST /api/stop` | — | **Stop** |
+| `POST /api/tmp_reference` | — | **Capture Temporary Reference** |
+| `POST /api/remove_last` | — | **Remove Last Measurement** |
+| `POST /api/clear_history` | — | **Clear Runtime History** |
+| `GET /api/configs` | — | список профилей в `run_configs/` |
+| `POST /api/load_config` | `{"name": "<файл>.json"}` | загрузить профиль |
+| `POST /api/save_dataset` | `{"path": "…", "name": "…"}` | **Save Last 1000** |
+| `POST /api/start_recording` | `{"path": "…", "name": "…", "count": N}` | **Start + Record to Disk**, N от 1 до 1 000 000 |
+| `GET /api/live_settings` | — | описание полей формы |
+| `POST /api/live_settings` | `{"<поле>": значение, …}` | применить настройки. Имена полей — из описания формы; у настроек обработки они совпадают с ключами `processing_live.json` |
+| `POST /api/display_combo` | `{"input_pos": i, "output_pos": o}` | выбрать пару B-scan |
+| `POST /api/next_combo` | — | **Next Combination** |
+| `WS /ws` | — | поток сообщений `frame` (картинка PNG в base64), `status`, `settings` |
+
+## GPIO-кнопка
+
+Кнопка, подключённая к GPIO компьютера, снимает временный референс — то же, что
+**Capture Temporary Reference**. Она настраивается в разделе `control_button`
+профиля (глава [Файл конфигурации](configuration.md)) и читается один раз при запуске
+программы.
+
+Схема по умолчанию: кнопка между линией GPIO и землёй, внутренняя подтяжка к питанию,
+нажатие — спад уровня, по одному действию на нажатие. Индикации на самой кнопке нет;
+результат виден в журнале (`GPIO control button pressed: capturing tmp reference`, затем
+`Tmp S21 Reference captured: …`) и на графике pass-through.
+
+Нажатие, пришедшее, пока программа останавливает сбор перед съёмом, отбрасывается
+(`Tmp reference capture already in progress; ignoring duplicate request`). Нажатие во
+время самого съёма ждёт его конца и запускает ещё один съём. Ошибки съёма только
+записываются в журнал, окон с ошибкой кнопка не
+открывает. Если настройки неполные, кнопка не включается
+(`GPIO control button configuration is incomplete or unsupported; watcher not started`).
+Если линию не удалось открыть — `Failed to start GPIO control button watcher`; если
+наблюдение за линией прервалось — `GPIO control button watcher stopped: …`. В обоих
+случаях программа работает дальше без кнопки до перезапуска.
+
+## TCP-локатор
+
+Локатор — TCP-сервер внутри процессора. Он отдаёт подключившимся программам объекты,
+найденные режимом GPR, и принимает от них скорость радара для поправки на движение.
+Настройки — раздел `run.locator_server` профиля; по умолчанию порт 8888 на всех
+интерфейсах. Сервер работает, пока работает процессор: от первого **Start** до
+закрытия программы или перезапуска процессора. **Stop**, временный референс и
+пересчёт B-scan процессор и локатор не останавливают.
+
+Обмен в обе стороны идёт пакетами: заголовок из 8 байт — номер устройства и длина
+данных (оба uint32, младший байт первым), затем текст JSON в UTF-8.
+
+Пакет от радара:
+
+```text
+03 00 00 00 71 00 00 00
+{"obs":[{"crs":0.3,"dst":4.3},{"crs":-1.0,"dst":6.0},{"crs":1.0,"dst":2.0}],"sts":1,"tim":"13:42:25.058","ver":1}
+```
+
+| Поле | Что это |
+| --- | --- |
+| `obs` | объекты: `crs` — координата X (вдоль ряда антенн, поперёк направления обзора), `dst` — дальность (Z), метры, округлены до 0,1 м |
+| `sts` | всегда 1 |
+| `tim` | местное время сборки пакета `ЧЧ:ММ:СС.ммм` (не время съёма) |
+| `ver` | `run.locator_server.protocol_version` |
+
+В заголовке — `run.locator_server.device_id`. Пакет уходит после обработки каждой
+новой коллекции в любом режиме; вне режима GPR и без объектов список `obs` пуст.
+Объекты — те же, что видны на графике GPR: внутри окна видимости, после отбора по
+числу и фильтра приближения. При пересчёте после смены настроек уходит пакет только
+для новейшей коллекции. Новый клиент сразу получает последний отправленный пакет.
+
+Пакет к радару — скорость в м/с:
+
+```text
+00 00 00 00 0c 00 00 00
+{"vlc":1.25}
+```
+
+Остальные поля и номер устройства в заголовке не читаются. Скорость считается свежей
+5 с после прихода. Пока она свежая и на странице GPR или 3D SAR выключена
+**Ignore socket speed**, она заменяет скорость из поля, а её изменение пересчитывает
+новейшую коллекцию в GPR и 3D SAR. Использованная скорость сохраняется в каждом
+результате.
+
+Клиентов может быть несколько; у каждого очередь на `client_queue_size` пакетов (по
+умолчанию 32), при переполнении выбрасываются старые. Клиент, который 5 с не принимает
+данные или прислал пакет больше `max_payload_bytes` (по умолчанию 65 536 байт),
+отключается. Сообщения локатора пишутся в
+`python_app/runtime/logs/data_processor.err.log`; если порт занят, процессор работает
+без локатора и пишет туда же `locator server disabled`.
+
+## Работа без экрана
+
+На машине без оператора (Raspberry Pi) программу запускает служба systemd
+`radar.service` (установка — в главе [Установка и запуск](installation.md)). Она
+запускает `./start.sh --headless --skip-build`: окно не показывается, через 0,5 с
+выполняется **Apply Radar**, ещё через 1 с — **Start**, даже если **Apply Radar** не
+удался. Если **Start** не удался, программа завершается, и systemd запускает её
+снова через 3 с.
+
+Профиль служба берёт последний использованный (из
+`python_app/runtime/gui_session_state.json`), а если его нет — корневой
+`run_config.json`, то есть симулятор. Поэтому перед установкой службы загрузите или
+сохраните на машине нужный профиль. Если запомненный профиль не читается, программа
+пишет `Failed to load the last selected config profile; falling back to root run_config.json.`,
+берёт корневой профиль и запоминает уже его: исправленный профиль нужно загрузить снова.
+
+| Канал | Для чего |
+| --- | --- |
+| браузер, `http://<машина>:8080/` | запуск и остановка, временный референс, сохранение данных, профили из `run_configs/`, настройки обработки |
+| GPIO-кнопка | временный референс |
+| TCP-локатор, порт 8888 | объекты и скорость |
+| `sudo systemctl start` / `stop` / `restart radar.service` | управление службой |
+| `journalctl -u radar.service -f` | журнал службы |
+
+Без экрана нет окон с ошибками: всё пишется в журнал. Окно калибровки и
+**Save Config…** без экрана недоступны: калибровку снимайте, запустив окно программы
+на той же машине. При запуске окна `./start.sh` сначала останавливает службу; после
+закрытия окна она сама не запускается.
+
+Если процесс конвейера упал, программа перезапускает конвейер: первый раз сразу,
+следующие подряд — не раньше чем через 6, 12, 24, 48, затем 60 с после предыдущей
+попытки; когда данные снова пошли, счёт начинается заново. После 400 подряд ошибок чтения
+колец конвейер останавливается и сам уже
+не запускается: нажмите **Start** в браузере.
diff --git a/docs/workflow.md b/docs/workflow.md
new file mode 100644
index 0000000..59092c3
--- /dev/null
+++ b/docs/workflow.md
@@ -0,0 +1,111 @@
+# Типовые сценарии
+
+## Подготовка к измерениям
+
+1. Подключите приборы, ВЧ-ключи и антенны (глава [Оборудование](hardware.md)).
+2. Запустите программу с профилем установки:
+ `./start.sh --profile <профиль>`. Проверьте в секции **Radar** развёртку, а под
+ полями — пределы прибора: если они прочитаны, прибор на связи.
+3. Нажмите **Apply Radar**.
+4. Убедитесь, что в **Selected Preprocess Sets** выбраны наборы, снятые при этой
+ развёртке. Если списки пусты — снимите калибровку и референс (ниже).
+5. Выберите режим pass-through (**Mode**), нажмите **Single Capture** и посмотрите
+ S21 каждой пары: у всех пар должен быть сигнал, без пар на уровне шума.
+ **Single Capture**, как и **Start**, требует выбранных наборов; при первой настройке
+ сигнал каждой пары виден на графиках окна предобработки во время съёма калибровки.
+
+## Калибровка
+
+Калибровку (through) снимают при первой настройке установки, после замены кабелей,
+ключей или антенн и после смены развёртки: другая развёртка — другой ключ радара, и
+старые наборы для неё не видны.
+
+1. Нажмите **Preprocessing…**, задайте **Set name** и
+ **Sweeps per combo (median)**.
+2. Подготовьте эталонный тракт для калибровки и нажмите **Start S21 Calibration**.
+3. Снимайте пары (**Capture Current Combo** или **Capture All Remaining**), проверяя
+ каждую развёртку на графике; неудачную переснимите через **Undo Last Capture**.
+4. Нажмите **Save Captured Set**.
+
+Подробно — в главе [Калибровка и предобработка](preprocessing.md).
+
+## Референс
+
+Референс — пустая сцена: то, что должно остаться фоном.
+
+1. Уберите из сцены цели, оставив всё постоянное на своих местах.
+2. В окне **Preprocessing Setup** задайте новое **Set name** и нажмите
+ **Start S21 Reference**, затем снимите пары и сохраните набор.
+
+На месте работы, когда сцена изменилась (новое место, переставили радар), снимите
+временный референс: кнопка **Capture Temporary Reference**, та же кнопка в
+веб-интерфейсе или GPIO-кнопка. Сбор приостанавливается на время съёма и
+продолжается уже с новым референсом.
+
+## Наблюдение
+
+1. Выберите режим обработки в **Mode**: B-scan — дальность во времени для одной
+ пары, GPR — карта земли с объектами, 3D SAR — объём над землёй.
+2. Нажмите **Start**.
+3. Настраивайте режим на ходу: изменения применяются сразу и пересчитывают последний
+ результат (глава [Режимы обработки](processing.md)). Для GPR и 3D SAR задайте
+ высоту радара и угол обзора, как на установке.
+4. Если радар движется, скорость передаёт клиент локатора; без него включите
+ **Ignore socket speed** и задайте скорость вручную.
+5. Нажмите **Save Config…**, когда настройки подобраны.
+
+## Сохранение данных
+
+- Сохранить то, что уже есть в памяти (до 1000 коллекций каждой стадии): задайте
+ **Path** и **Name**, нажмите **Save Last 1000**.
+- Записать следующие N коллекций: задайте **Sweeps to record**, **Path** и **Name**,
+ нажмите **Start + Record to Disk**. Ход записи виден в журнале и в веб-интерфейсе.
+- Нужны развёртки S21 в JSON для других программ — **Save S21 JSON**.
+
+Набор данных — новый каталог в **Path**; формат — в главе
+[Данные на диске](data.md).
+
+Если последняя коллекция испорчена (например, в кадр попал человек), нажмите
+**Remove Last Measurement**: она удалится из истории в памяти. Уже записанные на диск
+файлы останутся, а идущая запись на диск при этом завершится.
+
+## Калибровка по объекту
+
+Нужна, когда изображение GPR или 3D SAR размыто из-за того, что пары не совпадают по
+фазе.
+
+1. Поставьте металлический шар в сцену и измерьте положение его центра.
+2. Нажмите **Preprocessing…**, затем **Create…** рядом с **Object calibration**.
+3. Нажмите **Capture Data** и впишите X, Y, Z центра шара. Повторите для других
+ положений, если нужно.
+4. Нажмите **Calculate and Save** и проверьте карты фаз **Before** и **After**.
+5. Перезапустите сбор (**Stop**, затем **Start**): поправка применяется со следующего
+ запуска.
+
+Подробно — в главе [Калибровка и предобработка](preprocessing.md).
+
+## Работа без оператора
+
+1. Настройте всё на самой машине с экраном: профиль, наборы, режим, и нажмите
+ **Save Config…**.
+2. Установите службу: `sudo bash deploy/install-daemon.sh`, затем
+ `sudo systemctl start radar.service`.
+3. Управляйте через браузер (`http://<машина>:8080/`), GPIO-кнопку и локатор.
+
+Глава [Веб-интерфейс, кнопка и локатор](web.md), раздел «Работа без экрана».
+
+## Обновление программы
+
+1. На рабочей станции закоммитьте изменения и выполните `deploy/pack-usb.sh` (путь к
+ флешке можно не указывать, если подключена одна).
+2. На машине закройте окно программы и из корня флешки выполните
+ `bash radar_update/update.sh`.
+3. Проверьте `cat ~/Documents/radar_system/RELEASE`.
+
+Глава [Установка и запуск](installation.md), раздел «Обновление с флешки».
+
+## Окончание работы
+
+1. Нажмите **Stop**.
+2. Нажмите **Save Config…**, если меняли настройки или наборы.
+3. Закройте окно: запись на диск дописывается, процессы конвейера останавливаются.
diff --git a/python_app/tests/test_documentation.py b/python_app/tests/test_documentation.py
new file mode 100644
index 0000000..89438e7
--- /dev/null
+++ b/python_app/tests/test_documentation.py
@@ -0,0 +1,80 @@
+"""The user documentation names every profile key, live key and on-screen label, and its links resolve."""
+
+import dataclasses
+import re
+import unittest
+from pathlib import Path
+from typing import Any, Iterator
+
+from python_app.models.gui_profile_schema import GuiProfileModel
+from python_app.orchestration.live_processing_config import ProcessingLiveConfig
+
+ROOT = Path(__file__).resolve().parents[2]
+DOCS = ROOT / "docs"
+GUI = ROOT / "python_app" / "gui"
+# A full profile: every model section, both switches, geometry and the whole gui section.
+PROFILE = ROOT / "run_config_examples" / "run_config_librevna_multi_per_point.example.json"
+# Text the widgets show: constructor captions, and ("Label", widget) form rows of the sections and dialogs.
+CAPTION = re.compile(r'\b(?:QPushButton|QCheckBox|QGroupBox|QLabel|QToolButton|addRow|setWindowTitle|setTitle)\(\s*"([^"%{}]{2,})"')
+FORM_ROW = re.compile(r'\(\s*"([^"%{}]{1,60})"\s*,\s*[a-z_][a-z_0-9]*\s*\)')
+LINK = re.compile(r"\]\(([^)#\s]+)(?:#[^)]*)?\)")
+CODE = re.compile(r"(`+)(.+?)\1")
+BOLD = re.compile(r"\*\*(.+?)\*\*")
+HEADING = re.compile(r"^#+\s+(.+)$", re.MULTILINE)
+
+
+def leaf_names(value: Any) -> Iterator[str]:
+ if isinstance(value, dict):
+ for key, item in value.items():
+ if isinstance(item, (dict, list)) and item:
+ yield from leaf_names(item)
+ else:
+ yield key
+ elif isinstance(value, list) and isinstance(value[0], dict):
+ yield from leaf_names(value[0])
+
+
+def on_screen_labels() -> set[str]:
+ labels: set[str] = set()
+ for path in GUI.rglob("*.py"):
+ source = path.read_text(encoding="utf-8")
+ labels.update(CAPTION.findall(source))
+ if path.parent.name == "sections" or path.name.endswith(("_dialog.py", "_editor.py")):
+ labels.update(FORM_ROW.findall(source))
+ return labels
+
+
+class DocumentationTest(unittest.TestCase):
+ def setUp(self) -> None:
+ self.chapters = {path: path.read_text(encoding="utf-8") for path in [ROOT / "README.md", *DOCS.glob("*.md")]}
+ configuration = self.chapters[DOCS / "configuration.md"]
+ self.configuration_names = {part for _, span in CODE.findall(configuration)
+ for part in re.split(r"[.,*\s\[\]{}\"]+", span) if part}
+
+ def test_configuration_names_every_profile_and_live_key(self) -> None:
+ profile = GuiProfileModel.load_from_path(PROFILE).to_dict()
+ live = [field.name for field in dataclasses.fields(ProcessingLiveConfig)]
+ missing = sorted(set(leaf_names(profile)).union(live) - self.configuration_names)
+ self.assertEqual(missing, [], "keys missing from docs/configuration.md")
+
+ def test_every_on_screen_label_is_documented(self) -> None:
+ # A label counts only as a whole bold span, code span or heading; a hint may continue with "…".
+ text = "\n".join(self.chapters.values())
+ spans = {*BOLD.findall(text), *(span.strip() for _, span in CODE.findall(text)), *HEADING.findall(text)}
+
+ def documented(label: str) -> bool:
+ label = label.strip()
+ return label in spans or any(span.startswith(label) and span[len(label):].lstrip().startswith("…")
+ for span in spans)
+
+ self.assertEqual(sorted(label for label in on_screen_labels() if not documented(label)), [])
+
+ def test_relative_links_resolve(self) -> None:
+ broken = [f"{path.name}: {target}" for path, text in self.chapters.items()
+ for target in LINK.findall(text)
+ if "://" not in target and not (path.parent / target).exists()]
+ self.assertEqual(broken, [])
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/python_app/webui/static/app.js b/python_app/webui/static/app.js
index 097fe9a..607739e 100644
--- a/python_app/webui/static/app.js
+++ b/python_app/webui/static/app.js
@@ -340,7 +340,7 @@ btnApply.addEventListener("click", async () => {
btnResetHistory.addEventListener("click", async () => {
btnResetHistory.disabled = true;
try {
- await api("/api/clear_history");
+ await api("/api/clear_history", {});
settingsNote.textContent = "History reset requested.";
} catch (err) {
settingsNote.textContent = err.message;