diff --git a/config/config.example.ini b/config/config.example.ini index f33fac4..c20df69 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -206,19 +206,17 @@ homing_timeout_s = 65 [Scan] ; Capture scan grid (the (yaw,pitch) waypoints auto-sweep steps through). -; 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. +; grid_file is the ONLY source: that CSV is used verbatim (one +; "yaw_deg,pitch_deg" per line, '#' comments allowed). Nothing is generated - +; leave it blank and auto-sweep stays off with a message saying so, rather than +; the tower inventing an arc nobody chose. +; A relative path resolves against the directory of the config file it is +; written in (config.ini sits at the repo root, so: config/scan.csv). ; 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 -yaw_max_deg = 90 -pitch_levels = 10,30,50 +grid_file = config/scan.csv [Paths] ; Directory for saved .jxl images. Supports leading ~ and $ENV expansion. diff --git a/config/scan-staeffelsberg.csv b/config/scan-staeffelsberg.csv new file mode 100644 index 0000000..e26eff6 --- /dev/null +++ b/config/scan-staeffelsberg.csv @@ -0,0 +1,64 @@ +# Staeffelsberg scan grid: 56 bearings round the full circle at one elevation, +# matching the old firmware's 'ud56' sweep. Generated once from the retired +# [Scan] grid generator (0 -> 353.57 deg in 56 steps, pitch 0) and kept here as +# the explicit list, because the CSV is now the only source of waypoints. +# +# The sweep runs first to last, then returns to the first and repeats - always +# the same order, never in reverse. The return leg is one long slew back across +# the arc; that is the price of every pass being directly comparable. +0.00,0 +6.43,0 +12.86,0 +19.29,0 +25.71,0 +32.14,0 +38.57,0 +45.00,0 +51.43,0 +57.86,0 +64.29,0 +70.71,0 +77.14,0 +83.57,0 +90.00,0 +96.43,0 +102.86,0 +109.29,0 +115.71,0 +122.14,0 +128.57,0 +135.00,0 +141.43,0 +147.86,0 +154.29,0 +160.71,0 +167.14,0 +173.57,0 +180.00,0 +186.43,0 +192.86,0 +199.28,0 +205.71,0 +212.14,0 +218.57,0 +225.00,0 +231.43,0 +237.86,0 +244.28,0 +250.71,0 +257.14,0 +263.57,0 +270.00,0 +276.43,0 +282.86,0 +289.28,0 +295.71,0 +302.14,0 +308.57,0 +315.00,0 +321.43,0 +327.86,0 +334.28,0 +340.71,0 +347.14,0 +353.57,0 diff --git a/config/scan.csv b/config/scan.csv index 0b6c666..ae2e7e1 100644 --- a/config/scan.csv +++ b/config/scan.csv @@ -1,24 +1,30 @@ -# Scan grid — capture waypoints," one ""yaw_deg","pitch_deg"" per line." -# Edit these to define the exact scan coordinates. Blank lines and lines,, -# starting with '#' are ignored. The auto-sweep (ControlCode 0) walks the list,, -# forward then backward (ping-pong), triggering the cameras at each settled, -# point. Degrees are converted to encoder counts via the [Motor] calibration.,, -#,, -# Reference this file from config.ini: [Scan] grid_file = config/scan.csv,, -#,, -# Example: three elevation rows across a 180° yaw arc.,, -0,10, -45,10, -90,10, -135,10, -180,10, -0,5, -45,5, -90,5, -135,5, -180,5, -0,15, -45,15, -90,15, -135,15, -180,15, +# Scan grid - capture waypoints, one "yaw_deg,pitch_deg" per line. +# Edit these to define the exact scan coordinates. Blank lines and lines +# starting with '#' are ignored. This file is the only source of waypoints: +# nothing is generated, so what the tower photographs is always what is written +# here. +# +# The auto-sweep (ControlCode 0) walks the list from the first waypoint to the +# last, triggering the cameras at each settled point, then returns to the first +# and repeats - always in the same order, never in reverse. That return leg is +# one long slew (the yaw arc cannot wrap), so order the list with it in mind. +# Degrees are converted to encoder counts via the [Motor] calibration. +# +# Reference this file from config.ini: [Scan] grid_file = config/scan.csv +# +# Example: three elevation rows across a 180 deg yaw arc. +0,10 +45,10 +90,10 +135,10 +180,10 +0,5 +45,5 +90,5 +135,5 +180,5 +0,15 +45,15 +90,15 +135,15 +180,15 diff --git a/docs/configuration.md b/docs/configuration.md index aacda12..d4ca9e9 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -86,10 +86,7 @@ Parsed and validated by `ConfigLoader` ([src/core/Config.cpp](../src/core/Config | `Motor.` | `enc_deviation` | int | — | \|XACTUAL−XENC\| that means "position untrustworthy". **hardware.ini** | | `Motor.` | `enc_const` | float | 12.8 | Microsteps per encoder count (the TMC's `ENC_CONST`), up to 4 decimals. **hardware.ini**, optional: only a drivetrain whose encoder differs declares it. Wrong by a few percent, the step counter and encoder drift apart as the axis turns, and homing takes the drift for a stop (staeffelsberg: 12.5, from its old firmware) | | `Motor.` | `sw_mode` / `has_encoder` | int / bool | — | TMC `SW_MODE` (physical endstop switches on REFL/REFR) and whether this axis's encoder is aligned and usable. **hardware.ini** | -| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints; empty → generate | -| `Scan` | `yaw_intervals` | int | `56` | Generated yaw positions across `[yaw_min_deg, yaw_max_deg]` | -| `Scan` | `yaw_min_deg`/`yaw_max_deg` | float | `-90`/`90` | Generated yaw arc | -| `Scan` | `pitch_levels` | csv | `0` | Generated pitch elevations (deg) | +| `Scan` | `grid_file` | string | — | CSV of `yaw_deg,pitch_deg` waypoints. The only source of a scan grid; empty → auto-sweep disabled | ### `[Motor]` calibration & `[Scan]` grid @@ -179,15 +176,21 @@ the tower can measure it. Yaw is untouched by it. #### Scan grid The capture **scan grid** is the ordered `(yaw,pitch)` waypoints auto-sweep visits, always front to -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. +back, then round to the first again — never in reverse. `[Scan] grid_file` points at an editable CSV +([config/scan.csv](../config/scan.csv)) and is the **only** source of waypoints. + +**Nothing is generated.** Leave `grid_file` blank (or name a file that is missing, malformed or +empty) and the program logs why and leaves auto-sweep off, showing the reason in the TUI's scan-grid +panel. A tower that invented its own arc would archive images under bearings nobody chose, with no +file to read back to find out what was photographed. The pre-2026-09 keys `yaw_intervals`, +`yaw_min_deg`, `yaw_max_deg` and `pitch_levels` are gone; a config still carrying them gets a +startup warning naming them. **Grid yaw values are true bearings.** Yaw travel is under a full turn, so there is always an unreachable arc; waypoints falling in it are **skipped** with a warning rather than clamped to the nearest limit — clamping would image a direction nobody asked for and publish it under the bearing -they did. A one-line audit after each home reports how many waypoints are affected. Generated grids -interpolate `yaw_min_deg → yaw_max_deg` linearly and wrap each point, so an arc crossing north is -written monotonically (`340` → `380`). +they did. A one-line audit after each home reports how many waypoints are affected. Write an arc +crossing north monotonically (`340` → `380`) rather than letting it wrap back through zero. Each camera's output subfolder is its `[Camera.] label` (defaulting to the block name); nothing is derived from the order of the blocks. diff --git a/docs/modules-reference.md b/docs/modules-reference.md index fc0cd86..0a78b2d 100644 --- a/docs/modules-reference.md +++ b/docs/modules-reference.md @@ -11,7 +11,7 @@ Per-file reference for the refactored tree, plus the shared data structures. | [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/FileLogSink.h](../include/fgc/FileLogSink.h), [src/core/FileLogSink.cpp](../src/core/FileLogSink.cpp) | The persistent log file: append, size rotation, count-based pruning, a self-describing header per file. Runs inside `Logger`'s non-recursive lock, so it never logs - failures latch into `failed()`/`lastError()` for the caller to report | | [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) + one-way wrapping cursor (`[Scan]`) | +| [include/fgc/ScanGrid.h](../include/fgc/ScanGrid.h), [src/core/ScanGrid.cpp](../src/core/ScanGrid.cpp) | Capture waypoints (CSV only, never 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/include/fgc/Config.h b/include/fgc/Config.h index c3833f3..3a587c2 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -317,14 +317,11 @@ struct UiConfig { }; // [Scan]: source of the capture scan grid (the (yaw,pitch) waypoints the -// auto-sweep steps through). If grid_file is set, the CSV is loaded verbatim; -// otherwise a grid is generated from the parameters below (see ScanGrid). +// auto-sweep steps through). The CSV is the only source - it is loaded verbatim +// and there is no generated fallback, so what a tower photographs is always a +// file somebody wrote and can be read back (see ScanGrid). struct ScanConfig { - std::string grid_file; // path to an editable yaw_deg,pitch_deg CSV; empty => generate - int yaw_intervals = 56; // generated: yaw positions across [yaw_min_deg, yaw_max_deg] - double yaw_min_deg = -90.0; - double yaw_max_deg = 90.0; - std::string pitch_levels = "0"; // generated: comma list of pitch elevations (deg) + std::string grid_file; // path to an editable yaw_deg,pitch_deg CSV; required for auto-sweep }; // [Test] / [TestProfile.]: the hardware self-test command. A profile is a diff --git a/include/fgc/ScanGrid.h b/include/fgc/ScanGrid.h index c3898a3..c52e79c 100644 --- a/include/fgc/ScanGrid.h +++ b/include/fgc/ScanGrid.h @@ -22,15 +22,17 @@ struct ScanPoint { // 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. +// Points come from an operator-edited CSV (ScanConfig::grid_file) and nowhere +// else. There is deliberately no fallback grid: a tower that invented its own +// waypoints would archive images under bearings nobody chose, and the operator +// would have no file to point at to find out what was photographed. class ScanGrid { public: ScanGrid() = default; explicit ScanGrid(std::vector points) : points_(std::move(points)) {} - // Build from config: load grid_file if set (throws std::runtime_error on a - // missing/!malformed file), else generate from the parameters. + // Load grid_file. Throws std::runtime_error if it is unset, missing, + // malformed or empty; the caller reports that and leaves auto-sweep off. static ScanGrid fromConfig(const ScanConfig& cfg); bool empty() const { return points_.empty(); } @@ -50,6 +52,5 @@ private: // Exposed for unit testing. std::vector parseScanCsv(const std::string& text); -std::vector generateScanGrid(const ScanConfig& cfg); } // namespace fgc diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index 1c306c7..11bc1f8 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -157,7 +157,6 @@ struct CaptureView { // Defined scan grid (the auto-mode waypoints) with the live cursor // flagged, surfaced in the expanded camera view. std::vector scan_grid; - bool scan_from_file = false; // grid_file CSV vs generated bool scan_pitch = false; // 2-axis rig (show pitch column) std::string scan_error; // grid_file load failure (auto-sweep off) diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 59f4bab..ceb8fd1 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -925,7 +925,6 @@ struct Application::Impl { // Defined scan grid + live cursor (read on the control thread, same as the // scheduler that mutates it, so no locking is needed). - s.capture.scan_from_file = !cfg.scan.grid_file.empty(); s.capture.scan_pitch = t.pitch_present; s.capture.scan_error = scan_load_error_; s.capture.scan_grid.clear(); diff --git a/src/core/Config.cpp b/src/core/Config.cpp index d4f872d..1595801 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -521,12 +521,8 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { axisDrivetrain("yaw", cfg.drivetrain.yaw); axisDrivetrain("pitch", cfg.drivetrain.pitch); - // [Scan]: scan-grid source (explicit CSV file, else generated). - cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file); - cfg.scan.yaw_intervals = getInt(kv, "Scan.yaw_intervals", cfg.scan.yaw_intervals); - cfg.scan.yaw_min_deg = getDouble(kv, "Scan.yaw_min_deg", cfg.scan.yaw_min_deg); - cfg.scan.yaw_max_deg = getDouble(kv, "Scan.yaw_max_deg", cfg.scan.yaw_max_deg); - cfg.scan.pitch_levels = get(kv, "Scan.pitch_levels", cfg.scan.pitch_levels); + // [Scan]: the scan-grid CSV. No file, no auto-sweep - nothing is generated. + cfg.scan.grid_file = get(kv, "Scan.grid_file", cfg.scan.grid_file); // ---- validation ---- if (cfg.general.image_interval <= 0) @@ -601,6 +597,20 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { "setup'), and drivetrain keys belong under [Motor.yaw] / " "[Motor.pitch], not [Motor]"; + // Keys from when a blank grid_file meant "generate a grid". The generator is + // gone: the CSV is the only source. Ignoring these quietly would leave an + // operator believing they had set the scan arc when nothing reads it. + static const char* kRemovedScanKeys[] = { + "Scan.yaw_intervals", "Scan.yaw_min_deg", "Scan.yaw_max_deg", "Scan.pitch_levels", + }; + std::string stale_scan; + for (const char* k : kRemovedScanKeys) + if (kv.count(k)) stale_scan += (stale_scan.empty() ? "" : ", ") + std::string(k + 5); + if (!stale_scan.empty()) + LOG_WARN << "[Scan] ignoring keys nothing reads: " << stale_scan + << " -- grids are no longer generated; list the waypoints in the " + "grid_file CSV instead"; + return cfg; } diff --git a/src/core/ScanGrid.cpp b/src/core/ScanGrid.cpp index b0ddabc..1b54b7e 100644 --- a/src/core/ScanGrid.cpp +++ b/src/core/ScanGrid.cpp @@ -16,20 +16,6 @@ std::string trim(const std::string& s) { return s.substr(b, e - b + 1); } -std::vector parseDoubleList(const std::string& csv) { - std::vector out; - size_t start = 0; - while (start <= csv.size()) { - size_t comma = csv.find(',', start); - std::string tok = - trim(csv.substr(start, comma == std::string::npos ? std::string::npos : comma - start)); - if (!tok.empty()) out.push_back(std::stod(tok)); - if (comma == std::string::npos) break; - start = comma + 1; - } - return out; -} - } // namespace std::vector parseScanCsv(const std::string& text) { @@ -58,37 +44,21 @@ std::vector parseScanCsv(const std::string& text) { return pts; } -std::vector generateScanGrid(const ScanConfig& cfg) { - std::vector pts; - std::vector pitches = parseDoubleList(cfg.pitch_levels); - 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 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; - ScanPoint p; - p.yaw_deg = cfg.yaw_min_deg + frac * (cfg.yaw_max_deg - cfg.yaw_min_deg); - p.pitch_deg = pitch; - pts.push_back(p); - } - } - return pts; -} - ScanGrid ScanGrid::fromConfig(const ScanConfig& cfg) { - if (!cfg.grid_file.empty()) { - std::ifstream f(cfg.grid_file); - if (!f) throw std::runtime_error("cannot open scan grid file: " + cfg.grid_file); - std::stringstream buf; - buf << f.rdbuf(); - auto pts = parseScanCsv(buf.str()); - if (pts.empty()) - throw std::runtime_error("scan grid file has no waypoints: " + cfg.grid_file); - return ScanGrid(std::move(pts)); - } - return ScanGrid(generateScanGrid(cfg)); + // No grid_file, no grid. Generating a default sweep here would put a tower + // on the air photographing bearings nobody picked, under a configuration + // that shows no waypoints at all; refusing leaves auto-sweep off and says + // exactly what is missing. + if (cfg.grid_file.empty()) + throw std::runtime_error("no scan grid configured: set [Scan] grid_file to a CSV of " + "'yaw_deg,pitch_deg' waypoints (see config/scan.csv)"); + std::ifstream f(cfg.grid_file); + if (!f) throw std::runtime_error("cannot open scan grid file: " + cfg.grid_file); + std::stringstream buf; + buf << f.rdbuf(); + auto pts = parseScanCsv(buf.str()); + if (pts.empty()) throw std::runtime_error("scan grid file has no waypoints: " + cfg.grid_file); + return ScanGrid(std::move(pts)); } void ScanGrid::advance() { diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 04dd5a5..02a6d68 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -501,8 +501,7 @@ Element cameraDetailPanel(const CaptureView& c, ScrollState& st) { // --- Scan grid --- rows.push_back(separator()); - rows.push_back(hbox({text("SCAN GRID ") | bold | color(Color::Blue), - text(c.scan_from_file ? "(CSV file)" : "(generated)") | dim, filler(), + rows.push_back(hbox({text("SCAN GRID ") | bold | color(Color::Blue), filler(), text(std::to_string(c.scan_grid.size()) + " waypoints") | dim})); if (!c.scan_error.empty()) { diff --git a/tests/test_scangrid.cpp b/tests/test_scangrid.cpp index 128ae91..e8e8fcf 100644 --- a/tests/test_scangrid.cpp +++ b/tests/test_scangrid.cpp @@ -2,6 +2,8 @@ #include "fgc/ScanGrid.h" +#include + using namespace fgc; TEST_CASE("parseScanCsv reads waypoints and skips comments/blanks") { @@ -17,20 +19,18 @@ TEST_CASE("parseScanCsv throws on a malformed line") { CHECK_THROWS(parseScanCsv("1,abc\n")); } -TEST_CASE("generateScanGrid produces yaw_intervals x pitch_levels points") { +TEST_CASE("fromConfig refuses to invent a grid when no file is named") { + // The generator is gone on purpose: a blank grid_file must leave auto-sweep + // off, not produce waypoints nobody chose. ScanConfig c; - c.yaw_intervals = 3; - c.yaw_min_deg = -90; - c.yaw_max_deg = 90; - c.pitch_levels = "10,30"; - auto pts = generateScanGrid(c); - REQUIRE(pts.size() == 6); - // pitch-major, yaw inner (min -> max) - CHECK(pts[0].yaw_deg == doctest::Approx(-90.0)); - CHECK(pts[0].pitch_deg == doctest::Approx(10.0)); - CHECK(pts[1].yaw_deg == doctest::Approx(0.0)); - CHECK(pts[2].yaw_deg == doctest::Approx(90.0)); - CHECK(pts[3].pitch_deg == doctest::Approx(30.0)); + CHECK(c.grid_file.empty()); + CHECK_THROWS_AS(ScanGrid::fromConfig(c), std::runtime_error); +} + +TEST_CASE("fromConfig reports a grid_file that is not there") { + ScanConfig c; + c.grid_file = "/nonexistent/definitely-not-a-scan-grid.csv"; + CHECK_THROWS_AS(ScanGrid::fromConfig(c), std::runtime_error); } TEST_CASE("ScanGrid cursor always runs front to back") {