diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
new file mode 100644
index 000000000..e3185f087
--- /dev/null
+++ b/.github/workflows/docs.yml
@@ -0,0 +1,60 @@
+name: Documentation
+
+on:
+ push:
+ branches: [main, master]
+ paths:
+ - "docs/**"
+ - "zensical.toml"
+ - ".github/workflows/docs.yml"
+ pull_request:
+ paths:
+ - "docs/**"
+ - "zensical.toml"
+ - ".github/workflows/docs.yml"
+
+permissions:
+ contents: read
+
+concurrency:
+ group: docs-${{ github.ref }}
+ cancel-in-progress: true
+
+jobs:
+ # Runs on every PR touching docs: a broken internal link or a bad config
+ # fails review instead of shipping.
+ build:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v7
+ - uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ - name: Build the docs site
+ run: uv run --group docs zensical build --clean --strict
+
+ deploy:
+ # Only main pushes deploy; PRs stop after the strict build above.
+ if: github.event_name == 'push'
+ needs: build
+ permissions:
+ contents: read
+ pages: write
+ id-token: write
+ environment:
+ name: github-pages
+ url: ${{ steps.deployment.outputs.page_url }}
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/configure-pages@v6
+ - uses: actions/checkout@v7
+ - uses: astral-sh/setup-uv@v5
+ with:
+ enable-cache: true
+ - name: Build the docs site
+ run: uv run --group docs zensical build --clean
+ - uses: actions/upload-pages-artifact@v5
+ with:
+ path: site
+ - uses: actions/deploy-pages@v5
+ id: deployment
diff --git a/.gitignore b/.gitignore
index e31379aac..ec4b2b006 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,7 +4,9 @@ __pycache__/
*$py.class
*.so
.Python
-build/
+# Anchored: an unanchored `build/` also swallows docs/build/, the enclosure
+# section of the docs site.
+/build/
develop-eggs/
dist/
downloads/
@@ -77,8 +79,12 @@ ui/test-results/
.superpowers/
+# Zensical docs site build output
+/site/
+.cache/
+
#Markdown preview files
.crossnote/config.js
.crossnote/head.html
.crossnote/parser.js
-.crossnote/style.less
\ No newline at end of file
+.crossnote/style.less
diff --git a/AGENTS.md b/AGENTS.md
index 1aacafe72..949e53f61 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -338,7 +338,7 @@ SEN-14262 GND → Pi GND (shared with OPS243-A)
A through-hole resistor must be soldered into **R17** on the SEN-14262 to reduce preamp gain at 3.3V (47kΩ recommended, lower for noisy environments).
-See [docs/sound-trigger-wiring.md](docs/sound-trigger-wiring.md) for full instructions.
+See [docs/sound-trigger-wiring.md](docs/build/sound-trigger.md) for full instructions.
**Trigger Latency:**
| Trigger | Latency | Description |
diff --git a/CLAUDE.md b/CLAUDE.md
index c450025c0..42d995a40 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -230,7 +230,7 @@ SEN-14262 GND → Pi GND (shared with OPS243-A)
A through-hole resistor must be soldered into **R17** on the SEN-14262 to reduce preamp gain at 3.3V (47kΩ recommended, lower for noisy environments).
-See [docs/sound-trigger-wiring.md](docs/sound-trigger-wiring.md) for full instructions.
+See [docs/sound-trigger-wiring.md](docs/build/sound-trigger.md) for full instructions.
**Trigger Latency:**
| Trigger | Latency | Description |
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index db4b253a2..5efecf2cc 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -79,7 +79,7 @@ make format
### TypeScript/React
-Use the checked-in [UI color palette](docs/color_palette.html) when adding or
+Use the checked-in [UI color palette](docs/development/color-palette.md) when adding or
changing visual states.
```bash
diff --git a/Makefile b/Makefile
index 604317fff..26894f825 100644
--- a/Makefile
+++ b/Makefile
@@ -1,4 +1,4 @@
-.PHONY: test lint format dev build-ui start
+.PHONY: test lint format dev build-ui start docs docs-build
## Run Python tests
test:
@@ -35,3 +35,11 @@ install:
## Install pre-commit hooks
hooks:
uv run pre-commit install
+
+## Serve the docs site with live reload at http://localhost:8000
+docs:
+ uv run --group docs zensical serve
+
+## Build the docs site; fails on broken internal links
+docs-build:
+ uv run --group docs zensical build --clean --strict
diff --git a/README.md b/README.md
index 9e6c9063f..f33297a22 100644
--- a/README.md
+++ b/README.md
@@ -48,27 +48,27 @@ Without an angle radar you still get ball speed, club speed, smash factor,
experimental spin, and estimated carry. The angle radar adds measured launch
angle and experimental club path.
-> **⚠️ The K-LD7 angle radars are deprecated.** The supported angle radar is now the **TI IWR6843**. Don't buy K-LD7s for a new build; their software support remains for existing builds only. See the [full parts list](docs/PARTS.md) for details and links.
+> **⚠️ The K-LD7 angle radars are deprecated.** The supported angle radar is now the **TI IWR6843**. Don't buy K-LD7s for a new build; their software support remains for existing builds only. See the [full parts list](docs/get-started/parts.md) for details and links.
-> **The IWR6843 needs custom firmware** — the stock TI demo doesn't expose the raw radar cube OpenFlight needs. A validated prebuilt image ships in `firmware/releases/`, so flashing it doesn't require the TI toolchain. See the [IWR6843 Operator Guide](docs/iwr6843/README.md).
+> **The IWR6843 needs custom firmware** — the stock TI demo doesn't expose the raw radar cube OpenFlight needs. A validated prebuilt image ships in `firmware/releases/`, so flashing it doesn't require the TI toolchain. See the [IWR6843 Operator Guide](docs/iwr6843/index.md).
## Getting Started
### 1. Get the parts
-See the **[Parts List](docs/PARTS.md)** for everything you need with purchase links.
+See the **[Parts List](docs/get-started/parts.md)** for everything you need with purchase links.
### 2. Wire it up
-Follow the **[Sound Trigger Wiring Guide](docs/sound-trigger-wiring.md)** to connect the SEN-14262 to the OPS243-A. The (deprecated) K-LD7 modules connect via USB — no wiring needed.
+Follow the **[Sound Trigger Wiring Guide](docs/build/sound-trigger.md)** to connect the SEN-14262 to the OPS243-A. The (deprecated) K-LD7 modules connect via USB — no wiring needed.
**Adding the IWR6843 angle radar?** The Pi cannot power both radars over USB, so
the OPS243 moves to the Pi's GPIO UART header while the TI board takes the USB
port. Do it in this order, validating each step before the next — doing both at
once makes any failure ambiguous:
-1. **[Move the OPS243 from USB to the Pi GPIO UART](docs/ops243-uart-migration.md)** — rewire and confirm the OPS still triggers on its own.
-2. **[IWR6843 Operator Guide](docs/iwr6843/README.md)** — wire, flash the firmware, mount, aim, and measure geometry.
+1. **[Move the OPS243 from USB to the Pi GPIO UART](docs/build/ops243-uart.md)** — rewire and confirm the OPS still triggers on its own.
+2. **[IWR6843 Operator Guide](docs/iwr6843/index.md)** — wire, flash the firmware, mount, aim, and measure geometry.
If your OPS243-A has **WiFi**, you cannot use the GPIO UART — its WiFi module
already drives the radar's UART receive line. Use a separately powered USB hub
@@ -87,10 +87,7 @@ cd openflight
The script installs everything and walks you through the one-time hardware
configuration (radar flash setup, legacy K-LD7 device naming, auto-start, and
optional cloud sync) with prompts. It's safe to re-run any time.
-See the **[Raspberry Pi Setup Guide](docs/raspberry-pi-setup.md)** for
-details and troubleshooting. Touchscreen installations can use the
-**[Startup Splash Screen](docs/splash-screen.md)** guide to install a
-terminal-free desktop launcher with immediate startup progress.
+See the **[Raspberry Pi Setup Guide](docs/setup/raspberry-pi.md)** for details and troubleshooting. Touchscreen installations can use the **[Startup Splash Screen](docs/setup/splash-screen.md)** guide to install a terminal-free desktop launcher with immediate startup progress.
### 4. Hit balls
@@ -120,7 +117,7 @@ scripts/start-kiosk.sh --battery geekworm
```
The IWR6843 example values are not universal. Measure the geometry from the
-antenna center and follow the [operator guide](docs/iwr6843/README.md#measure-the-geometry);
+antenna center and follow the [operator guide](docs/iwr6843/mounting.md#measure-the-geometry);
wrong values bias the result instead of producing an obvious startup error.
Then open http://localhost:8080 or use the touchscreen. Footer tabs switch
@@ -147,7 +144,7 @@ openflight-cloud status # linked? queued? parked?
```
Once linked, sessions sync automatically (on session end and via a ~10-minute
-timer that heals wifi outages). See the **[Cloud Sync Guide](docs/cloud-sync.md)**
+timer that heals wifi outages). See the **[Cloud Sync Guide](docs/using/cloud-sync.md)**
for details.
### TV Display Mode
@@ -175,7 +172,7 @@ For air swings and speed-stick training, OpenFlight can use the OPS243-A fast
speed stream directly instead of waiting for impact audio. Start it with
`scripts/start-kiosk.sh --swing-speed`; the server emits `swing_speed` events
with peak club speed, rep duration, reading count, and session stats. See the
-**[Swing Speed Training Guide](docs/swing-speed-training.md)** for setup and
+**[Swing Speed Training Guide](docs/using/swing-speed.md)** for setup and
tuning options.
## How It Works
@@ -212,7 +209,7 @@ Place the OPS243-A **3-5 feet behind the tee**, pointing down the target line:
```
The IWR6843 has stricter mounting and measurement requirements; use the
-[operator guide](docs/iwr6843/README.md#mount-and-aim-the-radar) rather than assuming it
+[operator guide](docs/iwr6843/mounting.md#mount-and-aim-the-radar) rather than assuming it
shares the OPS243 position.
## Configuration
@@ -253,7 +250,7 @@ monitor.disconnect()
## Limitations
- **Cosine error**: If ball doesn't travel directly toward/away from radar, measured speed will be slightly lower than actual
-- **Spin detection**: The live multitaper value is experimental and is not used for carry by default. Short indoor flight windows and multipath make individual readings unreliable; see [Rolling Buffer and Spin Detection](docs/rolling_buffer_spin_detection.md).
+- **Spin detection**: The live multitaper value is experimental and is not used for carry by default. Short indoor flight windows and multipath make individual readings unreliable; see [Rolling Buffer and Spin Detection](docs/how-it-works/rolling-buffer.md).
- **K-LD7 speed aliasing** (deprecated hardware): The K-LD7 max speed is 62 mph, so it's used only for angle/distance, not speed
## Hardware Diagnostic
@@ -266,7 +263,7 @@ uv run python scripts/hardware-test/diagnose.py
The diagnostic checks the OPS243 transport, rolling-buffer persistence,
software and hardware triggers, and any connected K-LD7 radars. IWR6843 builds
-use the [operator guide's first-capture checks](docs/iwr6843/README.md#verify-the-first-capture).
+use the [operator guide's first-capture checks](docs/iwr6843/verify.md#verify-the-first-capture).
Missing optional hardware (like the horizontal K-LD7) is reported as a skip rather than a failure. Pass `--require-all` to fail on skips, or `--no-interactive` to skip the sound-trigger prompt in unattended runs.
@@ -312,26 +309,32 @@ uv run pytest tests/ -v
## Documentation
-- **[Parts List](docs/PARTS.md)** — What to buy
-- **[Sound Trigger Wiring](docs/sound-trigger-wiring.md)** — How to wire the sound trigger
-- **[Raspberry Pi Setup](docs/raspberry-pi-setup.md)** — Full setup guide
-- **[Battery Monitoring](docs/battery/README.md)** — Provider architecture, UI states, and shared Pi support
-- **[Geekworm X1202/X1206 Operator Guide](docs/battery/geekworm.md)** — Batteries, Pi setup, native telemetry, and warnings
-- **[IWR6843 Operator Guide](docs/iwr6843/README.md)** — Wire, flash, mount, aim, and calibrate the angle radar
-- **[LIS3DH Inclinometer Setup](docs/inclinometer/README.md)**: Add enclosure-level compensation to IWR6843 tilt
-- **[OPS243 USB → GPIO UART Migration](docs/ops243-uart-migration.md)** — Required before adding the IWR6843
-- **[IWR6843 Firmware Developer Guide](firmware/README.md)** — Build the firmware from source (not needed to flash the prebuilt image)
-- **[Simulator Connectors](docs/simulator/README.md)** — Stream shots to GSPro, OpenGolfSim, and others
-- **[Cloud Sync](docs/cloud-sync.md)** — Push filtered sessions to FlightWeb
-- **[Rolling Buffer & Spin Detection](docs/rolling_buffer_spin_detection.md)** — Production capture and experimental spin details
-- **[Dechirped-Sideband Spin Replay](docs/spin-dechirp-replay.md)** — Next-gen spin estimator test bench
-- **[Camera and YOLO Experiments](docs/yolo-performance-tuning.md)** — Optional, non-production vision work
-- **[Legacy K-LD7 Setup](docs/kld7.md)** — Existing K-LD7 builds only
-- **[K-LD7 Ball Detection Theory](docs/kld7-ball-detection-theory.md)** — How angle detection works (deprecated hardware)
-- **[K-LD7 Session Review](docs/kld7-session-review.md)** — Offline review workflow for session JSONL files (deprecated hardware)
-- **[Observability & Log Shipping](docs/observability.md)** — Ship logs to Grafana Cloud
+📖 **Full documentation site: **
+
+Build it locally with `make docs` (serves at `localhost:8000`).
+
+- **[Parts List](docs/get-started/parts.md)** — What to buy
+- **[Sound Trigger Wiring](docs/build/sound-trigger.md)** — How to wire the sound trigger
+- **[Raspberry Pi Setup](docs/setup/raspberry-pi.md)** — Full setup guide
+- **[Battery Monitoring](docs/using/battery.md)** — Provider architecture, UI states, and shared Pi support
+- **[Geekworm X1202/X1206 Operator Guide](docs/build/battery.md)** — Batteries, Pi setup, native telemetry, and warnings
+- **[IWR6843 Operator Guide](docs/iwr6843/index.md)** — Wire, flash, mount, aim, and calibrate the angle radar
+- **[LIS3DH Inclinometer Setup](docs/build/inclinometer.md)**: Add enclosure-level compensation to IWR6843 tilt
+- **[OPS243 USB → GPIO UART Migration](docs/build/ops243-uart.md)** — Required before adding the IWR6843
+- **[IWR6843 Firmware Developer Guide](docs/development/firmware.md)** — Build the firmware from source (not needed to flash the prebuilt image)
+- **[Enclosure & Case](docs/build/enclosure.md)** — The printed IARC v3 case
+- **[Hardware Diagnostic](docs/setup/raspberry-pi.md)** — Pi setup, auto-start, and troubleshooting
+- **[Simulator Connectors](docs/using/simulator/index.md)** — Stream shots to GSPro, OpenGolfSim, and others
+- **[Cloud Sync](docs/using/cloud-sync.md)** — Push filtered sessions to FlightWeb
+- **[Rolling Buffer & Spin Detection](docs/how-it-works/rolling-buffer.md)** — Production capture and experimental spin details
+- **[Dechirped-Sideband Spin Replay](docs/development/spin-replay.md)** — Next-gen spin estimator test bench
+- **[Camera and YOLO Experiments](docs/development/camera-yolo.md)** — Optional, non-production vision work
+- **[Legacy K-LD7 Setup](docs/legacy/index.md)** — Existing K-LD7 builds only
+- **[K-LD7 Ball Detection Theory](docs/legacy/ball-detection-theory.md)** — How angle detection works (deprecated hardware)
+- **[K-LD7 Session Review](docs/legacy/session-review.md)** — Offline review workflow for session JSONL files (deprecated hardware)
+- **[Observability & Log Shipping](docs/using/observability.md)** — Ship logs to Grafana Cloud
- **[Contributing Guide](CONTRIBUTING.md)** — How to contribute
-- **[Changelog](docs/CHANGELOG.md)** — Version history
+- **[Changelog](docs/changelog.md)** — Version history
## License
diff --git a/cad/IARC_case/README.md b/cad/IARC_case/README.md
index b51127456..7e9de1229 100644
--- a/cad/IARC_case/README.md
+++ b/cad/IARC_case/README.md
@@ -1,36 +1,16 @@
-
+# OpenFlight IARC v3 Case
-> **⚠️ Note:** The KLD7 mounts in this case are for the deprecated K-LD7 angle radars — OpenFlight has moved to a more capable radar chip. Skip the KLD7 mount steps for a new build.
+The assembly instructions and build photos now live in the documentation site:
-- M5 Bolts needed: 10
-- M2.5 Bolts needed: 12
+**→ [docs/build/enclosure.md](../../docs/build/enclosure.md)**
+(published at )
-Instructions for OpenFlight IARC v3 Case Build
-
-1. Print out `sensor_housing.stl` 
-2. Print out (2x) `OPS_mount.stl` 
-3. Secure the OPS sensor to each `OPS_mount.stl` part with screws (I don't know which ones)
-4. Secure the `OPS_mount.stl` parts to the `sensor_housing.stl` part with (2x) M5 bolt 
-5. Fit the sound sensor into the little slot on the left side of the `sensor_housing.stl` part. 
-6. Print out (1x) `KLD7_h_mount_L_v2.stl` and (1x) `KLD7_h_mount_R_v2.stl` 
-7. Slot a KLD7 sensor between the mount parts, and secure them to the `sensor_housing.stl` part with (2x) M5 bolt 
-8. Print out (1x) `KLD7_v_mount_L_v2.stl` and (1x) `KLD7_v_mount_R_v2.stl` 
-6. Slot a KLD7 sensor between the mount parts, and secure them to the `sensor_housing.stl` part with (2x) M5 bolt 
-7. Print out the `monitor_mount.stl` part. 
-8. Print out (4x) `monitor_standoffs.stl`'s. 
-9. Secure each `monitor_standoff.stl` part to the corners of the `monitor_mount.stl` part with (1x) M2.5 bolt. 
-10. Secure the monitor to the `monitor_mount.stl` part. 
-11. Secure the raspberry Pi to the monitor. 
-12. Print out the `monitor_shell.stl` part. If you use the Raspberry Pi Touch Display 2 instead of the 7" HMTECH touchscreen, print `Touch_Display2_shell.stl` and `Touch_Display2_backplate.stl` instead. 
-13. Secure the monitor assembly inside the `monitor_shell.stl` part with (4x) M2.5 bolts at the corners. 
-14. Print out the `monitor_back.stl` part. 
-15. Secure the `monitor_back.stl` part to the `monitor_standoffs.stl` parts with (4x) M2.5 bolts. 
-16. Slide the whole assembly into the sensor housing assembly, using the dovetails to align everything together. 
-17. Print out the `name_plate.stl` file. 
-18. Secure the `name_plate.stl` file to the front of the whole assembly with glue. 
-19. Print out the `curvy_backplate.stl` file. 
-20. Secure the `curvy_backplate.stl` file to the whole assembly with (4x) M2.5 bolts. 
-21. Print out (4x) `case_foot.stl` parts. 
-22. Secure each foot to the bottom of the whole assembly with (4x) M5 Bolts. 
+## What's in this directory
-And that’s how you put together the 3d printed parts!
+| Path | Contents |
+| --- | --- |
+| `stl/` | Printable parts |
+| `step/` | STEP files for CAD editing |
+| `IARC_Case_v3.3mf` | Project file with print settings |
+
+**Fasteners:** 10 × M5 bolts, 12 × M2.5 bolts.
diff --git a/docs/archive/index.md b/docs/archive/index.md
new file mode 100644
index 000000000..fb4f4f29b
--- /dev/null
+++ b/docs/archive/index.md
@@ -0,0 +1,66 @@
+---
+icon: lucide/archive
+---
+
+# Archive
+
+**These documents are engineering history, not instructions.**
+
+Everything in this section is a dated design note, implementation plan, or pull
+request write-up describing work that has already shipped. They are kept because
+they record *why* subsystems are shaped the way they are — the constraints, the
+alternatives considered, and the measurements that settled the argument.
+
+!!! warning "Do not follow these as guides"
+
+ Archive documents describe the state of the project on the date in their
+ filename. Commands, file paths, constants, and APIs in them may no longer
+ match the code. For anything you intend to *do*, use the current guides:
+
+ - Building the hardware → [Build](../build/sound-trigger.md)
+ - Running the software → [Using OpenFlight](../using/simulator/index.md)
+ - How a measurement works → [How it works](../how-it-works/rolling-buffer.md)
+
+## Design notes
+
+The "what and why" documents — problem statement, approach, architecture, and
+non-goals — written before implementation started.
+
+| Date | Document |
+| --- | --- |
+| 2026-03-03 | [Enhanced launch angle estimation](plans/2026-03-03-enhanced-launch-angle-design.md) |
+| 2026-03-23 | [K-LD7 angle radar integration](plans/2026-03-23-kld7-angle-radar-design.md) |
+| 2026-03-28 | [K-LD7 full stack integration](plans/2026-03-28-kld7-integration-design.md) |
+| 2026-04-05 | [K-LD7 raw ADC processing](plans/2026-04-05-kld7-radc-processing-design.md) |
+| 2026-04-10 | [Spin detection rework](plans/2026-04-10-spin-detection-rework-design.md) |
+| 2026-04-15 | [Spin & angle data quality validation](specs/2026-04-15-spin-angle-validation-design.md) |
+| 2026-04-20 | [Hardware diagnostic script](specs/2026-04-20-hardware-diagnostic-design.md) |
+| 2026-06-09 | [K-LD7 timing and PRF probe](specs/2026-06-09-kld7-prf-probing-design.md) |
+| 2026-07-25 | [IWR6843 club path from pre-impact frames](specs/2026-07-25-iwr6843-club-path-design.md) |
+| 2026-08-09 | [Optional battery and camera build items](specs/2026-08-09-optional-build-items-design.md) |
+
+## Implementation plans
+
+Task-by-task execution plans, most containing the source and test bodies as they
+were written at the time.
+
+| Date | Document |
+| --- | --- |
+| 2026-03-03 | [Enhanced launch angle estimation](plans/2026-03-03-enhanced-launch-angle-plan.md) |
+| 2026-03-28 | [K-LD7 full stack integration](plans/2026-03-28-kld7-integration-plan.md) |
+| 2026-04-05 | [K-LD7 raw ADC processing](plans/2026-04-05-kld7-radc-processing-plan.md) |
+| 2026-04-06 | [RADC launch angle full-stack](plans/2026-04-06-radc-launch-angle-fullstack.md) |
+| 2026-04-10 | [Spin detection rework](plans/2026-04-10-spin-detection-rework-plan.md) |
+| 2026-04-15 | [Spin & angle data quality validation](superpowers-plans/2026-04-15-spin-angle-validation.md) |
+| 2026-04-20 | [Hardware diagnostic script](superpowers-plans/2026-04-20-hardware-diagnostic.md) |
+| 2026-06-09 | [K-LD7 timing and PRF probe](superpowers-plans/2026-06-09-kld7-prf-probing.md) |
+| 2026-06-13 | [Sim connector abstraction](superpowers-plans/2026-06-13-sim-connector-abstraction.md) |
+| 2026-08-09 | [Documentation audit](superpowers-plans/2026-08-09-documentation-audit.md) |
+| 2026-08-23 | [Zensical docs site migration](plans/2026-08-23-zensical-docs-site-plan.md) |
+| — | [K-LD7 RADC club head extraction (TODO)](plans/kld7-club-extraction-TODO.md) |
+
+## Pull request notes
+
+| Date | Document |
+| --- | --- |
+| 2026-04-02 | [K-LD7 shot correlation and false-positive filtering](prs/2026-04-02-kld7-shot-correlation-pr.md) |
diff --git a/docs/plans/2026-03-03-enhanced-launch-angle-design.md b/docs/archive/plans/2026-03-03-enhanced-launch-angle-design.md
similarity index 89%
rename from docs/plans/2026-03-03-enhanced-launch-angle-design.md
rename to docs/archive/plans/2026-03-03-enhanced-launch-angle-design.md
index f4b7256a5..f234e0c2a 100644
--- a/docs/plans/2026-03-03-enhanced-launch-angle-design.md
+++ b/docs/archive/plans/2026-03-03-enhanced-launch-angle-design.md
@@ -1,5 +1,13 @@
# Enhanced Launch Angle Estimation Design
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
## Problem
The camera-based launch angle measurement is fragile (Hough circle detection, depth estimation heuristics, lighting sensitivity). The fallback is a per-club lookup table that only uses ball speed, producing low-confidence estimates. Additionally, launch angle is never used in carry distance calculation -- it only affects the uncertainty band width.
diff --git a/docs/plans/2026-03-03-enhanced-launch-angle-plan.md b/docs/archive/plans/2026-03-03-enhanced-launch-angle-plan.md
similarity index 98%
rename from docs/plans/2026-03-03-enhanced-launch-angle-plan.md
rename to docs/archive/plans/2026-03-03-enhanced-launch-angle-plan.md
index 85454c2c9..a8f53e833 100644
--- a/docs/plans/2026-03-03-enhanced-launch-angle-plan.md
+++ b/docs/archive/plans/2026-03-03-enhanced-launch-angle-plan.md
@@ -1,5 +1,13 @@
# Enhanced Launch Angle Estimation — Implementation Plan
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Improve launch angle estimation by incorporating smash factor and spin rate, then wire launch angle into carry distance calculation.
diff --git a/docs/plans/2026-03-23-kld7-angle-radar-design.md b/docs/archive/plans/2026-03-23-kld7-angle-radar-design.md
similarity index 92%
rename from docs/plans/2026-03-23-kld7-angle-radar-design.md
rename to docs/archive/plans/2026-03-23-kld7-angle-radar-design.md
index ed1e856f2..a4b1accdb 100644
--- a/docs/plans/2026-03-23-kld7-angle-radar-design.md
+++ b/docs/archive/plans/2026-03-23-kld7-angle-radar-design.md
@@ -1,5 +1,13 @@
# K-LD7 Angle Radar Integration — Design
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
**Date:** 2026-03-23
**Status:** Exploration phase
diff --git a/docs/plans/2026-03-28-kld7-integration-design.md b/docs/archive/plans/2026-03-28-kld7-integration-design.md
similarity index 94%
rename from docs/plans/2026-03-28-kld7-integration-design.md
rename to docs/archive/plans/2026-03-28-kld7-integration-design.md
index 76e55e4a8..913f345db 100644
--- a/docs/plans/2026-03-28-kld7-integration-design.md
+++ b/docs/archive/plans/2026-03-28-kld7-integration-design.md
@@ -1,5 +1,13 @@
# K-LD7 Full Stack Integration — Design
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
**Date:** 2026-03-28
**Status:** Approved
diff --git a/docs/plans/2026-03-28-kld7-integration-plan.md b/docs/archive/plans/2026-03-28-kld7-integration-plan.md
similarity index 98%
rename from docs/plans/2026-03-28-kld7-integration-plan.md
rename to docs/archive/plans/2026-03-28-kld7-integration-plan.md
index fe6a24f59..8468aa7ac 100644
--- a/docs/plans/2026-03-28-kld7-integration-plan.md
+++ b/docs/archive/plans/2026-03-28-kld7-integration-plan.md
@@ -1,5 +1,13 @@
# K-LD7 Full Stack Integration — Implementation Plan
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Integrate the K-LD7 angle radar into the openflight server so it provides real-time vertical (or horizontal) angle data alongside OPS243 speed/spin data, displayed in the UI.
diff --git a/docs/plans/2026-04-05-kld7-radc-processing-design.md b/docs/archive/plans/2026-04-05-kld7-radc-processing-design.md
similarity index 94%
rename from docs/plans/2026-04-05-kld7-radc-processing-design.md
rename to docs/archive/plans/2026-04-05-kld7-radc-processing-design.md
index a8c32fbba..f6743bbcd 100644
--- a/docs/plans/2026-04-05-kld7-radc-processing-design.md
+++ b/docs/archive/plans/2026-04-05-kld7-radc-processing-design.md
@@ -1,5 +1,13 @@
# K-LD7 Raw ADC Processing — Design Spec
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
**Date:** 2026-04-05
**Status:** Draft
diff --git a/docs/plans/2026-04-05-kld7-radc-processing-plan.md b/docs/archive/plans/2026-04-05-kld7-radc-processing-plan.md
similarity index 99%
rename from docs/plans/2026-04-05-kld7-radc-processing-plan.md
rename to docs/archive/plans/2026-04-05-kld7-radc-processing-plan.md
index 2d77cc309..c9c879036 100644
--- a/docs/plans/2026-04-05-kld7-radc-processing-plan.md
+++ b/docs/archive/plans/2026-04-05-kld7-radc-processing-plan.md
@@ -1,5 +1,13 @@
# K-LD7 Raw ADC Processing — Implementation Plan
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Build capture and analysis tools for K-LD7 raw I/Q ADC data, enabling custom FFT + CFAR signal processing that can detect ball returns the module's built-in detector misses.
@@ -1380,4 +1388,4 @@ Plan complete and saved to `docs/plans/2026-04-05-kld7-radc-processing-plan.md`.
**2. Inline Execution** — Execute tasks in this session, batch execution with checkpoints
-Which approach?
\ No newline at end of file
+Which approach?
diff --git a/docs/plans/2026-04-06-radc-launch-angle-fullstack.md b/docs/archive/plans/2026-04-06-radc-launch-angle-fullstack.md
similarity index 98%
rename from docs/plans/2026-04-06-radc-launch-angle-fullstack.md
rename to docs/archive/plans/2026-04-06-radc-launch-angle-fullstack.md
index 91a623e6e..6e5d8c2f9 100644
--- a/docs/plans/2026-04-06-radc-launch-angle-fullstack.md
+++ b/docs/archive/plans/2026-04-06-radc-launch-angle-fullstack.md
@@ -1,5 +1,13 @@
# RADC Launch Angle Full-Stack Integration Plan
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Ship RADC-based phase-interferometry launch angle extraction into the production K-LD7 tracker, replacing the PDAT distance-based method as primary, with PDAT as fallback.
diff --git a/docs/plans/2026-04-10-spin-detection-rework-design.md b/docs/archive/plans/2026-04-10-spin-detection-rework-design.md
similarity index 95%
rename from docs/plans/2026-04-10-spin-detection-rework-design.md
rename to docs/archive/plans/2026-04-10-spin-detection-rework-design.md
index cedb5536f..36cae667d 100644
--- a/docs/plans/2026-04-10-spin-detection-rework-design.md
+++ b/docs/archive/plans/2026-04-10-spin-detection-rework-design.md
@@ -1,5 +1,13 @@
# Spin Detection Rework: Amplitude Envelope Demodulation
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
## Problem
The current spin detection extracts ~30 ball speed values from overlapping FFT windows (937 Hz effective sample rate), then runs a secondary 256-point FFT to find spin modulation frequency. With only 30 data points, the frequency resolution is 220 RPM/bin — too coarse to detect real spin. 81% of captures show spectral leakage artifacts, and the 19% that "pass" are Hann window sidelobe artifacts at exactly 1318 and 1538 rpm (FFT bins 6 and 7).
diff --git a/docs/plans/2026-04-10-spin-detection-rework-plan.md b/docs/archive/plans/2026-04-10-spin-detection-rework-plan.md
similarity index 98%
rename from docs/plans/2026-04-10-spin-detection-rework-plan.md
rename to docs/archive/plans/2026-04-10-spin-detection-rework-plan.md
index 23f67bd52..0fd952304 100644
--- a/docs/plans/2026-04-10-spin-detection-rework-plan.md
+++ b/docs/archive/plans/2026-04-10-spin-detection-rework-plan.md
@@ -1,5 +1,13 @@
# Spin Detection Rework Implementation Plan
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the broken secondary-FFT spin detection with amplitude envelope demodulation that works directly on the raw I/Q capture, detecting the golf ball seam modulation at 2x spin rate.
diff --git a/docs/archive/plans/2026-08-23-zensical-docs-site-plan.md b/docs/archive/plans/2026-08-23-zensical-docs-site-plan.md
new file mode 100644
index 000000000..94d737bef
--- /dev/null
+++ b/docs/archive/plans/2026-08-23-zensical-docs-site-plan.md
@@ -0,0 +1,636 @@
+# Zensical Docs Site — Migration Plan
+
+!!! warning "ARCHIVED DOCUMENT"
+
+ This is a historical design or implementation note, kept as a record of why
+ the code is shaped the way it is. It describes the project as of the date in
+ its filename and **is not a guide to follow** — commands, paths, and
+ constants may no longer match the code. See the
+ [Archive index](../index.md) for current alternatives.
+
+**Date:** 2026-08-23
+**Status:** Proposed — awaiting decisions on Issues 1–4
+**Scope:** Convert `docs/` into a published Zensical site with a coherent
+information architecture, without losing content or breaking existing links.
+
+---
+
+## 1. What exists today
+
+### 1.1 The Zensical scaffold (untracked, unmodified)
+
+`zensical new` was run *inside* `docs/`, producing:
+
+| Path | State |
+|---|---|
+| `docs/zensical.toml` | Default scaffold. `site_url` points at a GitHub repo URL, not a Pages URL. `nav` lists only the two starter pages. |
+| `docs/docs/index.md` | Zensical's "Get started" demo page — feature showcase, zero OpenFlight content. |
+| `docs/docs/markdown.md` | Zensical's "Markdown in 5min" demo page. |
+| `docs/docs/assets/images/` | `openflight-logo.png`, `favicon.svg` — real assets, not yet referenced by the theme config. |
+| `docs/site/` | Built output of the two demo pages. Untracked, not gitignored. |
+| `docs/.cache/` | Zensical build cache. Untracked, not gitignored. |
+| `docs/.github/workflows/docs.yml` | Deploy workflow in the **wrong location** — GitHub only reads `.github/workflows/` at the repo root. |
+
+Zensical `0.0.57` is installed globally (mise Python 3.11), **not** in the
+project venv and **not** in `pyproject.toml`. `uv run zensical` currently works
+only by falling through to the global binary.
+
+Nothing from the real documentation set has been migrated. The scaffold is a
+blank slate.
+
+### 1.2 The real documentation set
+
+**47 real Markdown files** live under `docs/` (49 including the two scaffold demo
+pages), plus 3 standalone HTML pages, 6 PDFs, and ~15 images/SVGs. Another 6
+Markdown files sit outside `docs/` but are linked from it.
+
+Grouped by what they actually are:
+
+**Build & setup guides (9 files, 15,602 words) — the highest-value content**
+
+- `PARTS.md` — bill of materials, cost summary, IWR6843 vs deprecated K-LD7
+- `sound-trigger-wiring.md` — SEN-14262 → OPS243 `HOST_INT`, R17 solder step
+- `ops243-uart-migration.md` — USB → Pi GPIO UART, prerequisite for the IWR6843
+- `iwr6843/README.md` — **5,647 words**, the single largest doc: wire, flash,
+ mount, aim, measure geometry, verify first capture, club path
+- `iwr6843/low-confidence-recovery.md` — OPS-guided vertical recovery policy
+- `inclinometer/README.md` — LIS3DH tilt compensation wiring + calibration
+- `battery/README.md` — battery provider architecture, UI indicator states
+- `battery/geekworm.md` — X1202/X1206 operator guide
+- `raspberry-pi-setup.md` — full Pi setup, auto-start, kiosk mode
+
+**Operating guides (7 files, ~5k words)**
+
+- `simulator/README.md`, `simulator/gspro.md`, `simulator/opengolfsim.md`
+- `cloud-sync.md`
+- `observability.md` — Grafana Cloud / Alloy log shipping
+- `swing-speed-training.md`
+- `rolling_buffer_spin_detection.md`
+
+**Theory & reference (4 files + 3 HTML)**
+
+- `openflight-cloud-uploader-spec.md` — wire contract, versioned API
+- `spin-dechirp-replay.md` — next-gen spin estimator test bench
+- `trackman-test-process.md` — validation methodology
+- `yolo-performance-tuning.md` — camera experiments (explicitly non-production)
+- `iwr6843_field_report_2026-07.html` — **75 KB**, self-styled dark-theme page.
+ Plain-language explanation of the OPS243 + IWR6843 pipeline and LCMF-v1.
+- `kld7-launch-angle-explained.html` — **57 KB**, same treatment for K-LD7
+- `color_palette.html` — UI colour swatch page referenced from `CONTRIBUTING.md`
+
+**Deprecated K-LD7 hardware (5 files, ~10k words)**
+
+`kld7.md`, `kld7-troubleshooting.md`, `kld7-ball-detection-theory.md`,
+`kld7-session-review.md`, plus the two K-LD7 datasheet PDFs.
+
+**Internal development artifacts (22 files, 39,228 words) — should not publish**
+
+- `docs/plans/` (11 files) — dated design + implementation plans, many
+ containing full source listings and test bodies
+- `docs/superpowers/plans/` (5) and `docs/superpowers/specs/` (5)
+- `docs/prs/` (1) — a single PR write-up
+
+These are engineering history. They are valuable, but as *repository* history,
+not as pages on a user-facing site.
+
+**Docs outside `docs/` that are linked from inside it**
+
+- `firmware/README.md` — IWR6843 firmware build guide (linked twice from
+ `iwr6843/README.md`, once from root `README.md`)
+- `scripts/analysis/kld7_analysis_tooling.md` (linked from `kld7.md`)
+- `cad/IARC_case/README.md` (linked from `PARTS.md`)
+- `CONTRIBUTING.md`, `ui/README.md`, `src/analysis/README.md`
+- `archive/golf-launch-monitor-wiring-guide.md` — superseded
+
+**Root `README.md` (15.7 KB)** duplicates a large share of the above: overview,
+what it measures, hardware table, 5-step getting started, TV display mode, swing
+speed, system architecture, radar positioning, configuration, Python API,
+limitations, hardware diagnostic, project structure, and a 21-item hand-maintained
+documentation index.
+
+---
+
+## 2. Proposed information architecture
+
+Seven top-level sections, organised by **what the reader is trying to do**, not
+by which subsystem the file happens to describe. `navigation.tabs` should be
+enabled — eight top-level entries in a sidebar is a wall.
+
+```
+Home index.md (new — what it is, what it measures,
+ accuracy claims, pick your path)
+
+Get started
+ Overview (new — how a shot becomes numbers)
+ Parts list PARTS.md
+ Build order (new — the sequence, with prerequisites called out)
+ Quick start (new — from assembled hardware to first shot)
+
+Build
+ Sound trigger wiring sound-trigger-wiring.md
+ OPS243 → GPIO UART ops243-uart-migration.md
+ IWR6843 angle radar
+ Overview iwr6843/index.md (split from iwr6843/README.md)
+ Wiring iwr6843/wiring.md
+ Flashing firmware iwr6843/flashing.md
+ Mounting & aiming iwr6843/mounting.md
+ Geometry & calibration iwr6843/geometry.md
+ First capture iwr6843/verify.md
+ Club path iwr6843/club-path.md
+ Inclinometer (LIS3DH) inclinometer/index.md
+ Battery (Geekworm) battery/geekworm.md
+ Enclosure & CAD (new — imported from cad/IARC_case/README.md)
+
+Setup
+ Raspberry Pi setup raspberry-pi-setup.md
+ Rolling buffer setup (extracted — the one-time flash-persist procedure,
+ currently duplicated in 3 places)
+ Auto-start & kiosk mode (extracted from raspberry-pi-setup.md)
+ Hardware diagnostic (new — from superpowers/specs/…hardware-diagnostic)
+
+Using OpenFlight
+ Running & modes (new — every start-kiosk.sh flag, one table)
+ TV display mode (extracted from README.md)
+ Swing speed training swing-speed-training.md
+ Simulator connectors
+ Overview simulator/index.md
+ GSPro simulator/gspro.md
+ OpenGolfSim simulator/opengolfsim.md
+ Cloud sync cloud-sync.md
+ Battery monitoring battery/index.md
+ Observability observability.md
+
+How it works
+ Measurement pipeline (new — architecture diagram, mermaid)
+ Rolling buffer & spin rolling_buffer_spin_detection.md
+ IWR6843 launch angle (converted from iwr6843_field_report_2026-07.html)
+ Ballistics & carry (new — extracted from README + ballistics.py)
+ Radar positioning (extracted from README.md)
+
+Reference
+ CLI flags (new)
+ Configuration files (new — sim.json, cloud.json, config/)
+ Session log schema (new — consolidated from observability.md + CLAUDE.md)
+ Cloud uploader contract openflight-cloud-uploader-spec.md
+ Constants (new)
+ Datasheets (index page linking the 6 PDFs)
+
+Troubleshooting
+ Symptom index (new — routes by symptom to the right guide section)
+ …plus the existing per-guide troubleshooting sections, left in place
+
+Development
+ Contributing CONTRIBUTING.md (imported)
+ Architecture (new)
+ Testing (extracted from CONTRIBUTING.md)
+ Firmware build firmware/README.md (imported)
+ Analysis tooling scripts/analysis/kld7_analysis_tooling.md (imported)
+ Spin replay bench spin-dechirp-replay.md
+ TrackMan test process trackman-test-process.md
+ Camera / YOLO yolo-performance-tuning.md
+ UI colour palette color_palette.html (verbatim asset)
+
+Legacy (K-LD7) — collapsed section, deprecation banner on every page
+ Overview kld7.md
+ Troubleshooting kld7-troubleshooting.md
+ Ball detection theory kld7-ball-detection-theory.md
+ Session review kld7-session-review.md
+ Launch angle explained kld7-launch-angle-explained.html (verbatim asset)
+
+Changelog CHANGELOG.md
+```
+
+Accounting for all 25 publishable files: **23 move as-is** (plus 3 imported from
+outside `docs/` — firmware, analysis tooling, IARC case), **2 are split**
+(`iwr6843/README.md` → 7 pages, `raspberry-pi-setup.md` → 3), **1 is converted
+from HTML**, and **13 are newly authored**. The 22 internal plan/spec/PR files
+leave `docs/` entirely (Issue 2).
+
+---
+
+## Issue 1 — `docs_dir` layout: the scaffold nests `docs/docs/`
+
+**Problem.** `docs/zensical.toml` uses the default `docs_dir = "docs"`, resolved
+relative to the config file. Content root is therefore `docs/docs/`. Every one of
+the ~40 existing relative links (`docs/PARTS.md` from the README,
+`../sound-trigger-wiring.md` from `iwr6843/README.md`) assumes content lives at
+`docs/`, and `docs/docs/` is an awkward path to type, link, and explain.
+
+`config.py:390` confirms `docs_dir` and `site_dir` are both configurable, both
+resolved relative to the project root (the directory holding `zensical.toml`),
+and both validated to be distinct and inside the project root.
+
+**Option 1A — Move `zensical.toml` to the repo root, `docs_dir = "docs"`.**
+Delete `docs/docs/`, keeping only its `assets/images/` (move to `docs/assets/`).
+Content root becomes `docs/`. `site_dir = "site"` at the repo root.
+
+- Effort: 20 minutes.
+- Risk: low.
+- Impact: **every existing relative link and every `docs/…` README link keeps
+ working unchanged.** `edit_uri = "edit/main/docs/"` becomes correct. The
+ workflow's `zensical build --clean` from the repo root works with no
+ `working-directory` hack.
+- Maintenance: config sits with `pyproject.toml` / `Makefile` where a
+ contributor expects it.
+
+**Option 1B — Keep `docs/zensical.toml`, set `docs_dir = "."`.**
+
+- Effort: 5 minutes.
+- Risk: **high.** `site_dir` would then be nested inside `docs_dir`, and the
+ build cache in `docs/.cache/` sits inside the content root. Zensical only
+ validates that the two paths differ, not that they do not nest.
+- Recommend against.
+
+**Option 1C — Move all content into `docs/docs/`.**
+
+- Effort: 2 hours plus link rewriting.
+- Risk: medium — breaks every inbound link from the README, `CONTRIBUTING.md`,
+ and any external bookmark or GitHub permalink.
+- Recommend against.
+
+> **Recommendation: 1A.** It is the only option that preserves the existing link
+> graph for free, and it puts the config where the rest of the project's tooling
+> config lives. Nothing else is close.
+
+---
+
+## Issue 2 — 22 internal plan/spec documents would be published
+
+**Problem.** With `docs_dir = "docs"`, everything under `docs/plans/`,
+`docs/superpowers/`, and `docs/prs/` builds into the site. That is 39,228 words of
+dated implementation plans containing full source listings and test bodies —
+**53% of the site's word count and 47% of its pages** (39,228 of 73,743 words;
+22 of 47 files), all of it noise for anyone trying to build or run a launch
+monitor, and all of it indexed by search.
+
+Zensical exposes no `exclude_docs` / `not_in_nav` key (verified against
+`config.py`), so leaving these files in the content root and omitting them from
+`nav` does **not** stop them being built and indexed — it only orphans them.
+
+**Option 2A — Move to a repo-root `design/` directory.**
+`docs/plans/` → `design/plans/`, `docs/superpowers/` → `design/`,
+`docs/prs/` → `design/prs/`.
+
+- Effort: 30 minutes (`git mv`, plus fixing ~4 inbound links).
+- Risk: low. These files have almost no inbound links from published docs.
+- Impact: content root contains only publishable pages. History preserved and
+ browsable on GitHub. A single "Design notes" reference page in the site can
+ link to `design/` on GitHub for anyone who wants the archaeology.
+- Maintenance: creates an obvious home for future design docs, and a clear rule:
+ *`docs/` is published, `design/` is not.*
+
+**Option 2B — Publish them under a collapsed "Archive" section.**
+
+- Effort: 1 hour (needs a deprecation banner on each).
+- Risk: low technically, high for signal-to-noise. Search results for "spin"
+ would surface a 2026-04 implementation plan above the operating guide.
+- Do this only if the design history is considered part of the public value
+ proposition of the project.
+
+**Option 2C — Do nothing; leave them orphaned in the content root.**
+
+- Effort: zero.
+- Risk: medium. Orphan pages are still built, still in `sitemap.xml`, still in
+ `search.json`, still indexed by Google. Worst of both worlds.
+- Recommend against.
+
+> **Recommendation: 2A.** The DRY/signal argument is decisive: these documents
+> describe work that has already shipped, and the shipped behaviour is documented
+> in the operating guides. Keeping two descriptions of the same subsystem —
+> one current, one a snapshot from five months ago — is exactly the drift the
+> existing `2026-08-09-documentation-audit.md` plan was written to fix.
+
+---
+
+## Issue 3 — Three self-styled HTML pages
+
+**Problem.** `iwr6843_field_report_2026-07.html` (75 KB),
+`kld7-launch-angle-explained.html` (57 KB), and `color_palette.html` (8.7 KB)
+each ship their own `
+
+
+
+
+
+floor / turf
+
+
+radar
+
+
+boresight — radar aim ≈ 12.5°
+
+
+tee
+
+
+
+
+
+frame 1 · 25 ms
+frame 2 · 54 ms
+
+
+
+launch angle ≈ 16°
+
+
+net / screen
+10 ft from ball
+15 ft from radar
+radar setting: 5 m
+
+
+16.4 ft — range wraps here
+
+
+
+
+
+
+
+5 ft
+10 ft
+
diff --git a/docs/assets/legacy/kld7-angle-02.svg b/docs/assets/legacy/kld7-angle-02.svg
new file mode 100644
index 000000000..fae0ee58d
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-02.svg
@@ -0,0 +1,75 @@
+
diff --git a/docs/assets/legacy/kld7-angle-03.svg b/docs/assets/legacy/kld7-angle-03.svg
new file mode 100644
index 000000000..6825f3464
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-03.svg
@@ -0,0 +1,52 @@
+
diff --git a/docs/assets/legacy/kld7-angle-04.svg b/docs/assets/legacy/kld7-angle-04.svg
new file mode 100644
index 000000000..b1e9d0912
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-04.svg
@@ -0,0 +1,37 @@
+
diff --git a/docs/assets/legacy/kld7-angle-05.svg b/docs/assets/legacy/kld7-angle-05.svg
new file mode 100644
index 000000000..e3621f6dc
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-05.svg
@@ -0,0 +1,47 @@
+
diff --git a/docs/assets/legacy/kld7-angle-06.svg b/docs/assets/legacy/kld7-angle-06.svg
new file mode 100644
index 000000000..5446c24c4
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-06.svg
@@ -0,0 +1,31 @@
+
diff --git a/docs/assets/legacy/kld7-angle-07.svg b/docs/assets/legacy/kld7-angle-07.svg
new file mode 100644
index 000000000..7b702ccb1
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-07.svg
@@ -0,0 +1,40 @@
+
diff --git a/docs/assets/legacy/kld7-angle-08.svg b/docs/assets/legacy/kld7-angle-08.svg
new file mode 100644
index 000000000..acc063b81
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-08.svg
@@ -0,0 +1,54 @@
+
diff --git a/docs/assets/legacy/kld7-angle-09.svg b/docs/assets/legacy/kld7-angle-09.svg
new file mode 100644
index 000000000..71c2d8c0a
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-09.svg
@@ -0,0 +1,39 @@
+
diff --git a/docs/assets/legacy/kld7-angle-10.svg b/docs/assets/legacy/kld7-angle-10.svg
new file mode 100644
index 000000000..652d18817
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-10.svg
@@ -0,0 +1,42 @@
+
diff --git a/docs/assets/legacy/kld7-angle-11.svg b/docs/assets/legacy/kld7-angle-11.svg
new file mode 100644
index 000000000..7f333ba01
--- /dev/null
+++ b/docs/assets/legacy/kld7-angle-11.svg
@@ -0,0 +1,57 @@
+
diff --git a/docs/battery/geekworm.md b/docs/build/battery.md
similarity index 98%
rename from docs/battery/geekworm.md
rename to docs/build/battery.md
index 06381db54..3e3d52119 100644
--- a/docs/battery/geekworm.md
+++ b/docs/build/battery.md
@@ -2,7 +2,7 @@
This guide covers installation, Raspberry Pi configuration, verification, and
OpenFlight battery monitoring for the Geekworm X1202 and X1206 UPS boards.
-See the [battery monitoring overview](README.md) for the shared provider
+See the [battery monitoring overview](https://github.com/jewbetcha/openflight/blob/main/README.md) for the shared provider
architecture, UI behavior, and session logging contract.
Both boards use the same telemetry interface:
diff --git a/docs/build/enclosure.md b/docs/build/enclosure.md
new file mode 100644
index 000000000..23a9fc3cb
--- /dev/null
+++ b/docs/build/enclosure.md
@@ -0,0 +1,53 @@
+---
+icon: lucide/box
+---
+
+# Enclosure & Case
+
+The IARC v3 case holds the OPS243-A, the sound sensor, the angle radar, the
+monitor, and the Raspberry Pi in one printed assembly.
+
+STL, STEP, and 3MF files live in
+[`cad/IARC_case/`](https://github.com/jewbetcha/openflight/tree/main/cad/IARC_case)
+in the repository.
+
+**Fasteners:** 10 × M5 bolts, 12 × M2.5 bolts.
+
+!!! warning "K-LD7 mounts are for deprecated hardware"
+
+ The KLD7 mounts in this case are for the deprecated K-LD7 angle radars.
+ OpenFlight has moved to the TI IWR6843. Skip the KLD7 mount steps for a
+ new build.
+
+
+
+## Assembly
+
+
+1. Print out `sensor_housing.stl` 
+2. Print out (2x) `OPS_mount.stl` 
+3. Secure the OPS sensor to each `OPS_mount.stl` part with screws (I don't know which ones)
+4. Secure the `OPS_mount.stl` parts to the `sensor_housing.stl` part with (2x) M5 bolt 
+5. Fit the sound sensor into the little slot on the left side of the `sensor_housing.stl` part. 
+6. Print out (1x) `KLD7_h_mount_L_v2.stl` and (1x) `KLD7_h_mount_R_v2.stl` 
+7. Slot a KLD7 sensor between the mount parts, and secure them to the `sensor_housing.stl` part with (2x) M5 bolt 
+8. Print out (1x) `KLD7_v_mount_L_v2.stl` and (1x) `KLD7_v_mount_R_v2.stl` 
+6. Slot a KLD7 sensor between the mount parts, and secure them to the `sensor_housing.stl` part with (2x) M5 bolt 
+7. Print out the `monitor_mount.stl` part. 
+8. Print out (4x) `monitor_standoffs.stl`'s. 
+9. Secure each `monitor_standoff.stl` part to the corners of the `monitor_mount.stl` part with (1x) M2.5 bolt. 
+10. Secure the monitor to the `monitor_mount.stl` part. 
+11. Secure the raspberry Pi to the monitor. 
+12. Print out the `monitor_shell.stl` part. If you use the Raspberry Pi Touch Display 2 instead of the 7" HMTECH touchscreen, print `Touch_Display2_shell.stl` and `Touch_Display2_backplate.stl` instead. 
+13. Secure the monitor assembly inside the `monitor_shell.stl` part with (4x) M2.5 bolts at the corners. 
+14. Print out the `monitor_back.stl` part. 
+15. Secure the `monitor_back.stl` part to the `monitor_standoffs.stl` parts with (4x) M2.5 bolts. 
+16. Slide the whole assembly into the sensor housing assembly, using the dovetails to align everything together. 
+17. Print out the `name_plate.stl` file. 
+18. Secure the `name_plate.stl` file to the front of the whole assembly with glue. 
+19. Print out the `curvy_backplate.stl` file. 
+20. Secure the `curvy_backplate.stl` file to the whole assembly with (4x) M2.5 bolts. 
+21. Print out (4x) `case_foot.stl` parts. 
+22. Secure each foot to the bottom of the whole assembly with (4x) M5 Bolts. 
+
+And that’s how you put together the 3d printed parts!
diff --git a/cad/IARC_case/images/IARC_case_v3.png b/docs/build/images/IARC_case_v3.png
similarity index 100%
rename from cad/IARC_case/images/IARC_case_v3.png
rename to docs/build/images/IARC_case_v3.png
diff --git a/cad/IARC_case/images/KLD7_mounts.png b/docs/build/images/KLD7_mounts.png
similarity index 100%
rename from cad/IARC_case/images/KLD7_mounts.png
rename to docs/build/images/KLD7_mounts.png
diff --git a/cad/IARC_case/images/bottom_KLD7_mount.png b/docs/build/images/bottom_KLD7_mount.png
similarity index 100%
rename from cad/IARC_case/images/bottom_KLD7_mount.png
rename to docs/build/images/bottom_KLD7_mount.png
diff --git a/cad/IARC_case/images/case_foot_print.png b/docs/build/images/case_foot_print.png
similarity index 100%
rename from cad/IARC_case/images/case_foot_print.png
rename to docs/build/images/case_foot_print.png
diff --git a/cad/IARC_case/images/curvy_backplate_print.png b/docs/build/images/curvy_backplate_print.png
similarity index 100%
rename from cad/IARC_case/images/curvy_backplate_print.png
rename to docs/build/images/curvy_backplate_print.png
diff --git a/cad/IARC_case/images/curvy_backplate_secure.png b/docs/build/images/curvy_backplate_secure.png
similarity index 100%
rename from cad/IARC_case/images/curvy_backplate_secure.png
rename to docs/build/images/curvy_backplate_secure.png
diff --git a/cad/IARC_case/images/feet_attach.png b/docs/build/images/feet_attach.png
similarity index 100%
rename from cad/IARC_case/images/feet_attach.png
rename to docs/build/images/feet_attach.png
diff --git a/cad/IARC_case/images/monitor_back.png b/docs/build/images/monitor_back.png
similarity index 100%
rename from cad/IARC_case/images/monitor_back.png
rename to docs/build/images/monitor_back.png
diff --git a/cad/IARC_case/images/monitor_back_secure.png b/docs/build/images/monitor_back_secure.png
similarity index 100%
rename from cad/IARC_case/images/monitor_back_secure.png
rename to docs/build/images/monitor_back_secure.png
diff --git a/cad/IARC_case/images/monitor_mount_corners.png b/docs/build/images/monitor_mount_corners.png
similarity index 100%
rename from cad/IARC_case/images/monitor_mount_corners.png
rename to docs/build/images/monitor_mount_corners.png
diff --git a/cad/IARC_case/images/monitor_mount_print.png b/docs/build/images/monitor_mount_print.png
similarity index 100%
rename from cad/IARC_case/images/monitor_mount_print.png
rename to docs/build/images/monitor_mount_print.png
diff --git a/cad/IARC_case/images/monitor_secure.png b/docs/build/images/monitor_secure.png
similarity index 100%
rename from cad/IARC_case/images/monitor_secure.png
rename to docs/build/images/monitor_secure.png
diff --git a/cad/IARC_case/images/monitor_shell_assembly.png b/docs/build/images/monitor_shell_assembly.png
similarity index 100%
rename from cad/IARC_case/images/monitor_shell_assembly.png
rename to docs/build/images/monitor_shell_assembly.png
diff --git a/cad/IARC_case/images/monitor_shell_print.png b/docs/build/images/monitor_shell_print.png
similarity index 100%
rename from cad/IARC_case/images/monitor_shell_print.png
rename to docs/build/images/monitor_shell_print.png
diff --git a/cad/IARC_case/images/monitor_standoffs_print.png b/docs/build/images/monitor_standoffs_print.png
similarity index 100%
rename from cad/IARC_case/images/monitor_standoffs_print.png
rename to docs/build/images/monitor_standoffs_print.png
diff --git a/cad/IARC_case/images/name_plate_glue.png b/docs/build/images/name_plate_glue.png
similarity index 100%
rename from cad/IARC_case/images/name_plate_glue.png
rename to docs/build/images/name_plate_glue.png
diff --git a/cad/IARC_case/images/name_plate_print.png b/docs/build/images/name_plate_print.png
similarity index 100%
rename from cad/IARC_case/images/name_plate_print.png
rename to docs/build/images/name_plate_print.png
diff --git a/cad/IARC_case/images/ops_mount.png b/docs/build/images/ops_mount.png
similarity index 100%
rename from cad/IARC_case/images/ops_mount.png
rename to docs/build/images/ops_mount.png
diff --git a/cad/IARC_case/images/rasp_secure.png b/docs/build/images/rasp_secure.png
similarity index 100%
rename from cad/IARC_case/images/rasp_secure.png
rename to docs/build/images/rasp_secure.png
diff --git a/cad/IARC_case/images/secure_ops_mount.png b/docs/build/images/secure_ops_mount.png
similarity index 100%
rename from cad/IARC_case/images/secure_ops_mount.png
rename to docs/build/images/secure_ops_mount.png
diff --git a/cad/IARC_case/images/sensor_housing.png b/docs/build/images/sensor_housing.png
similarity index 100%
rename from cad/IARC_case/images/sensor_housing.png
rename to docs/build/images/sensor_housing.png
diff --git a/cad/IARC_case/images/sound_placement.png b/docs/build/images/sound_placement.png
similarity index 100%
rename from cad/IARC_case/images/sound_placement.png
rename to docs/build/images/sound_placement.png
diff --git a/cad/IARC_case/images/top_KLD7_mount.png b/docs/build/images/top_KLD7_mount.png
similarity index 100%
rename from cad/IARC_case/images/top_KLD7_mount.png
rename to docs/build/images/top_KLD7_mount.png
diff --git a/cad/IARC_case/images/whole_assembly.png b/docs/build/images/whole_assembly.png
similarity index 100%
rename from cad/IARC_case/images/whole_assembly.png
rename to docs/build/images/whole_assembly.png
diff --git a/docs/inclinometer/README.md b/docs/build/inclinometer.md
similarity index 100%
rename from docs/inclinometer/README.md
rename to docs/build/inclinometer.md
diff --git a/docs/build/index.md b/docs/build/index.md
new file mode 100644
index 000000000..d8b9e12f0
--- /dev/null
+++ b/docs/build/index.md
@@ -0,0 +1,45 @@
+---
+icon: lucide/hammer
+---
+
+# Build
+
+Assembling the hardware. The order matters — each step assumes the one before
+it is done and verified.
+
+
+
+- :material-numeric-1-circle-outline: **[Sound trigger wiring](sound-trigger.md)**
+
+ Solder R17, wire `GATE` → `HOST_INT`. Everything downstream depends on this
+ trigger path.
+
+- :material-numeric-2-circle-outline: **[OPS243 → GPIO UART](ops243-uart.md)**
+
+ Move the OPS243 off USB. Required before the IWR6843, which needs the bus.
+
+- :material-numeric-3-circle-outline: **[IWR6843 angle radar](../iwr6843/index.md)**
+
+ Wire, flash, mount, aim, and calibrate. The largest single step.
+
+- :material-plus-circle-outline: **[Inclinometer](inclinometer.md)**
+
+ *Optional.* LIS3DH enclosure-level tilt compensation.
+
+- :material-battery-outline: **[Battery](battery.md)**
+
+ *Optional.* Geekworm X1202/X1206 UPS with native telemetry.
+
+- :material-cube-outline: **[Enclosure & case](enclosure.md)**
+
+ *Optional.* The printed IARC v3 case.
+
+
+
+## Before you start
+
+Order everything from the **[parts list](../get-started/parts.md)** first — some
+items have long lead times, and a partial build cannot be verified end to end.
+
+You will need a soldering iron for exactly one joint: the R17 gain resistor on
+the SEN-14262. See [sound trigger wiring](sound-trigger.md#before-you-wire-solder-r17).
diff --git a/docs/ops243-uart-migration.md b/docs/build/ops243-uart.md
similarity index 94%
rename from docs/ops243-uart-migration.md
rename to docs/build/ops243-uart.md
index ffe81322d..0ac950b02 100644
--- a/docs/ops243-uart-migration.md
+++ b/docs/build/ops243-uart.md
@@ -18,7 +18,7 @@ time makes any failure ambiguous.
> already drives the radar's UART receive line, so J3 pin 6 cannot be used and
> the server could never send configuration or re-arm commands. Use a
> separately powered USB hub instead — see
-> [Option B in the IWR6843 guide](iwr6843/README.md).
+> [Option B in the IWR6843 guide](../iwr6843/index.md).
## What changes in software
@@ -63,7 +63,7 @@ on the board before counting.
| Pin 6 | `RxD` (in) | 8 | GPIO14 / `TXD0` |
| Pin 3 | `HOST_INT` | — | sound detector `GATE` (unchanged) |
-
+
*The diagram picks physical pin 4 for 5V and pin 14 for ground; any 5V and any
GND pin work as long as the ground is the same rail the sound detector uses.*
@@ -179,7 +179,11 @@ The session log records the negotiated baud in its `connection` entry.
## Re-running the one-time rolling-buffer setup
Only needed if the radar has lost its persisted rolling-buffer mode (symptom:
-no hardware triggers at all, or the diagnostic reporting CW-mode streaming):
+no hardware triggers at all, or the diagnostic reporting CW-mode streaming).
+
+The base procedure is [Rolling buffer setup](../setup/rolling-buffer.md); over
+UART it differs in two ways — an explicit `--port`, and a different way to
+power-cycle:
```bash
uv run python scripts/hardware-test/test_rolling_buffer_persist.py --setup \
@@ -214,6 +218,6 @@ jumpers (leave `GATE` → `HOST_INT` and the shared ground), and drop
## See also
-- [Sound trigger wiring](sound-trigger-wiring.md) — the `GATE` → `HOST_INT` path
-- [IWR6843 integration](iwr6843/README.md) — adding the TI radar afterwards
-- [Raspberry Pi setup](raspberry-pi-setup.md) — base OS configuration
+- [Sound trigger wiring](sound-trigger.md) — the `GATE` → `HOST_INT` path
+- [IWR6843 integration](../iwr6843/index.md) — adding the TI radar afterwards
+- [Raspberry Pi setup](../setup/raspberry-pi.md) — base OS configuration
diff --git a/docs/sound-trigger-wiring.md b/docs/build/sound-trigger.md
similarity index 87%
rename from docs/sound-trigger-wiring.md
rename to docs/build/sound-trigger.md
index e22673367..640844294 100644
--- a/docs/sound-trigger-wiring.md
+++ b/docs/build/sound-trigger.md
@@ -2,11 +2,11 @@
Step-by-step instructions for wiring the sound trigger that enables spin detection in rolling buffer mode.
-> **Parts needed:** See the [Parts List](PARTS.md#sound-trigger-for-rolling-buffer-mode) for what to buy.
+> **Parts needed:** See the [Parts List](../get-started/parts.md#sound-trigger-for-rolling-buffer-mode) for what to buy.
> **Running the OPS243 on the Pi GPIO UART instead of USB?** This trigger
> wiring is unchanged — `GATE` still drives `HOST_INT` directly. See
-> [Moving the OPS243 from USB to the Pi GPIO UART](ops243-uart-migration.md)
+> [Moving the OPS243 from USB to the Pi GPIO UART](ops243-uart.md)
> for the data and power side.
## Overview
@@ -23,11 +23,11 @@ SEN-14262 GND → Raspberry Pi GND
OPS243-A GND → Raspberry Pi GND (J3 Pin 10, shared rail)
```
-
+
*The OPS243 keeps its micro-USB connection for data and power here; only the
trigger and ground are wired by hand. To take the radar off USB as well, see
-[the UART migration](ops243-uart-migration.md).*
+[the UART migration](ops243-uart.md).*
## Before You Wire: Solder R17
@@ -104,17 +104,12 @@ SEN-14262 Raspberry Pi OPS243-A
## One-Time Radar Setup
-The OPS243-A must have rolling buffer mode saved to persistent memory for HOST_INT triggers to work. This is due to a firmware bug where the HOST_INT pin mode changes when transitioning modes at runtime.
+With the wiring done, the OPS243-A needs rolling-buffer mode saved to
+persistent flash before `HOST_INT` triggers will work.
-```bash
-# Configure and save rolling buffer mode to flash (one-time)
-uv run python scripts/hardware-test/test_rolling_buffer_persist.py --setup
-
-# Power cycle the radar (unplug USB, wait 3s, replug)
+**→ [Rolling buffer setup](../setup/rolling-buffer.md)**
-# Verify
-uv run python scripts/hardware-test/test_rolling_buffer_persist.py --test
-```
+Come back here for the trigger tests below once that is done.
---
diff --git a/docs/CHANGELOG.md b/docs/changelog.md
similarity index 98%
rename from docs/CHANGELOG.md
rename to docs/changelog.md
index 1ed1c6551..0e4d5c979 100644
--- a/docs/CHANGELOG.md
+++ b/docs/changelog.md
@@ -43,7 +43,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
visible. The footer logo opens units, dark/light theme, language, simulator,
and ball-detection status; a persistent footer power button opens the shutdown
confirmation. Club (or training implement) selection is a Live header action.
- See the [UI README](../ui/README.md).
+ See the [UI README](https://github.com/jewbetcha/openflight/blob/main/ui/README.md).
- **Kiosk languages.** English, Spanish, French, and Portuguese. Choice is
stored in `localStorage` (`openflight.locale:v1`).
- **Dark and light themes.** Toggle in the footer menu; stored as
@@ -66,7 +66,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
`--battery geekworm`; monitoring remains disabled when no provider is
selected. The accompanying Pi setup installs native Linux power-supply
telemetry and optional taskbar capacity support without enabling automatic
- shutdown or charging control. See [Battery Monitoring](battery/README.md).
+ shutdown or charging control. See [Battery Monitoring](using/battery.md).
- **System Prerequisites:** Documented missing binary dependencies (`swig`, `liblgpio-dev`, `python3-dev`) required prior to executing `./scripts/setup/setup.sh`.
- **Environment Reload Guidance:** Added instructions for reloading terminal environment variables (`source ~/.bashrc`) when installed dependencies or scripts (`setup.sh`, `start-kiosk.sh`) are not recognized in the current terminal session.
- **Configurable IWR6843 capture compression.** One firmware image can now
@@ -89,7 +89,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
UART-only failures that all look like an unresponsive radar — missing device
node, a login console holding the port, and the OPS USB cable still plugged in
(which silences the UART). See
- [Moving the OPS243 from USB to the Pi GPIO UART](ops243-uart-migration.md).
+ [Moving the OPS243 from USB to the Pi GPIO UART](build/ops243-uart.md).
- **Flash IWR6843 firmware directly from a Raspberry Pi.** Contributors no
longer need an Intel Mac, UniFlash, or TI Cloud Agent for routine firmware
updates. The guided terminal workflow verifies the image hash, offers a
@@ -244,7 +244,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Known Limitations
Deferred pending a session paired with a reference instrument. See
-[the IWR6843 operator guide](iwr6843/README.md#launch-angle-estimator-limitations).
+[the IWR6843 operator guide](iwr6843/calibration.md#launch-angle-estimator-limitations).
- **The calibration tilt sweep cannot recommend a tilt.** It minimises
`component_std_deg`, which is monotonic in tilt across the swept window, so
diff --git a/docs/color_palette.html b/docs/color_palette.html
deleted file mode 100644
index 1029d0e7a..000000000
--- a/docs/color_palette.html
+++ /dev/null
@@ -1,271 +0,0 @@
-
-
-
-
-
- OpenFlight Color Palette
-
-
-
-
-
-
diff --git a/docs/development/analysis-tooling.md b/docs/development/analysis-tooling.md
new file mode 100644
index 000000000..1ff015f96
--- /dev/null
+++ b/docs/development/analysis-tooling.md
@@ -0,0 +1,471 @@
+# K-LD7 Analysis Tooling
+
+> **⚠️ DEPRECATED:** The K-LD7 angle radars are deprecated — OpenFlight has moved to a more capable radar chip for angle measurement. This tooling is kept for existing K-LD7 builds and historical session logs only.
+
+This guide covers the K-LD7 offline and live analysis workflow built around:
+
+- `kld7_geometry_selection_report.py`
+- `kld7_live_sync.py`
+- `kld7_timing_shift_visualizer.html`
+
+The goal is to make K-LD7 frame selection visible shot-by-shot so we can answer
+questions that are difficult to see from the kiosk UI alone:
+
+- Which RADC frames were considered?
+- Which frames did the replay selector choose?
+- Did the chosen frames match the OPS ball-speed bin?
+- Did the selected bearings produce a plausible launch angle?
+- Would a small whole-shot timing shift improve the geometry fit?
+- Does F1B range agree with the expected ball position?
+
+This tooling is for analysis and development. It does not replace the live kiosk
+path, but `frames_live.csv` is intended to mirror the current OPS-bin/live-style
+selection logic closely enough to debug production behavior.
+
+## File Roles
+
+`kld7_geometry_selection_report.py`
+
+Builds a report from a saved OpenFlight session JSONL. It extracts K-LD7 RADC
+frames, replays vertical frame selection, writes CSV files, copies the HTML
+visualizer into the report folder, and writes a session summary.
+
+`kld7_live_sync.py`
+
+Polls the Pi over SSH/SCP, pulls the latest session JSONL when new shots appear,
+regenerates the report, and serves the visualizer locally from the generated
+report directory.
+
+`kld7_timing_shift_visualizer.html`
+
+Browser tool for loading `frames.csv` or `frames_live.csv`, selecting a shot and
+up to two frames, and visualizing launch-angle geometry, timing shifts, F1B
+range overlays, and start-position error.
+
+## Generated Files
+
+The report script writes these files:
+
+- `summary.md`: human-readable session summary.
+- `config.json`: report configuration used for the replay.
+- `shots.csv`: broad exploratory selector output, one row per shot.
+- `frames.csv`: broad exploratory frame rows.
+- `shots_live.csv`: live-style replay output, one row per shot.
+- `frames_live.csv`: live-style replay frame rows.
+- `index.html`: copied visualizer.
+
+## `frames.csv` vs `frames_live.csv`
+
+Use `frames_live.csv` when you want to debug what the current production-like
+selector would do.
+
+Use `frames.csv` when you want a broader exploratory view of strong returns,
+including frames or bins the production-style selector may intentionally avoid.
+
+`frames_live.csv` / `shots_live.csv`
+
+- Replays the current OPS-bin/live-style selection logic.
+- Prioritizes the OPS-anchored ball-speed bin.
+- Marks the selected anchor and neighbor frames.
+- Is the right default for validating kiosk behavior.
+- Is the file the live-sync browser URL loads by default.
+
+`frames.csv` / `shots.csv`
+
+- Uses a broader/high-SNR exploratory pass.
+- Can surface clutter, net/screen returns, or alternate strong bins.
+- Is useful when the live selector rejects a shot and you want to inspect what
+ else was present in the RADC data.
+- Can show high-SNR frames that should not necessarily be trusted as ball frames.
+
+In short: start with `frames_live.csv`; use `frames.csv` when you are hunting
+for why something went wrong.
+
+## Offline Workflow
+
+Run this from the repo on your Mac:
+
+```bash
+cd /path/to/openflight
+
+uv run python scripts/analysis/kld7_geometry_selection_report.py \
+ /path/to/openflight_sessions/session_YYYYMMDD_HHMMSS_trackman.jsonl \
+ --angle-offset-deg 2.5 \
+ --ball-distance-ft 5 \
+ --mount-deg 10
+```
+
+The report directory is created next to the session file unless `--output-dir`
+is supplied.
+
+To write to a specific folder:
+
+```bash
+uv run python scripts/analysis/kld7_geometry_selection_report.py \
+ /path/to/session.jsonl \
+ --output-dir /path/to/openflight_sessions/my_report \
+ --angle-offset-deg 2.5 \
+ --ball-distance-ft 5 \
+ --mount-deg 10
+```
+
+Then open the generated `index.html`, or serve that report folder and load:
+
+```text
+index.html?csv=frames_live.csv
+```
+
+## Live Workflow
+
+Use this while the Pi is running the kiosk and writing session logs:
+
+### SSH Prerequisite
+
+Live sync uses your Mac's `ssh` and `scp` commands. The `--pi-host` value is the
+normal SSH destination for the Pi:
+
+```text
+pi-user@pi-host.local
+```
+
+Before running live sync, verify that this works from the Mac:
+
+```bash
+ssh pi-user@pi-host.local
+```
+
+For continuous live polling, passwordless SSH is strongly recommended. Otherwise
+the script can block or repeatedly prompt for a password when it checks for new
+shots. The private key stays on the Mac, usually in:
+
+```text
+~/.ssh/id_ed25519
+```
+
+The matching public key is installed on the Pi in:
+
+```text
+~/.ssh/authorized_keys
+```
+
+If you do not already have an SSH key on the Mac, create one:
+
+```bash
+ssh-keygen -t ed25519 -C "openflight-live-sync"
+```
+
+Then copy the public key to the Pi:
+
+```bash
+ssh-copy-id pi-user@pi-host.local
+```
+
+If `ssh-copy-id` is unavailable on the Mac, use this fallback:
+
+```bash
+cat ~/.ssh/id_ed25519.pub | ssh pi-user@pi-host.local 'mkdir -p ~/.ssh && chmod 700 ~/.ssh && cat >> ~/.ssh/authorized_keys && chmod 600 ~/.ssh/authorized_keys'
+```
+
+Confirm the final connection does not ask for a password:
+
+```bash
+ssh pi-user@pi-host.local 'hostname'
+```
+
+If the Pi hostname does not resolve on the network, use the Pi's IP address
+instead, for example:
+
+```text
+--pi-host pi-user@192.168.1.50
+```
+
+### Run Live Sync
+
+```bash
+cd /path/to/openflight
+
+uv run python scripts/analysis/kld7_live_sync.py \
+ --pi-host pi-user@pi-host.local \
+ --angle-offset-deg 2.5 \
+ --ball-distance-ft 5 \
+ --mount-deg 10 \
+ --serve-port 8765
+```
+
+Open:
+
+```text
+http://127.0.0.1:8765/index.html?csv=frames_live.csv&auto=1
+```
+
+The live sync script:
+
+1. Finds the newest session JSONL on the Pi.
+2. Checks whether the shot count changed.
+3. Copies the session to the Mac.
+4. Regenerates `frames_live.csv`, `shots_live.csv`, and related files.
+5. Serves the report directory on the requested port.
+
+The visualizer auto-reloads the CSV and selects the newest shot when `auto=1`.
+
+For a one-time pull and report generation:
+
+```bash
+uv run python scripts/analysis/kld7_live_sync.py \
+ --pi-host pi-user@pi-host.local \
+ --angle-offset-deg 2.5 \
+ --ball-distance-ft 5 \
+ --mount-deg 10 \
+ --once
+```
+
+## Important Configuration
+
+`--angle-offset-deg`
+
+K-LD7 boresight/electrical-zero correction. In kiosk geometry mode, the current
+field default is positive `2.5` degrees. The corrected bearing is:
+
+```text
+corrected_bearing = raw_kld7_angle + angle_offset_deg
+```
+
+`--ball-distance-ft`
+
+Distance from the radar face to the ball at address. This is critical for
+geometry fitting. If the ball was moved from 5 ft to 4 ft, generate a separate
+report or pass the correct distance for that session.
+
+`--mount-deg`
+
+Physical vertical radar mount tilt. Field default has been `10` degrees.
+
+`--ball-above-radar-ft`
+
+Vertical ball offset relative to the radar. Use this when the ball is known to
+be above or below the radar face and you want that modeled explicitly.
+
+`--report-arg`
+
+Passes extra options from live sync to the report script. Repeat it for multiple
+options:
+
+```bash
+uv run python scripts/analysis/kld7_live_sync.py \
+ --pi-host pi-user@pi-host.local \
+ --angle-offset-deg 2.5 \
+ --ball-distance-ft 5 \
+ --mount-deg 10 \
+ --report-arg=--clock-error-ms \
+ --report-arg=20
+```
+
+## Visualizer Basics
+
+The visualizer loads one CSV at a time. The normal live URL is:
+
+```text
+index.html?csv=frames_live.csv&auto=1
+```
+
+Useful controls:
+
+- Shot dropdown: selects the shot to inspect.
+- Frame list: click up to two frames.
+- Shift controls: moves both selected frames by the same millisecond offset.
+- Range overlay: toggles F1B range markers.
+- Angle offset input: applies a display/replay bearing offset in the browser.
+
+The visualizer intentionally shifts both selected frames together because the
+timing concern is a per-shot alignment error, not independent per-frame drift.
+
+## Visualizer Metrics
+
+New Launch Angle
+
+The primary geometry-fit result for the currently selected frame or frames. This
+is the candidate angle to compare against TrackMan or the kiosk output.
+
+Launch From Ball To Frame 2
+
+The simple one-frame launch angle from the known ball position to frame 2. This
+is useful when only one good frame exists, but it is sensitive to timing.
+
+Free-Start 2-Frame Line
+
+The line through frame 1 and frame 2 without forcing the line to start at the
+known ball position. This helps show whether the selected frames imply a launch
+line that crosses the floor before or after the ball.
+
+Start Position Error
+
+Where the free-start line crosses ball height relative to the configured ball
+position. A large start-position error suggests timing, frame selection, or range
+issues.
+
+Frame 1 Miss
+
+How far frame 1 is from the ball-to-frame-2 line. This is a quick visual cue for
+whether the selected frames agree with the same trajectory.
+
+Corrected Times
+
+The selected frame timestamps after applying the current whole-shot shift.
+
+Frame Distances
+
+The physics distance from ball speed and corrected time:
+
+```text
+distance_from_ball = ball_speed * corrected_time
+```
+
+F1B Ranges
+
+Range derived from F1B phase for the selected frame or frames. Treat this as a
+diagnostic, not a final source of truth, until the F1B local-ball-bin selection
+path is fully validated.
+
+Range Delta
+
+Difference between F1B range and the speed/time-derived frame distance. Large
+deltas can indicate timing error, F1B clutter, wrong range unwrap, or a frame
+that is not actually the ball.
+
+## Reading Frame Rows
+
+Important columns:
+
+- `shot_number`: shot index from the session.
+- `frame_index`: K-LD7 frame number within the shot window.
+- `t_ms`: frame time relative to impact.
+- `expected_bin`: OPS-derived expected K-LD7 Doppler bin.
+- `peak_bin`: selected K-LD7 bin for that frame.
+- `bin_error`: absolute difference between expected and selected bins.
+- `speed_mph`: speed implied by the selected K-LD7 bin.
+- `snr`: selected-bin signal-to-noise ratio.
+- `angle_centroid_deg`: centroid bearing before browser offset handling.
+- `bearing_deg`: bearing with configured offset applied by the report.
+- `f1b_range_ft`: F1B phase-derived range estimate.
+- `f1b_same_bin_snr`: F1B SNR at the selected F1A/F2A bin.
+- `f1b_peak_bin_error`: current F1B peak error diagnostic.
+- `selection_role`: `anchor`, `neighbor`, or blank.
+- `status`: `selected`, `rejected`, or `invalid`.
+- `reasons`: why a frame was not selected.
+
+## F1B Range Caveats
+
+F1B range has been useful, but the current CSV can still report a global F1B peak
+that is not near the ball-speed bin. A frame can show a scary
+`f1b_peak_bin_error` while still having good local F1B support at the selected
+ball bin.
+
+When evaluating F1B, prefer this order:
+
+1. Is the main selected `peak_bin` close to the OPS `expected_bin`?
+2. Is `f1b_same_bin_snr` decent at that selected ball bin?
+3. Are nearby F1B bins around the selected ball bin coherent?
+4. Does `f1b_range_ft` make physical sense for ball speed and `t_ms`?
+5. Is the global F1B peak near DC/clutter, net/screen, or another non-ball
+ return?
+
+A follow-up improvement is to add explicit local F1B ball-bin columns so the CSV
+distinguishes global F1B peaks from local ball-bin F1B support.
+
+## TrackMan Test Workflow
+
+For TrackMan comparison sessions:
+
+1. Start the kiosk with geometry and raw RADC logging.
+2. Start `kld7_live_sync.py` on the Mac.
+3. Open the visualizer with `frames_live.csv&auto=1`.
+4. Hit shots and record TrackMan launch angle, ball speed, horizontal direction,
+ curve, carry side, and club.
+5. For each shot, compare TrackMan launch to:
+ - logged kiosk launch
+ - New Launch Angle
+ - one-frame angle
+ - best whole-shot shift within a practical bound
+ - F1B range consistency
+6. Categorize each shot as:
+ - clean 2-frame geometry
+ - 2-frame geometry recoverable by small timing shift
+ - one-frame geometry
+ - one-frame plus F1B range candidate
+ - rejected due to bin/SNR/clutter
+ - estimated/no usable radar frame
+
+The current hypothesis to test is whether two-frame shots can consistently use a
+small whole-shot shift to minimize geometry error, and whether one-frame shots
+can be validated or rejected with F1B range.
+
+## Common Gotchas
+
+Wrong ball distance
+
+If `--ball-distance-ft` does not match the setup, all geometry fits shift. A 4 ft
+setup analyzed as 5 ft can make otherwise good frames look wrong.
+
+Wrong angle offset
+
+The kiosk geometry default is positive `2.5` degrees. If the report and kiosk use
+different offsets, browser angles will not match logged angles.
+
+Frame timing from old sessions
+
+Older sessions may have pre-OPS-clock timing behavior. Timing conclusions from
+those sessions should be separated from newer sessions.
+
+Single-frame angles are easy to fit
+
+With one frame, a timing shift can make the launch angle look better, but there
+is less evidence than with a two-frame fit. Treat one-frame recoveries as lower
+confidence unless F1B range supports them.
+
+High SNR can be clutter
+
+The strongest bin is not always the ball. Net/screen returns, near-field clutter,
+or club returns can have high SNR. Bin error, timing, rising trajectory, and F1B
+range all matter.
+
+## Quick Command Reference
+
+Live:
+
+```bash
+cd /path/to/openflight
+uv run python scripts/analysis/kld7_live_sync.py \
+ --pi-host pi-user@pi-host.local \
+ --angle-offset-deg 2.5 \
+ --ball-distance-ft 5 \
+ --mount-deg 10 \
+ --serve-port 8765
+```
+
+Browser:
+
+```text
+http://127.0.0.1:8765/index.html?csv=frames_live.csv&auto=1
+```
+
+Offline:
+
+```bash
+cd /path/to/openflight
+uv run python scripts/analysis/kld7_geometry_selection_report.py \
+ /path/to/session.jsonl \
+ --angle-offset-deg 2.5 \
+ --ball-distance-ft 5 \
+ --mount-deg 10
+```
+
+One-time Pi sync:
+
+```bash
+uv run python scripts/analysis/kld7_live_sync.py \
+ --pi-host pi-user@pi-host.local \
+ --angle-offset-deg 2.5 \
+ --ball-distance-ft 5 \
+ --mount-deg 10 \
+ --once
+```
diff --git a/docs/yolo-performance-tuning.md b/docs/development/camera-yolo.md
similarity index 100%
rename from docs/yolo-performance-tuning.md
rename to docs/development/camera-yolo.md
diff --git a/docs/development/color-palette.md b/docs/development/color-palette.md
new file mode 100644
index 000000000..2fd1c8ab0
--- /dev/null
+++ b/docs/development/color-palette.md
@@ -0,0 +1,56 @@
+---
+icon: lucide/palette
+---
+
+# UI Colour Palette
+
+The canonical OpenFlight interface palette. Use these tokens when adding or
+changing UI, rather than introducing new values.
+
+Referenced from
+[`CONTRIBUTING.md`](https://github.com/jewbetcha/openflight/blob/main/CONTRIBUTING.md).
+
+
+## Backgrounds
+
+| | Name | Hex | CSS variable |
+| --- | --- | --- | --- |
+| | Deep Background | `#0A0A0F` | `--color-bg-deep` |
+| | Card Background | `#12121A` | `--color-bg-card` |
+| | Elevated | `#1A1A24` | `--color-bg-elevated` |
+| | Hover State | `#222230` | `--color-bg-hover` |
+
+## Gold (Primary)
+
+| | Name | Hex | CSS variable |
+| --- | --- | --- | --- |
+| | Gold | `#D4AF37` | `--color-gold` |
+| | Gold Bright | `#F4CF47` | `--color-gold-bright` |
+| | Gold Dim | `#A68B2A` | `--color-gold-dim` |
+
+## Cream (Text)
+
+| | Name | Hex | CSS variable |
+| --- | --- | --- | --- |
+| | Cream | `#F5F0E6` | `--color-cream` |
+| | Cream Dim | `rgba(245, 240, 230, 0.7)` | `--color-cream-dim` |
+| | Cream Muted | `rgba(245, 240, 230, 0.5)` | `--color-cream-muted` |
+
+## Accents
+
+| | Name | Hex | CSS variable |
+| --- | --- | --- | --- |
+| | Success | `#4ADE80` | `--color-success` |
+| | Info | `#60A5FA` | `--color-info` |
+| | Warning | `#FBBF24` | `--color-warning` |
+| | Danger | `#F87171` | `--color-danger` |
+
+## Using these
+
+The same palette drives the docs site — see
+`docs/stylesheets/extra.css`, where the gold, cream, and near-black values
+are mapped onto the theme's tokens.
+
+When adding UI, prefer an existing token over a new hex value. If a genuinely
+new colour is needed, add it here in the same commit so this page stays the
+single reference.
diff --git a/docs/development/firmware.md b/docs/development/firmware.md
new file mode 100644
index 000000000..f3f603a01
--- /dev/null
+++ b/docs/development/firmware.md
@@ -0,0 +1,506 @@
+# IWR6843 Firmware Developer Guide
+
+OpenFlight uses custom firmware on the TI IWR6843LEVM to preserve a short radar
+movie around impact in the chip's on-board L3 RAM. The firmware continuously
+processes chirps, stores selected complex range bins in a circular frame ring,
+and streams that ring to the Raspberry Pi after the shared sound trigger.
+
+Most builders do **not** need to compile firmware. A validated flashable image is
+checked into the repository. Build the firmware only when changing capture
+geometry, range windows, HWA/EDMA processing, or the binary dump contract.
+
+For hardware wiring, mounting, geometry, calibration, and normal OpenFlight
+startup, use the [IWR6843 Operator Guide](../iwr6843/index.md).
+
+## Current Release
+
+One firmware image supports two runtime capture profiles. Flash the image once,
+then choose a profile by passing its `.cfg` to OpenFlight.
+
+| Component | Current value |
+|---|---|
+| Flash image | `firmware/releases/l3_dump_configurable_capture_20260818.bin` |
+| Default config | `config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg` |
+| Dense config | `config/iwr6843_l3dump_dense_36f2ms_53bin_iq8.cfg` |
+| Reference calibration | `config/iwr6843_calibration_reference.json` |
+| Native build | `make -C firmware build-native` |
+| Container build | `make -C firmware docker-build` |
+| Flash image size | 346,820 bytes |
+| Flash SHA-256 | `823ddd18a231d0004020de6262160d6863384cccac6674bae6f7d0fcea58f955` |
+| Dump format | Variable-width, timed complex range-FFT snapshots |
+
+Verify the checked-in image before flashing:
+
+```bash
+sha256sum firmware/releases/l3_dump_configurable_capture_20260818.bin
+```
+
+## Choose A Capture Profile
+
+| Profile | Wide/default | Dense/advanced |
+|---|---:|---:|
+| Config | `iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg` | `iwr6843_l3dump_dense_36f2ms_53bin_iq8.cfg` |
+| Frames | 24 | 36 |
+| Frame spacing | 3 ms | 2 ms |
+| Movie duration | 72 ms | 72 ms |
+| Saved bins per frame | 53 | 53 |
+| Stored sample format | IQ16 | Fixed-scale IQ8 |
+| Payload bytes | 732,672 | 549,504 |
+| Primary goal | Robust ball flight | Dense impact sampling |
+
+Use **wide/default** unless you are deliberately testing dense impact data. Its
+53-bin windows tolerate more variation in tee distance, launch speed, and setup
+geometry, while IQ16 retains the HWA output without quantization. Hardware tests
+held the requested 3 ms cadence without RF or HWA faults. In an August 9
+TrackMan session, its live inclinometer-adjusted LCMF output covered all 59
+matched 9-iron and 7-iron shots with 0.86 degree MAE, 0.70 degree P50, and 1.75
+degree P90 absolute error.
+
+Use **dense/advanced** to test whether 2 ms temporal sampling improves impact
+and launch measurements. It preserves the same 53-bin range span as the wide
+profile and uses fixed-scale IQ8 so all 36 frames fit in L3. Its EDMA packing
+path sustained 99.9911% HWA frame coverage in hardware cadence testing, with
+zero IQ8 overruns or EDMA errors. The 53-bin dense profile still needs
+source-of-truth TrackMan validation; horizontal launch and club metrics remain
+experimental.
+
+Both profiles use 3 TX, 4 RX, 12 TDM loops, 128 acquired ADC samples, and the
+same 72 ms capture duration. Changing profiles does not require reflashing.
+
+The supported normal-TX profiles use a fixed positive TDM sign. This physical
+registration keeps the full eight-element vertical channel aligned with OPS
+radial speed. Automatic sign selection is useful for offline diagnostics, but
+it is not the production policy because multipath can select the mirrored sign
+and collapse that channel while leaving the range track apparently healthy.
+
+## On-Chip Data Path
+
+```text
+RF chirp
+ -> ADCBUF
+ -> HWA 128-point range FFT
+ -> EDMA copies the configured moving range window
+ -> IQ16 is stored directly, or EDMA compacts HWA-scaled IQ8 into L3
+ -> circular frame ring in L3 RAM
+ -> sound trigger freezes the completed pre/post-impact movie
+ -> header, timing/window metadata, scale table, and IQ payload stream to Pi
+ -> firmware rearms the ring for the next shot
+```
+
+The saved bins remain complex I/Q so the host retains phase for vertical and
+horizontal direction of arrival. Every frame carries its absolute range-window
+start, bin count, and measured time delta. IQ8 frames also carry their scale,
+allowing the host to restore the physical sample amplitude before processing.
+
+For IQ8, `iq8Scale` selects a fixed power-of-two scale before `sensorStart`.
+The HWA applies that scale, then EDMA copies the compact byte from each signed
+component into L3 without a CPU packing loop. Firmware `stats` report completed
+EDMA packs, waits, errors, clipped components, and missed HWA starts rather than
+silently hiding cadence failures.
+
+## Firmware And Host Contract
+
+The wire format is defined in two places that must stay synchronized:
+
+- Firmware: [`iwr6843/dump_format.h`](https://github.com/jewbetcha/openflight/blob/main/firmware/iwr6843/dump_format.h)
+- Host parser: [`../src/openflight/iwr6843/dump.py`](https://github.com/jewbetcha/openflight/blob/main/src/openflight/iwr6843/dump.py)
+
+The configurable version 7 transfer contains:
+
+1. A packed 20-byte little-endian `l3_dump_header_t`.
+2. A packed 24-byte `l3_temperature_report_t` captured immediately before streaming.
+3. A `(start bin, valid bins, elapsed microseconds)` descriptor per frame.
+4. A per-frame scale table when `sample_fmt` is IQ8.
+5. Complex IQ16 or IQ8 samples ordered by frame, chirp, RX, and local range bin.
+6. Each complex sample in TI's native imaginary-then-real order.
+
+The header carries:
+
+| Field | Meaning |
+|---|---|
+| `magic` | `ILD1` synchronization marker |
+| `version` | Dump contract version |
+| `n_frames` | Number of ring frames |
+| `chirps_per_frame` | `n_tx x loops` |
+| `n_tx`, `n_rx` | Virtual-array geometry |
+| `n_samples` | Stored bins per chirp/RX for snapshot formats |
+| `sample_fmt` | IQ16 or scaled IQ8 variable-width timed range snapshots |
+| `trigger_frame` | Oldest circular-ring slot for chronological rotation |
+| `frame_period_us` | Frame spacing used by trajectory fitting |
+
+Changing the header, sample order, frame metadata, or sample format requires a
+matching host-parser change and regression tests in the same commit.
+
+## Repository Layout
+
+| Path | Responsibility |
+|---|---|
+| `firmware/iwr6843/l3_dump.c` | RF control, HWA/EDMA pipeline, circular ring, freeze/rearm, CLI, and dump streaming |
+| `firmware/iwr6843/dump_format.h` | Packed firmware-side wire contract |
+| `firmware/iwr6843/makefile` | TI mmWave SDK application build and meta-image generation |
+| `firmware/iwr6843/mss.cfg` | SYS/BIOS configuration |
+| `firmware/iwr6843/mss_linker.cmd` | Places the ring and optional scratch buffers in L3 RAM |
+| `firmware/Makefile` | Toolchain setup and production firmware build target |
+| `firmware/releases/` | The single checked-in, validated flash image |
+| `firmware/flash_iwr6843.py` | Pi-compatible IWR6843 ROM bootloader client |
+| `config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg` | Default wide IQ16 capture profile |
+| `config/iwr6843_l3dump_dense_36f2ms_53bin_iq8.cfg` | Dense IQ8 capture profile |
+| `src/openflight/iwr6843/dump.py` | Python decoder and executable format reference |
+
+## Where To Build, Flash, And Run
+
+| Operation | Supported environment |
+|---|---|
+| Build | Native x86_64 Linux or the provided Docker image |
+| Build on Apple Silicon | Docker Desktop emulating the x86_64 build image; UTM is a fallback |
+| Build on Raspberry Pi 5 | Not currently reliable because TI's x86/i386 installer stubs can fail under QEMU and a 16 KiB host page size |
+| Flash | Raspberry Pi using `flash_iwr6843.py`, or TI UniFlash as a fallback |
+| Run | Raspberry Pi through OpenFlight |
+
+The Pi can flash and run the image, but it should not be treated as the
+canonical compiler host.
+
+## Build On Apple Silicon With Docker
+
+Install Docker Desktop, start its engine, and place the five TI installers
+listed below in `firmware/ti_installers/`. The installers are license-gated and
+are intentionally excluded from Git.
+
+Build the reusable x86_64 toolchain image once:
+
+```bash
+make -C firmware docker-image
+```
+
+Build the supported firmware after any source change:
+
+```bash
+make -C firmware docker-build
+```
+
+Docker runs the same `build-native` recipe under `linux/amd64` and writes the
+release artifact back into the host worktree at:
+
+```text
+firmware/releases/l3_dump_configurable_capture_20260818.bin
+```
+
+Use the UTM workflow below only when Docker emulation is unavailable.
+
+## Build On Apple Silicon With UTM
+
+### 1. Create An x86_64 Debian VM
+
+In UTM:
+
+1. Select **Create a New Virtual Machine**.
+2. Select **Emulate**, not Virtualize.
+3. Select **Linux** and an amd64 Debian netinst ISO.
+4. Use `Intel ICH9 based PC (2009, x86_64)`.
+5. Allocate at least 4 GB RAM and 30 GB storage.
+6. Install `SSH server` and `standard system utilities`; a desktop is optional.
+7. Eject the installer ISO before the first reboot into the installed system.
+
+Confirm the guest architecture and page size:
+
+```bash
+uname -m
+getconf PAGE_SIZE
+```
+
+Expected output is `x86_64` and `4096`.
+
+### 2. Put OpenFlight In The VM
+
+Clone the repository inside the VM or copy your existing worktree with `rsync`:
+
+```bash
+sudo apt-get update
+sudo apt-get install -y git rsync openssh-server
+git clone https://github.com/jewbetcha/openflight.git
+cd openflight
+```
+
+To copy an existing worktree from the Mac instead:
+
+```bash
+rsync -av --exclude '.venv' ~/Projects/openflight/ \
+ openflight@VM_ADDRESS:~/openflight/
+```
+
+Find the VM address with `ip addr` inside Debian.
+
+### 3. Supply The TI Installers
+
+TI's installers are large and license-gated, so they are intentionally ignored
+by git. Download them from TI and place these exact files under
+`firmware/ti_installers/` inside the VM:
+
+```text
+mmwave_sdk_03_06_02_00-LTS-Linux-x86-Install.bin
+ti_cgt_tms470_20.2.7.LTS_linux-x64_installer.bin
+bios_6_73_01_01.run
+sysconfig-1.10.0_2163-setup.run
+xdctools_3_61_00_16_core_linux.zip
+```
+
+The application is MSS/R4F-only; it does not require the C674x DSP compiler or
+DSP libraries.
+
+Verify the installer set:
+
+```bash
+make -C firmware check-installers
+```
+
+### 4. Install The Build Environment
+
+Install Debian packages, probe every installer stub, and install the TI tools
+under `/opt/ti`:
+
+```bash
+make -C firmware install-ti-deps-native
+make -C firmware probe-installers-native
+make -C firmware install-ti-tools-native
+```
+
+The resulting layout is:
+
+```text
+/opt/ti/sdk/mmwave_sdk_03_06_02_00-LTS
+/opt/ti/cgt-arm/ti-cgt-arm_20.2.7.LTS
+/opt/ti/bios/bios_6_73_01_01
+/opt/ti/xdc/xdctools_3_61_00_16_core
+/opt/ti/sysconfig
+```
+
+### 5. Build The Current Firmware
+
+From the repository root inside the VM:
+
+```bash
+make -C firmware build-native
+```
+
+The target performs the application build, generates the flashable TI
+meta-image, and copies the production image into `firmware/releases/`:
+
+```text
+firmware/releases/l3_dump_configurable_capture_20260818.bin
+```
+
+Generated `.xer4f`, `.map`, and intermediate `.bin` files stay under
+`firmware/iwr6843/` and are ignored by Git. Current production images and
+intentional rollback images live under `releases/`.
+
+### 6. Copy Artifacts Out Of The VM
+
+From the Mac:
+
+```bash
+mkdir -p artifacts/firmware_build
+rsync -av \
+ openflight@VM_ADDRESS:~/openflight/firmware/releases/ \
+ artifacts/firmware_build/
+```
+
+## Build On Native x86_64 Linux
+
+Use the same installer files and Make targets as the UTM VM. Confirm `uname -m`
+reports `x86_64`, then start at **Supply The TI Installers** above.
+
+The tool paths can be overridden when a machine does not use `/opt/ti`:
+
+```bash
+make -C firmware build-native \
+ TI_ROOT=/custom/ti
+```
+
+## Supported Build Target
+
+`make -C firmware build-native` and `make -C firmware docker-build` produce the
+same configurable image. Capture timing, frame plan, moving windows, and IQ16
+or IQ8 storage are selected by the runtime config. Use Git history for earlier
+experiments rather than distributing those images or targets as installation
+choices.
+
+## Flash From The Raspberry Pi
+
+The checked-in Python flasher uses the IWR6843 ROM UART bootloader and does not
+require TI Cloud Agent. Flash over the CP2105 **Enhanced/UARTA** interface,
+normally interface `00` and `/dev/ttyUSB0`. Do not use the Standard interface,
+normally `/dev/ttyUSB1`.
+
+### 1. Stop Serial Users
+
+Stop OpenFlight and any calibration or test process using the TI port:
+
+```bash
+pgrep -af 'openflight|calibrate|shot_test'
+sudo fuser -v /dev/ttyUSB0
+```
+
+### 2. Enter Flash Mode And Probe
+
+Set the IWR6843LEVM switches to:
+
+```text
+S1.1 ON, S1.2 OFF, S1.3 ON, S1.4 ON, S1.5 OFF
+```
+
+Start the non-destructive probe:
+
+```bash
+uv run python firmware/flash_iwr6843.py \
+ --probe \
+ --port /dev/ttyUSB0
+```
+
+Follow the prompts exactly:
+
+1. Type `READY` so the script opens UART and settles the control lines.
+2. Press and release RESET only when requested.
+3. Wait one second.
+4. Type `PROBE`.
+
+Do not continue until the ROM bootloader handshake passes.
+
+### 3. Flash The Current Image
+
+Leave the board in flash mode and run:
+
+```bash
+uv run python firmware/flash_iwr6843.py \
+ firmware/releases/l3_dump_configurable_capture_20260818.bin \
+ --port /dev/ttyUSB0
+```
+
+Type `READY`, press RESET when prompted, wait one second, and type `FLASH`. The
+default workflow erases SFLASH, writes acknowledged chunks, closes the image,
+and verifies the final ROM bootloader status.
+
+Expected completion:
+
+```text
+Erasing existing SFLASH...
+Opening firmware image...
+Writing firmware...
+Writing: 100% (346,820/346,820 bytes)
+Closing and verifying firmware...
+
+Flash verified by the IWR6843 ROM bootloader.
+```
+
+Do not reset, disconnect, or remove power while erase or write is active. A
+failed write is recoverable because the ROM bootloader is not stored in SFLASH.
+Leave the board in flash mode and rerun the complete command.
+
+### 4. Return To Functional Mode
+
+Set the switches to:
+
+```text
+S1.1 OFF, S1.2 OFF, S1.3 ON, S1.4 ON, S1.5 OFF
+```
+
+Press and release RESET. The firmware CLI and binary dumps now share the
+Enhanced UART at 1,041,667 baud. Flashing itself always uses the ROM
+bootloader's 115,200-baud protocol.
+
+The flasher follows TI application note
+[SWRA627, IWR6843 Bootloader Flow](https://www.ti.com/lit/an/swra627/swra627.pdf).
+
+## Verify The Installed Firmware
+
+Run OpenFlight with the matching config as described in the
+[Operator Guide](../iwr6843/verify.md#start-openflight). With `--debug`, a
+healthy capture reports:
+
+```text
+[IWR6843] Trigger #1: dumping firmware-frozen L3 ring
+[IWR6843] Capture #1 complete: 732812 bytes
+```
+
+The firmware/config geometry is checked at `sensorStart`. A mismatch in TX
+masks, loop count, frame count, or ADC samples is rejected rather than silently
+capturing a differently shaped cube.
+
+## Changing Capture Geometry
+
+The runtime config controls the capture without rebuilding firmware:
+
+| Config command | Purpose |
+|---|---|
+| `frameCfg` | TDM loop count and RF frame period |
+| `captureFormat iq16\|iq8` | L3 sample representation |
+| `iq8Scale 16\|32\|64\|128\|256` | Fixed power-of-two IQ8 quantization scale used by EDMA packing |
+| `phaseCaptureCfg` | Pre/impact/ball window starts, widths, counts, and stride |
+
+Before increasing loops, frames, transmitters, or bins, calculate the ring:
+
+```text
+IQ16 bytes = TX x loops x frames x RX x saved bins x 4
+IQ8 bytes = TX x loops x frames x RX x saved bins x 2
+```
+
+The result must fit within 786,432 L3 bytes along with any variant-specific L3
+scratch sections. The linker places `.l3ring` and `.l3scratch` in `L3_RAM` and
+fails the build if they overflow.
+
+The firmware rejects invalid windows, frame plans, and L3 budgets at
+`sensorStart`. The dense IQ8 profile also has only about 380 microseconds
+between its 1.62 ms RF burst and the next 2 ms frame. Its EDMA packer moves the
+low byte of each HWA-scaled IQ16 component into the compact ring without a CPU
+copy loop. Cadence testing at scale `128` reduced the observed HWA miss rate
+from 28.2% with CPU packing to 0.0089% with EDMA packing, with no IQ8 overruns
+or EDMA errors. That proves scheduling headroom, but ball-signal fidelity and
+launch-angle accuracy must still be validated against the IQ16 baseline.
+
+## Validation Before Flashing A New Variant
+
+Run the firmware contract and host-pipeline tests:
+
+```bash
+uv run pytest \
+ tests/test_iwr6843_firmware_rearm.py \
+ tests/test_iwr6843_pipeline.py \
+ tests/test_iwr6843_driver.py \
+ tests/test_iwr6843_monitor.py \
+ tests/test_iwr6843_bootloader.py
+```
+
+Also check:
+
+1. The `.cfg` matches all compile-time capture geometry.
+2. The map file keeps `.l3ring` and `.l3scratch` inside L3.
+3. The first static capture has the expected version, dimensions, frame period,
+ per-frame window table, and total byte count.
+4. Repeated dump/rearm cycles work without resetting the board.
+5. Vertical and horizontal estimators can replay the new format offline.
+6. Source-of-truth testing is repeated if timing, loops, frame spacing, TX
+ schedule, or saved range coverage changed.
+
+## Troubleshooting
+
+| Symptom | Cause | Action |
+|---|---|---|
+| TI installer exits immediately | Build host is ARM, installer lacks execute permission, or i386 compatibility is missing | Use x86_64 Debian, run `install-ti-deps-native`, then `probe-installers-native` |
+| VM returns to the Debian installer | ISO remains attached | Eject the ISO from the UTM CD/DVD drive and reboot |
+| `check-installers` reports missing files | Installer name or location differs | Use the exact filenames under `firmware/ti_installers/` |
+| Build cannot find `/opt/ti/...` | Tool installation did not complete or uses a custom root | Run `install-ti-tools-native` or pass `TI_ROOT=/custom/ti` |
+| Link fails with L3 overflow | Ring or scratch allocation exceeds 768 KiB | Reduce frames, loops, TX count, or saved bins and inspect the map file |
+| Probe receives no ROM response | Wrong CP2105 interface or RESET timing | Use Enhanced/UARTA, type `READY`, then RESET only when prompted |
+| Flash fails after erase | Image transfer was interrupted | Leave flash mode enabled and rerun the full flash command; the ROM bootloader remains available |
+| No CLI after flashing | Board remains in flash mode or was not reset | Restore functional switches and press RESET |
+| Server rejects `captureFormat`, `iq8Scale`, or `phaseCaptureCfg` | Older firmware is flashed | Flash `l3_dump_configurable_capture_20260818.bin`, reset in functional mode, and retry |
+| Dump length differs from the selected profile | Wrong config, interrupted UART transfer, or stale process | Verify firmware SHA-256, use Enhanced/UARTA, stop serial owners, reset, and retry |
+| Dense profile reports sustained `hwa_missed`, `iq8_overrun`, or `iq8_edma_err` | The requested cadence exceeds processing time or EDMA packing failed | Return to the wide profile and inspect `stats`; do not trust descriptor cadence from a missed-frame run |
+| First run works but restart hangs | Retired v1 image or incomplete shutdown | Flash the current release image and reset in functional mode |
+
+## Historical Context
+
+The [IWR6843 field report](../how-it-works/launch-angle.md) explains
+why the project moved capture into on-chip L3 and how the estimator evolved.
+The implementation has since advanced from full raw ADC rings to HWA-generated,
+dynamically windowed complex range snapshots; this README is the authoritative
+description of the current firmware.
diff --git a/docs/development/index.md b/docs/development/index.md
new file mode 100644
index 000000000..395947b24
--- /dev/null
+++ b/docs/development/index.md
@@ -0,0 +1,50 @@
+---
+icon: lucide/code
+---
+
+# Development
+
+Working on OpenFlight itself, plus the experimental and validation work that
+is not part of the production path.
+
+
+
+- :material-chip: **[Firmware build](firmware.md)**
+
+ Build the IWR6843 configurable image from source. Not needed to flash the
+ prebuilt release.
+
+- :material-target: **[TrackMan test process](trackman-testing.md)**
+
+ How accuracy claims here were validated against a reference monitor.
+
+- :material-sine-wave: **[Spin replay bench](spin-replay.md)**
+
+ The dechirped-sideband spin estimator test bench.
+
+- :material-camera-outline: **[Camera & YOLO](camera-yolo.md)**
+
+ Experimental vision work. Disabled in the production kiosk.
+
+- :material-chart-scatter-plot: **[Analysis tooling](analysis-tooling.md)**
+
+ Offline and live session analysis. K-LD7 era, deprecated hardware.
+
+
+
+## Contributing
+
+See [`CONTRIBUTING.md`](https://github.com/jewbetcha/openflight/blob/main/CONTRIBUTING.md)
+in the repository for development setup, code quality standards, and the pull
+request process.
+
+Quick reference:
+
+```bash
+uv run pytest tests/ -v # tests
+uv run pylint src/openflight/ --fail-under=9 # lint (must score 9.0+)
+uv run ruff check src/openflight/ # format check
+make docs-build # build these docs, strict
+```
+
+Always use `uv` for Python commands — never bare `python`, `pip`, or `pytest`.
diff --git a/docs/spin-dechirp-replay.md b/docs/development/spin-replay.md
similarity index 100%
rename from docs/spin-dechirp-replay.md
rename to docs/development/spin-replay.md
diff --git a/docs/trackman-test-process.md b/docs/development/trackman-testing.md
similarity index 100%
rename from docs/trackman-test-process.md
rename to docs/development/trackman-testing.md
diff --git a/docs/get-started/build-order.md b/docs/get-started/build-order.md
new file mode 100644
index 000000000..b79868457
--- /dev/null
+++ b/docs/get-started/build-order.md
@@ -0,0 +1,97 @@
+---
+icon: lucide/list-ordered
+---
+
+# Build Order
+
+The hardware has genuine prerequisites. Each step below assumes the previous one
+is done **and verified** — the point of the order is that when something breaks,
+only one thing changed.
+
+!!! tip "Verify at every step"
+
+ The most common way to lose an afternoon is to wire everything, turn it on,
+ and get nothing. Each step here ends with a check that produces visible
+ output. Do not move on until it passes.
+
+## 0. Order the parts
+
+**→ [Parts list](parts.md)**
+
+Some items ship slowly. Order everything before you start, including the R17
+resistor (47 kΩ, and a 33 kΩ as backup) for the sound detector.
+
+## 1. Wire the sound trigger
+
+**→ [Sound trigger wiring](../build/sound-trigger.md)**
+
+Solder the R17 gain resistor, then wire `GATE` → `HOST_INT` and the shared
+ground. This is the one soldering job in the build.
+
+**Verify:** the GATE LED flashes when you clap and goes out again. If it stays
+lit, fit a lower-value R17.
+
+## 2. Set up the Raspberry Pi
+
+**→ [Raspberry Pi setup](../setup/raspberry-pi.md)**
+
+Install the OS and dependencies, run the setup script.
+
+**Verify:** the server starts in mock mode — `scripts/start-kiosk.sh --mock`.
+
+## 3. Persist rolling-buffer mode
+
+**→ [Rolling buffer setup](../setup/rolling-buffer.md)**
+
+One time, per radar. The OPS243 must hold rolling-buffer mode in flash or
+hardware triggers will not work.
+
+**Verify:** `test_rolling_buffer_persist.py --test` reports triggers when you
+clap.
+
+!!! success "You now have a working launch monitor"
+
+ Ball speed, club speed, and carry all work from here. Everything below adds
+ angles. Play with it at this stage before continuing — it confirms the whole
+ trigger and capture path is sound.
+
+## 4. Move the OPS243 to the GPIO UART
+
+**→ [OPS243 → GPIO UART](../build/ops243-uart.md)**
+
+Required before the IWR6843, which needs the USB bus and real current. Do this
+migration on its own; adding the TI radar at the same time makes any failure
+ambiguous.
+
+**Verify:** the diagnostic reports `230400 baud` and roughly 22 KB/s. A pass
+reporting 19,200 is treated as a failure on purpose — it means the radar never
+received the `I5` command, so check the Pi pin 8 → J3 pin 6 wire.
+
+## 5. Add the IWR6843 angle radar
+
+**→ [IWR6843 operator guide](../iwr6843/index.md)**
+
+The largest step, and itself ordered: [wiring](../iwr6843/wiring.md) →
+[flashing](../iwr6843/flashing.md) → [mounting and geometry](../iwr6843/mounting.md)
+→ [start and verify](../iwr6843/verify.md).
+
+**Verify:** [the first capture](../iwr6843/verify.md#verify-the-first-capture)
+returns a sane launch angle.
+
+## Optional extras
+
+Any order, once the above works.
+
+| Add-on | Guide | Why |
+| --- | --- | --- |
+| Inclinometer | [LIS3DH](../build/inclinometer.md) | Compensates enclosure tilt so the IWR6843 angle stays honest if the unit is bumped |
+| Battery | [Geekworm X1202/X1206](../build/battery.md) | Portable operation with real telemetry and low-battery warnings |
+| Enclosure | [IARC v3 case](../build/enclosure.md) | Printed housing for the whole assembly |
+| Simulators | [Connectors](../using/simulator/index.md) | Stream shots to GSPro, OpenGolfSim, E6 |
+| Cloud sync | [Cloud sync](../using/cloud-sync.md) | Push filtered sessions to FlightWeb |
+| Log shipping | [Observability](../using/observability.md) | Query sessions in Grafana Cloud |
+
+## When something does not work
+
+Start at the **[troubleshooting symptom index](../troubleshooting/index.md)**,
+which routes by what you are seeing rather than by which component you suspect.
diff --git a/docs/get-started/index.md b/docs/get-started/index.md
new file mode 100644
index 000000000..effba2c68
--- /dev/null
+++ b/docs/get-started/index.md
@@ -0,0 +1,61 @@
+---
+icon: lucide/flag
+---
+
+# Get Started
+
+Four pages, in order. If you are deciding whether to build one, start with the
+overview; if you have already decided, go straight to the parts list.
+
+
+
+- :material-help-circle-outline: **[Overview](overview.md)**
+
+ What the system measures, how accurate it is, and how a swing becomes a row
+ of numbers.
+
+- :material-cart-outline: **[Parts list](parts.md)**
+
+ Everything to buy, with links and a cost summary. Some items have long lead
+ times — order first.
+
+- :material-format-list-numbered: **[Build order](build-order.md)**
+
+ The sequence and its prerequisites. Doing these out of order means redoing
+ work.
+
+- :material-rocket-launch-outline: **[Quick start](quick-start.md)**
+
+ Assembled hardware to first shot.
+
+
+
+## The short version
+
+OpenFlight measures a golf shot with two radars and a Raspberry Pi:
+
+- An **OPS243-A** 24 GHz Doppler radar gives ball speed and club speed from a
+ raw I/Q capture, triggered by a sound sensor that hears the strike.
+- A **TI IWR6843** 60 GHz mmWave radar gives launch angle, launch direction,
+ and club path from the raw radar cube.
+- A **ballistic simulator** turns those into carry.
+
+Shots are streamed to a React UI and, optionally, to GSPro, OpenGolfSim, or
+another simulator.
+
+## Before you commit
+
+!!! info "What this is not"
+
+ OpenFlight is a working DIY launch monitor, not a commercial product. Expect
+ to solder one joint, flash custom radar firmware, measure your rig's geometry
+ with a tape measure, and read a troubleshooting page or two.
+
+ Spin rate in particular is **experimental** and is not used for carry by
+ default — see [rolling buffer and spin detection](../how-it-works/rolling-buffer.md).
+
+!!! warning "Don't buy K-LD7 radars"
+
+ The K-LD7 angle radars are deprecated and superseded by the IWR6843. Their
+ software support is retained for existing builds only. See
+ [Legacy (K-LD7)](../legacy/index.md).
diff --git a/docs/get-started/overview.md b/docs/get-started/overview.md
new file mode 100644
index 000000000..eebf1e949
--- /dev/null
+++ b/docs/get-started/overview.md
@@ -0,0 +1,91 @@
+---
+icon: lucide/help-circle
+---
+
+# Overview
+
+How a swing becomes a row of numbers.
+
+## What it measures
+
+| Metric | Source | Trusted for carry? |
+| --- | --- | --- |
+| Ball speed | OPS243-A rolling-buffer I/Q, FFT mode extraction | Yes |
+| Club speed | Pre-impact window of the same capture | Yes |
+| Launch angle (vertical) | IWR6843, LCMF-v1 over the raw radar cube | Yes |
+| Launch direction (horizontal) | IWR6843, horizontal plane | Yes |
+| Club path | IWR6843, pre-impact frames | Reported, experimental |
+| Spin rate | OPS243-A amplitude-envelope demodulation | **No** — experimental |
+| Carry | RK4 ballistic simulation | Computed |
+
+Smash factor and other derived values come from the measured pair.
+
+## The chain
+
+```mermaid
+flowchart TD
+ A[Club strikes ball] --> B[SEN-14262 sound detector]
+ B -->|GATE, ~10 µs| C[OPS243-A HOST_INT]
+ B -->|same edge, BCM17| D[Raspberry Pi]
+ C --> E[Rolling buffer freezes 4096 I + 4096 Q samples]
+ D --> F[IWR6843 dumps its rolling frame ring]
+ E --> G[RollingBufferProcessor FFT + mode extraction]
+ F --> H[LCMF-v1 angle + club path]
+ G --> I[Ball speed, club speed, spin candidate]
+ H --> J[Launch angle, direction, club path]
+ I --> K[Shot]
+ J --> K
+ K --> L[Ballistic simulator drag + Magnus, RK4]
+ L --> M[Carry distance]
+ K --> N[Flask + WebSocket]
+ M --> N
+ N --> O[React UI]
+ N --> P[Simulator connectors GSPro, OpenGolfSim, …]
+ N --> Q[Session log JSONL]
+```
+
+The important detail is that **one sound edge drives both radars**. The
+SEN-14262's `GATE` output goes to the OPS243's `HOST_INT` pin in hardware — no
+software in the path, roughly 10 µs of latency — and to the Pi's BCM17, which
+asks the IWR6843 firmware to finish and dump its frame ring. Both captures
+therefore describe the same strike, and the OPS impact timestamp is what
+correlates them.
+
+## Why Doppler
+
+The OPS243-A transmits at 24.125 GHz. A moving object reflects that signal back
+with its frequency shifted in proportion to speed — the Doppler effect. At this
+frequency, **1 mph produces about a 71.7 Hz shift**, which is what makes a
+modest FFT sufficient to resolve golf-ball speeds precisely.
+
+The radar does not stream a speed number for the shot. It holds a rolling buffer
+of raw I/Q samples; the trigger freezes it, and OpenFlight does the signal
+processing itself. That is what allows club speed and ball speed to be pulled
+from a single capture — they are different regions of the same timeline.
+
+See [rolling buffer and spin detection](../how-it-works/rolling-buffer.md) for
+what happens inside that capture.
+
+## Why a second radar
+
+Doppler measures speed *along the beam*. It cannot tell you the angle the ball
+left at. The IWR6843 is a 60 GHz mmWave radar with multiple TX and RX antennas,
+so it can resolve angle as well as range and velocity — which is what launch
+angle, launch direction, and club path require.
+
+It needs custom firmware: the stock TI demo does not expose the raw radar cube.
+A validated prebuilt image ships in the repository, so flashing does not require
+the TI toolchain.
+
+## Accuracy
+
+Validated against a TrackMan — see the
+[TrackMan test process](../development/trackman-testing.md) for methodology and
+the July 2026 baseline. Ball speed and club speed are the strongest numbers;
+launch angle is good within the estimator's stated limits; club path is honest
+but coarse; spin is not yet trustworthy per-shot.
+
+## Next
+
+- **[Parts list](parts.md)** — what to buy
+- **[Build order](build-order.md)** — the sequence
diff --git a/docs/PARTS.md b/docs/get-started/parts.md
similarity index 94%
rename from docs/PARTS.md
rename to docs/get-started/parts.md
index 849c9a5d8..9a793fd37 100644
--- a/docs/PARTS.md
+++ b/docs/get-started/parts.md
@@ -4,7 +4,7 @@ Hardware components for building the OpenFlight golf launch monitor.
> **Ordering shortcut:** A shared **[OpenFlight Mouser project](https://www.mouser.com/en/Tools/Project/Share?AccessID=4c97a00bbc)** is available for the parts Mouser stocks — open it, save it to your own Mouser account, and add the whole list to your cart in one step instead of searching for each item. Check it against the tables below before you order: anything Mouser does not carry has a direct vendor link here.
-> **Next step after gathering parts:** See the [Raspberry Pi Setup Guide](raspberry-pi-setup.md) for assembly and software installation.
+> **Next step after gathering parts:** See the [Raspberry Pi Setup Guide](../setup/raspberry-pi.md) for assembly and software installation.
## Core Components
@@ -16,7 +16,7 @@ Hardware components for building the OpenFlight golf launch monitor.
> **NOTE on OPS243-A-W (WiFi version):** The standard **OPS243-A** (USB only) is strongly recommended. The WiFi module on the OPS243-A-W drives the internal UART receive line, preventing direct connection to the Raspberry Pi GPIO UART (Layout A). However, if you already have the WiFi version, it can still be used over USB with a powered USB hub (Layout B) when paired with the IWR6843 angle radar.
-> **Display alternative:** The [Raspberry Pi Touch Display 2](https://www.raspberrypi.com/products/touch-display-2/) (7" 720x1280, MIPI DSI) also works with the Pi 5. If you use it, print the `Touch_Display2_backplate.stl` and `Touch_Display2_shell.stl` from the IARC case instead of `monitor_shell.stl` — see the [IARC case instructions](../cad/IARC_case/README.md).
+> **Display alternative:** The [Raspberry Pi Touch Display 2](https://www.raspberrypi.com/products/touch-display-2/) (7" 720x1280, MIPI DSI) also works with the Pi 5. If you use it, print the `Touch_Display2_backplate.stl` and `Touch_Display2_shell.stl` from the IARC case instead of `monitor_shell.stl` — see the [IARC case instructions](../build/enclosure.md).
## Sound Trigger (for Rolling Buffer Mode)
@@ -44,7 +44,7 @@ SEN-14262 Raspberry Pi OPS243
└───────────┘ └──────────┘ └──────────┘
```
-See [sound-trigger-wiring.md](sound-trigger-wiring.md) for detailed instructions and troubleshooting.
+See [sound-trigger-wiring.md](../build/sound-trigger.md) for detailed instructions and troubleshooting.
## Angle Radar (TI IWR6843) — CURRENT
@@ -84,9 +84,9 @@ radars over USB.
> module already drives the radar's UART receive line, so the Pi cannot send it
> commands. WiFi OPS boards must use Layout B with a powered hub.
-Full instructions: **[IWR6843 Operator Guide](iwr6843/README.md)** for wiring,
+Full instructions: **[IWR6843 Operator Guide](../iwr6843/index.md)** for wiring,
flashing, mounting, and geometry; **[Moving the OPS243 to the Pi GPIO
-UART](ops243-uart-migration.md)** for the OPS side of Layout A.
+UART](../build/ops243-uart.md)** for the OPS side of Layout A.
### Optional Enclosure Inclinometer
@@ -98,7 +98,7 @@ tilt when the rig is placed on uneven ground.
| **Adafruit LIS3DH breakout** | Triple-axis accelerometer with STEMMA QT connectors | [Adafruit product 2809](https://www.adafruit.com/product/2809) | $5 |
| **JST-SH cable kit** | Solderless STEMMA QT/Qwiic to female Dupont wiring used in the validated build | [Amazon](https://www.amazon.com/Connector-Compatible-Development-Sensors-Drivers/dp/B0GJPRX4YT) | ~$10 |
-See the **[LIS3DH Inclinometer Setup Guide](inclinometer/README.md)** for wiring,
+See the **[LIS3DH Inclinometer Setup Guide](../build/inclinometer.md)** for wiring,
mounting, calibration, startup flags, and troubleshooting.
---
@@ -124,7 +124,7 @@ Each K-LD7 connects via a 3.3V FTDI adapter, appearing as `/dev/ttyUSB*` on Linu
K-LD7 Module (UART) → FTDI 3.3V Adapter → USB → Raspberry Pi
```
-One unit is mounted vertically (launch angle), one horizontally (club path / aim direction). A `--kld7-angle-offset` parameter corrects for mounting geometry — see the [setup guide](raspberry-pi-setup.md) for calibration.
+One unit is mounted vertically (launch angle), one horizontally (club path / aim direction). A `--kld7-angle-offset` parameter corrects for mounting geometry — see the [setup guide](../setup/raspberry-pi.md) for calibration.
## Power & Accessories
@@ -143,7 +143,7 @@ One unit is mounted vertically (launch angle), one horizontally (club path / aim
| **Geekworm X1206 UPS HAT** | Larger rechargeable Pi 5 power option using four matching 21700 Li-ion cells, advertised up to 20,000mAh total. Cells are not included | [Geekworm](https://geekworm.com/products/x1206) | Varies + cells |
| **InnoMaker OV9281 global-shutter camera** | High-speed monochrome camera for experimental vision work. Camera software is not enabled in the production kiosk path | [Amazon](https://www.amazon.com/dp/B09WTP5GZH?th=1) | ~$30 |
-See [Camera and YOLO Experiments](yolo-performance-tuning.md) before buying the
+See [Camera and YOLO Experiments](../development/camera-yolo.md) before buying the
camera; the standard setup does not install its optional software dependencies.
---
diff --git a/docs/get-started/quick-start.md b/docs/get-started/quick-start.md
new file mode 100644
index 000000000..02522ffa7
--- /dev/null
+++ b/docs/get-started/quick-start.md
@@ -0,0 +1,95 @@
+---
+icon: lucide/rocket
+---
+
+# Quick Start
+
+From assembled, verified hardware to a shot on screen.
+
+!!! note "Not yet assembled?"
+
+ Work through the [build order](build-order.md) first. This page assumes the
+ trigger is wired, the Pi is set up, and rolling-buffer mode is persisted.
+
+## Start it
+
+```bash
+scripts/start-kiosk.sh
+```
+
+That is the supported entry point. It activates the virtualenv, builds the UI if
+needed, and launches the server with the rolling buffer and sound trigger.
+
+The UI is at **`http://localhost:8080`** on the Pi, or
+`http://.local:8080` from another device on the LAN.
+
+## Common variants
+
+=== "No hardware"
+
+ ```bash
+ scripts/start-kiosk.sh --mock
+ ```
+
+ Simulated shots. Useful for UI work and for confirming the software side
+ before the radars arrive.
+
+=== "With the angle radar"
+
+ ```bash
+ scripts/start-kiosk.sh \
+ --iwr6843 \
+ --radar-port /dev/ttyAMA0 \
+ --iwr6843-tee-m 1.575 \
+ --iwr6843-net-m 4.6
+ ```
+
+ The geometry values are examples. **Measure your own** — see
+ [mounting and geometry](../iwr6843/mounting.md).
+
+=== "Swing speed only"
+
+ ```bash
+ scripts/start-kiosk.sh --swing-speed
+ ```
+
+ Club-only mode for air swings and speed sticks. No ball, no sound trigger.
+
+=== "With a simulator"
+
+ ```bash
+ cp config/sim.example.json config/sim.json
+ # edit config/sim.json for your simulator's host and port
+ scripts/start-kiosk.sh --sim
+ ```
+
+ See [simulator connectors](../using/simulator/index.md).
+
+Every flag is listed in the [CLI reference](../reference/cli.md).
+
+## Your first shot
+
+1. Place the OPS243-A **3–5 feet behind the tee**, pointing down the target
+ line. See [radar positioning](../how-it-works/positioning.md).
+2. Hit a ball.
+3. The UI should show ball speed, club speed, smash factor, and carry —
+ plus launch angle and club path if the IWR6843 is running.
+
+If nothing appears, the trigger is the usual cause. Clap near the sound detector
+and watch for the GATE LED; then check the
+[troubleshooting index](../troubleshooting/index.md).
+
+## What gets recorded
+
+Every session writes a JSONL log to `~/openflight_sessions/session_*.jsonl`,
+including the raw I/Q captures. That is what makes offline analysis and
+estimator work possible later — see the
+[session log reference](../reference/session-log.md).
+
+Disable it with `--no-logging` if you would rather not.
+
+## Next
+
+- **[Running & modes](../using/running.md)** — the full set of run modes
+- **[TV display mode](../using/display.md)** — put the UI on a TV or tablet
+- **[Simulator connectors](../using/simulator/index.md)** — stream shots out
diff --git a/docs/how-it-works/ballistics.md b/docs/how-it-works/ballistics.md
new file mode 100644
index 000000000..c526b752b
--- /dev/null
+++ b/docs/how-it-works/ballistics.md
@@ -0,0 +1,117 @@
+---
+icon: lucide/trending-up
+---
+
+# Ballistics & Carry
+
+Carry is not measured — it is simulated. OpenFlight integrates the ball's
+trajectory from the launch conditions it measured, accounting for drag and the
+Magnus force from spin.
+
+Implemented in `src/openflight/ballistics.py`.
+
+## The model
+
+Three forces act on the ball: gravity, aerodynamic drag opposing motion, and
+the Magnus lift generated by backspin.
+
+$$
+\vec{F} = m\vec{g}
+- \tfrac{1}{2}\rho A C_d \lvert\vec{v}\rvert \vec{v}
++ \tfrac{1}{2}\rho A C_l \lvert\vec{v}\rvert^2 \hat{n}
+$$
+
+The coefficients depend on the **spin parameter** $S_p = r\omega / v$ — the
+ratio of surface speed to translational speed:
+
+$$
+C_d = C_{d,\text{base}} + k_d S_p
+\qquad
+C_l = \frac{C_{l,\text{sat}} \, S_p}{C_{l,\text{half}} + S_p}
+$$
+
+Drag rises linearly with spin. Lift follows a Hill-type saturating form: it
+approaches a ceiling as spin increases rather than growing without bound, which
+is what the wind-tunnel data shows.
+
+These parametric forms are consistent with **Bearman & Harvey (1976)** and
+**Kensrud & Smith (2018)** for dimpled balls past the drag crisis
+(Re ≈ 5×10⁴–2×10⁵), which covers the full range of realistic golf shots.
+
+Spin decays exponentially over the flight:
+
+$$
+\omega(t) = \omega_0 \, e^{-\lambda t}
+$$
+
+at roughly 4 %/s per **Kiratidis & Leinweber (2018)** — small per second, but it
+matters across a six-second flight.
+
+## Constants
+
+| Constant | Value | Note |
+| --- | --- | --- |
+| `BALL_MASS_KG` | 0.04593 | USGA **maximum**-conforming ball, 45.93 g |
+| `BALL_RADIUS_M` | 0.02135 | 42.7 mm diameter |
+| `AIR_DENSITY_STD` | 1.225 kg/m³ | Sea level, 15 °C ISA |
+| `CD_BASE` | 0.205 | Drag at zero spin |
+| `CD_SPIN_COEFF` | 0.18 | Linear drag rise with $S_p$ |
+| `CL_SATURATION` | 0.32 | Lift ceiling |
+| `CL_HALF_SP` | 0.15 | $S_p$ at half the lift ceiling |
+| `SPIN_DECAY_RATE` | 0.04 /s | ≈4 %/s |
+| `GRAVITY` | 9.81 m/s² | |
+| `DT_SECONDS` | 0.002 | 500 Hz integration |
+| `MAX_FLIGHT_SECONDS` | 15.0 | Safety cap |
+| `SAMPLE_INTERVAL_S` | 0.05 | Cadence of returned trajectory points |
+
+!!! note "Why the maximum-conforming ball"
+
+ Mass and radius use the USGA maximum rather than an average, so carry
+ estimates are upper-bounded by the rules rather than by a guess about which
+ specific ball is in play.
+
+## Integration
+
+Fourth-order Runge-Kutta at 500 Hz. RK4 error is $O(\Delta t^5)$, so at this
+step size the integration is effectively exact for the timescales involved;
+noticeably larger steps begin to shorten long drives.
+
+The solver returns points every 50 ms rather than every step — integration still
+runs at the full rate, but the sampled list keeps WebSocket payloads and session
+logs a reasonable size.
+
+Real shots terminate in 5–9 seconds. The 15-second cap exists so a solver
+instability or pathological input fails loudly instead of spinning.
+
+## Inputs and fallbacks
+
+The simulator needs ball speed, launch angle, and spin. Ball speed always comes
+from the OPS243. The other two may be missing:
+
+| Input | When present | When missing |
+| --- | --- | --- |
+| Launch angle | IWR6843 measurement | Falls back to the legacy carry table estimator |
+| Spin | OPS243, if confidence is high | Club-typical spin from TrackMan PGA Tour averages, by `ClubType` |
+
+Measured spin is only trusted above `SPIN_CONFIDENCE_HIGH` (0.7). Below that,
+the club-typical value is used — see
+[rolling buffer & spin detection](rolling-buffer.md) for why.
+
+`--calculated-spin` forces the kinematic estimate
+$170 \cdot v \cdot \sin(\text{LA})^{1.2}$ even when a measured value exists,
+keeping the measured number in `spin_rpm_measured` for offline scoring.
+
+## Disabling it
+
+```bash
+scripts/start-kiosk.sh --no-ballistics
+```
+
+Uses the legacy carry-table estimator for every shot. The simulator is the
+default; shots without a vertical launch angle fall back to the table anyway.
+
+## Related
+
+- [Rolling buffer & spin detection](rolling-buffer.md) — where spin comes from
+- [Constants](../reference/constants.md) — the full constant set
+- [TrackMan test process](../development/trackman-testing.md) — validation
diff --git a/docs/how-it-works/index.md b/docs/how-it-works/index.md
new file mode 100644
index 000000000..b8a4d1b0f
--- /dev/null
+++ b/docs/how-it-works/index.md
@@ -0,0 +1,60 @@
+---
+icon: lucide/brain
+---
+
+# How It Works
+
+The measurement chain, from a strike to a carry number, and the honest limits of
+each stage.
+
+
+
+- :material-sitemap-outline: **[Measurement pipeline](pipeline.md)**
+
+ Components, the shared trigger edge, and how the two radars are correlated.
+
+- :material-waveform: **[Rolling buffer & spin](rolling-buffer.md)**
+
+ What is inside a capture, how ball and club speed are separated, and why
+ spin is still experimental.
+
+- :material-angle-acute: **[Launch angle](launch-angle.md)**
+
+ LCMF-v1 over the IWR6843 raw radar cube, and the July 2026 TrackMan
+ baseline.
+
+- :material-chart-bell-curve: **[Ballistics & carry](ballistics.md)**
+
+ Drag, Magnus, and the RK4 integration that produces carry distance.
+
+- :material-map-marker-radius-outline: **[Radar positioning](positioning.md)**
+
+ Where each radar goes and why the geometry matters.
+
+
+
+## In one paragraph
+
+A sound detector hears the strike and sends one electrical edge to two places at
+once: the OPS243's `HOST_INT` pin, which freezes its rolling buffer of raw I/Q
+samples, and the Pi's BCM17, which asks the IWR6843 to dump its rolling frame
+ring. OpenFlight then does its own signal processing on both captures — an FFT
+over short windows for speed, and LCMF-v1 over the radar cube for angle — merges
+them on the OPS impact timestamp, and runs the result through a ballistic
+simulation to get carry.
+
+## What is trusted
+
+Not every number the system produces is used for physics.
+
+| Measurement | Status |
+| --- | --- |
+| Ball speed, club speed | Trusted |
+| Launch angle, launch direction | Trusted within the estimator's stated limits |
+| Club path | Reported, experimental |
+| Spin rate | **Experimental** — recorded, not used for carry by default |
+
+Carry uses trusted measurements and fills anything missing with documented
+fallbacks rather than guessing silently. When spin is absent, the simulator uses
+a kinematic estimate derived from ball speed and launch angle; `--calculated-spin`
+makes that substitution explicit even when a measured value exists.
diff --git a/docs/how-it-works/launch-angle.md b/docs/how-it-works/launch-angle.md
new file mode 100644
index 000000000..f7893e2ee
--- /dev/null
+++ b/docs/how-it-works/launch-angle.md
@@ -0,0 +1,531 @@
+---
+icon: lucide/angle
+---
+
+# Launch Angle
+
+*Field report — updated 22 July 2026.*
+
+**How we measure launch angle with a 60 GHz radar.** A plain-language guide to
+the OPS243 + TI IWR6843 pipeline, the late-flight algorithm we call LCMF-v1,
+what three TrackMan sessions taught us, and how we plan to improve coverage
+without hiding quality.
+
+| | |
+| --- | --- |
+| Angle sensor | **TI IWR6843** |
+| Speed sensor | **OPS243** |
+| Estimator | **LCMF-v1** |
+| Validation | **Three indoor TrackMan sessions** |
+
+**Explain it like I’m five**
+
+## We watch the ball several times, keep the clearest pictures, and ask five judges where it is going.
+
+The OPS radar tells us how fast the ball is moving. The TI radar takes a short movie made of radio echoes. We find the little streak that moves away from the tee, keep the clearest moments from each frame, and pay extra attention to the later part of the flight because the ball is farther from the club, golfer, and impact mess.
+
+Five slightly different physics models each estimate launch angle. We average their answers and use the measured mount geometry directly. If the radar movie is strong, the UI shows the measured radar angle. If the movie is weaker but still plausible, we want to show it as a lower-confidence two-dot radar read. If the radar cannot honestly follow the ball, the UI labels a normal club-based estimate instead.
+
+!!! tip "The important idea"
+
+ Many looks + multiple models + honest confidence, never a hidden adjustment that quietly fits one golfer or one club.
+
+**Where we are**
+
+## The short version
+
+| Value | Meaning |
+| --- | --- |
+| **0.83°** | combined Iron/Wedge MAE across two indoor TrackMan validation sessions |
+| **87.4%** | strict LCMF-v1 coverage on Iron/Wedge shots, 76 of 87 |
+| **0.67°** | p50 absolute error; half of covered Iron/Wedge shots were inside this |
+| **-0.04°** | bias on covered Iron/Wedge shots, effectively centered |
+
+For Iron/Wedge shots, the current pipeline is inside the 1° target on the shots where the radar has enough clean evidence. Driver and Mis Hits are tracked separately because they expose different engineering problems. The next job is widening coverage and hardening setup inputs so the same result travels to different builders, mats, rooms, and ranges.
+
+**State the problem**
+
+## We need ball and club direction in a brutally short window
+
+OpenFlight has to measure vertical launch, horizontal launch, and eventually useful club-delivery signals while the ball is only a few feet from the radar. Indoors, a fast driver can hit a net or screen tens of milliseconds after impact. That leaves very little clean flight, and the earliest echoes are exactly where the club, hands, tee, ball, floor reflection, and impact noise overlap.
+
+The K-LD7 taught us the core lesson: one transmitter, a slower frame cadence, coarse range separation, and one or two useful post-impact looks were not enough to consistently separate the real ball path from multipath and blind-zone behavior. It could look good on selected shots, but it did not create a robust 1° path against TrackMan, the gold-standard source of truth for launch-monitor validation.
+
+!!! note
+
+ **The product problem is not just “detect a ball.”** It is detecting the right moving echo, proving it is the ball, modeling the ground-reflected copy, and reporting confidence honestly when the evidence is thin.
+
+**Why this radar**
+
+## The IWR6843 gives us enough raw evidence to model the mess
+
+We selected TI’s IWR6843 because it can capture coherent complex radar data across multiple receive channels at a much faster cadence than the K-LD7 setup. The IWR6843LEVM board was the practical evaluation platform: available hardware, known antenna geometry, TI tooling, and enough on-chip L3 memory to hold a compact radar cube from the shot.
+
+We created and uploaded custom firmware that turns the TI board into a short “radar movie” recorder. Instead of asking the radar to make a decision live, the board saves 12 tightly spaced snapshots of the ball leaving the tee. Each snapshot keeps detailed antenna information, so our software can later follow the ball moving away and separate it from the floor reflection, club, and impact noise.
+
+| Capability | Why it matters | Current use |
+| --- | --- | --- |
+| Fast frame cadence | More looks before the net or screen contaminates the track. | 12 snapshots over roughly 72 ms in the current custom firmware. |
+| Fine range bins | Direct and floor-reflected paths can separate in range as the ball leaves the tee. | 3.2 GHz sweep, about 4.7 cm bins. |
+| Complex antenna channels | Phase across the array carries angle information even when amplitude is messy. | Eight vertical virtual channels from TX1 + TX3 and four RX. |
+| L3 rolling buffer | We can keep raw shot evidence and improve offline without reflashing for every idea. | 786,452-byte dump per trigger, drained by the Pi. |
+
+**OPS + IWR6843**
+
+## Two radars, each doing the job it is best at
+
+The one-chip goal was useful while learning the TI sensor, but it is no longer the product direction. OPS speed has been highly consistent and does not need to be replaced. The TI board is now focused on the measurements its antenna array can add: vertical launch angle today, and eventually aim direction and club path.
+
+### OPS243
+
+Measures ball and club radial speed from its rolling buffer. This remains the speed authority.
+
+### TI IWR6843
+
+Stores a 72 ms coherent radar movie across eight virtual vertical antenna channels.
+
+### OpenFlight UI
+
+Combines OPS speed with TI launch angle. If TI has no read, the shot still appears with an estimated angle.
+
+### Why the TI chip changed the problem
+
+The K-LD7 could usually see only one or two useful frames before the indoor net stopped the ball. It also had coarse range resolution and only a two-element angle view, so a clean ball echo and a floor reflection could blend into one believable but wrong angle.
+
+The custom firmware saves a compact, high-detail radar movie: 12 snapshots of the ball leaving the tee, spaced about 6 ms apart. Each snapshot uses multiple antenna views and fine distance slices, which gives the software enough evidence to model the floor reflection instead of pretending it is not there.
+
+!!! note
+
+ **The rolling buffer is the enabling trick.** This is similar to how OPS keeps a rolling speed buffer: the radar is always recording, and impact tells the system which recent slice matters. A sound trigger connected to the Pi freezes the TI radar movie after impact, preserving the last 72 ms of raw antenna data. The Pi then drains the 786,452-byte dump over UART in about 7.6 seconds. We accept the delay because we keep the raw evidence for every shot.
+
+
+
+*Rolling buffer mental model.* The radar is not trying to decide launch angle from one echo. It stores a short radio movie, then LCMF looks for the portion where the ball has separated from the impact mess but has not yet reached the net or screen.
+
+**LCMF-v1**
+
+## What the algorithm actually does
+
+**LCMF** means **Late-Flight Complex Multipath Fusion**. “Late-flight” means it favors the cleaner second half of the captured ball flight. “Complex” means it keeps both amplitude and phase from every antenna. “Multipath” means direct and floor-reflected echoes are modeled together. “Fusion” means no single model gets to decide the answer.
+
+### Freeze both buffers
+
+The same impact edge timestamps the OPS shot and starts the TI dump. Matching is normally within a few milliseconds.
+
+### Find the outward streak
+
+Static clutter is removed. The tracker follows a target moving outward through range over time rather than trusting aliased Doppler speed.
+
+### Balance the frames
+
+Keep snapshots with strength score ≥ 8 and range ≤ 4.7 m, then retain at most the strongest four from each frame. One noisy frame cannot dominate.
+
+### Use OPS speed as the guide
+
+The independently measured OPS ball speed defines the candidate trajectory. TI’s local range-walk velocity handles the small timing correction between transmitters.
+
+### Ask five physics models
+
+Two models compare the eight antenna channels across all balanced snapshots. Three inspect the direct and reflected range structure in the chronological late half.
+
+### Fuse and report
+
+Each model receives exactly 20% weight. Their mean is the LCMF angle. The production path favors measured setup geometry and the same estimator rules for every club.
+
+| Model family | Plain-language question | Data used |
+| --- | --- | --- |
+| Two channel models | Which launch trajectory best explains the phase pattern across the antenna array when direct and floor paths are allowed? | All balanced snapshots |
+| Three fast-time models | Which trajectory best explains the small range separation and mixture of direct and reflected echoes around the tracked ball? | Chronological second half |
+| Equal fusion | What answer survives five different assumptions instead of winning one hand-picked model? | 20% per component |
+
+**Real captured shots**
+
+## Which frames are selected, and why late flight helps
+
+The gray marks below are usable snapshots along three real TrackMan-paired ball tracks. Orange circles are the strongest four retained from each frame. Teal dots are the chronological late half used by the three fast-time models. Frame numbers wrap because the radar memory is a ring; the horizontal time axis is the true order.
+
+
+
+*Actual July 14 captures.* The driver has only three clean frames and is therefore the hardest case. The 7-iron and 9-iron offer more looks across the flight. Late snapshots are not automatically “correct”; they are simply less contaminated by impact, club, hands, and tee while providing more direct-versus-ground path separation.
+
+This selection is deliberately boring: no club-specific timing window, no TrackMan input, and no hand-picked frame number. The same strength, range, per-frame balancing, and chronological-half rules run on every shot.
+
+**Setup calibration**
+
+## The radar is accurate only if the setup geometry is honest
+
+The biggest lesson from the first two validation sessions is simple: the algorithm can follow the ball, but it needs the real-world setup described correctly. Mount tilt, radar height, tee distance, ball height, mat height, and net distance all affect where the radar expects the direct and floor-reflected echoes to appear.
+
+When those inputs are right, the same LCMF-v1 estimator produces a centered result across two indoor TrackMan sessions without per-club tuning. When those inputs are wrong, the error can look like a radar problem even though the underlying ball track is still present.
+
+| Setup input | Why it matters | Product plan |
+| --- | --- | --- |
+| Mount tilt | Defines how the antenna frame maps into the golfer's launch frame. | Measured setting first; later add an on-rig level sensor. |
+| Tee distance | Changes the expected direct/reflected path geometry during the first few feet of flight. | Support measured distance and radar-assisted setup warnings. |
+| Mat and ball height | An elevated mat changes the ball height relative to the radar and the floor reflection. | Store ball height and mat/surface height separately. |
+| Net or screen distance | Defines how much clean late flight exists before impact with the screen or net. | Log it per session and use it when selecting late frames. |
+
+### What remains true
+
+- learnedThe estimator is extracting stable ball-angle information from the TI radar movie.
+- not solvedWe still need to make these setup measurements easy enough for normal builders, not just people who lived inside the test sessions.
+
+**TrackMan validation**
+
+## Two TrackMan sessions are the current accuracy baseline
+
+The headline below combines two indoor TrackMan validation sessions. Driver and Mis Hits are separated so the main number describes Iron/Wedge launch-angle performance rather than hiding known edge cases.
+
+| Group | Shots | Covered | Coverage | MAE | p50 | p75 | p90 | Bias |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| Iron/Wedge headline | 87 | 76 | **87.4%** | **0.83°** | 0.67° | 1.20° | 1.80° | -0.04° |
+| First validation session Iron/Wedge | 41 | 39 | 95.1% | **0.83°** | 0.78° | 1.25° | 1.81° | -0.10° |
+| Second validation session Iron/Wedge | 46 | 37 | 80.4% | **0.84°** | 0.59° | 1.19° | 1.70° | +0.02° |
+
+!!! note
+
+ **How to read absolute error:** MAE is the average distance from TrackMan, ignoring sign. p50 means half the covered shots were closer than that error. Bias keeps the sign and tells us whether the whole group is systematically high or low.
+
+### Current 18-frame firmware confirmation
+
+A third indoor TrackMan session on July 22 tested the production firmware in this report: 3 TX, 12 loops, 18 frames, 4 ms spacing, and moving 53-bin windows. Twenty shots had matched OpenFlight and TI captures and therefore formed the valid radar denominator; five additional TrackMan swings had no corresponding OpenFlight capture and were not counted as estimator misses.
+
+Using the physically measured 12.4° mount geometry, the matched group produced approximately **0.68° launch-angle MAE**. An earlier block in the same session independently suggested about 12.3°; applying that geometry to the later good-shot block produced approximately 0.47° MAE. That temporal result is encouraging, but it remains diagnostic rather than the headline because the tilt candidate was inferred inside the same session. The conservative conclusion is that denser, cropped firmware preserved sub-1° vertical accuracy while adding more frames and the third transmitter.
+
+### Iron/Wedge breakdown by club
+
+The 9-iron table excludes a same-day experimental transmitter-order test from the headline. That experiment is useful firmware evidence, but it should not be mixed into the normal production score.
+
+| Club | Good shots | Covered | Coverage | MAE | p50 | p75 | p90 | Bias |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| Sand wedge | 17 | 15 | 88.2% | **0.67°** | 0.46° | 1.06° | 1.58° | -0.22° |
+| 9-iron | 27 | 25 | 92.6% | **0.89°** | 0.81° | 1.18° | 1.73° | +0.24° |
+| 7-iron | 21 | 18 | 85.7% | **0.91°** | 0.49° | 1.15° | 1.88° | -0.06° |
+| 5-iron | 22 | 18 | 81.8% | **0.82°** | 0.69° | 1.31° | 1.84° | -0.25° |
+
+### Driver separated
+
+| Group | Shots | Covered | Coverage | MAE | p50 | p75 | p90 | Bias |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| Driver | 22 | 18 | 81.8% | **3.55°** | 1.31° | 1.82° | 15.57° | +3.39° |
+| First validation session driver | 9 | 5 | 55.6% | **1.58°** | 1.37° | 1.85° | 2.77° | +1.08° |
+| Second validation session driver | 13 | 13 | 100.0% | **4.31°** | 1.21° | 1.71° | 15.68° | +4.27° |
+
+!!! note
+
+ **Driver is not the same failure as irons.** The second-session driver misses were mostly false acceptance of slow/ghost tracks. Raw replay showed the real fast ball in the frames, so the immediate fix is an OPS-vs-TI speed gate and a low-confidence fast-track recovery path.
+
+### Mis Hits separated
+
+| Group | Shots | Covered | Coverage | MAE | p50 | p75 | p90 | Bias |
+| --- | --- | --- | --- | --- | --- | --- | --- | --- |
+| Mis Hits | 19 | 13 | 68.4% | **2.20°** | 1.16° | 3.07° | 5.10° | -0.54° |
+| First validation session Mis Hits | 8 | 7 | 87.5% | **2.40°** | 1.29° | 3.81° | 4.83° | -0.15° |
+| Second validation session Mis Hits | 11 | 6 | 54.5% | **1.98°** | 0.78° | 1.43° | 4.54° | -1.00° |
+| Skulls / very low launch | 5 | 5 | 100.0% | **6.00°** | 2.07° | 3.51° | 15.47° | +4.31° |
+
+These groups are still product-critical because real golfers hit them. They are separated here because they require different engineering: TX2 aim for directional Mis Hits, low-launch/impact-clutter logic, and OPS speed agreement for driver.
+
+**Next steps**
+
+## Increase coverage, add club data, and harden the setup inputs
+
+The strict LCMF-v1 gate is doing the right thing for accuracy, but it leaves some real ball flights unreported. The first validation session already had strong strict coverage after the setup geometry was cleaned up. The second session showed the more practical product problem: a relaxed pass can recover no-reads, but those reads should enter the UI as measured lower-confidence angles rather than being mixed into the high-confidence lane.
+
+**RMS** is a “how messy was the fit?” score. Lower RMS means the radar snapshots line up neatly with one clean ball path. Higher RMS means the ball is probably there, but the evidence is noisier, weaker, or more mixed with reflections. Relaxing the RMS limit lets us accept more of those imperfect radar tracks, which increases coverage, but it also increases the chance that a recovered angle is a little farther from TrackMan.
+
+The product answer should not be “lower the bar and call everything high confidence.” The product answer should be a second lane: **measured, but lower confidence**.
+
+| Mode | Iron/Wedge coverage | MAE | p50 | p75 | p90 | New recovered reads | Recommended UI |
+| --- | --- | --- | --- | --- | --- | --- | --- |
+| Combined strict LCMF-v1 | **76 / 87 · 87.4%** | **0.83°** | 0.67° | 1.20° | 1.80° | 0 | 3 dots |
+| First-session strict | **39 / 41 · 95.1%** | **0.83°** | 0.78° | 1.25° | 1.81° | 0 | 3 dots |
+| Second-session strict | **37 / 46 · 80.4%** | **0.84°** | 0.59° | 1.19° | 1.70° | 0 | 3 dots |
+| Second-session relaxed RMS ≤ 0.58 | **42 / 49 · 85.7%** | **1.00°** | 0.71° | 1.32° | 2.06° | 5 | 2 dots |
+| Second-session relaxed RMS ≤ 0.70 | **45 / 49 · 91.8%** | **1.09°** | 0.78° | 1.56° | 2.50° | 8 | 2 dots, lab-only first |
+
+### Recommended confidence contract
+
+1. **Strict pass:** if normal LCMF-v1 accepts, show the radar launch angle as the primary measured value with full confidence.
+2. **Relaxed pass:** if strict LCMF no-reads but a relaxed replay passes RMS, snapshot, frame, component-spread, and OPS-speed checks, show the radar angle with a two-dot confidence marker.
+3. **Estimate:** if both radar passes fail, show the club/speed estimate exactly as we do today.
+
+!!! note
+
+ **Why this is honest:** a two-dot radar read is still better than hiding useful evidence, but it tells the golfer and the engineering team that the ball track was recovered under looser rules. That preserves trust while improving coverage.
+
+### Driver-specific guardrail
+
+Driver needs an additional speed sanity check before any confidence badge. The worst driver misses were accepted TI tracks around 55–57 mph while OPS measured roughly 152–158 mph. Raw-frame replay showed the fast ball was present, so the immediate fix is to withhold any TI angle when its tracked speed is far below OPS speed, then optionally try an OPS-guided fast-track recovery as a low-confidence read.
+
+| Driver policy | Why | Expected effect |
+| --- | --- | --- |
+| Reject TI track speed below about 65–70% of OPS ball speed | Catches obvious slow ghost tracks before they reach the UI. | Turns bad measured angles into estimates instead of false confidence. |
+| Try OPS-guided fast-track recovery after rejection | Offline replay recovered 3 of 4 bad driver shots with about 2.6° MAE. | Potential two-dot driver reads, but needs more truth data before shipping as normal confidence. |
+
+### Club data and setup hardening
+
+- club**Keep club speed with OPS, but start collecting club-delivery evidence.** A split pre-impact/post-impact firmware mode may expose attack angle and club path research signals without pretending the TI board is the club-speed authority.
+- aim**Add TX2 for horizontal launch and shank classification.** Far-right shots should eventually be recognized as far-right shots, not forced through a purely vertical interpretation.
+- inputs**Harden tee distance, mount tilt, radar height, ball height, mat height, and net distance.** The app should support measured settings, defaults, and radar sanity checks that warn when the actual session appears to drift.
+
+**DIY setup variables**
+
+## Ball placement is a product variable, not just a measurement chore
+
+Commercial radar systems usually solve tee placement with a prescribed setup window and alignment aids. Photometric systems solve it by forcing the ball into a camera-observed hitting zone. OpenFlight sits in the DIY middle: we want the accuracy of a measured geometry, but builders may move between a marked home mat, an unmarked simulator bay, and a range mat where the ball can drift shot to shot.
+
+!!! note
+
+ **July 17 replay result:** when ball placement is disciplined, fixed tee distance is best. When placement wanders more than about 6–9 inches, radar-computed or blended tee distance starts beating a stale fixed setting.
+
+### How sensitive is tee distance?
+
+We reran second-session Iron/Wedge shots with tee distance shifted by ±6 inches. On the clean Iron/Wedge group, the original fixed tee distance scored **0.84° MAE**. A six-inch mistake roughly doubled or tripled the error.
+
+| Tee distance used by LCMF | Covered | MAE | p50 | p75 | p90 | Bias |
+| --- | --- | --- | --- | --- | --- | --- |
+| Saved distance − 6 in | 37 / 46 | **1.61°** | 1.34° | 2.23° | 2.89° | -1.54° |
+| Saved distance | 37 / 46 | **0.84°** | 0.59° | 1.18° | 1.69° | +0.01° |
+| Saved distance + 6 in | 37 / 46 | **2.05°** | 1.98° | 2.87° | 3.57° | +1.81° |
+
+### Radar-estimated tee range
+
+The TI track can be extrapolated backward toward impact to estimate where the ball started in range. On July 17, using an effective impact point near 12.5 ms inside the stored radar cube, the radar-derived start range centered very close to the measured tee distance:
+
+| Group | Median estimated distance error | Mean estimated distance error | Middle 50% | Middle 80% |
+| --- | --- | --- | --- | --- |
+| All Iron/Wedge | +0.81 in | -0.06 in | -2.65 to +2.63 in | -7.46 to +7.22 in |
+| Clean validation subset | +0.06 in | -0.66 in | -2.83 to +2.41 in | -8.27 to +6.12 in |
+
+That is good enough for a **setup sanity check**. It is not yet good enough to blindly replace the tee distance shot by shot. On the clean group, per-shot radar tee range increased MAE from **0.84°** to **1.22°**. The per-shot estimate is useful evidence; the raw value is still noisy.
+
+### Wild-placement simulation
+
+To model unmarked ranges and multi-golfer use, we simulated true ball placement wandering around the saved tee distance. The comparison below uses the clean Iron/Wedge validation subset.
+
+| Placement pattern | Best tee mode | Best MAE | Fixed-distance MAE | Per-shot radar MAE |
+| --- | --- | --- | --- | --- |
+| Random ±3 in | Fixed | **0.97°** | 0.97° | 1.30° |
+| Random ±6 in | 50/50 blend | **1.14°** | 1.22° | 1.28° |
+| Random ±9 in | 50/50 blend | **1.23°** | 1.50° | 1.29° |
+| Random ±12 in | Per-shot radar | **1.30°** | 1.84° | 1.30° |
+| Random ±18 in | Per-shot radar | **1.30°** | 2.59° | 1.30° |
+| Constant setup error ±6–12 in | Rolling 20-shot median | **0.90–1.00°** | 1.64–3.35° | 1.05–1.42° |
+
+### Recommended app settings
+
+| Mode | Use when | Behavior |
+| --- | --- | --- |
+| Fixed distance | Marked home mat, repeatable tee dot, single golfer. | Use the saved measured distance for every shot. Highest accuracy when placement is controlled. |
+| Radar assisted | Recommended default for DIY setups. | Use saved distance for LCMF, but warn when the rolling radar estimate says the ball is consistently closer or farther. |
+| Radar computed | Range bay, no marker, multiple golfers, or intentionally flexible hitting area. | Use a gated blend of per-shot radar estimate and rolling median. Prefer rolling median for stable setup errors; prefer per-shot only when placement is clearly moving. |
+
+### Other rig and user variables to test next
+
+- tilt**Mount angle.** The validation data strongly suggests tilt accuracy is one of the most important setup inputs. We need deliberate TrackMan A/B runs at 10.4°, 11.2°, and 12.0°.
+- height**Mat height above radar floor.** A one-inch elevated mat can move early-flight geometry by multiple degrees and late-flight geometry by roughly 0.5–1.0°. The app should store surface offset separately from ball/tee height.
+- net**Net or screen distance.** If the ball reaches the net quickly, late frames can become contaminated. Test 4.0 m, 4.6 m, and 5.2 m with driver and wedges.
+- floor**Ground material.** Hardwood, turf, concrete, carpet, and range mats change the floor reflection. We should log surface type and compare component spread and no-read rate.
+- aim**Horizontal ball position and shanks.** Launch direction changes the vertical fit slightly and shanks are a coverage/classification problem. TX2 aim is the right long-term fix.
+- alignment**Radar yaw to target line.** Small yaw errors mostly affect speed projection and future aim, but they can also change which multipath track wins. Add yaw/alignment to setup QA.
+- occlusion**Golfer stance and handedness.** Left/right-handed setup, foot position, and club path may alter early clutter. Track handedness and run a small lefty/righty A/B when possible.
+- ball**Ball type and markings.** Different balls should not change range-walk geometry much, but spin markings, metallic tape experiments, and range balls can change RCS and no-read rate.
+
+**Why it is working**
+
+## Four improvements compound
+
+- time**More frames.** The ball is observed repeatedly instead of asking one or two moments to carry the whole answer.
+- range**A real trajectory.** Fine range bins let us follow the ball moving outward and use never-aliasing range walk instead of trusting Doppler alone.
+- physics**Multipath is part of the model.** Direct and floor-reflected paths are allowed to coexist; the algorithm does not force their mixture into one fake point angle.
+- independence**OPS anchors speed.** Angle fitting is not allowed to improve itself by changing the speed assumption, and the product does not depend on TI club-speed calibration.
+- diversity**Five models must agree in aggregate.** Different models fail differently, so equal fusion is more stable than selecting whichever model happened to look best on this session.
+
+**Limits and next experiment**
+
+## What could still prevent a 1° product
+
+- validation**More independent data.** July 17 was a strong step, but confidence thresholds and driver recovery still need another truth session before we call them production behavior.
+- coverage**Strict no-reads.** The UI fallback makes the product complete, but 87.4% strict radar coverage on Iron/Wedge shots is not the finish line. A two-dot relaxed lane can recover useful reads without pretending they are equal to strict reads.
+- driver**Late flight can be short.** A driver may hit a close net around 40 ms after impact. The firmware budget may need denser frames or a driver-specific capture allocation without changing estimator rules.
+- confidence**No calibrated confidence yet.** Component spread and frame coverage are promising quality features, but thresholds must be learned on independent truth rather than invented.
+- latency**UART takes about 7.6 seconds.** On-chip range gating or compression can reduce the blind time and buy more frames without changing RF hardware.
+- 2D**Aim and club path are not in this capture.** Bringing in the third transmitter requires a new memory allocation and firmware experiment.
+
+**Technical deep dive**
+
+## How the current TI angle pipeline works
+
+This section is for contributors who want the engineering map without reading every replay script. The public story is “record a short radar movie and let five models vote.” The technical story is a synchronized OPS + TI capture, a ring-buffer unwrap, a range-walk tracker, balanced snapshot selection, five independent angle estimators, and strict quality gates before the value reaches the shot record.
+
+The key hardware unlock is the IWR6843’s on-chip **L3 RAM**. Instead of streaming every chirp over a slow UART connection in real time, the firmware writes the radar cube into local memory while the shot is happening. That lets the board preserve high-rate complex antenna data during the tiny post-impact window, then drain it slowly after the ball is gone. In plain English: L3 RAM lets us capture the important 72 ms at radar speed, then analyze it at Pi speed.
+
+### End-to-end signal chain
+
+| Stage | What happens | Why it exists |
+| --- | --- | --- |
+| Impact trigger | The sound trigger fires into the Pi. OPS and TI both preserve their recent rolling-buffer evidence around that impact. | Gives both radars the same shot reference instead of waiting for software to notice the ball. |
+| L3 capture | The custom TI firmware freezes the recent radar cube in on-chip L3 RAM before anything is sent to the Pi. | Keeps the full-speed antenna movie intact even though the serial dump takes several seconds afterward. |
+| OPS processing | OPS produces ball speed, club speed, impact timing, and the primary shot record. | OPS remains the speed authority because it is consistent and already product-integrated. |
+| TI dump | The IWR6843 dump is drained after the shot and associated with the OPS shot by trigger timing. | Preserves raw complex antenna evidence for measured launch angle and offline replay. |
+| LCMF replay | Static clutter is removed, the outward ball track is found, snapshots are selected, and five angle models are fused. | Separates the ball from impact clutter, floor reflection, and wrong tracks. |
+| Shot merge | The server adds TI launch angle when strict gates pass. Otherwise the UI can still show the normal estimate. | Keeps the product usable while making measured radar reads auditable. |
+
+### Current custom firmware capture
+
+The current firmware is optimized for vertical launch angle. Internally we still call this the Variant B baseline, but externally it is simply the custom rolling-buffer firmware.
+
+| Setting | Current value | Engineering tradeoff |
+| --- | --- | --- |
+| Frame count | 12 radar snapshots | Enough time history for irons/wedges; driver may need denser or more intentional post-impact timing. |
+| On-chip L3 RAM | 768 KB rolling storage for the radar cube | The unlock: capture first at radar speed, transfer later at UART speed. |
+| Frame spacing | About 6 ms | Fast enough to follow early flight, but a close net can still limit driver late-flight evidence. |
+| Range resolution | About 4.7 cm bins from a 3.2 GHz sweep | Fine enough to separate direct and floor-reflected structure better than the K-LD7 path. |
+| Vertical antenna view | TX1 + TX3 with four RX, forming eight vertical virtual channels | Preserves vertical phase diversity for launch angle. TX2 is reserved for future horizontal aim work. |
+| Per-frame evidence | 16 chirp pairs per frame | More chirps improve per-frame stability; fewer chirps could buy more frames for faster balls. |
+| Payload | 786,452 bytes per shot, drained in about 7.6 seconds | Large enough for rich offline evidence; slow enough that future compression/range gating matters. |
+
+### What the five LCMF models do
+
+LCMF does not trust a single angle estimate. It asks five models with different failure modes, then gives each model equal weight. That keeps the estimator explicit and reduces the temptation to choose whichever model happened to win one session.
+
+| Model | Uses | Plain-English role |
+| --- | --- | --- |
+| Channel model A | Complex phase and amplitude across the eight vertical antenna channels | Finds the launch trajectory that best explains the antenna pattern when a floor path is allowed. |
+| Channel model B | The same antenna evidence with a slightly different manifold assumption | Checks whether the answer survives a different view of the direct/reflected mixture. |
+| Fast-time model A | Late chronological snapshots and fine range-bin structure | Looks for the direct and floor-reflected range signature as the ball gets farther from the tee. |
+| Fast-time model B | Range-walk consistency through the late half of flight | Rewards trajectories that explain the ball moving outward at the OPS-guided speed. |
+| Fast-time model C | Snapshot strength, range shape, and late-frame consistency | Provides a third range-domain vote so one noisy frame cannot dominate the result. |
+| Fusion | 20% weight per model | Produces the final LCMF launch angle and exposes model spread as a quality signal. |
+
+### Deeper model notes
+
+All five models sweep candidate launch angles through the same geometry: measured tee range, radar height, ball height, mount tilt, OPS ball speed, and the tracked TI range samples. For each candidate angle, the software predicts where the direct ball echo and the floor-reflected “image ball” echo should appear. The models differ in which part of the raw radar evidence they trust most.
+
+- channel_two8**Two-source vertical-array model.** This is the simplest complex antenna model. It treats each selected snapshot as a mixture of two steering vectors across the eight virtual vertical channels: the direct path from the real ball and the image path from the floor reflection. The nuisance coefficients are complex, so amplitude and phase of each path are allowed to float. The candidate angle wins when those two columns predict the observed eight-channel vector with low leave-one-channel-out error. This is useful because it asks, “does the array phase look like this launch angle?” without needing the range-bin shape to be perfect.
+- channel_four4_path_tdm**Four-path transmit/receive manifold model.** The floor reflection can happen on transmit, receive, or both, so the full dictionary has four path products: direct-direct, direct-ground, ground-direct, and ground-ground. Because the IWR6843 is time-division multiplexed, the later TX block sees a slightly different path phase when direct and reflected components have different radial velocities. This model includes that TDM residual phase. It is more physically complete than the two-source model, but also has more nuisance freedom, so it is paired with leave-one-channel-out scoring to avoid simply overfitting noise.
+- fast_direct1**Late-flight direct-path range model.** This model ignores the floor image and asks whether the local FFT range window around the tracked ball can be explained by one direct path. It is intentionally under-modeled. When it agrees with the multipath models, that is a strong sign the direct echo is dominant and clean. When it disagrees, that disagreement is useful evidence that the shot is reflection-heavy or range-window contaminated.
+- fast_two2**Direct plus ground-ground range model.** This model keeps the direct path and the strongest image-ball term. It predicts the small range-bin separation between the real ball path length and the reflected path length, then fits complex coefficients inside a local FFT window around the track. This is the first fast-time model that directly asks, “does the range shape look like direct plus floor reflection?”
+- fast_four4**Full local range-shape multipath model.** This uses the same four DD/DG/GD/GG physical path products as the richer channel model, but now it also models where each path should fall inside the local range FFT. It is the most complete range-domain model: spatial steering across antennas multiplied by predicted fast-time tone responses around the ball bin. It is powerful when the late snapshots have enough SNR and path separation, but it can become sensitive to weak late frames, net contamination, or a wrong range track.
+
+The fusion step is deliberately boring: take the five component launch angles and average them with equal 20% weights. We do not let a single model “win” because each one fails differently. The channel models can be fooled by array manifold errors or calibration drift. The fast-time models can be fooled by range-window contamination or weak late frames. Agreement across both families is the useful signal.
+
+### Quality gates and confidence ingredients
+
+- tracker**Outward range track.** The ball candidate must move away from the radar in a physically plausible way.
+- speed**OPS agreement.** OPS ball speed guides the expected trajectory and should reject slow ghost tracks, especially for driver.
+- frames**Balanced snapshots.** LCMF keeps only a few strong snapshots per frame so one frame with clutter cannot overwhelm the vote.
+- RMS**Fit messiness.** RMS measures how cleanly the selected radar evidence fits the candidate path. Relaxed RMS recovers coverage but belongs in a lower-confidence lane.
+- spread**Component disagreement.** If the five models disagree too much, the shot should be withheld or shown as low confidence.
+- metadata**Session geometry.** Tilt, radar height, tee distance, ball height, mat height, net distance, firmware identity, and TX order must be stored with the shot.
+
+### How it connects to OpenFlight
+
+The server flag --iwr6843 enables the TI capture monitor alongside the existing OPS rolling-buffer monitor. OPS still creates the shot and owns speed/carry inputs. The TI monitor captures and processes the raw IWR6843 dump, then the server merges a measured launch angle into the shot when LCMF passes quality gates. When TI does not pass, the shot still appears using the existing estimated launch angle path.
+
+This split is intentional: OPS provides the stable product backbone, while TI adds measured ball-angle evidence without forcing the whole launch monitor to depend on one chip.
+
+**Firmware roadmap**
+
+## We stopped saving the empty parts of the radar movie
+
+The original roadmap asked whether the chip could calculate range on-board, retain only the useful evidence, add the third transmitter for aim, and still preserve a rolling history around impact. The answer is now mostly yes. The latest firmware computes the complete range picture for every chirp, then stores a smaller moving crop that follows the part of the hitting area where the club and ball can physically appear.
+
+!!! note
+
+ **The movie analogy is literal enough to be useful:** imagine every radar frame as a wide picture from the radar to the net. We still develop the whole picture, but save only a horizontal crop. The crop slides away from the golfer as time advances. Unlike resizing or video compression, the complex antenna values inside the crop are unchanged.
+
+!!! note
+
+ **Antenna-name clarification:** TI-labeled **TX1 + TX3** form the eight-element vertical array. The sideways **TX2** supplies the second axis for horizontal launch direction. The new capture now records all three transmitters; vertical launch still uses the proven TX1/TX3 pair while TX2 remains an experimental aim channel.
+
+
+
+*The planned 18-frame capture.* Six dense pre-impact frames keep the near-tee corridor, six early-flight frames shift outward, and six late-flight frames finish just beyond the home net distance. One frame may already be armed when impact arrives, so firmware records the actual crop used by every frame rather than asking the Pi to assume the schedule.
+
+### Develop the full range picture
+
+The Hardware Accelerator, or HWA, performs a 128-point range FFT for every chirp. That preserves the existing 4.7 cm range resolution while moving repetitive math off the CPU.
+
+Real ADC tests, corner reflectors, and ball captures confirmed that the HWA can process the live signal and preserve the complex phase and amplitude needed for angle estimation.
+
+### Store a moving 53-bin crop
+
+Instead of saving all 128 bins, EDMA copies 53 complex bins from the HWA into L3. The target crop moves through bins 20–72, 32–84, and 47–99 as the ball leaves the tee.
+
+A geometry replay of 196 TrackMan-recorded trajectories, from sand wedge through driver, retained every modeled ball point that the previous fixed 80-bin capture could have retained. Hardware ball validation is next.
+
+### Freeze on a clean frame boundary
+
+The ring continuously overwrites old history, like the OPS speed buffer. Impact tells the Pi to request a freeze; firmware then records a deliberate number of post-impact frames before stopping on a completed HWA/EDMA boundary.
+
+Repeated clap, corner-reflector, and ball tests proved that the ring can freeze, dump, rearm, and capture again without relying on a seven-second host-side delay.
+
+### Reinvest the saved memory
+
+The next candidate records **3 TX, 12 loops, 18 frames, and 4 ms spacing**. That is 20% more per-transmitter looks, 50% more frames, and a denser view of the club approaching impact.
+
+The linker proves it uses 549,504 of 786,432 L3 bytes, leaving 236,928 bytes for safety and future capture modes. Repeated home captures proved freeze, transfer, decode, and rearm; the July 22 TrackMan session confirmed approximately 0.68° vertical MAE on the matched capture group.
+
+### Where the L3 budget went
+
+The first reduction proved the idea with a fixed 80-bin crop. The moving 53-bin version goes further, then spends part of the savings on better time resolution rather than merely producing the smallest file.
+
+| Capture design | Geometry | L3 ring | Meaning |
+| --- | --- | --- | --- |
+| Original vertical baseline | 2 TX · 16 loops · 12 frames · 128 samples | 786,432 B | Proven TrackMan evidence, but no room for TX2 or more frames. |
+| Raw three-TX proof | 3 TX · 10 loops · 12 frames · 128 samples | 737,280 B | Added aim, but spent almost the entire ring. |
+| Fixed range snapshot | 3 TX · 10 loops · 12 frames · 80 bins | 460,800 B | Hardware proof that the chip can store FFT output instead of raw ADC. |
+| Moving range crop | 3 TX · 10 loops · 12 frames · 53 bins | 305,280 B | Smallest current ring while retaining the modeled flight corridor. |
+| Production capture | 3 TX · 12 loops · 18 frames · 53 bins | 549,504 B | Reinvests memory in stronger, denser club and ball evidence while preserving sub-1° vertical MAE. |
+
+### Four words that make the firmware easier to follow
+
+| Word | Plain-language meaning |
+| --- | --- |
+| Frame | One radar movie frame containing a short burst of measurements from every active antenna. |
+| Loop | One pass through TX1, TX2, and TX3. More loops provide more looks inside one frame. |
+| Range bin | One approximately 4.7 cm distance slice between the radar and the net. |
+| Complex I/Q | The amplitude and phase evidence retained for tracking speed and angle. The crop preserves both. |
+
+### What is proven, and what is not
+
+| Finding | Status | Evidence |
+| --- | --- | --- |
+| On-chip range FFT preserves usable complex antenna evidence | Hardware proven | HWA self-tests, real ADC tests, corner reflectors, and ball captures. |
+| Selected bins can continuously fill and rearm a compact L3 ring | Hardware proven | Repeated boundary-frozen captures without short dumps. |
+| All three transmitters retain detectable vertical and horizontal motion | Outdoor proven | Vertical launch remained plausible and intentional left/right groups separated. Horizontal accuracy still lacks TrackMan truth. |
+| The 53-bin schedule covers normal launch-monitor trajectories | Replay supported | 196 TrackMan trajectories modeled through the proposed early, middle, and late windows. |
+| 12 loops and 18 four-millisecond frames fit and decode correctly | Hardware proven | Firmware linker map, exact-geometry host regression, repeated home shots, and matched TrackMan captures. |
+| The new firmware maintains sub-1° launch-angle MAE | TrackMan confirmed | Approximately 0.68° MAE on 20 matched July 22 captures using the measured 12.4° mount geometry. |
+| Denser frames improve driver, club path, and attack angle | TrackMan pending | The extra pre-impact points are physically promising, but improvement has not been scored. |
+
+!!! note
+
+ **What cropping could miss:** a badly entered tee or net distance, unusual trigger timing, an extreme mishit, or an unexpected reflection could place useful energy outside the saved corridor. That is why the three windows are 2.48 m wide, overlap heavily, and record their actual starting bin in every frame. Runtime tee/net-aware presets come only after the fixed schedule passes TrackMan.
+
+### What remains
+
+| Work | Question it answers | Next proof |
+| --- | --- | --- |
+| Independent firmware holdout | Does the 12-loop/18-frame result repeat after moving the rig and measuring geometry from scratch? | Repeat the production capture in another bay without fitting tilt from the scored block. |
+| Capture coverage | Why did five TrackMan swings lack a corresponding OpenFlight/TI capture? | Separate trigger, OPS-shot, UART, and estimator denominators in the next truth session. |
+| Driver recovery | Do 4 ms frames expose the real fast ball before a close net? | Compare tracked TI speed with OPS and TrackMan; reject slow ghost tracks. |
+| Horizontal launch | Does TX2 measure degrees, not merely left/right sign? | TrackMan launch-direction MAE, bias, and coverage. |
+| Club delivery | Do six dense pre-impact frames improve club path and attack angle? | Score against TrackMan club data without changing the ball estimator. |
+| Sparse swing history | Can a second low-cost ring retain club-parallel-to-club-parallel motion? | First prove the dense frames consistently identify the club head. |
+| Club and room presets | Should driver favor cadence while wedges favor deeper evidence, and should the crop follow net distance? | Only after one global configuration establishes an unbiased baseline. |
+| Production calibration | Can multiple boards share one estimator? | Per-unit phase/gain calibration, enclosure tests, and eventual custom-PCB validation. |
+
+### Recommended validation order
+
+1. Repeat the 549,542-byte production capture in an independent bay with geometry measured before scoring.
+2. Separate trigger coverage, matched-capture coverage, estimator coverage, and accuracy in the session report.
+3. Score horizontal coverage, bias, MAE, P50, P75, and P90 before changing thresholds.
+4. Test 14 loops only if 12 loops show a quality or coverage limitation worth spending another 91,584 bytes.
+5. Use the remaining L3 budget for club-aware presets or sparse swing history only after ball-angle performance is protected.
+
+!!! note
+
+ **Bottom line:** the chip did not need more memory; it needed a better editor. On-chip HWA processing and frame-aware cropping let us keep the radar evidence that can affect the answer, discard distance slices the ball cannot occupy, and spend the recovered budget on a denser club-and-ball movie. TrackMan now decides whether that engineering improvement becomes a product improvement.
diff --git a/docs/how-it-works/pipeline.md b/docs/how-it-works/pipeline.md
new file mode 100644
index 000000000..32421301a
--- /dev/null
+++ b/docs/how-it-works/pipeline.md
@@ -0,0 +1,99 @@
+---
+icon: lucide/network
+---
+
+# Measurement Pipeline
+
+## Components
+
+```mermaid
+flowchart LR
+ UI[React UI] <-->|WebSocket| S[Flask server]
+ S --> RB[RollingBufferMonitor]
+ RB --> OPS[OPS243Radar]
+ RB --> ST[SoundTrigger SEN-14262 → HOST_INT]
+ S --> TI[IWR6843Runtime]
+ S --> KLD[KLD7Tracker deprecated]
+ S --> BAL[Ballistics simulator]
+ S --> SIM[Sim connectors]
+ S --> CS[Cloud sync]
+ S --> SL[SessionLogger]
+```
+
+| Module | Responsibility |
+| --- | --- |
+| `ops243.py` | OPS243 driver, rolling-buffer capture, I/Q transfer |
+| `rolling_buffer/processor.py` | FFT, mode-based speed extraction, spin detection |
+| `rolling_buffer/` | Trigger strategies |
+| `iwr6843/` | TI driver, L3 raw dump parser, LCMF-v1 angle and club path |
+| `launch_monitor.py` | `Shot` dataclass, `ClubType`, carry estimation |
+| `ballistics.py` | RK4 trajectory with drag and Magnus |
+| `club_data.py` | Canonical club physics parameters |
+| `inclinometer.py` | LIS3DH tilt compensation |
+| `sim/` | Simulator connectors and network transports |
+| `cloud/` | Telemetry, config, session upload |
+| `server.py` | Flask server, `AppState`, staged shot processing |
+| `session_logger.py` | JSONL session logs |
+
+## The sequence
+
+1. **Impact.** The SEN-14262 detects the strike and drives its `GATE` output
+ high.
+
+2. **Trigger, twice.** That single edge goes to the OPS243's `HOST_INT` pin
+ (hardware, ~10 µs) *and* to the Pi's BCM17. There is no software in the OPS
+ path — that is what makes the capture window reliable.
+
+3. **OPS dump.** The radar freezes its rolling buffer and returns 4,096 I and
+ 4,096 Q samples — 40,556 bytes on the wire.
+
+4. **IWR dump.** The Pi asks the IWR6843 firmware to finish its current frame
+ and dump the rolling frame ring.
+
+5. **Speed extraction.** `RollingBufferProcessor` runs a 128-sample FFT
+ zero-padded to 4,096 across the capture, building a timeline. Overlapping
+ regions separate club, impact, and ball; the club region is the pre-impact
+ window, the ball region is post-impact.
+
+6. **Angle extraction.** LCMF-v1 processes the IWR6843 radar cube for vertical
+ launch angle, horizontal launch direction, and club path from pre-impact
+ frames.
+
+7. **Correlation.** The two captures are matched on the OPS impact timestamp.
+
+8. **Carry.** The [ballistic simulator](ballistics.md) integrates the
+ trajectory.
+
+9. **Emit.** The Flask server sends a `shot` WebSocket event to the UI, forwards
+ to any connected [simulator](../using/simulator/index.md), and appends to the
+ [session log](../reference/session-log.md).
+
+## Why the trigger is hardware
+
+A software trigger would have to poll or interrupt, then command the radar to
+freeze — tens of milliseconds at best. At 150 mph a golf ball travels about
+2.7 metres in 40 ms, which is well outside the useful capture window.
+
+Wiring `GATE` directly to `HOST_INT` removes software from the decision. The
+cost is the [one-time flash-persist step](../setup/rolling-buffer.md): the
+OPS243 firmware changes `HOST_INT` behaviour when rolling-buffer mode is entered
+at runtime, so the mode has to already be in flash at power-on.
+
+## Timing constraints
+
+| Quantity | Value |
+| --- | --- |
+| Sample rate | 30,000 samples/s |
+| Buffer | 4,096 I + 4,096 Q samples (~136 ms) |
+| Dump size | 40,556 bytes |
+| Dump time at 230,400 baud | ~1.8 s |
+| Dump time at 19,200 baud (factory default) | ~21 s — every capture truncated |
+
+The baud rate is why the [UART migration](../build/ops243-uart.md) negotiates up
+to 230,400 rather than accepting the factory default.
+
+## Related
+
+- [Rolling buffer & spin detection](rolling-buffer.md)
+- [Session log reference](../reference/session-log.md)
+- [Constants](../reference/constants.md)
diff --git a/docs/how-it-works/positioning.md b/docs/how-it-works/positioning.md
new file mode 100644
index 000000000..00a28f9bb
--- /dev/null
+++ b/docs/how-it-works/positioning.md
@@ -0,0 +1,84 @@
+---
+icon: lucide/map-pin
+---
+
+# Radar Positioning
+
+The two radars have different placement requirements. They are not
+interchangeable and they do not share a mount.
+
+## OPS243-A
+
+Place it **3–5 feet behind the tee**, pointing down the target line.
+
+```
+ Ball flight direction
+ ======================>
+
+ [Tee] ←--- 3-5 ft ---→ [OPS243-A]
+```
+
+Doppler measures the velocity component **along the beam**. Pointing down the
+target line means the ball's flight is nearly parallel to the beam, so the
+cosine error is small. Off-axis placement under-reads speed by $\cos\theta$.
+
+The tolerance here is forgiving — a few degrees of misalignment costs a fraction
+of a percent. That is why the OPS243 has no calibration step while the IWR6843
+has several.
+
+## IWR6843
+
+**Do not assume it shares the OPS243 position.** It has stricter mounting and
+measurement requirements, and its geometry values are runtime inputs rather than
+approximations.
+
+You must measure and supply:
+
+| Value | Flag |
+| --- | --- |
+| Antenna-centre to tee slant range | `--iwr6843-tee-m` |
+| Antenna-centre to net range | `--iwr6843-net-m` |
+| Ball-centre height above the mat | `--iwr6843-ball-height-m` |
+| Mount tilt | from the calibration JSON, or `--iwr6843-tilt-deg` |
+| Antenna-centre height | from the calibration JSON, or `--iwr6843-radar-height-m` |
+
+Full procedure: **[mounting, aiming, and measuring](../iwr6843/mounting.md)**.
+
+!!! warning "Wrong geometry fails silently"
+
+ A bad tee distance or tilt does not produce an error — it produces a
+ plausible but wrong launch angle. Measure rather than estimate, and re-measure
+ after moving or bumping the unit.
+
+ Fitting the [LIS3DH inclinometer](../build/inclinometer.md) makes tilt
+ self-correcting, which is the main reason to bother with it.
+
+## Target line reference
+
+Club path and launch direction are reported relative to a target line, which the
+system cannot infer. Two flags set the reference:
+
+- `--iwr6843-azimuth-offset-deg` — azimuth of the radar boresight relative to
+ the target line. Positive means boresight points right of the target line.
+ With `0`, club path is reported relative to boresight rather than the target.
+- `--iwr6843-horizontal-phase-reference-rad` — the static target-line phase from
+ horizontal aim calibration.
+
+See [horizontal launch and club path](../iwr6843/club-path.md#set-the-target-line-reference).
+
+## Indoor considerations
+
+Indoors, the floor produces a strong ground-bounce return that arrives just
+after the direct path. The launch-angle estimator models this multipath
+explicitly rather than trying to filter it out — which is why the net distance
+matters as an input, not just as a safety consideration.
+
+Nets beyond the roughly 11-foot FSK range wrap need de-aliasing; far-flight
+frames are unwrapped and kept rather than dropped. Nets at or inside the wrap
+are unaffected.
+
+## Related
+
+- [Mounting and geometry](../iwr6843/mounting.md)
+- [Parts list](../get-started/parts.md) — mounting hardware
+- [Enclosure & case](../build/enclosure.md) — the printed housing
diff --git a/docs/rolling_buffer_spin_detection.md b/docs/how-it-works/rolling-buffer.md
similarity index 75%
rename from docs/rolling_buffer_spin_detection.md
rename to docs/how-it-works/rolling-buffer.md
index 00e1840ab..1b45e10ec 100644
--- a/docs/rolling_buffer_spin_detection.md
+++ b/docs/how-it-works/rolling-buffer.md
@@ -20,16 +20,11 @@ scripts/start-kiosk.sh
## One-time radar setup
The OPS243-A firmware changes the `HOST_INT` behavior when rolling-buffer mode
-is entered at runtime. Save the mode to flash once, then power-cycle the radar:
-
-```bash
-uv run python scripts/hardware-test/test_rolling_buffer_persist.py --setup
-# Disconnect power from the radar for at least three seconds, then reconnect it.
-uv run python scripts/hardware-test/test_rolling_buffer_persist.py --test
-```
+is entered at runtime, so the mode has to be saved to flash once and the radar
+power-cycled — see **[Rolling buffer setup](../setup/rolling-buffer.md)**.
The runtime then starts in the persisted `GC` rolling-buffer mode without
-re-entering it. See [Sound Trigger Wiring](sound-trigger-wiring.md) for the
+re-entering it. See [Sound trigger wiring](../build/sound-trigger.md) for the
recommended direct hardware trigger.
## Current defaults
@@ -67,12 +62,12 @@ capture geometry can produce plausible but incorrect candidates, and current
blind validation is not accurate enough for production carry calculations.
For offline comparison and estimator work, use
-[Spin Replay and Diagnostics](spin-dechirp-replay.md). Raw captures are stored
+[Spin Replay and Diagnostics](../development/spin-replay.md). Raw captures are stored
in the session JSONL logs described in the
-[observability guide](observability.md#session-log-format).
+[observability guide](../using/observability.md#session-log-format).
## Related guides
-- [Raspberry Pi Setup](raspberry-pi-setup.md)
-- [Sound Trigger Wiring](sound-trigger-wiring.md)
-- [Spin Replay and Diagnostics](spin-dechirp-replay.md)
+- [Raspberry Pi Setup](../setup/raspberry-pi.md)
+- [Sound Trigger Wiring](../build/sound-trigger.md)
+- [Spin Replay and Diagnostics](../development/spin-replay.md)
diff --git a/docs/index.md b/docs/index.md
new file mode 100644
index 000000000..c96c9a270
--- /dev/null
+++ b/docs/index.md
@@ -0,0 +1,96 @@
+---
+icon: lucide/radar
+---
+
+# OpenFlight
+
+**A golf launch monitor you build yourself.** An OPS243-A Doppler radar measures
+ball and club speed; a TI IWR6843 mmWave radar measures launch angle and club
+path; a Raspberry Pi ties them together and streams shots to your simulator.
+
+Everything here is open source, and every part is off the shelf.
+
+---
+
+## What it measures
+
+| Metric | Source | Notes |
+| --- | --- | --- |
+| Ball speed | OPS243-A | Rolling-buffer I/Q, FFT mode extraction |
+| Club speed | OPS243-A | Pre-impact window of the same capture |
+| Launch angle | IWR6843 | LCMF-v1 over the raw radar cube |
+| Launch direction | IWR6843 | Horizontal plane |
+| Club path | IWR6843 | Pre-impact frames |
+| Spin rate | OPS243-A | **Experimental** — not used for carry by default |
+| Carry | Computed | RK4 trajectory with drag and Magnus |
+
+## Start here
+
+
+
+- :material-cart-outline: **[Parts list](get-started/parts.md)**
+
+ What to buy, with purchase links and a cost summary.
+
+- :material-cog-outline: **[Raspberry Pi setup](setup/raspberry-pi.md)**
+
+ Install, configure, and auto-start the software.
+
+- :material-flash-outline: **[Sound trigger wiring](build/sound-trigger.md)**
+
+ Wire the SEN-14262 to the OPS243-A. Do this first.
+
+- :material-angle-acute: **[IWR6843 operator guide](iwr6843/index.md)**
+
+ Wire, flash, mount, aim, and calibrate the angle radar.
+
+
+
+## Build order
+
+The hardware has real prerequisites — doing these out of order means redoing
+work.
+
+1. **[Parts list](get-started/parts.md)** — order everything before starting.
+2. **[Sound trigger wiring](build/sound-trigger.md)** — solder R17, then wire
+ `GATE` → `HOST_INT`. The rolling-buffer capture depends on this.
+3. **[Raspberry Pi setup](setup/raspberry-pi.md)** — through the one-time
+ rolling-buffer flash-persist step. Confirm you can capture with the OPS243
+ alone before adding anything else.
+4. **[Move the OPS243 to the Pi GPIO UART](build/ops243-uart.md)** —
+ required before the IWR6843, which needs the USB bus.
+5. **[IWR6843 operator guide](iwr6843/index.md)** — wire, flash, mount, aim,
+ and measure the geometry.
+6. Optional: **[inclinometer](build/inclinometer.md)**,
+ **[battery](using/battery.md)**, **[simulator connectors](using/simulator/index.md)**,
+ **[cloud sync](using/cloud-sync.md)**.
+
+## Once it's running
+
+- **[Simulator connectors](using/simulator/index.md)** — stream shots to
+ [GSPro](using/simulator/gspro.md), [OpenGolfSim](using/simulator/opengolfsim.md), and others.
+- **[Swing speed training](using/swing-speed.md)** — club-only mode for air
+ swings and speed sticks. No ball strike, no sound trigger.
+- **[Cloud sync](using/cloud-sync.md)** — push filtered sessions to FlightWeb.
+- **[Observability](using/observability.md)** — ship session logs to Grafana Cloud.
+
+## Understanding the numbers
+
+- **[Rolling buffer and spin detection](how-it-works/rolling-buffer.md)** —
+ how a capture becomes a ball speed, and why spin is still experimental.
+- **[Dechirped-sideband spin replay](development/spin-replay.md)** — the next-gen
+ spin estimator test bench.
+- **[TrackMan test process](development/trackman-testing.md)** — how accuracy claims
+ here were validated.
+
+!!! warning "The K-LD7 angle radars are deprecated"
+
+ The supported angle radar is the **TI IWR6843**. Don't buy K-LD7s for a new
+ build — their software support is retained for existing builds only. See
+ [Legacy (K-LD7)](legacy/index.md).
+
+!!! info "The IWR6843 needs custom firmware"
+
+ The stock TI demo doesn't expose the raw radar cube OpenFlight needs. A
+ validated prebuilt image ships in `firmware/releases/`, so flashing it does
+ not require the TI toolchain.
diff --git a/docs/iwr6843/README.md b/docs/iwr6843/README.md
deleted file mode 100644
index c60a07d31..000000000
--- a/docs/iwr6843/README.md
+++ /dev/null
@@ -1,841 +0,0 @@
-# IWR6843 Operator Guide
-
-This guide covers the supported OpenFlight setup for the TI IWR6843LEVM. It
-starts with an OpenFlight-ready Raspberry Pi and an unconfigured radar, then
-walks through wiring, firmware flashing, mounting, measurement, startup,
-verification, calibration, and offline replay.
-
-The production system uses two radars:
-
-| Device | Responsibility |
-|---|---|
-| OPS243 | Sound-triggered shot detection, ball speed, and club speed |
-| IWR6843 | Short-window radar capture, vertical launch angle, and experimental horizontal direction |
-
-The sound detector sends the same impact edge to both systems. The OPS243
-freezes its rolling buffer directly. The Raspberry Pi receives that edge on
-BCM17 and immediately asks the IWR6843 firmware to finish and dump its rolling
-frame ring.
-
-For firmware development, architecture, and build instructions, see
-[`firmware/README.md`](../../firmware/README.md).
-For a plain-language explanation and the July 2026 TrackMan baseline, see the
-[IWR6843 launch-angle field report](../iwr6843_field_report_2026-07.html).
-
-## Current Configuration
-
-Flash one configurable firmware image, then select one of two runtime profiles:
-
-| Component | Current file or value |
-|---|---|
-| Firmware | `firmware/releases/l3_dump_configurable_capture_20260818.bin` |
-| Wide/default config | `config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg` |
-| Dense/advanced config | `config/iwr6843_l3dump_dense_36f2ms_53bin_iq8.cfg` |
-| Reference array calibration | `config/iwr6843_calibration_reference.json` |
-| Firmware size | 346,820 bytes |
-| Firmware SHA-256 | `823ddd18a231d0004020de6262160d6863384cccac6674bae6f7d0fcea58f955` |
-| Transmitters / receivers | 3 TX / 4 RX |
-| Loops | 12 per frame |
-| Movie duration | 72 ms |
-
-### Choose A Profile
-
-| Profile | Wide/default | Dense/advanced |
-|---|---:|---:|
-| Frames and spacing | 24 at 3 ms | 36 at 2 ms |
-| Saved window | 53 bins | 53 bins |
-| Storage | IQ16 | Fixed-scale IQ8 |
-| Complete dump | 732,812 bytes | 549,764 bytes |
-| Choose it for | Ball flight and setup tolerance | Dense impact sampling |
-
-Start with **wide/default**. Its wider range window is more tolerant of tee
-placement, ball speed, and setup geometry, while IQ16 retains full signal
-fidelity. Its live inclinometer-adjusted LCMF output measured 0.86 degree MAE
-across all 59 matched 9-iron and 7-iron shots in an August 9 TrackMan session,
-with 0.70 degree P50 and 1.75 degree P90 absolute error. Select
-**dense/advanced** when temporal density around impact is the priority. It now
-preserves the same 53-bin range span while using fixed-scale IQ8 and EDMA
-packing to fit 36 frames in L3. The 2 ms IQ8 transport has passed hardware
-cadence testing with a 0.0089% HWA miss rate and no EDMA errors, but the 53-bin
-dense profile still needs source-of-truth TrackMan MAE validation; its
-horizontal and club metrics remain experimental.
-
-Changing profiles does not require reflashing. It changes only the config
-passed to `--iwr6843-config`. Both profiles use the same host-side mount-tilt
-path, including live inclinometer correction when `--inclinometer` is enabled.
-They also use the measured positive TDM sign for normal TX order. Automatic
-sign selection is reserved for offline diagnostics because multipath can select
-the mirrored sign and collapse the vertical two8 channel.
-
-On the Pi, verify the checked-in image with:
-
-```bash
-sha256sum firmware/releases/l3_dump_configurable_capture_20260818.bin
-```
-
-## Before You Start
-
-You need:
-
-- A Raspberry Pi running OpenFlight.
-- A TI IWR6843LEVM and a data-capable USB cable.
-- An OPS243 radar connected through either the Pi GPIO UART or a separately
- powered USB hub.
-- A configured SparkFun SEN-14262 sound detector or the equivalent supported
- trigger. Complete the [sound-trigger wiring guide](../sound-trigger-wiring.md)
- first.
-- A stable Pi power supply and stable power for every USB-connected radar.
-- Access to the IWR6843 boot-mode switch and RESET button.
-- Measurements for radar-to-ball distance, radar-to-net distance, radar height,
- ball height, and radar tilt.
-
-Run all commands from the OpenFlight repository root unless a section says
-otherwise.
-
-## Connect The Hardware
-
-Power the system off before changing GPIO wiring.
-
-### Power And Data Layout
-
-The supported connection depends on the OPS243-A variant. Do not power both
-radars from an unpowered, bus-powered USB hub.
-
-#### Option A: OPS Through The Pi GPIO UART (Non-WiFi OPS Only)
-
-The validated layout keeps the TI board on USB and connects the OPS243 to the
-Pi UART header for power and data.
-
-If the OPS243 is currently on USB, migrate and validate it on its own before
-adding the TI board — see
-[Moving the OPS243 from USB to the Pi GPIO UART](../ops243-uart-migration.md).
-Doing both at once makes any failure ambiguous.
-
-> [!WARNING]
-> Do not use this option with a WiFi-equipped OPS243-A. The onboard WiFi module
-> already drives the radar processor's UART receive line, so J3 pin 6 cannot
-> accept API commands from the Pi. J3 pin 7 can expose transmit data, but
-> receive-only UART is not sufficient for OpenFlight because the server must
-> configure and rearm the OPS after every capture. Use Option B instead.
-
-| Connection | Wiring | Purpose |
-|---|---|---|
-| IWR6843 | USB to Pi or stable powered hub | Power, CLI commands, and binary L3 dump transfer |
-| OPS power | Pi 5V physical pin 2 or 4 to OPS J3 pin 9 (`5V`) | Powers the OPS without sharing the TI USB path |
-| OPS ground | Pi GND to OPS J3 pin 10 (`GND`) | Establishes the shared electrical reference |
-| OPS data to Pi | OPS J3 pin 7 (`TxD`) to Pi GPIO15 / physical pin 10 (`RXD0`) | OPS transmits readings into Pi RX |
-| Pi commands to OPS | Pi GPIO14 / physical pin 8 (`TXD0`) to OPS J3 pin 6 (`RxD`) | Pi transmits commands into OPS RX |
-| Sound trigger | Detector `GATE` to OPS J3 pin 3 (`HOST_INT`) and Pi BCM17 / physical pin 11 | Freezes OPS and notifies the Pi of the same impact |
-| Trigger power | Pi 3.3V and GND to detector `VCC` and `GND` | Keeps the trigger at Pi-safe logic levels |
-
-#### Option B: OPS Through USB
-
-The OPS243 can remain connected over USB, but the hub must have its own external
-power input and must be powered separately instead of drawing all radar power
-from the Pi. One option is the
-[Acer four-port powered USB hub](https://www.amazon.com/dp/B0CN3F9Y1Z).
-This is the recommended connection for a WiFi-equipped OPS243-A.
-
-With this layout, connect both radar USB cables to the externally powered hub.
-Do not also connect the OPS 5V, RX, or TX pins to the Pi GPIO header. The shared
-sound-trigger GATE connection to OPS `HOST_INT` and Pi BCM17 is still required.
-
-#### Pi Header Reference
-
-| Physical pin | BCM name | Use |
-|---|---|---|
-| Pin 2 or 4 | 5V | OPS power |
-| Pin 6, 9, 14, 20, 25, 30, 34, or 39 | GND | Shared ground |
-| Pin 8 | GPIO14 / TXD0 | Pi TX to OPS RX |
-| Pin 10 | GPIO15 / RXD0 | Pi RX from OPS TX |
-| Pin 11 | GPIO17 | Sound-trigger GATE input |
-
-#### OPS243-A J3 Header Reference
-
-Use the 10-pin header labeled `J3` on the OPS243-A. Confirm the pin-1 marker or
-board silkscreen before connecting wires; do not infer pin numbering from which
-side of the board is closest.
-
-| J3 pin | OPS signal | Connect to |
-|---|---|---|
-| Pin 3 | `HOST_INT` / rolling-buffer trigger | Sound detector `GATE` and Pi BCM17 / physical pin 11 |
-| Pin 6 | `RxD` (input to non-WiFi OPS only) | Pi GPIO14 / `TXD0` / physical pin 8 |
-| Pin 7 | `TxD` (output from OPS) | Pi GPIO15 / `RXD0` / physical pin 10 |
-| Pin 9 | `5V` | Pi 5V physical pin 2 or 4 |
-| Pin 10 | `GND` | Any Pi GND pin used by the shared ground |
-
-UART transmit and receive are intentionally crossed: the OPS `TxD` output goes
-to the Pi `RXD0` input, and the Pi `TXD0` output goes to the OPS `RxD` input.
-This bidirectional mapping applies only when the OPS does not contain the WiFi
-module described above.
-The pin assignments come from the
-[OPS243 datasheet](https://omnipresense.com/wp-content/uploads/2019/03/OPS-DS-003-0.1_OPS243.pdf);
-the use of J3 pin 3 as a trigger is defined by
-[AN-027 OPS243-A Rolling Buffer](https://omnipresense.com/wp-content/uploads/2025/06/AN-027-A_Rolling-Buffer.pdf).
-
-The GATE signal is a three-way electrical connection. Splice three jumper wires
-together at one junction: one from the sound detector `GATE`, one to the OPS243
-J3 pin 3 (`HOST_INT`), and one to Pi BCM17 / physical pin 11. Use a soldered and
-insulated splice or a secure three-way connector; do not rely on loosely
-twisted wires.
-
-```text
-Sound detector GATE
- +-- OPS243 J3 pin 3 (HOST_INT)
- +-- Pi BCM17 / physical pin 11
-
-Sound detector VCC
- +-- Pi 3.3V
-
-Sound detector GND
- +-- Pi GND, shared with OPS J3 pin 10 (GND)
-```
-
-Important electrical rules:
-
-- With Option A, cross serial TX and RX. OPS `TX` connects to Pi `RX`; OPS `RX`
- connects to Pi `TX`.
-- Never connect 5V to a Pi GPIO signal pin.
-- Confirm that the OPS serial interface uses 3.3V TTL signaling. Do not connect
- RS-232 voltage levels to Pi GPIO.
-- Power the sound detector from Pi 3.3V so its GATE output remains Pi-safe.
-- Keep Pi, OPS, and trigger grounds connected.
-- Treat intermittent USB disconnects and simultaneous radar failures as power
- problems first.
-
-## Prepare The Raspberry Pi UART
-
-Complete this section only when using Option A. If the OPS243 is connected over
-USB through an externally powered hub, skip this section and continue to
-**Prepare Serial And GPIO Permissions**.
-
-Enable the Pi hardware UART and remove the Linux login console from it:
-
-```bash
-sudo raspi-config
-```
-
-Choose `Interface Options` -> `Serial Port`, then answer:
-
-1. Disable the login shell over serial.
-2. Enable the serial-port hardware.
-3. Reboot the Pi.
-
-On Raspberry Pi 5, physical pins 8 and 10 use UART0 at `/dev/ttyAMA0`. Verify
-that device:
-
-```bash
-ls -l /dev/ttyAMA0
-```
-
-Do not use `/dev/serial0` for this wiring on Raspberry Pi 5: it normally points
-to `/dev/ttyAMA10`, which is the separate debug-header UART rather than the
-40-pin GPIO header.
-
-If `/dev/ttyAMA0` is missing, confirm that UART0 is enabled:
-
-```bash
-grep -E "enable_uart|dtparam=uart0" /boot/firmware/config.txt /boot/config.txt 2>/dev/null
-```
-
-Note for Raspberry Pi 5 & Newer OS Versions:
-Newer hardware and Debian Bookworm use dtparam=uart0=on instead of the legacy enable_uart=1 setting to enable the UART0 hardware block.
-
-At least one boot configuration should contain:
-
-```text
-enable_uart=1
-```
-Or
-
-```text
-dtparam=uart0=on
-```
-
-## Prepare Serial And GPIO Permissions
-
-Both connection options require serial-device access for the radars and GPIO
-access for the shared trigger. Confirm that the OpenFlight user belongs to
-`dialout` and `gpio`:
-
-```bash
-groups
-```
-
-If either group is missing:
-
-```bash
-sudo usermod -a -G dialout,gpio "$USER"
-sudo reboot
-```
-
-## Prepare The Sound-Trigger GPIO
-
-No `raspi-config` interface setting is required for the sound-trigger input.
-OpenFlight uses BCM17 by default, which is physical pin 11 on the Pi header. The
-checked-in Python dependencies include `gpiozero` and the Pi `lgpio` backend.
-
-The launch command does not need `--iwr6843-trigger-pin` when the GATE splice is
-wired to BCM17. If startup reports `GPIO busy`, another OpenFlight, calibration,
-or shot-test process still owns the pin; stop that process before retrying.
-
-OpenFlight selects the `lgpio` pin factory itself and names the gpiochip
-explicitly, because gpiozero 2.0.1.post2 cannot auto-detect one on a Pi 5 — its
-`pins/lgpio.py` calls `os.path.exists` without importing `os`, so every backend
-falls back and startup dies with `BadPinFactory: Unable to load any default pin
-factory!`. Setting `GPIOZERO_PIN_FACTORY=lgpio` does not help; it forces the
-same broken call. If a kernel update moves the 40-pin header to a different
-chip, override it:
-
-```bash
-OPENFLIGHT_GPIO_CHIP=0 scripts/start-kiosk.sh ...
-```
-
-## Identify The TI Serial Port
-
-Connect the IWR6843LEVM to the Pi over USB and inspect the serial devices:
-
-```bash
-ls -l /dev/serial/by-id/
-ls -l /dev/ttyUSB*
-```
-
-The board's CP2105 exposes two UART interfaces. OpenFlight firmware uses the
-**Enhanced/UARTA** interface for both CLI commands and binary dumps. This is
-normally USB interface `00` and `/dev/ttyUSB0`. Do not select the Standard/data
-interface, which is normally interface `01` and `/dev/ttyUSB1`.
-
-The exact `/dev/ttyUSB*` number can change after reconnecting hardware. Prefer
-the corresponding `/dev/serial/by-id/...-if00-port0` path when available. The
-examples below use `/dev/ttyUSB0`; replace it if your Enhanced interface has a
-different path.
-
-When using Option B, also identify the OPS243 USB serial path under
-`/dev/serial/by-id/`. Use that stable path for `--radar-port` instead of relying
-on a changing `/dev/ttyACM*` number.
-
-## Flash The IWR6843 Firmware
-
-The recommended method flashes directly from the Pi using the checked-in ROM
-bootloader client. TI UniFlash is a fallback, not a requirement.
-
-### 1. Stop Serial Users
-
-Stop OpenFlight, calibration, `shot_test.py`, and any other process using the TI
-port. Use `Ctrl+C` in the terminal that launched OpenFlight, then check for
-remaining owners:
-
-```bash
-pgrep -af 'openflight|calibrate|shot_test'
-sudo fuser -v /dev/ttyUSB0
-```
-
-Do not flash until the Enhanced UART is free.
-
-### 2. Enter Flash Mode
-
-Set the IWR6843LEVM boot switches to:
-
-```text
-S1.1 ON, S1.2 OFF, S1.3 ON, S1.4 ON, S1.5 OFF
-```
-
-Do not press RESET yet. The flashing script opens the UART first and tells you
-when to reset the board.
-
-### 3. Probe The ROM Bootloader
-
-Run the non-destructive probe:
-
-```bash
-uv run python firmware/flash_iwr6843.py \
- --probe \
- --port /dev/ttyUSB0
-```
-
-Follow the prompts exactly:
-
-1. Type `READY` so the script opens the UART and settles its control lines.
-2. Press and release RESET only when the script asks.
-3. Wait one second.
-4. Type `PROBE`.
-
-The expected result is:
-
-```text
-IWR6843 ROM bootloader handshake: PASS
-```
-
-If you are flashing immediately, leave the board in flash mode. The flash
-command will ask for another RESET after it opens the UART.
-
-### 4. Flash The Configurable Image
-
-```bash
-uv run python firmware/flash_iwr6843.py \
- firmware/releases/l3_dump_configurable_capture_20260818.bin \
- --port /dev/ttyUSB0
-```
-
-Follow the `READY` -> RESET -> one-second wait -> `FLASH` sequence shown by the
-script. The default operation erases the existing serial flash, writes the
-image in acknowledged chunks, closes it, and asks the ROM bootloader to verify
-the result.
-
-A successful flash ends with:
-
-```text
-Erasing existing SFLASH...
-Opening firmware image...
-Writing firmware...
-Writing: 100% (.../... bytes)
-Closing and verifying firmware...
-
-Flash verified by the IWR6843 ROM bootloader.
-```
-
-Do not reset, disconnect, or remove power while the erase or write is active.
-An erase can take longer than ten seconds.
-
-### 5. Return To Functional Mode
-
-Set the switches to:
-
-```text
-S1.1 OFF, S1.2 OFF, S1.3 ON, S1.4 ON, S1.5 OFF
-```
-
-Press and release RESET. The custom firmware is now ready for OpenFlight.
-
-## Mount And Aim The Radar
-
-Mount the radar behind the ball with the antenna face pointing down the target
-line. The validated enclosure rotates the board so its vertical virtual array
-is physically vertical, with the TX antennas above the RX antennas.
-
-The IWR6843 can start at approximately the same upward tilt as the OPS243,
-typically around 10 degrees, when both antenna faces are mounted parallel. Treat
-10 degrees as a mounting starting point, not a universal calibration value.
-Measure the IWR6843 antenna-face tilt independently and enter that measured
-value in OpenFlight.
-
-Start with the IWR6843 antenna center approximately 6 inches (`0.1524 m`) above
-the floor surface under the radar. Measure vertically from that surface to the
-center of the antenna array, not to the enclosure bottom or mounting feet. This
-is the validated starting height, not a substitute for entering the actual
-measured height.
-
-Mounting requirements:
-
-- Aim the antenna face toward the intended start line, not diagonally across
- the hitting area.
-- Keep the antenna face unobstructed.
-- Keep the board rotation consistent with the validated enclosure.
-- Use a rigid mount. Small mechanical shifts can appear as angle bias.
-- Measure tilt against the antenna face or a known-parallel enclosure surface.
-- Re-measure after moving to a different floor, mat, bay, or stand.
-
-A corner reflector placed on the target line can verify horizontal aim. It is
-useful for alignment and static health checks, but it does not replace moving
-golf-ball validation.
-
-## Measure The Geometry
-
-OpenFlight needs these physical inputs:
-
-| Argument | Measurement |
-|---|---|
-| `--iwr6843-tee-m` | Slant distance from antenna center to ball center |
-| `--iwr6843-net-m` | Distance from antenna center to net or screen |
-| `--iwr6843-tilt-deg` | Antenna-face mount tilt from an inclinometer |
-| `--iwr6843-radar-height-m` | Antenna-center height above the floor reference |
-| `--iwr6843-ball-height-m` | Ball-center height above the same floor reference |
-
-Measurement guidance:
-
-- Measure from the antenna center, not the enclosure edge or mounting feet.
-- Use radar-to-ball slant range for `tee-m`.
-- Keep `net-m` honest so late net reflections can be excluded.
-- Measure radar and ball height from the same floor reference. If the radar and
- ball sit on different surfaces, extend a common level reference between them.
-- Add an elevated mat to ball height. A 1 inch mat adds approximately `0.0254 m`.
-- A typical iron ball center is around `0.040 m`; a driver tee is higher.
-- Do not reuse a tilt value after moving the rig unless you verify it again.
-
-The checked-in reference calibration contains the array correction used by the
-validated radar. It provides a known starting point, not a universal factory
-calibration. The operator calibration session below checks geometry and
-estimator consistency; it does not regenerate the file's per-element complex
-array correction. A different radar board or antenna orientation may require a
-new corner-reflector array calibration before source-of-truth accuracy can be
-expected.
-
-## Start OpenFlight
-
-For the first run, use `--debug`. This retains each TI dump for inspection and
-offline replay. This example uses the Option A GPIO UART path. Replace the
-example geometry with your measurements:
-
-```bash
-scripts/start-kiosk.sh --debug \
- --radar-port /dev/ttyAMA0 \
- --iwr6843 \
- --iwr6843-port /dev/ttyUSB0 \
- --iwr6843-config config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg \
- --iwr6843-tee-m 1.575 \
- --iwr6843-net-m 4.6 \
- --iwr6843-tilt-deg 10.4 \
- --iwr6843-radar-height-m 0.1524 \
- --iwr6843-ball-height-m 0.040 \
- --session-location home
-```
-
-For Option B, replace `/dev/ttyAMA0` after `--radar-port` with the OPS USB serial
-device, preferably its stable `/dev/serial/by-id/...` path.
-
-The example uses the recommended wide profile. To test dense impact sampling,
-change only the config argument to:
-
-```text
---iwr6843-config config/iwr6843_l3dump_dense_36f2ms_53bin_iq8.cfg
-```
-
-Passing `--iwr6843-config` explicitly keeps the selected profile visible in the
-launch command and session log.
-
-The OPS port can also be supplied as `--ops-port /dev/ttyAMA0`. `--port` means
-the web-server port, so do not use it for the OPS serial device.
-
-The TI port can be omitted after the custom firmware is running; OpenFlight
-probes available USB serial ports for the expected CLI. Supplying
-`--iwr6843-port` is clearer during initial setup and avoids ambiguity when
-multiple USB serial devices are connected.
-
-Once the setup is stable, remove `--debug` for normal operation. The server
-still processes TI captures in memory, but it does not write a dump for
-every shot. Session JSONL entries only contain a dump path when debug capture
-is enabled.
-
-## Verify The First Capture
-
-Healthy startup includes messages similar to:
-
-```text
-[IWR6843] Configured on BCM17 using /dev/ttyUSB0 (..., waiting for OPS)
-[IWR6843] Armed on BCM17
-[SERVER] IWR6843 initialized (... firmware boundary freeze)
-```
-
-Use one clap to verify the shared trigger and dump transfer. A clap is not a
-golf ball, so `rejected_by_ball_tracker` is expected. The important result is a
-complete capture:
-
-```text
-[IWR6843] Trigger #1: dumping firmware-frozen L3 ring
-[IWR6843] Capture #1 complete: 732812 bytes
-```
-
-Firmware health should show an active sensor, increasing frame/wrap counters,
-and no RF faults:
-
-```text
-active=1 ... rf_faults=0
-```
-
-Then hit a ball. A trusted result logs `Angle source: radar`. A shot may still
-appear in the UI with an estimated angle when the TI capture completes but the
-ball track does not meet the acceptance gates.
-
-In debug mode, verify that the session contains an `iwr6843_capture` entry, a
-`temperature_report` object, and a `capture_path` pointing to the saved
-`.l3dump` file.
-
-## Horizontal Ball Launch
-
-TX2 measures horizontal ball launch from phase relative to the TX1/TX3 phase
-center. The IWR6843LEVM TX2 baseline is one-half wavelength; OpenFlight applies
-that board geometry and uses the OPS ball speed when removing TDM motion phase.
-This keeps multipath in the TI range slope from rotating the horizontal result.
-
-Each physical board and enclosure can retain a static target-line phase. Pass
-the phase measured by horizontal aim calibration when starting OpenFlight:
-
-```bash
-scripts/start-kiosk.sh --iwr6843 \
- --iwr6843-horizontal-phase-reference-rad -0.33434
-```
-
-`-0.33434 rad` is the measured reference for the OpenFlight prototype setup,
-not a universal default. It was calibrated from 18 wide-IQ16 shots against
-TrackMan using the eight-frame horizontal estimator, then frozen before a
-separate 41-shot holdout. The holdout produced 0.948 degrees MAE at full
-coverage and 0.797 degrees MAE on the 37 shots that passed the 0.90 phase-
-coherence gate.
-
-Recalibrate this value after changing the radar board, enclosure, antenna
-orientation, or target-line alignment. To evaluate a TrackMan-aligned session
-and calculate a setup-specific phase reference, run:
-
-```bash
-uv run python scripts/analysis/evaluate_iwr_horizontal_models.py \
- path/to/trackman_openflight_aligned.csv
-```
-
-The value is subtracted in phase space before conversion to degrees. Omitting
-it reports horizontal launch relative to the board's uncalibrated RF phase
-zero, which can create a consistent left/right bias even when the enclosure is
-aimed correctly. This is separate from `--iwr6843-azimuth-offset-deg`, which is
-the geometric target-line correction used by the experimental club-path fit.
-
-## Club Path
-
-Club path is the horizontal direction the club head travels through impact,
-measured from the six pre-impact frames the firmware retains. Positive is
-in-to-out, negative out-to-in, following TrackMan. **Right-handed only.**
-
-Measured on a first-principles fixture across ±12°, absolute error grows with
-angle rather than staying flat: 0.034° at 4°, 0.092° at 8°, and 0.297° at
-12°, worst at the largest angle measured (0.303° at −12°). The ±12° pair
-(0.297° vs. 0.303°) differs by only about 0.006°, so the residual is
-symmetric rather than sign-dependent. It is not, however, a fixed percentage
-of the angle — 0.034° at 4° is under 1%, while 0.297° at 12° is about
-2.5% — so quote it as ±0.3° working precision across the measured ±12° range
-rather than a percentage. Two explanations for the growth are on the table
-and neither is settled: it may be discretisation (range-bin quantisation,
-the tracker's 1.2-bin inlier tolerance, phase inversion at small angles), or
-it may be structural — the club's range comes from a *linear* fit against a
-genuinely nonlinear true range, which fits the observed growth pattern
-better. A future improvement may reduce the residual either way. It ships
-experimental.
-
-Do not extrapolate ±0.3° beyond ±12°: an out-of-band check during
-development read about 0.57° at ±16°, roughly double the 12° figure and
-consistent with the growth trend. That figure is indicative only — it is not
-covered by a test — but a strong out-to-in path can exceed 12°, and an
-operator trusting ±0.3° there would be relying on a number nobody measured.
-
-### Set the target-line reference
-
-Club path is relative to the target line, and the array calibration cannot
-supply where the radar's boresight points. Measure the angle between boresight
-and your target line, then pass it:
-
-```bash
-scripts/start-kiosk.sh --iwr6843 --iwr6843-azimuth-offset-deg 1.5
-```
-
-Positive means boresight points right of the target line. The value is added
-to the measured path. Left at 0, club path is reported relative to boresight
-rather than the target line, which is fine for separation testing but not for
-absolute numbers.
-
-This flag is not optional trim. The estimator fits `x(t)` and `y(t)` in
-Cartesian coordinates and reports `path = atan2(v_y, v_x)`, so absolute
-azimuth enters *additively* rather than cancelling out: a constant
-per-element phase error from the shipped array calibration (measured on a
-different board than the one it ships on) shifts the reported path by a
-constant. `--iwr6843-azimuth-offset-deg` is what absorbs that shift, not a
-convenience for aiming.
-
-### Why this can't be the ball
-
-Two independent checks keep a mis-tracked ball from being reported as club
-path, and both were verified. First, the pre-impact time window excludes the
-ball's flight — the club and ball share overlapping radial speed ranges, so
-without the window the tracker would lock onto the ball. Second, even inside
-that window the OPS club-speed cross-check rejects a ball track: a ball's
-radial speed against club-speed bounds produces a projection factor of about
-1.26, which falls outside the accepted 0.4–0.95 window.
-
-### Validate with a separation test
-
-Record three sessions of about 10 shots each, then compare them:
-
-```bash
-uv run python scripts/iwr6843/club_path_report.py \
- --out-to-in session_A.jsonl \
- --square session_B.jsonl \
- --in-to-out session_C.jsonl
-```
-
-It passes when the group means are ordered correctly and each adjacent gap
-exceeds the within-group spread. Exit status is non-zero when they do not
-separate.
-
-A session can fail for three distinct reasons, and the report names which one
-fired because each needs a different fix from the operator:
-
-- **Groups out of order** — the swings themselves may not have been distinct
- enough; swing more deliberately between blocks.
-- **A group has fewer than 5 accepted shots** — below that, a group's own
- stdev is too noisy to support a separation claim; hit more shots.
-- **A gap falls below the 0.3° measurement floor** — grounded in the 0.303°
- max residual measured on the fixture above; a gap this small is below
- instrument precision at any sample size, so more shots won't fix it —
- re-check the geometry (mount, tee range, azimuth offset) instead.
-
-The 0.3° floor sits at the measured residual, not comfortably above it —
-for the intended use (in-to-out vs. out-to-in, which typically differ by
-8–15°) that's irrelevant, but an operator chasing a marginal ~0.4° group
-separation should know the floor isn't a wide safety margin.
-
-### Reading `club_path.status` in the session log
-
-| Status | What it means |
-|---|---|
-| `accepted` | Club path measured; `confidence` reflects fit quality |
-| `rejected_requires_three_tx` | Capture wasn't a 3-TX dump; TX2 phase needs all three transmitters |
-| `rejected_no_pre_impact_frames` | The capture contains no pre-impact window |
-| `rejected_no_club_track` | No mover near the tee passed the club gates — check the tee range and that the radar sees the hitting area |
-| `rejected_club_speed_mismatch` | The tracked mover's speed does not match the OPS club speed, so it is probably hands or body, not the club |
-| `rejected_insufficient_snapshots` | Too few usable pre-impact samples |
-| `rejected_azimuth_fit` | The azimuth track was too noisy to fit |
-| `rejected_phase_wrap` | Phase swing far larger than physically possible; a broken track |
-| `..._tdm_sign_fallback` suffix | The ball estimate was rejected, so the TDM sign came from the configured policy rather than measurement |
-
-## Run A Calibration Session
-
-Stop the kiosk before running calibration so it releases BCM17 and both serial
-ports. The calibration command uses the same OPS trigger, OPS processing, TI
-capture, and LCMF estimator as the server.
-
-```bash
-uv run \
- --with gpiozero \
- --with lgpio \
- python scripts/iwr6843/calibrate.py \
- --shots 20 \
- --club 7i \
- --ops-port /dev/ttyAMA0 \
- --iwr6843-port /dev/ttyUSB0 \
- --cfg config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg \
- --tee-m 1.575 \
- --net-m 4.6 \
- --tilt-deg 10.4 \
- --radar-height-m 0.1524 \
- --ball-height-m 0.040
-```
-
-Add `--debug` when you want raw dumps for offline replay. Calibration output is
-written under `~/openflight_sessions/iwr6843_calibration//` unless
-`--outdir` is supplied.
-
-The terminal reports:
-
-- OPS ball and club speed.
-- TI launch angle or rejection reason.
-- Track RMS and inlier count.
-- Estimated ball-start range.
-- A radar-consistency tilt candidate.
-
-The tilt candidate is not source-of-truth launch-angle calibration, and on
-current data it cannot recommend a tilt at all. It reports the tilt where the
-LCMF component models agree best — it minimises `component_std_deg` — but that
-score is monotonic in tilt across the swept window, so the minimum lands on
-whichever end of the window the trend runs toward rather than on the real
-mount angle. On the 2026-07-25 range session, with the mount physically set
-and measured at 5.5°, a ±3° sweep recommended 2.5° on shots 1 and 4 and 8.5°
-on shots 2 and 3: both edges of the window, never the truth, disagreeing with
-itself by the full 6° width across four shots of one session.
-
-**Set tilt by physical measurement.** Treat a candidate sitting on the edge of
-the sweep as no answer rather than a recommendation — which, on every shot
-checked so far, is what it returns.
-
-Use `--no-tilt-sweep` when you only need capture diagnostics and faster shot
-turnaround.
-
-## Launch-Angle Estimator Limitations
-
-The vertical launch angle comes from two channel models, `two8` and
-`four4_path_tdm`. When they agree within 8° the estimate is their mean; when
-they disagree the estimator selects the one whose objective has the sharper
-minimum (its curvature) and reports the result at reduced confidence. Three
-limits of that scheme are known and unresolved. They are deferred pending a
-session paired with a reference instrument, which this repo does not have.
-
-**The curvature criterion is not scale-normalised.** `four4_path_tdm`'s
-objective spans a range 2–4× larger than `two8`'s, so most of the reported
-"3.7–10.7× sharper" margin is model scale, not evidence quality — on one shot
-the true margin is 1.14×. It is validated as a *degeneracy detector*: it
-reliably catches the collapsed `two8` channel seen on the 2026-07-25 mount. It
-is not validated as an accuracy ranker, and it is one-sided — a collapsed
-`four4_path_tdm` would likely win the comparison anyway.
-
-**Selecting is worse than averaging when both channels are healthy but
-disagree.** Monte Carlo at 6° of noise: 4.26° RMS averaging against 5.79°
-selecting, and 7.93° on the disagreeing subset alone. Selection pays off only
-when one channel is genuinely broken, which is the case the 8° gate was cut
-for.
-
-**The 8° gate rests on one session.** Its justification is a gap between one
-shot whose channels agreed to 4.59° and six that spread 15.9–20.2°, all from a
-single session, club, geometry and mount tilt. Nothing establishes that the
-gap sits in the same place at another tilt, with another club, or on another
-board.
-
-## Replay Saved Captures
-
-Offline replay reruns LCMF without hardware and is the safest way to compare
-estimator changes against identical radar data.
-
-Replay a debug session JSONL:
-
-```bash
-uv run python scripts/iwr6843/replay.py \
- --input ~/openflight_sessions/session_YYYYMMDD_HHMMSS_home.jsonl \
- --cfg config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg \
- --tee-m 1.575 \
- --net-m 4.6 \
- --tilt-deg 10.4 \
- --radar-height-m 0.1524 \
- --ball-height-m 0.040 \
- --club 9i \
- --out replay.csv
-```
-
-Replay one dump:
-
-```bash
-uv run python scripts/iwr6843/replay.py \
- --input ~/openflight_sessions/iwr6843/shot.l3dump \
- --ball-speed-mph 105.9 \
- --cfg config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg \
- --club 9i \
- --tee-m 1.575 \
- --net-m 4.6 \
- --tilt-deg 10.4 \
- --radar-height-m 0.1524 \
- --ball-height-m 0.040
-```
-
-A session JSONL can only replay TI captures saved while `--debug` was active. A
-standalone dump needs `--ball-speed-mph` because OPS speed is not stored in the
-TI binary dump.
-
-## Troubleshooting
-
-Start with the symptom shown in the terminal. Avoid changing estimator settings
-until power, ports, firmware, config, and geometry are verified.
-
-| Symptom | Likely cause | Action |
-|---|---|---|
-| `no IWR6843 CLI found` | Wrong USB interface, board still in flash mode, missing functional RESET, stale serial owner, or unstable power | Set functional switches, press RESET, verify interface `00`, stop serial processes, then retry with explicit `--iwr6843-port` |
-| `GPIO busy` | Another kiosk, calibration, or shot-test process owns BCM17 | Stop the old process; use `pgrep -af` and `sudo fuser -v /dev/gpiochip*` to locate it |
-| `captureFormat` or `phaseCaptureCfg` rejected | Older firmware is flashed | Flash the configurable release, reset in functional mode, and retry either supported profile |
-| Bootloader probe returns no response | Wrong CP2105 port or RESET occurred before the script opened UART | Use Enhanced/UARTA, rerun the probe, type `READY`, then RESET only when prompted |
-| Flash fails after `Erasing existing SFLASH` | Transfer was interrupted after the old image was erased | Leave the board in flash mode and rerun the complete flash; the ROM bootloader is still available |
-| Server starts only after unplugging TI | Board was not reset cleanly, a prior dump was still streaming, or USB/power wedged | Stop the old process, press RESET in functional mode, wait for the port, then reconnect USB only if needed |
-| `short IWR6843 dump` | Interrupted UART transfer, process shutdown during dump, or wrong firmware format | Let the active dump finish, restart, and confirm 732,812 bytes for wide or 549,764 bytes for dense |
-| Dense `stats` accumulates `hwa_missed` or `iq8_overrun` | The 2 ms processing budget is not being sustained | Stop using the capture for measurements, reset, and return to the wide profile while investigating |
-| Clap produces `rejected_by_ball_tracker` | A clap has no moving ball range track | Expected for trigger testing; confirm the dump completed, then hit a ball |
-| `rejected_track_quality` | A ball-like track was found but it was too thin, noisy, inconsistent, or net-contaminated | Verify geometry and aim; inspect the debug dump before relaxing acceptance gates |
-| `rejected_missing_tdm_sign` | The ball track was usable, but the TX timing evidence did not resolve a trustworthy correction sign | Keep the estimated UI angle, inspect the debug dump, and verify signal quality before changing gates |
-| All UI angles are estimated | TI captures are absent, unmatched to OPS, or rejected by LCMF | Run with `--debug`, inspect `iwr6843_capture`, and check the reported rejection reason |
-| OPS reports no data | Wrong OPS port, missing power, WiFi OPS connected through unsupported receive-only J3 UART, or non-WiFi UART wired incorrectly | For a WiFi OPS use the externally powered USB hub; otherwise verify `/dev/ttyAMA0`, power, shared ground, and crossed TX/RX |
-| Either radar disconnects when both run | Insufficient USB power or unstable cabling | Use OPS GPIO power or a hub with its own external supply; verify the hub supply is connected and sized for both radars |
-| Angles are consistently shifted | Tilt, antenna orientation, radar height, ball height, or tee distance is wrong | Re-measure all geometry from the antenna center and common floor reference |
-| Dump file is missing from the session | OpenFlight was not launched with `--debug` | Re-run in debug mode when raw capture retention is required |
-
-If the firmware itself must be rebuilt rather than flashed from the checked-in
-binary, continue with [`firmware/README.md`](../../firmware/README.md).
diff --git a/docs/iwr6843/calibration.md b/docs/iwr6843/calibration.md
new file mode 100644
index 000000000..8c419f5c6
--- /dev/null
+++ b/docs/iwr6843/calibration.md
@@ -0,0 +1,130 @@
+---
+icon: lucide/sliders-horizontal
+---
+
+# Calibration and Replay
+
+Tune the estimator against known shots, understand where it stops being
+trustworthy, and replay saved captures offline to test changes without
+swinging a club.
+
+## Run A Calibration Session
+
+Stop the kiosk before running calibration so it releases BCM17 and both serial
+ports. The calibration command uses the same OPS trigger, OPS processing, TI
+capture, and LCMF estimator as the server.
+
+```bash
+uv run \
+ --with gpiozero \
+ --with lgpio \
+ python scripts/iwr6843/calibrate.py \
+ --shots 20 \
+ --club 7i \
+ --ops-port /dev/ttyAMA0 \
+ --iwr6843-port /dev/ttyUSB0 \
+ --cfg config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg \
+ --tee-m 1.575 \
+ --net-m 4.6 \
+ --tilt-deg 10.4 \
+ --radar-height-m 0.1524 \
+ --ball-height-m 0.040
+```
+
+Add `--debug` when you want raw dumps for offline replay. Calibration output is
+written under `~/openflight_sessions/iwr6843_calibration//` unless
+`--outdir` is supplied.
+
+The terminal reports:
+
+- OPS ball and club speed.
+- TI launch angle or rejection reason.
+- Track RMS and inlier count.
+- Estimated ball-start range.
+- A radar-consistency tilt candidate.
+
+The tilt candidate is not source-of-truth launch-angle calibration, and on
+current data it cannot recommend a tilt at all. It reports the tilt where the
+LCMF component models agree best — it minimises `component_std_deg` — but that
+score is monotonic in tilt across the swept window, so the minimum lands on
+whichever end of the window the trend runs toward rather than on the real
+mount angle. On the 2026-07-25 range session, with the mount physically set
+and measured at 5.5°, a ±3° sweep recommended 2.5° on shots 1 and 4 and 8.5°
+on shots 2 and 3: both edges of the window, never the truth, disagreeing with
+itself by the full 6° width across four shots of one session.
+
+**Set tilt by physical measurement.** Treat a candidate sitting on the edge of
+the sweep as no answer rather than a recommendation — which, on every shot
+checked so far, is what it returns.
+
+Use `--no-tilt-sweep` when you only need capture diagnostics and faster shot
+turnaround.
+
+## Launch-Angle Estimator Limitations
+
+The vertical launch angle comes from two channel models, `two8` and
+`four4_path_tdm`. When they agree within 8° the estimate is their mean; when
+they disagree the estimator selects the one whose objective has the sharper
+minimum (its curvature) and reports the result at reduced confidence. Three
+limits of that scheme are known and unresolved. They are deferred pending a
+session paired with a reference instrument, which this repo does not have.
+
+**The curvature criterion is not scale-normalised.** `four4_path_tdm`'s
+objective spans a range 2–4× larger than `two8`'s, so most of the reported
+"3.7–10.7× sharper" margin is model scale, not evidence quality — on one shot
+the true margin is 1.14×. It is validated as a *degeneracy detector*: it
+reliably catches the collapsed `two8` channel seen on the 2026-07-25 mount. It
+is not validated as an accuracy ranker, and it is one-sided — a collapsed
+`four4_path_tdm` would likely win the comparison anyway.
+
+**Selecting is worse than averaging when both channels are healthy but
+disagree.** Monte Carlo at 6° of noise: 4.26° RMS averaging against 5.79°
+selecting, and 7.93° on the disagreeing subset alone. Selection pays off only
+when one channel is genuinely broken, which is the case the 8° gate was cut
+for.
+
+**The 8° gate rests on one session.** Its justification is a gap between one
+shot whose channels agreed to 4.59° and six that spread 15.9–20.2°, all from a
+single session, club, geometry and mount tilt. Nothing establishes that the
+gap sits in the same place at another tilt, with another club, or on another
+board.
+
+## Replay Saved Captures
+
+Offline replay reruns LCMF without hardware and is the safest way to compare
+estimator changes against identical radar data.
+
+Replay a debug session JSONL:
+
+```bash
+uv run python scripts/iwr6843/replay.py \
+ --input ~/openflight_sessions/session_YYYYMMDD_HHMMSS_home.jsonl \
+ --cfg config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg \
+ --tee-m 1.575 \
+ --net-m 4.6 \
+ --tilt-deg 10.4 \
+ --radar-height-m 0.1524 \
+ --ball-height-m 0.040 \
+ --club 9i \
+ --out replay.csv
+```
+
+Replay one dump:
+
+```bash
+uv run python scripts/iwr6843/replay.py \
+ --input ~/openflight_sessions/iwr6843/shot.l3dump \
+ --ball-speed-mph 105.9 \
+ --cfg config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg \
+ --club 9i \
+ --tee-m 1.575 \
+ --net-m 4.6 \
+ --tilt-deg 10.4 \
+ --radar-height-m 0.1524 \
+ --ball-height-m 0.040
+```
+
+A session JSONL can only replay TI captures saved while `--debug` was active. A
+standalone dump needs `--ball-speed-mph` because OPS speed is not stored in the
+TI binary dump.
+
diff --git a/docs/iwr6843/club-path.md b/docs/iwr6843/club-path.md
new file mode 100644
index 000000000..57a0a16b9
--- /dev/null
+++ b/docs/iwr6843/club-path.md
@@ -0,0 +1,153 @@
+---
+icon: lucide/move-diagonal
+---
+
+# Horizontal Launch and Club Path
+
+The horizontal plane: where the ball started and where the club was going. Both
+come from the same pre-impact frames, and both depend on a correctly set
+target-line reference.
+
+## Horizontal Ball Launch
+
+TX2 measures horizontal ball launch from phase relative to the TX1/TX3 phase
+center. The IWR6843LEVM TX2 baseline is one-half wavelength; OpenFlight applies
+that board geometry and uses the OPS ball speed when removing TDM motion phase.
+This keeps multipath in the TI range slope from rotating the horizontal result.
+
+Each physical board and enclosure can retain a static target-line phase. Pass
+the phase measured by horizontal aim calibration when starting OpenFlight:
+
+```bash
+scripts/start-kiosk.sh --iwr6843 \
+ --iwr6843-horizontal-phase-reference-rad -0.33434
+```
+
+`-0.33434 rad` is the measured reference for the OpenFlight prototype setup,
+not a universal default. It was calibrated from 18 wide-IQ16 shots against
+TrackMan using the eight-frame horizontal estimator, then frozen before a
+separate 41-shot holdout. The holdout produced 0.948 degrees MAE at full
+coverage and 0.797 degrees MAE on the 37 shots that passed the 0.90 phase-
+coherence gate.
+
+Recalibrate this value after changing the radar board, enclosure, antenna
+orientation, or target-line alignment. To evaluate a TrackMan-aligned session
+and calculate a setup-specific phase reference, run:
+
+```bash
+uv run python scripts/analysis/evaluate_iwr_horizontal_models.py \
+ path/to/trackman_openflight_aligned.csv
+```
+
+The value is subtracted in phase space before conversion to degrees. Omitting
+it reports horizontal launch relative to the board's uncalibrated RF phase
+zero, which can create a consistent left/right bias even when the enclosure is
+aimed correctly. This is separate from `--iwr6843-azimuth-offset-deg`, which is
+the geometric target-line correction used by the experimental club-path fit.
+
+## Club Path
+
+Club path is the horizontal direction the club head travels through impact,
+measured from the six pre-impact frames the firmware retains. Positive is
+in-to-out, negative out-to-in, following TrackMan. **Right-handed only.**
+
+Measured on a first-principles fixture across ±12°, absolute error grows with
+angle rather than staying flat: 0.034° at 4°, 0.092° at 8°, and 0.297° at
+12°, worst at the largest angle measured (0.303° at −12°). The ±12° pair
+(0.297° vs. 0.303°) differs by only about 0.006°, so the residual is
+symmetric rather than sign-dependent. It is not, however, a fixed percentage
+of the angle — 0.034° at 4° is under 1%, while 0.297° at 12° is about
+2.5% — so quote it as ±0.3° working precision across the measured ±12° range
+rather than a percentage. Two explanations for the growth are on the table
+and neither is settled: it may be discretisation (range-bin quantisation,
+the tracker's 1.2-bin inlier tolerance, phase inversion at small angles), or
+it may be structural — the club's range comes from a *linear* fit against a
+genuinely nonlinear true range, which fits the observed growth pattern
+better. A future improvement may reduce the residual either way. It ships
+experimental.
+
+Do not extrapolate ±0.3° beyond ±12°: an out-of-band check during
+development read about 0.57° at ±16°, roughly double the 12° figure and
+consistent with the growth trend. That figure is indicative only — it is not
+covered by a test — but a strong out-to-in path can exceed 12°, and an
+operator trusting ±0.3° there would be relying on a number nobody measured.
+
+### Set the target-line reference
+
+Club path is relative to the target line, and the array calibration cannot
+supply where the radar's boresight points. Measure the angle between boresight
+and your target line, then pass it:
+
+```bash
+scripts/start-kiosk.sh --iwr6843 --iwr6843-azimuth-offset-deg 1.5
+```
+
+Positive means boresight points right of the target line. The value is added
+to the measured path. Left at 0, club path is reported relative to boresight
+rather than the target line, which is fine for separation testing but not for
+absolute numbers.
+
+This flag is not optional trim. The estimator fits `x(t)` and `y(t)` in
+Cartesian coordinates and reports `path = atan2(v_y, v_x)`, so absolute
+azimuth enters *additively* rather than cancelling out: a constant
+per-element phase error from the shipped array calibration (measured on a
+different board than the one it ships on) shifts the reported path by a
+constant. `--iwr6843-azimuth-offset-deg` is what absorbs that shift, not a
+convenience for aiming.
+
+### Why this can't be the ball
+
+Two independent checks keep a mis-tracked ball from being reported as club
+path, and both were verified. First, the pre-impact time window excludes the
+ball's flight — the club and ball share overlapping radial speed ranges, so
+without the window the tracker would lock onto the ball. Second, even inside
+that window the OPS club-speed cross-check rejects a ball track: a ball's
+radial speed against club-speed bounds produces a projection factor of about
+1.26, which falls outside the accepted 0.4–0.95 window.
+
+### Validate with a separation test
+
+Record three sessions of about 10 shots each, then compare them:
+
+```bash
+uv run python scripts/iwr6843/club_path_report.py \
+ --out-to-in session_A.jsonl \
+ --square session_B.jsonl \
+ --in-to-out session_C.jsonl
+```
+
+It passes when the group means are ordered correctly and each adjacent gap
+exceeds the within-group spread. Exit status is non-zero when they do not
+separate.
+
+A session can fail for three distinct reasons, and the report names which one
+fired because each needs a different fix from the operator:
+
+- **Groups out of order** — the swings themselves may not have been distinct
+ enough; swing more deliberately between blocks.
+- **A group has fewer than 5 accepted shots** — below that, a group's own
+ stdev is too noisy to support a separation claim; hit more shots.
+- **A gap falls below the 0.3° measurement floor** — grounded in the 0.303°
+ max residual measured on the fixture above; a gap this small is below
+ instrument precision at any sample size, so more shots won't fix it —
+ re-check the geometry (mount, tee range, azimuth offset) instead.
+
+The 0.3° floor sits at the measured residual, not comfortably above it —
+for the intended use (in-to-out vs. out-to-in, which typically differ by
+8–15°) that's irrelevant, but an operator chasing a marginal ~0.4° group
+separation should know the floor isn't a wide safety margin.
+
+### Reading `club_path.status` in the session log
+
+| Status | What it means |
+|---|---|
+| `accepted` | Club path measured; `confidence` reflects fit quality |
+| `rejected_requires_three_tx` | Capture wasn't a 3-TX dump; TX2 phase needs all three transmitters |
+| `rejected_no_pre_impact_frames` | The capture contains no pre-impact window |
+| `rejected_no_club_track` | No mover near the tee passed the club gates — check the tee range and that the radar sees the hitting area |
+| `rejected_club_speed_mismatch` | The tracked mover's speed does not match the OPS club speed, so it is probably hands or body, not the club |
+| `rejected_insufficient_snapshots` | Too few usable pre-impact samples |
+| `rejected_azimuth_fit` | The azimuth track was too noisy to fit |
+| `rejected_phase_wrap` | Phase swing far larger than physically possible; a broken track |
+| `..._tdm_sign_fallback` suffix | The ball estimate was rejected, so the TDM sign came from the configured policy rather than measurement |
+
diff --git a/docs/iwr6843/flashing.md b/docs/iwr6843/flashing.md
new file mode 100644
index 000000000..b0d9c59d8
--- /dev/null
+++ b/docs/iwr6843/flashing.md
@@ -0,0 +1,105 @@
+---
+icon: lucide/cpu
+---
+
+# Flash the Firmware
+
+The stock TI demo does not expose the raw radar cube OpenFlight needs. A
+validated prebuilt image ships in `firmware/releases/`, so flashing does not
+require the TI toolchain — see the
+[firmware developer guide](../development/firmware.md) only if you want to
+build from source.
+
+## Flash The IWR6843 Firmware
+
+The recommended method flashes directly from the Pi using the checked-in ROM
+bootloader client. TI UniFlash is a fallback, not a requirement.
+
+### 1. Stop Serial Users
+
+Stop OpenFlight, calibration, `shot_test.py`, and any other process using the TI
+port. Use `Ctrl+C` in the terminal that launched OpenFlight, then check for
+remaining owners:
+
+```bash
+pgrep -af 'openflight|calibrate|shot_test'
+sudo fuser -v /dev/ttyUSB0
+```
+
+Do not flash until the Enhanced UART is free.
+
+### 2. Enter Flash Mode
+
+Set the IWR6843LEVM boot switches to:
+
+```text
+S1.1 ON, S1.2 OFF, S1.3 ON, S1.4 ON, S1.5 OFF
+```
+
+Do not press RESET yet. The flashing script opens the UART first and tells you
+when to reset the board.
+
+### 3. Probe The ROM Bootloader
+
+Run the non-destructive probe:
+
+```bash
+uv run python firmware/flash_iwr6843.py \
+ --probe \
+ --port /dev/ttyUSB0
+```
+
+Follow the prompts exactly:
+
+1. Type `READY` so the script opens the UART and settles its control lines.
+2. Press and release RESET only when the script asks.
+3. Wait one second.
+4. Type `PROBE`.
+
+The expected result is:
+
+```text
+IWR6843 ROM bootloader handshake: PASS
+```
+
+If you are flashing immediately, leave the board in flash mode. The flash
+command will ask for another RESET after it opens the UART.
+
+### 4. Flash The Configurable Image
+
+```bash
+uv run python firmware/flash_iwr6843.py \
+ firmware/releases/l3_dump_configurable_capture_20260818.bin \
+ --port /dev/ttyUSB0
+```
+
+Follow the `READY` -> RESET -> one-second wait -> `FLASH` sequence shown by the
+script. The default operation erases the existing serial flash, writes the
+image in acknowledged chunks, closes it, and asks the ROM bootloader to verify
+the result.
+
+A successful flash ends with:
+
+```text
+Erasing existing SFLASH...
+Opening firmware image...
+Writing firmware...
+Writing: 100% (.../... bytes)
+Closing and verifying firmware...
+
+Flash verified by the IWR6843 ROM bootloader.
+```
+
+Do not reset, disconnect, or remove power while the erase or write is active.
+An erase can take longer than ten seconds.
+
+### 5. Return To Functional Mode
+
+Set the switches to:
+
+```text
+S1.1 OFF, S1.2 OFF, S1.3 ON, S1.4 ON, S1.5 OFF
+```
+
+Press and release RESET. The custom firmware is now ready for OpenFlight.
+
diff --git a/docs/iwr6843/index.md b/docs/iwr6843/index.md
new file mode 100644
index 000000000..3dcf07af4
--- /dev/null
+++ b/docs/iwr6843/index.md
@@ -0,0 +1,139 @@
+# IWR6843 Operator Guide
+
+This guide covers the supported OpenFlight setup for the TI IWR6843LEVM. It
+starts with an OpenFlight-ready Raspberry Pi and an unconfigured radar, then
+walks through wiring, firmware flashing, mounting, measurement, startup,
+verification, calibration, and offline replay.
+
+The production system uses two radars:
+
+| Device | Responsibility |
+|---|---|
+| OPS243 | Sound-triggered shot detection, ball speed, and club speed |
+| IWR6843 | Short-window radar capture, vertical launch angle, and experimental horizontal direction |
+
+The sound detector sends the same impact edge to both systems. The OPS243
+freezes its rolling buffer directly. The Raspberry Pi receives that edge on
+BCM17 and immediately asks the IWR6843 firmware to finish and dump its rolling
+frame ring.
+
+For firmware development, architecture, and build instructions, see
+[firmware developer guide](../development/firmware.md).
+For a plain-language explanation and the July 2026 TrackMan baseline, see the
+[IWR6843 launch-angle field report](../how-it-works/launch-angle.md).
+
+## Current Configuration
+
+Flash one configurable firmware image, then select one of two runtime profiles:
+
+| Component | Current file or value |
+|---|---|
+| Firmware | `firmware/releases/l3_dump_configurable_capture_20260818.bin` |
+| Wide/default config | `config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg` |
+| Dense/advanced config | `config/iwr6843_l3dump_dense_36f2ms_53bin_iq8.cfg` |
+| Reference array calibration | `config/iwr6843_calibration_reference.json` |
+| Firmware size | 346,820 bytes |
+| Firmware SHA-256 | `823ddd18a231d0004020de6262160d6863384cccac6674bae6f7d0fcea58f955` |
+| Transmitters / receivers | 3 TX / 4 RX |
+| Loops | 12 per frame |
+| Movie duration | 72 ms |
+
+### Choose A Profile
+
+| Profile | Wide/default | Dense/advanced |
+|---|---:|---:|
+| Frames and spacing | 24 at 3 ms | 36 at 2 ms |
+| Saved window | 53 bins | 53 bins |
+| Storage | IQ16 | Fixed-scale IQ8 |
+| Complete dump | 732,812 bytes | 549,764 bytes |
+| Choose it for | Ball flight and setup tolerance | Dense impact sampling |
+
+Start with **wide/default**. Its wider range window is more tolerant of tee
+placement, ball speed, and setup geometry, while IQ16 retains full signal
+fidelity. Its live inclinometer-adjusted LCMF output measured 0.86 degree MAE
+across all 59 matched 9-iron and 7-iron shots in an August 9 TrackMan session,
+with 0.70 degree P50 and 1.75 degree P90 absolute error. Select
+**dense/advanced** when temporal density around impact is the priority. It now
+preserves the same 53-bin range span while using fixed-scale IQ8 and EDMA
+packing to fit 36 frames in L3. The 2 ms IQ8 transport has passed hardware
+cadence testing with a 0.0089% HWA miss rate and no EDMA errors, but the 53-bin
+dense profile still needs source-of-truth TrackMan MAE validation; its
+horizontal and club metrics remain experimental.
+
+Changing profiles does not require reflashing. It changes only the config
+passed to `--iwr6843-config`. Both profiles use the same host-side mount-tilt
+path, including live inclinometer correction when `--inclinometer` is enabled.
+They also use the measured positive TDM sign for normal TX order. Automatic
+sign selection is reserved for offline diagnostics because multipath can select
+the mirrored sign and collapse the vertical two8 channel.
+
+On the Pi, verify the checked-in image with:
+
+```bash
+sha256sum firmware/releases/l3_dump_configurable_capture_20260818.bin
+```
+
+## Before You Start
+
+You need:
+
+- A Raspberry Pi running OpenFlight.
+- A TI IWR6843LEVM and a data-capable USB cable.
+- An OPS243 radar connected through either the Pi GPIO UART or a separately
+ powered USB hub.
+- A configured SparkFun SEN-14262 sound detector or the equivalent supported
+ trigger. Complete the [sound-trigger wiring guide](../build/sound-trigger.md)
+ first.
+- A stable Pi power supply and stable power for every USB-connected radar.
+- Access to the IWR6843 boot-mode switch and RESET button.
+- Measurements for radar-to-ball distance, radar-to-net distance, radar height,
+ ball height, and radar tilt.
+
+Run all commands from the OpenFlight repository root unless a section says
+otherwise.
+
+
+## The setup path
+
+Work these in order — each depends on the one before it.
+
+
+
+- :material-numeric-1-circle-outline: **[Wiring](wiring.md)**
+
+ Power and data layout, Pi UART, serial and GPIO permissions, sound-trigger
+ line, and identifying the TI serial port.
+
+- :material-numeric-2-circle-outline: **[Flash the firmware](flashing.md)**
+
+ Boot mode, ROM bootloader, flashing the configurable image, and returning
+ to functional mode.
+
+- :material-numeric-3-circle-outline: **[Mount, aim, and measure](mounting.md)**
+
+ Physical placement and the geometry measurements the runtime needs.
+
+- :material-numeric-4-circle-outline: **[Start and verify](verify.md)**
+
+ Launch with your geometry and confirm the first capture is sane.
+
+- :material-numeric-5-circle-outline: **[Horizontal launch and club path](club-path.md)**
+
+ Target-line reference, separation test, and reading club path.
+
+- :material-numeric-6-circle-outline: **[Calibration and replay](calibration.md)**
+
+ Calibration sessions, estimator limits, and offline capture replay.
+
+- :material-wrench: **[Troubleshooting](troubleshooting.md)**
+
+ When the radar does not enumerate, dump, or report sane angles.
+
+
+
+## Related
+
+- [Low-confidence vertical recovery](low-confidence-recovery.md) — the
+ OPS-guided fallback policy and its validation.
+- [Firmware developer guide](../development/firmware.md) — build the image
+ from source.
diff --git a/docs/iwr6843/mounting.md b/docs/iwr6843/mounting.md
new file mode 100644
index 000000000..ce51b8c6b
--- /dev/null
+++ b/docs/iwr6843/mounting.md
@@ -0,0 +1,73 @@
+---
+icon: lucide/ruler
+---
+
+# Mount, Aim, and Measure
+
+Where the radar sits and how it is angled determines every launch angle it
+reports. Measure the geometry rather than estimating it — the numbers you
+record here go straight into the runtime.
+
+## Mount And Aim The Radar
+
+Mount the radar behind the ball with the antenna face pointing down the target
+line. The validated enclosure rotates the board so its vertical virtual array
+is physically vertical, with the TX antennas above the RX antennas.
+
+The IWR6843 can start at approximately the same upward tilt as the OPS243,
+typically around 10 degrees, when both antenna faces are mounted parallel. Treat
+10 degrees as a mounting starting point, not a universal calibration value.
+Measure the IWR6843 antenna-face tilt independently and enter that measured
+value in OpenFlight.
+
+Start with the IWR6843 antenna center approximately 6 inches (`0.1524 m`) above
+the floor surface under the radar. Measure vertically from that surface to the
+center of the antenna array, not to the enclosure bottom or mounting feet. This
+is the validated starting height, not a substitute for entering the actual
+measured height.
+
+Mounting requirements:
+
+- Aim the antenna face toward the intended start line, not diagonally across
+ the hitting area.
+- Keep the antenna face unobstructed.
+- Keep the board rotation consistent with the validated enclosure.
+- Use a rigid mount. Small mechanical shifts can appear as angle bias.
+- Measure tilt against the antenna face or a known-parallel enclosure surface.
+- Re-measure after moving to a different floor, mat, bay, or stand.
+
+A corner reflector placed on the target line can verify horizontal aim. It is
+useful for alignment and static health checks, but it does not replace moving
+golf-ball validation.
+
+## Measure The Geometry
+
+OpenFlight needs these physical inputs:
+
+| Argument | Measurement |
+|---|---|
+| `--iwr6843-tee-m` | Slant distance from antenna center to ball center |
+| `--iwr6843-net-m` | Distance from antenna center to net or screen |
+| `--iwr6843-tilt-deg` | Antenna-face mount tilt from an inclinometer |
+| `--iwr6843-radar-height-m` | Antenna-center height above the floor reference |
+| `--iwr6843-ball-height-m` | Ball-center height above the same floor reference |
+
+Measurement guidance:
+
+- Measure from the antenna center, not the enclosure edge or mounting feet.
+- Use radar-to-ball slant range for `tee-m`.
+- Keep `net-m` honest so late net reflections can be excluded.
+- Measure radar and ball height from the same floor reference. If the radar and
+ ball sit on different surfaces, extend a common level reference between them.
+- Add an elevated mat to ball height. A 1 inch mat adds approximately `0.0254 m`.
+- A typical iron ball center is around `0.040 m`; a driver tee is higher.
+- Do not reuse a tilt value after moving the rig unless you verify it again.
+
+The checked-in reference calibration contains the array correction used by the
+validated radar. It provides a known starting point, not a universal factory
+calibration. The operator calibration session below checks geometry and
+estimator consistency; it does not regenerate the file's per-element complex
+array correction. A different radar board or antenna orientation may require a
+new corner-reflector array calibration before source-of-truth accuracy can be
+expected.
+
diff --git a/docs/iwr6843/troubleshooting.md b/docs/iwr6843/troubleshooting.md
new file mode 100644
index 000000000..9854cdf48
--- /dev/null
+++ b/docs/iwr6843/troubleshooting.md
@@ -0,0 +1,30 @@
+---
+icon: lucide/wrench
+---
+
+# IWR6843 Troubleshooting
+
+Start with the symptom shown in the terminal. Avoid changing estimator settings
+until power, ports, firmware, config, and geometry are verified.
+
+| Symptom | Likely cause | Action |
+|---|---|---|
+| `no IWR6843 CLI found` | Wrong USB interface, board still in flash mode, missing functional RESET, stale serial owner, or unstable power | Set functional switches, press RESET, verify interface `00`, stop serial processes, then retry with explicit `--iwr6843-port` |
+| `GPIO busy` | Another kiosk, calibration, or shot-test process owns BCM17 | Stop the old process; use `pgrep -af` and `sudo fuser -v /dev/gpiochip*` to locate it |
+| `captureFormat` or `phaseCaptureCfg` rejected | Older firmware is flashed | Flash the configurable release, reset in functional mode, and retry either supported profile |
+| Bootloader probe returns no response | Wrong CP2105 port or RESET occurred before the script opened UART | Use Enhanced/UARTA, rerun the probe, type `READY`, then RESET only when prompted |
+| Flash fails after `Erasing existing SFLASH` | Transfer was interrupted after the old image was erased | Leave the board in flash mode and rerun the complete flash; the ROM bootloader is still available |
+| Server starts only after unplugging TI | Board was not reset cleanly, a prior dump was still streaming, or USB/power wedged | Stop the old process, press RESET in functional mode, wait for the port, then reconnect USB only if needed |
+| `short IWR6843 dump` | Interrupted UART transfer, process shutdown during dump, or wrong firmware format | Let the active dump finish, restart, and confirm 732,812 bytes for wide or 549,764 bytes for dense |
+| Dense `stats` accumulates `hwa_missed` or `iq8_overrun` | The 2 ms processing budget is not being sustained | Stop using the capture for measurements, reset, and return to the wide profile while investigating |
+| Clap produces `rejected_by_ball_tracker` | A clap has no moving ball range track | Expected for trigger testing; confirm the dump completed, then hit a ball |
+| `rejected_track_quality` | A ball-like track was found but it was too thin, noisy, inconsistent, or net-contaminated | Verify geometry and aim; inspect the debug dump before relaxing acceptance gates |
+| `rejected_missing_tdm_sign` | The ball track was usable, but the TX timing evidence did not resolve a trustworthy correction sign | Keep the estimated UI angle, inspect the debug dump, and verify signal quality before changing gates |
+| All UI angles are estimated | TI captures are absent, unmatched to OPS, or rejected by LCMF | Run with `--debug`, inspect `iwr6843_capture`, and check the reported rejection reason |
+| OPS reports no data | Wrong OPS port, missing power, WiFi OPS connected through unsupported receive-only J3 UART, or non-WiFi UART wired incorrectly | For a WiFi OPS use the externally powered USB hub; otherwise verify `/dev/ttyAMA0`, power, shared ground, and crossed TX/RX |
+| Either radar disconnects when both run | Insufficient USB power or unstable cabling | Use OPS GPIO power or a hub with its own external supply; verify the hub supply is connected and sized for both radars |
+| Angles are consistently shifted | Tilt, antenna orientation, radar height, ball height, or tee distance is wrong | Re-measure all geometry from the antenna center and common floor reference |
+| Dump file is missing from the session | OpenFlight was not launched with `--debug` | Re-run in debug mode when raw capture retention is required |
+
+If the firmware itself must be rebuilt rather than flashed from the checked-in
+binary, continue with the [firmware developer guide](../development/firmware.md).
diff --git a/docs/iwr6843/verify.md b/docs/iwr6843/verify.md
new file mode 100644
index 000000000..66db1509c
--- /dev/null
+++ b/docs/iwr6843/verify.md
@@ -0,0 +1,89 @@
+---
+icon: lucide/play
+---
+
+# Start and Verify
+
+Bring the system up with the geometry you measured, then confirm the first
+capture looks right before hitting a full session.
+
+## Start OpenFlight
+
+For the first run, use `--debug`. This retains each TI dump for inspection and
+offline replay. This example uses the Option A GPIO UART path. Replace the
+example geometry with your measurements:
+
+```bash
+scripts/start-kiosk.sh --debug \
+ --radar-port /dev/ttyAMA0 \
+ --iwr6843 \
+ --iwr6843-port /dev/ttyUSB0 \
+ --iwr6843-config config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg \
+ --iwr6843-tee-m 1.575 \
+ --iwr6843-net-m 4.6 \
+ --iwr6843-tilt-deg 10.4 \
+ --iwr6843-radar-height-m 0.1524 \
+ --iwr6843-ball-height-m 0.040 \
+ --session-location home
+```
+
+For Option B, replace `/dev/ttyAMA0` after `--radar-port` with the OPS USB serial
+device, preferably its stable `/dev/serial/by-id/...` path.
+
+The example uses the recommended wide profile. To test dense impact sampling,
+change only the config argument to:
+
+```text
+--iwr6843-config config/iwr6843_l3dump_dense_36f2ms_53bin_iq8.cfg
+```
+
+Passing `--iwr6843-config` explicitly keeps the selected profile visible in the
+launch command and session log.
+
+The OPS port can also be supplied as `--ops-port /dev/ttyAMA0`. `--port` means
+the web-server port, so do not use it for the OPS serial device.
+
+The TI port can be omitted after the custom firmware is running; OpenFlight
+probes available USB serial ports for the expected CLI. Supplying
+`--iwr6843-port` is clearer during initial setup and avoids ambiguity when
+multiple USB serial devices are connected.
+
+Once the setup is stable, remove `--debug` for normal operation. The server
+still processes TI captures in memory, but it does not write a dump for
+every shot. Session JSONL entries only contain a dump path when debug capture
+is enabled.
+
+## Verify The First Capture
+
+Healthy startup includes messages similar to:
+
+```text
+[IWR6843] Configured on BCM17 using /dev/ttyUSB0 (..., waiting for OPS)
+[IWR6843] Armed on BCM17
+[SERVER] IWR6843 initialized (... firmware boundary freeze)
+```
+
+Use one clap to verify the shared trigger and dump transfer. A clap is not a
+golf ball, so `rejected_by_ball_tracker` is expected. The important result is a
+complete capture:
+
+```text
+[IWR6843] Trigger #1: dumping firmware-frozen L3 ring
+[IWR6843] Capture #1 complete: 732812 bytes
+```
+
+Firmware health should show an active sensor, increasing frame/wrap counters,
+and no RF faults:
+
+```text
+active=1 ... rf_faults=0
+```
+
+Then hit a ball. A trusted result logs `Angle source: radar`. A shot may still
+appear in the UI with an estimated angle when the TI capture completes but the
+ball track does not meet the acceptance gates.
+
+In debug mode, verify that the session contains an `iwr6843_capture` entry, a
+`temperature_report` object, and a `capture_path` pointing to the saved
+`.l3dump` file.
+
diff --git a/docs/iwr6843/wiring.md b/docs/iwr6843/wiring.md
new file mode 100644
index 000000000..428331519
--- /dev/null
+++ b/docs/iwr6843/wiring.md
@@ -0,0 +1,237 @@
+---
+icon: lucide/cable
+---
+
+# Wiring
+
+Power and data for the IWR6843, the Raspberry Pi UART, serial and GPIO
+permissions, and the sound-trigger line.
+
+!!! warning "Do the OPS243 UART migration first"
+
+ The IWR6843 needs the USB bus, so the OPS243 has to move to the Pi GPIO
+ UART before you start. See
+ [Moving the OPS243 to the Pi GPIO UART](../build/ops243-uart.md).
+
+## Connect The Hardware
+
+Power the system off before changing GPIO wiring.
+
+### Power And Data Layout
+
+The supported connection depends on the OPS243-A variant. Do not power both
+radars from an unpowered, bus-powered USB hub.
+
+#### Option A: OPS Through The Pi GPIO UART (Non-WiFi OPS Only)
+
+The validated layout keeps the TI board on USB and connects the OPS243 to the
+Pi UART header for power and data.
+
+If the OPS243 is currently on USB, migrate and validate it on its own before
+adding the TI board — see
+[Moving the OPS243 from USB to the Pi GPIO UART](../build/ops243-uart.md).
+Doing both at once makes any failure ambiguous.
+
+> [!WARNING]
+> Do not use this option with a WiFi-equipped OPS243-A. The onboard WiFi module
+> already drives the radar processor's UART receive line, so J3 pin 6 cannot
+> accept API commands from the Pi. J3 pin 7 can expose transmit data, but
+> receive-only UART is not sufficient for OpenFlight because the server must
+> configure and rearm the OPS after every capture. Use Option B instead.
+
+| Connection | Wiring | Purpose |
+|---|---|---|
+| IWR6843 | USB to Pi or stable powered hub | Power, CLI commands, and binary L3 dump transfer |
+| OPS power | Pi 5V physical pin 2 or 4 to OPS J3 pin 9 (`5V`) | Powers the OPS without sharing the TI USB path |
+| OPS ground | Pi GND to OPS J3 pin 10 (`GND`) | Establishes the shared electrical reference |
+| OPS data to Pi | OPS J3 pin 7 (`TxD`) to Pi GPIO15 / physical pin 10 (`RXD0`) | OPS transmits readings into Pi RX |
+| Pi commands to OPS | Pi GPIO14 / physical pin 8 (`TXD0`) to OPS J3 pin 6 (`RxD`) | Pi transmits commands into OPS RX |
+| Sound trigger | Detector `GATE` to OPS J3 pin 3 (`HOST_INT`) and Pi BCM17 / physical pin 11 | Freezes OPS and notifies the Pi of the same impact |
+| Trigger power | Pi 3.3V and GND to detector `VCC` and `GND` | Keeps the trigger at Pi-safe logic levels |
+
+#### Option B: OPS Through USB
+
+The OPS243 can remain connected over USB, but the hub must have its own external
+power input and must be powered separately instead of drawing all radar power
+from the Pi. One option is the
+[Acer four-port powered USB hub](https://www.amazon.com/dp/B0CN3F9Y1Z).
+This is the recommended connection for a WiFi-equipped OPS243-A.
+
+With this layout, connect both radar USB cables to the externally powered hub.
+Do not also connect the OPS 5V, RX, or TX pins to the Pi GPIO header. The shared
+sound-trigger GATE connection to OPS `HOST_INT` and Pi BCM17 is still required.
+
+#### Pi Header Reference
+
+| Physical pin | BCM name | Use |
+|---|---|---|
+| Pin 2 or 4 | 5V | OPS power |
+| Pin 6, 9, 14, 20, 25, 30, 34, or 39 | GND | Shared ground |
+| Pin 8 | GPIO14 / TXD0 | Pi TX to OPS RX |
+| Pin 10 | GPIO15 / RXD0 | Pi RX from OPS TX |
+| Pin 11 | GPIO17 | Sound-trigger GATE input |
+
+#### OPS243-A J3 Header Reference
+
+Use the 10-pin header labeled `J3` on the OPS243-A. Confirm the pin-1 marker or
+board silkscreen before connecting wires; do not infer pin numbering from which
+side of the board is closest.
+
+| J3 pin | OPS signal | Connect to |
+|---|---|---|
+| Pin 3 | `HOST_INT` / rolling-buffer trigger | Sound detector `GATE` and Pi BCM17 / physical pin 11 |
+| Pin 6 | `RxD` (input to non-WiFi OPS only) | Pi GPIO14 / `TXD0` / physical pin 8 |
+| Pin 7 | `TxD` (output from OPS) | Pi GPIO15 / `RXD0` / physical pin 10 |
+| Pin 9 | `5V` | Pi 5V physical pin 2 or 4 |
+| Pin 10 | `GND` | Any Pi GND pin used by the shared ground |
+
+UART transmit and receive are intentionally crossed: the OPS `TxD` output goes
+to the Pi `RXD0` input, and the Pi `TXD0` output goes to the OPS `RxD` input.
+This bidirectional mapping applies only when the OPS does not contain the WiFi
+module described above.
+The pin assignments come from the
+[OPS243 datasheet](https://omnipresense.com/wp-content/uploads/2019/03/OPS-DS-003-0.1_OPS243.pdf);
+the use of J3 pin 3 as a trigger is defined by
+[AN-027 OPS243-A Rolling Buffer](https://omnipresense.com/wp-content/uploads/2025/06/AN-027-A_Rolling-Buffer.pdf).
+
+The GATE signal is a three-way electrical connection. Splice three jumper wires
+together at one junction: one from the sound detector `GATE`, one to the OPS243
+J3 pin 3 (`HOST_INT`), and one to Pi BCM17 / physical pin 11. Use a soldered and
+insulated splice or a secure three-way connector; do not rely on loosely
+twisted wires.
+
+```text
+Sound detector GATE
+ +-- OPS243 J3 pin 3 (HOST_INT)
+ +-- Pi BCM17 / physical pin 11
+
+Sound detector VCC
+ +-- Pi 3.3V
+
+Sound detector GND
+ +-- Pi GND, shared with OPS J3 pin 10 (GND)
+```
+
+Important electrical rules:
+
+- With Option A, cross serial TX and RX. OPS `TX` connects to Pi `RX`; OPS `RX`
+ connects to Pi `TX`.
+- Never connect 5V to a Pi GPIO signal pin.
+- Confirm that the OPS serial interface uses 3.3V TTL signaling. Do not connect
+ RS-232 voltage levels to Pi GPIO.
+- Power the sound detector from Pi 3.3V so its GATE output remains Pi-safe.
+- Keep Pi, OPS, and trigger grounds connected.
+- Treat intermittent USB disconnects and simultaneous radar failures as power
+ problems first.
+
+## Prepare The Raspberry Pi UART
+
+Complete this section only when using Option A. If the OPS243 is connected over
+USB through an externally powered hub, skip this section and continue to
+**Prepare Serial And GPIO Permissions**.
+
+Enable the Pi hardware UART and remove the Linux login console from it:
+
+```bash
+sudo raspi-config
+```
+
+Choose `Interface Options` -> `Serial Port`, then answer:
+
+1. Disable the login shell over serial.
+2. Enable the serial-port hardware.
+3. Reboot the Pi.
+
+On Raspberry Pi 5, physical pins 8 and 10 use UART0 at `/dev/ttyAMA0`. Verify
+that device:
+
+```bash
+ls -l /dev/ttyAMA0
+```
+
+Do not use `/dev/serial0` for this wiring on Raspberry Pi 5: it normally points
+to `/dev/ttyAMA10`, which is the separate debug-header UART rather than the
+40-pin GPIO header.
+
+If `/dev/ttyAMA0` is missing, confirm that UART0 is enabled:
+
+```bash
+grep -E "enable_uart|dtparam=uart0" /boot/firmware/config.txt /boot/config.txt 2>/dev/null
+```
+
+Note for Raspberry Pi 5 & Newer OS Versions:
+Newer hardware and Debian Bookworm use dtparam=uart0=on instead of the legacy enable_uart=1 setting to enable the UART0 hardware block.
+
+At least one boot configuration should contain:
+
+```text
+enable_uart=1
+```
+Or
+
+```text
+dtparam=uart0=on
+```
+
+## Prepare Serial And GPIO Permissions
+
+Both connection options require serial-device access for the radars and GPIO
+access for the shared trigger. Confirm that the OpenFlight user belongs to
+`dialout` and `gpio`:
+
+```bash
+groups
+```
+
+If either group is missing:
+
+```bash
+sudo usermod -a -G dialout,gpio "$USER"
+sudo reboot
+```
+
+## Prepare The Sound-Trigger GPIO
+
+No `raspi-config` interface setting is required for the sound-trigger input.
+OpenFlight uses BCM17 by default, which is physical pin 11 on the Pi header. The
+checked-in Python dependencies include `gpiozero` and the Pi `lgpio` backend.
+
+The launch command does not need `--iwr6843-trigger-pin` when the GATE splice is
+wired to BCM17. If startup reports `GPIO busy`, another OpenFlight, calibration,
+or shot-test process still owns the pin; stop that process before retrying.
+
+OpenFlight selects the `lgpio` pin factory itself and names the gpiochip
+explicitly, because gpiozero 2.0.1.post2 cannot auto-detect one on a Pi 5 — its
+`pins/lgpio.py` calls `os.path.exists` without importing `os`, so every backend
+falls back and startup dies with `BadPinFactory: Unable to load any default pin
+factory!`. Setting `GPIOZERO_PIN_FACTORY=lgpio` does not help; it forces the
+same broken call. If a kernel update moves the 40-pin header to a different
+chip, override it:
+
+```bash
+OPENFLIGHT_GPIO_CHIP=0 scripts/start-kiosk.sh ...
+```
+
+## Identify The TI Serial Port
+
+Connect the IWR6843LEVM to the Pi over USB and inspect the serial devices:
+
+```bash
+ls -l /dev/serial/by-id/
+ls -l /dev/ttyUSB*
+```
+
+The board's CP2105 exposes two UART interfaces. OpenFlight firmware uses the
+**Enhanced/UARTA** interface for both CLI commands and binary dumps. This is
+normally USB interface `00` and `/dev/ttyUSB0`. Do not select the Standard/data
+interface, which is normally interface `01` and `/dev/ttyUSB1`.
+
+The exact `/dev/ttyUSB*` number can change after reconnecting hardware. Prefer
+the corresponding `/dev/serial/by-id/...-if00-port0` path when available. The
+examples below use `/dev/ttyUSB0`; replace it if your Enhanced interface has a
+different path.
+
+When using Option B, also identify the OPS243 USB serial path under
+`/dev/serial/by-id/`. Use that stable path for `--radar-port` instead of relying
+on a changing `/dev/ttyACM*` number.
+
diff --git a/docs/iwr6843_field_report_2026-07.html b/docs/iwr6843_field_report_2026-07.html
deleted file mode 100644
index 89025acc9..000000000
--- a/docs/iwr6843_field_report_2026-07.html
+++ /dev/null
@@ -1,924 +0,0 @@
-
-
-
-
-
-
- OpenFlight — IWR6843 Launch-Angle Field Report
-
-
-
-
-
-
OpenFlight · Field report · Updated July 22, 2026
-
How we measure launch angle with a 60 GHz radar
-
A plain-language guide to the OPS243 + TI IWR6843 pipeline, the late-flight algorithm we call LCMF-v1, what three TrackMan sessions taught us, and how we plan to improve coverage without hiding quality.
-
- Angle sensor TI IWR6843
- Speed sensor OPS243
- Estimator LCMF-v1
- Validation Three indoor TrackMan sessions
-
-
-
-
-
-
-
-
-
Explain it like I’m five
-
We watch the ball several times, keep the clearest pictures, and ask five judges where it is going.
-
The OPS radar tells us how fast the ball is moving. The TI radar takes a short movie made of radio echoes. We find the little streak that moves away from the tee, keep the clearest moments from each frame, and pay extra attention to the later part of the flight because the ball is farther from the club, golfer, and impact mess.
-
Five slightly different physics models each estimate launch angle. We average their answers and use the measured mount geometry directly. If the radar movie is strong, the UI shows the measured radar angle. If the movie is weaker but still plausible, we want to show it as a lower-confidence two-dot radar read. If the radar cannot honestly follow the ball, the UI labels a normal club-based estimate instead.
-
The important idea: many looks + multiple models + honest confidence, never a hidden adjustment that quietly fits one golfer or one club.
-
-
-
-
-
Where we are
-
The short version
-
-
0.83°
combined Iron/Wedge MAE across two indoor TrackMan validation sessions
-
87.4%
strict LCMF-v1 coverage on Iron/Wedge shots, 76 of 87
-
0.67°
p50 absolute error; half of covered Iron/Wedge shots were inside this
-
-0.04°
bias on covered Iron/Wedge shots, effectively centered
-
-
For Iron/Wedge shots, the current pipeline is inside the 1° target on the shots where the radar has enough clean evidence. Driver and Mis Hits are tracked separately because they expose different engineering problems. The next job is widening coverage and hardening setup inputs so the same result travels to different builders, mats, rooms, and ranges.
-
-
-
-
-
State the problem
-
We need ball and club direction in a brutally short window
-
OpenFlight has to measure vertical launch, horizontal launch, and eventually useful club-delivery signals while the ball is only a few feet from the radar. Indoors, a fast driver can hit a net or screen tens of milliseconds after impact. That leaves very little clean flight, and the earliest echoes are exactly where the club, hands, tee, ball, floor reflection, and impact noise overlap.
-
The K-LD7 taught us the core lesson: one transmitter, a slower frame cadence, coarse range separation, and one or two useful post-impact looks were not enough to consistently separate the real ball path from multipath and blind-zone behavior. It could look good on selected shots, but it did not create a robust 1° path against TrackMan, the gold-standard source of truth for launch-monitor validation.
-
-
The product problem is not just “detect a ball.” It is detecting the right moving echo, proving it is the ball, modeling the ground-reflected copy, and reporting confidence honestly when the evidence is thin.
-
-
-
-
-
-
-
Why this radar
-
The IWR6843 gives us enough raw evidence to model the mess
-
We selected TI’s IWR6843 because it can capture coherent complex radar data across multiple receive channels at a much faster cadence than the K-LD7 setup. The IWR6843LEVM board was the practical evaluation platform: available hardware, known antenna geometry, TI tooling, and enough on-chip L3 memory to hold a compact radar cube from the shot.
-
We created and uploaded custom firmware that turns the TI board into a short “radar movie” recorder. Instead of asking the radar to make a decision live, the board saves 12 tightly spaced snapshots of the ball leaving the tee. Each snapshot keeps detailed antenna information, so our software can later follow the ball moving away and separate it from the floor reflection, club, and impact noise.
-
-
Capability
Why it matters
Current use
-
-
Fast frame cadence
More looks before the net or screen contaminates the track.
12 snapshots over roughly 72 ms in the current custom firmware.
-
Fine range bins
Direct and floor-reflected paths can separate in range as the ball leaves the tee.
3.2 GHz sweep, about 4.7 cm bins.
-
Complex antenna channels
Phase across the array carries angle information even when amplitude is messy.
Eight vertical virtual channels from TX1 + TX3 and four RX.
-
L3 rolling buffer
We can keep raw shot evidence and improve offline without reflashing for every idea.
786,452-byte dump per trigger, drained by the Pi.
-
-
-
-
-
-
-
-
OPS + IWR6843
-
Two radars, each doing the job it is best at
-
The one-chip goal was useful while learning the TI sensor, but it is no longer the product direction. OPS speed has been highly consistent and does not need to be replaced. The TI board is now focused on the measurements its antenna array can add: vertical launch angle today, and eventually aim direction and club path.
-
-
-
- Independent speed
-
OPS243
-
Measures ball and club radial speed from its rolling buffer. This remains the speed authority.
-
-
- Raw angle data
-
TI IWR6843
-
Stores a 72 ms coherent radar movie across eight virtual vertical antenna channels.
-
-
- One shot record
-
OpenFlight UI
-
Combines OPS speed with TI launch angle. If TI has no read, the shot still appears with an estimated angle.
-
-
-
-
Why the TI chip changed the problem
-
The K-LD7 could usually see only one or two useful frames before the indoor net stopped the ball. It also had coarse range resolution and only a two-element angle view, so a clean ball echo and a floor reflection could blend into one believable but wrong angle.
-
The custom firmware saves a compact, high-detail radar movie: 12 snapshots of the ball leaving the tee, spaced about 6 ms apart. Each snapshot uses multiple antenna views and fine distance slices, which gives the software enough evidence to model the floor reflection instead of pretending it is not there.
-
-
The rolling buffer is the enabling trick. This is similar to how OPS keeps a rolling speed buffer: the radar is always recording, and impact tells the system which recent slice matters. A sound trigger connected to the Pi freezes the TI radar movie after impact, preserving the last 72 ms of raw antenna data. The Pi then drains the 786,452-byte dump over UART in about 7.6 seconds. We accept the delay because we keep the raw evidence for every shot.
-
-
-
-
- Rolling buffer mental model. The radar is not trying to decide launch angle from one echo. It stores a short radio movie, then LCMF looks for the portion where the ball has separated from the impact mess but has not yet reached the net or screen.
-
-
-
-
-
-
LCMF-v1
-
What the algorithm actually does
-
LCMF means Late-Flight Complex Multipath Fusion. “Late-flight” means it favors the cleaner second half of the captured ball flight. “Complex” means it keeps both amplitude and phase from every antenna. “Multipath” means direct and floor-reflected echoes are modeled together. “Fusion” means no single model gets to decide the answer.
-
-
- 1
Freeze both buffers
The same impact edge timestamps the OPS shot and starts the TI dump. Matching is normally within a few milliseconds.
- 2
Find the outward streak
Static clutter is removed. The tracker follows a target moving outward through range over time rather than trusting aliased Doppler speed.
- 3
Balance the frames
Keep snapshots with strength score ≥ 8 and range ≤ 4.7 m, then retain at most the strongest four from each frame. One noisy frame cannot dominate.
- 4
Use OPS speed as the guide
The independently measured OPS ball speed defines the candidate trajectory. TI’s local range-walk velocity handles the small timing correction between transmitters.
- 5
Ask five physics models
Two models compare the eight antenna channels across all balanced snapshots. Three inspect the direct and reflected range structure in the chronological late half.
- 6
Fuse and report
Each model receives exactly 20% weight. Their mean is the LCMF angle. The production path favors measured setup geometry and the same estimator rules for every club.
-
-
-
-
Model family
Plain-language question
Data used
-
-
Two channel models
Which launch trajectory best explains the phase pattern across the antenna array when direct and floor paths are allowed?
All balanced snapshots
-
Three fast-time models
Which trajectory best explains the small range separation and mixture of direct and reflected echoes around the tracked ball?
Chronological second half
-
Equal fusion
What answer survives five different assumptions instead of winning one hand-picked model?
20% per component
-
-
-
-
-
-
-
-
Real captured shots
-
Which frames are selected, and why late flight helps
-
The gray marks below are usable snapshots along three real TrackMan-paired ball tracks. Orange circles are the strongest four retained from each frame. Teal dots are the chronological late half used by the three fast-time models. Frame numbers wrap because the radar memory is a ring; the horizontal time axis is the true order.
-
-
-
- Actual July 14 captures. The driver has only three clean frames and is therefore the hardest case. The 7-iron and 9-iron offer more looks across the flight. Late snapshots are not automatically “correct”; they are simply less contaminated by impact, club, hands, and tee while providing more direct-versus-ground path separation.
-
-
-
This selection is deliberately boring: no club-specific timing window, no TrackMan input, and no hand-picked frame number. The same strength, range, per-frame balancing, and chronological-half rules run on every shot.
-
-
-
-
-
-
Setup calibration
-
The radar is accurate only if the setup geometry is honest
-
The biggest lesson from the first two validation sessions is simple: the algorithm can follow the ball, but it needs the real-world setup described correctly. Mount tilt, radar height, tee distance, ball height, mat height, and net distance all affect where the radar expects the direct and floor-reflected echoes to appear.
-
When those inputs are right, the same LCMF-v1 estimator produces a centered result across two indoor TrackMan sessions without per-club tuning. When those inputs are wrong, the error can look like a radar problem even though the underlying ball track is still present.
-
-
Setup input
Why it matters
Product plan
-
-
Mount tilt
Defines how the antenna frame maps into the golfer's launch frame.
Measured setting first; later add an on-rig level sensor.
-
Tee distance
Changes the expected direct/reflected path geometry during the first few feet of flight.
Support measured distance and radar-assisted setup warnings.
-
Mat and ball height
An elevated mat changes the ball height relative to the radar and the floor reflection.
Store ball height and mat/surface height separately.
-
Net or screen distance
Defines how much clean late flight exists before impact with the screen or net.
Log it per session and use it when selecting late frames.
-
-
-
What remains true
-
-
learnedThe estimator is extracting stable ball-angle information from the TI radar movie.
-
not solvedWe still need to make these setup measurements easy enough for normal builders, not just people who lived inside the test sessions.
-
-
-
-
-
-
-
TrackMan validation
-
Two TrackMan sessions are the current accuracy baseline
-
-
-
The headline below combines two indoor TrackMan validation sessions. Driver and Mis Hits are separated so the main number describes Iron/Wedge launch-angle performance rather than hiding known edge cases.
-
-
Group
Shots
Covered
Coverage
MAE
p50
p75
p90
Bias
-
-
Iron/Wedge headline
87
76
87.4%
0.83°
0.67°
1.20°
1.80°
-0.04°
-
First validation session Iron/Wedge
41
39
95.1%
0.83°
0.78°
1.25°
1.81°
-0.10°
-
Second validation session Iron/Wedge
46
37
80.4%
0.84°
0.59°
1.19°
1.70°
+0.02°
-
-
-
-
How to read absolute error: MAE is the average distance from TrackMan, ignoring sign. p50 means half the covered shots were closer than that error. Bias keeps the sign and tells us whether the whole group is systematically high or low.
-
-
-
Current 18-frame firmware confirmation
-
A third indoor TrackMan session on July 22 tested the production firmware in this report: 3 TX, 12 loops, 18 frames, 4 ms spacing, and moving 53-bin windows. Twenty shots had matched OpenFlight and TI captures and therefore formed the valid radar denominator; five additional TrackMan swings had no corresponding OpenFlight capture and were not counted as estimator misses.
-
Using the physically measured 12.4° mount geometry, the matched group produced approximately 0.68° launch-angle MAE. An earlier block in the same session independently suggested about 12.3°; applying that geometry to the later good-shot block produced approximately 0.47° MAE. That temporal result is encouraging, but it remains diagnostic rather than the headline because the tilt candidate was inferred inside the same session. The conservative conclusion is that denser, cropped firmware preserved sub-1° vertical accuracy while adding more frames and the third transmitter.
-
-
Iron/Wedge breakdown by club
-
The 9-iron table excludes a same-day experimental transmitter-order test from the headline. That experiment is useful firmware evidence, but it should not be mixed into the normal production score.
-
-
Club
Good shots
Covered
Coverage
MAE
p50
p75
p90
Bias
-
-
Sand wedge
17
15
88.2%
0.67°
0.46°
1.06°
1.58°
-0.22°
-
9-iron
27
25
92.6%
0.89°
0.81°
1.18°
1.73°
+0.24°
-
7-iron
21
18
85.7%
0.91°
0.49°
1.15°
1.88°
-0.06°
-
5-iron
22
18
81.8%
0.82°
0.69°
1.31°
1.84°
-0.25°
-
-
-
-
Driver separated
-
-
Group
Shots
Covered
Coverage
MAE
p50
p75
p90
Bias
-
-
Driver
22
18
81.8%
3.55°
1.31°
1.82°
15.57°
+3.39°
-
First validation session driver
9
5
55.6%
1.58°
1.37°
1.85°
2.77°
+1.08°
-
Second validation session driver
13
13
100.0%
4.31°
1.21°
1.71°
15.68°
+4.27°
-
-
-
-
-
Driver is not the same failure as irons. The second-session driver misses were mostly false acceptance of slow/ghost tracks. Raw replay showed the real fast ball in the frames, so the immediate fix is an OPS-vs-TI speed gate and a low-confidence fast-track recovery path.
-
-
-
Mis Hits separated
-
-
Group
Shots
Covered
Coverage
MAE
p50
p75
p90
Bias
-
-
Mis Hits
19
13
68.4%
2.20°
1.16°
3.07°
5.10°
-0.54°
-
First validation session Mis Hits
8
7
87.5%
2.40°
1.29°
3.81°
4.83°
-0.15°
-
Second validation session Mis Hits
11
6
54.5%
1.98°
0.78°
1.43°
4.54°
-1.00°
-
Skulls / very low launch
5
5
100.0%
6.00°
2.07°
3.51°
15.47°
+4.31°
-
-
-
These groups are still product-critical because real golfers hit them. They are separated here because they require different engineering: TX2 aim for directional Mis Hits, low-launch/impact-clutter logic, and OPS speed agreement for driver.
-
-
-
-
-
-
Next steps
-
Increase coverage, add club data, and harden the setup inputs
-
The strict LCMF-v1 gate is doing the right thing for accuracy, but it leaves some real ball flights unreported. The first validation session already had strong strict coverage after the setup geometry was cleaned up. The second session showed the more practical product problem: a relaxed pass can recover no-reads, but those reads should enter the UI as measured lower-confidence angles rather than being mixed into the high-confidence lane.
-
RMS is a “how messy was the fit?” score. Lower RMS means the radar snapshots line up neatly with one clean ball path. Higher RMS means the ball is probably there, but the evidence is noisier, weaker, or more mixed with reflections. Relaxing the RMS limit lets us accept more of those imperfect radar tracks, which increases coverage, but it also increases the chance that a recovered angle is a little farther from TrackMan.
-
The product answer should not be “lower the bar and call everything high confidence.” The product answer should be a second lane: measured, but lower confidence.
-
-
-
Mode
Iron/Wedge coverage
MAE
p50
p75
p90
New recovered reads
Recommended UI
-
-
Combined strict LCMF-v1
76 / 87 · 87.4%
0.83°
0.67°
1.20°
1.80°
0
3 dots
-
First-session strict
39 / 41 · 95.1%
0.83°
0.78°
1.25°
1.81°
0
3 dots
-
Second-session strict
37 / 46 · 80.4%
0.84°
0.59°
1.19°
1.70°
0
3 dots
-
Second-session relaxed RMS ≤ 0.58
42 / 49 · 85.7%
1.00°
0.71°
1.32°
2.06°
5
2 dots
-
Second-session relaxed RMS ≤ 0.70
45 / 49 · 91.8%
1.09°
0.78°
1.56°
2.50°
8
2 dots, lab-only first
-
-
-
-
Recommended confidence contract
-
-
Strict pass: if normal LCMF-v1 accepts, show the radar launch angle as the primary measured value with full confidence.
-
Relaxed pass: if strict LCMF no-reads but a relaxed replay passes RMS, snapshot, frame, component-spread, and OPS-speed checks, show the radar angle with a two-dot confidence marker.
-
Estimate: if both radar passes fail, show the club/speed estimate exactly as we do today.
-
-
-
-
Why this is honest: a two-dot radar read is still better than hiding useful evidence, but it tells the golfer and the engineering team that the ball track was recovered under looser rules. That preserves trust while improving coverage.
-
-
-
Driver-specific guardrail
-
Driver needs an additional speed sanity check before any confidence badge. The worst driver misses were accepted TI tracks around 55–57 mph while OPS measured roughly 152–158 mph. Raw-frame replay showed the fast ball was present, so the immediate fix is to withhold any TI angle when its tracked speed is far below OPS speed, then optionally try an OPS-guided fast-track recovery as a low-confidence read.
-
-
Driver policy
Why
Expected effect
-
-
Reject TI track speed below about 65–70% of OPS ball speed
Catches obvious slow ghost tracks before they reach the UI.
Turns bad measured angles into estimates instead of false confidence.
-
Try OPS-guided fast-track recovery after rejection
Offline replay recovered 3 of 4 bad driver shots with about 2.6° MAE.
Potential two-dot driver reads, but needs more truth data before shipping as normal confidence.
-
-
-
-
Club data and setup hardening
-
-
clubKeep club speed with OPS, but start collecting club-delivery evidence. A split pre-impact/post-impact firmware mode may expose attack angle and club path research signals without pretending the TI board is the club-speed authority.
-
aimAdd TX2 for horizontal launch and shank classification. Far-right shots should eventually be recognized as far-right shots, not forced through a purely vertical interpretation.
-
inputsHarden tee distance, mount tilt, radar height, ball height, mat height, and net distance. The app should support measured settings, defaults, and radar sanity checks that warn when the actual session appears to drift.
-
-
-
-
-
-
-
DIY setup variables
-
Ball placement is a product variable, not just a measurement chore
-
Commercial radar systems usually solve tee placement with a prescribed setup window and alignment aids. Photometric systems solve it by forcing the ball into a camera-observed hitting zone. OpenFlight sits in the DIY middle: we want the accuracy of a measured geometry, but builders may move between a marked home mat, an unmarked simulator bay, and a range mat where the ball can drift shot to shot.
-
-
-
July 17 replay result: when ball placement is disciplined, fixed tee distance is best. When placement wanders more than about 6–9 inches, radar-computed or blended tee distance starts beating a stale fixed setting.
-
-
-
How sensitive is tee distance?
-
We reran second-session Iron/Wedge shots with tee distance shifted by ±6 inches. On the clean Iron/Wedge group, the original fixed tee distance scored 0.84° MAE. A six-inch mistake roughly doubled or tripled the error.
-
-
Tee distance used by LCMF
Covered
MAE
p50
p75
p90
Bias
-
-
Saved distance − 6 in
37 / 46
1.61°
1.34°
2.23°
2.89°
-1.54°
-
Saved distance
37 / 46
0.84°
0.59°
1.18°
1.69°
+0.01°
-
Saved distance + 6 in
37 / 46
2.05°
1.98°
2.87°
3.57°
+1.81°
-
-
-
-
Radar-estimated tee range
-
The TI track can be extrapolated backward toward impact to estimate where the ball started in range. On July 17, using an effective impact point near 12.5 ms inside the stored radar cube, the radar-derived start range centered very close to the measured tee distance:
-
-
Group
Median estimated distance error
Mean estimated distance error
Middle 50%
Middle 80%
-
-
All Iron/Wedge
+0.81 in
-0.06 in
-2.65 to +2.63 in
-7.46 to +7.22 in
-
Clean validation subset
+0.06 in
-0.66 in
-2.83 to +2.41 in
-8.27 to +6.12 in
-
-
-
That is good enough for a setup sanity check. It is not yet good enough to blindly replace the tee distance shot by shot. On the clean group, per-shot radar tee range increased MAE from 0.84° to 1.22°. The per-shot estimate is useful evidence; the raw value is still noisy.
-
-
Wild-placement simulation
-
To model unmarked ranges and multi-golfer use, we simulated true ball placement wandering around the saved tee distance. The comparison below uses the clean Iron/Wedge validation subset.
-
-
Placement pattern
Best tee mode
Best MAE
Fixed-distance MAE
Per-shot radar MAE
-
-
Random ±3 in
Fixed
0.97°
0.97°
1.30°
-
Random ±6 in
50/50 blend
1.14°
1.22°
1.28°
-
Random ±9 in
50/50 blend
1.23°
1.50°
1.29°
-
Random ±12 in
Per-shot radar
1.30°
1.84°
1.30°
-
Random ±18 in
Per-shot radar
1.30°
2.59°
1.30°
-
Constant setup error ±6–12 in
Rolling 20-shot median
0.90–1.00°
1.64–3.35°
1.05–1.42°
-
-
-
-
Recommended app settings
-
-
Mode
Use when
Behavior
-
-
Fixed distance
Marked home mat, repeatable tee dot, single golfer.
Use the saved measured distance for every shot. Highest accuracy when placement is controlled.
-
Radar assisted
Recommended default for DIY setups.
Use saved distance for LCMF, but warn when the rolling radar estimate says the ball is consistently closer or farther.
-
Radar computed
Range bay, no marker, multiple golfers, or intentionally flexible hitting area.
Use a gated blend of per-shot radar estimate and rolling median. Prefer rolling median for stable setup errors; prefer per-shot only when placement is clearly moving.
-
-
-
-
Other rig and user variables to test next
-
-
tiltMount angle. The validation data strongly suggests tilt accuracy is one of the most important setup inputs. We need deliberate TrackMan A/B runs at 10.4°, 11.2°, and 12.0°.
-
heightMat height above radar floor. A one-inch elevated mat can move early-flight geometry by multiple degrees and late-flight geometry by roughly 0.5–1.0°. The app should store surface offset separately from ball/tee height.
-
netNet or screen distance. If the ball reaches the net quickly, late frames can become contaminated. Test 4.0 m, 4.6 m, and 5.2 m with driver and wedges.
-
floorGround material. Hardwood, turf, concrete, carpet, and range mats change the floor reflection. We should log surface type and compare component spread and no-read rate.
-
aimHorizontal ball position and shanks. Launch direction changes the vertical fit slightly and shanks are a coverage/classification problem. TX2 aim is the right long-term fix.
-
alignmentRadar yaw to target line. Small yaw errors mostly affect speed projection and future aim, but they can also change which multipath track wins. Add yaw/alignment to setup QA.
-
occlusionGolfer stance and handedness. Left/right-handed setup, foot position, and club path may alter early clutter. Track handedness and run a small lefty/righty A/B when possible.
-
ballBall type and markings. Different balls should not change range-walk geometry much, but spin markings, metallic tape experiments, and range balls can change RCS and no-read rate.
-
-
-
-
-
-
-
Why it is working
-
Four improvements compound
-
-
timeMore frames. The ball is observed repeatedly instead of asking one or two moments to carry the whole answer.
-
rangeA real trajectory. Fine range bins let us follow the ball moving outward and use never-aliasing range walk instead of trusting Doppler alone.
-
physicsMultipath is part of the model. Direct and floor-reflected paths are allowed to coexist; the algorithm does not force their mixture into one fake point angle.
-
independenceOPS anchors speed. Angle fitting is not allowed to improve itself by changing the speed assumption, and the product does not depend on TI club-speed calibration.
-
diversityFive models must agree in aggregate. Different models fail differently, so equal fusion is more stable than selecting whichever model happened to look best on this session.
-
-
-
-
-
-
-
Limits and next experiment
-
What could still prevent a 1° product
-
-
validationMore independent data. July 17 was a strong step, but confidence thresholds and driver recovery still need another truth session before we call them production behavior.
-
coverageStrict no-reads. The UI fallback makes the product complete, but 87.4% strict radar coverage on Iron/Wedge shots is not the finish line. A two-dot relaxed lane can recover useful reads without pretending they are equal to strict reads.
-
driverLate flight can be short. A driver may hit a close net around 40 ms after impact. The firmware budget may need denser frames or a driver-specific capture allocation without changing estimator rules.
-
confidenceNo calibrated confidence yet. Component spread and frame coverage are promising quality features, but thresholds must be learned on independent truth rather than invented.
-
latencyUART takes about 7.6 seconds. On-chip range gating or compression can reduce the blind time and buy more frames without changing RF hardware.
-
2DAim and club path are not in this capture. Bringing in the third transmitter requires a new memory allocation and firmware experiment.
-
-
-
-
-
-
-
Technical deep dive
-
How the current TI angle pipeline works
-
This section is for contributors who want the engineering map without reading every replay script. The public story is “record a short radar movie and let five models vote.” The technical story is a synchronized OPS + TI capture, a ring-buffer unwrap, a range-walk tracker, balanced snapshot selection, five independent angle estimators, and strict quality gates before the value reaches the shot record.
-
The key hardware unlock is the IWR6843’s on-chip L3 RAM. Instead of streaming every chirp over a slow UART connection in real time, the firmware writes the radar cube into local memory while the shot is happening. That lets the board preserve high-rate complex antenna data during the tiny post-impact window, then drain it slowly after the ball is gone. In plain English: L3 RAM lets us capture the important 72 ms at radar speed, then analyze it at Pi speed.
-
-
End-to-end signal chain
-
-
Stage
What happens
Why it exists
-
-
Impact trigger
The sound trigger fires into the Pi. OPS and TI both preserve their recent rolling-buffer evidence around that impact.
Gives both radars the same shot reference instead of waiting for software to notice the ball.
-
L3 capture
The custom TI firmware freezes the recent radar cube in on-chip L3 RAM before anything is sent to the Pi.
Keeps the full-speed antenna movie intact even though the serial dump takes several seconds afterward.
-
OPS processing
OPS produces ball speed, club speed, impact timing, and the primary shot record.
OPS remains the speed authority because it is consistent and already product-integrated.
-
TI dump
The IWR6843 dump is drained after the shot and associated with the OPS shot by trigger timing.
Preserves raw complex antenna evidence for measured launch angle and offline replay.
-
LCMF replay
Static clutter is removed, the outward ball track is found, snapshots are selected, and five angle models are fused.
Separates the ball from impact clutter, floor reflection, and wrong tracks.
-
Shot merge
The server adds TI launch angle when strict gates pass. Otherwise the UI can still show the normal estimate.
Keeps the product usable while making measured radar reads auditable.
-
-
-
-
Current custom firmware capture
-
The current firmware is optimized for vertical launch angle. Internally we still call this the Variant B baseline, but externally it is simply the custom rolling-buffer firmware.
-
-
Setting
Current value
Engineering tradeoff
-
-
Frame count
12 radar snapshots
Enough time history for irons/wedges; driver may need denser or more intentional post-impact timing.
-
On-chip L3 RAM
768 KB rolling storage for the radar cube
The unlock: capture first at radar speed, transfer later at UART speed.
-
Frame spacing
About 6 ms
Fast enough to follow early flight, but a close net can still limit driver late-flight evidence.
-
Range resolution
About 4.7 cm bins from a 3.2 GHz sweep
Fine enough to separate direct and floor-reflected structure better than the K-LD7 path.
-
Vertical antenna view
TX1 + TX3 with four RX, forming eight vertical virtual channels
Preserves vertical phase diversity for launch angle. TX2 is reserved for future horizontal aim work.
-
Per-frame evidence
16 chirp pairs per frame
More chirps improve per-frame stability; fewer chirps could buy more frames for faster balls.
-
Payload
786,452 bytes per shot, drained in about 7.6 seconds
Large enough for rich offline evidence; slow enough that future compression/range gating matters.
-
-
-
-
What the five LCMF models do
-
LCMF does not trust a single angle estimate. It asks five models with different failure modes, then gives each model equal weight. That keeps the estimator explicit and reduces the temptation to choose whichever model happened to win one session.
-
-
Model
Uses
Plain-English role
-
-
Channel model A
Complex phase and amplitude across the eight vertical antenna channels
Finds the launch trajectory that best explains the antenna pattern when a floor path is allowed.
-
Channel model B
The same antenna evidence with a slightly different manifold assumption
Checks whether the answer survives a different view of the direct/reflected mixture.
-
Fast-time model A
Late chronological snapshots and fine range-bin structure
Looks for the direct and floor-reflected range signature as the ball gets farther from the tee.
-
Fast-time model B
Range-walk consistency through the late half of flight
Rewards trajectories that explain the ball moving outward at the OPS-guided speed.
-
Fast-time model C
Snapshot strength, range shape, and late-frame consistency
Provides a third range-domain vote so one noisy frame cannot dominate the result.
-
Fusion
20% weight per model
Produces the final LCMF launch angle and exposes model spread as a quality signal.
-
-
-
-
Deeper model notes
-
All five models sweep candidate launch angles through the same geometry: measured tee range, radar height, ball height, mount tilt, OPS ball speed, and the tracked TI range samples. For each candidate angle, the software predicts where the direct ball echo and the floor-reflected “image ball” echo should appear. The models differ in which part of the raw radar evidence they trust most.
-
-
channel_two8Two-source vertical-array model. This is the simplest complex antenna model. It treats each selected snapshot as a mixture of two steering vectors across the eight virtual vertical channels: the direct path from the real ball and the image path from the floor reflection. The nuisance coefficients are complex, so amplitude and phase of each path are allowed to float. The candidate angle wins when those two columns predict the observed eight-channel vector with low leave-one-channel-out error. This is useful because it asks, “does the array phase look like this launch angle?” without needing the range-bin shape to be perfect.
-
channel_four4_path_tdmFour-path transmit/receive manifold model. The floor reflection can happen on transmit, receive, or both, so the full dictionary has four path products: direct-direct, direct-ground, ground-direct, and ground-ground. Because the IWR6843 is time-division multiplexed, the later TX block sees a slightly different path phase when direct and reflected components have different radial velocities. This model includes that TDM residual phase. It is more physically complete than the two-source model, but also has more nuisance freedom, so it is paired with leave-one-channel-out scoring to avoid simply overfitting noise.
-
fast_direct1Late-flight direct-path range model. This model ignores the floor image and asks whether the local FFT range window around the tracked ball can be explained by one direct path. It is intentionally under-modeled. When it agrees with the multipath models, that is a strong sign the direct echo is dominant and clean. When it disagrees, that disagreement is useful evidence that the shot is reflection-heavy or range-window contaminated.
-
fast_two2Direct plus ground-ground range model. This model keeps the direct path and the strongest image-ball term. It predicts the small range-bin separation between the real ball path length and the reflected path length, then fits complex coefficients inside a local FFT window around the track. This is the first fast-time model that directly asks, “does the range shape look like direct plus floor reflection?”
-
fast_four4Full local range-shape multipath model. This uses the same four DD/DG/GD/GG physical path products as the richer channel model, but now it also models where each path should fall inside the local range FFT. It is the most complete range-domain model: spatial steering across antennas multiplied by predicted fast-time tone responses around the ball bin. It is powerful when the late snapshots have enough SNR and path separation, but it can become sensitive to weak late frames, net contamination, or a wrong range track.
-
-
The fusion step is deliberately boring: take the five component launch angles and average them with equal 20% weights. We do not let a single model “win” because each one fails differently. The channel models can be fooled by array manifold errors or calibration drift. The fast-time models can be fooled by range-window contamination or weak late frames. Agreement across both families is the useful signal.
-
-
Quality gates and confidence ingredients
-
-
trackerOutward range track. The ball candidate must move away from the radar in a physically plausible way.
-
speedOPS agreement. OPS ball speed guides the expected trajectory and should reject slow ghost tracks, especially for driver.
-
framesBalanced snapshots. LCMF keeps only a few strong snapshots per frame so one frame with clutter cannot overwhelm the vote.
-
RMSFit messiness. RMS measures how cleanly the selected radar evidence fits the candidate path. Relaxed RMS recovers coverage but belongs in a lower-confidence lane.
-
spreadComponent disagreement. If the five models disagree too much, the shot should be withheld or shown as low confidence.
-
metadataSession geometry. Tilt, radar height, tee distance, ball height, mat height, net distance, firmware identity, and TX order must be stored with the shot.
-
-
-
How it connects to OpenFlight
-
The server flag --iwr6843 enables the TI capture monitor alongside the existing OPS rolling-buffer monitor. OPS still creates the shot and owns speed/carry inputs. The TI monitor captures and processes the raw IWR6843 dump, then the server merges a measured launch angle into the shot when LCMF passes quality gates. When TI does not pass, the shot still appears using the existing estimated launch angle path.
-
This split is intentional: OPS provides the stable product backbone, while TI adds measured ball-angle evidence without forcing the whole launch monitor to depend on one chip.
-
-
-
-
-
-
Firmware roadmap
-
We stopped saving the empty parts of the radar movie
-
The original roadmap asked whether the chip could calculate range on-board, retain only the useful evidence, add the third transmitter for aim, and still preserve a rolling history around impact. The answer is now mostly yes. The latest firmware computes the complete range picture for every chirp, then stores a smaller moving crop that follows the part of the hitting area where the club and ball can physically appear.
-
-
-
The movie analogy is literal enough to be useful: imagine every radar frame as a wide picture from the radar to the net. We still develop the whole picture, but save only a horizontal crop. The crop slides away from the golfer as time advances. Unlike resizing or video compression, the complex antenna values inside the crop are unchanged.
-
-
-
-
Antenna-name clarification: TI-labeled TX1 + TX3 form the eight-element vertical array. The sideways TX2 supplies the second axis for horizontal launch direction. The new capture now records all three transmitters; vertical launch still uses the proven TX1/TX3 pair while TX2 remains an experimental aim channel.
-
-
-
-
-
- The planned 18-frame capture. Six dense pre-impact frames keep the near-tee corridor, six early-flight frames shift outward, and six late-flight frames finish just beyond the home net distance. One frame may already be armed when impact arrives, so firmware records the actual crop used by every frame rather than asking the Pi to assume the schedule.
-
-
-
-
- Hardware proven
-
Develop the full range picture
-
The Hardware Accelerator, or HWA, performs a 128-point range FFT for every chirp. That preserves the existing 4.7 cm range resolution while moving repetitive math off the CPU.
-
Real ADC tests, corner reflectors, and ball captures confirmed that the HWA can process the live signal and preserve the complex phase and amplitude needed for angle estimation.
-
-
-
- Replay supported
-
Store a moving 53-bin crop
-
Instead of saving all 128 bins, EDMA copies 53 complex bins from the HWA into L3. The target crop moves through bins 20–72, 32–84, and 47–99 as the ball leaves the tee.
-
A geometry replay of 196 TrackMan-recorded trajectories, from sand wedge through driver, retained every modeled ball point that the previous fixed 80-bin capture could have retained. Hardware ball validation is next.
-
-
-
- Hardware proven
-
Freeze on a clean frame boundary
-
The ring continuously overwrites old history, like the OPS speed buffer. Impact tells the Pi to request a freeze; firmware then records a deliberate number of post-impact frames before stopping on a completed HWA/EDMA boundary.
-
Repeated clap, corner-reflector, and ball tests proved that the ring can freeze, dump, rearm, and capture again without relying on a seven-second host-side delay.
-
-
-
- Hardware + TrackMan proven
-
Reinvest the saved memory
-
The next candidate records 3 TX, 12 loops, 18 frames, and 4 ms spacing. That is 20% more per-transmitter looks, 50% more frames, and a denser view of the club approaching impact.
-
The linker proves it uses 549,504 of 786,432 L3 bytes, leaving 236,928 bytes for safety and future capture modes. Repeated home captures proved freeze, transfer, decode, and rearm; the July 22 TrackMan session confirmed approximately 0.68° vertical MAE on the matched capture group.
-
-
-
-
-
Where the L3 budget went
-
The first reduction proved the idea with a fixed 80-bin crop. The moving 53-bin version goes further, then spends part of the savings on better time resolution rather than merely producing the smallest file.
-
-
Capture design
Geometry
L3 ring
Meaning
-
-
Original vertical baseline
2 TX · 16 loops · 12 frames · 128 samples
786,432 B
Proven TrackMan evidence, but no room for TX2 or more frames.
-
Raw three-TX proof
3 TX · 10 loops · 12 frames · 128 samples
737,280 B
Added aim, but spent almost the entire ring.
-
Fixed range snapshot
3 TX · 10 loops · 12 frames · 80 bins
460,800 B
Hardware proof that the chip can store FFT output instead of raw ADC.
-
Moving range crop
3 TX · 10 loops · 12 frames · 53 bins
305,280 B
Smallest current ring while retaining the modeled flight corridor.
-
Production capture
3 TX · 12 loops · 18 frames · 53 bins
549,504 B
Reinvests memory in stronger, denser club and ball evidence while preserving sub-1° vertical MAE.
-
-
-
-
Four words that make the firmware easier to follow
-
-
Word
Plain-language meaning
-
-
Frame
One radar movie frame containing a short burst of measurements from every active antenna.
-
Loop
One pass through TX1, TX2, and TX3. More loops provide more looks inside one frame.
-
Range bin
One approximately 4.7 cm distance slice between the radar and the net.
-
Complex I/Q
The amplitude and phase evidence retained for tracking speed and angle. The crop preserves both.
-
-
-
-
What is proven, and what is not
-
-
Finding
Status
Evidence
-
-
On-chip range FFT preserves usable complex antenna evidence
Hardware proven
HWA self-tests, real ADC tests, corner reflectors, and ball captures.
-
Selected bins can continuously fill and rearm a compact L3 ring
Hardware proven
Repeated boundary-frozen captures without short dumps.
-
All three transmitters retain detectable vertical and horizontal motion
Outdoor proven
Vertical launch remained plausible and intentional left/right groups separated. Horizontal accuracy still lacks TrackMan truth.
-
The 53-bin schedule covers normal launch-monitor trajectories
Replay supported
196 TrackMan trajectories modeled through the proposed early, middle, and late windows.
-
12 loops and 18 four-millisecond frames fit and decode correctly
Hardware proven
Firmware linker map, exact-geometry host regression, repeated home shots, and matched TrackMan captures.
-
The new firmware maintains sub-1° launch-angle MAE
TrackMan confirmed
Approximately 0.68° MAE on 20 matched July 22 captures using the measured 12.4° mount geometry.
-
Denser frames improve driver, club path, and attack angle
TrackMan pending
The extra pre-impact points are physically promising, but improvement has not been scored.
-
-
-
-
-
What cropping could miss: a badly entered tee or net distance, unusual trigger timing, an extreme mishit, or an unexpected reflection could place useful energy outside the saved corridor. That is why the three windows are 2.48 m wide, overlap heavily, and record their actual starting bin in every frame. Runtime tee/net-aware presets come only after the fixed schedule passes TrackMan.
-
-
-
What remains
-
-
Work
Question it answers
Next proof
-
-
Independent firmware holdout
Does the 12-loop/18-frame result repeat after moving the rig and measuring geometry from scratch?
Repeat the production capture in another bay without fitting tilt from the scored block.
-
Capture coverage
Why did five TrackMan swings lack a corresponding OpenFlight/TI capture?
Separate trigger, OPS-shot, UART, and estimator denominators in the next truth session.
-
Driver recovery
Do 4 ms frames expose the real fast ball before a close net?
Compare tracked TI speed with OPS and TrackMan; reject slow ghost tracks.
-
Horizontal launch
Does TX2 measure degrees, not merely left/right sign?
TrackMan launch-direction MAE, bias, and coverage.
-
Club delivery
Do six dense pre-impact frames improve club path and attack angle?
Score against TrackMan club data without changing the ball estimator.
-
Sparse swing history
Can a second low-cost ring retain club-parallel-to-club-parallel motion?
First prove the dense frames consistently identify the club head.
-
Club and room presets
Should driver favor cadence while wedges favor deeper evidence, and should the crop follow net distance?
Only after one global configuration establishes an unbiased baseline.
-
Production calibration
Can multiple boards share one estimator?
Per-unit phase/gain calibration, enclosure tests, and eventual custom-PCB validation.
-
-
-
-
Recommended validation order
-
-
Repeat the 549,542-byte production capture in an independent bay with geometry measured before scoring.
-
Separate trigger coverage, matched-capture coverage, estimator coverage, and accuracy in the session report.
-
Score horizontal coverage, bias, MAE, P50, P75, and P90 before changing thresholds.
-
Test 14 loops only if 12 loops show a quality or coverage limitation worth spending another 91,584 bytes.
-
Use the remaining L3 budget for club-aware presets or sparse swing history only after ball-angle performance is protected.
-
-
-
-
Bottom line: the chip did not need more memory; it needed a better editor. On-chip HWA processing and frame-aware cropping let us keep the radar evidence that can affect the answer, discard distance slices the ball cannot occupy, and spend the recovered budget on a denser club-and-ball movie. TrackMan now decides whether that engineering improvement becomes a product improvement.
-
-
-
-
-
-
-
-
-
-
diff --git a/docs/javascripts/mathjax.js b/docs/javascripts/mathjax.js
new file mode 100644
index 000000000..b40e81143
--- /dev/null
+++ b/docs/javascripts/mathjax.js
@@ -0,0 +1,22 @@
+// Arithmatex in generic mode emits \(...\) and \[...\] inside .arithmatex
+// spans. Re-typeset on every instant-navigation page load, not just the first
+// — navigation.instant is enabled, so DOMContentLoaded fires only once.
+window.MathJax = {
+ tex: {
+ inlineMath: [["\\(", "\\)"]],
+ displayMath: [["\\[", "\\]"]],
+ processEscapes: true,
+ processEnvironments: true,
+ },
+ options: {
+ ignoreHtmlClass: ".*|",
+ processHtmlClass: "arithmatex",
+ },
+};
+
+document$.subscribe(() => {
+ MathJax.startup.output.clearCache();
+ MathJax.typesetClear();
+ MathJax.texReset();
+ MathJax.typesetPromise();
+});
diff --git a/docs/kld7-launch-angle-explained.html b/docs/kld7-launch-angle-explained.html
deleted file mode 100644
index 90ad6aaf5..000000000
--- a/docs/kld7-launch-angle-explained.html
+++ /dev/null
@@ -1,814 +0,0 @@
-
-
-
-
-
-How OpenFlight Measures Launch Angle
-
-
-
-
-
-
-
OpenFlight · K-LD7 Vertical Radar
-
How OpenFlight Measures Launch Angle
-
Why this is one of the hardest numbers to get out of a $40 radar, what the
- ground does to the signal, and how the two-ray method plus a
- two-tier confidence system turns a noisy reflection into a usable launch angle.
-
A plain-language explainer. No prior context needed. Physics terms are defined the first time they appear and collected in the glossary.
-
-
-
-
⚡ The 60-second version
-
We point a small Doppler radar up the ball's flight to measure its launch angle (how steeply it leaves the face). The problem: the radar also sees the ball's reflection off the floor — a mirror-image ball that appears below the surface, the way a tree hangs upside-down in a still lake. Those two signals blend together and drag the measured angle too low.
-
The two-ray method mathematically pulls the real ball apart from its floor-reflection. When that separation is clean — which we expect over a hard, flat surface (an indoor floor, or a mat on concrete) — we trust the geometry directly (Tier 1). When it isn't — typically a soft, scattering surface like grass — we fall back to a lower-confidence estimate and, if the shot reads suspiciously flat, nudge it up toward what that club normally launches (Tier 2). The surface, not the venue, is what really decides it — though so far only the indoor case is measured.
-
-
-
-
-
-
1The job & the hardware
-
Launch angle is the vertical angle the ball leaves the clubface. A 7-iron tour
-average is about 16°; a driver about 10°; a wedge can be 30°+. Get it wrong by a few
-degrees and the simulated carry distance is off by yards.
-
-
OpenFlight measures it with a K-LD7: a 24 GHz FSK Doppler radar the size of a matchbox.
-It sits low, near the tee, aimed up the flight line. Every frame it gives us two independent
-readings of the ball — an angle and a range — and the launch-angle pipeline leans on both.
-
-
-
-The setup, to scale. Radar 5 ft behind the ball, net 10 ft in front of it (15 ft from the
-radar). Over this short window the ball climbs in a near-straight line at its launch angle — the trajectory
-only curves into an arc much farther downrange. The slope of that climb is what we measure, from the
-two frames we typically get before the ball reaches the net (here at 25 ms and 54 ms — see limit ①).
-The dashed line is the radar's aim (boresight); the ball flies below it through this whole window. (A separate
-horizontal radar handles left/right aim; this document is only about the vertical launch-angle radar.)
-
-
-
Reading 1 · the angle — where the ball sits in the beam
-
The radar has two receive antennas; the tiny phase difference between them encodes the ball's
-elevation angle each frame (this is interferometry). Stack those angles over the first few feet and
-the rise of the climb is the launch angle. It's the headline number — and the one a floor reflection
-corrupts (sections 2–3).
-
-
Reading 2 · the range — how far the ball is
-
Being an FSK radar, the K-LD7 also reports the ball's distance (slant range) every frame, from
-the phase of a second transmit frequency. Three things make it the quiet workhorse of the whole method:
-
-
It's accurate — and largely multipath-immune. Range is a distance (time-of-flight), which the
- floor bounce barely shifts; the angle, by contrast, arrives off the floor from a completely different direction
- and gets wrecked. So the range is the one clean, trustworthy clock in the signal.
-
It pins the moment of impact. The ball's range grows steadily as it flies — extend that line back to
- the tee distance (~5 ft) and you've found when impact happened, the timing anchor everything else hangs
- off. (Range + elevation each frame also fix the ball's actual position, which is what Tier 1 fits a line through.)
-
It comes in sub-frames. Each ~29 ms frame is sliced into about a dozen overlapping short windows,
- each yielding its own range sample — finer timing than one reading per frame, which sharpens that impact anchor.
-
-
-
-One frame, many range samples. The radar steps 13 overlapping short windows across each
-~29 ms frame, and the frames run back-to-back with no gap; every window yields its own range dot (gold).
-That dense, climbing track is fit to a line and run back to the tee distance (~5 ft) to pin the moment of
-impact, and it stays below the 16.4 ft wrap indoors. Only the range sub-divides like this — the
-angle is read once per frame (the sub-frame fringe it carries is consumed by the two-ray fit in
-section 4 rather than kept as a track).
-
-
Range has just one catch — it wraps past 16.4 ft — which is limit ② below.
-
-
-
2Three hard limits of the K-LD7
-
Everything downstream exists to work around these. None of them is a bug — they're the
-physics of a cheap, short-range radar.
-
-
① You get one or two usable frames — and can't choose when
-
The radar free-runs at about 35 frames per second — one frame every ~29 ms, on its
-clock. Impact lands at a random point in that cycle, so we can't pick when frames arrive; "give me a
-frame at 20 ms" isn't an option. Indoors, with a net ~10 ft away, the ball is in clean view for only a
-fraction of a second — room for two frames at best.
-
-
Best case — 2 frames. Two frames land inside the clean window, e.g. 25 ms and
- 54 ms after impact (one 29 ms step apart). Two points plus the known tee define the climb —
- the only road into Tier 1.
-
Common case — 1 frame. If a frame fires right at contact, the ball is still on the tee in
- the radar's blind/clutter zone, so that slot is wasted — leaving a single usable frame. One frame can't
- show a climb, so it routes to a single-frame Tier 2 estimate.
-
-
There's no "collect more data" lever: 35 Hz is the radar's ceiling, and the impact-to-frame timing is
-luck of the draw.
-
-
-
-It's the luck of the timing. The 29 ms spacing is fixed by the radar, but where impact
-lands within that cycle is random shot-to-shot. Catch two frames in the clean window and Tier 1 is on the
-table; lose one to the moment of contact and you're left with a single-frame Tier 2 estimate. We can't
-request a frame at a chosen time.
-
-
-
② Range "wraps" past 16.4 ft
-
The radar measures distance using a phase that resets every cycle. At the 5 m range setting,
-that cycle covers 16.4 ft. A ball truly at 23 ft reports as 23 − 16.4 = 6.6 ft — it
-"wraps," like a clock hand passing 12. For close nets this never matters; for far nets or screens
-it does, which is why OpenFlight can un-wrap these readings when you tell it the net distance.
-
-
-
-The 16.4-ft clock. Anything past one full cycle folds back to the start. Knowing the
-physical net distance lets OpenFlight add the cycle back ("de-aliasing") instead of believing the folded value.
-
-
-
③ The ground corrupts the angle
-
That delicate interferometric phase — the angle reading from section 1 — is exactly what a reflection
-off the floor corrupts. It's the central problem, and it earns the next section.
-
-
-
3The core problem: two rays
-
A radar aimed low at a ball over a floor never sees just the ball. It sees the ball
-twice.
-
-
One signal travels straight to the ball and back: the direct ray. A second bounces off the floor
-on the way — the ground-reflected ray. To the radar, that reflected ray looks like it came from a
-mirror-image ball below the floor, exactly the way a tree is mirrored beneath the surface of a still lake.
-This is the classic two-ray ground-reflection situation.
-
-
-
-One ball, two echoes. The radar can't natively tell the direct ray from the floor-bounce.
-It reports a blend of the two angles — and because the image sits below the floor, that blend is
-pulled downward. The result: launch angles that read too flat.
-
-
-
This shows up as "suppression"
-
When the two echoes blend, a ball that truly launched at 17° can be measured at 9°. We call
-this suppression — the floor image quietly suppresses the apparent launch angle. It's worst when
-the ball is still low (early in flight), because that's when the real ball and its image are closest
-together and hardest to separate.
-
-
-
-Suppression in one picture. This is the systematic error the rest of the pipeline is built to
-detect and undo.
-
-
-
-
4Why "two-ray" demodulation
-
If the floor reflection is the problem, the fix is to model it on purpose and subtract it out —
-rather than pretend the radar sees only the ball.
-
-
The two-ray method treats each frame's signal as the sum of two pieces — a ball component and an
-image component — and solves for both at once. Out of that fit we get the one number that matters: the
-true ball elevation, with the floor image accounted for instead of contaminating it.
-
-
The fit also hands us a quality signal we lean on constantly:
-
-
maxsep — the angular separation between the ball and its image, at its largest across the
- shot's frames. Big separation = the two rays are cleanly distinct = we can trust the decomposition.
- Small separation = they're smeared together = don't trust it.
-
maxel — the highest elevation the ball was actually seen at. If even the ball's peak
- reading is low, the shot probably reads suppressed.
-
-
-
-
-The whole idea. Split the blend into ball + image; keep the ball; measure how far apart they
-were (maxsep) as a confidence signal. Large maxsep is what makes a trustworthy measurement possible.
-
-
-
-
5Why the same radar behaves differently indoors and outdoors
-
The two-ray method lives or dies on being able to separate the ball from its image. The
-surface under the ball decides whether that's possible.
-
-
-
-Surface decides everything. A hard indoor floor reflects like a mirror — one clean image, wide
-separation, the two-ray fit succeeds. Grass scatters the reflection — the image smears out, separation
-collapses, and the fit can no longer be trusted.
-
-
-
-
🔬 Careful what we claim here
-
What we've measured: indoor shots show maxsep ≈ 11°, outdoor shots ≈ 2.8°, and
- the outdoor fit error runs ~2.5× higher. Interestingly, the reflection's strength is about the
- same in both (the image is ~88% as strong either way) — it's the angular separation we can resolve that
- collapses outdoors, not the amount of reflected energy.
-
The specular-vs-diffuse story above is our working explanation for that, and it's
- consistent with the data — but the exact mechanism hasn't been nailed down. The behavior split is solid;
- the physics label on it is a hypothesis.
-
-
-
-
6Tier 1: a real measurement
-
When the separation is clean, we don't guess — we read the launch angle straight off the
-geometry. That's Tier 1, and it's the only tier we call a true measurement.
-
-
A shot earns Tier 1 only if all four of these hold:
-
-
Gate
Meaning
7-iron value
-
-
la_position exists
A range-based, timing-free angle could be fit
required
-
nval ≥ 2
At least 2 clean frames survived
≥ 2
-
maxsep ≥ 9°
Ball & image were well separated
≥ 9°
-
maxel ≥ tour−7.3°
Ball was seen at a believable height
≥ 9.0°
-
-
-
-
The angle itself comes from the position fit (la_position): draw the straight line from the
-fixed tee through the clean (range, elevation) points the radar measured. The slope of that line is the
-launch direction. No clock, no trajectory model, no extrapolation — just geometry anchored at a point we
-know exactly. Tier 1 ships with confidence 0.85.
-
-
-
-What a Tier-1 shot looks like. The realistic best case: two clean frames, the ball
-well above its floor image (wide maxsep), both anchored to the known tee. The line through tee + the two
-frames is the launch angle — confidence 0.85. (Two frames is as good as it gets — see limit ①.)
-
-
-
-
7Tier 2 & the boost
-
A shot lands in Tier 2 for one of two reasons: the ball/image separation collapsed
-(usually grass), or there was only one good frame — most often because a frame burned at contact (limit ①),
-so there's no second point to fit a climb. Either way we still want to show the player something, so Tier 2
-is a lower-confidence estimate.
-
-
The three outcomes map straight to the UI's confidence dots — Tier 1 → 3 dots
-(0.85), Tier 2 as-measured → 2 dots (0.65), Tier 2
-boosted → 1 dot (0.35) — an honest measured → estimated → corrected gradient. Tier 2 has two flavors:
-
-
-
-
Tier 2 · as-measured
-
Reading looks plausible
-
confidence 0.65 · 2 dots · no boost
-
The ball reached a believable height (maxel is not suspiciously
- low). We show the estimate as-is, just with lower confidence than Tier 1.
-
-
-
Tier 2 · boosted
-
Reading looks suppressed
-
confidence 0.35 · 1 dot · +boost applied
-
Even the ball's peak elevation is low (maxel < 0.43 × tour).
- That's the fingerprint of suppression, so we add a fixed boost toward the club's tour-average launch.
-
-
-
-
The boost is derived per club from its tour-average launch — no hand-tuning. For a 7-iron (tour 16.3°) the
-trigger is maxel < 7° and the boost is +4.0°. It's a blunt, fixed nudge: enough to undo the
-typical suppression, applied whenever the suppression fingerprint is present.
-
-
-
-Why the boost is a trade-off. Suppression and a genuinely thin/skulled shot produce the
-same low reading. The boost fixes the common case (suppression) but will lift a real thin shot too — we
-can't yet tell them apart. We keep the boost because, on validated data, it lowers overall error
-(1.56° with it vs 1.87° without).
-
-
-
-
⚠ The boost's known blind spot
-
A boosted Tier-2 number will read too high on a genuinely thin or skulled shot (by roughly the boost
- amount). That's a deliberate, measured trade: it's right far more often than it's wrong. Distinguishing
- "suppressed" from "actually thin" — e.g. via smash factor — is an open problem, not yet solved.
-
-
-
-
8The whole decision flow
-
Here's how a single shot travels from raw frames to the number on screen.
-
-
-
-Tier first, boost second. A clean shot is a measurement (Tier 1). Everything else is a
-lower-confidence estimate (Tier 2), boosted only when it carries the suppression fingerprint. Every club is
-tour-derived, so two_ray runs on all of them.
-
-
-
One more gate: the screen
-
The server has the final say on what's shown, checking the confidence against a display floor of
-0.65. Tier 1 (0.85) and as-measured Tier 2 (0.65) clear it and display as radar
-measurements. A boosted Tier 2 sits at 0.35 — below the floor on purpose: a boosted shot is barely a
-measurement, so in normal play the server shows the simple ball-speed-and-club formula instead. A
-test mode can bypass the floor to surface every shot the radar catches — that's when the boosted reading
-appears as its 1-dot self.
-
-
-
9What's proven vs. what's assumed
-
Three weeks of work taught us to be precise about confidence. Here's the honest ledger.
-
-
-
Solid
-
-
The 7-iron is TrackMan-validated: on the 6/15 session, Tier-1 shots hit 0.68° mean error. The 7-iron's gate, trigger, and boost are tuned to that ground truth.
-
The indoor/outdoor maxsep split (≈11° vs ≈2.8°) is measured and repeatable.
-
Keeping the boost is net-positive on validated data (1.56° vs 1.87° overall error).
-
-
-
-
-
Principled, but not yet ground-truthed
-
-
All clubs are tour-derived by one uniform formula (no hand-tuned overrides); the coefficients are
- seeded so it reproduces the 7-iron's TrackMan-validated config. Only the 7-iron has been checked
- against ground truth, so treat the other 19 as principled defaults to refine as data arrives.
-
The pitching wedge in particular measured steeper than the linear trend predicts, so its boost is
- likely a touch low — flagged for a per-club override if it reads flat in practice.
-
The specular-vs-diffuse explanation for the outdoor collapse is a working hypothesis, not a
- proven mechanism.
-
-
-
-
-
Known gaps
-
-
We can't yet distinguish a suppressed shot from a genuinely thin one — the boost helps the first and hurts the second.
-
Outdoors, there's no validated Tier-1 path — grass rarely produces the clean separation Tier-1 requires, so outdoor shots lean on Tier-2.
-
Very fast / very low shots can hit the radar's blind spots (the DC clutter zone and the range wrap) and get refused.
-
Low-launch clubs (driver, woods) are the toughest case — and untested. A ~10° ball climbs only
- ~1.8 ft over the window, so it never gets far from its floor image and the maxsep Tier 1 needs
- (≥9°, the same for every club) barely develops; at 150–165 mph it also clears the net in ~1.5 frames, so even
- getting two clean frames is hard. Expect these clubs to sit in Tier 2 — often single-frame — on a small
- (~2.5°), unvalidated boost. This is reasoning, not data: no driver/wood shots have been collected yet.
-
-
-
-
-
10Glossary
-
-
-
Launch angle
Vertical angle the ball leaves the clubface. The number this whole pipeline exists to produce.
-
Elevation
The ball's vertical angle as seen by the radar in a given frame. Launch angle is reconstructed from how elevation climbs.
-
Boresight
The direction the radar is actually pointed (mount tilt + offset). Angles are measured relative to it.
-
Two-ray / multipath
The radar receives a direct echo and a floor-reflected echo. The reflection mimics a mirror-image ball below the floor.
-
Suppression
The downward bias on measured launch angle caused by the floor image blending with the ball.
-
maxsep
Largest angular separation between ball and image across a shot's frames. The two-ray method's core confidence signal — big = trustworthy.
-
maxel
Highest elevation the ball was seen at. A low maxel is the fingerprint of a suppressed shot.
-
la_position
Timing-free launch angle from fitting a line through the tee and the measured (range, elevation) points. Tier-1's output.
-
Specular vs. diffuse
A mirror-like (smooth/hard) reflection vs. a scattered (rough/grassy) one. Our explanation for why indoor separates and outdoor doesn't.
-
FSK range wrap
The radar's distance reading resets every 16.4 ft (at the 5 m setting), so far balls fold back to small values until "de-aliased."
-
Tier 1 / Tier 2
Trusted measurement (0.85 → 3 dots) vs. lower-confidence estimate: Tier-2 is 0.65 (2 dots) as-measured, or 0.35 (1 dot) when boosted toward tour average because it reads suppressed.
-
-
-
-
-
Scope: the K-LD7 vertical (launch-angle) radar. Companion deep-dives in this repo:
-kld7.md (setup & usage), kld7-ball-detection-theory.md,
-kld7-subframe-stft-findings.md. Tier thresholds and the boost live in
-src/openflight/kld7/two_ray.py (classify_two_ray_tier). This is an explainer, not a spec —
-the code is the source of truth.
-
-
-
-
diff --git a/docs/kld7-ball-detection-theory.md b/docs/legacy/ball-detection-theory.md
similarity index 97%
rename from docs/kld7-ball-detection-theory.md
rename to docs/legacy/ball-detection-theory.md
index 363fc3473..7d06f0a6e 100644
--- a/docs/kld7-ball-detection-theory.md
+++ b/docs/legacy/ball-detection-theory.md
@@ -1,5 +1,12 @@
# K-LD7 Ball Detection Theory
+!!! warning "DEPRECATED HARDWARE"
+
+ The K-LD7 angle radars are deprecated. The supported angle radar is the
+ **TI IWR6843** — see the [IWR6843 operator guide](../iwr6843/index.md).
+ Do not buy K-LD7s for a new build; this page is kept for existing builds
+ and for reading historical session logs.
+
> **⚠️ DEPRECATED:** The K-LD7 angle radars are deprecated — OpenFlight has moved to a more capable radar chip for angle measurement. This document is kept for existing K-LD7 builds only.
## Problem
diff --git a/docs/kld7.md b/docs/legacy/index.md
similarity index 87%
rename from docs/kld7.md
rename to docs/legacy/index.md
index d88f5fb44..df65581c4 100644
--- a/docs/kld7.md
+++ b/docs/legacy/index.md
@@ -1,7 +1,11 @@
# K-LD7 Launch Angle & Direction
-> **Deprecated:** K-LD7 support is retained for existing builds. New builds
-> should use the [IWR6843 setup](iwr6843/README.md).
+!!! warning "DEPRECATED HARDWARE"
+
+ The K-LD7 angle radars are deprecated. The supported angle radar is the
+ **TI IWR6843** — see the [IWR6843 operator guide](../iwr6843/index.md).
+ Do not buy K-LD7s for a new build; this page is kept for existing builds
+ and for reading historical session logs.
The K-LD7 radars measure the ball's **launch angle** (vertical) and
**launch direction** (horizontal aim) to complement the OPS243's ball
@@ -13,13 +17,13 @@ flag.
estimator, which models the indoor ground-bounce multipath directly.
Indoor accuracy is ~2–3° on irons and wedges.
- **Launch direction (horizontal)** — uses the legacy estimator (see
- [Horizontal / aim](#horizontal--aim) below).
+ [Horizontal / aim](#horizontal-aim) below).
- Both axes are filtered by the OPS243 ball speed and correlated to the
shot via the OPS impact timestamp.
For how the estimator works internally, see
-[kld7-launch-angle-explained.html](kld7-launch-angle-explained.html) and
-[kld7-ball-detection-theory.md](kld7-ball-detection-theory.md).
+[Launch angle explained](launch-angle-explained.md) and
+[kld7-ball-detection-theory.md](ball-detection-theory.md).
## Enabling it
@@ -97,7 +101,7 @@ uv run python scripts/analysis/session_shot_report.py \
```
> An older text/CSV review workflow also exists
-> ([kld7-session-review.md](kld7-session-review.md)); the HTML report
+> ([kld7-session-review.md](session-review.md)); the HTML report
> above is the recommended reviewer.
## Horizontal / aim
@@ -112,7 +116,7 @@ by the two-ray pipeline.
## Troubleshooting
-See [kld7-troubleshooting.md](kld7-troubleshooting.md) for detection and
+See [kld7-troubleshooting.md](troubleshooting.md) for detection and
serial issues. For validation sessions and offline tooling, see the
-[TrackMan test process](trackman-test-process.md) and
-[K-LD7 analysis tools](../scripts/analysis/kld7_analysis_tooling.md).
+[TrackMan test process](../development/trackman-testing.md) and
+[K-LD7 analysis tools](../development/analysis-tooling.md).
diff --git a/docs/legacy/launch-angle-explained.md b/docs/legacy/launch-angle-explained.md
new file mode 100644
index 000000000..804425826
--- /dev/null
+++ b/docs/legacy/launch-angle-explained.md
@@ -0,0 +1,210 @@
+---
+icon: lucide/triangle
+---
+
+# How OpenFlight Measures Launch Angle
+
+!!! warning "DEPRECATED HARDWARE"
+
+ This explains the **K-LD7** launch-angle method. The K-LD7 angle radars are
+ deprecated; the supported angle radar is the TI IWR6843 — see
+ [Launch angle](../how-it-works/launch-angle.md) for the current pipeline.
+ Kept because the two-ray multipath reasoning here still explains why indoor
+ launch angle is hard.
+
+Why this is one of the hardest numbers to get out of a $40 radar, what the ground does to the signal, and how the **two-ray** method plus a two-tier confidence system turns a noisy reflection into a usable launch angle.
+
+A plain-language explainer. No prior context needed. Physics terms are defined the first time they appear and collected in the glossary.
+
+We point a small Doppler radar up the ball's flight to measure its **launch angle** (how steeply it leaves the face). The problem: the radar also sees the ball's **reflection off the floor** — a mirror-image ball that appears below the surface, the way a tree hangs upside-down in a still lake. Those two signals blend together and drag the measured angle **too low**.
+
+The **two-ray method** mathematically pulls the real ball apart from its floor-reflection. When that separation is clean — which we expect over a **hard, flat surface** (an indoor floor, or a mat on concrete) — we trust the geometry directly (**Tier 1**). When it isn't — typically a **soft, scattering surface** like grass — we fall back to a lower-confidence estimate and, if the shot reads suspiciously flat, **nudge it up** toward what that club normally launches (**Tier 2**). The surface, not the venue, is what really decides it — though so far only the indoor case is measured.
+
+## 1. The job & the hardware
+
+Launch angle is the vertical angle the ball leaves the clubface. A 7-iron tour average is about **16°**; a driver about **10°**; a wedge can be **30°+**. Get it wrong by a few degrees and the simulated carry distance is off by yards.
+
+OpenFlight measures it with a **K-LD7**: a 24 GHz **FSK Doppler radar** the size of a matchbox. It sits low, near the tee, **aimed up the flight line**. Every frame it gives us **two independent readings** of the ball — an **angle** and a **range** — and the launch-angle pipeline leans on both.
+
+
+
+*The setup, to scale.* Radar 5 ft behind the ball, net 10 ft in front of it (15 ft from the radar). Over this short window the ball climbs in a near-straight line at its launch angle — the trajectory only curves into an arc much farther downrange. The slope of that climb is what we measure, from the **two frames** we typically get before the ball reaches the net (here at 25 ms and 54 ms — see limit ①). The dashed line is the radar's aim (boresight); the ball flies below it through this whole window. (A separate horizontal radar handles left/right aim; this document is only about the **vertical** launch-angle radar.)
+
+### Reading 1 · the angle — where the ball sits in the beam
+
+The radar has **two receive antennas**; the tiny **phase difference** between them encodes the ball's **elevation angle** each frame (this is **interferometry**). Stack those angles over the first few feet and the **rise** of the climb is the launch angle. It's the headline number — and the one a floor reflection corrupts (sections 2–3).
+
+### Reading 2 · the range — how far the ball is
+
+Being an **FSK** radar, the K-LD7 also reports the ball's **distance** (slant range) every frame, from the phase of a second transmit frequency. Three things make it the quiet workhorse of the whole method:
+
+- **It's accurate — and largely multipath-immune.** Range is a *distance* (time-of-flight), which the floor bounce barely shifts; the angle, by contrast, arrives off the floor from a completely different direction and gets wrecked. So the range is the one **clean, trustworthy clock** in the signal.
+- **It pins the moment of impact.** The ball's range grows steadily as it flies — extend that line back to the tee distance (~5 ft) and you've found **when impact happened**, the timing anchor everything else hangs off. (Range + elevation each frame also fix the ball's actual position, which is what Tier 1 fits a line through.)
+- **It comes in sub-frames.** Each ~29 ms frame is sliced into about a dozen **overlapping short windows**, each yielding its own range sample — finer timing than one reading per frame, which sharpens that impact anchor.
+
+
+
+*One frame, many range samples.* The radar steps 13 **overlapping short windows** across each ~29 ms frame, and the frames run **back-to-back with no gap**; every window yields its own range dot (gold). That dense, climbing track is fit to a line and run back to the tee distance (~5 ft) to pin the moment of impact, and it stays below the 16.4 ft wrap indoors. Only the **range** sub-divides like this — the **angle** is read once per frame (the sub-frame fringe it carries is consumed by the two-ray fit in section 4 rather than kept as a track).
+
+Range has just one catch — it **wraps** past 16.4 ft — which is limit ② below.
+
+## 2. Three hard limits of the K-LD7
+
+Everything downstream exists to work around these. None of them is a bug — they're the physics of a cheap, short-range radar.
+
+### ① You get one or two usable frames — and can't choose when
+
+The radar free-runs at about **35 frames per second** — one frame every **~29 ms**, on *its* clock. Impact lands at a random point in that cycle, so **we can't pick when frames arrive**; "give me a frame at 20 ms" isn't an option. Indoors, with a net ~10 ft away, the ball is in clean view for only a fraction of a second — room for **two frames at best**.
+
+- **Best case — 2 frames.** Two frames land inside the clean window, e.g. **25 ms** and **54 ms** after impact (one 29 ms step apart). Two points plus the known tee define the climb — the only road into **Tier 1**.
+- **Common case — 1 frame.** If a frame fires **right at contact**, the ball is still on the tee in the radar's blind/clutter zone, so that slot is wasted — leaving a single usable frame. One frame can't show a climb, so it routes to a single-frame **Tier 2** estimate.
+
+There's no "collect more data" lever: 35 Hz is the radar's ceiling, and the impact-to-frame timing is luck of the draw.
+
+
+
+*It's the luck of the timing.* The 29 ms spacing is fixed by the radar, but where impact lands within that cycle is random shot-to-shot. Catch two frames in the clean window and Tier 1 is on the table; lose one to the moment of contact and you're left with a single-frame Tier 2 estimate. We can't request a frame at a chosen time.
+
+### ② Range "wraps" past 16.4 ft
+
+The radar measures distance using a phase that resets every cycle. At the 5 m range setting, that cycle covers **16.4 ft**. A ball truly at 23 ft reports as 23 − 16.4 = **6.6 ft** — it "wraps," like a clock hand passing 12. For close nets this never matters; for **far nets or screens** it does, which is why OpenFlight can *un-wrap* these readings when you tell it the net distance.
+
+
+
+*The 16.4-ft clock.* Anything past one full cycle folds back to the start. Knowing the physical net distance lets OpenFlight add the cycle back ("de-aliasing") instead of believing the folded value.
+
+### ③ The ground corrupts the angle
+
+That delicate interferometric phase — the angle reading from section 1 — is exactly what a reflection off the floor corrupts. It's the central problem, and it earns the next section.
+
+## 3. The core problem: two rays
+
+A radar aimed low at a ball over a floor never sees just the ball. It sees the ball **twice**.
+
+One signal travels straight to the ball and back: the **direct ray**. A second bounces off the floor on the way — the **ground-reflected ray**. To the radar, that reflected ray looks like it came from a **mirror-image ball below the floor**, exactly the way a tree is mirrored beneath the surface of a still lake. This is the classic **two-ray ground-reflection** situation.
+
+
+
+*One ball, two echoes.* The radar can't natively tell the direct ray from the floor-bounce. It reports a **blend** of the two angles — and because the image sits *below* the floor, that blend is pulled **downward**. The result: launch angles that read too flat.
+
+### This shows up as "suppression"
+
+When the two echoes blend, a ball that truly launched at **17°** can be measured at **9°**. We call this **suppression** — the floor image quietly suppresses the apparent launch angle. It's worst when the ball is still low (early in flight), because that's when the real ball and its image are closest together and hardest to separate.
+
+
+
+*Suppression in one picture.* This is the systematic error the rest of the pipeline is built to detect and undo.
+
+## 4. Why "two-ray" demodulation
+
+If the floor reflection is the problem, the fix is to **model it on purpose** and subtract it out — rather than pretend the radar sees only the ball.
+
+The **two-ray method** treats each frame's signal as the sum of two pieces — a ball component and an image component — and solves for both at once. Out of that fit we get the one number that matters: the **true ball elevation**, with the floor image accounted for instead of contaminating it.
+
+The fit also hands us a quality signal we lean on constantly:
+
+- **maxsep** — the angular **separation** between the ball and its image, at its largest across the shot's frames. Big separation = the two rays are cleanly distinct = we can trust the decomposition. Small separation = they're smeared together = don't trust it.
+- **maxel** — the highest **elevation** the ball was actually seen at. If even the ball's peak reading is low, the shot probably reads suppressed.
+
+
+
+*The whole idea.* Split the blend into ball + image; keep the ball; measure how far apart they were (**maxsep**) as a confidence signal. Large maxsep is what makes a trustworthy measurement possible.
+
+## 5. Why the same radar behaves differently indoors and outdoors
+
+The two-ray method lives or dies on being able to **separate** the ball from its image. The surface under the ball decides whether that's possible.
+
+
+
+*Surface decides everything.* A hard indoor floor reflects like a mirror — one clean image, wide separation, the two-ray fit succeeds. Grass scatters the reflection — the image smears out, separation collapses, and the fit can no longer be trusted.
+
+What we've **measured**: indoor shots show `maxsep ≈ 11°`, outdoor shots `≈ 2.8°`, and the outdoor fit error runs **~2.5× higher**. Interestingly, the reflection's *strength* is about the same in both (the image is ~88% as strong either way) — it's the **angular separation we can resolve** that collapses outdoors, not the amount of reflected energy.
+
+The **specular-vs-diffuse** story above is our working *explanation* for that, and it's consistent with the data — but the exact mechanism hasn't been nailed down. The behavior split is solid; the physics label on it is a hypothesis.
+
+## 6. Tier 1: a real measurement
+
+When the separation is clean, we don't guess — we read the launch angle straight off the geometry. That's **Tier 1**, and it's the only tier we call a true measurement.
+
+A shot earns Tier 1 only if **all four** of these hold:
+
+| Gate | Meaning | 7-iron value |
+| --- | --- | --- |
+| `la_position` exists | A range-based, timing-free angle could be fit | required |
+| `nval ≥ 2` | At least 2 clean frames survived | ≥ 2 |
+| `maxsep ≥ 9°` | Ball & image were well separated | ≥ 9° |
+| `maxel ≥ tour−7.3°` | Ball was seen at a believable height | ≥ 9.0° |
+
+The angle itself comes from the **position fit** (`la_position`): draw the straight line from the fixed tee through the clean (range, elevation) points the radar measured. The slope of that line **is** the launch direction. No clock, no trajectory model, no extrapolation — just geometry anchored at a point we know exactly. Tier 1 ships with **confidence 0.85**.
+
+
+
+*What a Tier-1 shot looks like.* The realistic best case: **two** clean frames, the ball well above its floor image (wide maxsep), both anchored to the known tee. The line through tee + the two frames is the launch angle — confidence 0.85. (Two frames is as good as it gets — see limit ①.)
+
+## 7. Tier 2 & the boost
+
+A shot lands in **Tier 2** for one of two reasons: the ball/image **separation collapsed** (usually grass), or there was **only one good frame** — most often because a frame burned at contact (limit ①), so there's no second point to fit a climb. Either way we still want to show the player *something*, so Tier 2 is a lower-confidence estimate.
+
+The three outcomes map straight to the UI's confidence dots — **Tier 1 → 3 dots** (0.85), **Tier 2 as-measured → 2 dots** (0.65), **Tier 2 boosted → 1 dot** (0.35) — an honest *measured → estimated → corrected* gradient. Tier 2 has two flavors:
+
+#### Reading looks plausible
+
+The ball reached a believable height (`maxel` is not suspiciously low). We show the estimate as-is, just with lower confidence than Tier 1.
+
+#### Reading looks suppressed
+
+Even the ball's *peak* elevation is low (`maxel < 0.43 × tour`). That's the fingerprint of suppression, so we **add a fixed boost** toward the club's tour-average launch.
+
+The boost is derived per club from its tour-average launch — no hand-tuning. For a 7-iron (tour 16.3°) the trigger is `maxel < 7°` and the boost is **+4.0°**. It's a blunt, fixed nudge: enough to undo the typical suppression, applied whenever the suppression fingerprint is present.
+
+
+
+*Why the boost is a trade-off.* Suppression and a genuinely thin/skulled shot produce the *same* low reading. The boost fixes the common case (suppression) but will lift a real thin shot too — we can't yet tell them apart. We keep the boost because, on validated data, it lowers overall error (**1.56°** with it vs **1.87°** without).
+
+A boosted Tier-2 number will read **too high on a genuinely thin or skulled shot** (by roughly the boost amount). That's a deliberate, measured trade: it's right far more often than it's wrong. Distinguishing "suppressed" from "actually thin" — e.g. via smash factor — is an open problem, not yet solved.
+
+## 8. The whole decision flow
+
+Here's how a single shot travels from raw frames to the number on screen.
+
+
+
+*Tier first, boost second.* A clean shot is a measurement (Tier 1). Everything else is a lower-confidence estimate (Tier 2), boosted only when it carries the suppression fingerprint. Every club is tour-derived, so two_ray runs on all of them.
+
+### One more gate: the screen
+
+The server has the final say on what's *shown*, checking the confidence against a display floor of **0.65**. **Tier 1 (0.85)** and **as-measured Tier 2 (0.65)** clear it and display as radar measurements. A **boosted Tier 2 sits at 0.35 — below the floor on purpose**: a boosted shot is barely a measurement, so in normal play the server shows the simple **ball-speed-and-club formula** instead. A **test mode** can bypass the floor to surface every shot the radar catches — that's when the boosted reading appears as its 1-dot self.
+
+## 9. What's proven vs. what's assumed
+
+Three weeks of work taught us to be precise about confidence. Here's the honest ledger.
+
+- The **7-iron is TrackMan-validated**: on the 6/15 session, Tier-1 shots hit **0.68° mean error**. The 7-iron's gate, trigger, and boost are tuned to that ground truth.
+- The **indoor/outdoor maxsep split** (≈11° vs ≈2.8°) is measured and repeatable.
+- Keeping the **boost is net-positive** on validated data (1.56° vs 1.87° overall error).
+
+- **All clubs are tour-derived** by one uniform formula (no hand-tuned overrides); the coefficients are seeded so it reproduces the 7-iron's TrackMan-validated config. Only the **7-iron** has been checked against ground truth, so treat the other 19 as principled defaults to refine as data arrives.
+- The **pitching wedge** in particular measured steeper than the linear trend predicts, so its boost is likely a touch low — flagged for a per-club override if it reads flat in practice.
+- The **specular-vs-diffuse** explanation for the outdoor collapse is a working hypothesis, not a proven mechanism.
+
+- We **can't yet distinguish a suppressed shot from a genuinely thin one** — the boost helps the first and hurts the second.
+- Outdoors, there's **no validated Tier-1 path** — grass rarely produces the clean separation Tier-1 requires, so outdoor shots lean on Tier-2.
+- Very fast / very low shots can hit the radar's blind spots (the DC clutter zone and the range wrap) and get refused.
+- **Low-launch clubs (driver, woods) are the toughest case — and untested.** A ~10° ball climbs only ~1.8 ft over the window, so it never gets far from its floor image and the `maxsep` Tier 1 needs (≥9°, the same for every club) barely develops; at 150–165 mph it also clears the net in ~1.5 frames, so even getting two clean frames is hard. Expect these clubs to sit in Tier 2 — often single-frame — on a small (~2.5°), unvalidated boost. This is reasoning, not data: no driver/wood shots have been collected yet.
+
+## 10. Glossary
+
+| **Launch angle** | Vertical angle the ball leaves the clubface. The number this whole pipeline exists to produce. |
+| --- | --- |
+| **Elevation** | The ball's vertical angle *as seen by the radar* in a given frame. Launch angle is reconstructed from how elevation climbs. |
+| **Boresight** | The direction the radar is actually pointed (mount tilt + offset). Angles are measured relative to it. |
+| **Two-ray / multipath** | The radar receives a direct echo *and* a floor-reflected echo. The reflection mimics a mirror-image ball below the floor. |
+| **Suppression** | The downward bias on measured launch angle caused by the floor image blending with the ball. |
+| **maxsep** | Largest angular separation between ball and image across a shot's frames. The two-ray method's core confidence signal — big = trustworthy. |
+| **maxel** | Highest elevation the ball was seen at. A low maxel is the fingerprint of a suppressed shot. |
+| **la_position** | Timing-free launch angle from fitting a line through the tee and the measured (range, elevation) points. Tier-1's output. |
+| **Specular vs. diffuse** | A mirror-like (smooth/hard) reflection vs. a scattered (rough/grassy) one. Our explanation for why indoor separates and outdoor doesn't. |
+| **FSK range wrap** | The radar's distance reading resets every 16.4 ft (at the 5 m setting), so far balls fold back to small values until "de-aliased." |
+| **Tier 1 / Tier 2** | Trusted measurement (0.85 → 3 dots) vs. lower-confidence estimate: Tier-2 is 0.65 (2 dots) as-measured, or 0.35 (1 dot) when boosted toward tour average because it reads suppressed. |
+
+Scope: the K-LD7 **vertical** (launch-angle) radar. Companion deep-dives in this repo: `kld7.md` (setup & usage), `kld7-ball-detection-theory.md`, `kld7-subframe-stft-findings.md`. Tier thresholds and the boost live in `src/openflight/kld7/two_ray.py` (`classify_two_ray_tier`). This is an explainer, not a spec — the code is the source of truth.
diff --git a/docs/kld7-session-review.md b/docs/legacy/session-review.md
similarity index 92%
rename from docs/kld7-session-review.md
rename to docs/legacy/session-review.md
index 625c512d9..dd6634ac9 100644
--- a/docs/kld7-session-review.md
+++ b/docs/legacy/session-review.md
@@ -1,5 +1,12 @@
# K-LD7 Session Review
+!!! warning "DEPRECATED HARDWARE"
+
+ The K-LD7 angle radars are deprecated. The supported angle radar is the
+ **TI IWR6843** — see the [IWR6843 operator guide](../iwr6843/index.md).
+ Do not buy K-LD7s for a new build; this page is kept for existing builds
+ and for reading historical session logs.
+
> **⚠️ DEPRECATED:** The K-LD7 angle radars are deprecated — OpenFlight has moved to a more capable radar chip for angle measurement. This workflow is kept for existing K-LD7 builds and historical session logs only.
This workflow is for reviewing a full `session_logs/session_*.jsonl` file after a K-LD7 tuning change.
@@ -128,7 +135,7 @@ Quick filter while inspecting a session:
grep '"type": "error"' session_logs/session_*.jsonl
```
-In Grafana/Loki (see [observability.md](observability.md)):
+In Grafana/Loki (see [observability.md](../using/observability.md)):
```logql
{app="openflight", log_type="error"}
diff --git a/docs/kld7-troubleshooting.md b/docs/legacy/troubleshooting.md
similarity index 96%
rename from docs/kld7-troubleshooting.md
rename to docs/legacy/troubleshooting.md
index efdc1e1b5..2bacf47a7 100644
--- a/docs/kld7-troubleshooting.md
+++ b/docs/legacy/troubleshooting.md
@@ -1,5 +1,12 @@
# K-LD7 Troubleshooting Guide
+!!! warning "DEPRECATED HARDWARE"
+
+ The K-LD7 angle radars are deprecated. The supported angle radar is the
+ **TI IWR6843** — see the [IWR6843 operator guide](../iwr6843/index.md).
+ Do not buy K-LD7s for a new build; this page is kept for existing builds
+ and for reading historical session logs.
+
> **⚠️ DEPRECATED:** The K-LD7 angle radars are deprecated — OpenFlight has moved to a more capable radar chip for angle measurement. This guide is kept for existing K-LD7 builds only.
Common issues with the K-LD7 angle radar and how to resolve them.
@@ -19,7 +26,7 @@ Two K-LD7 radars measure independent angle planes:
| **Different USB controllers** | Spread the two K-LD7s across different USB *controllers* on the Pi, not just different ports. Two K-LD7s sharing one xHCI controller can starve each other at 3 Mbaud. See [USB bus arrangement](#usb-bus-arrangement) below. |
| **FTDI latency_timer=1ms** | The FTDI Linux default can be `16ms`; install `sudo scripts/setup/setup_kld7_latency.sh` and verify both K-LD7 startup logs show `latency_timer=1ms`. |
| **Different base frequencies** | Vertical: RBFR=0 (24.05 GHz), Horizontal: RBFR=2 (24.25 GHz). Set automatically by the server. |
-| **Stable device names** | Use udev rules to prevent port swaps on reboot (see [setup guide](raspberry-pi-setup.md#k-ld7-device-names-deprecated-hardware)) |
+| **Stable device names** | Use udev rules to prevent port swaps on reboot (see [setup guide](../setup/raspberry-pi.md#k-ld7-device-names-deprecated-hardware)) |
### USB bus arrangement
@@ -89,7 +96,7 @@ scripts/start-kiosk.sh --kld7 --kld7-mount-tilt
Measure the vertical radar face with a phone inclinometer; a guessed tilt
directly biases the launch angle. The script uses `/dev/kld7_vertical` and
auto-enables `/dev/kld7_horizontal` when that symlink exists. Use the explicit
-port and geometry flags in [K-LD7 setup](kld7.md) only when your rig differs
+port and geometry flags in [K-LD7 setup](index.md) only when your rig differs
from the defaults.
### Horizontal radar mounting
diff --git a/docs/reference/cli.md b/docs/reference/cli.md
new file mode 100644
index 000000000..6f57e9259
--- /dev/null
+++ b/docs/reference/cli.md
@@ -0,0 +1,200 @@
+---
+icon: lucide/terminal
+---
+
+# CLI Flags
+
+Every flag accepted by the server, grouped by subsystem.
+
+`scripts/start-kiosk.sh` accepts most of these and forwards them. Use
+`--dry-run` to print the exact command the script would run:
+
+```bash
+scripts/start-kiosk.sh --iwr6843 --dry-run
+```
+
+!!! note "Generated from the source"
+
+ This table is derived from the argparse definitions in
+ `src/openflight/server.py`. If a flag here disagrees with the code, the
+ code is right — please open an issue.
+
+## Server & web
+
+Binding, ports, and debug output.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--mock`, `-m` | flag | Run in mock mode without radar |
+| `--mock-swing-speed` | flag | Run swing speed training mode with simulated reps and no OPS radar |
+| `--host` | default `0.0.0.0` | Host to bind to (default: 0.0.0.0) |
+| `--web-port` | int; default `8080` | Web server port (default: 8080) |
+| `--debug`, `-d` | flag | Enable verbose FFT/CFAR debug output |
+| `--radar-log` | flag | Log raw radar data to console (Python logging) |
+| `--show-raw` | flag | Show raw radar readings in console (signed values) |
+
+## OPS243 radar & transport
+
+Serial port, baud, and sample rate.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--port`, `-p` | — | Serial port for radar |
+| `--ops-baud` | int | — |
+| `--sample-rate` | int; default `30` | Radar sample rate in ksps (default: 30). Lower = longer buffer but lower max speed. 25=174mph/164ms, 27=187mph/152ms |
+
+## Trigger & capture
+
+How a capture is initiated and framed.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--trigger` | choices: `polling`, `threshold`, `speed`, `sound`; default `polling` | Trigger strategy (default: polling) |
+| `--sound-pre-trigger` | int; default `16` | Pre-trigger segments S#n, 0-32 (default: 16 = 50/50 split, each segment ~4.27ms at 30ksps) |
+
+## IWR6843 angle radar
+
+The supported angle radar.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--iwr6843` | flag | Enable TI IWR6843 L3 capture and LCMF-v1 vertical launch angle |
+| `--iwr6843-port` | — | TI serial port (auto-detect by default) |
+| `--iwr6843-config` | default `config/iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg` | TI RF config matching the flashed L3 firmware |
+| `--iwr6843-cal` | default `config/iwr6843_calibration_reference.json` | TI complex array/range calibration JSON |
+| `--iwr6843-trigger-pin` | int; default `17` | BCM GPIO receiving the shared sound-trigger edge (default: 17) |
+| `--iwr6843-tee-m` | float; default `1.575` | Antenna-center to tee slant range in metres (default: 1.575) |
+| `--iwr6843-net-m` | float; default `4.6` | Antenna-center to net range in metres (default: 4.6) |
+| `--iwr6843-tilt-deg` | float | Override mount tilt from the TI calibration JSON |
+| `--iwr6843-radar-height-m` | float | Override antenna-center height from the TI calibration JSON |
+| `--iwr6843-ball-height-m` | float; default `0.04` | Ball-center height above the floor/mat (default: 0.040) |
+| `--iwr6843-tx-order` | choices: `auto`, `normal`, `reversed`; default `auto` | TI TDM chirp order; auto reads the chirp masks from the cfg |
+| `--iwr6843-capture-timeout` | float; default `12.0` | Maximum seconds an OPS shot waits for its TI UART dump (default: 12) |
+| `--iwr6843-output-dir` | — | Raw TI dump directory when --debug is enabled (default: /iwr6843) |
+| `--iwr6843-azimuth-offset-deg` | float | Azimuth of the radar boresight relative to the target line, in degrees. Positive means boresight points right of the target line. Added to the measured club path; 0 reports club path relative to boresight. |
+| `--iwr6843-horizontal-phase-reference-rad` | float | Static target-line phase measured by horizontal aim calibration. Subtracted from the TX2 horizontal proxy before angle conversion. |
+
+## Inclinometer
+
+LIS3DH enclosure tilt compensation.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--inclinometer` | flag | Enable LIS3DH enclosure pitch compensation for IWR6843 tilt |
+| `--inclinometer-zero-offset` | float | Degrees added to raw LIS3DH pitch (default: 0) |
+
+## Ballistics & spin
+
+Carry model and spin handling.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--ballistics` | flag | Use the physics-based carry simulator (drag + Magnus, RK4). This is the default; shots without a vertical launch angle fall back to the legacy table estimator. |
+| `--no-ballistics` | flag | Disable the physics simulator and use the legacy carry table for all shots. |
+| `--calculated-spin` | flag | Replace radar-measured spin with the kinematic estimate (170*v*sin(LA)^1.2) when the launch angle was measured. The 24 GHz OPS return carries no usable spin line (see src/openflight/spin_estimate.py); the measured value is kept in spin_rpm_measured for offline scoring |
+
+## Swing speed
+
+Club-only training mode.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--swing-speed` | flag | Run club-only swing speed training mode (no impact or ball required) |
+| `--swing-speed-threshold` | float; default `30.0` | Outbound speed threshold that starts a swing speed rep (default: 30 mph) |
+| `--swing-speed-max` | float; default `130.0` | Maximum plausible swing speed accepted from OPS reports; use 0 to disable (default: 130 mph) |
+| `--swing-speed-min-readings` | int; default `3` | Minimum qualifying radar readings required to count a swing speed rep (default: 3) |
+| `--swing-speed-single-peak` | float; default `60.0` | Peak speed that can count as a swing from one radar reading (default: 60 mph) |
+| `--swing-speed-num-reports` | int; default `8` | Number of OPS speed candidates to report per sample cycle (default: 8) |
+| `--swing-speed-end-ms` | float; default `1000.0` | Milliseconds below threshold before ending a swing speed rep (default: 1000) |
+| `--swing-speed-cooldown-ms` | float; default `750.0` | Cooldown after a swing speed rep before accepting another (default: 750) |
+| `--swing-speed-rejected-cooldown-ms` | float; default `100.0` | Cooldown after an ignored short motion before re-arming (default: 100) |
+
+## Logging & session data
+
+Where session logs go and what they capture.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--session-location`, `-l` | default `range` | Location identifier for session logs (e.g., 'range', 'course', 'home') |
+| `--log-dir` | — | Directory for session logs (default: ~/openflight_sessions) |
+| `--no-logging` | flag | Disable session logging |
+
+## Simulators & power
+
+Outbound connectors and battery status.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--battery` | — | Show battery and external-power status using the selected provider |
+| `--sim` | flag | Enable simulator connectors from config/sim.json (GSPro / OpenGolfSim). Off by default. |
+
+## Camera (experimental)
+
+Disabled in the production kiosk. See [camera & YOLO](../development/camera-yolo.md).
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--no-camera` | flag | Disable camera (auto-enabled if available) |
+| `--camera-model` | — | Path to YOLO model for ball detection (uses Hough by default) |
+| `--camera-imgsz` | int; default `256` | YOLO inference input size (256 for speed, 640 for accuracy) |
+| `--hough-param2` | int; default `33` | Hough accumulator threshold (lower = more sensitive, default 33) |
+| `--hough-param1` | int; default `48` | Canny edge threshold (lower = detects weaker edges, default 48) |
+| `--hough-min-radius` | int; default `4` | Min ball radius in pixels (default 4) |
+| `--hough-max-radius` | int; default `43` | Max ball radius in pixels (default 43) |
+| `--hough-min-dist` | int; default `266` | Min distance between detected circles in pixels (default 266) |
+| `--roboflow-model` | — | Roboflow model ID (e.g., 'golfballdetector/10'). Uses Roboflow API instead of Hough. |
+| `--roboflow-api-key` | — | Roboflow API key (can also use ROBOFLOW_API_KEY env var) |
+
+## K-LD7 (deprecated)
+
+Retained for existing builds only. See [Legacy (K-LD7)](../legacy/index.md).
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--kld7` | flag | [DEPRECATED] Enable K-LD7 vertical angle radar (launch angle) |
+| `--kld7-port` | — | K-LD7 vertical serial port (auto-detect if not specified) |
+| `--kld7-angle-offset` | float; default `1.5` | K-LD7 vertical boresight offset in degrees. Not user-measurable without a corner reflector; 1.5 is the calibrated default for the standard mount (default: 1.5) |
+| `--kld7-mount-tilt` | float | K-LD7 vertical radar mount tilt in degrees. REQUIRED with --kld7 — measure it with a phone inclinometer against the radar face; there is no default because a wrong tilt silently corrupts the launch angle |
+| `--kld7-ball-distance` | float; default `5.0` | Radar-to-tee distance in feet (default: 5.0) |
+| `--net-distance` | float; default `10.0` | Ball-to-net/screen distance in feet (two_ray). For nets beyond the ~11ft FSK range wrap, far-flight frames are de-aliased and kept instead of dropped (default: 10.0; nets at/inside the wrap are unaffected). |
+| `--kld7-radar-height-inches` | float; default `4.0` | K-LD7 radar height above the ball in inches, used by the ball-speed cosine correction geometry (default: 4.0) |
+| `--kld7-vertical-raw` | flag | TEST MODE: show the raw vertical launch angle for every shot the estimator produces, bypassing all display guardrails (plausibility, soft-lane, estimator-agreement, confidence floor). Default off. |
+| `--kld7-horizontal` | flag | [DEPRECATED] Enable K-LD7 horizontal angle radar (club path) |
+| `--kld7-horizontal-port` | — | K-LD7 horizontal serial port |
+| `--kld7-horizontal-offset` | float | K-LD7 horizontal angle offset in degrees (default: 0.0) |
+| `--kld7-raw-logging` | flag | Log raw K-LD7 RADC payloads (base64) in kld7_buffer session logs for offline replay and the session reviewer, without changing live angle extraction |
+
+## K-LD7 experimental tuning (deprecated)
+
+Off by default; for estimator work on deprecated hardware.
+
+| Flag | Type / default | Description |
+| --- | --- | --- |
+| `--experimental-kld7-radc-tuning` | flag | Enable temporary K-LD7 RADC extraction tuning parameters (off by default) |
+| `--experimental-kld7-speed-tolerance` | float; default `10.0` | Experimental K-LD7 RADC speed tolerance in mph (default: 10.0) |
+| `--experimental-kld7-centroid-floor` | float; default `0.5` | Experimental K-LD7 RADC centroid floor fraction (default: 0.5) |
+| `--experimental-kld7-spectrum-source` | choices: `f1a`, `f2a`, `f1b`, `sum12`, `sum1b`, `sumall`, `min12`, `geom12`; default `f1a` | Experimental K-LD7 spectrum used for target-bin selection (default: f1a; try sum12 for F1A+F2A non-coherent selection) |
+| `--experimental-kld7-ops-bin-tol` | int; default `25` | Experimental K-LD7 RADC OPS-bin outlier tolerance (default: 25) |
+| `--experimental-kld7-ops-bin-penalty` | float; default `10.0` | Experimental K-LD7 RADC OPS-bin outlier penalty (default: 10.0) |
+| `--experimental-kld7-ops-anchored-min-snr` | float; default `5.0` | Experimental K-LD7 RADC OPS-anchored local peak minimum SNR (default: 5.0) |
+| `--experimental-kld7-vertical-impact-energy` | float; default `3.0` | Experimental vertical K-LD7 RADC impact energy threshold (default: 3.0) |
+| `--experimental-kld7-horizontal-impact-energy` | float; default `1.85` | Experimental horizontal K-LD7 RADC impact energy threshold (default: 1.85) |
+| `--experimental-kld7-horizontal-retry-impact-energy` | float; default `0.5` | Experimental horizontal K-LD7 RADC retry impact energy threshold (default: 0.5) |
+| `--experimental-kld7-horizontal-angle-limit` | float; default `15.0` | Experimental horizontal K-LD7 RADC angle acceptance limit in degrees (default: 15.0) |
+
+## Wrapper-only flags
+
+Handled by `scripts/start-kiosk.sh` itself rather than passed through.
+
+| Flag | Description |
+| --- | --- |
+| `--dry-run` | Print the command that would run, then exit |
+| `--trackman-test` | Enable the TrackMan comparison session workflow |
+| `--mode` | **Deprecated** — rolling buffer is the only mode |
+| `--buffer-split` | Buffer split point for the capture window |
+
+## Related
+
+- [Running & modes](../using/running.md) — the common invocations
+- [Configuration files](configuration.md) — settings that are not flags
+- [Constants](constants.md) — values compiled in rather than passed
diff --git a/docs/openflight-cloud-uploader-spec.md b/docs/reference/cloud-uploader-spec.md
similarity index 100%
rename from docs/openflight-cloud-uploader-spec.md
rename to docs/reference/cloud-uploader-spec.md
diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md
new file mode 100644
index 000000000..5b167a494
--- /dev/null
+++ b/docs/reference/configuration.md
@@ -0,0 +1,102 @@
+---
+icon: lucide/file-cog
+---
+
+# Configuration Files
+
+Settings that live in files rather than on the command line.
+
+## `config/sim.json`
+
+Simulator connectors. Not tracked in git — copy the example and edit:
+
+```bash
+cp config/sim.example.json config/sim.json
+```
+
+```json
+{
+ "connectors": [
+ {
+ "type": "opengolfsim",
+ "enabled": false,
+ "host": "127.0.0.1",
+ "port": 3111,
+ "device_id": "OpenFlight"
+ },
+ {
+ "type": "gspro",
+ "enabled": false,
+ "host": "127.0.0.1",
+ "port": 921,
+ "device_id": "OpenFlight",
+ "units": "Yards",
+ "heartbeat_interval_s": 5
+ }
+ ]
+}
+```
+
+| Key | Meaning |
+| --- | --- |
+| `type` | Connector to use — `opengolfsim`, `gspro`, … |
+| `enabled` | Whether this connector is active |
+| `host`, `port` | Where the simulator is listening |
+| `device_id` | Identifier the simulator displays |
+| `units` | `Yards` or `Meters` (GSPro) |
+| `heartbeat_interval_s` | Keepalive cadence (GSPro) |
+
+Enable connectors at runtime with `--sim`. See
+[simulator connectors](../using/simulator/index.md).
+
+## `~/.config/openflight/cloud.json`
+
+Cloud sync credentials and endpoint. Mode `0600` — it holds a device token.
+
+Created by the device-linking flow rather than by hand; see
+[cloud sync](../using/cloud-sync.md#linking-a-device) and the
+[wire contract](cloud-uploader-spec.md#6-config-configopenflightcloudjson-mode-0600).
+
+## IWR6843 radar configs
+
+`config/iwr6843_l3dump_*.cfg` — TI RF configuration matching the flashed L3
+firmware. Selected with `--iwr6843-config`.
+
+| File | Profile |
+| --- | --- |
+| `iwr6843_l3dump_wide_24f3ms_53bin_iq16.cfg` | Wide, 24 frames × 3 ms, 53 bins, IQ16. **Default.** |
+| `iwr6843_l3dump_dense_36f2ms_53bin_iq8.cfg` | Dense, 36 frames × 2 ms, 53 bins, IQ8 |
+
+Both work with the same flashed image — the profile is selected at runtime, not
+at flash time. See
+[choosing a profile](../iwr6843/index.md#choose-a-profile).
+
+## `config/iwr6843_calibration_reference.json`
+
+Per-board complex array and range calibration. Selected with `--iwr6843-cal`.
+
+| Key | Meaning |
+| --- | --- |
+| `range_scale`, `range_offset_m`, `range_bias_const_m` | Range correction |
+| `elem_phase_rad` | Per-element phase correction, 8 elements |
+| `elem_gain` | Per-element gain correction, 8 elements |
+| `convention` | How the correction is applied to the array |
+
+The shipped file is a **validated starting point**, not a guarantee — per-board
+calibration may be required. Mount tilt and antenna height also live here and
+can be overridden with `--iwr6843-tilt-deg` and `--iwr6843-radar-height-m`.
+
+## `config/alloy.alloy`
+
+Grafana Alloy pipeline for shipping session logs. See
+[observability](../using/observability.md).
+
+## `config/credentials.env.example`
+
+Template for Grafana Cloud credentials. Copy to `credentials.env` — which is
+gitignored — and fill in.
+
+## Related
+
+- [CLI flags](cli.md)
+- [Constants](constants.md)
diff --git a/docs/reference/constants.md b/docs/reference/constants.md
new file mode 100644
index 000000000..0e2702e2a
--- /dev/null
+++ b/docs/reference/constants.md
@@ -0,0 +1,117 @@
+---
+icon: lucide/sliders
+---
+
+# Constants
+
+Values compiled into the code rather than passed as flags. Change them by
+editing the module, not the command line.
+
+## Capture and FFT
+
+`src/openflight/rolling_buffer/processor.py`
+
+| Constant | Value | Meaning |
+| --- | --- | --- |
+| `SAMPLE_RATE` | 30,000 | Samples per second |
+| `WINDOW_SIZE` | 128 | Samples per FFT window |
+| `FFT_SIZE` | 4,096 | Zero-padded FFT size |
+| `STEP_SIZE_STANDARD` | 128 | Non-overlapping step |
+| `STEP_SIZE_OVERLAP` | 32 | Overlapping step, high resolution |
+| `DC_MASK_BINS` | 150 | ~15 mph exclusion zone around DC |
+| `MAGNITUDE_THRESHOLD` | 3 | Minimum peak magnitude |
+| `MIN_PEAK_SEPARATION_BINS` | 50 | ~5 mph; rejects sidelobe duplicates |
+| `MAX_PEAKS_PER_DIRECTION` | 3 | |
+
+`--sample-rate` overrides the rate at runtime. Lowering it lengthens the buffer
+but reduces the maximum measurable speed: 25 ksps gives 174 mph over 164 ms,
+27 ksps gives 187 mph over 152 ms.
+
+## Radar physics
+
+| Constant | Value | Meaning |
+| --- | --- | --- |
+| `WAVELENGTH_M` | 0.01243 | 24.125 GHz |
+| `MPS_TO_MPH` | 2.23694 | |
+| `ADC_RANGE` | 4,096 | 12-bit ADC |
+| `VOLTAGE_REF` | 3.3 | Reference voltage |
+
+At 24.125 GHz, 1 mph produces roughly a 71.7 Hz Doppler shift.
+
+## Club extraction
+
+| Constant | Value |
+| --- | --- |
+| `CLUB_BRANCH_HISTORY_MS` | 30.0 |
+| `CLUB_BALL_ONSET_SEARCH_MS` | 10.0 |
+| `CLUB_BALL_MATCH_MIN_MPH` | 4.0 |
+| `CLUB_BALL_MATCH_FRACTION` | 0.06 |
+| `CLUB_PLATEAU_LOOKBACK_MS` | 18.0 |
+| `CLUB_PLATEAU_GUARD_MS` | 4.0 |
+| `CLUB_PLATEAU_QUANTILE` | 0.70 |
+| `CLUB_BALL_CONTAMINATION_RATIO` | 0.95 |
+| `CLUB_TERMINAL_START_MS` | −2.5 |
+| `CLUB_TERMINAL_END_MS` | 1.0 |
+| `CLUB_MAX_PLAUSIBLE_SPEED_MPH` | 150.0 |
+
+## Spin detection
+
+Amplitude-envelope demodulation. See
+[rolling buffer & spin detection](../how-it-works/rolling-buffer.md).
+
+| Constant | Value | Meaning |
+| --- | --- | --- |
+| `SPIN_BANDPASS_BW_HZ` | 700 | ±700 Hz around ball Doppler |
+| `SPIN_BANDPASS_ORDER` | 4 | Butterworth order |
+| `SPIN_ENVELOPE_FFT_SIZE` | 8,192 | Zero-padded envelope FFT |
+| `SPIN_MIN_SEAM_HZ` | 33.0 | ~2,000 RPM floor |
+| `SPIN_MAX_SEAM_HZ` | 200.0 | 12,000 RPM ceiling |
+| `SPIN_MIN_SAMPLES` | 600 | ~20 ms minimum ball signal |
+| `SPIN_SNR_HIGH` | 8.0 | High-confidence threshold |
+| `SPIN_SNR_MEDIUM` | 5.0 | Medium-confidence threshold |
+| `SPIN_SNR_MIN` | 2.5 | Minimum to report at all |
+| `SPIN_AUTOCORR_MIN` | 0.3 | Minimum normalised correlation |
+| `SPIN_MIN_CYCLES` | 2 | Minimum seam cycles |
+| `SPIN_DC_LEAKAGE_BINS` | 1 | Low bins zeroed |
+| `SPIN_LOW_BAND_SUSPECT_MAX_RPM` | 3,100.0 | |
+| `SPIN_DETREND_POLY_ORDER` | 3 | |
+| `SPIN_SIGNAL_LOSS_SMOOTH_SAMPLES` | 90 | ~3 ms moving average |
+
+`SPIN_CONFIDENCE_HIGH` (0.7, in `launch_monitor.py`) is the threshold above
+which measured spin is trusted for physics.
+
+## Ballistics
+
+`src/openflight/ballistics.py` — see
+[ballistics & carry](../how-it-works/ballistics.md) for the model and its
+references.
+
+| Constant | Value |
+| --- | --- |
+| `BALL_MASS_KG` | 0.04593 |
+| `BALL_RADIUS_M` | 0.02135 |
+| `AIR_DENSITY_STD` | 1.225 |
+| `CD_BASE` | 0.205 |
+| `CD_SPIN_COEFF` | 0.18 |
+| `CL_SATURATION` | 0.32 |
+| `CL_HALF_SP` | 0.15 |
+| `SPIN_DECAY_RATE` | 0.04 |
+| `GRAVITY` | 9.81 |
+| `DT_SECONDS` | 0.002 |
+| `MAX_FLIGHT_SECONDS` | 15.0 |
+| `SAMPLE_INTERVAL_S` | 0.05 |
+
+## Thresholds
+
+| Value | Setting |
+| --- | --- |
+| Minimum ball speed | 15 mph (35 mph in speed-triggered mode) |
+| Trigger latency, `sound` | ~10 µs |
+| Trigger latency, `speed` | ~5–6 ms |
+| Rolling-buffer dump | 40,556 bytes |
+| K-LD7 OS-CFAR threshold factor | 8.0 *(deprecated hardware)* |
+
+## Related
+
+- [CLI flags](cli.md) — what can be changed at runtime
+- [Configuration files](configuration.md) — settings held in files
diff --git a/docs/reference/datasheets.md b/docs/reference/datasheets.md
new file mode 100644
index 000000000..28760a835
--- /dev/null
+++ b/docs/reference/datasheets.md
@@ -0,0 +1,52 @@
+---
+icon: lucide/file-text
+---
+
+# Datasheets
+
+Vendor documentation, checked into the repository so builds stay reproducible
+when vendor URLs move.
+
+## OPS243-A Doppler radar
+
+| Document | Contents |
+| --- | --- |
+| [OPS243 datasheet](../radar/OPS-DS-003-01_OPS243-Datasheet.pdf) | Electrical specs, J3 pinout, operating modes |
+| [AN-010-AD: API interface](../radar/AN-010-AD_API_Interface.pdf) | Serial command set. Note that enumerating USB disables UART reporting — the cause of a common wiring false alarm. |
+| [AN-027-A: Rolling buffer](<../radar/AN-027-A_Rolling Buffer.pdf>) | Rolling-buffer mode, `HOST_INT` behaviour, dump format |
+| [Sports ball detection](../radar/OmniPreSense_Sports_Ball_Detect_2507.pdf) | Vendor application note for ball-speed use |
+
+`AN-027-A` is the one to read if you want to understand why the
+[one-time flash-persist step](../setup/rolling-buffer.md) exists.
+
+## K-LD7 (deprecated)
+
+Retained for existing builds. See [Legacy (K-LD7)](../legacy/index.md).
+
+| Document | Contents |
+| --- | --- |
+| [K-LD7 datasheet](../K-LD7_Datasheet.pdf) | Module specs |
+| [K-LD7 EVAL datasheet](../K-LD7-EVAL_Datasheet.pdf) | Evaluation board |
+
+## TI IWR6843
+
+TI does not permit redistribution of its documents, so these are links rather
+than checked-in copies:
+
+- [IWR6843 product page](https://www.ti.com/product/IWR6843)
+- [IWR6843ISK / LEVM evaluation module](https://www.ti.com/tool/IWR6843ISK)
+- [mmWave SDK](https://www.ti.com/tool/MMWAVE-SDK)
+
+For what OpenFlight actually does with the device, the
+[firmware developer guide](../development/firmware.md) and the
+[launch angle field report](../how-it-works/launch-angle.md) are more useful than
+the TI documents.
+
+## Other hardware
+
+- [SparkFun SEN-14262 sound detector](https://www.sparkfun.com/products/14262) —
+ see [sound trigger wiring](../build/sound-trigger.md) for the R17 modification
+- [ST LIS3DH accelerometer](https://www.st.com/en/mems-and-sensors/lis3dh.html) —
+ see [inclinometer](../build/inclinometer.md)
+- [Geekworm X1202 / X1206](https://wiki.geekworm.com/X1202) —
+ see [battery](../build/battery.md)
diff --git a/docs/reference/index.md b/docs/reference/index.md
new file mode 100644
index 000000000..181296774
--- /dev/null
+++ b/docs/reference/index.md
@@ -0,0 +1,37 @@
+---
+icon: lucide/book-open
+---
+
+# Reference
+
+Lookup material, not narrative. If you are trying to *do* something, the
+[build](../build/index.md), [setup](../setup/index.md), and
+[using](../using/index.md) sections are the places to start.
+
+
+
+- :material-console: **[CLI flags](cli.md)**
+
+ Every `start-kiosk.sh` and server flag, grouped by subsystem.
+
+- :material-file-cog-outline: **[Configuration files](configuration.md)**
+
+ `sim.json`, `cloud.json`, radar configs, and calibration JSON.
+
+- :material-code-json: **[Session log schema](session-log.md)**
+
+ All 20 JSONL entry types and what they contain.
+
+- :material-tune: **[Constants](constants.md)**
+
+ Sample rates, FFT sizes, thresholds, and physical parameters.
+
+- :material-api: **[Cloud uploader contract](cloud-uploader-spec.md)**
+
+ The `openflight-cloud` wire protocol and its evolution rules.
+
+- :material-file-document-outline: **[Datasheets](datasheets.md)**
+
+ Vendor PDFs for the radars and their APIs.
+
+
diff --git a/docs/reference/session-log.md b/docs/reference/session-log.md
new file mode 100644
index 000000000..a664dff92
--- /dev/null
+++ b/docs/reference/session-log.md
@@ -0,0 +1,108 @@
+---
+icon: lucide/file-json
+---
+
+# Session Log Schema
+
+Every session writes a JSON Lines file to
+`~/openflight_sessions/session_.jsonl` (override with `--log-dir`,
+disable with `--no-logging`).
+
+One JSON object per line. Every entry carries at least:
+
+```json
+{"ts": "2026-08-23T14:31:07.882431", "type": "shot_detected", "...": "..."}
+```
+
+| Field | Meaning |
+| --- | --- |
+| `ts` | ISO 8601 local timestamp |
+| `type` | Entry type, from the table below |
+
+Everything else is type-specific and merged into the same object — entries are
+flat, not nested under a payload key.
+
+## Entry types
+
+Written by `src/openflight/session_logger.py`.
+
+### Session lifecycle
+
+| Type | Written when |
+| --- | --- |
+| `session_start` | Session opens. Carries the session metadata block. |
+| `session_end` | Session closes. Carries the summary. |
+| `connection` | A radar connects — port, firmware, negotiated baud. |
+| `ops_clock_sync` | OPS243 clock synchronisation summary. |
+| `config_change` | Runtime configuration changed, with its source. |
+| `error` | An error, with optional context. |
+
+### Shots and readings
+
+| Type | Written when |
+| --- | --- |
+| `shot_detected` | A shot is measured. Ball speed, club speed, spin, angles, carry. |
+| `reading_accepted` | An individual radar speed reading passed the filters. |
+| `shot_camera` | Camera-derived data for a shot (experimental). |
+
+### Capture data
+
+| Type | Written when |
+| --- | --- |
+| `trigger_event` | Trigger accepted or rejected, with latency. |
+| `trigger_diagnostic` | Extended trigger diagnostics. |
+| `rolling_buffer_capture` | Raw OPS243 I/Q samples — 4,096 each. |
+| `iq_reading` | I/Q streaming detection with SNR and CFAR data. |
+| `iq_blocks` | Raw I/Q blocks for a shot. |
+| `iwr6843_capture` | IWR6843 L3 dump for a shot. |
+| `kld7_buffer` | Raw K-LD7 RADC payload, base64 (deprecated hardware). |
+
+### Outbound and system
+
+| Type | Written when |
+| --- | --- |
+| `sim_send` | A shot was forwarded to a simulator. |
+| `sim_status` | Simulator connection status changed. |
+| `sim_player` | Player state — target, handedness, club. |
+| `power_status` | Battery and external-power status. |
+
+!!! warning "`CLAUDE.md` lists only eight of these"
+
+ The repository's `CLAUDE.md` documents a subset. The 20 types above are the
+ complete set as written by `session_logger.py`.
+
+## Size
+
+Raw capture entries dominate. A session with a few hundred shots produces a
+file in the tens of megabytes because `rolling_buffer_capture` stores 8,192
+samples per shot.
+
+This is deliberate — those captures are what make offline estimator work
+possible without re-hitting balls. See
+[spin replay](../development/spin-replay.md) and
+[analysis tooling](../development/analysis-tooling.md).
+
+[Cloud sync](../using/cloud-sync.md) strips the raw ADC entries before upload;
+that filtering is **required** by the wire contract, not optional — see the
+[cloud uploader spec](cloud-uploader-spec.md).
+
+## Querying
+
+Locally, `jq` is usually enough:
+
+```bash
+# Every shot's ball speed
+jq -r 'select(.type=="shot_detected") | .ball_speed' session_*.jsonl
+
+# Trigger rejects and why
+jq -r 'select(.type=="trigger_event" and .accepted==false)' session_*.jsonl
+```
+
+For LogQL queries against shipped logs, see
+[observability](../using/observability.md#querying-session-data).
+
+## Related
+
+- [Observability & log shipping](../using/observability.md)
+- [Cloud uploader contract](cloud-uploader-spec.md)
+- [K-LD7 session review](../legacy/session-review.md) — offline review workflow
diff --git a/docs/setup/auto-start.md b/docs/setup/auto-start.md
new file mode 100644
index 000000000..6d57e7434
--- /dev/null
+++ b/docs/setup/auto-start.md
@@ -0,0 +1,70 @@
+---
+icon: lucide/power
+---
+
+# Auto-Start & Kiosk Mode
+
+Run OpenFlight as a systemd service so it comes up on boot, and drive the
+display in fullscreen kiosk mode.
+
+## Auto-start on boot
+
+The setup script installs and enables a systemd service configured for your
+username and install path.
+
+
+Manual steps and service management
+
+```bash
+# Install (adjust User= and paths in the file if your username isn't the default)
+sudo cp ~/openflight/scripts/setup/openflight.service /etc/systemd/system/
+sudo systemctl daemon-reload
+sudo systemctl enable openflight
+sudo systemctl start openflight
+```
+
+Management:
+
+```bash
+sudo systemctl status openflight --no-pager # Check status
+journalctl -u openflight -f # View logs
+sudo systemctl stop openflight # Stop
+sudo systemctl restart openflight # Restart
+sudo systemctl disable openflight # Disable auto-start
+```
+
+To modify the service:
+
+```bash
+sudo nano /etc/systemd/system/openflight.service
+sudo systemctl daemon-reload
+sudo systemctl restart openflight
+```
+
+
+
+## Kiosk mode (fullscreen, recommended)
+
+```bash
+./scripts/start-kiosk.sh # Default: rolling buffer + sound trigger
+./scripts/start-kiosk.sh --mock # Mock mode (no hardware needed)
+```
+
+Use the [IWR6843 Operator Guide](../iwr6843/verify.md#start-openflight) or
+[Legacy K-LD7 Setup](../legacy/index.md) for angle-radar startup commands.
+
+## Manual start
+
+```bash
+openflight-server # With radar
+openflight-server --mock # No hardware
+```
+
+Then open `http://localhost:8080`.
+
+## Running over SSH
+
+```bash
+DISPLAY=:0 ./scripts/start-kiosk.sh
+```
+
diff --git a/docs/setup/diagnostic.md b/docs/setup/diagnostic.md
new file mode 100644
index 000000000..f3a1a4493
--- /dev/null
+++ b/docs/setup/diagnostic.md
@@ -0,0 +1,74 @@
+---
+icon: lucide/stethoscope
+---
+
+# Hardware Diagnostic
+
+One command that checks the whole signal path and tells you which link is
+broken, rather than leaving you to guess from a silent UI.
+
+```bash
+uv run python scripts/hardware-test/diagnose.py
+```
+
+Run it after any wiring change, and first whenever shots stop registering.
+
+## The seven checks
+
+Each check depends on the ones above it. A failure cascades — later checks are
+skipped rather than reported as spurious failures.
+
+| # | Check | Time | What it proves |
+| --- | --- | --- | --- |
+| 1 | OPS243 UART preflight | ~1 s | Device present, no serial console holding the port, OPS USB not enumerated |
+| 2 | OPS243 connectivity | ~2 s | Radar answers; reports port, firmware, and negotiated baud |
+| 3 | OPS243 rolling buffer persisted | ~1 s | Radar **boots** into rolling-buffer mode without being told — proves the flash-persist took |
+| 4 | OPS243 software trigger | ~3 s | A capture returns 4,096 I/Q samples and parses |
+| 5 | K-LD7 vertical | ~3 s | *Deprecated hardware.* Frames streaming |
+| 6 | K-LD7 horizontal | ~3 s | *Deprecated hardware.* Skipped if only one unit is present |
+| 7 | Sound trigger end-to-end | ~20 s | Interactive — clap, and the hardware trigger fires a valid capture |
+
+## Reading the output
+
+```
+[2/7] OPS243 connectivity ................... ✓ PASS
+ /dev/ttyAMA0 • firmware 1.5.2 • 230400 baud • dump ~2.0s
+[4/7] OPS243 software trigger ............... ✓ PASS
+ Capture received: 4096 I/Q samples • 40556 bytes in 1.83s (22.2 KB/s)
+```
+
+Failures carry a hint rather than just a status:
+
+| Failure | Usual cause |
+| --- | --- |
+| Check 1 — console holds the port | Serial login shell still enabled; see [UART setup](../build/ops243-uart.md#3-prepare-the-pi-uart) |
+| Check 2 — no response | USB permissions (`dialout` group), or the wrong port |
+| Check 2 — 19,200 baud | The `I5` command never arrived. Check Pi pin 8 → J3 pin 6. |
+| Check 3 — "streaming data, CW mode" | Rolling-buffer mode was lost. Re-run [rolling buffer setup](rolling-buffer.md) and power-cycle. |
+| Check 4 — no I/Q response | Radar is in the wrong mode, or the dump was truncated by a slow baud |
+| Check 7 — timeout | SEN-14262 wiring to `HOST_INT`, or R17 too high a value |
+
+## Flags
+
+| Flag | Effect |
+| --- | --- |
+| `--ops-port ` | Skip auto-detection and use this port |
+| `--ops-baud ` | Force a baud rate instead of negotiating |
+| `--no-interactive` | Skip check 7, which needs you to clap |
+| `--require-all` | Treat skips as failures — including the optional horizontal K-LD7 |
+
+`--no-interactive` is what you want in a script or over SSH; the exit code is 0
+only if every non-skipped check passed.
+
+!!! tip "Check 3 is the one that catches the classic failure"
+
+ The most common "it worked yesterday" fault is the OPS243 losing its
+ persisted rolling-buffer mode. Check 3 tests this specifically — it verifies
+ the radar *boots* into the right mode rather than checking that it can be
+ put there.
+
+## Related
+
+- [Rolling buffer setup](rolling-buffer.md) — what check 3 verifies
+- [Troubleshooting index](../troubleshooting/index.md) — routing by symptom
+- [OPS243 → GPIO UART](../build/ops243-uart.md) — what checks 1 and 2 cover
diff --git a/docs/setup/index.md b/docs/setup/index.md
new file mode 100644
index 000000000..62c391559
--- /dev/null
+++ b/docs/setup/index.md
@@ -0,0 +1,44 @@
+---
+icon: lucide/settings
+---
+
+# Setup
+
+Software configuration, once the hardware is wired.
+
+
+
+- :material-raspberry-pi: **[Raspberry Pi setup](raspberry-pi.md)**
+
+ OS, dependencies, the setup script, and what it configures.
+
+- :material-content-save-outline: **[Rolling buffer setup](rolling-buffer.md)**
+
+ The one-time flash-persist step. Hardware triggers do not work without it.
+
+- :material-power: **[Auto-start & kiosk mode](auto-start.md)**
+
+ Run as a systemd service on boot; fullscreen, manual, and SSH variants.
+
+- :material-stethoscope: **[Hardware diagnostic](diagnostic.md)**
+
+ Seven checks over the whole signal path. Run this first when something
+ breaks.
+
+
+
+## Order
+
+1. [Raspberry Pi setup](raspberry-pi.md) — get the software running
+2. [Rolling buffer setup](rolling-buffer.md) — persist the radar mode
+3. [Hardware diagnostic](diagnostic.md) — confirm the path end to end
+4. [Auto-start & kiosk mode](auto-start.md) — make it survive a reboot
+
+!!! tip "Run the diagnostic before anything else"
+
+ ```bash
+ uv run python scripts/hardware-test/diagnose.py
+ ```
+
+ It tells you which link in the chain is broken instead of leaving you to
+ infer it from a UI that shows nothing.
diff --git a/docs/raspberry-pi-setup.md b/docs/setup/raspberry-pi.md
similarity index 78%
rename from docs/raspberry-pi-setup.md
rename to docs/setup/raspberry-pi.md
index a0de4ea0f..1deb1f8ff 100644
--- a/docs/raspberry-pi-setup.md
+++ b/docs/setup/raspberry-pi.md
@@ -4,7 +4,7 @@ Complete guide for setting up OpenFlight on a Raspberry Pi 5 with the 7" touchsc
## Prerequisites
-Make sure you have all the hardware. See the **[Parts List](PARTS.md)** for what to buy.
+Make sure you have all the hardware. See the **[Parts List](../get-started/parts.md)** for what to buy.
**Required:**
- Raspberry Pi 5 (4GB+ recommended)
@@ -12,15 +12,15 @@ Make sure you have all the hardware. See the **[Parts List](PARTS.md)** for what
- MicroSD Card (32GB+)
- 27W USB-C Power Supply (official Pi 5 PSU recommended)
- OPS243-A Doppler Radar + USB cable
-- SparkFun SEN-14262 sound detector (wired per the [Sound Trigger Wiring Guide](sound-trigger-wiring.md))
+- SparkFun SEN-14262 sound detector (wired per the [Sound Trigger Wiring Guide](../build/sound-trigger.md))
**Optional:**
-- TI IWR6843LEVM + data cable — measured launch angle and experimental club path; see the [IWR6843 Operator Guide](iwr6843/README.md)
-- Geekworm X1202 or X1206 UPS HAT — portable Pi 5 power using four separately purchased 18650 or 21700 cells; see the [battery monitoring overview](battery/README.md) and [Geekworm operator guide](battery/geekworm.md)
-- InnoMaker OV9281 global-shutter camera (~$30) — experimental vision work; see [Camera and YOLO Experiments](yolo-performance-tuning.md)
+- TI IWR6843LEVM + data cable — measured launch angle and experimental club path; see the [IWR6843 Operator Guide](../iwr6843/index.md)
+- Geekworm X1202 or X1206 UPS HAT — portable Pi 5 power using four separately purchased 18650 or 21700 cells; see the [battery monitoring overview](../using/battery.md) and [Geekworm operator guide](../build/battery.md)
+- InnoMaker OV9281 global-shutter camera (~$30) — experimental vision work; see [Camera and YOLO Experiments](../development/camera-yolo.md)
**Optional (deprecated):**
-- K-LD7 + FTDI adapter (×2) — for launch angle and club path (see [Parts List](PARTS.md)). **Deprecated** — superseded by a more capable radar chip; don't buy for a new build. Supported for existing builds only.
+- K-LD7 + FTDI adapter (×2) — for launch angle and club path (see [Parts List](../get-started/parts.md)). **Deprecated** — superseded by a more capable radar chip; don't buy for a new build. Supported for existing builds only.
## Setup
@@ -76,9 +76,9 @@ it picks up where you left off.
Then open `http://localhost:8080` or use the touchscreen.
For the current IWR6843 angle radar, use the measured startup command in the
-[IWR6843 Operator Guide](iwr6843/README.md#start-openflight). Existing K-LD7
+[IWR6843 Operator Guide](../iwr6843/verify.md#start-openflight). Existing K-LD7
builds use `--kld7 --kld7-mount-tilt `; see
-[Legacy K-LD7 Setup](kld7.md).
+[Legacy K-LD7 Setup](../legacy/index.md).
### 4. (Optional) Stream to a golf simulator
@@ -89,7 +89,7 @@ example config and enable your simulator:
cp config/sim.example.json config/sim.json # then edit host/port + "enabled": true
```
-See **[Simulator Connectors](simulator/README.md)** for the full guide.
+See **[Simulator Connectors](../using/simulator/index.md)** for the full guide.
---
@@ -98,57 +98,23 @@ See **[Simulator Connectors](simulator/README.md)** for the full guide.
You don't need this section unless something went wrong or you prefer to do
things by hand.
-### OPS243-A Rolling Buffer Mode
-
-The OPS243-A needs a one-time configuration to enable rolling buffer mode with
-hardware sound triggering, saved to flash so it boots correctly every time.
-
-> **Why?** The OPS243-A has a firmware bug where the HOST_INT pin mode switches
-> unexpectedly when entering rolling buffer mode at runtime. Saving to flash and
-> power cycling bypasses this. Confirmed by OmniPreSense engineering.
-
-
-Manual steps
-
-```bash
-# 1. Configure and save to flash
-uv run python scripts/hardware-test/test_rolling_buffer_persist.py --setup
-
-# 2. Power cycle the radar, wait 3 seconds, reconnect (see note below)
-
-# 3. Verify — make a sound near the SEN-14262, you should see I/Q trigger data
-uv run python scripts/hardware-test/test_rolling_buffer_persist.py --test
-```
-
-How you power cycle depends on how the OPS243 is connected:
-
-| Connection | Power cycle by |
-|------------|----------------|
-| USB | Unplugging the USB cable |
-| Pi GPIO UART | Disconnecting 5V from OPS `J3` pin 9 — **not** by rebooting the Pi, which does not necessarily drop the header rail |
-
-If the OPS243 is on the GPIO UART, add `--port /dev/ttyAMA0` to both commands
-above.
-
-
-
### IWR6843 Angle Radar
The setup script does not configure the IWR6843 — it needs custom firmware
flashed over the ROM bootloader, which requires physically moving a switch on
the board. That is covered end to end in the
-**[IWR6843 Operator Guide](iwr6843/README.md)**.
+**[IWR6843 Operator Guide](../iwr6843/index.md)**.
Do it in this order, and confirm each step works before starting the next:
1. **Move the OPS243 to the Pi GPIO UART** —
- [migration guide](ops243-uart-migration.md). The Pi cannot power both radars
+ [migration guide](../build/ops243-uart.md). The Pi cannot power both radars
over USB, so the OPS243 has to vacate the USB port. Validate the OPS on its
own after rewiring, before the TI board is involved at all.
2. **Flash the IWR6843** — operator guide, *Flash The IWR6843 Firmware*. A
validated prebuilt image is in `firmware/releases/`, so the TI toolchain is
not required. You only need the
- [firmware developer guide](../firmware/README.md) to build from source.
+ [firmware developer guide](../development/firmware.md) to build from source.
3. **Mount, aim, and measure geometry** — operator guide. The geometry values
are passed on the command line and a wrong one silently biases the launch
angle instead of erroring, so measure rather than estimate.
@@ -180,7 +146,7 @@ package. It does not install automatic shutdown or charging-control services.
Cell type, board-revision power limits, physical installation, every system
change, and troubleshooting are documented in the
-**[Geekworm X1202/X1206 Operator Guide](battery/geekworm.md)**.
+**[Geekworm X1202/X1206 Operator Guide](../build/battery.md)**.
### K-LD7 Device Names (Deprecated Hardware)
@@ -194,8 +160,8 @@ reboot. Run the device wizard, then follow the legacy guide:
```
The wizard also installs the required FTDI low-latency rule. See
-[Legacy K-LD7 Setup](kld7.md) for mounting and startup, and
-[K-LD7 Troubleshooting](kld7-troubleshooting.md) for serial failures.
+[Legacy K-LD7 Setup](../legacy/index.md) for mounting and startup, and
+[K-LD7 Troubleshooting](../legacy/troubleshooting.md) for serial failures.
### Desktop Launcher And Startup Splash
@@ -259,8 +225,8 @@ sudo systemctl restart openflight
./scripts/start-kiosk.sh --mock # Mock mode (no hardware needed)
```
-Use the [IWR6843 Operator Guide](iwr6843/README.md#start-openflight) or
-[Legacy K-LD7 Setup](kld7.md) for angle-radar startup commands.
+Use the [IWR6843 startup steps](../iwr6843/verify.md#start-openflight) or
+[Legacy K-LD7 Setup](../legacy/index.md) for angle-radar startup commands.
### Manual Start
@@ -286,7 +252,7 @@ sudo ./scripts/setup/setup_alloy.sh
sudo vim /etc/alloy/credentials.env
```
-See [observability.md](observability.md) for full setup and LogQL queries.
+See [observability.md](../using/observability.md) for full setup and LogQL queries.
## Troubleshooting
@@ -301,7 +267,7 @@ uv run python scripts/hardware-test/diagnose.py --ops-port /dev/ttyAMA0
### Sound Trigger Not Working
-See the [Sound Trigger Wiring Guide — Troubleshooting](sound-trigger-wiring.md#troubleshooting).
+See the [Sound Trigger Wiring Guide — Troubleshooting](../build/sound-trigger.md#troubleshooting).
### K-LD7 Not Connecting
@@ -315,7 +281,7 @@ uv run python scripts/hardware-test/test_kld7.py
If the mapping is missing or points at the wrong radar, re-run the wizard:
`./scripts/setup/setup_kld7_devices.sh`. Look for `[KLD7] Connected on
-/dev/ttyUSB...` in the server logs. See [K-LD7 Troubleshooting](kld7-troubleshooting.md)
+/dev/ttyUSB...` in the server logs. See [K-LD7 Troubleshooting](../legacy/troubleshooting.md)
for "Wrong length reply" and other connection issues.
### Service Won't Start
diff --git a/docs/setup/rolling-buffer.md b/docs/setup/rolling-buffer.md
new file mode 100644
index 000000000..c78f0b1e7
--- /dev/null
+++ b/docs/setup/rolling-buffer.md
@@ -0,0 +1,62 @@
+---
+icon: lucide/save
+---
+
+# Rolling Buffer Setup
+
+**One-time, per radar.** The OPS243-A must have rolling-buffer mode saved to
+persistent flash for hardware triggers to work. This is a firmware quirk: the
+`HOST_INT` pin mode switches when the radar changes modes at runtime, so the
+setting has to survive a power cycle rather than be applied at startup.
+
+Do this once, after wiring the sound trigger and before your first session.
+Every other guide links here rather than repeating the procedure.
+
+## The procedure
+
+The OPS243-A needs a one-time configuration to enable rolling buffer mode with
+hardware sound triggering, saved to flash so it boots correctly every time.
+
+> **Why?** The OPS243-A has a firmware bug where the HOST_INT pin mode switches
+> unexpectedly when entering rolling buffer mode at runtime. Saving to flash and
+> power cycling bypasses this. Confirmed by OmniPreSense engineering.
+
+
+Manual steps
+
+```bash
+# 1. Configure and save to flash
+uv run python scripts/hardware-test/test_rolling_buffer_persist.py --setup
+
+# 2. Power cycle the radar, wait 3 seconds, reconnect (see note below)
+
+# 3. Verify — make a sound near the SEN-14262, you should see I/Q trigger data
+uv run python scripts/hardware-test/test_rolling_buffer_persist.py --test
+```
+
+How you power cycle depends on how the OPS243 is connected:
+
+| Connection | Power cycle by |
+|------------|----------------|
+| USB | Unplugging the USB cable |
+| Pi GPIO UART | Disconnecting 5V from OPS `J3` pin 9 — **not** by rebooting the Pi, which does not necessarily drop the header rail |
+
+If the OPS243 is on the GPIO UART, add `--port /dev/ttyAMA0` to both commands
+above.
+
+
+
+
+## If the OPS243 is on the Pi GPIO UART
+
+Once you have completed the [UART migration](../build/ops243-uart.md), the same
+procedure needs an explicit port, and the power cycle is the 5 V jumper on J3
+pin 9 rather than a USB cable. See
+[Re-running the one-time rolling-buffer setup](../build/ops243-uart.md#re-running-the-one-time-rolling-buffer-setup).
+
+## Related
+
+- [Sound trigger wiring](../build/sound-trigger.md) — do this first; the
+ trigger path is what rolling-buffer mode exists to serve.
+- [Rolling buffer and spin detection](../how-it-works/rolling-buffer.md) — what
+ the captured buffer is actually used for.
diff --git a/docs/splash-screen.md b/docs/setup/splash-screen.md
similarity index 96%
rename from docs/splash-screen.md
rename to docs/setup/splash-screen.md
index 3b936bf8a..383d359bc 100644
--- a/docs/splash-screen.md
+++ b/docs/setup/splash-screen.md
@@ -11,7 +11,7 @@ scripts/start-kiosk.sh --startup-splash
The splash is opt-in. Without the flag, `scripts/start-kiosk.sh` retains its
existing startup behavior.
-
+
## What It Shows
@@ -101,7 +101,7 @@ spin with an opt-in kinematic estimate whenever a measured launch angle is
available. Enable it only when that behavior is desired.
The geometry above is an example, not a default. Follow the
-[IWR6843 operator guide](iwr6843/README.md#measure-the-geometry) and enter the
+[IWR6843 mounting guide](../iwr6843/mounting.md#measure-the-geometry) and enter the
measurements from the actual installation.
The example wrapper also holds a per-user launch lock. Repeated taps exit
@@ -169,7 +169,7 @@ action, and retains the terminal-log location for diagnosis.
| OPS unavailable | TI firmware wedged |
|---|---|
-|  |  |
+|  |  |
Select **Return to desktop** after a failure. This dismisses the error state and
releases the launch lock before the next attempt.
diff --git a/docs/stylesheets/extra.css b/docs/stylesheets/extra.css
new file mode 100644
index 000000000..5733b1424
--- /dev/null
+++ b/docs/stylesheets/extra.css
@@ -0,0 +1,494 @@
+/* ==========================================================================
+ OpenFlight docs theme
+ Mirrors the openflight.dev design system: deep near-black surfaces, gold
+ accent, warm cream text, DM Serif Display headings / Outfit body / IBM Plex
+ Mono code, and uppercase wide-tracked mono labels.
+
+ Outfit and IBM Plex Mono are loaded by Zensical from theme.font in
+ zensical.toml; only the display serif needs an extra request.
+ ========================================================================== */
+
+@import url("https://fonts.googleapis.com/css2?family=DM+Serif+Display:ital@0;1&display=swap");
+
+/* --------------------------------------------------------------------------
+ Brand tokens (verbatim from openflight.dev / ui/src/index.css)
+ -------------------------------------------------------------------------- */
+
+:root {
+ --of-font-display: "DM Serif Display", Georgia, serif;
+
+ --of-bg-deep: #0a0a0f;
+ --of-bg-card: #12121a;
+ --of-bg-elevated: #1a1a24;
+ --of-bg-hover: #222230;
+
+ --of-gold: #d4af37;
+ --of-gold-bright: #f4cf47;
+ --of-gold-dim: #a68b2a;
+ /* Deep gold reserved for light mode: 5.6:1 on --of-paper, gold stays gold */
+ --of-gold-deep: #7a5f14;
+
+ --of-cream: #f5f0e6;
+ --of-paper: #faf7f0;
+ --of-ink: #14141c;
+
+ --of-success: #4ade80;
+ --of-info: #60a5fa;
+ --of-warning: #fbbf24;
+ --of-danger: #f87171;
+
+ /* Tracking scale used for eyebrow / label text on the marketing site */
+ --of-tracking: 0.14em;
+ --of-tracking-wide: 0.18em;
+}
+
+/* --------------------------------------------------------------------------
+ Palette — dark (slate). This is the canonical OpenFlight look.
+ -------------------------------------------------------------------------- */
+
+[data-md-color-scheme="slate"] {
+ --md-hue: 240;
+
+ --md-default-fg-color: var(--of-cream);
+ --md-default-fg-color--light: rgba(245, 240, 230, 0.72);
+ --md-default-fg-color--lighter: rgba(245, 240, 230, 0.4);
+ --md-default-fg-color--lightest: rgba(245, 240, 230, 0.12);
+
+ --md-default-bg-color: var(--of-bg-deep);
+ --md-default-bg-color--light: rgba(10, 10, 15, 0.72);
+ --md-default-bg-color--lighter: rgba(10, 10, 15, 0.45);
+ --md-default-bg-color--lightest: rgba(10, 10, 15, 0.2);
+
+ --md-primary-fg-color: var(--of-gold);
+ --md-primary-fg-color--light: var(--of-gold-bright);
+ --md-primary-fg-color--dark: var(--of-gold-dim);
+ --md-primary-bg-color: var(--of-bg-deep);
+ --md-primary-bg-color--light: rgba(10, 10, 15, 0.7);
+
+ --md-accent-fg-color: var(--of-gold-bright);
+ --md-accent-fg-color--transparent: rgba(212, 175, 55, 0.12);
+ --md-accent-bg-color: var(--of-bg-deep);
+ --md-accent-bg-color--light: rgba(10, 10, 15, 0.7);
+
+ --md-typeset-color: var(--of-cream);
+ --md-typeset-a-color: var(--of-gold);
+ --md-typeset-mark-color: rgba(212, 175, 55, 0.28);
+ --md-typeset-del-color: rgba(248, 113, 113, 0.18);
+ --md-typeset-ins-color: rgba(74, 222, 128, 0.18);
+ --md-typeset-table-color: rgba(245, 240, 230, 0.14);
+ --md-typeset-table-color--light: rgba(245, 240, 230, 0.04);
+ --md-typeset-kbd-color: var(--of-bg-elevated);
+ --md-typeset-kbd-accent-color: var(--of-bg-hover);
+ --md-typeset-kbd-border-color: #05050a;
+
+ --md-code-fg-color: var(--of-cream);
+ --md-code-bg-color: var(--of-bg-card);
+ --md-code-bg-color--light: rgba(18, 18, 26, 0.9);
+ --md-code-bg-color--lighter: rgba(18, 18, 26, 0.54);
+ --md-code-hl-color: rgba(212, 175, 55, 0.24);
+ --md-code-hl-color--light: rgba(212, 175, 55, 0.1);
+
+ --md-code-hl-keyword-color: #c4b5fd;
+ --md-code-hl-function-color: var(--of-gold-bright);
+ --md-code-hl-string-color: var(--of-success);
+ --md-code-hl-number-color: #fca5a5;
+ --md-code-hl-constant-color: #93c5fd;
+ --md-code-hl-special-color: #f0abfc;
+ --md-code-hl-name-color: var(--md-code-fg-color);
+ --md-code-hl-operator-color: rgba(245, 240, 230, 0.62);
+ --md-code-hl-punctuation-color: rgba(245, 240, 230, 0.62);
+ --md-code-hl-comment-color: rgba(245, 240, 230, 0.42);
+ --md-code-hl-generic-color: rgba(245, 240, 230, 0.62);
+ --md-code-hl-variable-color: rgba(245, 240, 230, 0.82);
+
+ --md-admonition-fg-color: var(--md-default-fg-color);
+ --md-admonition-bg-color: var(--of-bg-card);
+
+ --md-footer-fg-color: var(--of-cream);
+ --md-footer-fg-color--light: rgba(245, 240, 230, 0.72);
+ --md-footer-fg-color--lighter: rgba(245, 240, 230, 0.4);
+ --md-footer-bg-color: var(--of-bg-card);
+ --md-footer-bg-color--dark: var(--of-bg-deep);
+
+ --md-shadow-z1: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.4), 0 0 0.05rem rgba(245, 240, 230, 0.08);
+ --md-shadow-z2: 0 0.25rem 1rem rgba(0, 0, 0, 0.55), 0 0 0.05rem rgba(245, 240, 230, 0.12);
+ --md-shadow-z3: 0 0.5rem 2rem rgba(0, 0, 0, 0.65), 0 0 0.05rem rgba(245, 240, 230, 0.14);
+}
+
+/* Subtle gold wash + film grain, as on the marketing site */
+[data-md-color-scheme="slate"] body {
+ background-image:
+ radial-gradient(ellipse at 50% 0%, rgba(212, 175, 55, 0.045) 0%, transparent 55%),
+ url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.03'/%3E%3C/svg%3E");
+ background-attachment: fixed;
+}
+
+/* --------------------------------------------------------------------------
+ Palette — light (default). Same palette inverted onto warm ivory paper so
+ the two schemes read as one brand rather than two sites.
+ -------------------------------------------------------------------------- */
+
+[data-md-color-scheme="default"] {
+ --md-default-fg-color: var(--of-ink);
+ --md-default-fg-color--light: rgba(20, 20, 28, 0.68);
+ --md-default-fg-color--lighter: rgba(20, 20, 28, 0.36);
+ --md-default-fg-color--lightest: rgba(20, 20, 28, 0.1);
+
+ --md-default-bg-color: var(--of-paper);
+ --md-default-bg-color--light: rgba(250, 247, 240, 0.75);
+ --md-default-bg-color--lighter: rgba(250, 247, 240, 0.45);
+ --md-default-bg-color--lightest: rgba(250, 247, 240, 0.2);
+
+ --md-primary-fg-color: var(--of-gold-deep);
+ --md-primary-fg-color--light: var(--of-gold-dim);
+ --md-primary-fg-color--dark: #5c470f;
+ --md-primary-bg-color: var(--of-paper);
+ --md-primary-bg-color--light: rgba(250, 247, 240, 0.7);
+
+ --md-accent-fg-color: #5c470f;
+ --md-accent-fg-color--transparent: rgba(122, 95, 20, 0.1);
+ --md-accent-bg-color: var(--of-paper);
+ --md-accent-bg-color--light: rgba(250, 247, 240, 0.7);
+
+ --md-typeset-color: var(--of-ink);
+ --md-typeset-a-color: var(--of-gold-deep);
+ --md-typeset-mark-color: rgba(212, 175, 55, 0.35);
+ --md-typeset-table-color: rgba(20, 20, 28, 0.14);
+ --md-typeset-table-color--light: rgba(20, 20, 28, 0.035);
+
+ --md-code-fg-color: #2a2a36;
+ --md-code-bg-color: #f1ece1;
+ --md-code-bg-color--light: rgba(241, 236, 225, 0.9);
+ --md-code-bg-color--lighter: rgba(241, 236, 225, 0.54);
+ --md-code-hl-color: rgba(212, 175, 55, 0.3);
+ --md-code-hl-color--light: rgba(212, 175, 55, 0.12);
+
+ --md-code-hl-keyword-color: #6d28d9;
+ --md-code-hl-function-color: #8a6a10;
+ --md-code-hl-string-color: #15803d;
+ --md-code-hl-number-color: #b91c1c;
+ --md-code-hl-constant-color: #1d4ed8;
+ --md-code-hl-special-color: #a21caf;
+
+ --md-admonition-bg-color: #f4efe4;
+}
+
+/* The modern variant paints .md-footer from --md-default-bg-color, so the
+ footer follows the active scheme. Give it a gold hairline instead — the
+ same divider the marketing site uses between sections. */
+.md-footer {
+ border-top: 1px solid color-mix(in srgb, var(--of-gold) 22%, transparent);
+}
+
+/* --------------------------------------------------------------------------
+ Typography
+ -------------------------------------------------------------------------- */
+
+/* DM Serif Display ships a single weight — never let the browser fake bold. */
+.md-typeset h1,
+.md-typeset h2,
+.md-header__topic:first-child .md-ellipsis {
+ font-family: var(--of-font-display);
+ font-weight: 400;
+ font-synthesis-weight: none;
+}
+
+.md-typeset h1 {
+ font-size: 2.1em;
+ line-height: 1.18;
+ letter-spacing: -0.015em;
+ color: var(--md-default-fg-color);
+}
+
+.md-typeset h2 {
+ margin-top: 2em;
+ font-size: 1.5em;
+ line-height: 1.25;
+ letter-spacing: -0.01em;
+}
+
+/* Hairline rule above section headings, echoing the site's section dividers */
+.md-typeset h2::before {
+ display: block;
+ width: 2.4rem;
+ height: 1px;
+ margin-bottom: 0.7em;
+ content: "";
+ background: linear-gradient(90deg, var(--of-gold), transparent);
+}
+
+.md-typeset h3,
+.md-typeset h4 {
+ font-weight: 600;
+ letter-spacing: -0.005em;
+}
+
+/* Eyebrow treatment: uppercase mono with wide tracking */
+.md-typeset h5,
+.md-typeset h6 {
+ font-family: var(--md-code-font-family);
+ font-size: 0.72em;
+ font-weight: 500;
+ letter-spacing: var(--of-tracking);
+ text-transform: uppercase;
+ color: var(--md-default-fg-color--light);
+}
+
+.md-typeset a {
+ text-underline-offset: 0.18em;
+ text-decoration-color: color-mix(in srgb, currentcolor 35%, transparent);
+}
+
+.md-typeset a:hover {
+ text-decoration-color: currentcolor;
+}
+
+::selection {
+ background: rgba(212, 175, 55, 0.3);
+}
+
+/* --------------------------------------------------------------------------
+ Header, navigation and search
+ -------------------------------------------------------------------------- */
+
+.md-header {
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
+ /* The header deliberately matches the page ground rather than using the
+ primary colour — but it must be *opaque*, or content scrolling underneath
+ the sticky tabs bar shows through the wordmark. */
+ background-color: var(--md-default-bg-color);
+ color: var(--md-default-fg-color);
+}
+
+.md-header__button.md-logo :is(img, svg) {
+ width: auto;
+ height: 1.3rem;
+ border-radius: 0.28rem;
+}
+
+/* Site name as a wide-tracked wordmark rather than plain body text */
+.md-header__topic:first-child .md-ellipsis {
+ font-size: 0.82rem;
+ letter-spacing: 0.01em;
+}
+
+.md-tabs {
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
+ /* Opaque for the same reason as .md-header — navigation.tabs.sticky keeps
+ this bar pinned while the page scrolls beneath it. */
+ background-color: var(--md-default-bg-color);
+ color: var(--md-default-fg-color);
+}
+
+.md-tabs__link {
+ opacity: 1;
+ color: var(--md-default-fg-color--light);
+}
+
+.md-tabs__link:is(:hover, :focus),
+.md-tabs__link--active {
+ color: var(--md-default-fg-color);
+}
+
+/* Sidebar section labels get the marketing site's eyebrow treatment */
+.md-nav--secondary .md-nav__title,
+.md-nav__item--section > .md-nav__link:not(.md-nav__link--index),
+.md-nav__item--section > label.md-nav__link {
+ font-family: var(--md-code-font-family);
+ font-size: 0.62rem;
+ font-weight: 500;
+ letter-spacing: var(--of-tracking);
+ text-transform: uppercase;
+ color: var(--md-default-fg-color--light);
+}
+
+.md-nav__link--active,
+.md-nav__link:focus,
+.md-nav__link:hover {
+ color: var(--md-accent-fg-color);
+}
+
+.md-search__input {
+ border-radius: 0.4rem;
+}
+
+.md-search__input::placeholder {
+ letter-spacing: 0.01em;
+}
+
+/* --------------------------------------------------------------------------
+ Code
+ -------------------------------------------------------------------------- */
+
+.md-typeset pre > code,
+.md-typeset .highlighttable {
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: 0.4rem;
+}
+
+.md-typeset .highlighttable .highlight pre > code {
+ border: none;
+}
+
+.md-typeset code {
+ border-radius: 0.25rem;
+}
+
+/* Code block titles read as mono eyebrows */
+.md-typeset .highlight span.filename {
+ font-family: var(--md-code-font-family);
+ font-size: 0.68em;
+ font-weight: 500;
+ letter-spacing: var(--of-tracking);
+ text-transform: uppercase;
+ color: var(--md-default-fg-color--light);
+ background-color: var(--md-code-bg-color);
+ border-bottom: 1px solid var(--md-default-fg-color--lightest);
+}
+
+/* --------------------------------------------------------------------------
+ Tables
+ -------------------------------------------------------------------------- */
+
+.md-typeset table:not([class]) {
+ border-radius: 0.4rem;
+}
+
+.md-typeset table:not([class]) th {
+ font-family: var(--md-code-font-family);
+ font-size: 0.86em;
+ font-weight: 500;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ color: var(--md-default-fg-color--light);
+ background-color: var(--md-typeset-table-color--light);
+}
+
+/* --------------------------------------------------------------------------
+ Buttons
+ -------------------------------------------------------------------------- */
+
+.md-typeset .md-button {
+ font-family: var(--md-code-font-family);
+ font-size: 0.72em;
+ font-weight: 500;
+ letter-spacing: var(--of-tracking);
+ text-transform: uppercase;
+ border: 1px solid var(--md-default-fg-color--lightest);
+ border-radius: 0.5rem;
+}
+
+.md-typeset .md-button--primary {
+ color: var(--of-bg-deep);
+ background-color: var(--of-gold);
+ border-color: var(--of-gold);
+}
+
+.md-typeset .md-button--primary:is(:hover, :focus) {
+ color: var(--of-bg-deep);
+ background-color: var(--of-gold-bright);
+ border-color: var(--of-gold-bright);
+}
+
+.md-typeset .md-button:not(.md-button--primary):is(:hover, :focus) {
+ color: var(--md-accent-fg-color);
+ background-color: var(--md-accent-fg-color--transparent);
+ border-color: color-mix(in srgb, var(--md-accent-fg-color) 45%, transparent);
+}
+
+/* --------------------------------------------------------------------------
+ Admonitions — retinted off Material's stock blues onto the brand accents.
+ Each type sets --of-adm; the generic rules below consume it. The [class]
+ attribute selector matches the theme's per-type specificity so these win
+ on cascade order.
+ -------------------------------------------------------------------------- */
+
+.md-typeset :is(.admonition, details) {
+ --of-adm: var(--of-gold);
+}
+
+.md-typeset :is(.admonition, details):is(.note) { --of-adm: var(--of-gold); }
+.md-typeset :is(.admonition, details):is(.abstract, .summary, .tldr) { --of-adm: var(--of-info); }
+.md-typeset :is(.admonition, details):is(.info, .todo) { --of-adm: var(--of-info); }
+.md-typeset :is(.admonition, details):is(.tip, .hint, .important) { --of-adm: #2dd4bf; }
+.md-typeset :is(.admonition, details):is(.success, .check, .done) { --of-adm: var(--of-success); }
+.md-typeset :is(.admonition, details):is(.question, .help, .faq) { --of-adm: #a3e635; }
+.md-typeset :is(.admonition, details):is(.warning, .caution, .attention) { --of-adm: var(--of-warning); }
+.md-typeset :is(.admonition, details):is(.failure, .fail, .missing) { --of-adm: #fb923c; }
+.md-typeset :is(.admonition, details):is(.danger, .error) { --of-adm: var(--of-danger); }
+.md-typeset :is(.admonition, details):is(.bug) { --of-adm: #f472b6; }
+.md-typeset :is(.admonition, details):is(.example) { --of-adm: #a78bfa; }
+.md-typeset :is(.admonition, details):is(.quote, .cite) { --of-adm: #9ca3af; }
+
+.md-typeset .admonition,
+.md-typeset details,
+.md-typeset .admonition[class],
+.md-typeset details[class] {
+ background-color: color-mix(in srgb, var(--of-adm) 9%, transparent);
+ border: 1px solid color-mix(in srgb, var(--of-adm) 26%, transparent);
+ border-radius: 0.5rem;
+}
+
+.md-typeset .admonition[class] > :is(.admonition-title, summary)::before,
+.md-typeset details[class] > :is(.admonition-title, summary)::before {
+ background-color: var(--of-adm);
+}
+
+.md-typeset .admonition[class] > :is(.admonition-title, summary)::after,
+.md-typeset details[class] > :is(.admonition-title, summary)::after {
+ color: var(--of-adm);
+}
+
+.md-typeset :is(.admonition-title, summary) {
+ font-weight: 600;
+ letter-spacing: 0.005em;
+}
+
+/* On paper the bright accents need darkening to stay legible */
+[data-md-color-scheme="default"] .md-typeset .admonition[class] > :is(.admonition-title, summary)::before,
+[data-md-color-scheme="default"] .md-typeset details[class] > :is(.admonition-title, summary)::before {
+ background-color: color-mix(in srgb, var(--of-adm) 58%, var(--of-ink));
+}
+
+[data-md-color-scheme="default"] .md-typeset .admonition[class] > :is(.admonition-title, summary)::after,
+[data-md-color-scheme="default"] .md-typeset details[class] > :is(.admonition-title, summary)::after {
+ color: color-mix(in srgb, var(--of-adm) 58%, var(--of-ink));
+}
+
+/* --------------------------------------------------------------------------
+ Content tabs, blockquotes and scrollbar
+ -------------------------------------------------------------------------- */
+
+.md-typeset .tabbed-labels > label {
+ font-family: var(--md-code-font-family);
+ font-size: 0.68em;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+
+.md-typeset blockquote {
+ color: var(--md-default-fg-color--light);
+ border-left: 2px solid color-mix(in srgb, var(--of-gold) 55%, transparent);
+}
+
+[data-md-color-scheme="slate"] {
+ scrollbar-color: rgba(212, 175, 55, 0.3) transparent;
+}
+
+::-webkit-scrollbar {
+ width: 6px;
+ height: 6px;
+}
+
+::-webkit-scrollbar-track {
+ background: transparent;
+}
+
+::-webkit-scrollbar-thumb {
+ background: color-mix(in srgb, var(--of-gold) 30%, transparent);
+ border-radius: 3px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: color-mix(in srgb, var(--of-gold) 55%, transparent);
+}
diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md
new file mode 100644
index 000000000..bf8251204
--- /dev/null
+++ b/docs/troubleshooting/index.md
@@ -0,0 +1,97 @@
+---
+icon: lucide/wrench
+---
+
+# Troubleshooting
+
+**Start here, then follow the link.** This page routes by *what you are seeing*.
+The actual fixes live next to the guide they belong to, because the same symptom
+means different things depending on which subsystem produced it.
+
+!!! tip "Run the diagnostic first"
+
+ ```bash
+ uv run python scripts/hardware-test/diagnose.py
+ ```
+
+ Seven checks across the whole signal path, each naming the link that broke.
+ It will usually answer the question faster than this page.
+ See [hardware diagnostic](../setup/diagnostic.md).
+
+## Nothing happens when I hit a ball
+
+| What you see | Go to |
+| --- | --- |
+| No trigger at all; GATE LED does not flash | [Sound trigger § No trigger received](../build/sound-trigger.md#no-trigger-received) |
+| GATE LED stays lit constantly | [Sound trigger § GATE LED stays on](../build/sound-trigger.md#gate-led-stays-on-stuck-high) |
+| Triggers fire but no I/Q data arrives | [Sound trigger § Triggers but no I/Q data](../build/sound-trigger.md#triggers-but-no-iq-data) |
+| Triggers constantly, even without a strike | [Sound trigger § Too sensitive](../build/sound-trigger.md#triggers-constantly-too-sensitive) |
+| Diagnostic says "CW mode, not rolling buffer" | [Rolling buffer setup](../setup/rolling-buffer.md) — re-run and power-cycle |
+| Radar not detected at all | [Pi setup § Radar not detected](../setup/raspberry-pi.md#radar-not-detected) |
+
+## Speeds are wrong or missing
+
+| What you see | Go to |
+| --- | --- |
+| Captures truncate or parse-fail intermittently | [UART § Troubleshooting](../build/ops243-uart.md#troubleshooting) — dropped bytes; try `--ops-baud 115200` |
+| Connects, but at 19,200 baud | [UART § Troubleshooting](../build/ops243-uart.md#troubleshooting) — Pi pin 8 → J3 pin 6 |
+| Ball speed reads low | [Radar positioning](../how-it-works/positioning.md#ops243-a) — cosine error from off-axis placement |
+| Spin looks implausible | [Rolling buffer & spin](../how-it-works/rolling-buffer.md) — spin is experimental by design |
+
+## Angles are wrong or missing
+
+| What you see | Go to |
+| --- | --- |
+| Launch angle reads zero or absent | [IWR6843 § Verify the first capture](../iwr6843/verify.md#verify-the-first-capture) |
+| Angles present but implausible | [IWR6843 § Measure the geometry](../iwr6843/mounting.md#measure-the-geometry) — wrong geometry fails silently |
+| Low-confidence angles on many shots | [Low-confidence recovery](../iwr6843/low-confidence-recovery.md) |
+| Club path looks wrong | [Club path § Set the target-line reference](../iwr6843/club-path.md#set-the-target-line-reference) |
+| Radar does not enumerate or dump | [IWR6843 troubleshooting](../iwr6843/troubleshooting.md) |
+| Estimator limits unclear | [Calibration § Estimator limitations](../iwr6843/calibration.md#launch-angle-estimator-limitations) |
+
+## Tilt and inclinometer
+
+| What you see | Go to |
+| --- | --- |
+| Green LED on, but `0x18` missing from `i2cdetect` | [Inclinometer § `0x18` is missing](../build/inclinometer.md#green-led-is-on-but-0x18-is-missing) |
+| `WHO_AM_I expected 0x33` | [Inclinometer § WHO_AM_I](../build/inclinometer.md#who_am_i-expected-0x33) |
+| Permission denied on `/dev/i2c-1` | [Inclinometer § Permission denied](../build/inclinometer.md#permission-denied-on-devi2c-1) |
+| Pitch sign is backward | [Inclinometer § Pitch sign is backward](../build/inclinometer.md#pitch-sign-is-backward) |
+| Shots report `moving` or `stale` | [Inclinometer § Noisy pitch](../build/inclinometer.md#pitch-is-noisy-or-shots-report-moving) |
+| Pi reboots or shows a black screen | [Inclinometer § Pi reboots](../build/inclinometer.md#pi-reboots-shows-a-black-screen-or-runs-the-fan-at-full-speed) |
+
+## Battery and power
+
+| What you see | Go to |
+| --- | --- |
+| OpenFlight shows a red `--` | [Battery § Red `--`](../build/battery.md#openflight-shows-a-red-) |
+| Pi taskbar says 0% | [Battery § Taskbar says 0%](../build/battery.md#raspberry-pis-taskbar-says-0) |
+| LEDs and percentage disagree | [Battery § LEDs disagree](../build/battery.md#leds-and-percentage-disagree) |
+| External power state backwards or stuck | [Battery § External power](../build/battery.md#external-power-state-is-backwards-or-stuck) |
+| Want to undo the Pi changes | [Battery § Roll back](../build/battery.md#roll-back-pi-changes) |
+
+## Software and UI
+
+| What you see | Go to |
+| --- | --- |
+| Service will not start | [Pi setup § Service won't start](../setup/raspberry-pi.md#service-wont-start) |
+| Slow UI updates | [Pi setup § Slow UI updates](../setup/raspberry-pi.md#slow-ui-updates) |
+| Display issues over SSH | [Pi setup § Display over SSH](../setup/raspberry-pi.md#display-issues-over-ssh) |
+| Shots not reaching the simulator | [Simulator connectors](../using/simulator/index.md) |
+| Cloud uploads stuck or parked | [Cloud sync § Troubleshooting](../using/cloud-sync.md#troubleshooting) |
+| Logs not arriving in Grafana | [Observability § Troubleshooting](../using/observability.md#troubleshooting) |
+
+## Deprecated K-LD7 hardware
+
+| What you see | Go to |
+| --- | --- |
+| Any K-LD7 issue | [Legacy K-LD7 troubleshooting](../legacy/troubleshooting.md) |
+| K-LD7 not connecting | [Pi setup § K-LD7 not connecting](../setup/raspberry-pi.md#k-ld7-not-connecting) |
+
+## Still stuck
+
+Collect a session log and the diagnostic output, then open an issue at
+[github.com/jewbetcha/openflight/issues](https://github.com/jewbetcha/openflight/issues).
+
+The [TrackMan test process](../development/trackman-testing.md) describes what
+data is worth collecting when a measurement looks wrong rather than absent.
diff --git a/docs/battery/README.md b/docs/using/battery.md
similarity index 99%
rename from docs/battery/README.md
rename to docs/using/battery.md
index 4aa0cebe6..8b7b88823 100644
--- a/docs/battery/README.md
+++ b/docs/using/battery.md
@@ -9,7 +9,7 @@ down Linux automatically.
| Provider | CLI value | Hardware | Setup guide |
|---|---|---|---|
-| Geekworm | `geekworm` | X1202 and X1206 | [Geekworm X1202/X1206](geekworm.md) |
+| Geekworm | `geekworm` | X1202 and X1206 | [Geekworm X1202/X1206](../build/battery.md) |
Start OpenFlight with an installed provider:
diff --git a/docs/cloud-sync.md b/docs/using/cloud-sync.md
similarity index 98%
rename from docs/cloud-sync.md
rename to docs/using/cloud-sync.md
index 8e6a3d536..790c82b57 100644
--- a/docs/cloud-sync.md
+++ b/docs/using/cloud-sync.md
@@ -160,5 +160,5 @@ exists.
## See also
-- [`docs/openflight-cloud-uploader-spec.md`](openflight-cloud-uploader-spec.md)
+- [`docs/openflight-cloud-uploader-spec.md`](../reference/cloud-uploader-spec.md)
— the wire contract this client implements (endpoints, status codes, caps).
diff --git a/docs/using/display.md b/docs/using/display.md
new file mode 100644
index 000000000..bf1a26239
--- /dev/null
+++ b/docs/using/display.md
@@ -0,0 +1,54 @@
+---
+icon: lucide/tv
+---
+
+# TV Display Mode
+
+OpenFlight serves a fullscreen-friendly browser display for tablets, TV
+browsers, or a Chrome tab cast to a Chromecast.
+
+## Setup
+
+1. Start OpenFlight as usual:
+
+ ```bash
+ scripts/start-kiosk.sh
+ ```
+
+2. Find the OpenFlight host on your LAN — its hostname or its IP address.
+
+3. From another laptop, tablet, or TV browser, open:
+
+ ```
+ http://:8080/display
+ ```
+
+4. For Chromecast, open that page in Chrome and use Chrome's built-in **Cast**
+ feature to cast the tab.
+
+!!! tip "Prefer the hostname over the IP"
+
+ Raspberry Pi OS broadcasts its hostname over mDNS (Avahi), so
+ `http://openflight.local:8080/display` keeps working even after the Pi's
+ DHCP lease expires and it returns on a different address. A bookmarked IP
+ breaks unless you reserved it on your router.
+
+ Set the name in Raspberry Pi Imager's **Hostname** field when you flash the
+ card. The default is `raspberrypi`, i.e. `raspberrypi.local`.
+
+ The viewing device has to support mDNS. macOS, iOS, Windows 10+, and most
+ Linux desktops do — some smart-TV browsers do not, so use the IP there.
+
+## Limitations
+
+This is browser and tab casting only. OpenFlight does not include native Cast
+SDK support.
+
+If the display is on a different port, pass `--web-port` and use that port in
+the URL — see [running & modes](running.md#frequently-used-flags).
+
+## Related
+
+- [Auto-start & kiosk mode](../setup/auto-start.md) — fullscreen on the Pi's own
+ display, and starting on boot
+- [Running & modes](running.md) — the full set of run options
diff --git a/docs/battery/images/battery-alert-critical.jpg b/docs/using/images/battery-alert-critical.jpg
similarity index 100%
rename from docs/battery/images/battery-alert-critical.jpg
rename to docs/using/images/battery-alert-critical.jpg
diff --git a/docs/battery/images/battery-alert-low.jpg b/docs/using/images/battery-alert-low.jpg
similarity index 100%
rename from docs/battery/images/battery-alert-low.jpg
rename to docs/using/images/battery-alert-low.jpg
diff --git a/docs/battery/images/battery-icon-critical.jpg b/docs/using/images/battery-icon-critical.jpg
similarity index 100%
rename from docs/battery/images/battery-icon-critical.jpg
rename to docs/using/images/battery-icon-critical.jpg
diff --git a/docs/battery/images/battery-icon-low.jpg b/docs/using/images/battery-icon-low.jpg
similarity index 100%
rename from docs/battery/images/battery-icon-low.jpg
rename to docs/using/images/battery-icon-low.jpg
diff --git a/docs/battery/images/battery-icon-on-battery.jpg b/docs/using/images/battery-icon-on-battery.jpg
similarity index 100%
rename from docs/battery/images/battery-icon-on-battery.jpg
rename to docs/using/images/battery-icon-on-battery.jpg
diff --git a/docs/battery/images/battery-icon-plugged-in.jpg b/docs/using/images/battery-icon-plugged-in.jpg
similarity index 100%
rename from docs/battery/images/battery-icon-plugged-in.jpg
rename to docs/using/images/battery-icon-plugged-in.jpg
diff --git a/docs/battery/images/battery-icon-unavailable.jpg b/docs/using/images/battery-icon-unavailable.jpg
similarity index 100%
rename from docs/battery/images/battery-icon-unavailable.jpg
rename to docs/using/images/battery-icon-unavailable.jpg
diff --git a/docs/battery/images/openflight-battery-status.jpg b/docs/using/images/openflight-battery-status.jpg
similarity index 100%
rename from docs/battery/images/openflight-battery-status.jpg
rename to docs/using/images/openflight-battery-status.jpg
diff --git a/docs/battery/images/raspberry-pi-taskbar.png b/docs/using/images/raspberry-pi-taskbar.png
similarity index 100%
rename from docs/battery/images/raspberry-pi-taskbar.png
rename to docs/using/images/raspberry-pi-taskbar.png
diff --git a/docs/using/index.md b/docs/using/index.md
new file mode 100644
index 000000000..9b55c2787
--- /dev/null
+++ b/docs/using/index.md
@@ -0,0 +1,45 @@
+---
+icon: lucide/circle-play
+---
+
+# Using OpenFlight
+
+Everything after the hardware works.
+
+
+
+- :material-golf: **[Simulator connectors](simulator/index.md)**
+
+ Stream shots to [GSPro](simulator/gspro.md),
+ [OpenGolfSim](simulator/opengolfsim.md), E6, and Garmin.
+
+- :material-speedometer: **[Swing speed training](swing-speed.md)**
+
+ Club-only mode for air swings and speed sticks. No ball strike, no sound
+ trigger.
+
+- :material-cloud-upload-outline: **[Cloud sync](cloud-sync.md)**
+
+ Push filtered sessions to FlightWeb, with spool-and-retry over flaky wifi.
+
+- :material-chart-line: **[Observability](observability.md)**
+
+ Ship session logs to Grafana Cloud and query them with LogQL.
+
+- :material-battery-charging: **[Battery monitoring](battery.md)**
+
+ Provider architecture, indicator states, and warning dialogs.
+
+