From 5053b82811a8cc02b47a686ad89b6f2f35b99b99 Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Wed, 26 Aug 2026 10:01:16 +0200 Subject: [PATCH] Scan sweep now always runs front to back instead of ping-ponging --- config/config.example.ini | 6 +++++- docs/architecture.md | 2 +- docs/configuration.md | 5 +++-- docs/modules-reference.md | 2 +- docs/mqtt-api.md | 2 +- include/fgc/CaptureScheduler.h | 2 +- include/fgc/ScanGrid.h | 17 +++++++++------- src/core/ScanGrid.cpp | 22 +++++--------------- tests/test_scangrid.cpp | 37 ++++++++++++++++++++++++++++++---- 9 files changed, 60 insertions(+), 35 deletions(-) diff --git a/config/config.example.ini b/config/config.example.ini index 488edd8..28964cb 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -188,7 +188,11 @@ enc_error_warn_counts = 400 ; If grid_file is set, that CSV is used verbatim (one "yaw_deg,pitch_deg" per ; line, '#' comments allowed) - edit it to define exact scan coordinates. ; If grid_file is blank, a grid is generated: yaw_intervals positions across -; [yaw_min_deg, yaw_max_deg] at each pitch level, traversed ping-pong. +; [yaw_min_deg, yaw_max_deg] at each pitch level. +; The sweep always runs front to back, then returns to the first waypoint and +; repeats, so every pass captures the waypoints in the same order. The return +; leg is one long slew (the yaw arc cannot wrap), so order the grid with that +; in mind. grid_file = yaw_intervals = 56 yaw_min_deg = -90 diff --git a/docs/architecture.md b/docs/architecture.md index de125ad..1bab1db 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -72,7 +72,7 @@ corrupted. 3. **Control input** — `IControlChannel::poll()` returns the latest `ControlCommand` (control code + target heading), clearing its "available" flags so each update is acted on once. 4. **Capture cycle** (`CaptureScheduler::tick`, per 10 ms) — a move → settle → trigger machine: - - ControlCode 0: `MOVE ,` to the next `ScanGrid` waypoint (ping-pong). + - ControlCode 0: `MOVE ,` to the next `ScanGrid` waypoint (front to back, then wrap). - ControlCode 1: `MOVE` yaw to `target_HDG` (pitch held), converted to counts via `Geometry`. - Trigger the cameras once **both axes report standstill at the target**, then advance the grid. 5. **Acquisition + quality gate** (`GatedCameraSource`) — `trigger()` does not simply take a picture. It diff --git a/docs/configuration.md b/docs/configuration.md index e5e5bb0..17993f2 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -130,7 +130,8 @@ the live session immediately; in the TUI the activity strip then offers to save new default (`y`/`n`). `gimbal stop` cancels a run. Timings (dwell, no-rotation duration, etc.) are the `CalibParams` defaults in [CalibrationRoutine.h](../include/fgc/CalibrationRoutine.h). -The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits (ping-pong). Set +The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits, always front to +back. Set `[Scan] grid_file` to an editable CSV ([config/scan.csv](../config/scan.csv)) to define exact coordinates, or leave it blank to generate `yaw_intervals × pitch_levels` points. @@ -387,6 +388,6 @@ The firmware speaks **full-word, newline-terminated** commands in absolute **enc Capture is a **move → settle → trigger** cycle: the scheduler issues a `MOVE`, waits until both axes report standstill at the target, then triggers the cameras. **ControlCode 0** walks the scan -grid (ping-pong); **ControlCode 1** drives yaw to `target_HDG` (pitch held). Telemetry arrives as +grid (front to back, repeating); **ControlCode 1** drives yaw to `target_HDG` (pitch held). Telemetry arrives as firmware `ST` lines (per-axis state + encoder counts), parsed by [TelemetryParser](../src/core/TelemetryParser.cpp). diff --git a/docs/modules-reference.md b/docs/modules-reference.md index 1bd6208..1df44ce 100644 --- a/docs/modules-reference.md +++ b/docs/modules-reference.md @@ -10,7 +10,7 @@ Per-file reference for the refactored tree, plus the shared data structures. | [include/fgc/Paths.h](../include/fgc/Paths.h), [src/core/Paths.cpp](../src/core/Paths.cpp) | `~`/`$ENV` expansion, executable dir, config search order, default output dir | | [include/fgc/Logger.h](../include/fgc/Logger.h), [src/core/Logger.cpp](../src/core/Logger.cpp) | Leveled, thread-safe logger + per-category wire trace; `LOG_TRACE..LOG_ERROR`, `LOG_TRACE_CAT` | | [include/fgc/Geometry.h](../include/fgc/Geometry.h), [src/core/Geometry.cpp](../src/core/Geometry.cpp) | Per-axis degrees↔encoder-counts affine map (`[Motor]` calibration) | -| [include/fgc/ScanGrid.h](../include/fgc/ScanGrid.h), [src/core/ScanGrid.cpp](../src/core/ScanGrid.cpp) | Capture waypoints (CSV or generated) + ping-pong cursor (`[Scan]`) | +| [include/fgc/ScanGrid.h](../include/fgc/ScanGrid.h), [src/core/ScanGrid.cpp](../src/core/ScanGrid.cpp) | Capture waypoints (CSV or generated) + one-way wrapping cursor (`[Scan]`) | | [include/fgc/TelemetryParser.h](../include/fgc/TelemetryParser.h), [src/core/TelemetryParser.cpp](../src/core/TelemetryParser.cpp) | `parseTelemetryLine` (firmware `ST` line) → `std::optional` | | [include/fgc/CommandParser.h](../include/fgc/CommandParser.h), [src/core/CommandParser.cpp](../src/core/CommandParser.cpp) | `parseCommand` whitespace tokenizer → `Command` | | [include/fgc/HelpText.h](../include/fgc/HelpText.h), [src/core/HelpText.cpp](../src/core/HelpText.cpp) | Operator command catalog (`helpCatalog`) + `renderHelp` (console & TUI help) | diff --git a/docs/mqtt-api.md b/docs/mqtt-api.md index 5e6f1af..d4e621a 100644 --- a/docs/mqtt-api.md +++ b/docs/mqtt-api.md @@ -41,7 +41,7 @@ flags** so each update is acted on once (`MqttControlChannel::poll()`). | ControlCode | Behaviour ([CaptureScheduler](../src/core/CaptureScheduler.cpp)) | |-------------|---------------------------------------------------| -| `0` | **Automatic sweep.** Walk the scan grid (ping-pong): `MOVE` to the next `(yaw,pitch)` waypoint, wait for standstill, trigger cameras. | +| `0` | **Automatic sweep.** Walk the scan grid front to back, then start over: `MOVE` to the next `(yaw,pitch)` waypoint, wait for standstill, trigger cameras. | | `1` | **Directed.** `MOVE` yaw to `target_HDG` (pitch held), wait for standstill, trigger. | When a ControlCode message arrives, the program echoes the current code back on the StatusCode topic. diff --git a/include/fgc/CaptureScheduler.h b/include/fgc/CaptureScheduler.h index d219abb..085a85c 100644 --- a/include/fgc/CaptureScheduler.h +++ b/include/fgc/CaptureScheduler.h @@ -26,7 +26,7 @@ namespace fgc { // layer (see GatedCameraSource) - the scheduler only cares that trigger() // eventually succeeds, and retries next tick if it does not. // -// ControlCode 0 = automatic sweep through the ScanGrid (ping-pong); +// ControlCode 0 = automatic sweep through the ScanGrid (front to back, repeating); // ControlCode 1 = drive yaw to the MQTT-supplied target heading (pitch held). // Degrees are converted to encoder counts via Geometry. class CaptureScheduler { diff --git a/include/fgc/ScanGrid.h b/include/fgc/ScanGrid.h index be4e79d..c3898a3 100644 --- a/include/fgc/ScanGrid.h +++ b/include/fgc/ScanGrid.h @@ -14,9 +14,13 @@ struct ScanPoint { double pitch_deg = 0.0; }; -// The ordered list of scan waypoints plus a ping-pong (boustrophedon) cursor: -// the auto-sweep walks the list forward to the last point, then backward to the -// first, repeating - so a bounded (e.g. 180 deg) yaw arc never has to wrap. +// The ordered list of scan waypoints plus a one-way cursor: the auto-sweep walks +// the list front to back, then returns to the first point and repeats, so every +// pass covers the waypoints in the same order and each image sequence is +// directly comparable to the last. +// +// The cost is a long return slew from the last waypoint to the first, since the +// yaw arc is bounded and cannot wrap. Order the grid so that leg is acceptable. // // Points come either from an operator-edited CSV (ScanConfig::grid_file) or, if // none is given, are generated from the [Scan] parameters. @@ -35,14 +39,13 @@ public: const ScanPoint& current() const { return points_[index_]; } const std::vector& points() const { return points_; } - // Step the cursor one waypoint, reversing direction at either end. + // Step the cursor one waypoint, wrapping from the last back to the first. void advance(); - void reset() { index_ = 0; forward_ = true; } + void reset() { index_ = 0; } private: std::vector points_; - std::size_t index_ = 0; - bool forward_ = true; + std::size_t index_ = 0; }; // Exposed for unit testing. diff --git a/src/core/ScanGrid.cpp b/src/core/ScanGrid.cpp index e31b778..b0ddabc 100644 --- a/src/core/ScanGrid.cpp +++ b/src/core/ScanGrid.cpp @@ -64,7 +64,7 @@ std::vector generateScanGrid(const ScanConfig& cfg) { if (pitches.empty()) pitches.push_back(0.0); int n = cfg.yaw_intervals; if (n < 1) n = 1; - // pitch-major, yaw inner (min -> max); the runtime cursor ping-pongs the whole list. + // pitch-major, yaw inner (min -> max); the runtime cursor walks the whole list in order. for (double pitch : pitches) { for (int i = 0; i < n; ++i) { double frac = n > 1 ? static_cast(i) / (n - 1) : 0.0; @@ -92,22 +92,10 @@ ScanGrid ScanGrid::fromConfig(const ScanConfig& cfg) { } void ScanGrid::advance() { - if (points_.size() <= 1) return; - if (forward_) { - if (index_ + 1 < points_.size()) { - ++index_; - } else { - forward_ = false; - --index_; - } - } else { - if (index_ > 0) { - --index_; - } else { - forward_ = true; - ++index_; - } - } + if (points_.empty()) return; + // Wrap rather than reverse: every pass visits the waypoints in the same + // order, at the cost of one long return slew back to the start. + index_ = (index_ + 1) % points_.size(); } } // namespace fgc diff --git a/tests/test_scangrid.cpp b/tests/test_scangrid.cpp index 95d2b3a..128ae91 100644 --- a/tests/test_scangrid.cpp +++ b/tests/test_scangrid.cpp @@ -33,21 +33,50 @@ TEST_CASE("generateScanGrid produces yaw_intervals x pitch_levels points") { CHECK(pts[3].pitch_deg == doctest::Approx(30.0)); } -TEST_CASE("ScanGrid cursor ping-pongs at both ends") { +TEST_CASE("ScanGrid cursor always runs front to back") { ScanGrid g({{0, 0}, {1, 0}, {2, 0}}); CHECK(g.current().yaw_deg == doctest::Approx(0.0)); g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(1.0)); g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(2.0)); - g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(1.0)); // reversed - g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(0.0)); - g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(1.0)); // reversed again + g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(0.0)); // wrapped, not reversed + g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(1.0)); + g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(2.0)); + g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(0.0)); // and again +} + +TEST_CASE("ScanGrid index wraps in step with the waypoints") { + ScanGrid g({{0, 0}, {1, 0}, {2, 0}, {3, 0}}); + for (std::size_t pass = 0; pass < 3; ++pass) { + for (std::size_t i = 0; i < g.size(); ++i) { + CHECK(g.index() == i); + CHECK(g.current().yaw_deg == doctest::Approx(static_cast(i))); + g.advance(); + } + } + CHECK(g.index() == 0); +} + +TEST_CASE("ScanGrid reset returns to the first waypoint") { + ScanGrid g({{0, 0}, {1, 0}, {2, 0}}); + g.advance(); + g.advance(); + CHECK(g.index() == 2); + g.reset(); + CHECK(g.index() == 0); + CHECK(g.current().yaw_deg == doctest::Approx(0.0)); + // Still forward after a reset - there is no direction state to get stuck in. + g.advance(); CHECK(g.current().yaw_deg == doctest::Approx(1.0)); } TEST_CASE("ScanGrid single point and empty are safe") { ScanGrid one({{5, 5}}); one.advance(); CHECK(one.current().yaw_deg == doctest::Approx(5.0)); + one.advance(); + CHECK(one.index() == 0); ScanGrid none; CHECK(none.empty()); + none.advance(); // must not divide by zero or index an empty vector + CHECK(none.empty()); }