Merge counting the picture interval from the last picture and waiting for a slow camera's next frame

This commit is contained in:
pgdalmeida 2026-10-05 13:02:13 +02:00
commit 70a30132bf
Signed by: pedro.almeida
GPG Key ID: D4A6C394DF13F1D7
19 changed files with 693 additions and 278 deletions

View File

@ -130,8 +130,9 @@ flowchart TD
A([next waypoint]) --> B["move yaw and pitch<br/>to the waypoint"] A([next waypoint]) --> B["move yaw and pitch<br/>to the waypoint"]
B --> C{"both axes<br/>stopped?"} B --> C{"both axes<br/>stopped?"}
C -- no --> C C -- no --> C
C -- yes --> D["hold still for settle_wait_ms<br/>(1 s by day, 2.5 s at night)"] C -- yes --> D["hold still for settle_wait_ms<br/>(and, with settle_rule = brightness,<br/>until the brightness has settled)"]
D --> E["keep the newest frame<br/>the camera delivered"] D --> W["wait out what is left of<br/>image_interval since the last picture"]
W --> E["keep the newest frame<br/>the camera delivered"]
E --> F["encode to JPEG XL<br/>save to the output folder"] E --> F["encode to JPEG XL<br/>save to the output folder"]
F --> G["publish a CamEvent<br/>(camera, heading, pitch, time)"] F --> G["publish a CamEvent<br/>(camera, heading, pitch, time)"]
G --> A G --> A

View File

@ -14,7 +14,7 @@
[General] [General]
; Tower identity, substituted into all MQTT topics and CamEvent payloads. ; Tower identity, substituted into all MQTT topics and CamEvent payloads.
tower_name = ExampleTower tower_name = ExampleTower
; Seconds between captures. ; Desired seconds from one picture to the next (decimals allowed, e.g. 2.5).
image_interval = 5 image_interval = 5
; 1 = print motor telemetry each loop tick, 0 = quiet. ; 1 = print motor telemetry each loop tick, 0 = quiet.
debug = 0 debug = 0

View File

