Changed heading logic; added snapshot feature

This commit is contained in:
pgdalmeida 2026-09-11 17:42:01 +02:00
parent cd8491dcc0
commit 1a572cb074
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
48 changed files with 2061 additions and 642 deletions

View File

@ -58,7 +58,8 @@ add_library(fgc_core STATIC
src/core/HostMetrics.cpp src/core/HostMetrics.cpp
src/core/TestRunner.cpp src/core/TestRunner.cpp
src/core/Calibration.cpp src/core/Calibration.cpp
src/core/CalibrationRoutine.cpp src/core/HeadingFit.cpp
src/core/PitchSetup.cpp
src/core/MtiProtocol.cpp src/core/MtiProtocol.cpp
src/core/ImuMount.cpp src/core/ImuMount.cpp
src/core/CameraProfile.cpp src/core/CameraProfile.cpp

View File

@ -125,12 +125,15 @@ While running, the program reads commands from stdin (one per line):
| Command | Action | | Command | Action |
|---------|--------| |---------|--------|
| `start` / `stop` | Start / stop the capture cycle (the gimbal stays powered and homed). | | `start` / `stop` | Start / stop the capture cycle (the gimbal stays powered and homed). |
| `gimbal move <yaw>,<pitch>` | Aim at an absolute heading/elevation in **degrees** (calibrated). | | `gimbal move <yaw>,<pitch>` | Aim at an absolute **true bearing** (0 = north) and elevation, in degrees. |
| `gimbal home [y\|p]` | Run the endstop-finding home sequence. | | `gimbal home [y\|p]` | Run the endstop-finding home sequence. |
| `gimbal dump` | Request a firmware state dump (shown in the gimbal `g` view). | | `gimbal dump` | Request a firmware state dump (shown in the gimbal `g` view). |
| `gimbal diag [y\|p\|all]` | Run the firmware motor self-test (results in the activity strip + `logs/`). | | `gimbal diag [y\|p\|all]` | Run the firmware motor self-test (results in the activity strip + `logs/`). |
| `gimbal calib` | IMU-referenced steps↔degrees calibration (auto-homes if needed; needs the IMU; applied to the session). | | `gimbal heading setup` | Tie yaw to true north by sighting known landmarks (TUI key `H`; **no IMU needed**). |
| `gimbal …` | Other motor controls — `steps`/`nudge`/`stop`/`speed`/`reset`/`enable`/`disable`/`setpos`/`status`. | | `gimbal heading offset <deg>` | Shift the whole yaw map by a known amount, and save it. |
| `gimbal pitch setup` | Level pitch against gravity (auto-homes if needed; **needs the IMU**). |
| `snapshot` | Take one image now and publish its CamEvent (TUI: `Space`; manual mode only). |
| `gimbal …` | Other motor controls — `steps`/`nudge`/`jog`/`stop`/`speed`/`reset`/`enable`/`disable`/`setpos`/`status`. |
| `gimbal raw "<command>"` | **Low-level:** send the quoted text to the firmware verbatim (newline added). Bypasses wrappers/unit conversion — use with care. | | `gimbal raw "<command>"` | **Low-level:** send the quoted text to the firmware verbatim (newline added). Bypasses wrappers/unit conversion — use with care. |
| `debug` | Toggle debug-level logging on/off. | | `debug` | Toggle debug-level logging on/off. |
| `set fps <n>` | Capture rate, in images per second. | | `set fps <n>` | Capture rate, in images per second. |
@ -158,7 +161,7 @@ GUI validated. `standard` runs 10 Hz for daylight, `night` runs 1 Hz with long e
[docs/configuration.md](docs/configuration.md). [docs/configuration.md](docs/configuration.md).
The **Xsens MTi IMU** (set `[Features] enable_imu` + `[IMU] device`) drives the Sensors panel's live The **Xsens MTi IMU** (set `[Features] enable_imu` + `[IMU] device`) drives the Sensors panel's live
roll/pitch/yaw and powers `gimbal calib`; the **SHT41** (`[Features] enable_env` + `[Env] i2c_device`, roll/pitch/yaw and powers `gimbal pitch setup`; the **SHT41** (`[Features] enable_env` + `[Env] i2c_device`,
or `--mock-env`) supplies the panel's ambient temperature and humidity — the MTi's device-internal or `--mock-env`) supplies the panel's ambient temperature and humidity — the MTi's device-internal
temperature is expanded-view-only, so the main window shows one unambiguous temperature. In the TUI, temperature is expanded-view-only, so the main window shows one unambiguous temperature. In the TUI,
`g` and `i` open full-screen **gimbal** and **sensors** views (the latter covering both the MTi and `g` and `i` open full-screen **gimbal** and **sensors** views (the latter covering both the MTi and

View File

@ -18,10 +18,16 @@ set(_vmb_hints
"$ENV{VIMBA_X_HOME}" "$ENV{VIMBA_X_HOME}"
"$ENV{VIMBAX_HOME}" "$ENV{VIMBAX_HOME}"
/opt/VimbaX /opt/VimbaX
/opt/VimbaX_2025-3
/opt/VimbaX_2025-1 /opt/VimbaX_2025-1
/opt/VimbaX_2024-1 /opt/VimbaX_2024-1
/opt/vimbax /opt/vimbax
/usr/local/VimbaX /usr/local/VimbaX
# The Allied Vision installer defaults to an unpacked directory under the
# user's home rather than /opt, so a developer machine typically has it here.
"$ENV{HOME}/Programs/VimbaX"
"$ENV{HOME}/Programs/VimbaX_2025-3"
"$ENV{HOME}/VimbaX"
) )
find_path(Vmb_INCLUDE_DIR find_path(Vmb_INCLUDE_DIR

View File

@ -3,6 +3,10 @@
; Copy this file to "config.ini" and edit for your deployment: ; Copy this file to "config.ini" and edit for your deployment:
; cp config/config.example.ini config.ini ; cp config/config.example.ini config.ini
; ;
; WARNING: do not edit config.ini while the program is running. `gimbal heading
; offset`, `gimbal heading setup` and `gimbal pitch setup` rewrite the [Motor]
; block in place, so concurrent edits would be lost. Stop the program first.
;
; The real config.ini is gitignored because it contains credentials. ; The real config.ini is gitignored because it contains credentials.
; MQTT credentials may instead be supplied via the environment variables ; MQTT credentials may instead be supplied via the environment variables
; FGC_MQTT_USER and FGC_MQTT_PW, which take precedence over the values here. ; FGC_MQTT_USER and FGC_MQTT_PW, which take precedence over the values here.
@ -129,9 +133,10 @@ baud = 115200
; gimbal - roll and pitch should read ~0. Tip the nose up - pitch goes ; gimbal - roll and pitch should read ~0. Tip the nose up - pitch goes
; positive. Roll to the right - roll goes positive. Slew right - yaw increases. ; positive. Roll to the right - roll goes positive. Slew right - yaw increases.
; ;
; NOTE: `gimbal calib` fits IMU yaw against encoder counts, so a calibration ; NOTE: `gimbal pitch setup` fits IMU pitch against encoder counts, so a
; made before this was set has the mount's error baked in. Re-run it after ; calibration made before this was set has the mount's error baked in. Re-run it
; changing these. ; after changing these. Yaw is unaffected - `gimbal heading setup` sights
; landmarks and never consults the IMU.
x = 0 x = 0
y = 0 y = 0
z = 0 z = 0
@ -147,40 +152,43 @@ i2c_addr = 0x44
period_ms = 2000 period_ms = 2000
[Motor] [Motor]
; Degrees<->encoder-counts calibration for each axis. The firmware speaks only ; Degrees<->encoder-counts maps, anchored on the mechanical centre found by
; in absolute encoder counts; these map them to the heading/elevation degrees ; homing. The firmware speaks only in absolute encoder counts; these map them to
; used by MQTT (target_HDG) and CamEvent. CALIBRATE on the rig after homing: ; the heading/elevation degrees used by MQTT (target_HDG) and CamEvent.
; counts = zero_count + deg * counts_per_deg ;
; counts_per_deg may be negative to flip direction. min/max_deg clamp commands. ; Homing finds both endstops, moves to the middle of that range and DEFINES the
; midpoint as <axis>_center_count. Anchoring on the midpoint rather than one
; endstop averages the error of both, and a large positive value keeps the whole
; working range positive (the driver misbehaves on negative positions).
;
; Set the heading with `gimbal heading setup` (sight two known landmarks) or
; `gimbal heading offset <deg>` to nudge it. Pitch uses `gimbal pitch setup`,
; which needs the IMU.
;
; Travel limits are NOT configured here - they are derived from the firmware's
; homed soft limits after every home, the only place they can be correct.
; ;
; Mechanical travel of this gimbal (between hard endstops, found at homing): ; Mechanical travel of this gimbal (between hard endstops, found at homing):
; Yaw ~320 deg (0 .. 320; NOT a full revolution, cannot wrap) ; Yaw ~320 deg (NOT a full revolution: there is an unreachable arc)
; Pitch 40 deg (-10 .. +30; -10 below horizon to +30 above) ; Pitch 40 deg
; Keep all scan waypoints inside these ranges or the move clamps short.
; ;
; Yaw starting point: firmware steps_per_rev=177000 over ~180 deg => ~983.33. ; Yaw starting point: firmware steps_per_rev=177000 over ~180 deg => ~983.33.
yaw_counts_per_deg = 983.33 yaw_counts_per_deg = 983.33
yaw_zero_count = 500000 yaw_center_count = 500000
yaw_min_deg = -90 ; True bearing (0 = north) the gimbal points at when parked at the centre.
yaw_max_deg = 90 yaw_heading_at_center = 0.0
; Re-anchor the yaw zero from homing instead of the fixed yaw_zero_count above: ; Clearance kept clear of each hard endstop, in counts. Sent to the firmware,
; off (default) - use yaw_zero_count as written ; which enforces it. Counts, not degrees: it must apply at homing time, before
; low - the lower-count endstop becomes 0 deg, degrees rise toward ; any degree map is trusted.
; the higher-count endstop (which lands near +360) yaw_soft_margin_counts = 5000
; high - the higher-count endstop becomes 0 deg, degrees rise toward ; Pitch (physical endstops). CALIBRATE with `gimbal pitch setup`.
; the lower-count endstop (which lands near +360)
; Keeps yaw_counts_per_deg as the scale; only the zero + direction come from the
; homed endstops, so the yaw origin is fixed by homing (no zero recalibration).
; yaw_min_deg/yaw_max_deg are auto-set to 0..(homed range). Pitch is unaffected.
yaw_home_zero = off
; Pitch (physical endstops, -10..+30 deg = 40 deg travel). CALIBRATE counts_per_deg/zero_count.
pitch_counts_per_deg = 8333.33 pitch_counts_per_deg = 8333.33
pitch_zero_count = 0 pitch_center_count = 500000
pitch_min_deg = -10 ; Elevation above level (+ = up) at the centre. This is NOT 0: the mechanical
pitch_max_deg = 30 ; centre is the middle of pitch travel, which sits well above the horizon.
; Live encoder-tracking WARN: log a warning when an axis's |xactual - xenc| pitch_angle_at_center = 10.0
; exceeds this many counts (positional slip). 0 disables. Default 400 sits above pitch_soft_margin_counts = 5000
; the firmware hold deadband (200) and below the DIAG peak limit (500). ; Live encoder-error WARN threshold in counts (0 = off).
enc_error_warn_counts = 400 enc_error_warn_counts = 400
[Scan] [Scan]

View File

@ -22,8 +22,8 @@ The design separates **policy** (the control logic) from **mechanism** (the I/O
| `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) | | `IEnvSensor` | `Sht41EnvSensor` (SHT41 over I2C) | `MockEnvSensor` (synthetic temp/humidity) |
Both sensors are optional (`[Features] enable_imu` / `enable_env`) and feed the Sensors panel: the IMU Both sensors are optional (`[Features] enable_imu` / `enable_env`) and feed the Sensors panel: the IMU
supplies orientation and the IMU-referenced `gimbal calib`, the SHT41 the ambient temperature and supplies orientation and the IMU-referenced `gimbal pitch setup`, the SHT41 the ambient temperature and
humidity (also published on the MQTT `Env` topic). Long-running operations (`gimbal calib` on a worker thread, `gimbal diag` captured from humidity. Long-running operations (`gimbal pitch setup` on a worker thread, `gimbal diag` captured from
the firmware) publish progress + results into the `UiSnapshot` activity strip, polled on the control the firmware) publish progress + results into the `UiSnapshot` activity strip, polled on the control
thread so all geometry/state mutation stays single-threaded. thread so all geometry/state mutation stays single-threaded.

View File

@ -57,15 +57,23 @@ first configure needs network access.
### Allied Vision Vimba X SDK (proprietary) ### Allied Vision Vimba X SDK (proprietary)
`VmbC`/`VmbCPP` are not in any package manager. Download the Vimba X SDK from Allied Vision `VmbC`/`VmbCPP` are not in any package manager. Download the Vimba X SDK from Allied Vision
(<https://www.alliedvision.com/en/products/software/vimba-x-sdk/>) and point CMake at it: (<https://www.alliedvision.com/en/products/software/vimba-x-sdk/>).
[cmake/FindVmb.cmake](../cmake/FindVmb.cmake) searches the usual locations — `/opt/VimbaX*`,
`/usr/local/VimbaX`, and `~/Programs/VimbaX*` (where the installer unpacks by default on a developer
machine) — so an ordinary install needs no flag at all. Point it explicitly only for an unusual path:
```bash ```bash
cmake -B build -DVMB_HOME=/opt/VimbaX_2024-1 cmake -B build -DVMB_HOME=/opt/VimbaX_2025-3
# or: export VIMBA_X_HOME=/opt/VimbaX_2024-1 # or: export VIMBA_X_HOME=/opt/VimbaX_2025-3
``` ```
[cmake/FindVmb.cmake](../cmake/FindVmb.cmake) locates the headers/libs and creates `Vmb::VmbC` / `Vmb::VmbCPP`. It creates the `Vmb::VmbC` / `Vmb::VmbCPP` imported targets. If the SDK is absent, build with
If the SDK is absent, build with `-DWITH_VIMBA=OFF` to get a mock-only binary. `-DWITH_VIMBA=OFF` to get a mock-only binary.
**Build with `WITH_VIMBA=ON` before touching `VimbaCameraSource.cpp`.** It is the one source file the
mock-only configurations do not compile, so a break in it passes every `-DWITH_VIMBA=OFF` build and
only shows up on the rig.
## Host setup (for a real run) ## Host setup (for a real run)
@ -84,8 +92,8 @@ If the SDK is absent, build with `-DWITH_VIMBA=OFF` to get a mock-only binary.
## Run ## Run
```bash ```bash
scripts/run.sh --start # real deployment scripts/run.sh --init --start # real deployment: home, then start
scripts/run.sh --init --start # find endstops, then start scripts/run.sh --start # only if already homed this power cycle
scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware
``` ```

View File

@ -65,8 +65,10 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Logging` | `trace` | csv | — | Wire-trace categories, off by default (`--trace` overrides) | | `Logging` | `trace` | csv | — | Wire-trace categories, off by default (`--trace` overrides) |
| `UI` | `enable_tui` | bool | `false` | Full-screen terminal dashboard (`--tui`/`--no-tui` override; needs `WITH_TUI=ON`) | | `UI` | `enable_tui` | bool | `false` | Full-screen terminal dashboard (`--tui`/`--no-tui` override; needs `WITH_TUI=ON`) |
| `Motor` | `yaw_counts_per_deg` / `pitch_counts_per_deg` | float | `983.33` / — | Encoder counts per degree (**calibrate**; may be negative to flip) | | `Motor` | `yaw_counts_per_deg` / `pitch_counts_per_deg` | float | `983.33` / — | Encoder counts per degree (**calibrate**; may be negative to flip) |
| `Motor` | `yaw_zero_count` / `pitch_zero_count` | int | `500000` / `0` | `xenc` value that = 0° | | `Motor` | `yaw_center_count` / `pitch_center_count` | int | `500000` | Count homing **defines** as the middle of the found travel |
| `Motor` | `yaw_min_deg`/`yaw_max_deg`/`pitch_*` | float | `-90`/`90`/… | Soft clamp on commanded degrees | | `Motor` | `yaw_heading_at_center` | float | `0` | True bearing (0 = north) at that centre (**calibrate**) |
| `Motor` | `pitch_angle_at_center` | float | `0` | Elevation above level at that centre (**calibrate**; not 0 on a real rig) |
| `Motor` | `yaw_soft_margin_counts` / `pitch_soft_margin_counts` | int | `5000` | Clearance kept from each hard endstop; sent to the firmware, which enforces it |
| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints; empty → generate | | `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints; empty → generate |
| `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` | | `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` |
| `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc | | `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc |
@ -74,47 +76,91 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
### `[Motor]` calibration & `[Scan]` grid ### `[Motor]` calibration & `[Scan]` grid
The firmware reports only **encoder counts**; `[Motor]` maps them to the heading/elevation degrees The firmware reports only **encoder counts**; `[Motor]` maps them to the bearing/elevation degrees
used by MQTT (`target_HDG`) and `CamEvent`. Calibrate `*_counts_per_deg` / `*_zero_count` against used by MQTT (`target_HDG`) and `CamEvent`. **Yaw degrees are true bearings: 0 is north.** Pitch
real `xenc` readings after homing (`MOVE` a known angle, read the resulting `xenc`), **or** run the degrees are elevation above level, so 0 is horizontal.
automated IMU-referenced `gimbal calib` (below).
#### `gimbal calib` — automated IMU-referenced calibration #### The counts are anchored by homing
`gimbal calib` (requires the IMU) fits each axis's `counts_per_deg` / `zero_count` by sweeping it and Homing finds both hard endstops, moves to the middle of that range, and **defines** the midpoint as
correlating encoder counts with the MTi's measured angle. The sequence is deliberately ordered so the `<axis>_center_count` (default 500000). Two consequences worth understanding:
**yaw fit is not spoiled by the IMU's heading drift** (see the no-magnetometer discussion below):
1. **Home if needed** — if the gimbal is not already `READY`, it runs the endstop-finding home first. - **The origin is reproducible.** Power-cycle the gimbal, re-home, and the limits land on the same
2. **Pitch at the first yaw position** — yaw moves to the start of its travel and holds there while numbers. Before this change the count origin was whatever the free-running encoder register
pitch is swept across its soft-limit travel; at each step it dwells and records the happened to hold at boot, so a saved `zero_count` silently pointed somewhere else after a restart.
gravity-referenced IMU **pitch** (stable, absolute), then least-squares fits pitch. - **Both endstops contribute.** Anchoring on the midpoint averages the error of the two searches
3. **Pitch → 0°** — using the just-fitted pitch map. instead of inheriting whichever one was used as the reference.
4. **Switch to a no-magnetometer XKF profile** — picked from the device's own available-profiles list
(a `*nomag*` profile, else a `VRU` one), so the IMU's heading stops chasing the stepper-distorted
magnetic field. Sent in the MTi's Config state, so it **persists on the device** across power-cycles.
Best-effort: if the device reports no magnetometer-free profile, this step is skipped (logged) and
calibration continues.
5. **Drift-correct + zero the heading** — holding the gimbal still, it runs the MTi **no-rotation**
gyro-bias update (cuts yaw drift), then a **heading reset** so the current pose becomes yaw 0. This
happens right before the yaw sweep, so any drift accrued during the slow pitch sweep is discarded.
6. **Yaw sweep** — yaw is swept from that first (now zero-heading) position and fitted.
After calibration the IMU is left on the no-mag profile (this is intentional — the gimbal's homed A large positive anchor also keeps the whole working range positive, which the driver wants.
encoders are the absolute heading reference; the IMU only needs stable roll/pitch and short-term
yaw-rate). The `i` view's **XKF profile** row will show the new selection (press `r` to refresh if you
changed it outside calibration).
Each axis fit reports an R² (shown in the gimbal `g` view + activity strip). The result is applied to `*_min_deg`/`*_max_deg` are **no longer configured**. They are derived from the firmware's homed soft
the live session immediately; in the TUI the activity strip then offers to save it to `[Motor]` as the limits after every home — the only place they can be correct. The host verifies on each home that the
new default (`y`/`n`). `gimbal stop` cancels a run. Timings (dwell, no-rotation duration, etc.) are the reported midpoint really is `<axis>_center_count`; a mismatch means the firmware did not adopt the
`CalibParams` defaults in [CalibrationRoutine.h](../include/fgc/CalibrationRoutine.h). `CENTER` command (it predates this host) and is reported as an error rather than papered over.
`*_soft_margin_counts` is the clearance kept from each hard endstop. It is sent to the firmware, which
enforces it, and takes effect immediately without re-homing. It is in **counts, not degrees**, because
it must apply at homing time — before any degrees↔counts map is trusted — and must stay correct even
when the heading calibration is absent or wrong.
#### `gimbal heading setup` — tying yaw to true north
Aim at landmarks whose true bearing you know (a mast, a peak, a road junction on the map) and record
the encoder position against each bearing. **No IMU is required**, which is the point: not every tower
has one, and MTi yaw drifts too much to be a heading reference.
In the TUI press **`H`** (manual mode only):
| Key | Action |
|---|---|
| `←` `→` | Aim yaw |
| `↑` `↓` | Aim pitch (centre the landmark vertically) |
| `Tab` | Cycle step size: coarse (5% of travel) → fine (0.5%) → very fine (25 counts, ≈0.05°) |
| `Space` | Record an image + `CamEvent`, exactly as a scan waypoint would |
| `Enter` | Freeze the position and type that landmark's true bearing |
| `f` | Fit and apply |
| `Esc` | Cancel |
Two landmarks are the minimum and fit the line exactly; more are least-squares fitted and report an
R². **Sightings closer than 10° apart are refused** — two points always fit perfectly, so a short
baseline produces a healthy-looking R² and a wildly wrong scale, and refusing is the only way you
find out. A set spanning north is handled (bearings are unwrapped before fitting).
The very-fine step is 25 counts rather than 1 because `ENC_CONST` makes the encoder advance in
12.8-microstep quanta: a smaller step moves the motor to a position it cannot reproduce.
The result is applied to the live session immediately, and in the TUI the activity strip offers to
save it to `[Motor]` (`y`/`n`). On a headless tower the same fit is available directly:
```
gimbal heading fit 480000:45.0 520000:115.0
gimbal heading offset -2.5 # shift the whole map by a known amount, and save
```
#### `gimbal pitch setup` — levelling pitch against gravity
`gimbal pitch setup` (**requires the IMU**) fits `pitch_counts_per_deg` and `pitch_angle_at_center` by
sweeping pitch and correlating encoder counts with the MTi's gravity-referenced pitch. It auto-homes
if needed, dwells at each step, fits, then parks level. Writes `logs/pitch_setup_*.log`, reports R²,
and offers the same save prompt. `stop` or Esc cancels. Timings are the `CalibParams` defaults in
[PitchSetup.h](../include/fgc/PitchSetup.h).
This is the one axis that genuinely needs the IMU — level is defined by gravity and nothing else on
the tower can measure it. Yaw is untouched by it.
#### Scan grid
The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits, always front to The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits, always front to
back. Set back. Set `[Scan] grid_file` to an editable CSV ([config/scan.csv](../config/scan.csv)) to define exact
`[Scan] grid_file` to an editable CSV ([config/scan.csv](../config/scan.csv)) to define exact
coordinates, or leave it blank to generate `yaw_intervals × pitch_levels` points. coordinates, or leave it blank to generate `yaw_intervals × pitch_levels` points.
**Grid yaw values are true bearings.** Yaw travel is under a full turn, so there is always an
unreachable arc; waypoints falling in it are **skipped** with a warning rather than clamped to the
nearest limit — clamping would image a direction nobody asked for and publish it under the bearing
they did. A one-line audit after each home reports how many waypoints are affected. Generated grids
interpolate `yaw_min_deg → yaw_max_deg` linearly and wrap each point, so an arc crossing north is
written monotonically (`340` → `380`).
Each camera's output subfolder is its `[Camera.<name>] label` (defaulting to the block name); nothing is derived from the order of the blocks. Each camera's output subfolder is its `[Camera.<name>] label` (defaulting to the block name); nothing is derived from the order of the blocks.
### `[IMU]` — Xsens MTi orientation sensor ### `[IMU]` — Xsens MTi orientation sensor
@ -268,8 +314,9 @@ section, it comes from `config.ini` rather than from the device.
**To check it on the rig:** level the gimbal — roll and pitch should read ≈0. Tip the nose up — pitch **To check it on the rig:** level the gimbal — roll and pitch should read ≈0. Tip the nose up — pitch
goes positive. Roll to the right — roll goes positive. Slew right — yaw increases. goes positive. Roll to the right — roll goes positive. Slew right — yaw increases.
**Re-run `gimbal calib` after changing the mount.** Calibration fits IMU yaw against encoder counts, so **Re-run `gimbal pitch setup` after changing the mount.** It fits IMU pitch against encoder counts, so
a calibration made under a different (or absent) mount has that error baked into `yaw_zero_count`. a calibration made under a different (or absent) mount has that error baked into
`pitch_angle_at_center`. Yaw is unaffected — `gimbal heading setup` uses landmarks, not the IMU.
`imu zero` is unaffected: it acts on the device, before this rotation, and the two compose correctly — `imu zero` is unaffected: it acts on the device, before this rotation, and the two compose correctly —
the mount is what makes "zero" mean zero *in the gimbal frame*. the mount is what makes "zero" mean zero *in the gimbal frame*.
@ -452,7 +499,7 @@ one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp).
- **Gimbal** (`g`) — both axes side by side: live telemetry, the decoded firmware register **dump** - **Gimbal** (`g`) — both axes side by side: live telemetry, the decoded firmware register **dump**
(`d` requests a fresh one), the homing limits, and the **last calibration** result (per-axis (`d` requests a fresh one), the homing limits, and the **last calibration** result (per-axis
`counts_per_deg` / `zero_count` / R² / age). `counts_per_deg` / `deg@ctr` / R² / age).
- **Cameras** (`c`) — a block per configured camera: identity (`name`, `label`, `id`, `driver`, - **Cameras** (`c`) — a block per configured camera: identity (`name`, `label`, `id`, `driver`,
enabled), the active **profile** and the others available, the `profile_dir` and the exact XML enabled), the active **profile** and the others available, the `profile_dir` and the exact XML
applied, the per-profile `settle_wait_ms`, `rotation_deg` and the JPEG XL settings, plus live applied, the per-profile `settle_wait_ms`, `rotation_deg` and the JPEG XL settings, plus live
@ -472,18 +519,21 @@ one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp).
(`enable_env` / `--mock-env`) or, when enabled but silent, the staleness window that was missed. (`enable_env` / `--mock-env`) or, when enabled but silent, the staleness window that was missed.
**Activity strip** — a compact section between the log and the key bar that shows the **Activity strip** — a compact section between the log and the key bar that shows the
currently-running special operation with live progress (`test`, `gimbal calib`, homing, capture currently-running special operation with live progress (`test`, `gimbal pitch setup`, homing, capture
scan) **and persists the last test/calibration result** (PASS/FAIL, age) so it doesn't scroll scan) **and persists the last test/calibration result** (PASS/FAIL, age) so it doesn't scroll
away in the log. It only appears once something has run. While a procedure is running, **Esc** away in the log. It only appears once something has run. While a procedure is running, **Esc**
cancels it. cancels it.
When a `gimbal calib` completes it is applied to the live session and the strip shows a highlighted When a `gimbal pitch setup` or `gimbal heading fit` completes it is applied to the live session and the
yes/no prompt — **`Save this calibration to config as the new default? (y / n)`**. Press **`y`** to strip shows a highlighted yes/no prompt — **`Save the new [Motor] calibration to config as the
write the full `[Motor]` per-axis map — `*_counts_per_deg`, `*_zero_count`, and `*_min_deg`/`*_max_deg` default? (y / n)`**. Press **`y`** to write the `[Motor]` map — `*_counts_per_deg`, `*_center_count`,
— back into the `config.ini` the program was launched with (replacing those keys in place, preserving `yaw_heading_at_center`/`pitch_angle_at_center` and `*_soft_margin_counts` — back into the
everything else) so it persists across restarts; press **`n`** to keep it for this session only. The keys are active only while the prompt is `config.ini` the program was launched with (replacing those keys in place, preserving comments and
showing. (Until you answer `y`, calibration remains session-only — see everything else) so it persists across restarts; press **`n`** to keep it for this session only. The
[known-issues.md](known-issues.md).) keys are active only while the prompt is showing. The file is written to a temp file and renamed over
the original, so an interrupted save cannot truncate your config.
`gimbal heading offset` saves immediately without asking — it is a one-line correction, not a fit.
**The key bar is contextual**: it advertises the keys that are useful in the current view, not every **The key bar is contextual**: it advertises the keys that are useful in the current view, not every
binding. `s` start and `x` stop lead everywhere — a scan can be started and stopped from any view — binding. `s` start and `x` stop lead everywhere — a scan can be started and stopped from any view —

View File

@ -137,7 +137,7 @@ frames, and confirm discovery with the Vimba X `VimbaXViewer`/`ListCameras` tool
```bash ```bash
cd /opt/fire_gimbal_control cd /opt/fire_gimbal_control
set -a; . /etc/fire_gimbal_control/credentials.env; set +a set -a; . /etc/fire_gimbal_control/credentials.env; set +a
./fire_gimbal_control --config ./config.ini --start --log-level debug ./fire_gimbal_control --config ./config.ini --init --start --log-level debug
``` ```
Watch for: `Loaded config`, `Serial controller started`, `Opened camera ...`, `MQTT connected`, then Watch for: `Loaded config`, `Serial controller started`, `Opened camera ...`, `MQTT connected`, then

View File

@ -27,7 +27,7 @@ doctest unit-test suite (`ctest`).
| # | Issue | Status | | # | Issue | Status |
|---|-------|--------| |---|-------|--------|
| 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Calibrate `*_counts_per_deg` / `*_zero_count` against real `xenc` readings after homing on the rig. | | 13 | `[Motor]` degrees↔counts calibration | The `config.example.ini` values are **placeholders**. Home, then run `gimbal heading setup` (yaw, needs no IMU) and `gimbal pitch setup` (pitch, needs the IMU) on the rig. |
| 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). | | 14 | Capture sweep on hardware | **Mechanically verified on the rig (2026-08-07).** A full `--init --start` run homed both axes and completed `MOVE → settle → trigger` for 15/15 waypoints with 0 dropped frames, 0 stalls and 0 acquisition failures. `kSettleTolCounts` (600) and the per-interval timing in [CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) have **not** been tuned against observed `ST` behaviour yet, and the settle tolerance has not been checked against a sharp image (see #16). |
| 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. | | 15 | **Camera profiles unverified on a lit scene** | The `standard`/`night` profiles must be exported from the VimbaX Viewer (see `config/camera_profiles/rgb/README.md`) and have not been measured against real light. Confirm on the rig that `LoadSettings` succeeds, that `standard` sustains 10 fps with `frames_dropped` at 0, and that the auto-exposure lands on a sensible `ExposureTime` outdoors. The only previous rig test ran with the lens covered. |
| 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. | | 16 | **`settle_wait_ms` untuned** | `standard` waits 1 s and `night` 2.5 s after standstill before keeping a frame. Both are reasoned (≫ one frame period; long enough for the auto-exposure to see the new scene) but not measured. Check on a lit scene that the kept frame shows no slew blur and that its exposure has actually converged for that heading, and shorten the wait if it has. |
@ -124,17 +124,20 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
- **`[IMU] x` should be re-confirmed now that the reference is corrected.** `x = 180` was - **`[IMU] x` should be re-confirmed now that the reference is corrected.** `x = 180` was
set while pitch was inverted. Level the gimbal and confirm roll and pitch ≈ 0, tip the set while pitch was inverted. Level the gimbal and confirm roll and pitch ≈ 0, tip the
nose up and confirm pitch goes **positive**, roll right and confirm roll goes positive. nose up and confirm pitch goes **positive**, roll right and confirm roll goes positive.
Then re-run `gimbal calib` — its yaw reference moved with the earth-frame correction. Then re-run `gimbal pitch setup` — its reference moved with the earth-frame correction.
Yaw is unaffected: `gimbal heading setup` uses landmarks, not the IMU.
- **The handshake still does not await acks for its state transitions.** - **The handshake still does not await acks for its state transitions.**
`GoToConfig`/`GoToMeasurement` are blind writes separated by 60 ms sleeps `GoToConfig`/`GoToMeasurement` are blind writes separated by 60 ms sleeps
([MtiImuSource.cpp](../src/serial/MtiImuSource.cpp)). The readback verification makes ([MtiImuSource.cpp](../src/serial/MtiImuSource.cpp)). The readback verification makes
this harmless for correctness — if `GoToConfig` is missed the Sets do nothing and the this harmless for correctness — if `GoToConfig` is missed the Sets do nothing and the
mismatch is reported — but a missed transition is diagnosed one step later than it mismatch is reported — but a missed transition is diagnosed one step later than it
happened. happened.
- **A saved calibration silently predates the IMU mount.** `gimbal calib` fits IMU yaw against encoder - **A saved pitch calibration silently predates the IMU mount.** `gimbal pitch setup` fits IMU pitch
counts, so a calibration made before `[IMU] x/y/z` were set — or under different values — has that against encoder counts, so a calibration made before `[IMU] x/y/z` were set — or under different
mount error baked into `yaw_zero_count`. Nothing detects this; re-run `gimbal calib` after changing values — has that mount error baked into `pitch_angle_at_center`. Nothing detects this; re-run
the mount. Stamping the mount into the saved `[Motor]` block and warning on a mismatch would close it. `gimbal pitch setup` after changing the mount. Stamping the mount into the saved `[Motor]` block and
warning on a mismatch would close it. Yaw no longer has this exposure at all, since
`gimbal heading setup` never consults the IMU.
- **The mount is host-side only.** The rotation is applied to the MTi's output, so the XKF filter itself - **The mount is host-side only.** The rotation is applied to the MTi's output, so the XKF filter itself
still runs in the sensor frame. Xsens `SetAlignmentRotation` (MID 0xEC) would push the alignment into still runs in the sensor frame. Xsens `SetAlignmentRotation` (MID 0xEC) would push the alignment into
the device so the filter's own gravity/magnetic corrections are made in the gimbal frame — better the device so the filter's own gravity/magnetic corrections are made in the gimbal frame — better
@ -153,18 +156,17 @@ scripts for this are worth keeping; note that wrapping them in `timeout` SIGTERM
thermal or NIR unit needs a new `ICameraSource` implementation behind that key. thermal or NIR unit needs a new `ICameraSource` implementation behind that key.
- Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was - Reintroduce optional image upload to the ground station, config-driven (the old hardcoded NFS/SMB upload was
removed). removed).
- **`gimbal calib` persistence**: the fitted `counts_per_deg`/`zero_count` are always applied to the live - **Calibration persistence needs the TUI.** `gimbal pitch setup` and `gimbal heading fit` are always
session (display, manual moves, MQTT heading, **and** the capture scheduler) and written to applied to the live session (display, manual moves, MQTT bearings, **and** the capture scheduler) and
`logs/calib_*.log`. In the **TUI**, the activity strip then prompts `Save … as the new default? (y/n)` logged. In the **TUI**, the activity strip then prompts `Save … as the default? (y/n)` — `y` writes
— `y` writes the full per-axis `[Motor]` map (`*_counts_per_deg`, `*_zero_count`, the `[Motor]` map into `config.ini`, `n` keeps it session-only. In the **headless** console there is
`*_min_deg`/`*_max_deg`) into `config.ini` (persists across restarts), `n` keeps them no prompt, so it stays session-only there; copy the logged values into `[Motor]` by hand.
session-only. In the **headless** console there is no prompt, so calibration stays session-only there; `gimbal heading offset` is the exception — it saves immediately, with no prompt.
copy the logged values into `[Motor]` by hand to keep them.
- **`gimbal nudge`** moves a fixed fraction of the **homed** endstop-to-endstop travel (from the - **`gimbal nudge`** moves a fixed fraction of the **homed** endstop-to-endstop travel (from the
firmware dump), so it is independent of the degrees↔counts calibration. If no dump has been captured firmware dump), so it is independent of the degrees↔counts calibration. If no dump has been captured
yet (not homed / no `gimbal dump`), nudge requests one and does nothing that press — it never falls yet (not homed / no `gimbal dump`), nudge requests one and does nothing that press — it never falls
back to the configured degree clamps (which, if `*_min_deg`/`*_max_deg` were unset, produced absurd back to a degree-derived guess. `gimbal jog` is the raw-counts equivalent when a finer step is
±100000° steps). wanted.
- Ambient temperature/humidity (SHT41, `IEnvSensor`/`Sht41EnvSensor`/`MockEnvSensor`) is fully - Ambient temperature/humidity (SHT41, `IEnvSensor`/`Sht41EnvSensor`/`MockEnvSensor`) is fully
integrated and **validated against the physical sensor on the LattePanda** — config-gated integrated and **validated against the physical sensor on the LattePanda** — config-gated
(`[Features] enable_env`, `[Env]`), Sensors panel + expanded `i` view + MQTT `Env` topic wired. (`[Features] enable_env`, `[Env]`), Sensors panel + expanded `i` view + MQTT `Env` topic wired.

View File

@ -17,7 +17,8 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/DumpParser.h](../include/fgc/DumpParser.h), [src/core/DumpParser.cpp](../src/core/DumpParser.cpp) | `parseDump` firmware `DUMP` block → `DumpData` (per-axis state + decoded TMC registers/flags); `formatDump` | | [include/fgc/DumpParser.h](../include/fgc/DumpParser.h), [src/core/DumpParser.cpp](../src/core/DumpParser.cpp) | `parseDump` firmware `DUMP` block → `DumpData` (per-axis state + decoded TMC registers/flags); `formatDump` |
| [include/fgc/DiagParser.h](../include/fgc/DiagParser.h), [src/core/DiagParser.cpp](../src/core/DiagParser.cpp) | `parseDiag` firmware `DG` self-test stream → `DiagResult` (per-axis tests, PASS/FAIL); `formatDiag` | | [include/fgc/DiagParser.h](../include/fgc/DiagParser.h), [src/core/DiagParser.cpp](../src/core/DiagParser.cpp) | `parseDiag` firmware `DG` self-test stream → `DiagResult` (per-axis tests, PASS/FAIL); `formatDiag` |
| [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration | | [include/fgc/Calibration.h](../include/fgc/Calibration.h), [src/core/Calibration.cpp](../src/core/Calibration.cpp) | `linearFit` (least-squares, R²) + `circularMeanDeg` for the IMU-referenced calibration |
| [include/fgc/CalibrationRoutine.h](../include/fgc/CalibrationRoutine.h), [src/core/CalibrationRoutine.cpp](../src/core/CalibrationRoutine.cpp) | `gimbal calib` worker thread: home-if-needed → pitch sweep at the first yaw position → pitch 0° → switch IMU to a no-mag XKF profile → IMU no-rotation + heading reset → yaw sweep, fitting degrees↔counts; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()` | | [include/fgc/PitchSetup.h](../include/fgc/PitchSetup.h), [src/core/PitchSetup.cpp](../src/core/PitchSetup.cpp) | `gimbal pitch setup` worker thread: home-if-needed → pitch sweep dwelling on the gravity-referenced IMU pitch → least-squares fit → park level; tunable via `CalibParams`; exposes `progress()`/`report()`/`takeResult()`. Yaw is not touched |
| [include/fgc/HeadingFit.h](../include/fgc/HeadingFit.h), [src/core/HeadingFit.cpp](../src/core/HeadingFit.cpp) | `gimbal heading fit`: least-squares fit of the yaw map from operator sightings of landmarks with known true bearings. Sorts by encoder position, unwraps bearings so a set spanning north fits one line, and refuses a baseline under 10° (two points always fit exactly, so a short baseline looks perfect and is wrong). No IMU involved |
| [include/fgc/MtiProtocol.h](../include/fgc/MtiProtocol.h), [src/core/MtiProtocol.cpp](../src/core/MtiProtocol.cpp) | Xsens MTi binary protocol: `MtiFramer` (checksum framing), config-message builders, `parseMTData` → `ImuSample` (temp/acc/gyr/mag/euler), config-readback query builders + `applyImuConfigAck`/`finalizeImuConfig` → `ImuDeviceConfig` (product/firmware/device-id/output mode+settings/sample rate/**XKF scenario**); orientation-control builders `msgSetNoRotation` (gyro-bias update) + `msgResetOrientation` (heading reset / store) + `msgSetFilterProfile` (select XKF profile) + `pickNoMagProfile` (choose a magnetometer-free profile from the available list); named `mtmode`/`mtsettings` bitfields with `mtDataLen` deriving the payload length from them (so `kMTDataLen` cannot drift from what the parser decodes), plus `checkImuDeviceConfig` → `ImuConfigMismatch` (does the device report what the host asked for?) and `mtiErrorText` for MID `0x42` rejections | | [include/fgc/MtiProtocol.h](../include/fgc/MtiProtocol.h), [src/core/MtiProtocol.cpp](../src/core/MtiProtocol.cpp) | Xsens MTi binary protocol: `MtiFramer` (checksum framing), config-message builders, `parseMTData` → `ImuSample` (temp/acc/gyr/mag/euler), config-readback query builders + `applyImuConfigAck`/`finalizeImuConfig` → `ImuDeviceConfig` (product/firmware/device-id/output mode+settings/sample rate/**XKF scenario**); orientation-control builders `msgSetNoRotation` (gyro-bias update) + `msgResetOrientation` (heading reset / store) + `msgSetFilterProfile` (select XKF profile) + `pickNoMagProfile` (choose a magnetometer-free profile from the available list); named `mtmode`/`mtsettings` bitfields with `mtDataLen` deriving the payload length from them (so `kMTDataLen` cannot drift from what the parser decodes), plus `checkImuDeviceConfig` → `ImuConfigMismatch` (does the device report what the host asked for?) and `mtiErrorText` for MID `0x42` rejections |
| [include/fgc/ImuMount.h](../include/fgc/ImuMount.h), [src/core/ImuMount.cpp](../src/core/ImuMount.cpp) | `ImuMount` (the `[IMU] x/y/z` mounting rotation) and the pure math around it: `mountRotationZXY` (intrinsic Z-X-Y), the `rotationFromEulerZYX`/`eulerZYXFromRotation` pair for the MTi's own yaw-pitch-roll output, `applyFrames`, which moves a whole `ImuSample` into the gimbal's NED frame by re-referencing the earth frame (`ImuReference`/`nedFromReference`, left-multiplied) and then applying the mount (right-multiplied) — the two are not interchangeable, which is why a mount alone cannot correct a Z-up device; `applyMount` is the NED-already shorthand. Body-frame vectors are rotated by the mount only. Returns the sample untouched when there is nothing to do | | [include/fgc/ImuMount.h](../include/fgc/ImuMount.h), [src/core/ImuMount.cpp](../src/core/ImuMount.cpp) | `ImuMount` (the `[IMU] x/y/z` mounting rotation) and the pure math around it: `mountRotationZXY` (intrinsic Z-X-Y), the `rotationFromEulerZYX`/`eulerZYXFromRotation` pair for the MTi's own yaw-pitch-roll output, `applyFrames`, which moves a whole `ImuSample` into the gimbal's NED frame by re-referencing the earth frame (`ImuReference`/`nedFromReference`, left-multiplied) and then applying the mount (right-multiplied) — the two are not interchangeable, which is why a mount alone cannot correct a Z-up device; `applyMount` is the NED-already shorthand. Body-frame vectors are rotated by the mount only. Returns the sample untouched when there is nothing to do |
| [include/fgc/MountedImuSource.h](../include/fgc/MountedImuSource.h) | Decorator over any `IImuSource` applying that rotation in `sample()`. Wrapped around the factory's output in `Application::makeImu()`, so every consumer reads gimbal-frame values without knowing a mount exists; skipped entirely when the mount is the identity | | [include/fgc/MountedImuSource.h](../include/fgc/MountedImuSource.h) | Decorator over any `IImuSource` applying that rotation in `sample()`. Wrapped around the factory's output in `Application::makeImu()`, so every consumer reads gimbal-frame values without knowing a mount exists; skipped entirely when the mount is the identity |
@ -111,7 +112,7 @@ Owned pixel buffer + `width`, `height`, `channels` (1 or 3), `timestamp_ms`, `ca
One decoded Xsens MTi reading: `temp_c` (°C), `acc[3]` (m/s², incl. gravity), `gyr[3]` (rad/s), One decoded Xsens MTi reading: `temp_c` (°C), `acc[3]` (m/s², incl. gravity), `gyr[3]` (rad/s),
`mag[3]` (a.u., earth-normalized), `roll_deg` (−180..180), `pitch_deg` (−90..90), `yaw_deg` `mag[3]` (a.u., earth-normalized), `roll_deg` (−180..180), `pitch_deg` (−90..90), `yaw_deg`
(**−180..180**, exactly as the device reports it), `sample_counter`. `parseMTData` applies no sign flip and no re-ranging to any axis. (**−180..180**, exactly as the device reports it), `sample_counter`. `parseMTData` applies no sign flip and no re-ranging to any axis.
`gimbal calib` phase-unwraps the swept yaw (`unwrapNear`) so a sweep crossing 0/360 still fits a clean `gimbal heading fit` phase-unwraps the sighted bearings (`unwrapNear`) so a set crossing 0/360 still fits a clean
line. line.
### `ImuDeviceConfig` ([MtiProtocol.h](../include/fgc/MtiProtocol.h)) ### `ImuDeviceConfig` ([MtiProtocol.h](../include/fgc/MtiProtocol.h))

View File

@ -33,7 +33,7 @@ Subscribed on (re)connect; handled in `MqttControlChannel::message_arrived()`
| Topic | Payload | Parsed as | Effect | | Topic | Payload | Parsed as | Effect |
|-------|---------|-----------|--------| |-------|---------|-----------|--------|
| `GGS/FWT/<tower>/target_HDG` | integer heading as string, e.g. `"137"` | validated with `stoi`, stored as **string** | Sets the yaw target used in ControlCode 1 (converted to encoder counts via `[Motor]`) | | `GGS/FWT/<tower>/target_HDG` | integer bearing as string, e.g. `"137"` | validated with `stoi`, stored as **string** | Sets the yaw target used in ControlCode 1 (a **true bearing**, 0 = north, converted to encoder counts via `[Motor]`) |
| `GGS/FWT/<tower>/ControlCode` | integer as string, `"0"` or `"1"` | `stoi` → int | Selects the capture mode (see below) | | `GGS/FWT/<tower>/ControlCode` | integer as string, `"0"` or `"1"` | `stoi` → int | Selects the capture mode (see below) |
Invalid (non-integer) payloads are caught and logged; the previous value is kept. Invalid (non-integer) payloads are caught and logged; the previous value is kept.
@ -48,6 +48,11 @@ flags** so each update is acted on once (`MqttControlChannel::poll()`).
| `0` | **Automatic sweep.** Walk the scan grid front to back, then start over: `MOVE` to the next `(yaw,pitch)` waypoint, wait for standstill, trigger cameras. | | `0` | **Automatic sweep.** Walk the scan grid front to back, then start over: `MOVE` to the next `(yaw,pitch)` waypoint, wait for standstill, trigger cameras. |
| `1` | **Directed.** `MOVE` yaw to `target_HDG` (pitch held), wait for standstill, trigger. | | `1` | **Directed.** `MOVE` yaw to `target_HDG` (pitch held), wait for standstill, trigger. |
> **`target_HDG` is a true bearing and can be refused.** Yaw travel is under a full turn, so there is
> always an unreachable arc. A bearing falling in it is logged once and the gimbal **holds position**;
> it is not clamped to the nearest limit, which would image a direction up to 180° away and publish it
> under the bearing that was asked for. The reachable arc is logged after every home.
When a ControlCode message arrives, the program echoes the current code back on the StatusCode topic. When a ControlCode message arrives, the program echoes the current code back on the StatusCode topic.
## Published topics (outbound) ## Published topics (outbound)
@ -69,7 +74,7 @@ Built by `MqttControlChannel::publishCamEvent` from a `CamEvent`:
|-------|------|---------| |-------|------|---------|
| `fwt` | string | Tower name (`config.ini` `tower_name`) | | `fwt` | string | Tower name (`config.ini` `tower_name`) |
| `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) | | `cam` | string | Camera label: `RGB`, `ACR`, or `NIR` (by camera index) |
| `hdg` | int | Gimbal yaw heading **× 10** (one decimal place encoded as integer) at capture time, from the **yaw encoder** | | `hdg` | int | Gimbal **true bearing** × 10 (0 = north) at capture time, from the **yaw encoder** |
| `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle | | `pit` | int | Gimbal pitch elevation **× 10** at capture time, from the **IMU** — elevation relative to gravity, i.e. true pointing angle |
| `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) | | `time` | int | Capture timestamp, Unix epoch **milliseconds** (matches the `.jxl` filename) |
@ -81,7 +86,8 @@ different sources deliberately. The pitch encoder only measures the axis relativ
gimbal's own base, so a leaning mast is invisible to it; the IMU measures against gravity gimbal's own base, so a leaning mast is invisible to it; the IMU measures against gravity
and therefore reports the elevation the imagery is actually pointing at. Yaw stays on the and therefore reports the elevation the imagery is actually pointing at. Yaw stays on the
encoder because IMU yaw drifts and depends on the heading reset and XKF profile, whereas encoder because IMU yaw drifts and depends on the heading reset and XKF profile, whereas
encoder yaw is calibrated against the IMU by `gimbal calib` and then holds. encoder yaw is tied to true north by `gimbal heading setup` (sighting known
landmarks, no IMU involved) and then holds.
Two consequences for consumers: Two consequences for consumers:

View File

@ -7,7 +7,7 @@ firmware-only `gimbal diag` (the firmware motor self-test is now one leaf inside
`test gimbal encoder`). `test gimbal encoder`).
Implementation: [`TestRunner`](../src/core/TestRunner.cpp) (worker thread, mirrors Implementation: [`TestRunner`](../src/core/TestRunner.cpp) (worker thread, mirrors
`CalibrationRoutine`) + the pure [`TestReport`](../src/core/TestReport.cpp) model `PitchSetupRoutine`) + the pure [`TestReport`](../src/core/TestReport.cpp) model
(`formatTestReport`/`parseTestReport`/`applyComparison`) and (`formatTestReport`/`parseTestReport`/`applyComparison`) and
[`HostMetrics`](../src/core/HostMetrics.cpp) host reads. [`HostMetrics`](../src/core/HostMetrics.cpp) host reads.

View File

@ -5,8 +5,9 @@
namespace fgc { namespace fgc {
// Pure math helpers for `gimbal calib`. Kept I/O-free in fgc_core so the fit can // Pure math helpers shared by `gimbal pitch setup` and `gimbal heading fit`.
// be unit-tested independently of the threaded routine that drives the hardware. // Kept I/O-free in fgc_core so the fits can be unit-tested independently of the
// threaded routine that drives the hardware.
struct LinearFit { struct LinearFit {
bool ok = false; // false if < 2 points or x has no spread bool ok = false; // false if < 2 points or x has no spread
@ -17,7 +18,8 @@ struct LinearFit {
}; };
// Least-squares fit of y over x. For calibration: x = measured degrees, // Least-squares fit of y over x. For calibration: x = measured degrees,
// y = motor encoder counts ⇒ slope = counts_per_deg, intercept = zero_count. // y = motor encoder counts ⇒ slope = counts_per_deg, and the intercept gives the
// angle at the anchored centre (see AxisMap).
LinearFit linearFit(const std::vector<std::pair<double, double>>& xy); LinearFit linearFit(const std::vector<std::pair<double, double>>& xy);
// Circular mean of angles in degrees, robust to ±180° wrap (e.g. IMU heading). // Circular mean of angles in degrees, robust to ±180° wrap (e.g. IMU heading).

View File

@ -39,6 +39,13 @@ public:
void setCaptureActive(bool active); void setCaptureActive(bool active);
bool captureActive() const { return capture_active_; } bool captureActive() const { return capture_active_; }
// Take one image now, outside any scan (the TUI's spacebar / `snapshot`).
// Serviced on the next tick once the gimbal is standing still and the settle
// wait has elapsed, so the frame is as clean as a scan frame. Ignored while a
// capture is active. Idempotent: asking twice before it fires yields one image.
void requestSnapshot();
bool snapshotPending() const { return snapshot_pending_; }
void setImageRate(double rate); // images per second void setImageRate(double rate); // images per second
double imageRate() const { return image_rate_; } double imageRate() const { return image_rate_; }
@ -48,7 +55,7 @@ public:
void setSettleWait(int settle_ms, int timeout_ms); void setSettleWait(int settle_ms, int timeout_ms);
int settleWaitMs() const { return settle_wait_ms_; } int settleWaitMs() const { return settle_wait_ms_; }
// Replace the degrees<->counts maps (e.g. after `gimbal calib`) so subsequent // Replace the degrees<->counts maps (e.g. after `gimbal heading fit`) so subsequent
// scan waypoints target with the calibrated conversion. Called on the control // scan waypoints target with the calibrated conversion. Called on the control
// thread, same as tick(), so no locking is needed. // thread, same as tick(), so no locking is needed.
void setGeometry(const Geometry& g) { geometry_ = g; } void setGeometry(const Geometry& g) { geometry_ = g; }
@ -68,6 +75,7 @@ private:
void resetTimer(); void resetTimer();
void sendMove(); // emit MOVE to (yaw_target_, pitch_target_) void sendMove(); // emit MOVE to (yaw_target_, pitch_target_)
bool settledAt(const MotorTelemetry& t) const; bool settledAt(const MotorTelemetry& t) const;
void serviceSnapshot(const MotorTelemetry& t);
IMotorController& motor_; IMotorController& motor_;
ICameraSource& camera_; ICameraSource& camera_;
@ -88,6 +96,13 @@ private:
int settle_wait_ms_ = 1000; int settle_wait_ms_ = 1000;
int frame_timeout_ms_ = 2000; int frame_timeout_ms_ = 2000;
bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig) bool pitch_present_ = false; // a P: segment was seen in telemetry (2-axis rig)
// One warning per run for each unreachable-heading case: a grid with a few
// out-of-arc waypoints would otherwise log on every pass, forever.
bool unreachable_warned_ = false;
bool directed_unreachable_warned_ = false;
bool snapshot_pending_ = false; // a manual snapshot is armed
long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet)
long yaw_target_ = 0; // current target, encoder counts long yaw_target_ = 0; // current target, encoder counts
long pitch_target_ = 0; long pitch_target_ = 0;
}; };

View File

@ -171,11 +171,6 @@ struct TestConfig {
const TestProfile* find(const std::string& name) const; // nullptr if absent const TestProfile* find(const std::string& name) const; // nullptr if absent
}; };
// Which homed endstop becomes yaw 0 deg, with degrees rising toward the other
// limit (which then lands near +360, a bit less for the soft-limit/mechanical
// gap). Off = use the configured/calibrated yaw zero_count as-is.
enum class YawHomeZero { Off, Low, High };
struct AppConfig { struct AppConfig {
GeneralConfig general; GeneralConfig general;
NetworkConfig network; NetworkConfig network;
@ -186,7 +181,11 @@ struct AppConfig {
LoggingConfig logging; LoggingConfig logging;
UiConfig ui; // [UI] terminal dashboard toggle UiConfig ui; // [UI] terminal dashboard toggle
Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch) Geometry geometry; // [Motor] degrees<->counts maps (yaw + pitch)
YawHomeZero yaw_home_zero = YawHomeZero::Off; // [Motor] re-anchor yaw zero on homing // [Motor] clearance kept from each hard endstop, in counts. Sent to the
// firmware (MARGIN), which enforces it; kept in counts rather than degrees
// because it must apply at homing time, before any degree map is trusted.
long yaw_soft_margin_counts = 5000;
long pitch_soft_margin_counts = 5000;
long enc_error_warn_counts = 400; // [Motor] live encoder-error WARN (0=off) long enc_error_warn_counts = 400; // [Motor] live encoder-error WARN (0=off)
ScanConfig scan; // [Scan] grid source ScanConfig scan; // [Scan] grid source
ImuConfig imu; // [IMU] Xsens MTi serial device ImuConfig imu; // [IMU] Xsens MTi serial device
@ -220,8 +219,11 @@ public:
std::string updateIniSectionKeys(const std::string& contents, const std::string& section, std::string updateIniSectionKeys(const std::string& contents, const std::string& section,
const std::vector<std::pair<std::string, std::string>>& kv); const std::vector<std::pair<std::string, std::string>>& kv);
// Persist the live `[Motor]` calibration (counts_per_deg / zero_count for both // Persist the live `[Motor]` geometry (counts_per_deg / center_count /
// axes) back into the INI file at `path`. Returns true on success. // deg_at_center for both axes, plus the endstop margins) back into the INI file
bool saveMotorCalibration(const std::string& path, const Geometry& geo); // at `path`. Written via a temp file and rename, so an interrupted save cannot
// leave a truncated config behind. Returns true on success.
bool saveMotorGeometry(const std::string& path, const Geometry& geo, long yaw_margin,
long pitch_margin);
} // namespace fgc } // namespace fgc

View File

@ -14,11 +14,11 @@ struct DumpAxis {
char axis = '?'; // 'Y' (yaw) / 'P' (pitch) char axis = '?'; // 'Y' (yaw) / 'P' (pitch)
std::string state_name = "?"; // BOOT/RESET/HOMING/READY/ERROR std::string state_name = "?"; // BOOT/RESET/HOMING/READY/ERROR
bool enabled = false; bool enabled = false;
bool eeprom_restored = false;
bool has_encoder = false; bool has_encoder = false;
long lim_neg = 0; long lim_neg = 0;
long lim_pos = 0; long lim_pos = 0;
long soft_margin = 0; // counts kept clear of each hard limit long soft_margin = 0; // counts kept clear of each hard limit
long center_count = 0; // count the firmware anchored the homed midpoint to
long hold_target = 0; long hold_target = 0;
long speed = 0; long speed = 0;
// Encoder-tracking health accumulated by the firmware hold corrector. // Encoder-tracking health accumulated by the firmware hold corrector.

View File

@ -1,27 +1,53 @@
#pragma once #pragma once
#include <optional>
namespace fgc { namespace fgc {
// Affine map between heading degrees and absolute encoder counts for one axis: // Normalise an angle to [0, 360) / [-180, 180). Free functions because headings
// are compared and differenced in several places outside AxisMap.
double wrap360(double deg);
double wrap180(double deg);
// Affine map between degrees and absolute encoder counts for one axis, anchored
// on the *mechanical centre* found by homing:
// //
// counts = zero_count + deg * counts_per_deg // counts = center_count + (deg - deg_at_center) * counts_per_deg
// deg = (counts - zero_count) / counts_per_deg // deg = deg_at_center + (counts - center_count) / counts_per_deg
// //
// The firmware speaks only in encoder counts; this is how the host converts to // The firmware speaks only in encoder counts. `center_count` is the count the
// the heading/elevation degrees used by the MQTT contract and CamEvent. The // firmware assigns to the midpoint of the homed travel (it is told this value,
// parameters are operator-calibrated against real `xenc` readings after homing // see the CENTER command), so it is reproducible across power cycles - unlike the
// (counts_per_deg may be negative to flip the direction sense). // free-running encoder origin it replaced. `deg_at_center` is what that midpoint
// points at: a true compass heading for yaw (0 = north), an elevation above level
// for pitch (+ = up).
//
// min_deg/max_deg are DERIVED from the homed soft limits after each home, not
// configured - the firmware's limits are the only place they can be correct.
// counts_per_deg may be negative to flip the direction sense.
struct AxisMap { struct AxisMap {
double counts_per_deg = 1.0; // calibrate on the rig double counts_per_deg = 1.0; // calibrate on the rig
long zero_count = 0; // xenc value that corresponds to 0 deg long center_count = 500000; // count at the homed mechanical centre
double deg_at_center = 0.0; // heading/elevation there
double min_deg = -100000.0; // soft clamp applied by toCounts (degrees) double min_deg = -100000.0; // soft clamp applied by toCounts (degrees)
double max_deg = 100000.0; double max_deg = 100000.0;
// Degrees -> counts. Clamps `deg` to [min_deg, max_deg] and rounds. // Degrees -> counts. Clamps `deg` to [min_deg, max_deg] and rounds.
long toCounts(double deg) const; long toCounts(double deg) const;
// Counts -> degrees. Returns 0 if counts_per_deg is ~0 (misconfigured). // Counts -> degrees. Returns deg_at_center if counts_per_deg is ~0
// (misconfigured), i.e. it degrades to "we are at the centre" rather than to
// an arbitrary zero.
double toDeg(long counts) const; double toDeg(long counts) const;
// Yaw only: the same reading as a compass heading in [0, 360).
double headingDeg(long counts) const;
// Yaw only: counts for a true heading, taking the short way round. Returns
// nullopt when the heading lies in the arc the gimbal cannot reach - the
// travel is under 360 deg, so there is always a dead sector, and pointing at
// the nearest limit instead would silently image the wrong direction.
std::optional<long> headingToCounts(double heading_deg) const;
}; };
// Per-axis maps for the two-axis gimbal. // Per-axis maps for the two-axis gimbal.

48
include/fgc/HeadingFit.h Normal file
View File

@ -0,0 +1,48 @@
#pragma once
#include <string>
#include <vector>
namespace fgc {
// Fitting the yaw map from operator sightings of known landmarks.
//
// The gimbal is aimed at something whose true bearing is known (a mast, a peak, a
// road junction on the map), and the encoder position is recorded against that
// bearing. Two sightings determine the line exactly; more are least-squares
// fitted. Nothing here needs an IMU, which is the point: not every tower has one,
// and IMU yaw drifts badly enough that it was never a trustworthy heading source.
//
// Kept I/O-free in fgc_core so the fit is unit-testable without hardware.
struct HeadingSighting {
long counts = 0; // yaw encoder position when the landmark was centred
double heading_deg = 0.0; // its true bearing, degrees (0 = north)
};
struct HeadingFitResult {
bool ok = false;
std::string error; // operator-facing reason when !ok
double counts_per_deg = 0.0;
double deg_at_center = 0.0; // bearing at center_count, normalised to [0,360)
double r2 = 0.0;
int n = 0;
double span_deg = 0.0; // angular baseline actually used
};
// Shortest baseline we will fit. Two sightings a few degrees apart put the whole
// scale on a handful of counts, so a small aiming error swings counts_per_deg
// wildly - and the result looks perfectly healthy (two points always fit exactly,
// R^2 = 1). Refusing is the only way the operator finds out.
inline constexpr double kMinHeadingBaselineDeg = 10.0;
// Fit counts_per_deg and the bearing at `center_count`. Sightings may be given in
// any order and may span north.
HeadingFitResult fitHeading(const std::vector<HeadingSighting>& sightings, long center_count);
// Parse "<counts>:<deg>" tokens (the form the TUI wizard emits). Returns false and
// sets `error` on the first malformed token.
bool parseHeadingSightings(const std::vector<std::string>& tokens,
std::vector<HeadingSighting>& out, std::string& error);
} // namespace fgc

View File

@ -72,7 +72,10 @@ public:
// which camera on every keystroke would make that worse, not better. // which camera on every keystroke would make that worse, not better.
void setDistance(double d) { for (auto& c : params_.cameras) c.jxl_distance = d; } void setDistance(double d) { for (auto& c : params_.cameras) c.jxl_distance = d; }
void setEffort(int e) { for (auto& c : params_.cameras) c.jxl_effort = e; } void setEffort(int e) { for (auto& c : params_.cameras) c.jxl_effort = e; }
void setDisplay(bool on) { params_.display = on; } // Toggled from the control thread while the worker reads it, hence atomic.
// The worker also CLEARS it when the display turns out to be unusable, so this
// is genuinely shared state rather than a one-way setting.
void setDisplay(bool on) { display_.store(on); }
// Last CamEvent published (camera label, heading/pitch, timestamp), for the // Last CamEvent published (camera label, heading/pitch, timestamp), for the
// UI's camera panel. Thread-safe; nullopt until the first capture is saved. // UI's camera panel. Thread-safe; nullopt until the first capture is saved.
@ -105,6 +108,10 @@ private:
std::atomic<bool> running_{false}; std::atomic<bool> running_{false};
std::thread worker_; std::thread worker_;
// Live preview state. Seeded from Params::display, then owned by setDisplay()
// and by process()'s self-disable on a display failure.
std::atomic<bool> display_{false};
mutable std::mutex last_event_mutex_; mutable std::mutex last_event_mutex_;
std::optional<CamEvent> last_event_; std::optional<CamEvent> last_event_;
std::atomic<long long> saved_{0}; // images successfully written this session std::atomic<long long> saved_{0}; // images successfully written this session

View File

@ -14,7 +14,7 @@ namespace fgc {
class IMotorController; class IMotorController;
class IImuSource; class IImuSource;
// Live progress of a running calibration, for the TUI activity strip. // Live progress of a running pitch setup, for the TUI activity strip.
struct CalibProgress { struct CalibProgress {
bool running = false; bool running = false;
char axis = '?'; // 'Y' / 'P' char axis = '?'; // 'Y' / 'P'
@ -23,13 +23,13 @@ struct CalibProgress {
std::string phase; // "moving" / "dwelling" / "fitting" / ... std::string phase; // "moving" / "dwelling" / "fitting" / ...
}; };
// Structured outcome of the last completed calibration (persists for display). // Structured outcome of the last completed pitch setup (persists for display).
struct CalibReport { struct CalibReport {
struct Axis { struct Axis {
char axis = '?'; char axis = '?';
bool ok = false; bool ok = false;
double counts_per_deg = 0; double counts_per_deg = 0;
long zero_count = 0; double deg_at_center = 0;
double r2 = 0; double r2 = 0;
int n = 0; int n = 0;
}; };
@ -42,36 +42,37 @@ struct CalibReport {
// Tunable timings/sizes for a calibration run. Defaults are the production // Tunable timings/sizes for a calibration run. Defaults are the production
// values; tests inject tiny ones so a full run completes in milliseconds. // values; tests inject tiny ones so a full run completes in milliseconds.
struct CalibParams { struct CalibParams {
int positions = 10; // sweep steps per axis int positions = 10; // sweep steps
int dwell_ms = 5000; // hold at each position while sampling int dwell_ms = 5000; // hold at each position while sampling
int sample_ms = 100; // IMU sampling cadence during a dwell int sample_ms = 100; // IMU sampling cadence during a dwell
int settle_timeout_ms = 15000; // max wait for a single MOVE to settle int settle_timeout_ms = 15000; // max wait for a single MOVE to settle
int home_timeout_ms = 90000; // max wait for HOME to reach READY int home_timeout_ms = 90000; // max wait for HOME to reach READY
int norotation_s = 3; // no-rotation gyro-bias update duration
int reset_settle_ms = 1500; // let the filter apply the heading reset
double inset_frac = 0.05; // keep targets off the hard endstops double inset_frac = 0.05; // keep targets off the hard endstops
}; };
// `gimbal calib`: an IMU-referenced steps<->degrees calibration. Runs on its own // `gimbal pitch setup`: an IMU-referenced steps<->degrees calibration for the
// thread (the motor/imu/Logger interfaces are thread-safe). Sequence: // PITCH axis only. Runs on its own thread (the motor/imu/Logger interfaces are
// 1. home the gimbal if it is not already READY; // thread-safe). Sequence:
// 2. move yaw to its first sweep position and calibrate PITCH there (sweep its // 1. abort unless the IMU is connected - there is no other way to find level;
// soft-limit travel, dwelling to record the gravity-referenced IMU pitch); // 2. home the gimbal if it is not already READY;
// 3. move pitch to 0 deg; // 3. sweep pitch across its soft-limit travel, dwelling at each step to record
// 4. switch the IMU to a no-magnetometer XKF profile (persists on the device) so // the gravity-referenced IMU pitch, and least-squares fit counts vs degrees;
// its heading stops chasing the stepper-distorted magnetic field; // 4. park at 0 deg (level) using the map just fitted.
// 5. with the gimbal held still, run the IMU no-rotation update (cuts yaw drift) //
// then reset the IMU heading so the current pose is yaw 0 — this removes the // Yaw is deliberately absent. It is tied to true north by `gimbal heading setup`
// drift accumulated during the slow pitch sweep right before it matters; // against landmarks the operator can identify, which needs no IMU (not every
// 6. sweep YAW from that first position and calibrate it. // tower has one) and does not inherit the MTi's heading drift - the reason the
// Each axis fit is least-squares (counts vs degrees). The resulting Geometry is // old two-axis routine had to switch XKF profiles and reset the heading mid-run
// published via takeResult() for the main thread to apply; raw samples + fits are // just to get one usable yaw sweep.
// written to a logfile. Progress streams to the LOG pane. Cancellable, one-at-a-time. //
class CalibrationRoutine { // The resulting Geometry is published via takeResult() for the main thread to
// apply; raw samples + the fit are written to a logfile. Progress streams to the
// LOG pane. Cancellable, one-at-a-time.
class PitchSetupRoutine {
public: public:
CalibrationRoutine(IMotorController& motor, IImuSource& imu, Geometry initial, PitchSetupRoutine(IMotorController& motor, IImuSource& imu, Geometry initial,
CalibParams params = {}); CalibParams params = {});
~CalibrationRoutine(); ~PitchSetupRoutine();
// Begin on a worker thread. Logs a reason and returns false if already // Begin on a worker thread. Logs a reason and returns false if already
// running (prechecks happen on the worker and abort there). // running (prechecks happen on the worker and abort there).

View File

@ -12,8 +12,8 @@ namespace fgc {
// ST Y:<state>,<xactual>,<xenc>,<drv_hex>,<sg>,<cs>,<pwm>,<flags> [P:...] // ST Y:<state>,<xactual>,<xenc>,<drv_hex>,<sg>,<cs>,<pwm>,<flags> [P:...]
// //
// where <state> is B/R/H/A/E and <flags> is an optional run of status letters // where <state> is B/R/H/A/E and <flags> is an optional run of status letters
// (S=standstill, s=stall, d=encoder deviation, o/O=overtemp, L/R=endstops, // (S=standstill, s=stall, d=encoder deviation, o/O=overtemp, L/R=endstops).
// e=eeprom). Returns nullopt // Returns nullopt
// for any line that is not a well-formed `ST` line (acks and other async output // for any line that is not a well-formed `ST` line (acks and other async output
// like OK/ERR/DG/DUMP are handled by the caller). A missing/garbled axis segment // like OK/ERR/DG/DUMP are handled by the caller). A missing/garbled axis segment
// yields nullopt rather than partial data. // yields nullopt rather than partial data.

View File

@ -57,7 +57,7 @@ struct TestProgress {
}; };
// Runs the selected hardware self-tests on a worker thread, off the control // Runs the selected hardware self-tests on a worker thread, off the control
// loop. Mirrors CalibrationRoutine's lifecycle: start()/cancel()/running(), live // loop. Mirrors PitchSetupRoutine's lifecycle: start()/cancel()/running(), live
// progress(), and takeReport() once finished. The motor/imu/Logger interfaces are // progress(), and takeReport() once finished. The motor/imu/Logger interfaces are
// thread-safe; the produced TestReport is handed back for the control thread to // thread-safe; the produced TestReport is handed back for the control thread to
// compare against the baseline, write to disk, and surface in the UI. // compare against the baseline, write to disk, and surface in the UI.

View File

@ -66,7 +66,7 @@ public:
d << "DUMP " << L << " state=" << st d << "DUMP " << L << " state=" << st
<< " hsub=0 enabled=" << (homed_ ? 1 : 0) << " hsub=0 enabled=" << (homed_ ? 1 : 0)
<< " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc << " lim_neg=0 lim_pos=1000000 hold_target=" << a.xenc
<< " speed=200000 eeprom_restored=1 soft_margin=5000" << " speed=200000 center=500000 soft_margin=5000"
<< " hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n"; << " hold_corr=0 hold_peak=0 hold_cumul=0 has_encoder=1\n";
d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000" d << "DUMP " << L << " TMC GCONF=0x00000004 GSTAT=0x00000000"
<< " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000" << " IOIN=0x30000008 TSTEP=0x000fffff RAMPMODE=0x00000000"

View File

@ -28,6 +28,7 @@ struct AxisView {
long xenc = 0; long xenc = 0;
long enc_err = 0; // live tracking error = xactual - xenc (counts) long enc_err = 0; // live tracking error = xactual - xenc (counts)
double target_deg = 0.0; // current scheduler target double target_deg = 0.0; // current scheduler target
double counts_per_deg = 0.0; // live scale, for step-size readouts
int sg = 0; // SG_RESULT (live, from ST line) int sg = 0; // SG_RESULT (live, from ST line)
int cs = 0; // CS_ACTUAL int cs = 0; // CS_ACTUAL
int pwm = 0; // PWM_SCALE_SUM int pwm = 0; // PWM_SCALE_SUM
@ -140,6 +141,9 @@ struct CameraDeviceView {
struct CaptureView { struct CaptureView {
bool present = false; bool present = false;
bool active = false; bool active = false;
// No scan running, so hand aiming (arrows) and `snapshot` are accepted.
bool manual = true;
bool snapshot_armed = false; // a snapshot is waiting to fire
double image_rate = 0.0; // img/s double image_rate = 0.0; // img/s
int camera_count = 0; int camera_count = 0;
std::vector<std::string> labels; std::vector<std::string> labels;
@ -281,7 +285,7 @@ struct ActivityView {
struct CalibAxisView { struct CalibAxisView {
bool ok = false; bool ok = false;
double counts_per_deg = 0; double counts_per_deg = 0;
long zero_count = 0; double deg_at_center = 0;
double r2 = 0; double r2 = 0;
int n = 0; int n = 0;
}; };

View File

@ -16,7 +16,7 @@ int main(int argc, char* argv[]) {
desc.add_options() desc.add_options()
("help,h", "show this help") ("help,h", "show this help")
("config,c", po::value<std::string>(), "path to config.ini") ("config,c", po::value<std::string>(), "path to config.ini")
("init,i", po::bool_switch(), "run the endstop-finding init sequence") ("init,i", po::bool_switch(), "home the gimbal on startup (required: nothing is homed across a power cycle)")
("start,s", po::bool_switch(), "start capture automatically") ("start,s", po::bool_switch(), "start capture automatically")
("demo,d", po::bool_switch(), "demo mode (copy placeholder image instead of encoding)") ("demo,d", po::bool_switch(), "demo mode (copy placeholder image instead of encoding)")
("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)") ("no-mqtt", po::bool_switch(), "disable MQTT (use a null control channel)")
@ -93,7 +93,7 @@ int main(int argc, char* argv[]) {
if (vm["no-tui"].as<bool>()) opts.use_tui = false; // --no-tui wins if (vm["no-tui"].as<bool>()) opts.use_tui = false; // --no-tui wins
if (vm.count("log-level")) opts.log_level = vm["log-level"].as<std::string>(); if (vm.count("log-level")) opts.log_level = vm["log-level"].as<std::string>();
if (vm.count("trace")) opts.trace_categories = vm["trace"].as<std::string>(); if (vm.count("trace")) opts.trace_categories = vm["trace"].as<std::string>();
opts.config_path = *cfg_path; // so `gimbal calib` can offer to save back to it opts.config_path = *cfg_path; // so the setup commands can save back to it
Application app(std::move(cfg), std::move(opts)); Application app(std::move(cfg), std::move(opts));
try { try {

View File

@ -15,7 +15,10 @@ Wants=network-online.target
Type=simple Type=simple
User=ggs User=ggs
WorkingDirectory=/opt/fire_gimbal_control WorkingDirectory=/opt/fire_gimbal_control
ExecStart=/opt/fire_gimbal_control/fire_gimbal_control --start # --init is REQUIRED, not a convenience: the firmware keeps no state across a
# power cycle, so both axes boot into RESET and refuse to move until homed. Without
# it the service comes up and sits there, capturing nothing, with no obvious cause.
ExecStart=/opt/fire_gimbal_control/fire_gimbal_control --init --start
Environment=FGC_CONFIG=/opt/fire_gimbal_control/config.ini Environment=FGC_CONFIG=/opt/fire_gimbal_control/config.ini
# EnvironmentFile=/etc/fire_gimbal_control/credentials.env # FGC_MQTT_USER / FGC_MQTT_PW # EnvironmentFile=/etc/fire_gimbal_control/credentials.env # FGC_MQTT_USER / FGC_MQTT_PW
Restart=on-failure Restart=on-failure

View File

@ -2,8 +2,11 @@
# Launch fire_gimbal_control, resolving the binary relative to this script so it # Launch fire_gimbal_control, resolving the binary relative to this script so it
# works regardless of where the repo is checked out. Extra args are forwarded. # works regardless of where the repo is checked out. Extra args are forwarded.
# #
# scripts/run.sh --start # scripts/run.sh --init --start # homes first
# scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware # scripts/run.sh --mock-serial --mock-camera --no-mqtt --start # dev, no hardware
#
# --init homes the gimbal. The firmware keeps nothing across a power cycle, so
# without it the axes stay in RESET and refuse every move.
set -euo pipefail set -euo pipefail
here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"

View File

@ -28,7 +28,9 @@ bool rotateCode(int deg, int& code) {
ImagePipeline::ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation, ImagePipeline::ImagePipeline(IControlChannel& channel, std::function<Orientation()> orientation,
Params params) Params params)
: channel_(channel), orientation_(std::move(orientation)), params_(std::move(params)) {} : channel_(channel), orientation_(std::move(orientation)), params_(std::move(params)) {
display_.store(params_.display);
}
ImagePipeline::~ImagePipeline() { stop(); } ImagePipeline::~ImagePipeline() { stop(); }
@ -96,11 +98,27 @@ void ImagePipeline::process(const QueuedFrame& queued) {
// src only wraps frame.data, so an unrotated frame costs no copy at all. // src only wraps frame.data, so an unrotated frame costs no copy at all.
const cv::Mat& img = rotate ? rotated : src; const cv::Mat& img = rotate ? rotated : src;
if (params_.display) { if (display_.load()) {
cv::namedWindow("Display Image", cv::WINDOW_NORMAL); // This runs on the worker thread, so anything escaping here terminates the
cv::resizeWindow("Display Image", img.cols / 4, img.rows / 4); // whole program - and OpenCV's GUI calls throw whenever there is no usable
cv::imshow("Display Image", img); // display: no X/Wayland, DISPLAY unset or pointing at a dead server (an SSH
cv::waitKey(10); // session without X11 forwarding), or a highgui built with no GUI backend.
// A preview window is never worth taking a tower down for, so failure
// disables it and capture carries on untouched.
try {
cv::namedWindow("Display Image", cv::WINDOW_NORMAL);
cv::resizeWindow("Display Image", img.cols / 4, img.rows / 4);
cv::imshow("Display Image", img);
cv::waitKey(10);
} catch (const std::exception& e) {
display_.store(false);
LOG_WARN << "local display unavailable; preview disabled (capture is unaffected): "
<< e.what();
} catch (...) {
display_.store(false);
LOG_WARN << "local display failed for an unknown reason; preview disabled "
"(capture is unaffected)";
}
} }
const std::string label = out.label.empty() ? ("cam" + std::to_string(frame.cam_id)) const std::string label = out.label.empty() ? ("cam" + std::to_string(frame.cam_id))

View File

@ -193,7 +193,15 @@ struct VimbaCameraSource::Impl {
// Begin/end a free-run acquisition session (fresh observers each time). Split out so // Begin/end a free-run acquisition session (fresh observers each time). Split out so
// trigger() can restart the stream when it stalls. Stats persist across restarts. // trigger() can restart the stream when it stalls. Stats persist across restarts.
void startAcquisition() { void startAcquisition() {
observers.clear(); // Stop first, unconditionally. Clearing `observers` drops the last
// references to the FrameObserver objects, and until
// StopContinuousImageAcquisition() has returned the SDK's acquisition
// thread may still be calling FrameReceived on them - destroying them
// under it is a use-after-free, not a leak.
stopAcquisition();
// A fresh session has delivered nothing, so a watermark from the previous
// one would make every genuinely-new frame look stale to trigger().
last_captured_ts = 0;
int cam_id = 0; int cam_id = 0;
for (auto& cam : cameras) { for (auto& cam : cameras) {
IFrameObserverPtr observer(new FrameObserver(cam, cam_id, stats[cam_id].get())); IFrameObserverPtr observer(new FrameObserver(cam, cam_id, stats[cam_id].get()));
@ -256,6 +264,15 @@ void VimbaCameraSource::open() {
LOG_INFO << "Opened camera '" << spec.name << "' " << id << " (" LOG_INFO << "Opened camera '" << spec.name << "' " << id << " ("
<< impl_->identity.back().model << ')'; << impl_->identity.back().model << ')';
} }
// Configure each camera ONCE, here. Settings are GenICam features that live on
// the device and survive a stop/start of acquisition, so re-applying them
// whenever the stream starts is both wasted work (thousands of features) and
// wrong in principle: it lets a request to *stream* alter the *configuration*.
// A scan frame and a manual snapshot must come off an identically configured
// camera, and the only way to guarantee that is to never touch the
// configuration in between.
for (size_t i = 0; i < impl_->cameras.size() && i < impl_->profiles.size(); ++i)
applyProfile(impl_->cameras[i], impl_->profiles[i]);
} }
void VimbaCameraSource::close() { void VimbaCameraSource::close() {
@ -264,9 +281,13 @@ void VimbaCameraSource::close() {
impl_->sys.Shutdown(); impl_->sys.Shutdown();
} }
// Start/stop the free-running stream. Configuration is NOT touched here - that
// happened at open(), and changes only through setProfile().
void VimbaCameraSource::start() { void VimbaCameraSource::start() {
for (size_t i = 0; i < impl_->cameras.size() && i < impl_->profiles.size(); ++i) // Idempotent. Several callers reach here without knowing whether another
applyProfile(impl_->cameras[i], impl_->profiles[i]); // already has - `start` (scan), `snapshot`, and --start on the command line -
// and re-entering would restart acquisition under the SDK's feet.
if (impl_->started) return;
impl_->startAcquisition(); impl_->startAcquisition();
impl_->started = true; impl_->started = true;
} }
@ -279,7 +300,11 @@ bool VimbaCameraSource::setProfile(int camera_index, const CameraProfile& profil
} }
impl_->profiles[idx] = profile; impl_->profiles[idx] = profile;
if (!impl_->started) return true; // start() will apply it if (!impl_->started) {
// Idle: every feature is writable, so apply directly. start() no longer
// applies profiles, so skipping this would leave the camera on the old one.
return applyProfile(impl_->cameras[idx], profile);
}
// Binning, ROI and pixel format cannot be written while the stream runs, and a // Binning, ROI and pixel format cannot be written while the stream runs, and a
// profile may change any of them, so always cycle the stream. Profile switches // profile may change any of them, so always cycle the stream. Profile switches
@ -299,17 +324,36 @@ bool VimbaCameraSource::setProfile(int camera_index, const CameraProfile& profil
} }
void VimbaCameraSource::stop() { void VimbaCameraSource::stop() {
if (!impl_->started) return;
impl_->stopAcquisition(); impl_->stopAcquisition();
impl_->started = false; impl_->started = false;
} }
bool VimbaCameraSource::trigger() { bool VimbaCameraSource::trigger() {
// Deliver the freshest complete frame per waypoint. This host's USB3 // Deliver the freshest complete frame per waypoint. This host's USB3 stream
// stream reliably delivers the first frames of a session but stalls after a burst; if // reliably delivers the first frames of a session but stalls after a burst; if
// no frame newer than the last capture is available, the stream has stalled - restart // no frame newer than the last capture is available, the stream has stalled -
// acquisition to unstick it and wait briefly for a fresh frame. Without this, keep-latest // restart acquisition to unstick it and wait briefly for a fresh frame. Without
// would hand the same stale frame to every remaining waypoint (all overwriting one file). // this, keep-latest would hand the same stale frame to every remaining waypoint
if (impl_->newestTs() <= impl_->last_captured_ts) { // (all overwriting one file).
const long long newest = impl_->newestTs();
// A session that has delivered NOTHING is warming up, not stalled. These are
// different states that both fail the "no newer frame" test, and conflating
// them is expensive: restarting throws away the warm-up and blocks the control
// thread for 3 s, so a cold stream would be repeatedly reset just as it was
// about to produce its first frame. Return empty-handed instead; the caller
// retries every tick.
if (newest == 0) {
long long now = nowMs();
if (now - impl_->last_no_frame_warn_ms > 5000) {
LOG_WARN << "camera: no frame yet (stream warming up)";
impl_->last_no_frame_warn_ms = now;
}
return false;
}
if (newest <= impl_->last_captured_ts) {
LOG_TRACE_CAT(LogCat::Camera) << "stream stale; restarting acquisition"; LOG_TRACE_CAT(LogCat::Camera) << "stream stale; restarting acquisition";
for (auto& s : impl_->stats) s->restarts.fetch_add(1); for (auto& s : impl_->stats) s->restarts.fetch_add(1);
try { try {
@ -334,11 +378,12 @@ bool VimbaCameraSource::trigger() {
} }
} }
if (!any) { if (!any) {
// The scheduler retries trigger() every tick until a frame is ready, so a brief // Frames HAVE arrived this session (the newest == 0 case returned above), so
// warm-up (or a fully stalled stream) would otherwise flood the log. Rate-limit. // this is the restart-did-not-help path. The scheduler retries every tick,
// so rate-limit.
long long now = nowMs(); long long now = nowMs();
if (now - impl_->last_no_frame_warn_ms > 5000) { if (now - impl_->last_no_frame_warn_ms > 5000) {
LOG_WARN << "camera: no frame available to capture yet (stream warming up or stalled)"; LOG_WARN << "camera: stream stalled and did not recover after a restart";
impl_->last_no_frame_warn_ms = now; impl_->last_no_frame_warn_ms = now;
} }
} }

View File

@ -1,6 +1,7 @@
#include "fgc/Application.h" #include "fgc/Application.h"
#include "fgc/CalibrationRoutine.h" #include "fgc/PitchSetup.h"
#include "fgc/HeadingFit.h"
#include "fgc/CaptureScheduler.h" #include "fgc/CaptureScheduler.h"
#include "fgc/CommandParser.h" #include "fgc/CommandParser.h"
#include "fgc/DiagParser.h" #include "fgc/DiagParser.h"
@ -62,6 +63,11 @@ namespace fgc {
namespace { namespace {
// One encoder count in microsteps, rounded up. ENC_CONST is set so X_ENC advances
// in 12.8-microstep quanta, which is the finest position the gimbal can actually
// reproduce - the tolerance for any "is the count where we asked for it" check.
constexpr long kEncoderQuantumCounts = 13;
// Set by SIGINT/SIGTERM so the control loop exits cleanly and tears the camera down // Set by SIGINT/SIGTERM so the control loop exits cleanly and tears the camera down
// (StopContinuousImageAcquisition + Close). Without this, a Ctrl-C / kill / systemd // (StopContinuousImageAcquisition + Close). Without this, a Ctrl-C / kill / systemd
// stop skips teardown and leaves the camera acquiring - which wedges it so the NEXT // stop skips teardown and leaves the camera acquiring - which wedges it so the NEXT
@ -193,7 +199,7 @@ struct Application::Impl {
std::unique_ptr<ImagePipeline> pipeline; std::unique_ptr<ImagePipeline> pipeline;
std::unique_ptr<CaptureScheduler> scheduler; std::unique_ptr<CaptureScheduler> scheduler;
std::unique_ptr<IUserInterface> ui; std::unique_ptr<IUserInterface> ui;
std::unique_ptr<CalibrationRoutine> calib; std::unique_ptr<PitchSetupRoutine> calib;
std::unique_ptr<TestRunner> tester; std::unique_ptr<TestRunner> tester;
unsigned last_diag_seq = 0; unsigned last_diag_seq = 0;
ScanGrid grid; // outlives scheduler (holds a reference to it) ScanGrid grid; // outlives scheduler (holds a reference to it)
@ -234,6 +240,8 @@ struct Application::Impl {
// costs one line, not one per frame. Written from the camera delivery // costs one line, not one per frame. Written from the camera delivery
// thread(s) via the orientation supplier, hence atomic. // thread(s) via the orientation supplier, hence atomic.
std::atomic<bool> imu_pitch_warned_{false}; std::atomic<bool> imu_pitch_warned_{false};
// The TUI heading wizard is aiming by hand; blocks a scan from starting.
bool heading_setup_active_ = false;
std::atomic<bool> running{true}; std::atomic<bool> running{true};
std::mutex cmd_mutex; std::mutex cmd_mutex;
@ -443,9 +451,9 @@ struct Application::Impl {
return out; return out;
} }
// `imu profile [<type|label>]`. The MTi's XKF profile decides whether heading // `imu profile [<type|label>]`. The MTi's XKF profile decides whether its
// uses the magnetometer, so switching it is how you move between a compass- // heading uses the magnetometer. Note this does not affect the gimbal's own
// referenced heading and the no-mag profile `gimbal calib` leaves behind. // heading, which comes from the encoder via `gimbal heading setup`.
void imuProfile(const std::string& arg) { void imuProfile(const std::string& arg) {
if (arg.empty()) { if (arg.empty()) {
LOG_INFO << "XKF profiles (* = active): " << imuProfileList(); LOG_INFO << "XKF profiles (* = active): " << imuProfileList();
@ -532,8 +540,8 @@ struct Application::Impl {
} }
// `imu` command family, mirroring handleGimbal: the operator-facing surface of // `imu` command family, mirroring handleGimbal: the operator-facing surface of
// IImuSource. `imu norotation` + `imu zero` are the quick heading calibration // IImuSource. `imu norotation` + `imu zero` correct and re-zero the MTi's own
// that gimbal calib runs internally, without the multi-minute sweep. // heading; the gimbal's heading is independent of both.
void handleImu(const std::string& line) { void handleImu(const std::string& line) {
std::istringstream iss(line); std::istringstream iss(line);
std::vector<std::string> tok; std::vector<std::string> tok;
@ -608,15 +616,19 @@ struct Application::Impl {
MotorTelemetry t = motor->telemetry(); MotorTelemetry t = motor->telemetry();
s.gimbal.present = motor->connected(); s.gimbal.present = motor->connected();
s.gimbal.pitch_present = t.pitch_present; s.gimbal.pitch_present = t.pitch_present;
// Yaw degrees are true compass bearings in [0,360); pitch is a signed
// elevation about level, so it must not wrap.
auto fillAxis = [](AxisView& v, const char* label, const AxisTelemetry& a, auto fillAxis = [](AxisView& v, const char* label, const AxisTelemetry& a,
const AxisMap& map, long target_counts) { const AxisMap& map, long target_counts, bool wrap) {
auto deg = [&](long counts) { return wrap ? map.headingDeg(counts) : map.toDeg(counts); };
v.label = label; v.label = label;
v.state = a.state; v.state = a.state;
v.deg = map.toDeg(a.xenc); v.deg = deg(a.xenc);
v.xactual = a.xactual; v.xactual = a.xactual;
v.xenc = a.xenc; v.xenc = a.xenc;
v.enc_err = a.xactual - a.xenc; // live tracking error (counts) v.enc_err = a.xactual - a.xenc; // live tracking error (counts)
v.target_deg = map.toDeg(target_counts); v.target_deg = deg(target_counts);
v.counts_per_deg = map.counts_per_deg;
v.sg = a.sg; v.sg = a.sg;
v.cs = a.cs; v.cs = a.cs;
v.pwm = a.pwm; v.pwm = a.pwm;
@ -631,27 +643,27 @@ struct Application::Impl {
}; };
long yaw_tgt = scheduler ? scheduler->yawTargetCounts() : 0; long yaw_tgt = scheduler ? scheduler->yawTargetCounts() : 0;
long pitch_tgt = scheduler ? scheduler->pitchTargetCounts() : 0; long pitch_tgt = scheduler ? scheduler->pitchTargetCounts() : 0;
fillAxis(s.gimbal.yaw, "YAW", t.yaw, cfg.geometry.yaw, yaw_tgt); fillAxis(s.gimbal.yaw, "YAW", t.yaw, cfg.geometry.yaw, yaw_tgt, true);
fillAxis(s.gimbal.pitch, "PITCH", t.pitch, cfg.geometry.pitch, pitch_tgt); fillAxis(s.gimbal.pitch, "PITCH", t.pitch, cfg.geometry.pitch, pitch_tgt, false);
// Homing travel limits come from the last firmware dump (lim_neg/lim_pos); // Homing travel limits come from the last firmware dump (lim_neg/lim_pos);
// convert the endstop counts to degrees via the per-axis geometry. // convert the endstop counts to degrees via the per-axis geometry.
DumpData dd = parseDump(motor->lastDump()); DumpData dd = parseDump(motor->lastDump());
if (dd.valid) { if (dd.valid) {
auto fillLimits = [](AxisView& v, const DumpAxis& a, const AxisMap& map) { auto fillLimits = [](AxisView& v, const DumpAxis& a, const AxisMap& map, bool wrap) {
v.has_limits = true; v.has_limits = true;
v.lim_neg = a.lim_neg; v.lim_neg = a.lim_neg;
v.lim_pos = a.lim_pos; v.lim_pos = a.lim_pos;
v.lim_neg_deg = map.toDeg(a.lim_neg); v.lim_neg_deg = wrap ? map.headingDeg(a.lim_neg) : map.toDeg(a.lim_neg);
v.lim_pos_deg = map.toDeg(a.lim_pos); v.lim_pos_deg = wrap ? map.headingDeg(a.lim_pos) : map.toDeg(a.lim_pos);
v.has_track = true; v.has_track = true;
v.hold_corrections = a.hold_corrections; v.hold_corrections = a.hold_corrections;
v.hold_peak_dev = a.hold_peak_dev; v.hold_peak_dev = a.hold_peak_dev;
v.hold_cumulative_dev = a.hold_cumulative_dev; v.hold_cumulative_dev = a.hold_cumulative_dev;
}; };
for (const auto& a : dd.axes) { for (const auto& a : dd.axes) {
if (a.axis == 'Y') fillLimits(s.gimbal.yaw, a, cfg.geometry.yaw); if (a.axis == 'Y') fillLimits(s.gimbal.yaw, a, cfg.geometry.yaw, true);
else if (a.axis == 'P') fillLimits(s.gimbal.pitch, a, cfg.geometry.pitch); else if (a.axis == 'P') fillLimits(s.gimbal.pitch, a, cfg.geometry.pitch, false);
} }
} }
@ -753,6 +765,10 @@ struct Application::Impl {
// --- Camera / capture --- // --- Camera / capture ---
s.capture.present = true; s.capture.present = true;
s.capture.active = scheduler && scheduler->captureActive(); s.capture.active = scheduler && scheduler->captureActive();
// Manual = no scan running. Drives what the key bar advertises; the
// enforcement is in requireManual(), not in the UI.
s.capture.manual = manualMode();
s.capture.snapshot_armed = scheduler && scheduler->snapshotPending();
s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0; s.capture.image_rate = scheduler ? scheduler->imageRate() : 0.0;
s.capture.camera_count = camera ? camera->cameraCount() : 0; s.capture.camera_count = camera ? camera->cameraCount() : 0;
s.capture.labels.clear(); s.capture.labels.clear();
@ -926,7 +942,7 @@ struct Application::Impl {
// Pending yes/no: persist the just-applied calibration as the new default? // Pending yes/no: persist the just-applied calibration as the new default?
if (calib_save_pending_) if (calib_save_pending_)
a.prompt = "Save this calibration to config as the new default? (y / n)"; a.prompt = "Save the new [Motor] calibration to config as the default? (y / n)";
} }
void publishSnapshot() { void publishSnapshot() {
@ -971,15 +987,21 @@ struct Application::Impl {
motor->sendCommand("ENABLE Y"); motor->sendCommand("ENABLE Y");
motor->sendCommand("ENABLE P"); motor->sendCommand("ENABLE P");
// Tell the firmware where to anchor the homed midpoint and how much
// clearance to keep from each endstop, BEFORE homing: finishHoming() uses
// the centre count to re-origin, so a late send would apply to the next
// home, not this one.
sendAnchorConfig();
motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking
auto allReady = [](const MotorTelemetry& t) { auto allReady = [](const MotorTelemetry& t) {
return t.yaw.ready() && (!t.pitch_present || t.pitch.ready()); return t.yaw.ready() && (!t.pitch_present || t.pitch.ready());
}; };
// The axes may already be READY (EEPROM fast-path), so first wait briefly // An axis re-homed from READY is still READY for a moment, so first wait
// for homing to actually begin (an axis leaves READY) before waiting for // briefly for homing to actually begin (an axis leaves READY) before
// it to finish - otherwise we'd see the residual READY and return early. // waiting for it to finish - otherwise we'd see the residual READY and
// return early having waited for nothing.
const auto t0 = std::chrono::steady_clock::now(); const auto t0 = std::chrono::steady_clock::now();
while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < t0 + 3s) { while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < t0 + 3s) {
std::this_thread::sleep_for(100ms); std::this_thread::sleep_for(100ms);
@ -1013,77 +1035,136 @@ struct Application::Impl {
return; return;
} }
// Re-anchor the yaw zero to an endstop now that the homed limits are known. // Re-anchor the yaw zero to an endstop now that the homed limits are known.
if (homed) applyYawHomeZero(); if (homed) applyHomedTravel();
// Production move speeds for subsequent MOVE commands. // Production move speeds for subsequent MOVE commands.
motor->sendCommand("SPEED Y 50000"); motor->sendCommand("SPEED Y 50000");
motor->sendCommand("SPEED P 150000"); motor->sendCommand("SPEED P 150000");
LOG_INFO << "Gimbal init finished"; LOG_INFO << "Gimbal init finished";
} }
// With [Motor] yaw_home_zero = low|high, redefine the yaw degree origin from // Tell the firmware the anchor point and endstop clearance for each axis.
// the freshly homed endstops: the chosen endstop becomes 0 deg and degrees // The centre count is what finishHoming() re-origins onto, so all counts stay
// rise toward the other limit (forced positive, so it lands near +360). The // positive and reproducible across power cycles; the margin is what
// counts_per_deg magnitude is kept from config/calibration; only the zero and // clampTarget() keeps clear of each hard limit.
// sign change, so the yaw zero is fixed by homing and never needs a separate void sendAnchorConfig() {
// calibration. Pitch is left untouched. motor->sendCommand("CENTER Y " + std::to_string(cfg.geometry.yaw.center_count));
void applyYawHomeZero() { motor->sendCommand("CENTER P " + std::to_string(cfg.geometry.pitch.center_count));
motor->sendCommand("MARGIN Y " + std::to_string(cfg.yaw_soft_margin_counts));
motor->sendCommand("MARGIN P " + std::to_string(cfg.pitch_soft_margin_counts));
}
// Derive each axis's reachable degree window from the freshly homed soft
// limits. These are deliberately NOT configurable: the firmware's limits are
// the only place they can be correct, and a stale configured range would
// either advertise travel that does not exist or hide travel that does.
//
// Also verifies that the firmware actually adopted the anchor and margin we
// sent. With no EEPROM the limits can only come from a home in this power
// cycle, so a midpoint that is not center_count means the CENTER command did
// not take effect - firmware predating it answers "ERR unknown command" and
// homes to its own compiled default, which would leave every published
// bearing silently wrong.
void applyHomedTravel() {
using namespace std::chrono_literals; using namespace std::chrono_literals;
if (cfg.yaw_home_zero == YawHomeZero::Off) return;
// The homed limits live in the firmware DUMP; pull a fresh one and wait // The homed limits live in the firmware DUMP; pull a fresh one and wait
// briefly for it to arrive with yaw endstops populated. // briefly for it to arrive.
motor->sendCommand("DUMP"); motor->sendCommand("DUMP");
long lo = 0, hi = 0, margin = 0; DumpData dd;
bool got = false;
const auto deadline = std::chrono::steady_clock::now() + 3s; const auto deadline = std::chrono::steady_clock::now() + 3s;
while (running && std::chrono::steady_clock::now() < deadline) { while (running && std::chrono::steady_clock::now() < deadline) {
std::this_thread::sleep_for(100ms); std::this_thread::sleep_for(100ms);
publishSnapshot(); publishSnapshot();
DumpData dd = parseDump(motor->lastDump()); dd = parseDump(motor->lastDump());
if (dd.valid) if (dd.valid) break;
for (const auto& a : dd.axes)
if (a.axis == 'Y' && a.lim_pos != a.lim_neg) {
lo = a.lim_neg; hi = a.lim_pos; margin = a.soft_margin; got = true;
}
if (got) break;
} }
if (!got) { if (!dd.valid) {
LOG_WARN << "yaw home-zero: no homed yaw limits in dump; keeping configured zero"; LOG_WARN << "homed travel: no firmware dump; keeping the previous degree limits";
return; return;
} }
if (margin <= 0)
LOG_WARN << "yaw home-zero: firmware reported no soft_margin (update firmware); "
"anchoring at the hard homing limit instead of the soft limit";
const double mag = std::fabs(cfg.geometry.yaw.counts_per_deg); bool any = false;
if (mag < 1e-9) { for (const auto& a : dd.axes) {
LOG_WARN << "yaw home-zero: yaw_counts_per_deg ~0; calibrate first; keeping zero"; if (a.lim_pos == a.lim_neg) continue; // that axis has not homed
return; const bool is_yaw = (a.axis == 'Y');
AxisMap& map = is_yaw ? cfg.geometry.yaw : cfg.geometry.pitch;
const char* name = is_yaw ? "yaw" : "pitch";
const long want_margin =
is_yaw ? cfg.yaw_soft_margin_counts : cfg.pitch_soft_margin_counts;
// One encoder quantum of slack: ENC_CONST makes X_ENC advance in 12.8
// microstep steps, so the midpoint can land a count or so off.
const long mid = a.lim_neg + (a.lim_pos - a.lim_neg) / 2;
if (std::labs(mid - map.center_count) > kEncoderQuantumCounts) {
LOG_ERROR << "homed " << name << " midpoint is " << mid << " but ["
<< "Motor] " << name << "_center_count is " << map.center_count
<< " -- the firmware did not adopt CENTER (is it older than this "
"host?). Refusing to derive travel limits; headings would be "
"wrong.";
continue;
}
long margin = a.soft_margin;
if (margin <= 0) {
LOG_WARN << "homed " << name
<< ": firmware reported no soft_margin (update firmware); "
"anchoring at the hard homing limit instead";
margin = 0;
} else if (margin != want_margin) {
// Trust the firmware: it is what is actually clamping the axis, so
// deriving from our value would advertise travel it will not allow.
LOG_WARN << "homed " << name << ": firmware soft_margin " << margin
<< " != configured " << want_margin
<< "; using the firmware's value";
}
const long lo_soft = a.lim_neg + margin;
const long hi_soft = a.lim_pos - margin;
const double d_lo = map.toDeg(lo_soft);
const double d_hi = map.toDeg(hi_soft);
map.min_deg = std::min(d_lo, d_hi);
map.max_deg = std::max(d_lo, d_hi);
any = true;
if (is_yaw)
LOG_INFO << "homed yaw travel: " << map.min_deg << ".." << map.max_deg
<< " deg (headings " << wrap360(map.min_deg) << ".."
<< wrap360(map.max_deg) << " true), centre " << map.center_count
<< " = " << map.deg_at_center << " deg";
else
LOG_INFO << "homed pitch travel: " << map.min_deg << ".." << map.max_deg
<< " deg, centre " << map.center_count << " = " << map.deg_at_center
<< " deg";
} }
// Anchor at the soft limits (the actual reachable travel), not the hard if (!any) return;
// homing endstops, so the rightmost *reachable* position reads exactly 0.
const long lo_soft = lo + margin;
const long hi_soft = hi - margin;
const bool zero_high = (cfg.yaw_home_zero == YawHomeZero::High);
const long zero_cnt = zero_high ? hi_soft : lo_soft;
const long other_cnt = zero_high ? lo_soft : hi_soft;
// Sign so the other endstop sits at positive degrees (rises toward ~+360).
const double cpd = (other_cnt > zero_cnt) ? mag : -mag;
const double range_deg = std::fabs(static_cast<double>(other_cnt - zero_cnt)) / mag;
AxisMap& y = cfg.geometry.yaw;
y.zero_count = zero_cnt;
y.counts_per_deg = cpd;
y.min_deg = 0.0;
y.max_deg = range_deg;
if (scheduler) scheduler->setGeometry(cfg.geometry); // re-target the scan with it if (scheduler) scheduler->setGeometry(cfg.geometry); // re-target the scan with it
auditScanReachability();
}
LOG_INFO << "yaw zero anchored at " << (zero_high ? "high" : "low") // Report scan waypoints that fall in the yaw arc the gimbal cannot reach, once
<< "-count endstop: 0.0.." << range_deg << " deg (zero_count=" << zero_cnt // per homing rather than once per pass. The scheduler skips them at run time;
<< ", counts_per_deg=" << cpd << ")"; // without this the only symptom would be a grid quietly shorter than the CSV.
void auditScanReachability() {
if (grid.empty()) return;
std::size_t bad = 0;
for (const auto& p : grid.points())
if (!cfg.geometry.yaw.headingToCounts(p.yaw_deg)) ++bad;
if (bad == 0) return;
LOG_WARN << bad << " of " << grid.size()
<< " scan waypoints lie outside the reachable yaw arc ("
<< wrap360(cfg.geometry.yaw.min_deg) << ".."
<< wrap360(cfg.geometry.yaw.max_deg) << " true) and will be skipped";
} }
void startCapture() { void startCapture() {
// Heading setup owns the gimbal by hand. Starting a scan under it would
// move the axis between a sighting and the bearing being typed for it,
// silently poisoning the fit with a position nobody looked through.
if (heading_setup_active_) {
LOG_WARN << "Capture not started: heading setup in progress "
"(finish it, or 'gimbal heading cancel')";
return;
}
// The scheduler arms regardless, but a scan can't progress until the axes // The scheduler arms regardless, but a scan can't progress until the axes
// are homed: before READY the firmware leaves coils disabled and clamps // are homed: before READY the firmware leaves coils disabled and clamps
// MOVE targets to a coarse bench guard, so no waypoint is ever reached and // MOVE targets to a coarse bench guard, so no waypoint is ever reached and
@ -1104,6 +1185,123 @@ struct Application::Impl {
LOG_INFO << "Capture stopped"; LOG_INFO << "Capture stopped";
} }
// `gimbal heading fit <counts>:<deg> ...` / `gimbal heading offset <deg>`.
//
// `fit` is what the TUI wizard emits after the operator has sighted landmarks,
// but it is a first-class command in its own right: on a headless tower an
// operator who already knows two (encoder position, true bearing) pairs can
// type it directly.
void handleHeading(const std::vector<std::string>& tok) {
const std::string what = tok.size() > 2 ? lowerStr(tok[2]) : "";
if (what == "setup") {
// The TUI drives the interaction (arrow keys and typed bearings exist
// only there); this just claims the gimbal so a scan cannot start
// underneath it and move the axis between a sighting and its bearing.
if (procedureBusy()) {
LOG_WARN << "gimbal heading setup: " << procedureName() << " in progress";
return;
}
if (!requireManual("gimbal heading setup")) return;
heading_setup_active_ = true;
LOG_INFO << "heading setup: aim at a landmark whose true bearing you know, "
"Space to record an image, Enter to enter its bearing. Two "
"landmarks minimum, further apart is better.";
return;
}
if (what == "cancel") {
if (heading_setup_active_) LOG_INFO << "heading setup cancelled";
heading_setup_active_ = false;
return;
}
if (what == "offset") {
double off = 0;
if (tok.size() < 4) { LOG_WARN << "usage: gimbal heading offset <deg>"; return; }
try { off = std::stod(tok[3]); } catch (...) {
LOG_WARN << "gimbal heading offset: bad number '" << tok[3] << "'";
return;
}
AxisMap& y = cfg.geometry.yaw;
const double old = y.deg_at_center;
y.deg_at_center = wrap360(old + off);
applyYawMapChange();
LOG_INFO << "yaw heading at centre " << old << " -> " << y.deg_at_center
<< " deg true (offset " << off << ")";
return;
}
if (what != "fit") {
LOG_WARN << "usage: gimbal heading <setup|fit <counts>:<deg> ...|offset <deg>>";
return;
}
std::vector<HeadingSighting> sightings;
std::string err;
if (!parseHeadingSightings({tok.begin() + 3, tok.end()}, sightings, err)) {
LOG_WARN << "gimbal heading fit: " << err;
return;
}
const HeadingFitResult r = fitHeading(sightings, cfg.geometry.yaw.center_count);
if (!r.ok) {
LOG_WARN << "gimbal heading fit: " << r.error;
return;
}
AxisMap& y = cfg.geometry.yaw;
LOG_INFO << "yaw heading fit over " << r.n << " sightings spanning " << r.span_deg
<< " deg: counts_per_deg " << y.counts_per_deg << " -> " << r.counts_per_deg
<< ", heading at centre " << y.deg_at_center << " -> " << r.deg_at_center
<< " deg true (R^2=" << r.r2 << ")";
y.counts_per_deg = r.counts_per_deg;
y.deg_at_center = r.deg_at_center;
applyYawMapChange();
heading_setup_active_ = false;
// Offer to persist, through the same activity-strip prompt the pitch setup
// uses - the operator answers y/n without leaving the dashboard.
if (!opts.config_path.empty()) calib_save_pending_ = true;
}
// Re-derive everything that depends on the yaw map after it changes: the
// reachable degree window (which is in degrees, so it moves with the map) and
// the scheduler's copy used to target scan waypoints.
void applyYawMapChange() {
applyHomedTravel();
if (scheduler) scheduler->setGeometry(cfg.geometry);
}
// Capture one image now, outside any scan: the operator's reference shot while
// aiming by hand. It goes through the scheduler rather than poking the camera
// directly so it produces exactly what a scan waypoint produces - same file,
// same CamEvent on MQTT.
void takeSnapshot() {
if (!requireManual("snapshot")) return;
if (!camera || !scheduler) { LOG_WARN << "snapshot: no camera configured"; return; }
MotorTelemetry t = motor->telemetry();
if (!t.yaw.ready() || (t.pitch_present && !t.pitch.ready())) {
LOG_WARN << "snapshot: gimbal not homed (press h / run 'gimbal home' first)";
return;
}
// The camera is stopped outside a scan; leave it running afterwards so a
// burst of snapshots does not pay the start-up cost each time. stopCapture()
// and shutdown stop it as before.
camera->start();
scheduler->requestSnapshot();
LOG_INFO << "snapshot requested";
}
// Manual mode is simply "no scan running" - derived, never a separate flag an
// operator could strand. Hand aiming and a scan are mutually exclusive: both
// drive the same axes, and a nudge mid-sweep would silently corrupt the
// waypoint the next frame is published under.
bool manualMode() const { return !scheduler || !scheduler->captureActive(); }
// Guard for the hand-aiming commands. Returns false (having said why) when a
// scan owns the gimbal.
bool requireManual(const char* what) {
if (manualMode()) return true;
LOG_WARN << "ignored '" << what << "': a scan is running (press x / type 'stop' first)";
return false;
}
// `trace off|none`, `trace all`, or `trace <cat> [on|off]` — toggle the // `trace off|none`, `trace all`, or `trace <cat> [on|off]` — toggle the
// verbatim wire-trace categories (serial/mqtt/camera/control). // verbatim wire-trace categories (serial/mqtt/camera/control).
void handleTrace(const Command& c) { void handleTrace(const Command& c) {
@ -1160,13 +1358,19 @@ struct Application::Impl {
std::string t; std::string t;
while (iss >> t) tok.push_back(t); // tok[0] == "gimbal" while (iss >> t) tok.push_back(t); // tok[0] == "gimbal"
if (tok.size() < 2) { if (tok.size() < 2) {
LOG_WARN << "usage: gimbal <move|steps|nudge|home|stop|reset|enable|disable|" LOG_WARN << "usage: gimbal <move|steps|nudge|jog|heading|pitch|home|stop|reset|"
"speed|setpos|status|dump|calib|raw> (self-test: 'test')"; "enable|disable|speed|setpos|status|dump|raw> (self-test: 'test')";
return; return;
} }
std::string sub = tok[1]; std::string sub = tok[1];
for (char& ch : sub) ch = static_cast<char>(std::tolower((unsigned char)ch)); for (char& ch : sub) ch = static_cast<char>(std::tolower((unsigned char)ch));
// Hand aiming is manual-mode only: these fight a running scan for the
// same axes, and a move mid-sweep mislabels the next published frame.
if (sub == "move" || sub == "steps" || sub == "nudge" || sub == "jog") {
if (!requireManual(("gimbal " + sub).c_str())) return;
}
if (sub == "move") { if (sub == "move") {
double yaw_deg = 0, pitch_deg = 0; double yaw_deg = 0, pitch_deg = 0;
if (!parsePair(tok, 2, yaw_deg, pitch_deg)) { if (!parsePair(tok, 2, yaw_deg, pitch_deg)) {
@ -1205,8 +1409,25 @@ struct Application::Impl {
long target = cur + static_cast<long>(pct / 100.0 * range); long target = cur + static_cast<long>(pct / 100.0 * range);
LOG_INFO << "gimbal nudge " << axis << " " << pct << "% -> MOVE " << axis << " " << target; LOG_INFO << "gimbal nudge " << axis << " " << pct << "% -> MOVE " << axis << " " << target;
motor->sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target)); motor->sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target));
} else if (sub == "calib") { } else if (sub == "jog") {
startCalibration(); // Relative move in raw counts. `nudge` is a percent of travel and
// `steps` is absolute; neither can express the sub-degree steps the
// heading wizard needs, and counts are what the encoder resolves.
if (tok.size() < 4) { LOG_WARN << "usage: gimbal jog <yaw|pitch> <+/-counts>"; return; }
char axis = (tok[2][0] == 'p' || tok[2][0] == 'P') ? 'P' : 'Y';
long delta = 0;
try { delta = std::stol(tok[3]); } catch (...) { LOG_WARN << "gimbal jog: bad count"; return; }
MotorTelemetry tel = motor->telemetry();
long cur = (axis == 'Y') ? tel.yaw.xenc : tel.pitch.xenc;
long target = cur + delta;
LOG_INFO << "gimbal jog " << axis << " " << delta << " counts -> MOVE " << axis
<< " " << target;
motor->sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target));
} else if (sub == "heading") {
handleHeading(tok);
} else if (sub == "pitch") {
if (tok.size() > 2 && lowerStr(tok[2]) == "setup") startPitchSetup();
else LOG_WARN << "usage: gimbal pitch setup";
} else if (sub == "dump") { } else if (sub == "dump") {
LOG_INFO << "requesting firmware dump..."; LOG_INFO << "requesting firmware dump...";
motor->sendCommand("DUMP"); motor->sendCommand("DUMP");
@ -1246,11 +1467,25 @@ struct Application::Impl {
} }
} }
void startCalibration() { void startPitchSetup() {
if (!imu) { LOG_WARN << "gimbal calib requires the IMU (set [Features] enable_imu)"; return; } // Pitch is the one axis that genuinely needs the IMU: level is defined by
if (calib && calib->running()) { LOG_WARN << "calibration already running (gimbal stop to cancel)"; return; } // gravity, and nothing else on the tower can measure it. Yaw does not -
// `gimbal heading setup` uses landmarks instead.
if (!imu) {
LOG_WARN << "gimbal pitch setup requires the IMU (set [Features] enable_imu); "
"yaw does not - use 'gimbal heading setup'";
return;
}
if (calib && calib->running()) {
LOG_WARN << "pitch setup already running (press Esc, or type 'stop', to cancel)";
return;
}
if (heading_setup_active_) {
LOG_WARN << "pitch setup: heading setup in progress ('gimbal heading cancel' first)";
return;
}
stopCapture(); // free the serial link + don't fight the routine's moves stopCapture(); // free the serial link + don't fight the routine's moves
calib = std::make_unique<CalibrationRoutine>(*motor, *imu, cfg.geometry); calib = std::make_unique<PitchSetupRoutine>(*motor, *imu, cfg.geometry);
calib->start(); calib->start();
} }
@ -1258,25 +1493,26 @@ struct Application::Impl {
void saveCalibration() { void saveCalibration() {
if (!calib_save_pending_) return; if (!calib_save_pending_) return;
calib_save_pending_ = false; calib_save_pending_ = false;
if (saveMotorCalibration(opts.config_path, cfg.geometry)) if (saveMotorGeometry(opts.config_path, cfg.geometry, cfg.yaw_soft_margin_counts,
LOG_INFO << "calibration saved as the new default in " << opts.config_path; cfg.pitch_soft_margin_counts))
LOG_INFO << "[Motor] calibration saved as the new default in " << opts.config_path;
else else
LOG_WARN << "could not write calibration to " << opts.config_path; LOG_WARN << "could not write [Motor] calibration to " << opts.config_path;
} }
void cancelCalibration() { void cancelPitchSetup() {
if (calib && calib->running()) { if (calib && calib->running()) {
LOG_INFO << "cancelling calibration"; LOG_INFO << "cancelling pitch setup";
calib->cancel(); calib->cancel();
motor->sendCommand("STOP ALL"); motor->sendCommand("STOP ALL");
} }
} }
// ---- Procedure lock-out (test / calibration / homing are mutually exclusive) ---- // ---- Procedure lock-out (test / pitch setup / homing are mutually exclusive) ----
enum class Procedure { None, Homing, Calibration, Test }; enum class Procedure { None, Homing, PitchSetup, Test };
Procedure activeProcedure() const { Procedure activeProcedure() const {
if (tester && tester->running()) return Procedure::Test; if (tester && tester->running()) return Procedure::Test;
if (calib && calib->running()) return Procedure::Calibration; if (calib && calib->running()) return Procedure::PitchSetup;
if (homing_active_.load()) return Procedure::Homing; if (homing_active_.load()) return Procedure::Homing;
return Procedure::None; return Procedure::None;
} }
@ -1284,7 +1520,7 @@ struct Application::Impl {
const char* procedureName() const { const char* procedureName() const {
switch (activeProcedure()) { switch (activeProcedure()) {
case Procedure::Test: return "test"; case Procedure::Test: return "test";
case Procedure::Calibration: return "calibration"; case Procedure::PitchSetup: return "pitch setup";
case Procedure::Homing: return "homing"; case Procedure::Homing: return "homing";
default: return "none"; default: return "none";
} }
@ -1297,8 +1533,8 @@ struct Application::Impl {
tester->cancel(); tester->cancel();
motor->sendCommand("STOP ALL"); motor->sendCommand("STOP ALL");
break; break;
case Procedure::Calibration: case Procedure::PitchSetup:
cancelCalibration(); cancelPitchSetup();
break; break;
case Procedure::Homing: case Procedure::Homing:
LOG_INFO << "cancelling homing"; LOG_INFO << "cancelling homing";
@ -1475,13 +1711,13 @@ struct Application::Impl {
if (ax.axis == 'P') v.pitch_present = true; if (ax.axis == 'P') v.pitch_present = true;
dst->ok = ax.ok; dst->ok = ax.ok;
dst->counts_per_deg = ax.counts_per_deg; dst->counts_per_deg = ax.counts_per_deg;
dst->zero_count = ax.zero_count; dst->deg_at_center = ax.deg_at_center;
dst->r2 = ax.r2; dst->r2 = ax.r2;
dst->n = ax.n; dst->n = ax.n;
char b[96]; char b[96];
std::snprintf(b, sizeof(b), "%s cpd=%.3f zero=%ld R2=%.4f (n=%d)%s", std::snprintf(b, sizeof(b), "%s cpd=%.3f deg@ctr=%.3f R2=%.4f (n=%d)%s",
ax.axis == 'P' ? "PITCH" : "YAW", ax.counts_per_deg, ax.axis == 'P' ? "PITCH" : "YAW", ax.counts_per_deg,
ax.zero_count, ax.r2, ax.n, ax.ok ? "" : " FIT FAILED"); ax.deg_at_center, ax.r2, ax.n, ax.ok ? "" : " FIT FAILED");
last_calib_summary.emplace_back(b); last_calib_summary.emplace_back(b);
} }
last_calib_view = v; last_calib_view = v;
@ -1576,8 +1812,8 @@ struct Application::Impl {
// Commands that would disturb a running procedure (motion, capture, a second // Commands that would disturb a running procedure (motion, capture, a second
// long op, or a DUMP over the same serial link). Ignored while busy. // long op, or a DUMP over the same serial link). Ignored while busy.
static bool interferingVerb(const std::string& verb) { static bool interferingVerb(const std::string& verb) {
return verb == "gimbal" || verb == "start" || verb == "calib" || return verb == "gimbal" || verb == "start" || verb == "test" ||
verb == "test" || verb == "refresh"; verb == "refresh" || verb == "snapshot";
} }
void handleCommand(const std::string& line) { void handleCommand(const std::string& line) {
@ -1605,14 +1841,18 @@ struct Application::Impl {
} else if (c.verb == "imu") { } else if (c.verb == "imu") {
handleImu(line); handleImu(line);
} else if (c.verb == "calib") { } else if (c.verb == "calib") {
// Answer to the "save calibration?" prompt (activity-strip y/n keys). // Answer to the "save to config?" prompt (activity-strip y/n keys).
// Shared by `gimbal pitch setup` and `gimbal heading fit`: both change
// the [Motor] map and both offer the same choice.
if (c.device == "save") saveCalibration(); if (c.device == "save") saveCalibration();
else if (c.device == "discard") { else if (c.device == "discard") {
calib_save_pending_ = false; calib_save_pending_ = false;
LOG_INFO << "calibration kept for this session only (not saved to config)"; LOG_INFO << "kept for this session only (not saved to config)";
} }
} else if (c.verb == "exit") { } else if (c.verb == "exit") {
running = false; running = false;
} else if (c.verb == "snapshot") {
takeSnapshot();
} else if (c.verb == "start") { } else if (c.verb == "start") {
startCapture(); startCapture();
} else if (c.verb == "stop") { } else if (c.verb == "stop") {
@ -1655,7 +1895,16 @@ struct Application::Impl {
} }
else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value); else if (c.option == "jxlq" && c.has_value) pipeline->setDistance(c.value);
else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value)); else if (c.option == "jxle" && c.has_value) pipeline->setEffort(static_cast<int>(c.value));
else if (c.option == "display") pipeline->setDisplay(c.value != 0.0); else if (c.option == "display") {
const bool on = c.value != 0.0;
pipeline->setDisplay(on);
// Confirm it: the preview only repaints when a frame goes
// through the pipeline, so with no capture running there is
// nothing on screen and silence is indistinguishable from the
// command having been rejected.
LOG_INFO << "local display " << (on ? "on" : "off")
<< " (updates on the next captured frame)";
}
else LOG_WARN << "unknown 'set camera' option: " << c.option; else LOG_WARN << "unknown 'set camera' option: " << c.option;
} else if (c.device == "fps" && c.has_value) { } else if (c.device == "fps" && c.has_value) {
scheduler->setImageRate(c.value); scheduler->setImageRate(c.value);
@ -1742,9 +1991,10 @@ struct Application::Impl {
// Orientation supplier, called by ImagePipeline::submit() as each frame // Orientation supplier, called by ImagePipeline::submit() as each frame
// arrives. The two axes come from different sources on purpose: // arrives. The two axes come from different sources on purpose:
// //
// yaw - the encoder. IMU yaw drifts and depends on the heading reset // yaw - the encoder, as a true compass bearing in [0,360). IMU yaw
// and the XKF profile, whereas encoder yaw is calibrated against // drifts and depends on the heading reset and the XKF profile,
// the IMU by `gimbal calib` and then stays put. // whereas encoder yaw is anchored to the homed centre and tied to
// north by `gimbal heading setup`, and then stays put.
// pitch - the IMU. The encoder only measures the axis relative to the // pitch - the IMU. The encoder only measures the axis relative to the
// gimbal's own base, so a leaning mast is invisible to it; the // gimbal's own base, so a leaning mast is invisible to it; the
// IMU measures against gravity, which is the true elevation the // IMU measures against gravity, which is the true elevation the
@ -1753,7 +2003,7 @@ struct Application::Impl {
*channel, *channel,
[this] { [this] {
MotorTelemetry t = motor->telemetry(); MotorTelemetry t = motor->telemetry();
Orientation o{static_cast<float>(cfg.geometry.yaw.toDeg(t.yaw.xenc)), Orientation o{static_cast<float>(cfg.geometry.yaw.headingDeg(t.yaw.xenc)),
static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))}; static_cast<float>(cfg.geometry.pitch.toDeg(t.pitch.xenc))};
if (!imu) return o; // IMU disabled in config: encoder pitch, as before if (!imu) return o; // IMU disabled in config: encoder pitch, as before
if (auto s = imu->sample()) { if (auto s = imu->sample()) {
@ -1813,13 +2063,14 @@ struct Application::Impl {
}); });
if (opts.init) { if (opts.init) {
runInitSequence(); // homes, then re-anchors the yaw zero runInitSequence(); // homes, then derives the travel window
} else if (cfg.yaw_home_zero != YawHomeZero::Off) { } else {
// No homing this launch: the firmware restores its homed limits from // No homing this launch. The firmware keeps nothing across a power
// EEPROM to READY on boot, so re-derive the same yaw zero from them. // cycle, so this only finds limits when the gimbal was already homed
// This makes the homing-anchored zero persist across software restarts // earlier in this session (e.g. the host restarted, the board did not).
// without re-homing. Self-guards (no-op + note) if not yet homed. // Self-guards to a warning when there is no dump to read.
applyYawHomeZero(); sendAnchorConfig();
applyHomedTravel();
} }
if (opts.start) startCapture(); if (opts.start) startCapture();

View File

@ -38,6 +38,10 @@ void CaptureScheduler::setCaptureActive(bool active) {
capture_active_ = active; capture_active_ = active;
moving_ = false; // restart the move/settle cycle cleanly moving_ = false; // restart the move/settle cycle cleanly
settled_at_ms_ = 0; settled_at_ms_ = 0;
// Drop any armed snapshot: a scan starting means the operator moved on, and
// keeping it would fire an image at some unrelated later moment when the scan
// stops - attributed to whatever the gimbal happened to be pointing at then.
snapshot_pending_ = false;
resetTimer(); resetTimer();
} }
void CaptureScheduler::setImageRate(double rate) { image_rate_ = rate; } void CaptureScheduler::setImageRate(double rate) { image_rate_ = rate; }
@ -69,6 +73,45 @@ bool CaptureScheduler::settledAt(const MotorTelemetry& t) const {
std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts; std::labs(t.pitch.xenc - pitch_target_) <= kSettleTolCounts;
} }
void CaptureScheduler::requestSnapshot() {
snapshot_pending_ = true;
snapshot_settled_ms_ = 0;
}
// One frame, on request, through exactly the path a scan waypoint takes: the
// same standstill + settle wait, the same camera trigger, and therefore the same
// .jxl on disk and the same CamEvent on MQTT. Reusing the path rather than
// triggering the camera directly is the point - a snapshot that differed from a
// scan frame would be useless as a reference for aiming.
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
if (!standstill) {
snapshot_settled_ms_ = 0; // still slewing (e.g. a jog is in flight)
return;
}
if (snapshot_settled_ms_ == 0) {
snapshot_settled_ms_ = now_ms_();
return;
}
const long long since_settled = now_ms_() - snapshot_settled_ms_;
if (since_settled < settle_wait_ms_) return;
if (camera_.trigger()) {
LOG_INFO << "snapshot captured";
snapshot_pending_ = false;
return;
}
// No fresh frame yet. Give up on the same budget a waypoint gets - measured
// from settling, as the message says, not from the keypress: the operator may
// have pressed Space mid-slew, and counting the slew against the camera would
// abandon a snapshot the camera never had a chance at.
if (since_settled > settle_wait_ms_ + frame_timeout_ms_) {
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_
<< " ms of settling; press Space again";
snapshot_pending_ = false;
}
}
void CaptureScheduler::tick() { void CaptureScheduler::tick() {
// 1. Remote control input. // 1. Remote control input.
ControlCommand cmd = channel_.poll(); ControlCommand cmd = channel_.poll();
@ -86,9 +129,15 @@ void CaptureScheduler::tick() {
MotorTelemetry t = motor_.telemetry(); MotorTelemetry t = motor_.telemetry();
pitch_present_ = t.pitch_present; pitch_present_ = t.pitch_present;
// 3. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act. // 3. Manual snapshot. Only outside a scan, so a keypress can never inject a
// frame into a running sweep or fight it for the settle window.
if (!capture_active_) {
if (snapshot_pending_) serviceSnapshot(t);
return;
}
// 4. Capture cycle. Only ControlCode 0 (sweep) and 1 (directed) act.
if (control_code_ != 0 && control_code_ != 1) return; if (control_code_ != 0 && control_code_ != 1) return;
if (!capture_active_) return;
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0; const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0;
@ -99,18 +148,49 @@ void CaptureScheduler::tick() {
if (control_code_ == 0) { if (control_code_ == 0) {
if (grid_.empty()) return; if (grid_.empty()) return;
const ScanPoint& p = grid_.current(); const ScanPoint& p = grid_.current();
yaw_target_ = geometry_.yaw.toCounts(p.yaw_deg); // Yaw travel is under a full turn, so some bearings are unreachable.
// Skip such a waypoint rather than clamping to the nearest limit,
// which would image a direction nobody asked for and label it with
// the one they did.
auto yc = geometry_.yaw.headingToCounts(p.yaw_deg);
if (!yc) {
if (!unreachable_warned_) {
unreachable_warned_ = true;
LOG_WARN << "scan waypoint " << (grid_.index() + 1) << "/" << grid_.size()
<< " heading " << p.yaw_deg
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable waypoints are skipped silently)";
}
grid_.advance();
resetTimer();
return;
}
yaw_target_ = *yc;
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg); pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
// One INFO line per move so the operator can follow the scan in the // One INFO line per move so the operator can follow the scan in the
// log without enabling the control wire-trace. // log without enabling the control wire-trace.
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size() LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)"; << ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
} else { // ControlCode 1: directed yaw, pitch held at current position. } else { // ControlCode 1: directed yaw, pitch held at current position.
double heading = 0.0;
try { try {
yaw_target_ = geometry_.yaw.toCounts(std::stod(target_heading_)); heading = std::stod(target_heading_);
} catch (const std::exception&) { } catch (const std::exception&) {
return; // bad heading; wait for a valid one return; // bad heading; wait for a valid one
} }
auto yc = geometry_.yaw.headingToCounts(heading);
if (!yc) {
// Hold position and say so: silently pointing at the nearest limit
// would publish CamEvents claiming a bearing we cannot reach.
if (!directed_unreachable_warned_) {
directed_unreachable_warned_ = true;
LOG_WARN << "target heading " << heading
<< " deg is outside the reachable yaw arc; holding position";
}
return;
}
directed_unreachable_warned_ = false;
yaw_target_ = *yc;
pitch_target_ = t.pitch.xenc; pitch_target_ = t.pitch.xenc;
LOG_INFO << "directed move: heading " << target_heading_ << " (deg)"; LOG_INFO << "directed move: heading " << target_heading_ << " (deg)";
} }

View File

@ -1,4 +1,5 @@
#include "fgc/Config.h" #include "fgc/Config.h"
#include "fgc/Logger.h"
#include "fgc/Paths.h" #include "fgc/Paths.h"
#include <cmath> #include <cmath>
@ -17,6 +18,8 @@ extern "C" {
namespace fgc { namespace fgc {
namespace fs = std::filesystem;
namespace { namespace {
std::string get(const std::map<std::string, std::string>& kv, const std::string& key, std::string get(const std::map<std::string, std::string>& kv, const std::string& key,
@ -362,22 +365,17 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.ui.enable_tui = getBool(kv, "UI.enable_tui", cfg.ui.enable_tui); cfg.ui.enable_tui = getBool(kv, "UI.enable_tui", cfg.ui.enable_tui);
// [Motor]: operator-calibrated degrees<->counts maps (see Geometry). // [Motor]: the degrees<->counts maps, anchored on the homed mechanical centre
// (see Geometry). min_deg/max_deg are NOT configured - they are derived from
// the firmware's homed soft limits, the only place they can be correct.
cfg.geometry.yaw.counts_per_deg = getDouble(kv, "Motor.yaw_counts_per_deg", cfg.geometry.yaw.counts_per_deg); cfg.geometry.yaw.counts_per_deg = getDouble(kv, "Motor.yaw_counts_per_deg", cfg.geometry.yaw.counts_per_deg);
cfg.geometry.yaw.zero_count = getLong(kv, "Motor.yaw_zero_count", cfg.geometry.yaw.zero_count); cfg.geometry.yaw.center_count = getLong(kv, "Motor.yaw_center_count", cfg.geometry.yaw.center_count);
cfg.geometry.yaw.min_deg = getDouble(kv, "Motor.yaw_min_deg", cfg.geometry.yaw.min_deg); cfg.geometry.yaw.deg_at_center = getDouble(kv, "Motor.yaw_heading_at_center", cfg.geometry.yaw.deg_at_center);
cfg.geometry.yaw.max_deg = getDouble(kv, "Motor.yaw_max_deg", cfg.geometry.yaw.max_deg);
cfg.geometry.pitch.counts_per_deg = getDouble(kv, "Motor.pitch_counts_per_deg", cfg.geometry.pitch.counts_per_deg); cfg.geometry.pitch.counts_per_deg = getDouble(kv, "Motor.pitch_counts_per_deg", cfg.geometry.pitch.counts_per_deg);
cfg.geometry.pitch.zero_count = getLong(kv, "Motor.pitch_zero_count", cfg.geometry.pitch.zero_count); cfg.geometry.pitch.center_count = getLong(kv, "Motor.pitch_center_count", cfg.geometry.pitch.center_count);
cfg.geometry.pitch.min_deg = getDouble(kv, "Motor.pitch_min_deg", cfg.geometry.pitch.min_deg); cfg.geometry.pitch.deg_at_center = getDouble(kv, "Motor.pitch_angle_at_center", cfg.geometry.pitch.deg_at_center);
cfg.geometry.pitch.max_deg = getDouble(kv, "Motor.pitch_max_deg", cfg.geometry.pitch.max_deg); cfg.yaw_soft_margin_counts = getLong(kv, "Motor.yaw_soft_margin_counts", cfg.yaw_soft_margin_counts);
{ cfg.pitch_soft_margin_counts = getLong(kv, "Motor.pitch_soft_margin_counts", cfg.pitch_soft_margin_counts);
std::string hz = get(kv, "Motor.yaw_home_zero", "off");
for (auto& ch : hz) ch = static_cast<char>(std::tolower((unsigned char)ch));
cfg.yaw_home_zero = hz == "high" ? YawHomeZero::High
: hz == "low" ? YawHomeZero::Low
: YawHomeZero::Off;
}
cfg.enc_error_warn_counts = getLong(kv, "Motor.enc_error_warn_counts", cfg.enc_error_warn_counts = getLong(kv, "Motor.enc_error_warn_counts",
cfg.enc_error_warn_counts); cfg.enc_error_warn_counts);
@ -395,6 +393,40 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
if (cfg.serial.baud == 0) if (cfg.serial.baud == 0)
throw std::runtime_error("Serial.baud must be > 0"); throw std::runtime_error("Serial.baud must be > 0");
// A zero scale makes every degree map to the centre and every count read back
// as the centre - the axis would look calibrated and point nowhere.
if (std::abs(cfg.geometry.yaw.counts_per_deg) < 1e-9)
throw std::runtime_error("Motor.yaw_counts_per_deg must be non-zero");
if (std::abs(cfg.geometry.pitch.counts_per_deg) < 1e-9)
throw std::runtime_error("Motor.pitch_counts_per_deg must be non-zero");
// The centre count exists so the whole working range stays positive; a
// non-positive one defeats the point and can drive the firmware negative.
if (cfg.geometry.yaw.center_count <= 0)
throw std::runtime_error("Motor.yaw_center_count must be > 0");
if (cfg.geometry.pitch.center_count <= 0)
throw std::runtime_error("Motor.pitch_center_count must be > 0");
if (cfg.yaw_soft_margin_counts < 0)
throw std::runtime_error("Motor.yaw_soft_margin_counts must be >= 0");
if (cfg.pitch_soft_margin_counts < 0)
throw std::runtime_error("Motor.pitch_soft_margin_counts must be >= 0");
// Keys from the pre-centre-anchoring layout. Silently ignoring them would
// leave an operator convinced they had configured a heading that is in fact
// coming from somewhere else entirely - the exact failure this layout removes.
static const char* kRemovedMotorKeys[] = {
"Motor.yaw_zero_count", "Motor.pitch_zero_count", "Motor.yaw_min_deg",
"Motor.yaw_max_deg", "Motor.pitch_min_deg", "Motor.pitch_max_deg",
"Motor.yaw_home_zero",
};
std::string stale;
for (const char* k : kRemovedMotorKeys)
if (kv.count(k)) stale += (stale.empty() ? "" : ", ") + std::string(k + 6);
if (!stale.empty())
LOG_WARN << "[Motor] ignoring keys removed in the centre-anchored layout: " << stale
<< " -- travel limits are now derived from homing, and the heading "
"origin comes from yaw_heading_at_center (set it with "
"'gimbal heading setup')";
return cfg; return cfg;
} }
@ -470,7 +502,8 @@ std::string updateIniSectionKeys(const std::string& contents, const std::string&
return out.str(); return out.str();
} }
bool saveMotorCalibration(const std::string& path, const Geometry& geo) { bool saveMotorGeometry(const std::string& path, const Geometry& geo, long yaw_margin,
long pitch_margin) {
if (path.empty()) return false; if (path.empty()) return false;
std::ifstream f(path); std::ifstream f(path);
if (!f) return false; if (!f) return false;
@ -483,26 +516,43 @@ bool saveMotorCalibration(const std::string& path, const Geometry& geo) {
std::snprintf(b, sizeof(b), "%.10g", v); std::snprintf(b, sizeof(b), "%.10g", v);
return std::string(b); return std::string(b);
}; };
// Persist the full per-axis map, not just the fitted counts_per_deg/zero_count: // The whole [Motor] block, so it stays complete and self-consistent rather
// min_deg/max_deg are part of the geometry and consumers (e.g. travel-range // than depending on which lines happened to be there already. min_deg/max_deg
// estimates) rely on them, so writing them keeps the [Motor] block complete and // are deliberately absent: they are derived from homing, not configured.
// self-consistent rather than depending on pre-existing lines.
const std::vector<std::pair<std::string, std::string>> kv = { const std::vector<std::pair<std::string, std::string>> kv = {
{"yaw_counts_per_deg", num(geo.yaw.counts_per_deg)}, {"yaw_counts_per_deg", num(geo.yaw.counts_per_deg)},
{"yaw_zero_count", std::to_string(geo.yaw.zero_count)}, {"yaw_center_count", std::to_string(geo.yaw.center_count)},
{"yaw_min_deg", num(geo.yaw.min_deg)}, {"yaw_heading_at_center", num(geo.yaw.deg_at_center)},
{"yaw_max_deg", num(geo.yaw.max_deg)}, {"yaw_soft_margin_counts", std::to_string(yaw_margin)},
{"pitch_counts_per_deg", num(geo.pitch.counts_per_deg)}, {"pitch_counts_per_deg", num(geo.pitch.counts_per_deg)},
{"pitch_zero_count", std::to_string(geo.pitch.zero_count)}, {"pitch_center_count", std::to_string(geo.pitch.center_count)},
{"pitch_min_deg", num(geo.pitch.min_deg)}, {"pitch_angle_at_center", num(geo.pitch.deg_at_center)},
{"pitch_max_deg", num(geo.pitch.max_deg)}, {"pitch_soft_margin_counts", std::to_string(pitch_margin)},
}; };
const std::string updated = updateIniSectionKeys(buf.str(), "Motor", kv); const std::string updated = updateIniSectionKeys(buf.str(), "Motor", kv);
std::ofstream out(path, std::ios::trunc); // Write a sibling temp file and rename over the original: a truncating write
if (!out) return false; // that dies partway leaves config.ini destroyed, and three commands rewrite
out << updated; // this file while the program is running.
return static_cast<bool>(out); std::error_code ec;
const fs::path target(path);
const fs::path tmp = target.parent_path() / (target.filename().string() + ".tmp");
{
std::ofstream out(tmp, std::ios::trunc | std::ios::binary);
if (!out) return false;
out << updated;
out.flush();
if (!out) {
fs::remove(tmp, ec);
return false;
}
}
fs::rename(tmp, target, ec);
if (ec) {
fs::remove(tmp, ec);
return false;
}
return true;
} }
} // namespace fgc } // namespace fgc

View File

@ -155,11 +155,11 @@ DumpData parseDump(const std::string& block) {
ax.state_name = stateName(static_cast<int>(asInt(get(m, "state", "-1")))); ax.state_name = stateName(static_cast<int>(asInt(get(m, "state", "-1"))));
ax.hsub = static_cast<int>(asInt(get(m, "hsub", "0"))); ax.hsub = static_cast<int>(asInt(get(m, "hsub", "0")));
ax.enabled = asInt(get(m, "enabled", "0")) != 0; ax.enabled = asInt(get(m, "enabled", "0")) != 0;
ax.eeprom_restored = asInt(get(m, "eeprom_restored", "0")) != 0;
ax.has_encoder = asInt(get(m, "has_encoder", "0")) != 0; ax.has_encoder = asInt(get(m, "has_encoder", "0")) != 0;
ax.lim_neg = asInt(get(m, "lim_neg", "0")); ax.lim_neg = asInt(get(m, "lim_neg", "0"));
ax.lim_pos = asInt(get(m, "lim_pos", "0")); ax.lim_pos = asInt(get(m, "lim_pos", "0"));
ax.soft_margin = asInt(get(m, "soft_margin", "0")); ax.soft_margin = asInt(get(m, "soft_margin", "0"));
ax.center_count = asInt(get(m, "center", "0"));
ax.hold_target = asInt(get(m, "hold_target", "0")); ax.hold_target = asInt(get(m, "hold_target", "0"));
ax.speed = asInt(get(m, "speed", "0")); ax.speed = asInt(get(m, "speed", "0"));
ax.hold_corrections = asInt(get(m, "hold_corr", "0")); ax.hold_corrections = asInt(get(m, "hold_corr", "0"));
@ -198,7 +198,6 @@ std::vector<std::string> formatDump(const DumpData& d) {
<< " enabled=" << (ax.enabled ? 1 : 0) << " enabled=" << (ax.enabled ? 1 : 0)
<< " limits=[" << ax.lim_neg << ".." << ax.lim_pos << "]" << " limits=[" << ax.lim_neg << ".." << ax.lim_pos << "]"
<< " hold_target=" << ax.hold_target << " hold_target=" << ax.hold_target
<< " eeprom_restored=" << (ax.eeprom_restored ? 1 : 0)
<< " has_encoder=" << (ax.has_encoder ? 1 : 0); << " has_encoder=" << (ax.has_encoder ? 1 : 0);
out.push_back(os.str()); out.push_back(os.str());
} }

View File

@ -5,14 +5,45 @@
namespace fgc { namespace fgc {
double wrap360(double deg) {
double w = std::fmod(deg, 360.0);
if (w < 0.0) w += 360.0;
// fmod of a tiny negative can round to exactly 360.0 after the += above.
if (w >= 360.0) w = 0.0;
return w;
}
double wrap180(double deg) {
double w = wrap360(deg);
return w >= 180.0 ? w - 360.0 : w;
}
long AxisMap::toCounts(double deg) const { long AxisMap::toCounts(double deg) const {
double clamped = std::clamp(deg, min_deg, max_deg); const double lo = std::min(min_deg, max_deg);
return std::lround(static_cast<double>(zero_count) + clamped * counts_per_deg); const double hi = std::max(min_deg, max_deg);
const double clamped = std::clamp(deg, lo, hi);
return std::lround(static_cast<double>(center_count) +
(clamped - deg_at_center) * counts_per_deg);
} }
double AxisMap::toDeg(long counts) const { double AxisMap::toDeg(long counts) const {
if (std::abs(counts_per_deg) < 1e-9) return 0.0; if (std::abs(counts_per_deg) < 1e-9) return deg_at_center;
return (static_cast<double>(counts) - static_cast<double>(zero_count)) / counts_per_deg; return deg_at_center +
(static_cast<double>(counts) - static_cast<double>(center_count)) / counts_per_deg;
}
double AxisMap::headingDeg(long counts) const { return wrap360(toDeg(counts)); }
std::optional<long> AxisMap::headingToCounts(double heading_deg) const {
if (std::abs(counts_per_deg) < 1e-9) return std::nullopt;
// The travel is under a full turn, so wrapping the offset into [-180, 180)
// yields the one candidate that could be reachable - there is never a second
// solution to choose between.
const double deg = deg_at_center + wrap180(heading_deg - deg_at_center);
const double lo = std::min(min_deg, max_deg);
const double hi = std::max(min_deg, max_deg);
if (deg < lo || deg > hi) return std::nullopt;
return toCounts(deg);
} }
} // namespace fgc } // namespace fgc

99
src/core/HeadingFit.cpp Normal file
View File

@ -0,0 +1,99 @@
#include "fgc/HeadingFit.h"
#include "fgc/Calibration.h"
#include "fgc/Geometry.h"
#include <algorithm>
#include <cmath>
#include <cstddef>
#include <stdexcept>
#include <string>
namespace fgc {
HeadingFitResult fitHeading(const std::vector<HeadingSighting>& sightings, long center_count) {
HeadingFitResult r;
r.n = static_cast<int>(sightings.size());
if (sightings.size() < 2) {
r.error = "need at least two sightings (aim at a second landmark)";
return r;
}
// Sort by encoder position so the bearings can be unwrapped along the axis.
// Sighting order is whatever the operator found convenient; the axis is the
// only thing that varies monotonically.
std::vector<HeadingSighting> pts = sightings;
std::sort(pts.begin(), pts.end(),
[](const HeadingSighting& a, const HeadingSighting& b) { return a.counts < b.counts; });
// Unwrap so a set spanning north fits as a straight line instead of folding
// at 360 (e.g. 350 and 10 become 350 and 370, not 350 and 10).
std::vector<std::pair<double, double>> xy; // x = bearing, y = counts
xy.reserve(pts.size());
double prev = wrap180(pts.front().heading_deg);
for (std::size_t i = 0; i < pts.size(); ++i) {
const double deg = (i == 0) ? prev : unwrapNear(prev, pts[i].heading_deg);
prev = deg;
xy.emplace_back(deg, static_cast<double>(pts[i].counts));
}
auto [lo, hi] = std::minmax_element(
xy.begin(), xy.end(), [](const auto& a, const auto& b) { return a.first < b.first; });
r.span_deg = hi->first - lo->first;
if (r.span_deg < kMinHeadingBaselineDeg) {
r.error = "sightings are only " + std::to_string(static_cast<int>(r.span_deg)) +
" deg apart; use landmarks at least " +
std::to_string(static_cast<int>(kMinHeadingBaselineDeg)) +
" deg apart (further is better)";
return r;
}
const LinearFit fit = linearFit(xy);
if (!fit.ok) {
r.error = "degenerate sightings (all at the same bearing)";
return r;
}
// Distinct bearings at the same encoder position: the axis did not move
// between sightings, so one of the bearings is wrong.
if (std::abs(fit.slope) < 1e-6) {
r.error = "sightings give no encoder travel between bearings; did the gimbal move?";
return r;
}
r.counts_per_deg = fit.slope;
// The map is anchored on the homed centre, so what gets stored is the bearing
// that centre points at - solve the fitted line for it.
r.deg_at_center = wrap360((static_cast<double>(center_count) - fit.intercept) / fit.slope);
r.r2 = fit.r2;
r.ok = true;
return r;
}
bool parseHeadingSightings(const std::vector<std::string>& tokens,
std::vector<HeadingSighting>& out, std::string& error) {
out.clear();
for (const std::string& t : tokens) {
const auto colon = t.find(':');
if (colon == std::string::npos) {
error = "expected <counts>:<deg>, got '" + t + "'";
return false;
}
HeadingSighting s;
try {
std::size_t used = 0;
s.counts = std::stol(t.substr(0, colon), &used);
if (used != colon) throw std::invalid_argument("trailing");
const std::string deg = t.substr(colon + 1);
s.heading_deg = std::stod(deg, &used);
if (used != deg.size()) throw std::invalid_argument("trailing");
} catch (const std::exception&) {
error = "bad sighting '" + t + "' (expected <counts>:<deg>)";
return false;
}
out.push_back(s);
}
return true;
}
} // namespace fgc

View File

@ -24,25 +24,54 @@ std::string verbOf(const std::string& syntax) {
const std::vector<HelpSection>& helpCatalog() { const std::vector<HelpSection>& helpCatalog() {
// clang-format off // clang-format off
static const std::vector<HelpSection> catalog = { static const std::vector<HelpSection> catalog = {
{"Positioning", "Aim the gimbal. 'move' is degrees; 'steps' is raw encoder counts.", { {"Positioning",
"Aim the gimbal. Yaw is a TRUE BEARING (0 = north); pitch is elevation (0 = level).", {
{"gimbal move <yaw>,<pitch>", {"gimbal move <yaw>,<pitch>",
"Point the gimbal at an absolute heading/elevation in DEGREES.", { "Point at an absolute bearing/elevation in DEGREES.", {
"Converts to encoder counts with the operator calibration and moves both", "Converts to encoder counts with the [Motor] calibration and moves both",
"axes together, soft-clamped to the travel limits.", "axes together. Yaw travel is under a full turn, so bearings in the",
"Example: gimbal move 30,-10 (yaw 30 deg, pitch -10 deg)"}}, "unreachable arc are refused rather than clamped to the nearest limit.",
"Manual mode only - refused while a scan is running.",
"Example: gimbal move 30,-10 (bearing 30 deg, 10 deg below level)"}},
{"gimbal steps <yaw>,<pitch>", {"gimbal steps <yaw>,<pitch>",
"Move both axes to absolute encoder COUNTS (no degree conversion).", { "Move both axes to absolute encoder COUNTS (no degree conversion).", {
"Example: gimbal steps 100000,250000"}}, "Example: gimbal steps 100000,250000"}},
{"gimbal nudge <yaw|pitch> <+/-pct>", {"gimbal nudge <yaw|pitch> <+/-pct>",
"Relative step move by a percent of the axis travel.", { "Relative move by a percent of the homed axis travel.", {
"The arrow keys do this when no overlay is open; inside an overlay they", "The arrow keys do this when no overlay is open; inside an overlay they",
"scroll instead.", "scroll instead. Manual mode only.",
"Example: gimbal nudge yaw -5"}}, "Example: gimbal nudge yaw -5"}},
{"gimbal jog <yaw|pitch> <+/-counts>",
"Relative move in raw encoder counts.", {
"Finer than 'nudge' (a percent of travel) and relative unlike 'steps'.",
"Below ~13 counts the encoder cannot resolve the difference, so a smaller",
"step moves the motor to a position it cannot reproduce.",
"Example: gimbal jog yaw -25"}},
{"gimbal heading setup",
"Tie encoder counts to true north by sighting known landmarks (TUI).", {
"Opens a guided pane (dashboard key 'H'): aim with the arrow keys, Tab",
"cycles coarse/fine/very-fine steps, Space records an image, Enter enters",
"that landmark's true bearing. Two landmarks minimum - the further apart",
"the better. Needs no IMU, which is the point: IMU yaw drifts and not",
"every tower has one."}},
{"gimbal heading fit <counts>:<deg> <counts>:<deg> [...]",
"Fit the yaw map directly from known (encoder position, bearing) pairs.", {
"What the 'H' wizard emits, usable by hand on a headless tower. Two pairs",
"fit exactly; more are least-squares fitted and report R^2. Pairs closer",
"than 10 deg apart are refused - the fit would look perfect and be wrong.",
"Example: gimbal heading fit 480000:45.0 520000:115.0"}},
{"gimbal heading offset <deg>",
"Shift the whole yaw map by a known amount and save it.", {
"For when the tower is found to be off by a fixed bearing and re-sighting",
"landmarks is not worth it.",
"Example: gimbal heading offset -2.5"}},
{"gimbal home [y|p]", {"gimbal home [y|p]",
"Run the endstop-finding home sequence (both axes, or one).", { "Run the endstop-finding home sequence (both axes, or one).", {
"Example: gimbal home / gimbal home y"}}, "Example: gimbal home / gimbal home y"}},
{"gimbal stop [y|p|all]", {"gimbal stop [y|p|all]",
"Stop motion immediately (also cancels a running calibration).", {}}, "Stop motion immediately.", {
"To cancel a running procedure use the bare 'stop' (or 'cancel', or Esc):",
"the whole 'gimbal' family is ignored while one is in progress."}},
{"gimbal speed <y|p> <vel>", {"gimbal speed <y|p> <vel>",
"Set the max slew speed (counts/s) for an axis.", {}}, "Set the max slew speed (counts/s) for an axis.", {}},
{"gimbal reset [y|p] / gimbal enable|disable <y|p> / gimbal setpos <y|p> <v>", {"gimbal reset [y|p] / gimbal enable|disable <y|p> / gimbal setpos <y|p> <v>",
@ -59,9 +88,9 @@ const std::vector<HelpSection>& helpCatalog() {
"list. Re-reads first, so it never reports a stale profile."}}, "list. Re-reads first, so it never reports a stale profile."}},
{"imu profile [<type|label>]", {"imu profile [<type|label>]",
"Select the XKF filter profile (persists on the device).", { "Select the XKF filter profile (persists on the device).", {
"The profile decides whether heading uses the magnetometer: 'gimbal calib'", "The profile decides whether heading uses the magnetometer. Nothing here",
"leaves the device on a no-mag one, where yaw is drift-corrected but not", "affects the gimbal's own heading: that comes from the encoder, tied to",
"compass-referenced. Typical MTi profiles are General, High_mag_dep,", "north by 'gimbal heading setup'. Typical MTi profiles are General, High_mag_dep,",
"Dynamic and VRU_general; run with no argument for this device's real list.", "Dynamic and VRU_general; run with no argument for this device's real list.",
"Example: imu profile General / imu profile 39"}}, "Example: imu profile General / imu profile 39"}},
{"imu zero", {"imu zero",
@ -85,11 +114,13 @@ const std::vector<HelpSection>& helpCatalog() {
"token names a config profile. Each run is compared to a pinned baseline", "token names a config profile. Each run is compared to a pinned baseline",
"and the previous run. Esc cancels.", "and the previous run. Esc cancels.",
"Example: test gimbal homing / test list / test baseline"}}, "Example: test gimbal homing / test list / test baseline"}},
{"gimbal calib", {"gimbal pitch setup",
"Calibrate steps<->degrees using the IMU (auto-homes; ~minutes).", { "Calibrate PITCH steps<->degrees against gravity, using the IMU.", {
"Fits pitch then yaw, applies the result to the session and writes", "Auto-homes if needed, sweeps pitch dwelling at each step, fits, then",
"logs/calib_*.log. Leaves the IMU on a no-mag XKF profile. 'gimbal stop'", "parks level. Writes logs/pitch_setup_*.log and offers to save. Takes",
"cancels."}}, "minutes; 'stop' or Esc cancels. Requires the IMU - level is defined by",
"gravity and nothing else on the tower can measure it. Yaw is NOT touched:",
"use 'gimbal heading setup'."}},
{"gimbal status", {"gimbal status",
"Ask the firmware to emit one telemetry (ST) line now.", {}}, "Ask the firmware to emit one telemetry (ST) line now.", {}},
{"gimbal raw \"<command>\"", {"gimbal raw \"<command>\"",
@ -124,6 +155,15 @@ const std::vector<HelpSection>& helpCatalog() {
"An overlay takes the full terminal height. Esc closes it; with none open,", "An overlay takes the full terminal height. Esc closes it; with none open,",
"Esc cancels a running procedure. 'm' shows broker state, per-topic traffic", "Esc cancels a running procedure. 'm' shows broker state, per-topic traffic",
"and the recent MQTT messages, including any dropped while offline."}}, "and the recent MQTT messages, including any dropped while offline."}},
{"Space",
"Take one image now and publish its CamEvent (manual mode only).", {
"Goes through the same settle-and-trigger path a scan waypoint uses, so",
"the file and the CamEvent are indistinguishable from a scan frame.",
"Refused while a scan is running."}},
{"H",
"Open the heading-setup wizard (see 'gimbal heading setup').", {
"Manual mode only. Blocks a scan from starting until it is finished or",
"cancelled with Esc."}},
{"Up/Down, j/k", {"Up/Down, j/k",
"Scroll the open overlay one line (Help: change section).", { "Scroll the open overlay one line (Help: change section).", {
"The scrollbar on the right shows how much is off-screen."}}, "The scrollbar on the right shows how much is off-screen."}},

View File

@ -1,4 +1,4 @@
#include "fgc/CalibrationRoutine.h" #include "fgc/PitchSetup.h"
#include "fgc/Calibration.h" #include "fgc/Calibration.h"
#include "fgc/DumpParser.h" #include "fgc/DumpParser.h"
@ -23,16 +23,16 @@ using clock = std::chrono::steady_clock;
void msleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } void msleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); }
} // namespace } // namespace
CalibrationRoutine::CalibrationRoutine(IMotorController& motor, IImuSource& imu, Geometry initial, PitchSetupRoutine::PitchSetupRoutine(IMotorController& motor, IImuSource& imu, Geometry initial,
CalibParams params) CalibParams params)
: motor_(motor), imu_(imu), initial_(std::move(initial)), params_(params) {} : motor_(motor), imu_(imu), initial_(std::move(initial)), params_(params) {}
CalibrationRoutine::~CalibrationRoutine() { PitchSetupRoutine::~PitchSetupRoutine() {
cancel(); cancel();
if (thread_.joinable()) thread_.join(); if (thread_.joinable()) thread_.join();
} }
bool CalibrationRoutine::start() { bool PitchSetupRoutine::start() {
if (running_.exchange(true)) { if (running_.exchange(true)) {
LOG_WARN << "calibration already running"; LOG_WARN << "calibration already running";
return false; return false;
@ -43,26 +43,26 @@ bool CalibrationRoutine::start() {
return true; return true;
} }
void CalibrationRoutine::cancel() { cancel_ = true; } void PitchSetupRoutine::cancel() { cancel_ = true; }
std::optional<Geometry> CalibrationRoutine::takeResult() { std::optional<Geometry> PitchSetupRoutine::takeResult() {
std::lock_guard<std::mutex> lk(result_mutex_); std::lock_guard<std::mutex> lk(result_mutex_);
std::optional<Geometry> r; std::optional<Geometry> r;
r.swap(result_); r.swap(result_);
return r; return r;
} }
CalibProgress CalibrationRoutine::progress() const { CalibProgress PitchSetupRoutine::progress() const {
std::lock_guard<std::mutex> lk(progress_mutex_); std::lock_guard<std::mutex> lk(progress_mutex_);
return progress_; return progress_;
} }
CalibReport CalibrationRoutine::report() const { CalibReport PitchSetupRoutine::report() const {
std::lock_guard<std::mutex> lk(result_mutex_); std::lock_guard<std::mutex> lk(result_mutex_);
return report_; return report_;
} }
void CalibrationRoutine::setProgress(char axis, int step, int total, const char* phase) { void PitchSetupRoutine::setProgress(char axis, int step, int total, const char* phase) {
std::lock_guard<std::mutex> lk(progress_mutex_); std::lock_guard<std::mutex> lk(progress_mutex_);
progress_.running = true; progress_.running = true;
progress_.axis = axis; progress_.axis = axis;
@ -107,25 +107,19 @@ bool waitHomed(IMotorController& motor, int timeout_ms, const std::atomic<bool>&
return false; return false;
} }
// Dwell `dwell_ms` sampling the IMU; return the (circular for yaw) mean of the // Dwell `dwell_ms` sampling the IMU; return the mean measured pitch in degrees.
// requested Euler angle in degrees. // A plain arithmetic mean is right here: pitch is a gravity-referenced elevation
double dwellAndMeasure(IImuSource& imu, char axis, int dwell_ms, int sample_ms, // over a ~40 deg range, so it cannot wrap the way a heading can.
double dwellAndMeasure(IImuSource& imu, int dwell_ms, int sample_ms,
const std::atomic<bool>& cancel) { const std::atomic<bool>& cancel) {
std::vector<double> vals; std::vector<double> vals;
const auto end = clock::now() + std::chrono::milliseconds(dwell_ms); const auto end = clock::now() + std::chrono::milliseconds(dwell_ms);
while (clock::now() < end) { while (clock::now() < end) {
if (cancel) break; if (cancel) break;
if (auto s = imu.sample()) if (auto s = imu.sample()) vals.push_back(s->pitch_deg);
vals.push_back(axis == 'Y' ? s->yaw_deg : s->pitch_deg);
msleep(sample_ms); msleep(sample_ms);
} }
if (vals.empty()) return 0.0; if (vals.empty()) return 0.0;
if (axis == 'Y') {
// Robust to a wrap within the dwell, and already in the device's own
// -180..180. The sweep's per-sample unwrapNear below is what keeps the
// fit continuous, so no range shift is needed - or wanted - here.
return circularMeanDeg(vals);
}
double m = 0; double m = 0;
for (double v : vals) m += v; for (double v : vals) m += v;
return m / vals.size(); return m / vals.size();
@ -133,9 +127,9 @@ double dwellAndMeasure(IImuSource& imu, char axis, int dwell_ms, int sample_ms,
} // namespace } // namespace
void CalibrationRoutine::run() { void PitchSetupRoutine::run() {
struct Done { struct Done {
CalibrationRoutine* self; PitchSetupRoutine* self;
~Done() { ~Done() {
self->running_ = false; self->running_ = false;
std::lock_guard<std::mutex> lk(self->progress_mutex_); std::lock_guard<std::mutex> lk(self->progress_mutex_);
@ -144,13 +138,13 @@ void CalibrationRoutine::run() {
} done{this}; } done{this};
const int N = params_.positions; const int N = params_.positions;
LOG_INFO << "=== gimbal calibration starting ==="; LOG_INFO << "=== gimbal pitch setup starting ===";
setProgress('?', 0, N, "starting"); setProgress('?', 0, N, "starting");
CalibReport report; CalibReport report;
// 1. Preconditions. // 1. Preconditions.
if (!imu_.connected()) { if (!imu_.connected()) {
LOG_WARN << "calibration aborted: IMU not connected"; LOG_WARN << "pitch setup aborted: IMU not connected (set [Features] enable_imu)";
return; return;
} }
@ -158,12 +152,12 @@ void CalibrationRoutine::run() {
MotorTelemetry t0 = motor_.telemetry(); MotorTelemetry t0 = motor_.telemetry();
bool homed = t0.yaw.ready() && (!t0.pitch_present || t0.pitch.ready()); bool homed = t0.yaw.ready() && (!t0.pitch_present || t0.pitch.ready());
if (!homed) { if (!homed) {
LOG_INFO << "calibration: gimbal not homed; homing first..."; LOG_INFO << "pitch setup: gimbal not homed; homing first...";
setProgress('?', 0, N, "homing"); setProgress('?', 0, N, "homing");
motor_.sendCommand("HOME"); motor_.sendCommand("HOME");
if (!waitHomed(motor_, params_.home_timeout_ms, cancel_)) { if (!waitHomed(motor_, params_.home_timeout_ms, cancel_)) {
if (cancel_) { LOG_WARN << "calibration cancelled"; return; } if (cancel_) { LOG_WARN << "pitch setup cancelled"; return; }
LOG_WARN << "calibration aborted: homing did not reach READY"; LOG_WARN << "pitch setup aborted: homing did not reach READY";
return; return;
} }
t0 = motor_.telemetry(); t0 = motor_.telemetry();
@ -171,7 +165,7 @@ void CalibrationRoutine::run() {
const bool pitch_present = t0.pitch_present; const bool pitch_present = t0.pitch_present;
// Fetch soft limits from a fresh dump. // Fetch soft limits from a fresh dump.
LOG_INFO << "calibration: requesting soft limits (dump)..."; LOG_INFO << "pitch setup: requesting soft limits (dump)...";
motor_.sendCommand("DUMP"); motor_.sendCommand("DUMP");
DumpData dump; DumpData dump;
for (int i = 0; i < 50 && !cancel_; ++i) { // up to ~5 s for (int i = 0; i < 50 && !cancel_; ++i) { // up to ~5 s
@ -180,13 +174,13 @@ void CalibrationRoutine::run() {
msleep(100); msleep(100);
} }
if (!dump.valid) { if (!dump.valid) {
LOG_WARN << "calibration aborted: could not read soft limits"; LOG_WARN << "pitch setup aborted: could not read soft limits";
return; return;
} }
Geometry result = initial_; Geometry result = initial_;
std::ostringstream log; std::ostringstream log;
log << "gimbal calibration\n==================\n"; log << "gimbal pitch setup\n=================\n";
// Per-axis sweep span [a..b] (inset off the hard endstops) + a settle tolerance. // Per-axis sweep span [a..b] (inset off the hard endstops) + a settle tolerance.
auto axisSpan = [&](char axis, long& a, long& b, long& tol) -> bool { auto axisSpan = [&](char axis, long& a, long& b, long& tol) -> bool {
@ -206,16 +200,14 @@ void CalibrationRoutine::run() {
// then least-squares fit counts vs degrees and record the result. Returns // then least-squares fit counts vs degrees and record the result. Returns
// false on cancel; on a degenerate fit it records a failed axis and returns true. // false on cancel; on a degenerate fit it records a failed axis and returns true.
auto sweepAxis = [&](char axis, AxisMap& map, long a, long b, long tol) -> bool { auto sweepAxis = [&](char axis, AxisMap& map, long a, long b, long tol) -> bool {
LOG_INFO << "calibrating " << (axis == 'Y' ? "YAW" : "PITCH") LOG_INFO << "measuring PITCH over [" << a << ".." << b << "] counts in " << N
<< " over [" << a << ".." << b << "] counts in " << N << " steps"; << " steps";
log << "\n[" << axis << "] sweep=[" << a << ".." << b << "] tol=" << tol log << "\n[" << axis << "] sweep=[" << a << ".." << b << "] tol=" << tol
<< "\n target_counts, imu_deg\n"; << "\n target_counts, imu_deg\n";
std::vector<std::pair<double, double>> pts; // (deg, counts) std::vector<std::pair<double, double>> pts; // (deg, counts)
double prev_deg = 0.0;
bool have_prev = false;
for (int i = 0; i < N; ++i) { for (int i = 0; i < N; ++i) {
if (cancel_) { LOG_WARN << "calibration cancelled"; return false; } if (cancel_) { LOG_WARN << "pitch setup cancelled"; return false; }
long target = a + (b - a) * i / (N - 1); long target = a + (b - a) * i / (N - 1);
setProgress(axis, i + 1, N, "moving"); setProgress(axis, i + 1, N, "moving");
motor_.sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target)); motor_.sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target));
@ -225,12 +217,7 @@ void CalibrationRoutine::run() {
<< " did not settle near " << target << " (continuing)"; << " did not settle near " << target << " (continuing)";
} }
setProgress(axis, i + 1, N, "dwelling"); setProgress(axis, i + 1, N, "dwelling");
double deg = dwellAndMeasure(imu_, axis, params_.dwell_ms, params_.sample_ms, cancel_); const double deg = dwellAndMeasure(imu_, params_.dwell_ms, params_.sample_ms, cancel_);
// Unwrap against the previous sample so a sweep crossing 0/360 stays a
// continuous line for the fit (no ±360 jump).
if (have_prev) deg = unwrapNear(prev_deg, deg);
prev_deg = deg;
have_prev = true;
pts.emplace_back(deg, static_cast<double>(target)); pts.emplace_back(deg, static_cast<double>(target));
LOG_INFO << " " << (i + 1) << "/" << N << " counts=" << target LOG_INFO << " " << (i + 1) << "/" << N << " counts=" << target
<< " imu=" << deg << " deg"; << " imu=" << deg << " deg";
@ -246,89 +233,47 @@ void CalibrationRoutine::run() {
return true; return true;
} }
const double old_cpd = map.counts_per_deg; const double old_cpd = map.counts_per_deg;
const long old_zc = map.zero_count; const double old_dac = map.deg_at_center;
// The fit gives counts as a function of degrees; the map is anchored on
// the homed centre, so what we store is the angle that centre sits at.
map.counts_per_deg = fit.slope; map.counts_per_deg = fit.slope;
map.zero_count = static_cast<long>(std::lround(fit.intercept)); map.deg_at_center =
(static_cast<double>(map.center_count) - fit.intercept) / fit.slope;
LOG_INFO << " fit: counts_per_deg " << old_cpd << " -> " << fit.slope LOG_INFO << " fit: counts_per_deg " << old_cpd << " -> " << fit.slope
<< ", zero_count " << old_zc << " -> " << map.zero_count << ", deg_at_center " << old_dac << " -> " << map.deg_at_center
<< " (R^2=" << fit.r2 << ")"; << " (R^2=" << fit.r2 << ")";
log << " fit: counts_per_deg=" << fit.slope << " zero_count=" << map.zero_count log << " fit: counts_per_deg=" << fit.slope << " deg_at_center=" << map.deg_at_center
<< " R2=" << fit.r2 << " (was cpd=" << old_cpd << " zc=" << old_zc << ")\n"; << " R2=" << fit.r2 << " (was cpd=" << old_cpd << " dac=" << old_dac << ")\n";
report.axes.push_back({axis, true, fit.slope, map.zero_count, fit.r2, fit.n}); report.axes.push_back({axis, true, fit.slope, map.deg_at_center, fit.r2, fit.n});
return true; return true;
}; };
// Resolve the yaw sweep span up front — we move to its first position before // Sweep pitch across its inset travel, dwelling at each step to record the
// the pitch sweep so the whole pitch calibration happens at that fixed yaw. // gravity-referenced IMU pitch, and fit. Yaw is NOT touched: it is tied to
long ya = 0, yb = 0, ytol = 0; // north by `gimbal heading setup` against real landmarks, which needs no IMU
if (!axisSpan('Y', ya, yb, ytol)) { // and does not inherit the MTi's heading drift.
LOG_WARN << "calibration aborted: no yaw soft limits in dump"; if (!pitch_present) {
LOG_WARN << "pitch setup aborted: this rig reports no pitch axis";
return; return;
} }
long pa = 0, pb = 0, ptol = 0;
// 3. Move yaw to its first sweep position and hold it there. if (!axisSpan('P', pa, pb, ptol)) {
LOG_INFO << "calibration: moving yaw to first position (counts=" << ya << ")"; LOG_WARN << "pitch setup aborted: no pitch soft limits in dump";
setProgress('Y', 1, N, "moving"); return;
motor_.sendCommand(std::string("MOVE Y ") + std::to_string(ya));
waitSettle(motor_, 'Y', ya, ytol, params_.settle_timeout_ms, cancel_);
if (cancel_) { LOG_WARN << "calibration cancelled"; return; }
// 4. Calibrate PITCH at this yaw position, then 5. move pitch to 0 deg.
if (pitch_present) {
long pa = 0, pb = 0, ptol = 0;
if (axisSpan('P', pa, pb, ptol)) {
if (!sweepAxis('P', result.pitch, pa, pb, ptol)) { return; } // cancelled
const long pzero = result.pitch.toCounts(0.0);
LOG_INFO << "calibration: moving pitch to 0 deg (counts=" << pzero << ")";
setProgress('P', N, N, "pitch->0");
motor_.sendCommand(std::string("MOVE P ") + std::to_string(pzero));
waitSettle(motor_, 'P', pzero, ptol, params_.settle_timeout_ms, cancel_);
} else {
LOG_WARN << "calibration: no pitch soft limits in dump; skipping pitch";
}
} }
if (cancel_) { LOG_WARN << "calibration cancelled"; return; } if (!sweepAxis('P', result.pitch, pa, pb, ptol)) return; // cancelled
// 6. Switch the IMU to a no-magnetometer XKF profile so its heading stops // Park level, using the map just fitted - a visible confirmation that it is
// chasing the (stepper-distorted) magnetic field. Selected from the device's // right, and a sane place to leave the axis.
// own available list; persists on the device. Best-effort — if none is const long plevel = result.pitch.toCounts(0.0);
// available we log and continue with the drift correction below. LOG_INFO << "pitch setup: moving to 0 deg / level (counts=" << plevel << ")";
if (auto cfg = imu_.config()) { setProgress('P', N, N, "pitch->level");
int nomag = pickNoMagProfile(cfg->available_profiles); motor_.sendCommand(std::string("MOVE P ") + std::to_string(plevel));
if (nomag >= 0 && (!cfg->has_scenario || cfg->scenario_type != nomag)) { waitSettle(motor_, 'P', plevel, ptol, params_.settle_timeout_ms, cancel_);
LOG_INFO << "calibration: switching IMU to no-mag XKF profile (type " << nomag << ")"; if (cancel_) { LOG_WARN << "pitch setup cancelled"; return; }
setProgress('Y', 0, N, "no-mag profile");
if (!imu_.setFilterProfile(nomag))
LOG_WARN << "calibration: IMU did not confirm the no-mag profile (continuing)";
} else if (nomag < 0) {
LOG_WARN << "calibration: no no-mag XKF profile available on this device (continuing)";
}
}
if (cancel_) { LOG_WARN << "calibration cancelled"; return; }
// 7. Hold still: correct yaw drift (no-rotation gyro-bias update) then reset std::string path = paths::writeLogFile(paths::timestampedLogName("pitch_setup"), log.str());
// the IMU heading so this pose is yaw 0 — done right before the yaw sweep so if (!path.empty()) LOG_INFO << "pitch setup log written: " << path;
// the drift accumulated during the long pitch sweep doesn't bias the fit.
LOG_INFO << "calibration: correcting yaw drift (no-rotation " << params_.norotation_s
<< " s, holding still)...";
setProgress('Y', 0, N, "yaw drift correction");
imu_.noRotation(params_.norotation_s);
for (int waited = 0; waited < params_.norotation_s * 1000 + 500 && !cancel_; waited += 100)
msleep(100);
if (cancel_) { LOG_WARN << "calibration cancelled"; return; }
LOG_INFO << "calibration: resetting IMU heading (this pose is now yaw 0)";
setProgress('Y', 0, N, "yaw heading reset");
imu_.headingReset();
msleep(params_.reset_settle_ms);
if (cancel_) { LOG_WARN << "calibration cancelled"; return; }
// 8. Calibrate YAW from this (now zero-heading) first position.
if (!sweepAxis('Y', result.yaw, ya, yb, ytol)) { return; } // cancelled
if (cancel_) { LOG_WARN << "calibration cancelled"; return; }
std::string path = paths::writeLogFile(paths::timestampedLogName("calib"), log.str());
if (!path.empty()) LOG_INFO << "calibration log written: " << path;
report.valid = true; report.valid = true;
report.ts_ms = nowMs(); report.ts_ms = nowMs();
@ -339,7 +284,7 @@ void CalibrationRoutine::run() {
result_ = result; result_ = result;
report_ = report; report_ = report;
} }
LOG_INFO << "=== gimbal calibration complete (applied to session) ==="; LOG_INFO << "=== gimbal pitch setup complete (applied to session) ===";
} }
} // namespace fgc } // namespace fgc

View File

@ -57,7 +57,7 @@ std::optional<AxisTelemetry> parseAxisSegment(const std::string& seg) {
case 'o': case 'O': a.overtemp = true; break; case 'o': case 'O': a.overtemp = true; break;
case 'L': a.endstop_l = true; break; case 'L': a.endstop_l = true; break;
case 'R': a.endstop_r = true; break; case 'R': a.endstop_r = true; break;
default: break; // 'e' (eeprom) and unknown letters ignored default: break; // unknown letters ignored
} }
} }
} }

View File

@ -10,10 +10,12 @@
#include <algorithm> #include <algorithm>
#include <array> #include <array>
#include <chrono> #include <chrono>
#include <cmath>
#include <cstdlib> #include <cstdlib>
#include <cstdio> #include <cstdio>
#include <map> #include <map>
#include <sstream> #include <sstream>
#include <stdexcept>
#include <vector> #include <vector>
#include <ftxui/component/component.hpp> #include <ftxui/component/component.hpp>
@ -152,7 +154,129 @@ Element keyHint(const std::string& key, const std::string& label) {
// Which overlay owns the body. Declared here so keyBar() can switch on it; the // Which overlay owns the body. Declared here so keyBar() can switch on it; the
// render loop keeps the authoritative value. // render loop keeps the authoritative value.
enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn }; enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn, Heading };
// ---- heading-setup wizard (Overlay::Heading) -------------------------------
//
// Ties encoder counts to true bearings by having the operator aim at landmarks
// they can identify on a map. Deliberately does NOT need an IMU: not every tower
// has one, and IMU yaw drifts too much to be a heading reference.
//
// The interaction lives here because arrow keys and typed input exist only in the
// TUI; the fit itself is `gimbal heading fit`, an ordinary command, so it stays
// testable and usable on a headless tower.
enum class JogPhase { Coarse, Fine, VeryFine };
// The finest step is 25 counts, not 1. ENC_CONST makes X_ENC advance in
// 12.8-microstep quanta, so a smaller step moves the motor but records a position
// the gimbal cannot reproduce - and 25 counts is already ~0.05 deg on yaw, half
// the rig's pointing uncertainty, so it is never the limiting factor.
constexpr long kVeryFineCounts = 25;
constexpr double kCoarseTravelPct = 5.0;
constexpr double kFineTravelPct = 0.5;
const char* jogPhaseName(JogPhase p) {
switch (p) {
case JogPhase::Coarse: return "coarse";
case JogPhase::Fine: return "fine";
case JogPhase::VeryFine: return "very fine";
}
return "?";
}
// Step size in counts for a phase, given that axis's homed endstop-to-endstop
// travel. Falls back to the very-fine step when travel is unknown (no dump yet),
// which is slow but never wild.
long jogStepCounts(JogPhase p, long travel_counts) {
if (p == JogPhase::VeryFine || travel_counts <= 0) return kVeryFineCounts;
const double pct = (p == JogPhase::Coarse) ? kCoarseTravelPct : kFineTravelPct;
long step = static_cast<long>(pct / 100.0 * static_cast<double>(travel_counts));
return step > kVeryFineCounts ? step : kVeryFineCounts;
}
struct Sighting {
long counts = 0;
double bearing = 0.0;
};
struct HeadingWizard {
JogPhase phase = JogPhase::Coarse;
bool entering = false; // typing a bearing for pending_counts
long pending_counts = 0;
std::vector<Sighting> sightings;
std::string note; // last action / error, shown in the pane
};
// Angular size of a step, when the yaw scale is known. Shown alongside the count
// so the operator can judge the phase without doing the arithmetic.
std::string jogStepLabel(JogPhase p, long travel_counts, double counts_per_deg) {
const long step = jogStepCounts(p, travel_counts);
std::string out = std::to_string(step) + " counts";
if (std::fabs(counts_per_deg) > 1e-9)
out += " (~" + num(std::fabs(step / counts_per_deg), 3) + " deg)";
return out;
}
Element headingWizardPanel(const UiSnapshot& s, const HeadingWizard& w, Component bearing_input) {
const AxisView& y = s.gimbal.yaw;
const long travel = y.has_limits ? std::labs(y.lim_pos - y.lim_neg) : 0;
Elements rows;
rows.push_back(text("Aim at a landmark whose true bearing you know, then record it.") | bold);
rows.push_back(text("Two landmarks minimum; the further apart, the better the fit.") | dim);
rows.push_back(separator());
// Live position, in both the count the fit will use and the bearing the
// current (possibly wrong) calibration believes.
rows.push_back(hbox({text("position ") | dim,
text(std::to_string(y.xenc) + " counts") | bold,
text(" currently reads " + num(y.deg, 2) + " deg") | dim}));
rows.push_back(hbox({text("step ") | dim,
text(jogPhaseName(w.phase)) | bold | color(Color::Cyan),
text(" " + jogStepLabel(w.phase, travel, y.counts_per_deg)) | dim}));
if (!y.has_limits)
rows.push_back(text(" no homed travel yet - steps fall back to the finest; "
"home or press d in the gimbal view") | color(Color::Yellow));
rows.push_back(separator());
if (w.sightings.empty()) {
rows.push_back(text("no sightings recorded yet") | dim);
} else {
rows.push_back(text("sightings") | bold);
for (size_t i = 0; i < w.sightings.size(); ++i)
rows.push_back(text(" " + std::to_string(i + 1) + ". " +
std::to_string(w.sightings[i].counts) + " counts -> " +
num(w.sightings[i].bearing, 2) + " deg true"));
}
rows.push_back(separator());
if (w.entering) {
rows.push_back(hbox({text("bearing of the landmark at " + std::to_string(w.pending_counts) +
" counts (deg true): "),
bearing_input->Render() | flex}));
rows.push_back(text("Enter to record, Esc to discard this sighting") | dim);
} else if (s.capture.snapshot_armed) {
rows.push_back(text("snapshot armed - waiting for the gimbal to settle") |
color(Color::Yellow));
} else if (s.capture.has_last) {
// The operator identifies the landmark from the image, which reaches them
// through the ground station; the path is here so they can find the file.
rows.push_back(hbox({text("last image ") | dim,
text(s.capture.config.output_dir + "/" + s.capture.last_label + "/" +
std::to_string(s.capture.last_ts_ms) + ".jxl")}));
}
if (!w.note.empty()) rows.push_back(text(w.note) | color(Color::Yellow));
rows.push_back(separator());
rows.push_back(text(w.sightings.size() >= 2
? "f to fit and apply"
: "record at least two sightings before fitting") |
(w.sightings.size() >= 2 ? color(Color::Green) : dim));
return vbox(std::move(rows)) | flex;
}
// The bottom bar, contextual: the overview advertises everyday operations and // The bottom bar, contextual: the overview advertises everyday operations and
// the overlay keys, and each overlay advertises what its own body responds to. // the overlay keys, and each overlay advertises what its own body responds to.
@ -163,7 +287,7 @@ enum class Overlay { None, Help, Gimbal, Sensors, Cameras, Conn };
// //
// flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider // flexbox (Wrap::Wrap by default) rather than hbox: the overview set is wider
// than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui"). // than an 80-column terminal, and hbox silently truncated it mid-word ("q Qui").
Element keyBar(Overlay overlay) { Element keyBar(Overlay overlay, bool manual) {
static const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93"; static const char* kArrowsV = "\xE2\x86\x91\xE2\x86\x93";
static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93"; static const char* kArrows4 = "\xE2\x86\x90\xE2\x86\x92\xE2\x86\x91\xE2\x86\x93";
@ -175,8 +299,14 @@ Element keyBar(Overlay overlay) {
e.push_back(keyHint("h", "Home")); e.push_back(keyHint("h", "Home"));
e.push_back(keyHint("r", "Refresh")); e.push_back(keyHint("r", "Refresh"));
e.push_back(keyHint("g i c m", "Views")); e.push_back(keyHint("g i c m", "Views"));
if (manual) e.push_back(keyHint("H", "Heading"));
e.push_back(keyHint("p", "Pause")); e.push_back(keyHint("p", "Pause"));
e.push_back(keyHint(kArrows4, "Nudge")); // Hand aiming is refused while a scan owns the axes, so stop
// advertising it rather than inviting a keypress that only warns.
if (manual) {
e.push_back(keyHint(kArrows4, "Nudge"));
e.push_back(keyHint("Spc", "Snap"));
}
e.push_back(keyHint("PgUp/Dn", "Log")); e.push_back(keyHint("PgUp/Dn", "Log"));
break; break;
case Overlay::Help: case Overlay::Help:
@ -201,6 +331,16 @@ Element keyBar(Overlay overlay) {
e.push_back(keyHint("PgUp/Dn", "Page")); e.push_back(keyHint("PgUp/Dn", "Page"));
e.push_back(keyHint("Home/End", "Ends")); e.push_back(keyHint("Home/End", "Ends"));
break; break;
case Overlay::Heading:
// Replaces the common set entirely: start/stop are refused during
// heading setup, so advertising them here would only mislead.
e.clear();
e.push_back(keyHint(kArrows4, "Aim"));
e.push_back(keyHint("Tab", "Step size"));
e.push_back(keyHint("Spc", "Snap"));
e.push_back(keyHint("Enter", "Record"));
e.push_back(keyHint("f", "Fit"));
break;
} }
if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close")); if (overlay != Overlay::None) e.push_back(keyHint("Esc", "Close"));
@ -750,8 +890,7 @@ Element axisDumpDetail(const DumpAxis& ax) {
hbox({text(std::string(1, ax.axis) + " ") | bold, hbox({text(std::string(1, ax.axis) + " ") | bold,
text(ax.state_name) | color(Color::Green) | bold, text(ax.state_name) | color(Color::Green) | bold,
text(" enabled=" + std::to_string(ax.enabled ? 1 : 0)) | dim, text(" enabled=" + std::to_string(ax.enabled ? 1 : 0)) | dim,
text(" enc=" + std::to_string(ax.has_encoder ? 1 : 0)) | dim, text(" enc=" + std::to_string(ax.has_encoder ? 1 : 0)) | dim}),
text(" eeprom=" + std::to_string(ax.eeprom_restored ? 1 : 0)) | dim}),
kvRow("limits", "[" + std::to_string(ax.lim_neg) + ".." + std::to_string(ax.lim_pos) + "]"), kvRow("limits", "[" + std::to_string(ax.lim_neg) + ".." + std::to_string(ax.lim_pos) + "]"),
kvRow("hold_tgt", std::to_string(ax.hold_target)), kvRow("hold_tgt", std::to_string(ax.hold_target)),
reg("GCONF"), reg("CHOPCONF"), reg("XACTUAL"), reg("X_ENC"), reg("GCONF"), reg("CHOPCONF"), reg("XACTUAL"), reg("X_ENC"),
@ -820,7 +959,7 @@ Element axisColumn(const std::string& title, const AxisView& live, const DumpDat
col.push_back(text("CALIBRATION") | bold | color(Color::Magenta)); col.push_back(text("CALIBRATION") | bold | color(Color::Magenta));
if (calib_has && cal.ok) { if (calib_has && cal.ok) {
col.push_back(kvRow("cnt/deg", fmt("%.3f", cal.counts_per_deg))); col.push_back(kvRow("cnt/deg", fmt("%.3f", cal.counts_per_deg)));
col.push_back(kvRow("zero", std::to_string(cal.zero_count))); col.push_back(kvRow("deg@ctr", fmt("%.3f", cal.deg_at_center)));
col.push_back(kvRow("R2", fmt("%.4f", cal.r2), cal.r2 >= 0.99 ? Color::Green : Color::Yellow)); col.push_back(kvRow("R2", fmt("%.4f", cal.r2), cal.r2 >= 0.99 ? Color::Green : Color::Yellow));
col.push_back(kvRow("points", std::to_string(cal.n))); col.push_back(kvRow("points", std::to_string(cal.n)));
} else { } else {
@ -1123,9 +1262,20 @@ void TuiUi::uiLoop() {
Overlay overlay = Overlay::None; // which takeover panel owns the main area Overlay overlay = Overlay::None; // which takeover panel owns the main area
int help_sel = 0; int help_sel = 0;
bool gimbal_dump_requested = false; // auto-pull a dump the first time bool gimbal_dump_requested = false; // auto-pull a dump the first time
bool calib_prompt = false; // a yes/no calib-save question is showing bool calib_prompt = false; // a yes/no calib-save question is showing
// Mirrored out of the snapshot each render: CatchEvent runs on the same
// thread but has no access to the renderer's local snapshot copy.
bool manual_mode = true;
bool exit_prompt = false; // 'q' asked; awaiting quit/background/cancel bool exit_prompt = false; // 'q' asked; awaiting quit/background/cancel
// Heading-setup wizard. Its own text field, so the command line keeps its
// buffer and history semantics untouched.
HeadingWizard wiz;
std::string bearing_buffer;
long wiz_yaw_counts = 0; // live yaw position, mirrored for CatchEvent
long wiz_yaw_travel = 0;
long wiz_pitch_travel = 0;
// Per-pane scroll positions. UI-thread-only view state (the renderer lambda // Per-pane scroll positions. UI-thread-only view state (the renderer lambda
// and CatchEvent below both run on this thread), so unlike screen_/paused_ // and CatchEvent below both run on this thread), so unlike screen_/paused_
// these need no synchronisation and stay locals rather than TuiUi members. // these need no synchronisation and stay locals rather than TuiUi members.
@ -1135,8 +1285,15 @@ void TuiUi::uiLoop() {
size_t log_dropped_at_freeze = 0; // buffer evictions when scrollback was entered size_t log_dropped_at_freeze = 0; // buffer evictions when scrollback was entered
auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel"); auto input = Input(&cmd_buffer, "type a command, Enter to run, Esc to cancel");
auto bearing_input = Input(&bearing_buffer, "e.g. 137.4");
// Only one text field is ever live, so a Tab container (rather than a
// Vertical) keeps focus unambiguous: index 0 = command line, 1 = the wizard's
// bearing field. Renderer's child is fixed at construction, hence the
// container rather than swapping the Input.
int focused_input = 0;
auto inputs = Container::Tab({input, bearing_input}, &focused_input);
auto renderer = Renderer(input, [&] { auto renderer = Renderer(inputs, [&] {
UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{}; UiSnapshot s = snapshot_ ? snapshot_() : UiSnapshot{};
size_t log_dropped_now = 0; size_t log_dropped_now = 0;
{ {
@ -1152,6 +1309,12 @@ void TuiUi::uiLoop() {
log_dropped_at_freeze = log_dropped_now; log_dropped_at_freeze = log_dropped_now;
} }
calib_prompt = !s.activity.prompt.empty(); calib_prompt = !s.activity.prompt.empty();
manual_mode = s.capture.manual;
wiz_yaw_counts = s.gimbal.yaw.xenc;
wiz_yaw_travel = s.gimbal.yaw.has_limits ? std::labs(s.gimbal.yaw.lim_pos - s.gimbal.yaw.lim_neg) : 0;
wiz_pitch_travel =
s.gimbal.pitch.has_limits ? std::labs(s.gimbal.pitch.lim_pos - s.gimbal.pitch.lim_neg) : 0;
focused_input = wiz.entering ? 1 : 0;
gimbal_overlay_open_.store(overlay == Overlay::Gimbal); // refreshLoop polls a dump while open gimbal_overlay_open_.store(overlay == Overlay::Gimbal); // refreshLoop polls a dump while open
std::string mode = s.header.live ? "LIVE" : "MOCK"; std::string mode = s.header.live ? "LIVE" : "MOCK";
@ -1183,7 +1346,7 @@ void TuiUi::uiLoop() {
Element bottom = command_mode Element bottom = command_mode
? hbox({text(" : ") | inverted, input->Render() | flex}) | border ? hbox({text(" : ") | inverted, input->Render() | flex}) | border
: exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX"))) : exit_prompt ? exitPrompt(backgroundAvailable(std::getenv("TMUX")))
: keyBar(overlay); : keyBar(overlay, s.capture.manual);
// An open overlay takes over the whole body (the dashboard panels are // An open overlay takes over the whole body (the dashboard panels are
// hidden) so it has the full height — otherwise tall content like the // hidden) so it has the full height — otherwise tall content like the
@ -1203,6 +1366,9 @@ void TuiUi::uiLoop() {
return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom}); return vbox({header, separator(), cameraDetailPanel(s.capture, cam_scroll) | flex, bottom});
case Overlay::Conn: case Overlay::Conn:
return vbox({header, separator(), connDetailPanel(s.conn, conn_scroll) | flex, bottom}); return vbox({header, separator(), connDetailPanel(s.conn, conn_scroll) | flex, bottom});
case Overlay::Heading:
return vbox({header, separator(),
headingWizardPanel(s, wiz, bearing_input) | flex, bottom});
default: { default: {
// Activity strip sits between the log and the key bar; shown only // Activity strip sits between the log and the key bar; shown only
// once a special op has run or is running, else it costs no space. // once a special op has run or is running, else it costs no space.
@ -1274,6 +1440,90 @@ void TuiUi::uiLoop() {
return true; // swallow everything else return true; // swallow everything else
} }
// The heading wizard owns the keyboard while it is up, like the exit
// prompt: a stray key must not reach the dashboard behind it and move the
// gimbal between a sighting and the bearing being typed for it.
if (overlay == Overlay::Heading) {
auto jog = [&](const char* axis, long travel, long sign) {
if (!sink_) return;
const long step = jogStepCounts(wiz.phase, travel) * sign;
sink_(std::string("gimbal jog ") + axis + " " + std::to_string(step));
};
if (wiz.entering) {
if (e == Event::Return) {
try {
size_t used = 0;
const double bearing = std::stod(bearing_buffer, &used);
if (used != bearing_buffer.size()) throw std::invalid_argument("trailing");
wiz.sightings.push_back({wiz.pending_counts, bearing});
wiz.note = "recorded " + num(bearing, 2) + " deg at " +
std::to_string(wiz.pending_counts) + " counts";
wiz.entering = false;
bearing_buffer.clear();
} catch (const std::exception&) {
wiz.note = "'" + bearing_buffer + "' is not a bearing in degrees";
}
return true;
}
if (e == Event::Escape) {
wiz.entering = false;
bearing_buffer.clear();
wiz.note = "sighting discarded";
return true;
}
return false; // let the Input edit the buffer
}
if (e == Event::ArrowLeft) { jog("yaw", wiz_yaw_travel, -1); return true; }
if (e == Event::ArrowRight) { jog("yaw", wiz_yaw_travel, +1); return true; }
// Pitch too: the landmark has to be in frame vertically before its
// bearing means anything.
if (e == Event::ArrowUp) { jog("pitch", wiz_pitch_travel, +1); return true; }
if (e == Event::ArrowDown) { jog("pitch", wiz_pitch_travel, -1); return true; }
if (e == Event::Tab) {
wiz.phase = wiz.phase == JogPhase::Coarse ? JogPhase::Fine
: wiz.phase == JogPhase::Fine ? JogPhase::VeryFine
: JogPhase::Coarse;
return true;
}
if (e == Event::Return) {
// Freeze the position now: the operator is about to look away to
// read a bearing off a map, and the field must belong to where the
// gimbal is pointing at this moment.
wiz.pending_counts = wiz_yaw_counts;
wiz.entering = true;
wiz.note.clear();
bearing_buffer.clear();
return true;
}
if (e == Event::Escape) {
overlay = Overlay::None;
wiz = {};
bearing_buffer.clear();
if (sink_) sink_("gimbal heading cancel");
return true;
}
if (e.is_character()) {
const std::string& k = e.character();
if (k == " ") { if (sink_) sink_("snapshot"); return true; }
if (k == "f" || k == "F") {
if (wiz.sightings.size() < 2) {
wiz.note = "need at least two sightings before fitting";
return true;
}
std::string cmd = "gimbal heading fit";
for (const auto& sg : wiz.sightings)
cmd += " " + std::to_string(sg.counts) + ":" + num(sg.bearing, 4);
if (sink_) sink_(cmd);
overlay = Overlay::None;
wiz = {};
return true;
}
}
return true; // swallow everything else
}
const int n = static_cast<int>(helpCatalog().size()); const int n = static_cast<int>(helpCatalog().size());
if (overlay == Overlay::Help) { // help pane navigation if (overlay == Overlay::Help) { // help pane navigation
// Selecting a different section replaces the body wholesale, so the // Selecting a different section replaces the body wholesale, so the
@ -1335,8 +1585,10 @@ void TuiUi::uiLoop() {
} }
} }
// Arrow keys nudge the gimbal in steps (only when no overlay is open): // Arrow keys nudge the gimbal in steps (only when no overlay is open):
// Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel. // Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel. Only in
if (overlay == Overlay::None && sink_) { // manual mode: during a scan the app refuses these anyway, and a silent
// no-op beats a warning per keypress from someone leaning on an arrow.
if (overlay == Overlay::None && manual_mode && sink_) {
if (e == Event::ArrowLeft) { sink_("gimbal nudge yaw -5"); return true; } if (e == Event::ArrowLeft) { sink_("gimbal nudge yaw -5"); return true; }
if (e == Event::ArrowRight) { sink_("gimbal nudge yaw 5"); return true; } if (e == Event::ArrowRight) { sink_("gimbal nudge yaw 5"); return true; }
if (e == Event::ArrowUp) { sink_("gimbal nudge pitch -10"); return true; } if (e == Event::ArrowUp) { sink_("gimbal nudge pitch -10"); return true; }
@ -1400,6 +1652,20 @@ void TuiUi::uiLoop() {
if (c == "h") { if (sink_) sink_("gimbal home"); return true; } if (c == "h") { if (sink_) sink_("gimbal home"); return true; }
if (c == "r") { if (sink_) sink_("refresh"); return true; } // IMU config + dump if (c == "r") { if (sink_) sink_("refresh"); return true; } // IMU config + dump
if (c == ":") { command_mode = true; return true; } if (c == ":") { command_mode = true; return true; }
// Heading setup: only from the dashboard, and only in manual mode - the
// app refuses to arm it during a scan, so opening the pane would strand
// the operator in a wizard that cannot record anything.
if (c == "H") {
if (!manual_mode) return true;
overlay = Overlay::Heading;
wiz = {};
bearing_buffer.clear();
if (sink_) sink_("gimbal heading setup");
return true;
}
// Spacebar: one image now, through the same pipeline a scan waypoint uses.
// The app refuses it during a scan; the bar just stops advertising it.
if (c == " ") { if (sink_) sink_("snapshot"); return true; }
return false; return false;
}); });

View File

@ -41,7 +41,8 @@ add_executable(fgc_tests
test_helptext.cpp test_helptext.cpp
test_diagparser.cpp test_diagparser.cpp
test_calibration.cpp test_calibration.cpp
test_calibroutine.cpp test_headingfit.cpp
test_pitchsetup.cpp
test_testreport.cpp test_testreport.cpp
test_hostmetrics.cpp test_hostmetrics.cpp
test_testrunner.cpp test_testrunner.cpp

View File

@ -236,16 +236,16 @@ TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact"
"[Motor]\n" "[Motor]\n"
"; calibrate me\n" "; calibrate me\n"
"yaw_counts_per_deg = 983.33\n" "yaw_counts_per_deg = 983.33\n"
"yaw_zero_count = 500000\n" "yaw_center_count = 500000\n"
"yaw_min_deg = -90\n" "yaw_min_deg = -90\n"
"[Scan]\n" "[Scan]\n"
"yaw_min_deg = -90\n"; // same key name, different section — must NOT change "yaw_min_deg = -90\n"; // same key name, different section — must NOT change
auto out = updateIniSectionKeys(in, "Motor", auto out = updateIniSectionKeys(in, "Motor",
{{"yaw_counts_per_deg", "1000"}, {"yaw_zero_count", "12345"}}); {{"yaw_counts_per_deg", "1000"}, {"yaw_center_count", "12345"}});
CHECK(out.find("yaw_counts_per_deg = 1000") != std::string::npos); CHECK(out.find("yaw_counts_per_deg = 1000") != std::string::npos);
CHECK(out.find("yaw_zero_count = 12345") != std::string::npos); CHECK(out.find("yaw_center_count = 12345") != std::string::npos);
CHECK(out.find("yaw_counts_per_deg = 983.33") == std::string::npos); // replaced CHECK(out.find("yaw_counts_per_deg = 983.33") == std::string::npos); // replaced
CHECK(out.find("; calibrate me") != std::string::npos); // comment kept CHECK(out.find("; calibrate me") != std::string::npos); // comment kept
CHECK(out.find("tower_name = Foo") != std::string::npos); // other section kept CHECK(out.find("tower_name = Foo") != std::string::npos); // other section kept
@ -255,49 +255,87 @@ TEST_CASE("updateIniSectionKeys replaces in-section keys, leaving others intact"
TEST_CASE("updateIniSectionKeys appends keys missing from the section") { TEST_CASE("updateIniSectionKeys appends keys missing from the section") {
const std::string in = "[Motor]\nyaw_counts_per_deg = 1\n"; const std::string in = "[Motor]\nyaw_counts_per_deg = 1\n";
auto out = updateIniSectionKeys(in, "Motor", {{"pitch_zero_count", "7"}}); auto out = updateIniSectionKeys(in, "Motor", {{"pitch_center_count", "7"}});
// re-parsing must see the appended key under [Motor] // re-parsing must see the appended key under [Motor]
CHECK(out.find("pitch_zero_count = 7") != std::string::npos); CHECK(out.find("pitch_center_count = 7") != std::string::npos);
} }
TEST_CASE("saveMotorCalibration round-trips the full per-axis map") { TEST_CASE("saveMotorGeometry round-trips the full per-axis map") {
// Write a starting config, save a calibrated Geometry into it, reload, and // Write a starting config, save a calibrated Geometry into it, reload, and
// confirm every [Motor] parameter (incl. min/max_deg) survives, while another // confirm every [Motor] parameter survives, while another section is left
// section is left untouched. // untouched. min_deg/max_deg are deliberately NOT persisted - they come from
// homing - so they must come back at their defaults, not at the saved values.
const std::string path = "test_calib_roundtrip.ini"; const std::string path = "test_calib_roundtrip.ini";
{ {
std::ofstream f(path, std::ios::trunc); std::ofstream f(path, std::ios::trunc);
f << "[General]\ntower_name = rig7\n\n" f << "[General]\ntower_name = rig7\n\n"
"[Motor]\n" "[Motor]\n"
"yaw_counts_per_deg = 1.0\n" "yaw_counts_per_deg = 1.0\n"
"yaw_zero_count = 0\n" "yaw_center_count = 500000\n"
"yaw_min_deg = -90\n" "yaw_heading_at_center = 0\n"
"yaw_max_deg = 90\n"
"pitch_counts_per_deg = 1.0\n" "pitch_counts_per_deg = 1.0\n"
"pitch_zero_count = 0\n" "pitch_center_count = 500000\n"
"pitch_min_deg = 0\n" "pitch_angle_at_center = 0\n";
"pitch_max_deg = 60\n";
} }
Geometry geo; Geometry geo;
geo.yaw = {983.33, 500123, -88.0, 92.0}; geo.yaw = {983.33, 500123, 137.25, -88.0, 92.0};
geo.pitch = {8333.33, -4200, -2.0, 61.5}; geo.pitch = {8333.33, 499000, 10.5, -2.0, 61.5};
REQUIRE(saveMotorCalibration(path, geo)); REQUIRE(saveMotorGeometry(path, geo, 4000, 6000));
AppConfig cfg = ConfigLoader::loadFromFile(path); AppConfig cfg = ConfigLoader::loadFromFile(path);
CHECK(cfg.general.tower_name == "rig7"); // unrelated section preserved CHECK(cfg.general.tower_name == "rig7"); // unrelated section preserved
CHECK(cfg.geometry.yaw.counts_per_deg == doctest::Approx(983.33)); CHECK(cfg.geometry.yaw.counts_per_deg == doctest::Approx(983.33));
CHECK(cfg.geometry.yaw.zero_count == 500123); CHECK(cfg.geometry.yaw.center_count == 500123);
CHECK(cfg.geometry.yaw.min_deg == doctest::Approx(-88.0)); CHECK(cfg.geometry.yaw.deg_at_center == doctest::Approx(137.25));
CHECK(cfg.geometry.yaw.max_deg == doctest::Approx(92.0));
CHECK(cfg.geometry.pitch.counts_per_deg == doctest::Approx(8333.33)); CHECK(cfg.geometry.pitch.counts_per_deg == doctest::Approx(8333.33));
CHECK(cfg.geometry.pitch.zero_count == -4200); CHECK(cfg.geometry.pitch.center_count == 499000);
CHECK(cfg.geometry.pitch.min_deg == doctest::Approx(-2.0)); CHECK(cfg.geometry.pitch.deg_at_center == doctest::Approx(10.5));
CHECK(cfg.geometry.pitch.max_deg == doctest::Approx(61.5)); CHECK(cfg.yaw_soft_margin_counts == 4000);
CHECK(cfg.pitch_soft_margin_counts == 6000);
std::remove(path.c_str()); std::remove(path.c_str());
} }
TEST_CASE("the [Motor] loader rejects values that would silently misaim the gimbal") {
auto base = [] {
return std::map<std::string, std::string>{
{"Motor.yaw_counts_per_deg", "983.33"}, {"Motor.yaw_center_count", "500000"},
{"Motor.pitch_counts_per_deg", "8333.33"}, {"Motor.pitch_center_count", "500000"}};
};
CHECK_NOTHROW(ConfigLoader::fromMap(base()));
// A zero scale maps every degree to the centre: it would look calibrated.
auto zero_scale = base();
zero_scale["Motor.yaw_counts_per_deg"] = "0";
CHECK_THROWS_AS(ConfigLoader::fromMap(zero_scale), std::runtime_error);
// The centre count exists to keep the working range positive.
auto neg_centre = base();
neg_centre["Motor.pitch_center_count"] = "-1";
CHECK_THROWS_AS(ConfigLoader::fromMap(neg_centre), std::runtime_error);
auto neg_margin = base();
neg_margin["Motor.yaw_soft_margin_counts"] = "-100";
CHECK_THROWS_AS(ConfigLoader::fromMap(neg_margin), std::runtime_error);
}
TEST_CASE("keys from the old [Motor] layout are ignored, not silently honoured") {
// A config still carrying yaw_zero_count must load (so an upgrade is not a
// hard stop) but must not pick the stale origin up from anywhere.
std::map<std::string, std::string> kv{
{"Motor.yaw_counts_per_deg", "983.33"}, {"Motor.yaw_center_count", "500000"},
{"Motor.yaw_heading_at_center", "42"}, {"Motor.yaw_zero_count", "851592"},
{"Motor.yaw_min_deg", "0"}, {"Motor.yaw_max_deg", "319"},
{"Motor.yaw_home_zero", "high"}};
AppConfig cfg = ConfigLoader::fromMap(kv);
CHECK(cfg.geometry.yaw.center_count == 500000);
CHECK(cfg.geometry.yaw.deg_at_center == doctest::Approx(42.0));
// The removed range keys must not have leaked into the derived window.
CHECK(cfg.geometry.yaw.min_deg == doctest::Approx(-100000.0));
CHECK(cfg.geometry.yaw.max_deg == doctest::Approx(100000.0));
}
TEST_CASE("loadFromFile anchors a relative profile_dir to the config file's directory") { TEST_CASE("loadFromFile anchors a relative profile_dir to the config file's directory") {
// The config is found through a search path that has nothing to do with the // The config is found through a search path that has nothing to do with the
// working directory, so a relative profile_dir must resolve next to the // working directory, so a relative profile_dir must resolve next to the

View File

@ -14,15 +14,15 @@ namespace {
// TMC register line, bracketed by DUMP BEGIN / DUMP END. // TMC register line, bracketed by DUMP BEGIN / DUMP END.
const char* kDump = const char* kDump =
"DUMP BEGIN build=11dd3ce-dirty uptime=11740067 mcusr=0x01 free_ram=1852\n" "DUMP BEGIN build=11dd3ce-dirty uptime=11740067 mcusr=0x01 free_ram=1852\n"
"DUMP Y state=3 hsub=10 enabled=1 lim_neg=-82919 lim_pos=98687 hold_target=-90 " "DUMP Y state=3 hsub=10 enabled=1 lim_neg=411500 lim_pos=588500 hold_target=499910 "
"speed=50000 eeprom_restored=0 soft_margin=5000 hold_corr=7 hold_peak=430 hold_cumul=2810 " "speed=50000 center=500000 soft_margin=5000 hold_corr=7 hold_peak=430 hold_cumul=2810 "
"has_encoder=1\n" "has_encoder=1\n"
"DUMP Y TMC GCONF=0x0000000C GSTAT=0x00000000 IOIN=0x30000008 TSTEP=0x000FFFFF " "DUMP Y TMC GCONF=0x0000000C GSTAT=0x00000000 IOIN=0x30000008 TSTEP=0x000FFFFF "
"RAMPMODE=0x00000000 XACTUAL=0xFFFFFFA6 VACTUAL=0x00000000 XTARGET=0xFFFFFFA6 " "RAMPMODE=0x00000000 XACTUAL=0xFFFFFFA6 VACTUAL=0x00000000 XTARGET=0xFFFFFFA6 "
"SW_MODE=0x000008A0 RAMP_STAT=0x00001680 X_ENC=0xFFFFFF7F ENC_STATUS=0x00000002 " "SW_MODE=0x000008A0 RAMP_STAT=0x00001680 X_ENC=0xFFFFFF7F ENC_STATUS=0x00000002 "
"CHOPCONF=0x00410043 DRV_STATUS=0x80084000 PWM_SCALE=0x00000011 PWM_AUTO=0x003F0039\n" "CHOPCONF=0x00410043 DRV_STATUS=0x80084000 PWM_SCALE=0x00000011 PWM_AUTO=0x003F0039\n"
"DUMP P state=3 hsub=10 enabled=1 lim_neg=495183 lim_pos=1069042 hold_target=500183 " "DUMP P state=3 hsub=10 enabled=1 lim_neg=213070 lim_pos=786930 hold_target=500183 "
"speed=150000 eeprom_restored=0 has_encoder=1\n" "speed=150000 center=500000 has_encoder=1\n"
"DUMP P TMC GCONF=0x0000000C GSTAT=0x00000000 IOIN=0x30000008 TSTEP=0x000FFFFF " "DUMP P TMC GCONF=0x0000000C GSTAT=0x00000000 IOIN=0x30000008 TSTEP=0x000FFFFF "
"RAMPMODE=0x00000000 XACTUAL=0x0007A1C8 VACTUAL=0x00000000 XTARGET=0x0007A1C8 " "RAMPMODE=0x00000000 XACTUAL=0x0007A1C8 VACTUAL=0x00000000 XTARGET=0x0007A1C8 "
"SW_MODE=0x000008A3 RAMP_STAT=0x00001688 X_ENC=0x0007A231 ENC_STATUS=0x00000002 " "SW_MODE=0x000008A3 RAMP_STAT=0x00001688 X_ENC=0x0007A231 ENC_STATUS=0x00000002 "
@ -59,10 +59,12 @@ TEST_CASE("parseDump decodes the per-axis state line") {
CHECK(y->state_name == "READY"); // state=3 CHECK(y->state_name == "READY"); // state=3
CHECK(y->enabled); CHECK(y->enabled);
CHECK(y->has_encoder); CHECK(y->has_encoder);
CHECK_FALSE(y->eeprom_restored); CHECK(y->lim_neg == 411500);
CHECK(y->lim_neg == -82919); CHECK(y->lim_pos == 588500);
CHECK(y->lim_pos == 98687); CHECK(y->hold_target == 499910);
CHECK(y->hold_target == -90); // The anchor the firmware homed onto; limits straddle it symmetrically.
CHECK(y->center_count == 500000);
CHECK((y->lim_neg + y->lim_pos) / 2 == y->center_count);
CHECK(y->speed == 50000); CHECK(y->speed == 50000);
CHECK(y->hsub == 10); CHECK(y->hsub == 10);
CHECK(y->soft_margin == 5000); CHECK(y->soft_margin == 5000);
@ -72,8 +74,8 @@ TEST_CASE("parseDump decodes the per-axis state line") {
const DumpAxis* p = axis(d, 'P'); const DumpAxis* p = axis(d, 'P');
REQUIRE(p != nullptr); REQUIRE(p != nullptr);
CHECK(p->lim_neg == 495183); CHECK(p->lim_neg == 213070);
CHECK(p->lim_pos == 1069042); CHECK(p->lim_pos == 786930);
} }
TEST_CASE("parseDump keeps all 16 TMC registers (regression: TMC-line whitespace)") { TEST_CASE("parseDump keeps all 16 TMC registers (regression: TMC-line whitespace)") {

View File

@ -4,8 +4,10 @@
using namespace fgc; using namespace fgc;
TEST_CASE("AxisMap converts degrees and counts with an offset") { // Fields in order: counts_per_deg, center_count, deg_at_center, min_deg, max_deg.
AxisMap m{983.33, 500000, -90.0, 90.0};
TEST_CASE("AxisMap converts degrees and counts about the anchored centre") {
AxisMap m{983.33, 500000, 0.0, -90.0, 90.0};
CHECK(m.toCounts(0.0) == 500000); CHECK(m.toCounts(0.0) == 500000);
CHECK(m.toCounts(90.0) == 588500); // lround(500000 + 90*983.33) CHECK(m.toCounts(90.0) == 588500); // lround(500000 + 90*983.33)
@ -13,20 +15,94 @@ TEST_CASE("AxisMap converts degrees and counts with an offset") {
CHECK(m.toDeg(m.toCounts(45.0)) == doctest::Approx(45.0).epsilon(0.001)); CHECK(m.toDeg(m.toCounts(45.0)) == doctest::Approx(45.0).epsilon(0.001));
} }
TEST_CASE("a non-zero deg_at_center shifts the whole map") {
// The centre points at 137.4 deg true; everything else follows from that.
AxisMap m{-529.8, 500000, 137.4, 137.4 - 160.0, 137.4 + 160.0};
CHECK(m.toCounts(137.4) == 500000);
CHECK(m.toDeg(500000) == doctest::Approx(137.4));
// Negative counts_per_deg: a higher heading is a lower count.
CHECK(m.toCounts(147.4) < 500000);
CHECK(m.toDeg(m.toCounts(200.0)) == doctest::Approx(200.0).epsilon(0.001));
}
TEST_CASE("AxisMap clamps to the soft range on the way out") { TEST_CASE("AxisMap clamps to the soft range on the way out") {
AxisMap m{10.0, 0, -90.0, 90.0}; AxisMap m{10.0, 0, 0.0, -90.0, 90.0};
CHECK(m.toCounts(200.0) == 900); // clamped to +90 CHECK(m.toCounts(200.0) == 900); // clamped to +90
CHECK(m.toCounts(-200.0) == -900); // clamped to -90 CHECK(m.toCounts(-200.0) == -900); // clamped to -90
CHECK(m.toCounts(45.0) == 450); CHECK(m.toCounts(45.0) == 450);
} }
TEST_CASE("AxisMap supports a negative (flipped) direction") { TEST_CASE("AxisMap supports a negative (flipped) direction") {
AxisMap m{-10.0, 1000, -90.0, 90.0}; AxisMap m{-10.0, 1000, 0.0, -90.0, 90.0};
CHECK(m.toCounts(10.0) == 900); CHECK(m.toCounts(10.0) == 900);
CHECK(m.toDeg(900) == doctest::Approx(10.0)); CHECK(m.toDeg(900) == doctest::Approx(10.0));
} }
TEST_CASE("AxisMap toDeg guards against a zero scale") { TEST_CASE("AxisMap toDeg guards against a zero scale") {
AxisMap m{0.0, 0, -90.0, 90.0}; // Degrades to "we are at the centre", not to an unrelated zero.
CHECK(m.toDeg(12345) == doctest::Approx(0.0)); AxisMap m{0.0, 0, 42.0, -90.0, 90.0};
CHECK(m.toDeg(12345) == doctest::Approx(42.0));
}
TEST_CASE("wrap360 and wrap180 normalise at and across the boundaries") {
CHECK(wrap360(0.0) == doctest::Approx(0.0));
CHECK(wrap360(360.0) == doctest::Approx(0.0));
CHECK(wrap360(361.0) == doctest::Approx(1.0));
CHECK(wrap360(-1.0) == doctest::Approx(359.0));
CHECK(wrap360(-361.0) == doctest::Approx(359.0));
CHECK(wrap360(180.0) == doctest::Approx(180.0));
// A tiny negative must land at 0, never at exactly 360.
CHECK(wrap360(-1e-15) < 360.0);
CHECK(wrap180(0.0) == doctest::Approx(0.0));
CHECK(wrap180(179.0) == doctest::Approx(179.0));
CHECK(wrap180(180.0) == doctest::Approx(-180.0));
CHECK(wrap180(181.0) == doctest::Approx(-179.0));
CHECK(wrap180(-179.0) == doctest::Approx(-179.0));
CHECK(wrap180(359.0) == doctest::Approx(-1.0));
}
TEST_CASE("headingDeg reports a compass bearing regardless of the linear degree") {
// Centre at 350 deg true; +20 deg of travel crosses north.
AxisMap m{100.0, 500000, 350.0, 350.0 - 160.0, 350.0 + 160.0};
CHECK(m.headingDeg(500000) == doctest::Approx(350.0));
// Linear 370 deg is the compass bearing 10 deg.
CHECK(m.headingDeg(m.toCounts(370.0)) == doctest::Approx(10.0));
}
TEST_CASE("headingToCounts reaches across north") {
AxisMap m{100.0, 500000, 350.0, 350.0 - 160.0, 350.0 + 160.0};
// 10 deg true is 20 deg past the centre the short way, well inside travel.
auto c = m.headingToCounts(10.0);
REQUIRE(c.has_value());
CHECK(*c == 502000);
CHECK(m.headingDeg(*c) == doctest::Approx(10.0));
// And the other way, back past north.
auto d = m.headingToCounts(330.0);
REQUIRE(d.has_value());
CHECK(*d == 498000);
}
TEST_CASE("headingToCounts refuses the dead arc instead of clamping to a limit") {
// 320 deg of travel centred on 0 deg true: reachable is 200..360..160.
AxisMap m{100.0, 500000, 0.0, -160.0, 160.0};
CHECK(m.headingToCounts(90.0).has_value());
CHECK(m.headingToCounts(270.0).has_value()); // == -90, inside
CHECK(m.headingToCounts(160.0).has_value()); // exactly on the limit
CHECK_FALSE(m.headingToCounts(180.0).has_value()); // dead centre of the gap
CHECK_FALSE(m.headingToCounts(170.0).has_value());
CHECK_FALSE(m.headingToCounts(190.0).has_value());
// The point of returning nullopt: 180 must NOT come back as the 160 limit.
CHECK(m.headingToCounts(180.0) != m.headingToCounts(160.0));
}
TEST_CASE("headingToCounts refuses a degenerate scale") {
AxisMap m{0.0, 500000, 0.0, -160.0, 160.0};
CHECK_FALSE(m.headingToCounts(90.0).has_value());
} }

161
tests/test_headingfit.cpp Normal file
View File

@ -0,0 +1,161 @@
#include <doctest/doctest.h>
#include "fgc/Geometry.h"
#include "fgc/HeadingFit.h"
#include <cmath>
using namespace fgc;
namespace {
// A rig whose yaw runs at -529.8 counts/deg (the direction sense is flipped) with
// the homed centre at count 500000. Build the sighting a landmark at `bearing`
// would produce on a gimbal already calibrated to `cpd` / `at_center`.
long countsFor(double bearing, double cpd, double at_center, long center = 500000) {
return center + static_cast<long>(std::lround((bearing - at_center) * cpd));
}
} // namespace
TEST_CASE("two sightings recover the map exactly") {
const double cpd = -529.8, at_center = 137.4;
std::vector<HeadingSighting> s{
{countsFor(90.0, cpd, at_center), 90.0},
{countsFor(200.0, cpd, at_center), 200.0},
};
auto r = fitHeading(s, 500000);
REQUIRE(r.ok);
CHECK(r.counts_per_deg == doctest::Approx(cpd).epsilon(1e-4));
CHECK(r.deg_at_center == doctest::Approx(at_center).epsilon(1e-4));
CHECK(r.n == 2);
CHECK(r.r2 == doctest::Approx(1.0));
}
TEST_CASE("sighting order does not matter") {
const double cpd = 983.33, at_center = 40.0;
std::vector<HeadingSighting> forward{
{countsFor(10.0, cpd, at_center), 10.0},
{countsFor(70.0, cpd, at_center), 70.0},
{countsFor(130.0, cpd, at_center), 130.0},
};
std::vector<HeadingSighting> jumbled{forward[2], forward[0], forward[1]};
auto a = fitHeading(forward, 500000);
auto b = fitHeading(jumbled, 500000);
REQUIRE(a.ok);
REQUIRE(b.ok);
CHECK(a.counts_per_deg == doctest::Approx(b.counts_per_deg));
CHECK(a.deg_at_center == doctest::Approx(b.deg_at_center));
}
TEST_CASE("a set straddling north fits as one line, not two") {
// Centre points at 350 deg; landmarks at 320 and 30 sit either side of north.
// Without unwrapping, 30 would look like it is 290 deg *below* 320 and the
// fitted scale would come out with the wrong sign and magnitude.
const double cpd = 100.0, at_center = 350.0;
std::vector<HeadingSighting> s{
{countsFor(320.0, cpd, at_center), 320.0},
{countsFor(390.0, cpd, at_center), 30.0}, // 390 == 30 deg true
};
auto r = fitHeading(s, 500000);
REQUIRE(r.ok);
CHECK(r.counts_per_deg == doctest::Approx(cpd).epsilon(1e-6));
CHECK(r.deg_at_center == doctest::Approx(350.0).epsilon(1e-6));
CHECK(r.span_deg == doctest::Approx(70.0));
}
TEST_CASE("more sightings are least-squares fitted and report a usable R^2") {
const double cpd = 983.33, at_center = 40.0;
std::vector<HeadingSighting> s;
// A couple of counts of aiming slop per sighting, alternating.
const long slop[] = {40, -35, 30, -45, 25};
for (int i = 0; i < 5; ++i) {
const double bearing = 10.0 + 40.0 * i;
s.push_back({countsFor(bearing, cpd, at_center) + slop[i], bearing});
}
auto r = fitHeading(s, 500000);
REQUIRE(r.ok);
CHECK(r.n == 5);
CHECK(r.counts_per_deg == doctest::Approx(cpd).epsilon(0.01));
CHECK(r.deg_at_center == doctest::Approx(at_center).epsilon(0.01));
CHECK(r.r2 > 0.999); // slop is tiny against a 160 deg baseline
}
TEST_CASE("a single sighting cannot determine a scale") {
std::vector<HeadingSighting> s{{500000, 137.0}};
auto r = fitHeading(s, 500000);
CHECK_FALSE(r.ok);
CHECK(r.error.find("two sightings") != std::string::npos);
}
TEST_CASE("a short baseline is refused rather than fitted") {
// Two points ALWAYS fit exactly, so R^2 would be 1 and the result would look
// perfect. Only the baseline reveals that a count of aiming error would swing
// counts_per_deg wildly.
const double cpd = 983.33, at_center = 40.0;
std::vector<HeadingSighting> s{
{countsFor(40.0, cpd, at_center), 40.0},
{countsFor(43.0, cpd, at_center), 43.0},
};
auto r = fitHeading(s, 500000);
CHECK_FALSE(r.ok);
CHECK(r.error.find("apart") != std::string::npos);
CHECK(r.span_deg == doctest::Approx(3.0));
}
TEST_CASE("distinct bearings at the same encoder position are refused") {
// The gimbal did not move between sightings, so one bearing must be wrong.
std::vector<HeadingSighting> s{{500000, 40.0}, {500000, 120.0}};
auto r = fitHeading(s, 500000);
CHECK_FALSE(r.ok);
}
TEST_CASE("identical sightings are refused") {
std::vector<HeadingSighting> s{{500000, 40.0}, {500000, 40.0}};
auto r = fitHeading(s, 500000);
CHECK_FALSE(r.ok);
}
TEST_CASE("the fitted map round-trips through AxisMap") {
const double cpd = -529.8, at_center = 137.4;
std::vector<HeadingSighting> s{
{countsFor(60.0, cpd, at_center), 60.0},
{countsFor(210.0, cpd, at_center), 210.0},
};
auto r = fitHeading(s, 500000);
REQUIRE(r.ok);
AxisMap m;
m.counts_per_deg = r.counts_per_deg;
m.center_count = 500000;
m.deg_at_center = r.deg_at_center;
m.min_deg = r.deg_at_center - 160.0;
m.max_deg = r.deg_at_center + 160.0;
// The map now reads back the very bearings that were sighted.
for (const auto& sight : s)
CHECK(m.headingDeg(sight.counts) == doctest::Approx(sight.heading_deg).epsilon(1e-4));
}
TEST_CASE("parseHeadingSightings accepts the wizard's token form") {
std::vector<HeadingSighting> out;
std::string err;
REQUIRE(parseHeadingSightings({"500123:137.4", "480000:-12"}, out, err));
REQUIRE(out.size() == 2);
CHECK(out[0].counts == 500123);
CHECK(out[0].heading_deg == doctest::Approx(137.4));
CHECK(out[1].counts == 480000);
CHECK(out[1].heading_deg == doctest::Approx(-12.0));
CHECK_FALSE(parseHeadingSightings({"500123"}, out, err)); // no colon
CHECK_FALSE(parseHeadingSightings({"500123:"}, out, err)); // no bearing
CHECK_FALSE(parseHeadingSightings({"abc:1"}, out, err)); // counts not a number
CHECK_FALSE(parseHeadingSightings({"1:2:3"}, out, err)); // trailing junk
CHECK_FALSE(parseHeadingSightings({"500123x:12"}, out, err)); // trailing junk in counts
CHECK_FALSE(err.empty());
}

View File

@ -38,7 +38,8 @@ TEST_CASE("renderHelp() with no topic lists every section and entry") {
} }
// A couple of the commands. // A couple of the commands.
CHECK(out.find("gimbal move") != std::string::npos); CHECK(out.find("gimbal move") != std::string::npos);
CHECK(out.find("gimbal calib") != std::string::npos); CHECK(out.find("gimbal pitch setup") != std::string::npos);
CHECK(out.find("gimbal heading setup") != std::string::npos);
} }
TEST_CASE("renderHelp(<section>) expands that section with detail") { TEST_CASE("renderHelp(<section>) expands that section with detail") {
@ -52,7 +53,9 @@ TEST_CASE("renderHelp(<verb>) matches the gimbal commands, case-insensitively")
std::string lower = join(renderHelp("gimbal")); std::string lower = join(renderHelp("gimbal"));
std::string upper = join(renderHelp("GIMBAL")); std::string upper = join(renderHelp("GIMBAL"));
CHECK(lower.find("gimbal move <yaw>,<pitch>") != std::string::npos); CHECK(lower.find("gimbal move <yaw>,<pitch>") != std::string::npos);
CHECK(lower.find("gimbal calib") != std::string::npos); CHECK(lower.find("gimbal pitch setup") != std::string::npos);
// The removed `gimbal calib` must not linger anywhere in the catalog.
CHECK(lower.find("gimbal calib") == std::string::npos);
CHECK(lower == upper); CHECK(lower == upper);
} }

View File

@ -1,6 +1,6 @@
#include <doctest/doctest.h> #include <doctest/doctest.h>
#include "fgc/CalibrationRoutine.h" #include "fgc/PitchSetup.h"
#include "fgc/Geometry.h" #include "fgc/Geometry.h"
#include "fgc/IImuSource.h" #include "fgc/IImuSource.h"
#include "fgc/IMotorController.h" #include "fgc/IMotorController.h"
@ -21,10 +21,10 @@ namespace {
const char* kDump = const char* kDump =
"DUMP BEGIN build=test uptime=1000 mcusr=0x01 free_ram=1852\n" "DUMP BEGIN build=test uptime=1000 mcusr=0x01 free_ram=1852\n"
"DUMP Y state=3 hsub=10 enabled=1 lim_neg=-80000 lim_pos=80000 hold_target=0 " "DUMP Y state=3 hsub=10 enabled=1 lim_neg=-80000 lim_pos=80000 hold_target=0 "
"speed=50000 eeprom_restored=0 has_encoder=1\n" "speed=50000 center=500000 has_encoder=1\n"
"DUMP Y TMC GCONF=0x0000000C DRV_STATUS=0x80084000\n" "DUMP Y TMC GCONF=0x0000000C DRV_STATUS=0x80084000\n"
"DUMP P state=3 hsub=10 enabled=1 lim_neg=0 lim_pos=600000 hold_target=0 " "DUMP P state=3 hsub=10 enabled=1 lim_neg=0 lim_pos=600000 hold_target=0 "
"speed=150000 eeprom_restored=0 has_encoder=1\n" "speed=150000 center=500000 has_encoder=1\n"
"DUMP P TMC GCONF=0x0000000C DRV_STATUS=0x80084000\n" "DUMP P TMC GCONF=0x0000000C DRV_STATUS=0x80084000\n"
"DUMP END\n"; "DUMP END\n";
@ -81,60 +81,33 @@ struct FakeMotor : IMotorController {
bool connected() const override { return true; } bool connected() const override { return true; }
}; };
// Fake IMU: pitch is the live pitch encoder / kFakeCpd; yaw is the live yaw // Fake IMU: reported pitch is the live pitch encoder / kFakeCpd, so a correct
// encoder offset by the heading-reset zero, / kFakeCpd. Records noRotation / // routine recovers kFakeCpd exactly. Yaw is not modelled - pitch setup never
// headingReset into the shared motor event log so ordering can be asserted. // looks at it.
struct FakeImu : IImuSource { struct FakeImu : IImuSource {
FakeMotor& motor; FakeMotor& motor;
long yaw_zero = 0; bool is_connected = true;
int scenario_type = 39; // starts on a mag-using profile
bool has_profiles = true; // expose an available list (incl. a no-mag one)
explicit FakeImu(FakeMotor& m) : motor(m) {} explicit FakeImu(FakeMotor& m) : motor(m) {}
void start() override {} void start() override {}
void stop() override {} void stop() override {}
bool connected() const override { return true; } bool connected() const override { return is_connected; }
std::optional<ImuSample> sample() override { std::optional<ImuSample> sample() override {
MotorTelemetry t = motor.telemetry(); MotorTelemetry t = motor.telemetry();
ImuSample s; ImuSample s;
s.valid = true; s.valid = true;
s.pitch_deg = static_cast<float>(t.pitch.xenc / kFakeCpd); s.pitch_deg = static_cast<float>(t.pitch.xenc / kFakeCpd);
double y = (t.yaw.xenc - yaw_zero) / kFakeCpd;
s.yaw_deg = static_cast<float>(y);
return s; return s;
} }
std::optional<ImuDeviceConfig> config() const override {
ImuDeviceConfig c;
c.valid = true;
c.has_scenario = true;
c.scenario_type = static_cast<uint8_t>(scenario_type);
if (has_profiles)
c.available_profiles = {{39, 11, "General"}, {40, 11, "High_mag_dep"},
{53, 11, "VRU_general"}};
return c;
}
void noRotation(int seconds) override {
motor.record("noRotation:" + std::to_string(seconds));
}
void headingReset() override {
motor.record("headingReset");
std::lock_guard<std::mutex> lk(motor.mtx);
yaw_zero = motor.tel.yaw.xenc; // current pose becomes yaw 0
}
bool setFilterProfile(int type) override {
motor.record("setProfile:" + std::to_string(type));
scenario_type = type;
return true;
}
}; };
Geometry uncalibrated() { Geometry uncalibrated() {
Geometry g; Geometry g;
g.yaw = {1.0, 0, -100000.0, 100000.0}; // center_count 0 so the fake's "counts = deg * kFakeCpd" holds exactly and
g.pitch = {1.0, 0, -100000.0, 100000.0}; // deg_at_center comes out as 0.
g.yaw = {1.0, 0, 0.0, -100000.0, 100000.0};
g.pitch = {1.0, 0, 0.0, -100000.0, 100000.0};
return g; return g;
} }
@ -146,17 +119,15 @@ CalibParams fastParams() {
p.sample_ms = 1; p.sample_ms = 1;
p.settle_timeout_ms = 200; p.settle_timeout_ms = 200;
p.home_timeout_ms = 500; p.home_timeout_ms = 500;
p.norotation_s = 0;
p.reset_settle_ms = 2;
return p; return p;
} }
// Run to completion (or fail the test on timeout). // Run to completion (or fail the test on timeout).
void runToCompletion(CalibrationRoutine& cal) { void runToCompletion(PitchSetupRoutine& cal) {
REQUIRE(cal.start()); REQUIRE(cal.start());
auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5);
while (cal.running()) { while (cal.running()) {
if (std::chrono::steady_clock::now() > deadline) { FAIL("calibration did not finish"); } if (std::chrono::steady_clock::now() > deadline) { FAIL("pitch setup did not finish"); }
std::this_thread::sleep_for(std::chrono::milliseconds(5)); std::this_thread::sleep_for(std::chrono::milliseconds(5));
} }
} }
@ -175,131 +146,120 @@ int lastIndexOf(const std::vector<std::string>& v, const std::string& needle) {
} // namespace } // namespace
TEST_CASE("calibration homes first when the gimbal is not READY") { TEST_CASE("pitch setup homes first when the gimbal is not READY") {
FakeMotor motor; // starts in Boot (not ready) FakeMotor motor; // starts in Boot (not ready)
motor.tel.pitch_present = true; motor.tel.pitch_present = true;
FakeImu imu(motor); FakeImu imu(motor);
CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); PitchSetupRoutine cal(motor, imu, uncalibrated(), fastParams());
runToCompletion(cal); runToCompletion(cal);
const auto& ev = motor.events; const auto& ev = motor.events;
REQUIRE_FALSE(ev.empty()); REQUIRE_FALSE(ev.empty());
CHECK(ev.front() == "HOME"); // homing is the first action CHECK(ev.front() == "HOME"); // homing is the first action
CHECK(indexOf(ev, "HOME") < indexOf(ev, "MOVE P")); // home before any pitch move CHECK(indexOf(ev, "HOME") < indexOf(ev, "MOVE P")); // home before any pitch move
} }
TEST_CASE("calibration does NOT home when already READY") { TEST_CASE("pitch setup does NOT home when already READY") {
FakeMotor motor; FakeMotor motor;
motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true;
motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true; motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true;
motor.tel.pitch_present = true; motor.tel.pitch_present = true;
FakeImu imu(motor); FakeImu imu(motor);
CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); PitchSetupRoutine cal(motor, imu, uncalibrated(), fastParams());
runToCompletion(cal); runToCompletion(cal);
CHECK(indexOf(motor.events, "HOME") == -1); // no homing issued CHECK(indexOf(motor.events, "HOME") == -1); // no homing issued
} }
TEST_CASE("calibration runs pitch-at-first-yaw, then drift-correct+reset, then yaw") { TEST_CASE("pitch setup never moves yaw") {
// Yaw is tied to north by `gimbal heading setup` against landmarks, not by
// this routine. Touching it here would silently undo that calibration.
FakeMotor motor; FakeMotor motor;
motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true;
motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true; motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true;
motor.tel.pitch_present = true; motor.tel.pitch_present = true;
FakeImu imu(motor); FakeImu imu(motor);
CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); PitchSetupRoutine cal(motor, imu, uncalibrated(), fastParams());
runToCompletion(cal); runToCompletion(cal);
const auto& ev = motor.events; CHECK(indexOf(motor.events, "MOVE Y") == -1);
// Yaw is parked at its first position before the pitch sweep begins. CHECK(indexOf(motor.events, "MOVE P") >= 0);
const int first_yaw_move = indexOf(ev, "MOVE Y");
const int first_pitch_move = indexOf(ev, "MOVE P");
REQUIRE(first_yaw_move >= 0);
REQUIRE(first_pitch_move >= 0);
CHECK(first_yaw_move < first_pitch_move);
// No-mag profile switch, drift correction, then heading reset happen AFTER the // And the published geometry leaves the yaw map exactly as it found it.
// pitch sweep and BEFORE the yaw sweep reaches its far end. auto geo = cal.takeResult();
const int prof = indexOf(ev, "setProfile:53"); // VRU_general from the fake list REQUIRE(geo.has_value());
const int norot = indexOf(ev, "noRotation"); CHECK(geo->yaw.counts_per_deg == doctest::Approx(uncalibrated().yaw.counts_per_deg));
const int reset = indexOf(ev, "headingReset"); CHECK(geo->yaw.deg_at_center == doctest::Approx(uncalibrated().yaw.deg_at_center));
REQUIRE(prof >= 0);
REQUIRE(norot >= 0);
REQUIRE(reset >= 0);
CHECK(first_pitch_move < prof); // pitch calibrated before the profile switch
CHECK(prof < norot); // switch to no-mag, then correct drift
CHECK(norot < reset); // correct drift, then reset
CHECK(reset < lastIndexOf(ev, "MOVE Y")); // yaw sweep continues after the reset
// Pitch is commanded to 0 deg before the heading reset.
CHECK(indexOf(ev, "MOVE P 0") >= 0);
CHECK(indexOf(ev, "MOVE P 0") < reset);
} }
TEST_CASE("calibration skips the profile switch when no no-mag profile exists") { TEST_CASE("pitch setup recovers the fake IMU slope and reports one axis") {
FakeMotor motor;
motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true;
motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true;
motor.tel.pitch_present = true;
FakeImu imu(motor);
imu.has_profiles = false; // device reports no available profiles
CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams());
runToCompletion(cal);
const auto& ev = motor.events;
CHECK(indexOf(ev, "setProfile") == -1); // nothing to switch to
CHECK(indexOf(ev, "headingReset") >= 0); // still resets + sweeps yaw
CHECK(cal.report().valid);
}
TEST_CASE("calibration recovers the fake IMU slope for both axes") {
FakeMotor motor; FakeMotor motor;
motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true;
motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true; motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true;
motor.tel.pitch_present = true; motor.tel.pitch_present = true;
FakeImu imu(motor); FakeImu imu(motor);
CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); PitchSetupRoutine cal(motor, imu, uncalibrated(), fastParams());
runToCompletion(cal); runToCompletion(cal);
CalibReport rep = cal.report(); CalibReport rep = cal.report();
REQUIRE(rep.valid); REQUIRE(rep.valid);
CHECK(rep.all_ok); CHECK(rep.all_ok);
REQUIRE(rep.axes.size() == 2); REQUIRE(rep.axes.size() == 1);
for (const auto& ax : rep.axes) { CHECK(rep.axes[0].axis == 'P');
CHECK(ax.ok); CHECK(rep.axes[0].ok);
CHECK(ax.counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001)); CHECK(rep.axes[0].counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001));
CHECK(ax.r2 == doctest::Approx(1.0).epsilon(0.001)); CHECK(rep.axes[0].r2 == doctest::Approx(1.0).epsilon(0.001));
}
// The new geometry is published for the session to apply.
auto geo = cal.takeResult(); auto geo = cal.takeResult();
REQUIRE(geo.has_value()); REQUIRE(geo.has_value());
CHECK(geo->yaw.counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001));
CHECK(geo->pitch.counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001)); CHECK(geo->pitch.counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001));
} }
TEST_CASE("calibration handles a yaw-only gimbal (no pitch axis)") { TEST_CASE("pitch setup parks level when it is done") {
FakeMotor motor;
motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true;
motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true;
motor.tel.pitch_present = true;
FakeImu imu(motor);
PitchSetupRoutine cal(motor, imu, uncalibrated(), fastParams());
runToCompletion(cal);
// With center_count 0 and the fake's slope, 0 deg is count 0 - and the last
// pitch move must be to it, not to wherever the sweep happened to end.
CHECK(motor.events.back() == "MOVE P 0");
}
TEST_CASE("pitch setup aborts without an IMU rather than guessing") {
FakeMotor motor;
motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true;
motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true;
motor.tel.pitch_present = true;
FakeImu imu(motor);
imu.is_connected = false;
PitchSetupRoutine cal(motor, imu, uncalibrated(), fastParams());
runToCompletion(cal);
CHECK(motor.events.empty()); // nothing moved
CHECK_FALSE(cal.report().valid);
CHECK_FALSE(cal.takeResult().has_value());
}
TEST_CASE("pitch setup aborts on a yaw-only gimbal") {
FakeMotor motor; FakeMotor motor;
motor.pitch_present = false; motor.pitch_present = false;
motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true;
motor.tel.pitch_present = false; motor.tel.pitch_present = false;
FakeImu imu(motor); FakeImu imu(motor);
CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); PitchSetupRoutine cal(motor, imu, uncalibrated(), fastParams());
runToCompletion(cal); runToCompletion(cal);
const auto& ev = motor.events; CHECK(indexOf(motor.events, "MOVE P") == -1);
CHECK(indexOf(ev, "MOVE P") == -1); // no pitch moves CHECK_FALSE(cal.report().valid);
CHECK(indexOf(ev, "headingReset") >= 0); // still resets + sweeps yaw
CHECK(indexOf(ev, "MOVE Y") >= 0);
CalibReport rep = cal.report();
REQUIRE(rep.valid);
REQUIRE(rep.axes.size() == 1);
CHECK(rep.axes[0].axis == 'Y');
CHECK(rep.axes[0].counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001));
} }

View File

@ -51,11 +51,13 @@ struct FakeChannel : IControlChannel {
} }
}; };
// Simple linear map: 10 counts/deg, zero at 0. Yaw clamps -90..90, pitch 0..60. // Simple linear map: 10 counts/deg, centre at count 0 pointing at 0 deg.
// Reachable yaw travel -90..90, pitch 0..60.
// Fields: counts_per_deg, center_count, deg_at_center, min_deg, max_deg.
Geometry testGeometry() { Geometry testGeometry() {
Geometry g; Geometry g;
g.yaw = {10.0, 0, -90.0, 90.0}; g.yaw = {10.0, 0, 0.0, -90.0, 90.0};
g.pitch = {10.0, 0, 0.0, 60.0}; g.pitch = {10.0, 0, 0.0, 0.0, 60.0};
return g; return g;
} }
@ -169,7 +171,7 @@ TEST_CASE("CaptureScheduler ControlCode 1 drives yaw to the target heading") {
CHECK(sch.controlCode() == 1); CHECK(sch.controlCode() == 1);
} }
TEST_CASE("CaptureScheduler clamps the target heading to the soft range") { TEST_CASE("CaptureScheduler refuses an unreachable target heading instead of clamping") {
long long clock = 0; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
@ -182,15 +184,20 @@ TEST_CASE("CaptureScheduler clamps the target heading to the soft range") {
chan.next.control_code_available = true; chan.next.control_code_available = true;
chan.next.control_code = 1; chan.next.control_code = 1;
chan.next.heading_available = true; chan.next.heading_available = true;
chan.next.target_heading = "200"; // clamped to yaw max 90 deg -> 900 counts // 200 deg is 160 deg the short way round, past the 90 deg limit: in the arc
// this gimbal physically cannot reach.
chan.next.target_heading = "200";
clock = 1600; clock = 1600;
sch.tick(); sch.tick();
CHECK(motor.cmds.back() == "MOVE 900,0"); // No move at all. Clamping to the 90 deg limit would have imaged a direction
// 110 deg away from the one requested and labelled it "200".
CHECK(motor.cmds.empty());
CHECK_FALSE(sch.moving());
} }
TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conversion") { TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conversion") {
// A `gimbal calib` updates the live geometry; the scheduler must adopt it so // A `gimbal heading fit` updates the live geometry; the scheduler must adopt it so
// the scan/directed targets use the calibrated counts/deg (not the stale copy). // the scan/directed targets use the calibrated counts/deg (not the stale copy).
long long clock = 0; long long clock = 0;
FakeMotor motor; FakeMotor motor;
@ -220,8 +227,8 @@ TEST_CASE("CaptureScheduler::setGeometry re-targets with the recalibrated conver
// Recalibrate to 20 counts/deg; the same persisted heading must now resolve // Recalibrate to 20 counts/deg; the same persisted heading must now resolve
// to twice the counts — no new heading sent. // to twice the counts — no new heading sent.
Geometry g2; Geometry g2;
g2.yaw = {20.0, 0, -90.0, 90.0}; g2.yaw = {20.0, 0, 0.0, -90.0, 90.0};
g2.pitch = {10.0, 0, 0.0, 60.0}; g2.pitch = {10.0, 0, 0.0, 0.0, 60.0};
sch.setGeometry(g2); sch.setGeometry(g2);
clock = 2800; clock = 2800;
@ -309,3 +316,154 @@ TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
CHECK_FALSE(sch.moving()); CHECK_FALSE(sch.moving());
CHECK(grid.index() == 1); CHECK(grid.index() == 1);
} }
// --- manual snapshot -------------------------------------------------------
//
// The snapshot exists so an operator aiming by hand gets exactly what a scan
// waypoint produces. These pin the two properties that make that true: it goes
// through the same settle gate, and it never overlaps a scan.
TEST_CASE("requestSnapshot captures one frame once the gimbal has settled") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
// Capture inactive: this is manual mode.
settleAt(motor, 0, 0);
sch.requestSnapshot();
CHECK(sch.snapshotPending());
clock = 10;
sch.tick(); // first standstill seen; starts the settle clock
CHECK(cam.triggers == 0);
clock = 500;
sch.tick(); // still inside the settle wait
CHECK(cam.triggers == 0);
clock = 1100;
sch.tick(); // settle wait elapsed
CHECK(cam.triggers == 1);
CHECK_FALSE(sch.snapshotPending());
// One request, one image: further ticks must not keep firing.
clock = 5000;
sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("a snapshot waits out a slew rather than shooting mid-move") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 60000); // long frame budget: the point here is the settle
settleAt(motor, 0, 0);
motor.tel.yaw.standstill = false; // a jog is in flight
sch.requestSnapshot();
for (clock = 10; clock < 3000; clock += 500) sch.tick();
CHECK(cam.triggers == 0); // never fires while moving
motor.tel.yaw.standstill = true;
clock = 3100;
sch.tick(); // standstill seen, clock starts here
CHECK(cam.triggers == 0);
clock = 4200;
sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("a scan owns the camera: snapshots are refused and never queue up") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 0);
settleAt(motor, 0, 0);
sch.requestSnapshot();
sch.setCaptureActive(true);
// Arming is dropped, not deferred: firing it whenever the scan happens to end
// would attribute an image to whatever the gimbal was pointing at then.
CHECK_FALSE(sch.snapshotPending());
sch.requestSnapshot(); // asked for again mid-scan
clock = 1600;
sch.tick(); // the scan's own move, not a snapshot
sch.setCaptureActive(false);
clock = 3000;
sch.tick();
CHECK_FALSE(sch.snapshotPending());
}
TEST_CASE("the snapshot frame budget is measured from settling, not from the keypress") {
// Pressing Space mid-slew must not spend the camera's budget on the slew.
// Before this was fixed, a long jog could exhaust the timeout before the
// gimbal had even stopped, and the snapshot was abandoned having never asked
// the camera for anything.
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
settleAt(motor, 0, 0);
motor.tel.yaw.standstill = false; // a long slew is in flight
sch.requestSnapshot();
// Slew for far longer than the whole settle+timeout budget.
for (clock = 10; clock < 30000; clock += 500) sch.tick();
CHECK(sch.snapshotPending()); // still armed, not timed out
CHECK(cam.triggers == 0);
motor.tel.yaw.standstill = true;
clock = 30500;
sch.tick(); // standstill seen; the budget starts HERE
clock = 31600; // settle wait elapsed
sch.tick();
CHECK(cam.triggers == 1);
}
TEST_CASE("a snapshot that times out can simply be asked for again") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
FakeChannel chan;
ScanGrid grid({{0.0, 0.0}});
CaptureScheduler sch(motor, cam, chan, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(1000, 2000);
settleAt(motor, 0, 0);
cam.ok = false; // camera has no frame to give
sch.requestSnapshot();
for (clock = 10; clock < 6000; clock += 250) sch.tick();
CHECK_FALSE(sch.snapshotPending()); // gave up rather than staying armed forever
const int attempts = cam.triggers;
CHECK(attempts > 0); // it did keep retrying within the budget
// It must not keep trying after giving up.
for (clock = 6000; clock < 12000; clock += 250) sch.tick();
CHECK(cam.triggers == attempts);
// And the next press starts a fresh budget and succeeds.
cam.ok = true;
sch.requestSnapshot();
clock = 12500;
sch.tick();
clock = 13600;
sch.tick();
CHECK(cam.triggers == attempts + 1);
CHECK_FALSE(sch.snapshotPending());
}

View File

@ -29,8 +29,8 @@ TestProfile fastProfile() {
Geometry unitGeometry() { Geometry unitGeometry() {
Geometry g; Geometry g;
g.yaw.counts_per_deg = 1000; g.yaw.zero_count = 0; g.yaw.min_deg = -90; g.yaw.max_deg = 90; g.yaw.counts_per_deg = 1000; g.yaw.center_count = 0; g.yaw.min_deg = -90; g.yaw.max_deg = 90;
g.pitch.counts_per_deg = 1000; g.pitch.zero_count = 0; g.pitch.min_deg = 0; g.pitch.max_deg = 60; g.pitch.counts_per_deg = 1000; g.pitch.center_count = 0; g.pitch.min_deg = 0; g.pitch.max_deg = 60;
return g; return g;
} }