diff --git a/README.md b/README.md index 830fb05..49a4c9f 100644 --- a/README.md +++ b/README.md @@ -130,8 +130,9 @@ flowchart TD A([next waypoint]) --> B["move yaw and pitch
to the waypoint"] B --> C{"both axes
stopped?"} C -- no --> C - C -- yes --> D["hold still for settle_wait_ms
(1 s by day, 2.5 s at night)"] - D --> E["keep the newest frame
the camera delivered"] + C -- yes --> D["hold still for settle_wait_ms
(and, with settle_rule = brightness,
until the brightness has settled)"] + D --> W["wait out what is left of
image_interval since the last picture"] + W --> E["keep the newest frame
the camera delivered"] E --> F["encode to JPEG XL
save to the output folder"] F --> G["publish a CamEvent
(camera, heading, pitch, time)"] G --> A diff --git a/config/config.example.ini b/config/config.example.ini index 8c2855b..0aaf9ce 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -14,7 +14,7 @@ [General] ; Tower identity, substituted into all MQTT topics and CamEvent payloads. tower_name = ExampleTower -; Seconds between captures. +; Desired seconds from one picture to the next (decimals allowed, e.g. 2.5). image_interval = 5 ; 1 = print motor telemetry each loop tick, 0 = quiet. debug = 0 diff --git a/docs/architecture.md b/docs/architecture.md index 5340219..4079304 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,8 +79,10 @@ corrupted. pan's pitch, converted to counts via `Geometry`. - manual: nothing, bar a requested snapshot or continuous capture (`capture on`), which skip the move and wait only for standstill. - - Once **both axes report standstill at the target**, wait `settle_wait_ms`, then keep the newest - frame and advance the grid or pan position. + - Once **both axes report standstill at the target**, wait `settle_wait_ms` (and, with + `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 auto-exposure tracking the light; `trigger()` hands over the freshest complete frame. See *Image capture* below. @@ -94,20 +96,29 @@ corrupted. ## 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) - │ MOVE , │ pan → next pan heading, at the pan's pitch - │ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving, reset timer + ┌──────────────────────┐ auto → next ScanGrid (yaw,pitch); pan → next pan heading + │ MOVE , │ unreachable targets skipped in the same tick + │ (yaw-only: MOVE Y) │ deg→counts via Geometry; set moving └──────────┬───────────┘ │ (both axes standstill AND |xenc − target| ≤ tol) ▼ ┌──────────────────────┐ 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 └──────────────────────┘ 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; 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 - frame once brightness is in the camera's target band and steady for 3 frames - ([configuration.md](configuration.md#which-frame-a-waypoint-keeps-settle_rule)). +3. with `settle_rule = brightness`, waits on until brightness is in the camera's + target band and steady for 3 frames + ([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 `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, 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 -stalled camera costs single images, never the whole sweep. +If no fresh frame arrives within `frame_timeout_ms` of the frame falling due, the waypoint is logged and +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 diff --git a/docs/configuration.md b/docs/configuration.md index c5fb821..a4ad64e 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,7 +27,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | Section | Key | Type | Default | Meaning | |---------|-----|------|---------|---------| | `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 | | `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 | @@ -44,7 +44,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `Camera.` | `enabled` | bool | `true` | A disabled camera is not opened and does not affect the scan timing | | `Camera.` | `rotation_deg` | `0`/`90`/`180`/`270` | `0` | Clockwise rotation applied to that camera's saved images | | `Camera..` | `settle_wait_ms` | int ≥ 0 | `1000` | Hold after standstill before keeping a frame | -| `Camera..` | `frame_timeout_ms` | int ≥ 0 | `2000` | Longest wait after `settle_wait_ms`; then keep the newest frame, or skip the waypoint if none | +| `Camera..` | `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..` | `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 | 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 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 -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 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 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. ### `[Env]` — SHT41 ambient temperature/humidity sensor @@ -692,7 +696,7 @@ Handled in `Application::Impl::handleCommand`. | `trace all` / `trace off` | Enable every category / silence all | | `set camera profile [] ` | 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) | -| `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 | | `test camera [frames\|dark\|bright]` | Check the camera delivers frames and responds to exposure/gain | | `test [ []] []` | Run the hardware self-test suite (see below) | diff --git a/docs/known-issues.md b/docs/known-issues.md index 4368964..efff870 100644 --- a/docs/known-issues.md +++ b/docs/known-issues.md @@ -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. | | 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)). | | 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). | diff --git a/docs/modules-reference.md b/docs/modules-reference.md index 8944dcb..76a549b 100644 --- a/docs/modules-reference.md +++ b/docs/modules-reference.md @@ -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//…` 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/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/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 | @@ -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/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/JpegXlEncoder.cpp](../src/camera/JpegXlEncoder.cpp) | libjxl encode-to-file | 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/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 → 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 diff --git a/docs/mqtt-api.md b/docs/mqtt-api.md index 029423d..a47f35d 100644 --- a/docs/mqtt-api.md +++ b/docs/mqtt-api.md @@ -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 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 -(`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. **Headings are true bearings and can be out of reach.** Yaw travel is under a diff --git a/include/fgc/CameraStall.h b/include/fgc/CameraStall.h index 0ca4e64..1baaab8 100644 --- a/include/fgc/CameraStall.h +++ b/include/fgc/CameraStall.h @@ -28,6 +28,18 @@ long long firstFrameLimitMs(double exposure_us); // `failed_restarts` counts restarts since the last frame that arrived. 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 // failed to bring a fresh frame. Restart again, or reset once restarts have // stopped working. diff --git a/include/fgc/CaptureScheduler.h b/include/fgc/CaptureScheduler.h index db031e9..06e251b 100644 --- a/include/fgc/CaptureScheduler.h +++ b/include/fgc/CaptureScheduler.h @@ -18,13 +18,22 @@ namespace fgc { // // Each tick(): // 1. reads motor telemetry (per-axis encoder counts + state), -// 2. runs the capture cycle as a move -> settle -> wait -> keep machine: when -// the interval elapses it issues an absolute `MOVE ,` to the next -// target, waits until both axes report standstill at that target, waits a -// further settle_wait_ms so the free-running camera has exposed a frame with -// the gimbal at rest, then keeps the newest frame. If none is ready it +// 2. runs the capture cycle. The interval is the time from one kept frame to +// the next: as soon as a frame is kept it issues an absolute +// `MOVE ,` to the next target, waits until both axes report +// standstill there, waits for the exposure to settle, then waits out what +// 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. // +// 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 // 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 @@ -60,9 +69,10 @@ public: bool snapshotPending() const { return snapshot_pending_; } // Continuous capture (`capture on|off`): in manual, keep a frame on the scan's - // own cycle - the interval, then standstill + settle wait - wherever the - // operator has aimed. The gimbal stays free to nudge; a frame is only kept at - // rest. Ignored outside manual, and cleared by any setMode(). + // own cycle - one per interval since the last, once standstill + the exposure + // wait are done - wherever the operator has aimed. The gimbal stays free to + // 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); bool continuous() const { return continuous_; } @@ -78,8 +88,8 @@ public: bool holdingForSave() const { return holding_; } // 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 - // waypoint. Called on the control thread, same as tick(). + // for the exposure to settle, and how long to keep retrying before abandoning + // the waypoint. Called on the control thread, same as tick(). void setSettleWait(int settle_ms, int timeout_ms); int settleWaitMs() const { return settle_wait_ms_; } @@ -103,22 +113,50 @@ public: long yawTargetCounts() const { return yaw_target_; } long pitchTargetCounts() const { return pitch_target_; } 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: + // 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; void resetTimer(); void sendMove(); // emit MOVE to (yaw_target_, pitch_target_) 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 serviceContinuous(const MotorTelemetry& t); - // The part a snapshot and a continuous-capture frame share: wait for the - // gimbal to stand still, then the settle wait, then trigger until the frame - // budget runs out. `settled_ms` is that request's own settle clock. - enum class Shot { Waiting, Captured, TimedOut }; - Shot shootAtRest(const MotorTelemetry& t, long long& settled_ms); + // Standstill seen: begin the exposure wait. + void beginHold(Hold& h); + // Whether the exposure wait that began at h.settled_ms is over (sets + // h.ready_ms): settle_wait_ms, then with `keep_rule` the keep rule too. + 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. 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_; ICameraSource& camera_; @@ -129,7 +167,8 @@ private: std::function can_save_; bool holding_ = false; 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; PanSpec pan_; @@ -137,12 +176,17 @@ private: std::vector pan_headings_; 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 SettleRule rule_; bool rule_started_ = false; // this waypoint's rule has its first frame number 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 frame_timeout_ms_ = 2000; 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 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` - long long continuous_settled_ms_ = 0; + Hold continuous_hold_; long yaw_target_ = 0; // current target, encoder counts long pitch_target_ = 0; }; diff --git a/include/fgc/Config.h b/include/fgc/Config.h index 308f0e4..759ce34 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -16,7 +16,7 @@ namespace fgc { struct GeneralConfig { 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 }; diff --git a/src/camera/VimbaCameraSource.cpp b/src/camera/VimbaCameraSource.cpp index 99a2c1f..be51cea 100644 --- a/src/camera/VimbaCameraSource.cpp +++ b/src/camera/VimbaCameraSource.cpp @@ -188,12 +188,16 @@ private: 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) { FeaturePtr f; - double v = fallback; - if (SP_ACCESS(cam)->GetFeatureByName(name, f) == VmbErrorSuccess && f->GetValue(v) == VmbErrorSuccess) - return v; + if (SP_ACCESS(cam)->GetFeatureByName(name, f) != VmbErrorSuccess) return fallback; + double v = fallback; + if (f->GetValue(v) == VmbErrorSuccess) return v; + VmbInt64_t i = 0; + if (f->GetValue(i) == VmbErrorSuccess) return static_cast(i); return fallback; } 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")); 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 // CameraStall.h for why this exists. @@ -515,7 +528,15 @@ bool VimbaCameraSource::trigger() { } 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); try { impl_->stopAcquisition(); diff --git a/src/core/Application.cpp b/src/core/Application.cpp index b321d7c..46eeef7 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -1041,7 +1041,7 @@ struct Application::Impl { a.status = "finding endstops…"; } else if (scheduler && scheduler->captureActive()) { a.active = true; - const char* phase = scheduler->moving() ? "moving" : "dwell"; + const char* phase = scheduler->phase(); if (scheduler->mode() == Mode::Pan) { a.title = "PAN"; const auto& h = scheduler->panHeadingsNow(); @@ -2098,10 +2098,9 @@ struct Application::Impl { scheduler->setContinuous(true); const double interval_s = scheduler->imageRate() > 0.0 ? 1.0 / scheduler->imageRate() : 0.0; - LOG_INFO << "continuous capture on: one image every ~" - << interval_s + scheduler->settleWaitMs() / 1000.0 << " s (" - << interval_s << " s interval + " << scheduler->settleWaitMs() - << " ms settle), kept only while the gimbal stands still"; + LOG_INFO << "continuous capture on: one image every " << interval_s + << " s, each once the gimbal has stood still for " + << scheduler->settleWaitMs() << " ms"; } // End continuous capture because something else is taking the gimbal or the diff --git a/src/core/CameraStall.cpp b/src/core/CameraStall.cpp index 275b349..6b952b3 100644 --- a/src/core/CameraStall.cpp +++ b/src/core/CameraStall.cpp @@ -14,6 +14,17 @@ StallAction onNoFirstFrame(long long waited_ms, double exposure_us, int failed_r 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(2000, static_cast(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) { return failed_restarts >= kRestartsBeforeReset ? StallAction::Reset : StallAction::Restart; } diff --git a/src/core/CaptureScheduler.cpp b/src/core/CaptureScheduler.cpp index e819ef9..f74f439 100644 --- a/src/core/CaptureScheduler.cpp +++ b/src/core/CaptureScheduler.cpp @@ -6,7 +6,9 @@ #include #include #include +#include #include +#include namespace fgc { @@ -22,6 +24,13 @@ long long steadyNowMs() { // deadband (~200 counts). 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 CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camera, @@ -32,7 +41,8 @@ CaptureScheduler::CaptureScheduler(IMotorController& motor, ICameraSource& camer geometry_(geometry), grid_(grid), 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_(); } @@ -43,22 +53,26 @@ void CaptureScheduler::setMode(Mode mode, const PanSpec& pan) { pan_index_ = 0; pan_unreachable_warned_ = false; moving_ = false; // restart the move/settle cycle cleanly - settled_at_ms_ = 0; - rule_started_ = false; + 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; // Drop any armed snapshot: a scan starting means the operator moved on, and // keeping it would fire an image at some unrelated later moment when the scan // stops - attributed to whatever the gimbal happened to be pointing at then. snapshot_pending_ = false; // Continuous capture is a manual-mode activity: any mode change ends it, so // a later return to manual never resumes shooting nobody asked for. - continuous_ = false; - continuous_settled_ms_ = 0; + continuous_ = false; + continuous_hold_ = {}; + // The first frame comes no sooner than one interval after the mode starts. resetTimer(); } void CaptureScheduler::setContinuous(bool on) { - continuous_ = on; - continuous_settled_ms_ = 0; + continuous_ = on; + continuous_hold_ = {}; // The first frame comes one interval after switching on, as the first scan // waypoint does after `mode auto`. resetTimer(); @@ -105,38 +119,105 @@ bool CaptureScheduler::roomToSave() { return room; } -void CaptureScheduler::requestSnapshot() { - snapshot_pending_ = true; - snapshot_settled_ms_ = 0; +const char* CaptureScheduler::phase() const { + if (!moving_ || hold_.settled_ms == 0) return "moving"; + return hold_.ready_ms == 0 ? "settling" : "waiting"; } -// Wait for rest, settle, 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. -// 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; +std::string CaptureScheduler::waypointLabel() const { + if (mode_ == Mode::Pan) + return "pan position " + std::to_string(pan_index_ + 1) + "/" + + std::to_string(pan_headings_.size()); + return "waypoint " + std::to_string(grid_.index() + 1) + "/" + std::to_string(grid_.size()); +} + +void CaptureScheduler::requestSnapshot() { + snapshot_pending_ = true; + snapshot_hold_ = {}; +} + +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 (settled_ms == 0) { - settled_ms = now_ms_(); - return Shot::Waiting; + 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; } - const long long since_settled = now_ms_() - settled_ms; - if (since_settled < settle_wait_ms_) return Shot::Waiting; + 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 (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 } +// 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) { - switch (shootAtRest(t, snapshot_settled_ms_)) { + switch (shootAtRest(t, snapshot_hold_, false)) { case Shot::Waiting: return; case Shot::Captured: @@ -144,21 +225,17 @@ void CaptureScheduler::serviceSnapshot(const MotorTelemetry& t) { break; case Shot::TimedOut: LOG_WARN << "snapshot: no frame within " << frame_timeout_ms_ - << " ms of settling; send 'snapshot' (Space) again"; + << " ms; send 'snapshot' (Space) again"; break; } snapshot_pending_ = false; + snapshot_hold_ = {}; } -// The scan's cycle without the move: wait the interval, then shoot at rest. -// The timer restarts after each frame kept or given up, exactly as a scan's -// does, so the cadence matches what a scan achieves rather than the bare -// interval. +// The scan's cycle without the move: one frame per interval since the last, +// once at rest and the exposure has settled. void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) { - const double interval_ms = image_rate_ > 0.0 ? 1000.0 / image_rate_ : 0.0; - 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_)) { + switch (shootAtRest(t, continuous_hold_, true)) { case Shot::Waiting: return; case Shot::Captured: @@ -166,10 +243,92 @@ void CaptureScheduler::serviceContinuous(const MotorTelemetry& t) { break; case Shot::TimedOut: LOG_WARN << "continuous capture: no frame within " << frame_timeout_ms_ - << " ms of settling; trying again next interval"; + << " ms; trying again next interval"; 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 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(); } @@ -189,143 +348,63 @@ void CaptureScheduler::tick() { } // 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_) { - // Issue the next move once the interval has elapsed, and only while - // there is room to save what it will capture. - 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; - } - 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; - // 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 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)"; + if (!roomToSave()) { + save_held_ = true; + return; } - sendMove(); - moving_ = true; - settled_at_ms_ = 0; - rule_started_ = false; - resetTimer(); + startNextMove(); return; } - // Moving: wait for standstill at the target, then hold still a moment before - // keeping a frame. - if (settled_at_ms_ == 0) { + // Moving: wait for standstill at the target. + if (hold_.settled_ms == 0) { if (!settledAt(t)) return; - settled_at_ms_ = now_ms_(); + beginHold(hold_); LOG_TRACE_CAT(LogCat::Control) - << "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc << "; waiting " - << settle_wait_ms_ << " ms before capture"; + << "settled at yaw=" << t.yaw.xenc << " pitch=" << t.pitch.xenc + << "; waiting " << settle_wait_ms_ << " ms" + << (use_rule_ ? " and for the image to settle" : "") << " before capture"; return; } - // The camera free-runs, so this pause is what makes the kept frame one that was - // exposed with the gimbal at rest (no slew blur) and with the on-camera auto - // exposure settled on this waypoint's light. - const long long since_settled = now_ms_() - settled_at_ms_; - if (since_settled < settle_wait_ms_) return; - - // The keep rule: from here on, watch the frames the camera delivers until - // brightness says the image has settled, or the frame budget runs out. - if (use_rule_) { - 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) { - 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 + // The camera free-runs, so this wait is what makes the kept frame one that + // was exposed with the gimbal at rest (no slew blur) and with the on-camera + // auto exposure settled on this waypoint's light. + if (hold_.ready_ms == 0) { + if (!dwellOver(hold_, true)) return; + const long long used = hold_.ready_ms - timer_start_; + if (static_cast(used) > interval_ms_ && !quiet_overrun_) { + ++overruns_; + LOG_WARN << waypointLabel() << ": " << seconds(static_cast(used)) + << " since the last picture, over the " << seconds(interval_ms_) + << " interval (move " + << seconds(static_cast(hold_.settled_ms - move_sent_ms_)) + << ", exposure " + << seconds(static_cast(hold_.ready_ms - hold_.settled_ms)) << ")"; } } - if (camera_.trigger()) { - if (mode_ == Mode::Auto) - 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()); - } + // Then whatever is left of the interval. + if (static_cast(elapsedMs()) < interval_ms_) return; - moving_ = false; - settled_at_ms_ = 0; - rule_started_ = false; - if (mode_ == Mode::Auto) grid_.advance(); - else if (!pan_headings_.empty()) pan_index_ = (pan_index_ + 1) % pan_headings_.size(); - resetTimer(); + switch (fire(hold_)) { + case Shot::Waiting: + return; + case Shot::Captured: + LOG_INFO << "captured " << waypointLabel(); + 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 diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 1edcfe2..f9d265e 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -235,7 +235,7 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { AppConfig cfg; 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); // [Board] / [Hardware]: normally from hardware.ini, but read from the same @@ -558,9 +558,9 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file); // ---- 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 " + - std::to_string(cfg.general.image_interval) + ")"); + get(kv, "General.image_interval") + ")"); if (cfg.serial.baud == 0) throw std::runtime_error("Serial.baud must be > 0"); diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index d33370c..02c1fb9 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -42,10 +42,11 @@ const std::vector& helpCatalog() { "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."}}, {"capture [on|off]", "Continuous capture where the gimbal is aimed (key r).", { - "One image per scan cycle: [General] image_interval, then standstill and", - "the profile's settle time. The gimbal can still be nudged; images are", - "kept only while it stands still. Bare 'capture' reports on/off. Ends on", - "any mode change, homing, heading setup or test."}}, + "One image per [General] image_interval since the last, each once the", + "gimbal has stood still for the profile's settle time. The gimbal can", + "still be nudged; images are kept only while it stands still. Bare", + "'capture' reports on/off. Ends on any mode change, homing, heading", + "setup or test."}}, {"set camera profile [] ", "Switch a camera's VimbaX settings profile.", { " is an XML stem in that camera's profile_dir. The camera may be", "omitted when only one is configured.", diff --git a/tests/test_camerastall.cpp b/tests/test_camerastall.cpp index 561f299..b427c6b 100644 --- a/tests/test_camerastall.cpp +++ b/tests/test_camerastall.cpp @@ -25,3 +25,19 @@ TEST_CASE("a stale stream escalates to a reset once restarts stop working") { CHECK(onRestartFailed(1) == StallAction::Restart); 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); +} diff --git a/tests/test_config.cpp b/tests/test_config.cpp index ec088fe..cfd35f3 100644 --- a/tests/test_config.cpp +++ b/tests/test_config.cpp @@ -95,9 +95,17 @@ TEST_CASE("environment overrides file credentials") { TEST_CASE("ConfigLoader validates input") { CHECK_THROWS(ConfigLoader::fromMap({{"General.image_interval", "0"}})); 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"}})); } +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") { // 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. diff --git a/tests/test_scheduler.cpp b/tests/test_scheduler.cpp index 33a4d90..dfd1f33 100644 --- a/tests/test_scheduler.cpp +++ b/tests/test_scheduler.cpp @@ -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, [&] { 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); - - // Before the interval elapses: nothing moves. - clock = 500; - sch.tick(); - CHECK(motor.cmds.empty()); - - // After the interval: MOVE to the first grid point (0,0) is issued. - clock = 1600; + // The move goes out straight away: MOVE to the first grid point (0,0). + clock = 10; sch.tick(); REQUIRE(motor.cmds.size() == 1); CHECK(motor.cmds[0] == "MOVE 0,0"); CHECK(sch.moving()); 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); - clock = 1700; - sch.tick(); // first tick records standstill - clock = 1710; - sch.tick(); // next one keeps the frame + clock = 100; + sch.tick(); // records standstill + clock = 200; + 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_FALSE(sch.moving()); - // Next interval: MOVE to the second grid point (90 deg -> 900 counts). - clock = 2800; + // And the next move goes out on the next tick: (90 deg -> 900 counts). + clock = 1010; sch.tick(); 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") { @@ -263,11 +262,12 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram FakeCamera cam; 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.setMode(Mode::Auto); - clock = 1600; + clock = 10; sch.tick(); REQUIRE(motor.cmds.size() == 1); @@ -276,17 +276,153 @@ TEST_CASE("CaptureScheduler holds still for settle_wait_ms before keeping a fram clock = 2000; sch.tick(); CHECK(cam.triggers == 0); + CHECK(std::string(sch.phase()) == "settling"); clock = 2900; // 900 ms after settling: still inside the wait sch.tick(); 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; sch.tick(); + CHECK(cam.triggers == 0); + CHECK(std::string(sch.phase()) == "waiting"); + clock = 5000; + sch.tick(); CHECK(cam.triggers == 1); 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 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") { long long clock = 0; FakeMotor motor; @@ -298,20 +434,22 @@ TEST_CASE("CaptureScheduler abandons a waypoint after frame_timeout_ms") { sch.setSettleWait(1000, 2000); sch.setMode(Mode::Auto); - clock = 1600; + clock = 10; sch.tick(); settleAt(motor, 0, 0); - clock = 1700; + clock = 100; sch.tick(); - // Retries through the timeout window without giving up. - clock = 3500; // 1800 ms after settling: past the wait, inside the timeout + // Due at 1100 (settled + 1000 > interval); retries through the timeout window. + clock = 1100; + sch.tick(); + clock = 2900; // 1800 ms after falling due: inside the timeout sch.tick(); CHECK(sch.moving()); CHECK(grid.index() == 0); - // Past wait + timeout, the waypoint is skipped rather than blocking the sweep. - clock = 4800; + // Past the timeout, the waypoint is skipped rather than blocking the sweep. + clock = 3200; sch.tick(); CHECK_FALSE(sch.moving()); 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`) ------------------------------------- // -// The scan's cycle without the move: interval, then standstill + settle, then -// one frame, and the timer restarts from there - so the cadence is what a scan +// The scan's cycle without the move: standstill + settle, then the rest of the +// 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. -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; FakeMotor motor; FakeCamera cam; @@ -482,22 +620,21 @@ TEST_CASE("continuous capture keeps one frame per interval + settle, in manual") sch.setContinuous(true); 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); - clock = 1100; sch.tick(); // interval over: standstill seen, settle starts - CHECK(cam.triggers == 0); - clock = 1500; sch.tick(); // still settling - CHECK(cam.triggers == 0); - clock = 1700; sch.tick(); // settled: shoot + clock = 1000; sch.tick(); // interval over: shoot CHECK(cam.triggers == 1); CHECK(motor.cmds.empty()); // never moves the gimbal - // The timer restarted at the capture, so the next frame is a whole cycle on. - clock = 2600; sch.tick(); + // The timer restarted at the capture, so the next frame is one interval on. + clock = 1100; sch.tick(); // standstill seen + clock = 1700; sch.tick(); // settled + clock = 1900; sch.tick(); CHECK(cam.triggers == 1); - clock = 2800; sch.tick(); // interval over, standstill seen - clock = 3400; sch.tick(); // + settle + clock = 2000; sch.tick(); CHECK(cam.triggers == 2); + CHECK(sch.overruns() == 0); } 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); clock = 5700; sch.tick(); 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") { @@ -532,18 +670,18 @@ TEST_CASE("continuous capture gives up on a cycle without a frame and carries on settleAt(motor, 0, 0); sch.setContinuous(true); - clock = 1100; sch.tick(); // standstill seen - clock = 1200; sch.tick(); // tries, no frame + clock = 100; sch.tick(); // standstill seen + clock = 1000; sch.tick(); // due: tries, no frame 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; - 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(sch.continuous()); // still on cam.ok = true; - clock = 3300; sch.tick(); // next cycle: standstill seen - clock = 3400; sch.tick(); + clock = 3000; sch.tick(); // one interval after giving up 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); 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(); CHECK(motor.cmds.size() == 1); // held: no next move 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 CHECK(motor.cmds[1] == "MOVE 900,0"); 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") { @@ -637,14 +783,13 @@ TEST_CASE("continuous capture holds while saving is behind; a snapshot never doe clock = 200; sch.tick(); CHECK(cam.triggers == 1); // taken despite the full queue - sch.setContinuous(true); - clock = 1500; sch.tick(); - clock = 3000; sch.tick(); + sch.setContinuous(true); // the interval counts from here + clock = 1500; sch.tick(); // standstill seen + clock = 3000; sch.tick(); // settled and due, but no room CHECK(cam.triggers == 1); // held CHECK(sch.holdingForSave()); room = true; - clock = 3100; sch.tick(); // standstill seen - clock = 3200; sch.tick(); // kept + clock = 3100; sch.tick(); // kept at once: the gimbal never moved 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 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); +}