@ -79,8 +79,10 @@ corrupted.
pan's pitch, converted to counts via `Geometry`. pan's pitch, converted to counts via `Geometry`.
- manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the - manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the
move and wait only for standstill. move and wait only for standstill.
- Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest - Once **both axes report standstill at the target**, wait `settle_wait_ms` (and, with
frame and advance the grid or pan position. `settle_rule = brightness`, until the brightness has settled), then whatever is left of
`image_interval` since the last frame kept; keep the newest frame and move straight on to the next
grid or pan position - unless saving is behind, when the scan holds until it catches up.
5. **Acquisition** — the camera free-runs at the active profile's `AcquisitionFrameRate` with its own 5. **Acquisition** — the camera free-runs at the active profile's `AcquisitionFrameRate` with its own
auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image
capture* below. capture* below.
@ -94,20 +96,29 @@ corrupted.
## Capture state machine ## Capture state machine
``` ```
interval elapsed AND mode auto or pan AND not already moving mode auto or pan AND not already moving (straight after the last frame)
AND room to save (else hold here until saving catches up)
│ │
▼ ▼
┌──────────────────────┐ auto → next ScanGrid (yaw,pitch) ┌──────────────────────┐ auto → next ScanGrid (yaw,pitch); pan → next pan heading
│ MOVE <yaw>,<pitch> │ pan → next pan heading, at the pan's pitch │ MOVE <yaw>,<pitch> │ unreachable targets skipped in the same tick
│ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving, reset timer │ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving
└──────────┬───────────┘ └──────────┬───────────┘
│ (both axes standstill AND |xenc − target| ≤ tol) │ (both axes standstill AND |xenc − target| ≤ tol)
▼ ▼
┌──────────────────────┐ the camera keeps streaming throughout; this pause is ┌──────────────────────┐ the camera keeps streaming throughout; this pause is
│ wait settle_wait_ms │ what makes the kept frame one exposed at rest │ wait settle_wait_ms, │ what makes the kept frame one exposed at rest.
│ then the keep rule │ settle_rule = brightness: until brightness is in band
│ if on │ and steady, at most frame_timeout_ms (then keep, warn)
└──────────┬───────────┘ move + wait > interval: warn (not on the return to the
│ first waypoint, nor the first move after a mode starts)
▼
┌──────────────────────┐
│ wait out the rest of │ interval counted from the last frame kept
│ image_interval │
└──────────┬───────────┘ └──────────┬───────────┘
▼ ▼
┌──────────────────────┐ on success: clear moving, advance the grid / pan position ┌──────────────────────┐ on success: clear moving, advance, restart the interval
│ camera.trigger() │ no fresh frame: retry next tick │ camera.trigger() │ no fresh frame: retry next tick
└──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance └──────────────────────┘ still none after frame_timeout_ms: warn, skip, advance
``` ```
@ -132,9 +143,11 @@ At each waypoint the scheduler therefore:
1. moves, and waits for both axes to report standstill at the target; 1. moves, and waits for both axes to report standstill at the target;
2. waits a further `settle_wait_ms` (1 s in `standard`, 2.5 s in `night`); 2. waits a further `settle_wait_ms` (1 s in `standard`, 2.5 s in `night`);
3. keeps the newest complete frame - or, with `settle_rule = brightness`, the first 3. with `settle_rule = brightness`, waits on until brightness is in the camera's
frame once brightness is in the camera's target band and steady for 3 frames target band and steady for 3 frames
([configuration.md](configuration.md#which-frame-a-waypoint-keeps-settle_rule)). ([configuration.md](configuration.md#which-frame-a-waypoint-keeps-settle_rule));
4. waits out what is left of `image_interval`, counted from the last frame kept, and
keeps the newest complete frame.
Saving runs in background workers and never blocks this loop; if more than Saving runs in background workers and never blocks this loop; if more than
`save_queue_max` frames are waiting, the scan holds at the waypoint until there is `save_queue_max` frames are waiting, the scan holds at the waypoint until there is
@ -146,8 +159,11 @@ slew blur) and after the auto-exposure has seen this waypoint's light for severa
floor on a profile's frame rate: a profile must produce comfortably more than one frame inside its own settle wait, floor on a profile's frame rate: a profile must produce comfortably more than one frame inside its own settle wait,
which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s. which is why `night` at 1 Hz waits 2.5 s where `standard` at 10 Hz waits 1 s.
If no fresh frame has arrived by `frame_timeout_ms` past the wait, the waypoint is logged and skipped. A If no fresh frame arrives within `frame_timeout_ms` of the frame falling due, the waypoint is logged and
stalled camera costs single images, never the whole sweep. skipped. A stalled camera costs single images, never the whole sweep. A merely slow camera is not stalled:
with multi-second exposures the next frame may not be due yet, so the stream is only restarted once nothing
newer has come for twice the longer of the exposure and the frame period, plus a second (at least 2 s;
`CameraStall.h`). Restarting earlier throws the exposure in progress away.
### Camera profiles ### Camera profiles

View File

@ -27,7 +27,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| Section | Key | Type | Default | Meaning | | Section | Key | Type | Default | Meaning |
|---------|-----|------|---------|---------| |---------|-----|------|---------|---------|
| `General` | `tower_name` | string | `Unnamed` | Tower identity; used in all MQTT topics/payloads | | `General` | `tower_name` | string | `Unnamed` | Tower identity; used in all MQTT topics/payloads |
| `General` | `image_interval` | int > 0 | `5` | Seconds between captures (→ `image_rate = 1/interval`) | | `General` | `image_interval` | number > 0 | `5` | Desired seconds from one picture to the next, decimals allowed (→ `image_rate = 1/interval`). Counted from the last frame kept; the move and the exposure wait come out of it. A waypoint whose move + wait outlasts it warns (not the return to the first waypoint, nor the first move after a mode starts) |
| `General` | `debug` | bool | `false` | Start with debug-level logging | | `General` | `debug` | bool | `false` | Start with debug-level logging |
| `Startup` | `home` | bool | `false` | Home the gimbal at every start (see [Startup](#startup--homing-and-the-start-mode)). A tower in service needs `true`, and then homes **unattended** at every boot | | `Startup` | `home` | bool | `false` | Home the gimbal at every start (see [Startup](#startup--homing-and-the-start-mode)). A tower in service needs `true`, and then homes **unattended** at every boot |
| `Startup` | `mode` | `manual`/`auto` | `manual` | Mode the tower starts in. `auto` is refused, leaving it in manual, if the gimbal is not homed by then. `pan` is by command only | | `Startup` | `mode` | `manual`/`auto` | `manual` | Mode the tower starts in. `auto` is refused, leaving it in manual, if the gimbal is not homed by then. `pan` is by command only |
@ -44,7 +44,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config
| `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing | | `Camera.<name>` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing |
| `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images | | `Camera.<name>` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images |
| `Camera.<name>.<profile>` | `settle_wait_ms` | int ≥ 0 | `1000` | Hold after standstill before keeping a frame | | `Camera.<name>.<profile>` | `settle_wait_ms` | int ≥ 0 | `1000` | Hold after standstill before keeping a frame |
| `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | Longest wait after `settle_wait_ms`; then keep the newest frame, or skip the waypoint if none | | `Camera.<name>.<profile>` | `frame_timeout_ms` | int ≥ 0 | `2000` | With `settle_rule = brightness`, the longest wait for the brightness after `settle_wait_ms`; then keep the newest frame anyway. Also how long to wait for a frame once one is due before skipping the waypoint |
| `Camera.<name>.<profile>` | `settle_rule` | `fixed` \| `brightness` | `fixed` | `fixed`: keep the newest frame once `settle_wait_ms` has passed. `brightness`: keep the first frame whose brightness is in the camera's target band and steady for 3 frames ([keep rule](#which-frame-a-waypoint-keeps-settle_rule)) | | `Camera.<name>.<profile>` | `settle_rule` | `fixed` \| `brightness` | `fixed` | `fixed`: keep the newest frame once `settle_wait_ms` has passed. `brightness`: keep the first frame whose brightness is in the camera's target band and steady for 3 frames ([keep rule](#which-frame-a-waypoint-keeps-settle_rule)) |
| `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block | | `Camera` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | `0.8` / `4` | Encoder defaults inherited by every camera block |
| `Camera.<name>` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings. Distance 0 is truly lossless | | `Camera.<name>` | `jxl_distance` / `jxl_effort` | double ≥ 0 / int 1..9 | the `[Camera]` value | Per-camera override of the encoder settings. Distance 0 is truly lossless |
@ -460,14 +460,18 @@ outside the band also counts when exposure and gain sit at the limits that would
move it (waiting cannot help). Past `frame_timeout_ms` the newest frame is kept move it (waiting cannot help). Past `frame_timeout_ms` the newest frame is kept
anyway, with a warning. On the 24 recorded exposure steps this picked a good frame anyway, with a warning. On the 24 recorded exposure steps this picked a good frame
21 times; "exposure and gain unchanged" never did, because auto-exposure pauses 21 times; "exposure and gain unchanged" never did, because auto-exposure pauses
mid-way. A camera that reports no frame data falls back to the fixed wait. mid-way. A camera that reports no frame data falls back to the fixed wait. The
rule applies to scan and pan waypoints; a snapshot or continuous capture keeps
the fixed wait. Its time counts towards `image_interval`, like the move's.
#### When saving falls behind #### When saving falls behind
Saving runs in background workers (`[Hardware] save_workers`, `jxl_threads`). If Saving runs in background workers (`[Hardware] save_workers`, `jxl_threads`). If
more than `save_queue_max` frames are waiting or being saved, the scan **holds at more than `save_queue_max` frames are waiting or being saved, the scan **holds at
its waypoint** until there is room (logged once), so no picture is lost and memory its waypoint** until there is room (logged once), so no picture is lost and memory
stays bounded. A requested snapshot is never held. Measured: the IOTA keeps up stays bounded. That waypoint does not also warn about running past
`image_interval`. Continuous capture holds the same way; a requested snapshot is
never held. Measured: the IOTA keeps up
with a frame every 0.7 s on one worker; the 3 Delta needs two. with a frame every 0.7 s on one worker; the 3 Delta needs two.
### `[Env]` — SHT41 ambient temperature/humidity sensor ### `[Env]` — SHT41 ambient temperature/humidity sensor
@ -692,7 +696,7 @@ Handled in `Application::Impl::handleCommand`.
| `trace all` / `trace off` | Enable every category / silence all | | `trace all` / `trace off` | Enable every category / silence all |
| `set camera profile [<camera>] <name>` | Switch a camera to another profile from its `profile_dir` (the camera may be omitted when only one is configured) | | `set camera profile [<camera>] <name>` | Switch a camera to another profile from its `profile_dir` (the camera may be omitted when only one is configured) |
| `snapshot` | One image now, through the scan's settle-and-trigger path (manual mode, homed) | | `snapshot` | One image now, through the scan's settle-and-trigger path (manual mode, homed) |
| `capture [on\|off]` | Continuous capture where the gimbal is aimed: one image per `image_interval` + standstill + `settle_wait_ms`, kept only at rest. Manual mode, homed; ends on any mode change, homing, heading setup or test. Bare: report | | `capture [on\|off]` | Continuous capture where the gimbal is aimed: one image per `image_interval` since the last, each once the gimbal has stood still for `settle_wait_ms`. Manual mode, homed; ends on any mode change, homing, heading setup or test. Bare: report |
| `camera reset` | Reboot the cameras and put them back on their profiles | | `camera reset` | Reboot the cameras and put them back on their profiles |
| `test camera [frames\|dark\|bright]` | Check the camera delivers frames and responds to exposure/gain | | `test camera [frames\|dark\|bright]` | Check the camera delivers frames and responds to exposure/gain |
| `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) | | `test [<subsystem> [<leaf>]] [<profile>]` | Run the hardware self-test suite (see below) |

View File

@ -29,7 +29,7 @@ doctest unit-test suite (`ctest`).
|---|-------|--------| |---|-------|--------|
| 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. | | 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. **2026-09-24:** tower 121 has still never imaged a scene - a frame grabbed directly and the program's own saved images (2026-09-21) are a smooth grey gradient with no edges, i.e. the lens is covered or grossly out of focus. Tower 111's images from the same afternoon show nothing sharp anywhere either (soft blobs, no edges) - defocus, a cover or a fogged window; unexplained. `test camera` (below) proves the camera and its data path work in both cases; it cannot, by design, judge the picture. **Stream stuck, 2026-09-24:** from 16:10 to 16:19 tower 111's camera delivered no frames at all - not to the program, not to a fresh Vimba session, not at a fixed 10 ms exposure - and it survived program restarts and a USB re-authorisation. The camera's own `DeviceReset` command cleared it. Production captured nothing for those 9 minutes and logged only `no frame yet (stream warming up)`: a session that has NEVER delivered is not restarted by `trigger()`, by design, so a stream stuck from its start stays stuck. It began just after a `test camera` run by an early version that stopped and restarted the stream several times in a few seconds (to write `BlackLevel`, which the camera refuses while streaming); whether that caused it or met 111's known USB stall is not established. `test camera` itself reported it correctly. **Answered the same day:** a stream that never delivers is now restarted once the wait exceeds what its exposure can explain (at least 30 s), and after two fruitless restarts the camera is reset (DeviceReset) and reopened on its profile - both by the program itself (`CameraStall.h`), and on demand with the console command `camera reset` (verified on tower 121: back in ~14 s, frames straight after). `test camera` no longer stops the stream at all: it changes only exposure and gain, which are writable live, and puts them back live. With that, 111 ran it inside the capturing program, passed, and kept capturing. **2026-09-30:** tower 121's camera is indoors, facing a window, still badly out of focus, gain at its 24 dB maximum ([camera-capture-measurements.md](camera-capture-measurements.md)). | | 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. **2026-09-24:** tower 121 has still never imaged a scene - a frame grabbed directly and the program's own saved images (2026-09-21) are a smooth grey gradient with no edges, i.e. the lens is covered or grossly out of focus. Tower 111's images from the same afternoon show nothing sharp anywhere either (soft blobs, no edges) - defocus, a cover or a fogged window; unexplained. `test camera` (below) proves the camera and its data path work in both cases; it cannot, by design, judge the picture. **Stream stuck, 2026-09-24:** from 16:10 to 16:19 tower 111's camera delivered no frames at all - not to the program, not to a fresh Vimba session, not at a fixed 10 ms exposure - and it survived program restarts and a USB re-authorisation. The camera's own `DeviceReset` command cleared it. Production captured nothing for those 9 minutes and logged only `no frame yet (stream warming up)`: a session that has NEVER delivered is not restarted by `trigger()`, by design, so a stream stuck from its start stays stuck. It began just after a `test camera` run by an early version that stopped and restarted the stream several times in a few seconds (to write `BlackLevel`, which the camera refuses while streaming); whether that caused it or met 111's known USB stall is not established. `test camera` itself reported it correctly. **Answered the same day:** a stream that never delivers is now restarted once the wait exceeds what its exposure can explain (at least 30 s), and after two fruitless restarts the camera is reset (DeviceReset) and reopened on its profile - both by the program itself (`CameraStall.h`), and on demand with the console command `camera reset` (verified on tower 121: back in ~14 s, frames straight after). `test camera` no longer stops the stream at all: it changes only exposure and gain, which are writable live, and puts them back live. With that, 111 ran it inside the capturing program, passed, and kept capturing. **2026-09-30:** tower 121's camera is indoors, facing a window, still badly out of focus, gain at its 24 dB maximum ([camera-capture-measurements.md](camera-capture-measurements.md)). **Slow, not stuck, 2026-10-04:** at dusk tower 111 ran multi-second exposures, one frame every 7-9 s, and the program restarted the stream whenever a picture fell due before the next frame had come - throwing that exposure away, so the next frame came later still. A stream that has delivered is now only restarted once nothing newer has come for twice the longer of the exposure and the frame period, plus a second (`CameraStall.h`); not yet checked on the tower. |
| 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. **Seen in real data 2026-09-24:** of eight consecutive images tower 111 kept at 8 deg/s, one was 98.6 % clipped white, one 57 % black and two about half clipped - frames kept before the continuous auto-exposure had caught up with the new view. **Measured 2026-09-30 (tower 121, still camera, forced ×16 brightness steps):** auto-exposure took 1.4–1.8 s to come within ±10% at 4.95 fps, so 1 s is too short for a large step; from a dark step it first overshoots to its maximum exposure, 3.4–4.1× too bright. Exposure and gain holding still for a few frames does not mean it has finished ([camera-capture-measurements.md](camera-capture-measurements.md)). **2026-10-05:** `settle_rule = brightness` keeps the frame once brightness is in the camera's band and steady (21 of 24 recorded steps right), and raw profiles double the frame rate it works with; to be confirmed with real slews ([raw12-pipeline-measurements.md](raw12-pipeline-measurements.md)). | | 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. **Seen in real data 2026-09-24:** of eight consecutive images tower 111 kept at 8 deg/s, one was 98.6 % clipped white, one 57 % black and two about half clipped - frames kept before the continuous auto-exposure had caught up with the new view. **Measured 2026-09-30 (tower 121, still camera, forced ×16 brightness steps):** auto-exposure took 1.4–1.8 s to come within ±10% at 4.95 fps, so 1 s is too short for a large step; from a dark step it first overshoots to its maximum exposure, 3.4–4.1× too bright. Exposure and gain holding still for a few frames does not mean it has finished ([camera-capture-measurements.md](camera-capture-measurements.md)). **2026-10-05:** `settle_rule = brightness` keeps the frame once brightness is in the camera's band and steady (21 of 24 recorded steps right), and raw profiles double the frame rate it works with; to be confirmed with real slews ([raw12-pipeline-measurements.md](raw12-pipeline-measurements.md)). |
| 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. **Solved on tower 121 by the setup kit** (`hardware/lattepanda-iota/preconditions/`): a systemd-tmpfiles drop-in for usbfs and two udev rules, all installed by `install.sh --preconditions`. Verified 2026-09-23 — `usbfs_memory_mb` reads 1000 after **6 days of uptime**, so it survived the boot, and both rules are in `/etc/udev/rules.d/`; and again across a deliberate reboot the same day, when `install-tower --verify` read `usbfs_memory_mb` 1000 and the camera port's `usb3_lpm_permit` 0 (a check `test hardware camera` now makes too, once `[Test] usb3_lpm_path` is set - the installer sets it). Note it is **tmpfiles, not the kernel cmdline** the original entry proposed. **On the Delta fleet**: tower 111 read 16 and `u1_u2` on 2026-09-23, and the Delta kit (`hardware/lattepanda-3-delta/`, the same two files) was applied the same day - 1000 and `0` afterwards, confirmed by `test hardware camera`. Confirmed across a reboot on 2026-09-24 (`install-tower --verify`). **Solved on both boards**; open only for Delta towers the kit has not reached. **2026-09-30:** with the kit applied, tower 121 streamed full resolution in every format at 250 and 450 MB/s for 60 s with nothing lost; 2 incomplete frames in 1,574 at 450 MB/s while encoding. **2026-10-01:** tower 111 (Delta kit applied) likewise, at 200, 250 and 450 MB/s, also while encoding, with nothing lost ([camera-capture-measurements.md](camera-capture-measurements.md)). | | 19 | **USB preconditions do not survive a reboot** | `usbcore.usbfs_memory_mb` reverts to 16 (needs 1000) and USB3 LPM re-enables on the camera's port. **Both were wrong on the LattePanda when tested on 2026-08-07**, and frame delivery stalls without them regardless of application code. **Solved on tower 121 by the setup kit** (`hardware/lattepanda-iota/preconditions/`): a systemd-tmpfiles drop-in for usbfs and two udev rules, all installed by `install.sh --preconditions`. Verified 2026-09-23 — `usbfs_memory_mb` reads 1000 after **6 days of uptime**, so it survived the boot, and both rules are in `/etc/udev/rules.d/`; and again across a deliberate reboot the same day, when `install-tower --verify` read `usbfs_memory_mb` 1000 and the camera port's `usb3_lpm_permit` 0 (a check `test hardware camera` now makes too, once `[Test] usb3_lpm_path` is set - the installer sets it). Note it is **tmpfiles, not the kernel cmdline** the original entry proposed. **On the Delta fleet**: tower 111 read 16 and `u1_u2` on 2026-09-23, and the Delta kit (`hardware/lattepanda-3-delta/`, the same two files) was applied the same day - 1000 and `0` afterwards, confirmed by `test hardware camera`. Confirmed across a reboot on 2026-09-24 (`install-tower --verify`). **Solved on both boards**; open only for Delta towers the kit has not reached. **2026-09-30:** with the kit applied, tower 121 streamed full resolution in every format at 250 and 450 MB/s for 60 s with nothing lost; 2 incomplete frames in 1,574 at 450 MB/s while encoding. **2026-10-01:** tower 111 (Delta kit applied) likewise, at 200, 250 and 450 MB/s, also while encoding, with nothing lost ([camera-capture-measurements.md](camera-capture-measurements.md)). |
| 20 | **`jxl_distance = 0` is not lossless** | `JpegXlEncoder::encodeToFile` always sets `uses_original_profile = false`; for distance 0 libjxl prints `Set uses_original_profile=true for lossless encoding`, still reports success, and encodes through its lossy colour space. Measured 2026-09-30: decoded pixels differ by up to 1 (raw mosaic) or 2 (RGB8) levels in 1.5% / 7.3% of values ([camera-capture-measurements.md](camera-capture-measurements.md)). **Fixed 2026-10-05:** the encoder sets `uses_original_profile` for distance 0, and `tests/test_jpegxl.cpp` checks 8-bit and 12-bit lossless decode exactly; 8-bit lossy output is byte-identical to before (72 of 72 compared). | | 20 | **`jxl_distance = 0` is not lossless** | `JpegXlEncoder::encodeToFile` always sets `uses_original_profile = false`; for distance 0 libjxl prints `Set uses_original_profile=true for lossless encoding`, still reports success, and encodes through its lossy colour space. Measured 2026-09-30: decoded pixels differ by up to 1 (raw mosaic) or 2 (RGB8) levels in 1.5% / 7.3% of values ([camera-capture-measurements.md](camera-capture-measurements.md)). **Fixed 2026-10-05:** the encoder sets `uses_original_profile` for distance 0, and `tests/test_jpegxl.cpp` checks 8-bit and 12-bit lossless decode exactly; 8-bit lossy output is byte-identical to before (72 of 72 compared). |

View File

@ -34,7 +34,10 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [include/fgc/ChannelTopics.h](../include/fgc/ChannelTopics.h), [src/core/ChannelTopics.cpp](../src/core/ChannelTopics.cpp) | The MQTT wire format in one place: topic builders for the `GGS/FWT/<tower>/…` tree and the `CamEvent`/status payload builders. Pure, so the documented format is unit-tested rather than assembled inline at each publish site | | [include/fgc/ChannelTopics.h](../include/fgc/ChannelTopics.h), [src/core/ChannelTopics.cpp](../src/core/ChannelTopics.cpp) | The MQTT wire format in one place: topic builders for the `GGS/FWT/<tower>/…` tree and the `CamEvent`/status payload builders. Pure, so the documented format is unit-tested rather than assembled inline at each publish site |
| [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Connectivity view so a publish can be confirmed without a broker-side subscriber | | [include/fgc/ChannelHistory.h](../include/fgc/ChannelHistory.h), [src/core/ChannelHistory.cpp](../src/core/ChannelHistory.cpp) | Ring buffer of recently published messages (time, topic, payload, outcome) plus the delivery counters — sent, failed, connects, losses, last status. Feeds the expanded Connectivity view so a publish can be confirmed without a broker-side subscriber |
| [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash | | [include/fgc/Hash.h](../include/fgc/Hash.h), [src/core/Hash.cpp](../src/core/Hash.cpp) | FNV-1a 64 over a string or a file, with an algorithm-prefixed label. Change detection for the profile records, deliberately not a crypto hash |
| [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces; injectable clock; `setGeometry` adopts a recalibration | | [include/fgc/CaptureScheduler.h](../include/fgc/CaptureScheduler.h), [src/core/CaptureScheduler.cpp](../src/core/CaptureScheduler.cpp) | Capture state machine over the interfaces: move, standstill, settle wait (+ `SettleRule`), the rest of the interval since the last frame, keep; holds while saving is behind; injectable clock; `setGeometry` adopts a recalibration |
| [include/fgc/SettleRule.h](../include/fgc/SettleRule.h), [src/core/SettleRule.cpp](../src/core/SettleRule.cpp) | The `settle_rule = brightness` keep rule, pure: fed one frame's brightness, exposure and gain at a time, says when the image has settled (in the camera's target band and steady for 3 frames) or is pinned at the auto-exposure limits |
| [include/fgc/RawImage.h](../include/fgc/RawImage.h), [src/core/RawImage.cpp](../src/core/RawImage.cpp) | Pixel layouts the program accepts (`Mono8`, `RGB8`, `BayerRG8`, `BayerRG12p`), their sizes and bit depths, `unpack12p`, and `sampleBrightness` - the cheap per-frame sample the keep rule reads |
| [include/fgc/CameraStall.h](../include/fgc/CameraStall.h), [src/core/CameraStall.cpp](../src/core/CameraStall.cpp) | When a camera stream delivers nothing new: wait, restart the stream, or reset the camera. Pure policy, scaled by the exposure and frame period so a slow frame is not taken for a stall |
| [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` | | [include/fgc/Application.h](../include/fgc/Application.h), [src/core/Application.cpp](../src/core/Application.cpp) | Factory (real vs mock, headless vs TUI), wiring, control loop, `gimbal …` commands, background-result polling, `buildSnapshot()` |
| [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) | | [include/fgc/ui/UiSnapshot.h](../include/fgc/ui/UiSnapshot.h), [src/ui/UiSnapshot.cpp](../src/ui/UiSnapshot.cpp) | Plain-data view model (incl. `ImuView`, `EnvView`, `ActivityView`, `CalibResultView`, `DumpView`) + pure formatting helpers (state label/colour, degrees, time-ago, pending-sensors) |
| [include/fgc/ui/HeadlessUi.h](../include/fgc/ui/HeadlessUi.h), [src/ui/HeadlessUi.cpp](../src/ui/HeadlessUi.cpp) | Default line console: stdin → command sink; logs via the default stdout/stderr writer | | [include/fgc/ui/HeadlessUi.h](../include/fgc/ui/HeadlessUi.h), [src/ui/HeadlessUi.cpp](../src/ui/HeadlessUi.cpp) | Default line console: stdin → command sink; logs via the default stdout/stderr writer |
@ -62,8 +65,8 @@ Per-file reference for the refactored tree, plus the shared data structures.
| [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` | | [src/mqtt/MqttControlChannel.cpp](../src/mqtt/MqttControlChannel.cpp) | `IControlChannel` over Eclipse Paho | `WITH_MQTT` |
| [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` | | [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp) | `IUserInterface` over FTXUI: panels, key bar, log pane | `WITH_TUI` |
| [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` | | [src/camera/VimbaCameraSource.cpp](../src/camera/VimbaCameraSource.cpp) | `ICameraSource` over Vimba X (pImpl): free-running stream + keep-latest, restart-on-stall, and per-camera profiles applied with the SDK's `LoadSettings` on the Viewer's settings XML | `WITH_VIMBA` |
| [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | always | | [src/camera/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file, 8-bit or up to 16-bit values (a 12-bit frame keeps its depth); distance 0 is lossless | always |
| [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → rotate → encode → write → CamEvent (worker thread). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always | | [src/camera/ImagePipeline.cpp](../src/camera/ImagePipeline.cpp) | frame → de-Bayer (raw profiles) → rotate → encode → write → CamEvent (`save_workers` background workers; `canAccept()` is the scan's save gate). The pose is sampled in `submit()` and carried with the frame, so it belongs to the pixels rather than to whenever the encode finished | always |
## Mock / null implementations ## Mock / null implementations

View File

@ -46,7 +46,7 @@ edges included: 75, 81, 87, 93, 99, 105 - six pictures. A span that does not
divide evenly gets one more picture, never a gap (32° → seven, 5.33° apart). A divide evenly gets one more picture, never a gap (32° → seven, 5.33° apart). A
span under one picture is the one heading, photographed once per loop. Pitch is span under one picture is the one heading, photographed once per loop. Pitch is
optional and defaults to 0 (level); a yaw-only tower ignores it, with a warning. Pictures are taken at the same pace as auto optional and defaults to 0 (level); a yaw-only tower ignores it, with a warning. Pictures are taken at the same pace as auto
(`image_interval`), and each publishes a CamEvent exactly as a sweep picture (one per `image_interval`, counted from the last picture), and each publishes a CamEvent exactly as a sweep picture
does. does.
**Headings are true bearings and can be out of reach.** Yaw travel is under a **Headings are true bearings and can be out of reach.** Yaw travel is under a

View File

@ -28,6 +28,18 @@ long long firstFrameLimitMs(double exposure_us);
// `failed_restarts` counts restarts since the last frame that arrived. // `failed_restarts` counts restarts since the last frame that arrived.
StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_restarts); StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_restarts);
// How long a stream that has delivered may go without a newer frame before it
// counts as stalled: twice the longer of the exposure and the frame period, plus
// a second, and never under 2 s. A slow frame is not a stall - tower 111 runs
// multi-second exposures, one frame every 7-9 s, and restarting the stream the
// moment a capture fell due before the next frame threw that exposure away, so
// the next frame came later still (2026-10-04).
long long staleFrameLimitMs(double exposure_us, double frame_period_ms);
// No frame newer than the last one kept, the newest having arrived
// `since_newest_ms` ago: wait for the next one, or restart the stream.
StallAction onStaleFrame(long long since_newest_ms, double exposure_us, double frame_period_ms);
// A stream that delivered before but has gone stale, and a restart has just // A stream that delivered before but has gone stale, and a restart has just
// failed to bring a fresh frame. Restart again, or reset once restarts have // failed to bring a fresh frame. Restart again, or reset once restarts have
// stopped working. // stopped working.

View File

@ -18,13 +18,22 @@ namespace fgc {
// //
// Each tick(): // Each tick():
// 1. reads motor telemetry (per-axis encoder counts + state), // 1. reads motor telemetry (per-axis encoder counts + state),
// 2. runs the capture cycle as a move -> settle -> wait -> keep machine: when // 2. runs the capture cycle. The interval is the time from one kept frame to
// the interval elapses it issues an absolute `MOVE <yaw>,<pitch>` to the next // the next: as soon as a frame is kept it issues an absolute
// target, waits until both axes report standstill at that target, waits a // `MOVE <yaw>,<pitch>` to the next target, waits until both axes report
// further settle_wait_ms so the free-running camera has exposed a frame with // standstill there, waits for the exposure to settle, then waits out what
// the gimbal at rest, then keeps the newest frame. If none is ready it // is left of the interval and keeps the newest frame. If none is ready it
// retries each tick, and gives up on the waypoint after frame_timeout_ms. // retries each tick, and gives up on the waypoint after frame_timeout_ms.
// //
// The exposure wait is settle_wait_ms; with the keep rule on (setSettleRule)
// a scan waypoint then also waits until the camera's frames say the image
// has settled (SettleRule), at most frame_timeout_ms more - after which the
// newest frame is kept anyway, with a warning.
//
// When the move and the exposure wait alone take longer than the interval,
// that waypoint warns - except the return from the last waypoint to the
// first, and the first move after a mode starts, which are long by nature.
//
// What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front // What it captures is the mode (OperatingMode.h): auto sweeps the ScanGrid front
// to back, repeating; pan walks panHeadings() left to right at the pan's pitch, // to back, repeating; pan walks panHeadings() left to right at the pan's pitch,
// then jumps back to the left edge; manual captures nothing, bar a snapshot or // then jumps back to the left edge; manual captures nothing, bar a snapshot or
@ -60,9 +69,10 @@ public:
bool snapshotPending() const { return snapshot_pending_; } bool snapshotPending() const { return snapshot_pending_; }
// Continuous capture (`capture on|off`): in manual, keep a frame on the scan's // Continuous capture (`capture on|off`): in manual, keep a frame on the scan's
// own cycle - the interval, then standstill + settle wait - wherever the // own cycle - one per interval since the last, once standstill + the exposure
// operator has aimed. The gimbal stays free to nudge; a frame is only kept at // wait are done - wherever the operator has aimed. The gimbal stays free to
// rest. Ignored outside manual, and cleared by any setMode(). // nudge; a frame is only kept at rest. No overrun warnings: the operator may be
// jogging. Ignored outside manual, and cleared by any setMode().
void setContinuous(bool on); void setContinuous(bool on);
bool continuous() const { return continuous_; } bool continuous() const { return continuous_; }
@ -78,8 +88,8 @@ public:
bool holdingForSave() const { return holding_; } bool holdingForSave() const { return holding_; }
// Per-camera-profile timing (see CameraProfile): how long to wait after standstill // Per-camera-profile timing (see CameraProfile): how long to wait after standstill
// before keeping a frame, and how long to keep retrying before abandoning the // for the exposure to settle, and how long to keep retrying before abandoning
// waypoint. Called on the control thread, same as tick(). // the waypoint. Called on the control thread, same as tick().
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_; }
@ -103,22 +113,50 @@ public:
long yawTargetCounts() const { return yaw_target_; } long yawTargetCounts() const { return yaw_target_; }
long pitchTargetCounts() const { return pitch_target_; } long pitchTargetCounts() const { return pitch_target_; }
bool moving() const { return moving_; } bool moving() const { return moving_; }
// Where the current waypoint is: "moving", "settling" (exposure) or "waiting"
// (for the rest of the interval). For the status line.
const char* phase() const;
// Waypoints that warned for running past the interval since construction.
int overruns() const { return overruns_; }
private: private:
// One frame's way to the camera, from standstill: when the gimbal came to
// rest, when the exposure wait ended, when the trigger fell due (0 = not yet).
struct Hold {
long long settled_ms = 0;
long long ready_ms = 0;
long long due_ms = 0;
};
enum class Shot { Waiting, Captured, TimedOut };
long long elapsedMs() const; long long elapsedMs() const;
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;
bool startNextMove(); // pick the next target and MOVE; false if none
void finishWaypoint();
std::string waypointLabel() const; // "waypoint 3/15" / "pan position 2/6"
void serviceSnapshot(const MotorTelemetry& t); void serviceSnapshot(const MotorTelemetry& t);
void serviceContinuous(const MotorTelemetry& t); void serviceContinuous(const MotorTelemetry& t);
// The part a snapshot and a continuous-capture frame share: wait for the // Standstill seen: begin the exposure wait.
// gimbal to stand still, then the settle wait, then trigger until the frame void beginHold(Hold& h);
// budget runs out. `settled_ms` is that request's own settle clock. // Whether the exposure wait that began at h.settled_ms is over (sets
enum class Shot { Waiting, Captured, TimedOut }; // h.ready_ms): settle_wait_ms, then with `keep_rule` the keep rule too.
Shot shootAtRest(const MotorTelemetry& t, long long& settled_ms); bool dwellOver(Hold& h, bool keep_rule);
// The keep rule, fed the frames since it last looked: true once it has
// decided - settled, at the exposure limits, timed out (with a warning), or
// no frame data to judge.
bool ruleDecided(long long since_settled);
// The save gate, with one log line when a hold starts and one when it ends. // The save gate, with one log line when a hold starts and one when it ends.
bool roomToSave(); bool roomToSave();
// Trigger until a frame comes or frame_timeout_ms passes since it fell due.
Shot fire(Hold& h);
// The part a snapshot and a continuous-capture frame share: wait for the
// gimbal to stand still, then the exposure wait, then (continuous only) the
// rest of the interval, then trigger until the frame budget runs out.
Shot shootAtRest(const MotorTelemetry& t, Hold& h, bool wait_interval);
IMotorController& motor_; IMotorController& motor_;
ICameraSource& camera_; ICameraSource& camera_;
@ -129,7 +167,8 @@ private:
std::function<bool()> can_save_; std::function<bool()> can_save_;
bool holding_ = false; bool holding_ = false;
double image_rate_; double image_rate_;
long long timer_start_ = 0; double interval_ms_; // 1000 / image_rate_; 0 = never
long long timer_start_ = 0; // the last frame kept or given up, or the mode start
Mode mode_ = Mode::Manual; Mode mode_ = Mode::Manual;
PanSpec pan_; PanSpec pan_;
@ -137,12 +176,17 @@ private:
std::vector<double> pan_headings_; std::vector<double> pan_headings_;
size_t pan_index_ = 0; size_t pan_index_ = 0;
bool moving_ = false; // a MOVE has been issued, its frame not yet kept
Hold hold_; // the current waypoint's frame
long long move_sent_ms_ = 0;
bool first_move_ = true; // no move yet since setMode()
bool quiet_overrun_ = false; // this waypoint is a long move by nature
int overruns_ = 0;
bool save_held_ = false; // the next move waited for saving to catch up
bool use_rule_ = false; // keep by SettleRule, not after a fixed wait bool use_rule_ = false; // keep by SettleRule, not after a fixed wait
SettleRule rule_; SettleRule rule_;
bool rule_started_ = false; // this waypoint's rule has its first frame number bool rule_started_ = false; // this waypoint's rule has its first frame number
uint64_t rule_seq_ = 0; // last frame fed to the rule uint64_t rule_seq_ = 0; // last frame fed to the rule
bool moving_ = false; // a MOVE has been issued, awaiting settle
long long settled_at_ms_ = 0; // when standstill was first seen (0 = not yet)
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)
@ -152,9 +196,9 @@ private:
bool pan_unreachable_warned_ = false; bool pan_unreachable_warned_ = false;
bool snapshot_pending_ = false; // a manual snapshot is armed bool snapshot_pending_ = false; // a manual snapshot is armed
long long snapshot_settled_ms_ = 0; // when standstill was first seen (0 = not yet) Hold snapshot_hold_;
bool continuous_ = false; // `capture on` bool continuous_ = false; // `capture on`
long long continuous_settled_ms_ = 0; Hold continuous_hold_;
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

@ -16,7 +16,7 @@ namespace fgc {
struct GeneralConfig { struct GeneralConfig {
std::string tower_name = "Unnamed"; std::string tower_name = "Unnamed";
int image_interval = 5; // seconds between captures double image_interval = 5; // desired seconds from one picture to the next
bool debug = false; // print motor telemetry each loop tick bool debug = false; // print motor telemetry each loop tick
}; };

View File

@ -188,12 +188,16 @@ private:
bool has_ = false; bool has_ = false;
}; };
// Read a float/string feature, returning `fallback`/"" if absent (tolerant, for telemetry). // Read a numeric/string feature, returning `fallback`/"" if absent (tolerant, for
// telemetry). Numeric takes an integer feature too: some are Int on one model and
// Float on another (IntensityControllerTolerance is Int on the Alvium 1800 U-1240c).
double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) { double getFloat(const CameraPtr& cam, const char* name, double fallback = 0.0) {
FeaturePtr f; FeaturePtr f;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) != VmbErrorSuccess) return fallback;
double v = fallback; double v = fallback;
if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess) if (f->GetValue(v) == VmbErrorSuccess) return v;
return v; VmbInt64_t i = 0;
if (f->GetValue(i) == VmbErrorSuccess) return static_cast<double>(i);
return fallback; return fallback;
} }
std::string getString(const CameraPtr& cam, const char* name) { std::string getString(const CameraPtr& cam, const char* name) {
@ -298,6 +302,15 @@ struct VimbaCameraSource::Impl {
for (auto& cam : cameras) e = std::max(e, getFloat(cam, "ExposureTime")); for (auto& cam : cameras) e = std::max(e, getFloat(cam, "ExposureTime"));
return e; return e;
} }
// Longest frame period any camera runs at (the rate it reports), 0 if unknown.
double slowestFramePeriodMs() {
double p = 0;
for (auto& cam : cameras) {
const double fps = getFloat(cam, "AcquisitionFrameRate");
if (fps > 0) p = std::max(p, 1000.0 / fps);
}
return p;
}
// Reboot one camera, reopen it by id and re-apply its profile. See // Reboot one camera, reopen it by id and re-apply its profile. See
// CameraStall.h for why this exists. // CameraStall.h for why this exists.
@ -515,7 +528,15 @@ bool VimbaCameraSource::trigger() {
} }
if (newest <= impl_->last_captured_ts) { if (newest <= impl_->last_captured_ts) {
LOG_TRACE_CAT(LogCat::Camera) << "stream stale; restarting acquisition"; // Nothing newer than the last frame kept - but with long exposures the
// next one may simply not be due yet. Restarting now would throw away the
// exposure in progress, so wait while that is still plausible.
const long long since_newest = nowMs() - newest;
if (onStaleFrame(since_newest, impl_->longestExposureUs(),
impl_->slowestFramePeriodMs()) == StallAction::Wait)
return false;
LOG_TRACE_CAT(LogCat::Camera) << "stream stale (" << since_newest
<< " ms since the newest frame); restarting acquisition";
for (auto& s : impl_->stats) s->restarts.fetch_add(1); for (auto& s : impl_->stats) s->restarts.fetch_add(1);
try { try {
impl_->stopAcquisition(); impl_->stopAcquisition();

View File

@ -1041,7 +1041,7 @@ struct Application::Impl {
a.status = "finding endstops…"; a.status = "finding endstops…";
} else if (scheduler && scheduler->captureActive()) { } else if (scheduler && scheduler->captureActive()) {
a.active = true; a.active = true;
const char* phase = scheduler->moving() ? "moving" : "dwell"; const char* phase = scheduler->phase();
if (scheduler->mode() == Mode::Pan) { if (scheduler->mode() == Mode::Pan) {
a.title = "PAN"; a.title = "PAN";
const auto& h = scheduler->panHeadingsNow(); const auto& h = scheduler->panHeadingsNow();
@ -2098,10 +2098,9 @@ struct Application::Impl {
scheduler->setContinuous(true); scheduler->setContinuous(true);
const double interval_s = const double interval_s =
scheduler->imageRate() > 0.0 ? 1.0 / scheduler->imageRate() : 0.0; scheduler->imageRate() > 0.0 ? 1.0 / scheduler->imageRate() : 0.0;
LOG_INFO << "continuous capture on: one image every ~" LOG_INFO << "continuous capture on: one image every " << interval_s
<< interval_s + scheduler->settleWaitMs() / 1000.0 << " s (" << " s, each once the gimbal has stood still for "
<< interval_s << " s interval + " << scheduler->settleWaitMs() << scheduler->settleWaitMs() << " ms";
<< " ms settle), kept only while the gimbal stands still";
} }
// End continuous capture because something else is taking the gimbal or the // End continuous capture because something else is taking the gimbal or the

View File

@ -14,6 +14,17 @@ StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_r
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart; return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
} }
long long staleFrameLimitMs(double exposure_us, double frame_period_ms) {
const double exposure_ms = exposure_us > 0 ? exposure_us / 1000 : 0;
const double slowest = std::max(exposure_ms, frame_period_ms > 0 ? frame_period_ms : 0);
return std::max<long long>(2000, static_cast<long long>(2 * slowest) + 1000);
}
StallAction onStaleFrame(long long since_newest_ms, double exposure_us, double frame_period_ms) {
return since_newest_ms < staleFrameLimitMs(exposure_us, frame_period_ms) ? StallAction::Wait
: StallAction::Restart;
}
StallAction onRestartFailed(int failed_restarts) { StallAction onRestartFailed(int failed_restarts) {
return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart; return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart;
} }

View File

@ -6,7 +6,9 @@
#include <chrono> #include <chrono>
#include <cstdint> #include <cstdint>
#include <cstdlib> #include <cstdlib>
#include <iomanip>
#include <optional> #include <optional>
#include <sstream>
namespace fgc { namespace fgc {
@ -22,6 +24,13 @@ long long steadyNowMs() {
// deadband (~200 counts). // deadband (~200 counts).
constexpr long kSettleTolCounts = 600; constexpr long kSettleTolCounts = 600;
// Seconds with one decimal, for the overrun warning.
std::string seconds(double ms) {
std::ostringstream o;
o << std::fixed << std::setprecision(1) << ms / 1000.0 << " s";
return o.str();
}
} // namespace } // namespace
CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera, CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera,
@ -32,7 +41,8 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer
geometry_(geometry), geometry_(geometry),
grid_(grid), grid_(grid),
now_ms_(now_ms ? std::move(now_ms) : steadyNowMs), now_ms_(now_ms ? std::move(now_ms) : steadyNowMs),
image_rate_(image_rate) { image_rate_(image_rate),
interval_ms_(image_rate > 0.0 ? 1000.0 / image_rate : 0.0) {
timer_start_ = now_ms_(); timer_start_ = now_ms_();
} }
@ -43,7 +53,10 @@ void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
pan_index_ = 0; pan_index_ = 0;
pan_unreachable_warned_ = false; pan_unreachable_warned_ = false;
moving_ = false; // restart the move/settle cycle cleanly moving_ = false; // restart the move/settle cycle cleanly
settled_at_ms_ = 0; hold_ = {};
// The first move goes from wherever the gimbal is, so it may be long: no
// overrun warning for it.
first_move_ = true;
rule_started_ = false; rule_started_ = false;
// Drop any armed snapshot: a scan starting means the operator moved on, and // 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 // keeping it would fire an image at some unrelated later moment when the scan
@ -52,13 +65,14 @@ void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) {
// Continuous capture is a manual-mode activity: any mode change ends it, so // Continuous capture is a manual-mode activity: any mode change ends it, so
// a later return to manual never resumes shooting nobody asked for. // a later return to manual never resumes shooting nobody asked for.
continuous_ = false; continuous_ = false;
continuous_settled_ms_ = 0; continuous_hold_ = {};
// The first frame comes no sooner than one interval after the mode starts.
resetTimer(); resetTimer();
} }
void CaptureScheduler::setContinuous(bool on) { void CaptureScheduler::setContinuous(bool on) {
continuous_ = on; continuous_ = on;
continuous_settled_ms_ = 0; continuous_hold_ = {};
// The first frame comes one interval after switching on, as the first scan // The first frame comes one interval after switching on, as the first scan
// waypoint does after `mode auto`. // waypoint does after `mode auto`.
resetTimer(); resetTimer();
@ -105,38 +119,105 @@ bool CaptureScheduler::roomToSave() {
return room; return room;
} }
void CaptureScheduler::requestSnapshot() { const char* CaptureScheduler::phase() const {
snapshot_pending_ = true; if (!moving_ || hold_.settled_ms == 0) return "moving";
snapshot_settled_ms_ = 0; return hold_.ready_ms == 0 ? "settling" : "waiting";
} }
// Wait for rest, settle, then shoot - the tail of a scan waypoint without the std::string CaptureScheduler::waypointLabel() const {
// move. A snapshot and a continuous-capture frame both come through here, so if (mode_ == Mode::Pan)
// either is exactly what a scan waypoint produces: same file, same CamEvent. return "pan position " + std::to_string(pan_index_ + 1) + "/" +
// std::to_string(pan_headings_.size());
// Standstill only, no target check: the operator aims, so there is no target. return "waypoint " + std::to_string(grid_.index() + 1) + "/" + std::to_string(grid_.size());
// The frame budget is measured from settling, not from the request - a request
// made mid-slew must not spend the camera's budget on the slew.
CaptureScheduler::Shot CaptureScheduler::shootAtRest(const MotorTelemetry& t,
long long& settled_ms) {
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
if (!standstill) {
settled_ms = 0; // still slewing (e.g. a jog is in flight)
return Shot::Waiting;
} }
if (settled_ms == 0) {
settled_ms = now_ms_(); void CaptureScheduler::requestSnapshot() {
return Shot::Waiting; snapshot_pending_ = true;
snapshot_hold_ = {};
} }
const long long since_settled = now_ms_() - settled_ms;
if (since_settled < settle_wait_ms_) return Shot::Waiting; void CaptureScheduler::beginHold(Hold& h) {
h = {};
h.settled_ms = now_ms_();
}
// From here on, watch the frames the camera delivers until brightness says the
// image has settled, or the frame budget runs out.
bool CaptureScheduler::ruleDecided(long long since_settled) {
if (!rule_started_) {
rule_started_ = true;
rule_.reset();
rule_seq_ = camera_.frameSeq();
// No frame data from this camera: keep the fixed wait's behaviour.
if (rule_seq_ == 0) rule_seq_ = UINT64_MAX;
}
if (rule_seq_ == UINT64_MAX) return true;
SettleRule::Verdict v = SettleRule::Verdict::Waiting;
for (const auto& f : camera_.framesSince(rule_seq_)) {
rule_seq_ = f.seq;
v = rule_.add(f);
if (v != SettleRule::Verdict::Waiting) break;
}
if (v == SettleRule::Verdict::Waiting) {
if (since_settled < settle_wait_ms_ + frame_timeout_ms_) return false; // keep watching
LOG_WARN << waypointLabel() << ": the image did not settle within " << frame_timeout_ms_
<< " ms of standstill; keeping the newest frame";
} else {
LOG_TRACE_CAT(LogCat::Control)
<< "image settled after " << since_settled << " ms"
<< (v == SettleRule::Verdict::AtLimit ? " (exposure and gain at their limit)" : "");
}
rule_seq_ = UINT64_MAX; // decided
return true;
}
bool CaptureScheduler::dwellOver(Hold& h, bool keep_rule) {
if (h.ready_ms != 0) return true;
const long long since = now_ms_() - h.settled_ms;
if (since < settle_wait_ms_) return false;
if (keep_rule && use_rule_ && !ruleDecided(since)) return false;
h.ready_ms = now_ms_();
return true;
}
CaptureScheduler::Shot CaptureScheduler::fire(Hold& h) {
if (h.due_ms == 0) h.due_ms = now_ms_();
if (camera_.trigger()) return Shot::Captured; if (camera_.trigger()) return Shot::Captured;
if (since_settled > settle_wait_ms_ + frame_timeout_ms_) return Shot::TimedOut; if (now_ms_() - h.due_ms >= frame_timeout_ms_) return Shot::TimedOut;
return Shot::Waiting; // no fresh frame yet; retry next tick return Shot::Waiting; // no fresh frame yet; retry next tick
} }
// Wait for rest and the exposure, then shoot - the tail of a scan waypoint
// without the move. A snapshot and a continuous-capture frame both come through
// here, so either is exactly what a scan waypoint produces: same file, same
// CamEvent.
//
// Standstill only, no target check: the operator aims, so there is no target.
// Movement restarts the wait. The frame budget is measured from when the trigger
// fell due, not from the request - a request made mid-slew must not spend the
// camera's budget on the slew.
CaptureScheduler::Shot CaptureScheduler::shootAtRest(const MotorTelemetry& t, Hold& h,
bool wait_interval) {
const bool standstill = t.yaw.standstill && (!pitch_present_ || t.pitch.standstill);
if (!standstill) {
h = {}; // still slewing (e.g. a jog is in flight)
return Shot::Waiting;
}
if (h.settled_ms == 0) {
beginHold(h);
return Shot::Waiting;
}
if (!dwellOver(h, false)) return Shot::Waiting;
if (wait_interval) {
if (interval_ms_ <= 0.0 || elapsedMs() < interval_ms_) return Shot::Waiting;
// Continuous capture holds, as the scan does, while saving is behind.
if (h.due_ms == 0 && !roomToSave()) return Shot::Waiting;
}
return fire(h);
}
void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) { void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
switch (shootAtRest(t, snapshot_settled_ms_)) { switch (shootAtRest(t, snapshot_hold_, false)) {
case Shot::Waiting: case Shot::Waiting:
return; return;
case Shot::Captured: case Shot::Captured:
@ -144,21 +225,17 @@ void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) {
break; break;
case Shot::TimedOut: case Shot::TimedOut:
LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_ LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_
<< " ms of settling; send 'snapshot' (Space) again"; << " ms; send 'snapshot' (Space) again";
break; break;
} }
snapshot_pending_ = false; snapshot_pending_ = false;
snapshot_hold_ = {};
} }
// The scan's cycle without the move: wait the interval, then shoot at rest. // The scan's cycle without the move: one frame per interval since the last,
// The timer restarts after each frame kept or given up, exactly as a scan's // once at rest and the exposure has settled.
// does, so the cadence matches what a scan achieves rather than the bare
// interval.
void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) { void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) {
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0; switch (shootAtRest(t, continuous_hold_, true)) {
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
if (continuous_settled_ms_ == 0 && !roomToSave()) return; // never mid-settle
switch (shootAtRest(t, continuous_settled_ms_)) {
case Shot::Waiting: case Shot::Waiting:
return; return;
case Shot::Captured: case Shot::Captured:
@ -166,10 +243,92 @@ void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) {
break; break;
case Shot::TimedOut: case Shot::TimedOut:
LOG_WARN << "continuous capture: no frame within " << frame_timeout_ms_ LOG_WARN << "continuous capture: no frame within " << frame_timeout_ms_
<< " ms of settling; trying again next interval"; << " ms; trying again next interval";
break; break;
} }
continuous_settled_ms_ = 0; continuous_hold_ = {};
resetTimer();
}
// Pick the next target, skipping unreachable ones in this same tick (a skip
// costs no time and does not restart the interval), and MOVE there.
bool CaptureScheduler::startNextMove() {
// A move that starts a pass again - back from the last waypoint to the first -
// is long by nature and the interval is not expected to hold for it; nor for
// the first move after a mode starts, from wherever the gimbal was.
bool pass_start = false;
std::optional<long> yc;
if (mode_ == Mode::Auto) {
if (grid_.empty()) return false;
pass_start = grid_.index() == 0 && grid_.size() > 1;
// 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. Try each at most once per tick, so a grid with none reachable
// just idles.
for (size_t tries = 0; tries < grid_.size(); ++tries) {
yc = geometry_.yaw.headingToCounts(grid_.current().yaw_deg);
if (yc) break;
if (!unreachable_warned_) {
unreachable_warned_ = true;
LOG_WARN << "scan waypoint " << (grid_.index() + 1) << "/" << grid_.size()
<< " heading " << grid_.current().yaw_deg
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable waypoints are skipped silently)";
}
grid_.advance();
}
if (!yc) return false;
const ScanPoint& p = grid_.current();
yaw_target_ = *yc;
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
// One INFO line per move so the operator can follow the scan in the
// log without enabling the control wire-trace.
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
} else { // pan: the next position, left to right, at the pan's pitch
if (pan_headings_.empty()) return false;
pass_start = pan_index_ == 0 && pan_headings_.size() > 1;
// Skip positions outside the reachable arc, as the sweep does.
for (size_t tries = 0; tries < pan_headings_.size(); ++tries) {
yc = geometry_.yaw.headingToCounts(pan_headings_[pan_index_]);
if (yc) break;
if (!pan_unreachable_warned_) {
pan_unreachable_warned_ = true;
LOG_WARN << "pan position " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< " heading " << pan_headings_[pan_index_]
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable positions are skipped silently)";
}
pan_index_ = (pan_index_ + 1) % pan_headings_.size();
}
if (!yc) return false;
yaw_target_ = *yc;
pitch_target_ = geometry_.pitch.toCounts(pan_.pitch);
LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch
<< " (deg)";
}
// Time spent waiting for saving is already announced, so not an overrun.
quiet_overrun_ = first_move_ || pass_start || save_held_;
first_move_ = false;
save_held_ = false;
sendMove();
moving_ = true;
hold_ = {};
rule_started_ = false;
move_sent_ms_ = now_ms_();
return true;
}
// The waypoint's frame is kept or given up: on to the next, and the interval
// counts from now.
void CaptureScheduler::finishWaypoint() {
moving_ = false;
hold_ = {};
rule_started_ = false;
if (mode_ == Mode::Auto) grid_.advance();
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size();
resetTimer(); resetTimer();
} }
@ -189,143 +348,63 @@ void CaptureScheduler::tick() {
} }
// 3. Capture cycle: the next scan waypoint (auto) or pan position (pan). // 3. Capture cycle: the next scan waypoint (auto) or pan position (pan).
const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0; // Move straight away - while there is room to save what it will capture -
// and wait out what is left of the interval at the end.
if (interval_ms_ <= 0.0) return;
if (!moving_) { if (!moving_) {
// Issue the next move once the interval has elapsed, and only while if (!roomToSave()) {
// there is room to save what it will capture. save_held_ = true;
if (interval_ms <= 0.0 || elapsedMs() <= interval_ms) return;
if (!roomToSave()) return;
if (mode_ == Mode::Auto) {
if (grid_.empty()) return;
const ScanPoint& p = grid_.current();
// 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; return;
} }
yaw_target_ = *yc; startNextMove();
pitch_target_ = geometry_.pitch.toCounts(p.pitch_deg);
// One INFO line per move so the operator can follow the scan in the
// log without enabling the control wire-trace.
LOG_INFO << "scan move " << (grid_.index() + 1) << "/" << grid_.size()
<< ": yaw " << p.yaw_deg << " pitch " << p.pitch_deg << " (deg)";
} else { // pan: the next position, left to right, at the pan's pitch
if (pan_headings_.empty()) return;
// Skip positions outside the reachable arc, as the sweep does; try
// each at most once per tick so a pan with none reachable just idles.
std::optional<long> yc;
for (size_t tries = 0; tries < pan_headings_.size() && !yc; ++tries) {
yc = geometry_.yaw.headingToCounts(pan_headings_[pan_index_]);
if (yc) break;
if (!pan_unreachable_warned_) {
pan_unreachable_warned_ = true;
LOG_WARN << "pan position " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< " heading " << pan_headings_[pan_index_]
<< " deg is outside the reachable yaw arc; skipping it (further "
"unreachable positions are skipped silently)";
}
pan_index_ = (pan_index_ + 1) % pan_headings_.size();
}
if (!yc) return;
yaw_target_ = *yc;
pitch_target_ = geometry_.pitch.toCounts(pan_.pitch);
LOG_INFO << "pan move " << (pan_index_ + 1) << "/" << pan_headings_.size()
<< ": yaw " << pan_headings_[pan_index_] << " pitch " << pan_.pitch
<< " (deg)";
}
sendMove();
moving_ = true;
settled_at_ms_ = 0;
rule_started_ = false;
resetTimer();
return; return;
} }
// Moving: wait for standstill at the target, then hold still a moment before // Moving: wait for standstill at the target.
// keeping a frame. if (hold_.settled_ms == 0) {
if (settled_at_ms_ == 0) {
if (!settledAt(t)) return; if (!settledAt(t)) return;
settled_at_ms_ = now_ms_(); beginHold(hold_);
LOG_TRACE_CAT(LogCat::Control) LOG_TRACE_CAT(LogCat::Control)
<< "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; waiting " << "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc
<< settle_wait_ms_ << " ms before capture"; << "; waiting " << settle_wait_ms_ << " ms"
<< (use_rule_ ? " and for the image to settle" : "") << " before capture";
return; return;
} }
// The camera free-runs, so this pause is what makes the kept frame one that was // The camera free-runs, so this wait is what makes the kept frame one that
// exposed with the gimbal at rest (no slew blur) and with the on-camera auto // was exposed with the gimbal at rest (no slew blur) and with the on-camera
// exposure settled on this waypoint's light. // auto exposure settled on this waypoint's light.
const long long since_settled = now_ms_() - settled_at_ms_; if (hold_.ready_ms == 0) {
if (since_settled < settle_wait_ms_) return; if (!dwellOver(hold_, true)) return;
const long long used = hold_.ready_ms - timer_start_;
// The keep rule: from here on, watch the frames the camera delivers until if (static_cast<double>(used) > interval_ms_ && !quiet_overrun_) {
// brightness says the image has settled, or the frame budget runs out. ++overruns_;
if (use_rule_) { LOG_WARN << waypointLabel() << ": " << seconds(static_cast<double>(used))
if (!rule_started_) { << " since the last picture, over the " << seconds(interval_ms_)
rule_started_ = true; << " interval (move "
rule_.reset(); << seconds(static_cast<double>(hold_.settled_ms - move_sent_ms_))
rule_seq_ = camera_.frameSeq(); << ", exposure "
// No frame data from this camera: keep the fixed wait's behaviour. << seconds(static_cast<double>(hold_.ready_ms - hold_.settled_ms)) << ")";
if (rule_seq_ == 0) rule_seq_ = UINT64_MAX;
}
if (rule_seq_ != UINT64_MAX) {
SettleRule::Verdict v = SettleRule::Verdict::Waiting;
for (const auto& f : camera_.framesSince(rule_seq_)) {
rule_seq_ = f.seq;
v = rule_.add(f);
if (v != SettleRule::Verdict::Waiting) break;
}
if (v == SettleRule::Verdict::Waiting) {
if (since_settled < settle_wait_ms_ + frame_timeout_ms_) return; // keep watching
LOG_WARN << "the image did not settle within " << frame_timeout_ms_
<< " ms of standstill; keeping the newest frame";
} else {
LOG_TRACE_CAT(LogCat::Control)
<< "image settled after " << since_settled << " ms"
<< (v == SettleRule::Verdict::AtLimit ? " (exposure and gain at their limit)"
: "");
}
rule_seq_ = UINT64_MAX; // decided: from here on, only the trigger retries
} }
} }
if (camera_.trigger()) { // Then whatever is left of the interval.
if (mode_ == Mode::Auto) if (static_cast<double>(elapsedMs()) < interval_ms_) return;
LOG_INFO << "captured waypoint " << (grid_.index() + 1) << "/" << grid_.size();
else
LOG_INFO << "captured pan position " << (pan_index_ + 1) << "/"
<< pan_headings_.size();
} else if (since_settled < settle_wait_ms_ + frame_timeout_ms_) {
return; // no fresh frame yet; retry next tick
} else {
// Never park on one waypoint: a stalled camera would otherwise stop the
// whole sweep rather than costing it a single image.
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms of settling; skipping "
<< (mode_ == Mode::Auto ? "waypoint " : "pan position ")
<< (mode_ == Mode::Auto ? grid_.index() + 1 : pan_index_ + 1) << "/"
<< (mode_ == Mode::Auto ? grid_.size() : pan_headings_.size());
}
moving_ = false; switch (fire(hold_)) {
settled_at_ms_ = 0; case Shot::Waiting:
rule_started_ = false; return;
if (mode_ == Mode::Auto) grid_.advance(); case Shot::Captured:
else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size(); LOG_INFO << "captured " << waypointLabel();
resetTimer(); break;
case Shot::TimedOut:
// Never park on one waypoint: a stalled camera would otherwise stop
// the whole sweep rather than costing it a single image.
LOG_WARN << "no frame within " << frame_timeout_ms_ << " ms; skipping "
<< waypointLabel();
break;
}
finishWaypoint();
} }
} // namespace fgc } // namespace fgc

View File

@ -235,7 +235,7 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
AppConfig cfg; AppConfig cfg;
cfg.general.tower_name = get(kv, "General.tower_name", cfg.general.tower_name); cfg.general.tower_name = get(kv, "General.tower_name", cfg.general.tower_name);
cfg.general.image_interval = getInt(kv, "General.image_interval", cfg.general.image_interval); cfg.general.image_interval = getDouble(kv, "General.image_interval", cfg.general.image_interval);
cfg.general.debug = getBool(kv, "General.debug", cfg.general.debug); cfg.general.debug = getBool(kv, "General.debug", cfg.general.debug);
// [Board] / [Hardware]: normally from hardware.ini, but read from the same // [Board] / [Hardware]: normally from hardware.ini, but read from the same
@ -558,9 +558,9 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file); cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file);
// ---- validation ---- // ---- validation ----
if (cfg.general.image_interval <= 0) if (!(cfg.general.image_interval > 0) || !std::isfinite(cfg.general.image_interval))
throw std::runtime_error("General.image_interval must be > 0 (got " + throw std::runtime_error("General.image_interval must be > 0 (got " +
std::to_string(cfg.general.image_interval) + ")"); get(kv, "General.image_interval") + ")");
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");

View File

@ -42,10 +42,11 @@ const std::vector<HelpSection>& helpCatalog() {
"Waits for standstill and the profile's settle time, as a scan waypoint", "Waits for standstill and the profile's settle time, as a scan waypoint",
"does, so the file and CamEvent are the same as a scan frame's."}}, "does, so the file and CamEvent are the same as a scan frame's."}},
{"capture [on|off]", "Continuous capture where the gimbal is aimed (key r).", { {"capture [on|off]", "Continuous capture where the gimbal is aimed (key r).", {
"One image per scan cycle: [General] image_interval, then standstill and", "One image per [General] image_interval since the last, each once the",
"the profile's settle time. The gimbal can still be nudged; images are", "gimbal has stood still for the profile's settle time. The gimbal can",
"kept only while it stands still. Bare 'capture' reports on/off. Ends on", "still be nudged; images are kept only while it stands still. Bare",
"any mode change, homing, heading setup or test."}}, "'capture' reports on/off. Ends on any mode change, homing, heading",
"setup or test."}},
{"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", { {"set camera profile [<camera>] <name>", "Switch a camera's VimbaX settings profile.", {
"<name> is an XML stem in that camera's profile_dir. The camera may be", "<name> is an XML stem in that camera's profile_dir. The camera may be",
"omitted when only one is configured.", "omitted when only one is configured.",

View File

@ -25,3 +25,19 @@ TEST_CASE("a stale stream escalates to a reset once restarts stop working") {
CHECK(onRestartFailed(1) == StallAction::Restart); CHECK(onRestartFailed(1) == StallAction::Restart);
CHECK(onRestartFailed(kRestartsBeforeReset) == StallAction::Reset); CHECK(onRestartFailed(kRestartsBeforeReset) == StallAction::Reset);
} }
TEST_CASE("a slow next frame is not a stale stream") {
// Fast stream (5 ms exposure, 5 fps): the 2 s floor.
CHECK(staleFrameLimitMs(5000, 200) == 2000);
// Tower 111 at dusk: 9 s exposures, a frame every ~9 s.
CHECK(staleFrameLimitMs(9e6, 9000) == 2 * 9000 + 1000);
// A frame-rate cap slower than the exposure governs (night: 0.5 s at 0.215 fps).
CHECK(staleFrameLimitMs(5e5, 1000.0 / 0.215) == 2 * 4651 + 1000);
// Asked 8 s after the last frame of a 9 s exposure: wait for it, do not
// restart the stream and throw that exposure away (tower 111, 2026-10-04).
CHECK(onStaleFrame(8000, 9e6, 9000) == StallAction::Wait);
CHECK(onStaleFrame(20000, 9e6, 9000) == StallAction::Restart);
// A fast stream that has delivered nothing new for seconds is stale.
CHECK(onStaleFrame(2500, 5000, 200) == StallAction::Restart);
}

View File

@ -95,9 +95,17 @@ TEST_CASE("environment overrides file credentials") {
TEST_CASE("ConfigLoader validates input") { TEST_CASE("ConfigLoader validates input") {
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "0"}})); CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "0"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "abc"}})); CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "abc"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "-1"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "nan"}}));
CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}})); CHECK_THROWS(ConfigLoader::fromMap({{"General.debug", "maybe"}}));
} }
TEST_CASE("image_interval takes fractions of a second") {
AppConfig c = ConfigLoader::fromMap({{"General.image_interval", "2.5"}});
CHECK(c.general.image_interval == doctest::Approx(2.5));
CHECK(c.image_rate() == doctest::Approx(0.4));
}
TEST_CASE("brokerUri builds a Paho serverURI from host + port") { TEST_CASE("brokerUri builds a Paho serverURI from host + port") {
// The port used to be whatever Paho defaulted to when handed a bare address. // The port used to be whatever Paho defaulted to when handed a bare address.
// It is now explicit, so these cases pin the whole resolution table. // It is now explicit, so these cases pin the whole resolution table.

View File

@ -75,36 +75,35 @@ TEST_CASE("CaptureScheduler sweeps the scan grid with MOVE + settle + trigger")
CaptureScheduler sch(motor, cam, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid, CaptureScheduler sch(motor, cam, 1.0 /*img/s -> 1000ms*/, testGeometry(), grid,
[&] { return clock; }); [&] { return clock; });
sch.setSettleWait(0, 0); // these predate the settle wait; timing has its own test sch.setSettleWait(0, 0); // no exposure wait
sch.setMode(Mode::Auto); sch.setMode(Mode::Auto);
// The move goes out straight away: MOVE to the first grid point (0,0).
// Before the interval elapses: nothing moves. clock = 10;
clock = 500;
sch.tick();
CHECK(motor.cmds.empty());
// After the interval: MOVE to the first grid point (0,0) is issued.
clock = 1600;
sch.tick(); sch.tick();
REQUIRE(motor.cmds.size() == 1); REQUIRE(motor.cmds.size() == 1);
CHECK(motor.cmds[0] == "MOVE 0,0"); CHECK(motor.cmds[0] == "MOVE 0,0");
CHECK(sch.moving()); CHECK(sch.moving());
CHECK(cam.triggers == 0); // not yet settled CHECK(cam.triggers == 0); // not yet settled
// Report standstill at the target -> camera fires, cursor advances. // At rest early: the frame waits for the interval, counted from the mode start.
settleAt(motor, 0, 0); settleAt(motor, 0, 0);
clock = 1700; clock = 100;
sch.tick(); // first tick records standstill sch.tick(); // records standstill
clock = 1710; clock = 200;
sch.tick(); // next one keeps the frame sch.tick(); // exposure settled, interval not over
CHECK(cam.triggers == 0);
CHECK(std::string(sch.phase()) == "waiting");
clock = 1000;
sch.tick(); // interval over: the frame is kept, the cursor advances
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
CHECK_FALSE(sch.moving()); CHECK_FALSE(sch.moving());
// Next interval: MOVE to the second grid point (90 deg -> 900 counts). // And the next move goes out on the next tick: (90 deg -> 900 counts).
clock = 2800; clock = 1010;
sch.tick(); sch.tick();
CHECK(motor.cmds.back() == "MOVE 900,0"); CHECK(motor.cmds.back() == "MOVE 900,0");
CHECK(sch.overruns() == 0);
} }
TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/settle") { TEST_CASE("CaptureScheduler scans a 1-axis (yaw-only) rig with yaw-only MOVE/settle") {
@ -263,11 +262,12 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram
FakeCamera cam; FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}}); ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; }); CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(1000, 2000); sch.setSettleWait(1000, 2000);
sch.setMode(Mode::Auto); sch.setMode(Mode::Auto);
clock = 1600; clock = 10;
sch.tick(); sch.tick();
REQUIRE(motor.cmds.size() == 1); REQUIRE(motor.cmds.size() == 1);
@ -276,17 +276,153 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram
clock = 2000; clock = 2000;
sch.tick(); sch.tick();
CHECK(cam.triggers == 0); CHECK(cam.triggers == 0);
CHECK(std::string(sch.phase()) == "settling");
clock = 2900; // 900 ms after settling: still inside the wait clock = 2900; // 900 ms after settling: still inside the wait
sch.tick(); sch.tick();
CHECK(cam.triggers == 0); CHECK(cam.triggers == 0);
// Past the wait, the newest frame is kept and the sweep advances. // Past the wait, but the interval still has time left: wait it out.
clock = 3100; clock = 3100;
sch.tick(); sch.tick();
CHECK(cam.triggers == 0);
CHECK(std::string(sch.phase()) == "waiting");
clock = 5000;
sch.tick();
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
CHECK_FALSE(sch.moving()); CHECK_FALSE(sch.moving());
} }
TEST_CASE("the interval is counted from the last frame, so pictures come one interval apart") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}, {60.0, 0.0}});
CaptureScheduler sch(motor, cam, 0.5 /*2000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(500, 2000);
sch.setMode(Mode::Auto);
std::vector<long long> shots;
// 10 ms ticks; the gimbal takes 700 ms to reach each target.
long long arrive = -1;
size_t moves = 0;
for (clock = 0; clock <= 7000; clock += 10) {
if (motor.cmds.size() != moves) {
moves = motor.cmds.size();
arrive = clock + 700;
motor.tel.yaw.standstill = false;
}
if (arrive >= 0 && clock >= arrive)
settleAt(motor, sch.yawTargetCounts(), sch.pitchTargetCounts());
const int before = cam.triggers;
sch.tick();
if (cam.triggers != before) shots.push_back(clock);
}
// Move 700 + settle 500 fit inside 2000, so frames land 2000 ms apart.
REQUIRE(shots.size() >= 3);
CHECK(shots[0] == 2000);
CHECK(shots[1] == 4000);
CHECK(shots[2] == 6000);
CHECK(sch.overruns() == 0);
}
TEST_CASE("a waypoint whose move and settle outlast the interval warns, and shoots when ready") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}, {60.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0 /*1000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(500, 2000);
sch.setMode(Mode::Auto);
// First waypoint: the move from wherever the gimbal was is quiet even when long.
clock = 0; sch.tick(); // MOVE 0,0
clock = 3000; settleAt(motor, 0, 0); sch.tick();
clock = 3500; sch.tick(); // settled + 500: kept at once
CHECK(cam.triggers == 1);
CHECK(sch.overruns() == 0);
// Second: 900 ms of travel + 500 settle > 1000 ms interval.
clock = 3510; sch.tick(); // MOVE 300,0
REQUIRE(motor.cmds.back() == "MOVE 300,0");
motor.tel.yaw.standstill = false;
clock = 4410; settleAt(motor, 300, 0); sch.tick();
clock = 4910; sch.tick(); // 1410 ms since the last frame
CHECK(cam.triggers == 2); // no extra wait: shot as soon as ready
CHECK(sch.overruns() == 1);
}
TEST_CASE("no overrun warning on the return from the last waypoint to the first") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {30.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 2000);
sch.setMode(Mode::Auto);
// Waypoint 1 (first move: quiet), waypoint 2 within the interval.
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 1000; sch.tick();
CHECK(cam.triggers == 1);
clock = 1010; sch.tick(); // MOVE 300,0
settleAt(motor, 300, 0);
clock = 1100; sch.tick();
clock = 1200; sch.tick(); // ready well inside the interval
clock = 2010; sch.tick();
CHECK(cam.triggers == 2);
// The long way back to waypoint 1: well over the interval, but quiet.
clock = 2020; sch.tick();
REQUIRE(motor.cmds.back() == "MOVE 0,0");
motor.tel.yaw.standstill = false;
clock = 6000; settleAt(motor, 0, 0); sch.tick();
clock = 6010; sch.tick();
CHECK(cam.triggers == 3);
CHECK(sch.overruns() == 0);
// Waypoint 2 again, also too slow: this one warns.
clock = 6020; sch.tick();
motor.tel.yaw.standstill = false;
clock = 8000; settleAt(motor, 300, 0); sch.tick();
clock = 8010; sch.tick();
CHECK(cam.triggers == 4);
CHECK(sch.overruns() == 1);
}
TEST_CASE("an unreachable waypoint is skipped without restarting the interval") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
// 150 deg is past the 90 deg limit.
ScanGrid grid({{0.0, 0.0}, {150.0, 0.0}, {30.0, 0.0}});
CaptureScheduler sch(motor, cam, 1.0, testGeometry(), grid, [&] { return clock; });
sch.setSettleWait(0, 2000);
sch.setMode(Mode::Auto);
clock = 0; sch.tick();
settleAt(motor, 0, 0);
clock = 100; sch.tick();
clock = 1000; sch.tick();
REQUIRE(cam.triggers == 1);
// Straight past waypoint 2 to waypoint 3 in one tick.
clock = 1010; sch.tick();
CHECK(motor.cmds.back() == "MOVE 300,0");
CHECK(grid.index() == 2);
settleAt(motor, 300, 0);
clock = 1100; sch.tick();
clock = 2000; sch.tick(); // one interval after the last frame
CHECK(cam.triggers == 2);
}
TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") { TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
long long clock = 0; long long clock = 0;
FakeMotor motor; FakeMotor motor;
@ -298,20 +434,22 @@ TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") {
sch.setSettleWait(1000, 2000); sch.setSettleWait(1000, 2000);
sch.setMode(Mode::Auto); sch.setMode(Mode::Auto);
clock = 1600; clock = 10;
sch.tick(); sch.tick();
settleAt(motor, 0, 0); settleAt(motor, 0, 0);
clock = 1700; clock = 100;
sch.tick(); sch.tick();
// Retries through the timeout window without giving up. // Due at 1100 (settled + 1000 > interval); retries through the timeout window.
clock = 3500; // 1800 ms after settling: past the wait, inside the timeout clock = 1100;
sch.tick();
clock = 2900; // 1800 ms after falling due: inside the timeout
sch.tick(); sch.tick();
CHECK(sch.moving()); CHECK(sch.moving());
CHECK(grid.index() == 0); CHECK(grid.index() == 0);
// Past wait + timeout, the waypoint is skipped rather than blocking the sweep. // Past the timeout, the waypoint is skipped rather than blocking the sweep.
clock = 4800; clock = 3200;
sch.tick(); sch.tick();
CHECK_FALSE(sch.moving()); CHECK_FALSE(sch.moving());
CHECK(grid.index() == 1); CHECK(grid.index() == 1);
@ -465,11 +603,11 @@ TEST_CASE("a snapshot that times out can simply be asked for again") {
// --- continuous capture (`capture on`) ------------------------------------- // --- continuous capture (`capture on`) -------------------------------------
// //
// The scan's cycle without the move: interval, then standstill + settle, then // The scan's cycle without the move: standstill + settle, then the rest of the
// one frame, and the timer restarts from there - so the cadence is what a scan // interval since the last frame, then one frame - so the cadence is what a scan
// achieves, and the gimbal stays free to be aimed in between. // achieves, and the gimbal stays free to be aimed in between.
TEST_CASE("continuous capture keeps one frame per interval + settle, in manual") { TEST_CASE("continuous capture keeps one frame per interval, in manual") {
long long clock = 0; long long clock = 0;
FakeMotor motor; FakeMotor motor;
FakeCamera cam; FakeCamera cam;
@ -482,22 +620,21 @@ TEST_CASE("continuous capture keeps one frame per interval + settle, in manual")
sch.setContinuous(true); sch.setContinuous(true);
CHECK(sch.continuous()); CHECK(sch.continuous());
clock = 900; sch.tick(); // inside the interval clock = 100; sch.tick(); // standstill seen, settle starts
clock = 700; sch.tick(); // settled, interval not over
CHECK(cam.triggers == 0); CHECK(cam.triggers == 0);
clock = 1100; sch.tick(); // interval over: standstill seen, settle starts clock = 1000; sch.tick(); // interval over: shoot
CHECK(cam.triggers == 0);
clock = 1500; sch.tick(); // still settling
CHECK(cam.triggers == 0);
clock = 1700; sch.tick(); // settled: shoot
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
CHECK(motor.cmds.empty()); // never moves the gimbal CHECK(motor.cmds.empty()); // never moves the gimbal
// The timer restarted at the capture, so the next frame is a whole cycle on. // The timer restarted at the capture, so the next frame is one interval on.
clock = 2600; sch.tick(); clock = 1100; sch.tick(); // standstill seen
clock = 1700; sch.tick(); // settled
clock = 1900; sch.tick();
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
clock = 2800; sch.tick(); // interval over, standstill seen clock = 2000; sch.tick();
clock = 3400; sch.tick(); // + settle
CHECK(cam.triggers == 2); CHECK(cam.triggers == 2);
CHECK(sch.overruns() == 0);
} }
TEST_CASE("continuous capture waits for the gimbal to stop before keeping a frame") { TEST_CASE("continuous capture waits for the gimbal to stop before keeping a frame") {
@ -519,6 +656,7 @@ TEST_CASE("continuous capture waits for the gimbal to stop before keeping a fram
CHECK(cam.triggers == 0); CHECK(cam.triggers == 0);
clock = 5700; sch.tick(); clock = 5700; sch.tick();
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
CHECK(sch.overruns() == 0); // no waypoints, no overrun warnings
} }
TEST_CASE("continuous capture gives up on a cycle without a frame and carries on") { TEST_CASE("continuous capture gives up on a cycle without a frame and carries on") {
@ -532,18 +670,18 @@ TEST_CASE("continuous capture gives up on a cycle without a frame and carries on
settleAt(motor, 0, 0); settleAt(motor, 0, 0);
sch.setContinuous(true); sch.setContinuous(true);
clock = 1100; sch.tick(); // standstill seen clock = 100; sch.tick(); // standstill seen
clock = 1200; sch.tick(); // tries, no frame clock = 1000; sch.tick(); // due: tries, no frame
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
clock = 2200; sch.tick(); // past the budget: give up, timer restarts clock = 2000; sch.tick(); // past the budget: give up, timer restarts
const int after_giving_up = cam.triggers; const int after_giving_up = cam.triggers;
clock = 2500; sch.tick(); // new cycle not due yet clock = 2500; sch.tick(); // standstill seen; next frame not due yet
clock = 2600; sch.tick();
CHECK(cam.triggers == after_giving_up); CHECK(cam.triggers == after_giving_up);
CHECK(sch.continuous()); // still on CHECK(sch.continuous()); // still on
cam.ok = true; cam.ok = true;
clock = 3300; sch.tick(); // next cycle: standstill seen clock = 3000; sch.tick(); // one interval after giving up
clock = 3400; sch.tick();
CHECK(cam.triggers == after_giving_up + 1); CHECK(cam.triggers == after_giving_up + 1);
} }
@ -608,7 +746,7 @@ TEST_CASE("a scan holds at its waypoint while saving is behind, and loses nothin
CHECK(cam.triggers == 1); CHECK(cam.triggers == 1);
room = false; // saving falls behind room = false; // saving falls behind
clock = 2500; sch.tick(); // interval over, but no room clock = 1400; sch.tick(); // the next move is due, but no room
clock = 9000; sch.tick(); clock = 9000; sch.tick();
CHECK(motor.cmds.size() == 1); // held: no next move CHECK(motor.cmds.size() == 1); // held: no next move
CHECK(sch.holdingForSave()); CHECK(sch.holdingForSave());
@ -618,6 +756,14 @@ TEST_CASE("a scan holds at its waypoint while saving is behind, and loses nothin
REQUIRE(motor.cmds.size() == 2); // moves on to the waypoint it was due REQUIRE(motor.cmds.size() == 2); // moves on to the waypoint it was due
CHECK(motor.cmds[1] == "MOVE 900,0"); CHECK(motor.cmds[1] == "MOVE 900,0");
CHECK_FALSE(sch.holdingForSave()); CHECK_FALSE(sch.holdingForSave());
// The hold is long past the interval, but it was announced as such: that
// waypoint is not also an overrun.
motor.tel.yaw.standstill = false;
clock = 9200; settleAt(motor, 900, 0); sch.tick();
clock = 9300; sch.tick();
CHECK(cam.triggers == 2);
CHECK(sch.overruns() == 0);
} }
TEST_CASE("continuous capture holds while saving is behind; a snapshot never does") { TEST_CASE("continuous capture holds while saving is behind; a snapshot never does") {
@ -637,14 +783,13 @@ TEST_CASE("continuous capture holds while saving is behind; a snapshot never doe
clock = 200; sch.tick(); clock = 200; sch.tick();
CHECK(cam.triggers == 1); // taken despite the full queue CHECK(cam.triggers == 1); // taken despite the full queue
sch.setContinuous(true); sch.setContinuous(true); // the interval counts from here
clock = 1500; sch.tick(); clock = 1500; sch.tick(); // standstill seen
clock = 3000; sch.tick(); clock = 3000; sch.tick(); // settled and due, but no room
CHECK(cam.triggers == 1); // held CHECK(cam.triggers == 1); // held
CHECK(sch.holdingForSave()); CHECK(sch.holdingForSave());
room = true; room = true;
clock = 3100; sch.tick(); // standstill seen clock = 3100; sch.tick(); // kept at once: the gimbal never moved
clock = 3200; sch.tick(); // kept
CHECK(cam.triggers == 2); CHECK(cam.triggers == 2);
} }
@ -714,3 +859,58 @@ TEST_CASE("with the keep rule, an image that never settles is kept at the time l
r.clock = 4500; r.sch.tick(); // past settle + timeout r.clock = 4500; r.sch.tick(); // past settle + timeout
CHECK(r.cam.triggers == 1); CHECK(r.cam.triggers == 1);
} }
TEST_CASE("with the keep rule, a settled image still waits out the rest of the interval") {
long long clock = 0;
FakeMotor motor;
FakeCamera cam;
ScanGrid grid({{0.0, 0.0}, {90.0, 0.0}});
CaptureScheduler sch(motor, cam, 0.2 /*5000 ms*/, testGeometry(), grid,
[&] { return clock; });
sch.setSettleWait(200, 3000);
sch.setSettleRule(true);
cam.deliver(255);
sch.setMode(Mode::Auto);
clock = 0; sch.tick(); // MOVE
settleAt(motor, 0, 0);
clock = 100; sch.tick(); // standstill seen
clock = 300; sch.tick(); // mechanical settle over: rule starts
cam.deliver(126);
clock = 400; sch.tick();
CHECK(std::string(sch.phase()) == "settling");
for (double b : {125.0, 125.5}) {
cam.deliver(b);
clock += 100; sch.tick();
}
CHECK(std::string(sch.phase()) == "waiting"); // settled at 600 ms
CHECK(cam.triggers == 0);
clock = 4990; sch.tick();
CHECK(cam.triggers == 0);
clock = 5000; sch.tick(); // one interval after the mode started
CHECK(cam.triggers == 1);
CHECK(sch.overruns() == 0);
}
TEST_CASE("the keep rule starts afresh at each waypoint") {
RuleRig r;
for (double b : {126.0, 125.0, 125.5}) { // the first waypoint settles
r.cam.deliver(b);
r.clock += 100; r.sch.tick();
}
REQUIRE(r.cam.triggers == 1);
r.clock = 1800; r.sch.tick(); // MOVE to the second
REQUIRE(r.motor.cmds.back() == "MOVE 900,0");
r.motor.tel.yaw.standstill = false;
r.clock = 1900; settleAt(r.motor, 900, 0); r.sch.tick();
r.clock = 2100; r.sch.tick(); // settle over: the rule starts again
r.cam.deliver(230); // new view, far too bright
r.clock = 2800; r.sch.tick(); // interval over, but not settled
CHECK(r.cam.triggers == 1);
for (double b : {126.0, 125.0, 125.5}) {
r.cam.deliver(b);
r.clock += 100; r.sch.tick();
}
CHECK(r.cam.triggers == 2);
}