From bdb4b82885e37a250b66b2da54871227b2113c0c Mon Sep 17 00:00:00 2001 From: pgdalmeida Date: Wed, 1 Jul 2026 10:02:34 +0200 Subject: [PATCH] Introduced harware in-situ testing --- CMakeLists.txt | 3 + README.md | 3 + config/config.example.ini | 41 ++ docs/configuration.md | 20 +- docs/roadmap.md | 49 +++ docs/test-command.md | 95 +++++ docs/test-roadmap.md | 75 ++++ include/fgc/Config.h | 25 ++ include/fgc/HostMetrics.h | 49 +++ include/fgc/TestReport.h | 68 ++++ include/fgc/TestRunner.h | 111 ++++++ include/fgc/ui/UiSnapshot.h | 11 + src/core/Application.cpp | 320 ++++++++++++++- src/core/Config.cpp | 66 ++++ src/core/HelpText.cpp | 13 +- src/core/HostMetrics.cpp | 117 ++++++ src/core/TestReport.cpp | 170 ++++++++ src/core/TestRunner.cpp | 760 ++++++++++++++++++++++++++++++++++++ src/ui/TuiUi.cpp | 4 + tests/CMakeLists.txt | 3 + tests/test_hostmetrics.cpp | 38 ++ tests/test_testreport.cpp | 88 +++++ tests/test_testrunner.cpp | 110 ++++++ 23 files changed, 2212 insertions(+), 27 deletions(-) create mode 100644 docs/roadmap.md create mode 100644 docs/test-command.md create mode 100644 docs/test-roadmap.md create mode 100644 include/fgc/HostMetrics.h create mode 100644 include/fgc/TestReport.h create mode 100644 include/fgc/TestRunner.h create mode 100644 src/core/HostMetrics.cpp create mode 100644 src/core/TestReport.cpp create mode 100644 src/core/TestRunner.cpp create mode 100644 tests/test_hostmetrics.cpp create mode 100644 tests/test_testreport.cpp create mode 100644 tests/test_testrunner.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index aad1e63..1384b1d 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -53,6 +53,9 @@ add_library(fgc_core STATIC src/core/HelpText.cpp src/core/DumpParser.cpp src/core/DiagParser.cpp + src/core/TestReport.cpp + src/core/HostMetrics.cpp + src/core/TestRunner.cpp src/core/Calibration.cpp src/core/CalibrationRoutine.cpp src/core/MtiProtocol.cpp diff --git a/README.md b/README.md index d2a04fd..0d7b5f4 100644 --- a/README.md +++ b/README.md @@ -187,8 +187,11 @@ libjxl-dev`, plus the Vimba X SDK under `/opt/VimbaX` for `-DWITH_VIMBA=ON`. | [docs/deployment.md](docs/deployment.md) | Step-by-step deploy to the gimbal LattePanda (systemd) | | [docs/configuration.md](docs/configuration.md) | `config.ini` keys, CLI flags, console commands | | [docs/mqtt-api.md](docs/mqtt-api.md) | MQTT topic catalog and payloads | +| [docs/test-command.md](docs/test-command.md) | The `test` hardware self-test suite: subsystems, profiles, reports, baselines | | [docs/modules-reference.md](docs/modules-reference.md) | Per-file reference and data structures | | [docs/known-issues.md](docs/known-issues.md) | Status of past issues + remaining caveats | +| [docs/roadmap.md](docs/roadmap.md) | Forward-looking project roadmap | +| [docs/test-roadmap.md](docs/test-roadmap.md) | Planned tests blocked on not-yet-integrated components | ## Repository layout diff --git a/config/config.example.ini b/config/config.example.ini index f4e9199..ff92877 100644 --- a/config/config.example.ini +++ b/config/config.example.ini @@ -111,6 +111,47 @@ mock_camera = false mock_serial = false mock_imu = false +[Test] +; Hardware self-test command (`test`). `profile` is the default profile used when +; none is named on the command line. `baseline_file` is where `test baseline` pins +; a golden report (empty => /test_baseline.txt). Each [TestProfile.*] is a +; named bundle of repetition counts, sampling windows and pass thresholds; modules +; read the keys they need (unknown keys are ignored, so new tests can add knobs +; here without code changes). +profile = standard +baseline_file = + +[TestProfile.standard] +reps = 5 ; repetitions for homing / friction / balance +drift_warn_pct = 15 ; flag a metric drifting more than this vs baseline +drift_gates_pass = false ; true => a drift flag also fails the metric +home_spread_max_counts = 80 ; max endstop-count spread across homings +home_ms_min = 4000 ; expected homing-time band (ms) +home_ms_max = 20000 +encoder_diag_reps = 2 +imu_sample_window_ms = 3000 ; static window for IMU health +imu_drift_window_ms = 60000 ; long static window for the yaw-drift fit +imu_yaw_noise_max_deg = 0.5 +imu_yaw_drift_max_deg_min = 1.0 +imu_expected_hz = 100 +; imu_expected_profile = VRU_general ; assert the active XKF profile, if set +backlash_step_counts = 200,500,1000 ; small steps probing reversal dead-band +balance_steps = 10 ; pitch up/down current-profile resolution +balance_asym_max = 4 ; max |up-down| current (CS) before "imbalanced" +cpu_temp_max_c = 85 +disk_free_min_gb = 5 +ram_avail_min_mb = 256 + +[TestProfile.quick] +reps = 3 +imu_sample_window_ms = 1000 +imu_drift_window_ms = 10000 + +[TestProfile.strict] +reps = 10 +drift_warn_pct = 8 +drift_gates_pass = true + [UI] ; Full-screen terminal dashboard (sectioned, colored, live status + log pane). ; false => headless line console (default; stdin commands, stdout logs). diff --git a/docs/configuration.md b/docs/configuration.md index f9dafa7..f4436a7 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -179,8 +179,21 @@ Handled in `Application::Impl::handleCommand`. | `set camera fps ` | Camera acquisition frame rate (real camera only) | | `set fps ` | Capture interval rate (images/second) | | `set motorctl ` | Forward a raw command to the motor controller (e.g. `set motorctl MOVE Y 20000`) | +| `test [ []] []` | Run the hardware self-test suite (see below) | +| `test list` / `test baseline` | List profiles / pin the last report as the comparison baseline | | `exit` | Quit (Ctrl-D also works) | +### `test` — hardware self-test suite + +`test` validates the gimbal/IMU/host on real hardware, writes a text report to +`logs/test_*.log`, and compares each run to a pinned baseline and the previous run. +It is modular (`test gimbal`, `test imu drift`, `test host`, …) and driven by +**profiles** configured under `[Test]` / `[TestProfile.]` in `config.ini` +(repetition counts, sampling windows, pass thresholds; default `standard`). While a +test (or calibration, or homing) runs, interfering commands are ignored and **Esc** +cancels. Full reference: [test-command.md](test-command.md). Replaces the former +`gimbal diag` (now folded into `test gimbal encoder`). + ## Terminal dashboard (TUI) An **optional** full-screen interface (`--tui`, or `[UI] enable_tui = true`) renders the tower @@ -211,9 +224,10 @@ one node in [src/ui/TuiUi.cpp](../src/ui/TuiUi.cpp). profile the device supports, by name, with the active one marked `●` (selected). **Activity strip** — a compact section between the log and the key bar that shows the -currently-running special operation with live progress (`gimbal calib`, `gimbal diag`, homing, capture -scan) **and persists the last calibration/diagnostics result** (PASS/FAIL, age) so it doesn't scroll -away in the log. It only appears once something has run. +currently-running special operation with live progress (`test`, `gimbal calib`, homing, capture +scan) **and persists the last test/calibration result** (PASS/FAIL, age) so it doesn't scroll +away in the log. It only appears once something has run. While a procedure is running, **Esc** +cancels it. When a `gimbal calib` completes it is applied to the live session and the strip shows a highlighted yes/no prompt — **`Save this calibration to config as the new default? (y / n)`**. Press **`y`** to diff --git a/docs/roadmap.md b/docs/roadmap.md new file mode 100644 index 0000000..fb36c6c --- /dev/null +++ b/docs/roadmap.md @@ -0,0 +1,49 @@ +# Fire Gimbal Control — Roadmap + +Forward-looking plan for the software/firmware. Present-state docs live alongside +this one ([architecture.md](architecture.md), [modules-reference.md](modules-reference.md), +[known-issues.md](known-issues.md), …); this file records **intent**, not status. +Keep entries dated; move shipped items into the reference docs. + +## Near-term / in progress + +- **Hardware self-test framework (`test` command).** Modular on-rig validation + with profiles, on-disk text reports, and baseline/drift comparison — replaces + `gimbal diag`. Covers gimbal (homing, encoder, friction, backlash, balance), IMU + (config, health, drift), and host/LattePanda (thermal, disk, memory, load). See + [test-command.md](test-command.md); tests blocked on not-yet-integrated + components are tracked in [test-roadmap.md](test-roadmap.md). + - **Firmware support (next):** a homing-report `HM` line (firmware-measured + homing duration + endstop counts) and a load-capture `LP` primitive + (peak/mean CS, SG, PWM during a move) so the friction/balance/homing-time + metrics come off the firmware clock instead of host `ST` sampling; plus a + `PING`/`PONG` link-health command for `test system link`. + +## Planned components & integrations + +- **MQTT → RabbitMQ migration.** Move the control/telemetry channel onto the + project's RabbitMQ stack. Decision pending between (a) RabbitMQ's MQTT plugin + (near-zero firmware change, keeps QoS/retain/reconnect) and (b) native AMQP via + a new `IControlChannel` implementation (richer routing/durable queues, but must + re-engineer retained "last value" semantics). The abstraction seam already + exists (`IControlChannel`). _(TODO: confirm approach, target release.)_ +- **DHT11 environmental sensor.** Temperature + humidity on the Arduino side; new + firmware `READ DHT` command, host `env` telemetry, condensation monitoring. + Enables the `env/dht11` self-test. _(TODO: wiring, sampling cadence.)_ +- **RGB camera.** Integrate the production RGB sensor end-to-end (acquire → JXL → + CamEvent); enables the `camera/rgb` self-test. _(TODO: model/SDK, mounting.)_ +- **Thermal camera.** Add the thermal sensor with radiometric handling (NUC, + temperature range); enables the `camera/thermal` self-test. _(TODO: model/SDK, + calibration workflow.)_ + +## Cross-cutting / later + +- **Supply-voltage telemetry** (bus-voltage sense → firmware) to enable + `gimbal/supply` and brown-out detection. _(TODO: hardware path.)_ +- **Generic, multi-tower deployment.** Continue removing site-specific + assumptions (tower identity is already config-driven). _(TODO.)_ +- _Stubs to expand: long-term storage/offload, remote update, + observability/metrics, security hardening._ + +> Detailed, implementer-ready specs for tests blocked on the above hardware live +> in [test-roadmap.md](test-roadmap.md). diff --git a/docs/test-command.md b/docs/test-command.md new file mode 100644 index 0000000..e7194e0 --- /dev/null +++ b/docs/test-command.md @@ -0,0 +1,95 @@ +# The `test` command — hardware self-test suite + +`test` runs modular hardware self-tests on the real gimbal, writes a plain-text +report to disk, and compares each run against a pinned baseline and the previous +run so both outright failures **and** slow drift are visible. It replaces the old +firmware-only `gimbal diag` (the firmware motor self-test is now one leaf inside +`test gimbal encoder`). + +Implementation: [`TestRunner`](../src/core/TestRunner.cpp) (worker thread, mirrors +`CalibrationRoutine`) + the pure [`TestReport`](../src/core/TestReport.cpp) model +(`formatTestReport`/`parseTestReport`/`applyComparison`) and +[`HostMetrics`](../src/core/HostMetrics.cpp) host reads. + +## Usage + +``` +test [ []] [] +test list # list configured profiles +test baseline # pin the most recent report from this session as the baseline +``` + +The command is a three-level hierarchy; omitting a level widens scope. A trailing +token that names a configured profile selects the profile at any position. + +| Command | Runs | +|---------|------| +| `test` | everything | +| `test gimbal` | all gimbal leaves | +| `test gimbal homing` | just homing | +| `test imu drift` | just IMU yaw drift | +| `test host` | host thermal/disk/memory/load (no hardware needed) | +| `test gimbal encoder strict` | one leaf, `strict` profile | +| `test strict` | everything, `strict` profile | + +While a test runs, interfering commands (moves, homing, a second test, capture, +`refresh`) are ignored; **Esc** (or `stop`) cancels. Pure-UI keys (`p`, `i`, `c`, +`g`, `?`) keep working. + +## Subsystems and leaves + +| Subsystem | Leaf | Checks | +|-----------|------|--------| +| `gimbal` | `homing` | endstop-count + travel + homing-time reproducibility | +| `gimbal` | `encoder` | firmware DIAG tracking quality (repeated) + hold-corrector drift | +| `gimbal` | `friction` | full-range traverse time + motor load per direction | +| `gimbal` | `backlash` | reversal dead-band (encoder error) at small steps | +| `gimbal` | `balance` | pitch up-vs-down per-step peak current (center-of-mass symmetry) | +| `imu` | `config` | device identity, output mode, sample rate, active XKF profile | +| `imu` | `health` | rate, dropped samples, angle noise, accel-norm, temperature | +| `imu` | `drift` | yaw drift rate (deg/min) over a long static window | +| `host` | `thermal` | hottest CPU thermal zone (`/sys/class/thermal`) | +| `host` | `disk` | free space on the image partition (`statvfs`) | +| `host` | `memory` | available RAM / swap (`/proc/meminfo`) | +| `host` | `load` | load average vs CPU count | + +Gimbal leaves require a homed gimbal (`home` first); IMU leaves require the IMU +(`[Features] enable_imu`). `host` needs neither, so `test host` doubles as a fast +non-intrusive smoke check. + +> Some metrics are most accurate with firmware support (a homing-report `HM` line +> and a load-capture `LP` primitive); until those land the modules fall back to +> sampling the `ST` telemetry stream and note it in the report. See +> [test-roadmap.md](test-roadmap.md). + +## Profiles + +A profile is a named bundle of repetition counts, sampling windows and pass +thresholds, configured in `config.ini` under `[Test]` and `[TestProfile.]` +(see [configuration.md](configuration.md) and the example config). The default is +`standard` (5 reps). Unknown keys are ignored, so new tests add knobs without code +changes. Ship profiles like `quick` (fewer reps) and `strict` (more reps, tighter +drift gate). + +## Pass / fail and comparison + +Each metric can carry an **absolute threshold** from the profile. Independently, +every metric is compared to the **pinned baseline** and the **previous run**; a +move worse than `drift_warn_pct` is flagged, and if `drift_gates_pass = true` a +flagged metric also fails. A section fails if any of its metrics fail; the run +fails if any section fails. + +## Reports + +Each run writes `logs/test_YYYYMMDD-HHMMSS.log` (under the per-user log dir). The +format is human-readable and re-parseable: + +``` +[host_disk] PASS 0 ms # image partition free space + free_gb = 3.65 GB PASS base=3.66 prev=3.66 drift=-0.001% + used_pct = 2.65 % PASS +``` + +`test baseline` copies the most recent report to the baseline file +(`[Test] baseline_file`, default `/test_baseline.txt`); subsequent runs +show `base=` columns against it. diff --git a/docs/test-roadmap.md b/docs/test-roadmap.md new file mode 100644 index 0000000..7faed3c --- /dev/null +++ b/docs/test-roadmap.md @@ -0,0 +1,75 @@ +# Test Roadmap + +Tests planned for the [`test` command](test-command.md) that depend on hardware or +services not yet integrated. Each lands as a new subsystem/leaf in the existing +framework (profiles, baseline comparison, text report, Esc-cancel lock-out all +apply unchanged). **Status: planned — blocked on component.** When a component +lands, promote its entry into [test-command.md](test-command.md) and the taxonomy +table. + +## 1. `env / *` — environmental sensing — *blocked on: DHT11* + +The DHT11 (temperature + humidity) attaches to the Arduino/firmware side. + +- **`env / dht11`** — _Healthy:_ sensor responds every read; temperature and + humidity in plausible range; values update (not stuck). _Checks:_ issue the + firmware read N times over a window; verify each returns a valid frame (DHT11 + checksum ok). _Metrics:_ temperature °C, relative humidity %, read-failure count, + **dewpoint margin** (flag condensation risk when humidity is high and temperature + is near the computed dewpoint — relevant for an outdoor tower enclosure). + _Firmware:_ add a `READ DHT` command → `EN T RH OK|ERR` line + (DHT11 is bit-banged; the firmware already owns timing-critical I/O). _Host:_ an + `EnvReport` parser mirroring `DiagParser`; add `IEnvSource` if the IMU-style + abstraction is wanted, else read over the motor serial link. + +## 2. `comms / *` — message bus — *blocked on: RabbitMQ integration* + +Validates the telemetry/control transport. Tests whichever `IControlChannel` is +active (MQTT today, RabbitMQ once integrated — see the MQTT→RabbitMQ migration in +[roadmap.md](roadmap.md)). + +- **`comms / broker`** — _Healthy:_ broker reachable; auth succeeds; the expected + exchange/queue (or MQTT topic tree) exists. _Checks:_ connect with configured + credentials; assert connection within timeout; for RabbitMQ assert the + exchange/queue topology is present. _Metrics:_ connect latency, auth ok, + topology-present. +- **`comms / roundtrip`** — _Healthy:_ a published message returns to a subscriber + promptly with no loss. _Checks:_ subscribe to a loopback/test topic (or a + dedicated test queue), publish K sequenced messages, time each round-trip, detect + gaps. _Metrics:_ mean/95th round-trip latency, loss %, out-of-order count. The + message-bus analogue of `system/link`; for RabbitMQ also surface confirms/acks. + +## 3. `camera / *` — imaging — *blocked on: RGB + thermal cameras on the rig* + +One leaf per physical sensor; shares a common frame-quality core. Uses the existing +`ICameraSource`/`ImagePipeline`. + +- **`camera / rgb`** — _Healthy:_ enumerates and connects; delivers frames at ~the + configured rate; exposure/gain give a usable histogram (not clipped); few + dead/hot pixels; frames sharp when focused; timestamps strictly increase; few + dropped frames; JXL encode keeps up. _Checks:_ acquire a short burst; measure + realized FPS vs configured, histogram clipping %, dead/hot-pixel count + (dark/flat-field heuristic), a **sharpness/focus metric** (variance-of-Laplacian), + timestamp monotonicity, dropped-frame count from sequence gaps, mean JXL encode + time vs frame interval. _Metrics:_ realized FPS, exposure/histogram health, + dead/hot-pixel count, focus score, dropped frames, encode throughput. +- **`camera / thermal`** — all of the above **plus** radiometric sanity: + temperatures within expected range, NUC (non-uniformity correction) recent/valid, + no excessive fixed-pattern noise. _Checks:_ as RGB, plus a flat-scene uniformity + check and a temperature range/sanity check; verify NUC/shutter recency if exposed. +- _Shared:_ factor a `frameQualityMetrics(frame)` helper so both leaves (and future + cameras) reuse the histogram/sharpness/dead-pixel code. + +## 4. Deferred / needs additional hardware to measure + +- **`gimbal / supply`** — supply-voltage sag during high-acceleration moves. + _Blocked on:_ a voltage-sense path to the firmware (the TMC/board doesn't report + bus voltage today). If added, the firmware reports it alongside the `LP` load + capture; metric = min bus voltage under load vs nominal. + +## Conventions for all roadmap tests + +Same as the live suite: profiles carry per-metric thresholds + drift gates; results +go into the one text report with baseline/prev comparison; new firmware lines keep +the `OK`/`ERR` + 2-letter-prefix contract with a matching pure host parser and +`firmware/test/` coverage; new host-only sources (cameras, broker) need no firmware. diff --git a/include/fgc/Config.h b/include/fgc/Config.h index fde5515..51ff99e 100644 --- a/include/fgc/Config.h +++ b/include/fgc/Config.h @@ -77,6 +77,30 @@ struct ScanConfig { std::string pitch_levels = "0"; // generated: comma list of pitch elevations (deg) }; +// [Test] / [TestProfile.]: the hardware self-test command. A profile is a +// named bundle of repetition counts, sampling windows and pass thresholds; the +// modules read the values they care about by key (open map so new tests can add +// knobs without touching the loader). +struct TestProfile { + std::string name; + std::map params; // numeric knobs: reps, *_window_ms, *_max_* + std::map text; // raw values incl. comma lists (e.g. step sizes) + + double get(const std::string& key, double fallback) const; + int geti(const std::string& key, int fallback) const; + bool getBool(const std::string& key, bool fallback) const; + std::string gets(const std::string& key, const std::string& fallback = "") const; + std::vector getLongList(const std::string& key) const; // parse "200,500,1000" +}; + +struct TestConfig { + std::string default_profile = "standard"; + std::string baseline_file; // empty => /test_baseline.txt + std::map profiles; + + const TestProfile* find(const std::string& name) const; // nullptr if absent +}; + // Which homed endstop becomes yaw 0 deg, with degrees rising toward the other // limit (which then lands near +360, a bit less for the soft-limit/mechanical // gap). Off = use the configured/calibrated yaw zero_count as-is. @@ -96,6 +120,7 @@ struct AppConfig { long enc_error_warn_counts = 400; // [Motor] live encoder-error WARN (0=off) ScanConfig scan; // [Scan] grid source ImuConfig imu; // [IMU] Xsens MTi serial device + TestConfig test; // [Test] hardware self-test profiles // Capture rate in images/second (derived from general.image_interval). double image_rate() const; diff --git a/include/fgc/HostMetrics.h b/include/fgc/HostMetrics.h new file mode 100644 index 0000000..0e4d180 --- /dev/null +++ b/include/fgc/HostMetrics.h @@ -0,0 +1,49 @@ +#pragma once + +#include + +namespace fgc::hostmetrics { + +// Host (LattePanda / Linux) health reads for the `test host` subsystem. Pure +// parsers are split from the live readers so they unit-test without a real /sys +// or /proc. All readers degrade gracefully (ok=false) rather than throw. + +struct ThermalInfo { + bool ok = false; + double max_temp_c = 0.0; // hottest thermal zone + std::string hottest_zone; // e.g. "x86_pkg_temp" + bool throttled = false; // best-effort (false if unknown) +}; + +struct DiskInfo { + bool ok = false; + std::string path; + double total_gb = 0.0; + double free_gb = 0.0; + double used_pct = 0.0; +}; + +struct MemInfo { + bool ok = false; + double total_mb = 0.0; + double avail_mb = 0.0; + double swap_used_mb = 0.0; +}; + +struct LoadInfo { + bool ok = false; + double load1 = 0.0, load5 = 0.0, load15 = 0.0; + int cpus = 0; +}; + +// ---- live readers ---- +ThermalInfo readThermal(); // scans /sys/class/thermal +DiskInfo readDisk(const std::string& path); // statvfs() on the filesystem holding `path` +MemInfo readMeminfo(); // /proc/meminfo +LoadInfo readLoad(); // getloadavg() + nproc + +// ---- pure parsers (reused by the readers; exposed for tests) ---- +MemInfo parseMeminfo(const std::string& proc_meminfo); +double parseMilliCelsius(const std::string& zone_temp_contents); // "52000\n" -> 52.0 + +} // namespace fgc::hostmetrics diff --git a/include/fgc/TestReport.h b/include/fgc/TestReport.h new file mode 100644 index 0000000..65e3b7e --- /dev/null +++ b/include/fgc/TestReport.h @@ -0,0 +1,68 @@ +#pragma once + +#include +#include + +namespace fgc { + +// Structured result of a hardware self-test run (the `test` command). Pure data +// + I/O-free format/parse/compare, mirroring DiagParser so it unit-tests in +// fgc_core. formatTestReport() emits a plain-text report that is also stable +// enough for parseTestReport() to read back for baseline/previous comparison. + +struct TestMetric { + std::string name; // e.g. "yaw_lim_neg_spread" + double value = 0.0; + std::string unit; // e.g. "counts", "ms", "deg/min" ("" = none) + bool lower_is_better = true; + + bool has_threshold = false; + double threshold = 0.0; + bool pass = true; // verdict for this metric (abs + optional drift) + + // Filled by applyComparison() against the baseline / previous report. + bool has_baseline = false; + double baseline = 0.0; + bool has_prev = false; + double prev = 0.0; + double drift_pct = 0.0; // vs baseline (or prev if no baseline) + bool drift_flag = false; // drift exceeded the profile's warn threshold +}; + +struct TestSection { + std::string id; // "homing", "encoder", "imu_health", ... + std::string title; // human label + bool ran = false; + bool pass = true; + double duration_ms = 0.0; + std::vector metrics; + std::vector notes; // free-form lines (e.g. "LP unavailable; ST-sampled") +}; + +struct TestReport { + long long ts_ms = 0; + std::string profile; + std::string host; + std::string fw; + bool all_pass = true; + std::vector sections; + + // Find a metric by section id + metric name (used by applyComparison/tests). + const TestMetric* find(const std::string& section_id, const std::string& metric) const; +}; + +// Plain-text, human-readable AND round-trippable report. +std::string formatTestReport(const TestReport& r); + +// Re-read a report previously produced by formatTestReport(). Only the fields +// needed for comparison are recovered (section id, metric name + value); +// derived columns (base/prev/drift) are recomputed by applyComparison(). +TestReport parseTestReport(const std::string& text); + +// Fill baseline/prev/drift on `cur` from prior reports (either may be null). +// `drift_warn_pct` flags a metric when |drift| exceeds it; if `drift_gates_pass` +// a flagged metric also fails (and its section + the report roll up to FAIL). +void applyComparison(TestReport& cur, const TestReport* baseline, const TestReport* prev, + double drift_warn_pct, bool drift_gates_pass); + +} // namespace fgc diff --git a/include/fgc/TestRunner.h b/include/fgc/TestRunner.h new file mode 100644 index 0000000..25cb5ef --- /dev/null +++ b/include/fgc/TestRunner.h @@ -0,0 +1,111 @@ +#pragma once + +#include "fgc/Config.h" // TestProfile +#include "fgc/Geometry.h" +#include "fgc/TestReport.h" + +#include +#include +#include +#include +#include +#include + +namespace fgc { + +class IMotorController; +class IImuSource; + +// Leaf-test selection bitmask. Subsystem masks are unions of their leaves. +enum TestLeaf : uint32_t { + T_GIMBAL_HOMING = 1u << 0, + T_GIMBAL_ENCODER = 1u << 1, + T_GIMBAL_FRICTION = 1u << 2, + T_GIMBAL_BACKLASH = 1u << 3, + T_GIMBAL_BALANCE = 1u << 4, + T_IMU_CONFIG = 1u << 5, + T_IMU_HEALTH = 1u << 6, + T_IMU_DRIFT = 1u << 7, + T_HOST_THERMAL = 1u << 8, + T_HOST_DISK = 1u << 9, + T_HOST_MEMORY = 1u << 10, + T_HOST_LOAD = 1u << 11, +}; +constexpr uint32_t T_GIMBAL_ALL = T_GIMBAL_HOMING | T_GIMBAL_ENCODER | T_GIMBAL_FRICTION | + T_GIMBAL_BACKLASH | T_GIMBAL_BALANCE; +constexpr uint32_t T_IMU_ALL = T_IMU_CONFIG | T_IMU_HEALTH | T_IMU_DRIFT; +constexpr uint32_t T_HOST_ALL = T_HOST_THERMAL | T_HOST_DISK | T_HOST_MEMORY | T_HOST_LOAD; +constexpr uint32_t T_ALL = T_GIMBAL_ALL | T_IMU_ALL | T_HOST_ALL; + +// Which leaf masks need real motor / IMU hardware (used by the caller to guard). +constexpr uint32_t T_NEEDS_MOTOR = T_GIMBAL_ALL; +constexpr uint32_t T_NEEDS_IMU = T_IMU_ALL; + +// Resolve "subsystem [test]" tokens to a leaf mask. Empty subsystem => T_ALL; +// a subsystem with empty test => that subsystem's leaves. Returns false and sets +// `err` for an unknown subsystem/test token. +bool resolveTestSelection(const std::string& subsystem, const std::string& test, + uint32_t& mask, std::string& err); + +// Live progress for the activity strip (mirrors CalibProgress). +struct TestProgress { + bool running = false; + std::string section; // current leaf id + int step = 0; + int total = 0; + std::string phase; +}; + +// Runs the selected hardware self-tests on a worker thread, off the control +// loop. Mirrors CalibrationRoutine's lifecycle: start()/cancel()/running(), live +// progress(), and takeReport() once finished. The motor/imu/Logger interfaces are +// thread-safe; the produced TestReport is handed back for the control thread to +// compare against the baseline, write to disk, and surface in the UI. +class TestRunner { +public: + TestRunner(IMotorController& motor, IImuSource* imu, Geometry geo, + uint32_t selection, TestProfile profile, std::string disk_path); + ~TestRunner(); + + bool start(); + void cancel(); + bool running() const { return running_.load(); } + + std::optional takeReport(); + TestProgress progress() const; + +private: + void run(); + void setProgress(const std::string& section, int step, int total, const char* phase); + bool cancelled() const { return cancel_.load(); } + + // Leaf modules — each appends one TestSection to `report_`. + void testHoming(TestReport& r); + void testEncoder(TestReport& r); + void testFriction(TestReport& r); + void testBacklash(TestReport& r); + void testBalance(TestReport& r); + void testImuConfig(TestReport& r); + void testImuHealth(TestReport& r); + void testImuDrift(TestReport& r); + void testHost(TestReport& r); + + IMotorController& motor_; + IImuSource* imu_; + Geometry geo_; + uint32_t selection_; + TestProfile profile_; + std::string disk_path_; + + std::thread thread_; + std::atomic running_{false}; + std::atomic cancel_{false}; + + mutable std::mutex result_mutex_; + std::optional report_; + + mutable std::mutex progress_mutex_; + TestProgress progress_; +}; + +} // namespace fgc diff --git a/include/fgc/ui/UiSnapshot.h b/include/fgc/ui/UiSnapshot.h index 97e27d6..ef2f3ff 100644 --- a/include/fgc/ui/UiSnapshot.h +++ b/include/fgc/ui/UiSnapshot.h @@ -187,6 +187,7 @@ struct ActivityView { std::vector result; // summary lines UiColor result_color = UiColor::Default; // green pass / red fail std::string prompt; // a yes/no question awaiting the operator (empty = none) + bool cancelable = false; // a procedure is running and Esc cancels it }; // Last calibration fit, per axis, for the gimbal expanded view. @@ -221,6 +222,15 @@ struct DiagResultView { std::vector axes; // Y and (if present) P }; +// Outcome of the last `test` (hardware self-test) run, for the activity strip. +struct TestResultView { + bool has = false; + long long ts_ms = 0; + bool pass = false; + std::string profile; + std::vector summary; // per-section PASS/FAIL lines +}; + struct UiSnapshot { HeaderView header; GimbalView gimbal; @@ -233,6 +243,7 @@ struct UiSnapshot { ActivityView activity; CalibResultView calib; DiagResultView diag; + TestResultView test; }; // ---- Pure formatting helpers (unit-tested in tests/test_uisnapshot.cpp) ---- diff --git a/src/core/Application.cpp b/src/core/Application.cpp index 6c61a6d..7c332d8 100644 --- a/src/core/Application.cpp +++ b/src/core/Application.cpp @@ -16,6 +16,8 @@ #include "fgc/MtiImuSource.h" #include "fgc/ScanGrid.h" #include "fgc/SerialMotorController.h" +#include "fgc/TestReport.h" +#include "fgc/TestRunner.h" #include "fgc/mock/MockCameraSource.h" #include "fgc/mock/MockImuSource.h" #include "fgc/mock/MockMotorController.h" @@ -173,6 +175,7 @@ struct Application::Impl { std::unique_ptr scheduler; std::unique_ptr ui; std::unique_ptr calib; + std::unique_ptr tester; unsigned last_diag_seq = 0; ScanGrid grid; // outlives scheduler (holds a reference to it) std::string scan_load_error_; // non-empty if grid_file failed to load @@ -191,6 +194,21 @@ struct Application::Impl { std::vector last_calib_summary; long long last_calib_ts = 0; // epoch ms bool calib_save_pending_ = false; // awaiting y/n to persist to config + + // Hardware self-test (`test` command) results, for the activity strip. + TestResultView last_test_view; + std::vector last_test_summary; + long long last_test_ts = 0; // epoch ms + bool last_test_pass = false; + std::string last_test_report_path; // promoted by `test baseline` + + // Procedure lock-out: while a long op runs, interfering commands are ignored + // and Esc cancels. Homing runs on the control thread (blocking), so it uses + // these flags directly; calibration/test run on their own threads. + std::atomic homing_active_{false}; + std::atomic cancel_procedure_{false}; + double test_drift_warn_ = 15.0; // % (from the running profile) + bool test_drift_gate_ = false; mutable ImuConfigView imu_config_view_; // formatted MTi config (read once) mutable bool imu_config_done_ = false; @@ -418,6 +436,7 @@ struct Application::Impl { s.calib = last_calib_view; s.calib.pitch_present = s.gimbal.pitch_present || s.calib.pitch_present; s.diag = last_diag_view; + s.test = last_test_view; fillActivity(s); return s; } @@ -427,7 +446,15 @@ struct Application::Impl { void fillActivity(UiSnapshot& s) const { ActivityView& a = s.activity; - if (calib && calib->running()) { + if (tester && tester->running()) { + TestProgress p = tester->progress(); + a.active = true; + a.title = "TESTING"; + a.status = p.section.empty() + ? "running hardware self-test…" + : p.section + " " + std::to_string(p.step) + "/" + + std::to_string(p.total) + " — " + p.phase; + } else if (calib && calib->running()) { CalibProgress p = calib->progress(); a.active = true; a.title = "CALIBRATING"; @@ -462,9 +489,18 @@ struct Application::Impl { } } - // Persisted result: the more recent of the last calibration / diagnostics. + // A running procedure is cancelable with Esc. + a.cancelable = procedureBusy(); + + // Persisted result: the most recent of last test / calibration / diagnostics. const long long now = nowEpochMs(); - if (last_calib_ts || last_diag_ts) { + if (last_test_ts >= last_calib_ts && last_test_ts >= last_diag_ts && last_test_ts) { + a.has_result = true; + a.result_title = std::string("Test ") + (last_test_pass ? "PASS" : "FAIL") + + " · " + formatTimeAgo(now, last_test_ts); + a.result = last_test_summary; + a.result_color = last_test_pass ? UiColor::Green : UiColor::Red; + } else if (last_calib_ts || last_diag_ts) { const bool calib_newer = last_calib_ts >= last_diag_ts; a.has_result = true; if (calib_newer) { @@ -517,6 +553,15 @@ struct Application::Impl { void runInitSequence() { using namespace std::chrono_literals; LOG_INFO << "Running gimbal init sequence (enable + home)"; + // Mark homing active so interfering commands are ignored and Esc cancels. + // This blocks the control thread, so commands are drained+discarded here. + homing_active_ = true; + cancel_procedure_ = false; + struct ClearHoming { + std::atomic& flag; + ~ClearHoming() { flag = false; } + } clear_homing{homing_active_}; + motor->sendCommand("ENABLE Y"); motor->sendCommand("ENABLE P"); motor->sendCommand("HOME"); // homes all axes; firmware runs it non-blocking @@ -529,8 +574,9 @@ struct Application::Impl { // for homing to actually begin (an axis leaves READY) before waiting for // it to finish - otherwise we'd see the residual READY and return early. const auto t0 = std::chrono::steady_clock::now(); - while (running && std::chrono::steady_clock::now() < t0 + 3s) { + while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < t0 + 3s) { std::this_thread::sleep_for(100ms); + drainAndDiscardCommands(); // ignore moves/etc queued during homing publishSnapshot(); // keep the TUI's gimbal panel live during homing if (!allReady(motor->telemetry())) break; // homing started } @@ -538,8 +584,9 @@ struct Application::Impl { // Wait for completion (both axes READY again) or the homing timeout. bool homed = false; const auto deadline = std::chrono::steady_clock::now() + 65s; - while (running && std::chrono::steady_clock::now() < deadline) { + while (running && !cancel_procedure_ && std::chrono::steady_clock::now() < deadline) { std::this_thread::sleep_for(250ms); + drainAndDiscardCommands(); publishSnapshot(); // keep the TUI's gimbal panel live during homing MotorTelemetry t = motor->telemetry(); if (t.yaw.state == AxisState::Error || @@ -553,6 +600,11 @@ struct Application::Impl { break; } } + if (cancel_procedure_) { + LOG_WARN << "Homing cancelled"; + motor->sendCommand("STOP ALL"); + return; + } // Re-anchor the yaw zero to an endstop now that the homed limits are known. if (homed) applyYawHomeZero(); // Production move speeds for subsequent MOVE commands. @@ -702,7 +754,7 @@ struct Application::Impl { while (iss >> t) tok.push_back(t); // tok[0] == "gimbal" if (tok.size() < 2) { LOG_WARN << "usage: gimbal "; + "speed|setpos|status|dump|calib|raw> (self-test: 'test')"; return; } std::string sub = tok[1]; @@ -746,18 +798,6 @@ struct Application::Impl { long target = cur + static_cast(pct / 100.0 * range); LOG_INFO << "gimbal nudge " << axis << " " << pct << "% -> MOVE " << axis << " " << target; motor->sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target)); - } else if (sub == "diag") { - MotorTelemetry tel = motor->telemetry(); - if (!tel.yaw.ready() && !(tel.pitch_present && tel.pitch.ready())) { - LOG_WARN << "gimbal diag: axes not READY (home first)"; - return; - } - std::string fw = "DIAG"; - for (size_t i = 2; i < tok.size(); ++i) fw += " " + tok[i]; - LOG_INFO << "running gimbal diagnostics..."; - motor->sendCommand(fw); - diag_running_ = true; - diag_started_ = std::chrono::steady_clock::now(); } else if (sub == "calib") { startCalibration(); } else if (sub == "dump") { @@ -825,6 +865,189 @@ struct Application::Impl { } } + // ---- Procedure lock-out (test / calibration / homing are mutually exclusive) ---- + enum class Procedure { None, Homing, Calibration, Test }; + Procedure activeProcedure() const { + if (tester && tester->running()) return Procedure::Test; + if (calib && calib->running()) return Procedure::Calibration; + if (homing_active_.load()) return Procedure::Homing; + return Procedure::None; + } + bool procedureBusy() const { return activeProcedure() != Procedure::None; } + const char* procedureName() const { + switch (activeProcedure()) { + case Procedure::Test: return "test"; + case Procedure::Calibration: return "calibration"; + case Procedure::Homing: return "homing"; + default: return "none"; + } + } + + void cancelActiveProcedure() { + switch (activeProcedure()) { + case Procedure::Test: + LOG_INFO << "cancelling test"; + tester->cancel(); + motor->sendCommand("STOP ALL"); + break; + case Procedure::Calibration: + cancelCalibration(); + break; + case Procedure::Homing: + LOG_INFO << "cancelling homing"; + cancel_procedure_ = true; + motor->sendCommand("STOP ALL"); + break; + case Procedure::None: + LOG_INFO << "nothing to cancel"; + break; + } + } + + // Pop the command queue and drop any interfering command (homing runs on the + // control thread, so handleCommand() is not reached during it). Used inside + // runInitSequence()'s wait loops so queued moves are *ignored*, not deferred. + void drainAndDiscardCommands() { + std::queue local; + { + std::lock_guard lock(cmd_mutex); + std::swap(local, cmd_queue); + } + while (!local.empty()) { + const std::string& line = local.front(); + Command c = parseCommand(line); + if (c.verb == "cancel" || c.verb == "stop") + cancel_procedure_ = true; + else if (!c.empty()) + LOG_WARN << "ignored '" << c.verb << "': homing in progress (Esc to cancel)"; + local.pop(); + } + } + + // ---- `test` command ---- + static std::string lowerStr(std::string s) { + for (char& ch : s) ch = static_cast(std::tolower((unsigned char)ch)); + return s; + } + + void handleTest(const std::string& line) { + std::istringstream iss(line); + std::vector tok; + std::string t; + while (iss >> t) tok.push_back(t); // tok[0] == "test" + + if (tok.size() >= 2) { + std::string sub = lowerStr(tok[1]); + if (sub == "baseline") { promoteBaseline(); return; } + if (sub == "list") { listTestProfiles(); return; } + } + + // Split [subsystem] [test] [profile]: a token naming a configured profile + // is the profile; the remaining (ordered) tokens are subsystem then test. + std::string subsystem, leaf, profileName; + for (size_t i = 1; i < tok.size(); ++i) { + std::string w = lowerStr(tok[i]); + if (profileName.empty() && cfg.test.profiles.count(w)) { profileName = w; continue; } + if (subsystem.empty()) subsystem = w; + else if (leaf.empty()) leaf = w; + } + if (profileName.empty()) profileName = cfg.test.default_profile; + + uint32_t mask = 0; + std::string err; + if (!resolveTestSelection(subsystem, leaf, mask, err)) { + LOG_WARN << "test: " << err; + LOG_INFO << "usage: test [gimbal|imu|host []] [] (also: test list, test baseline)"; + return; + } + startTest(mask, profileName); + } + + void startTest(uint32_t mask, const std::string& profileName) { + if (procedureBusy()) { + LOG_WARN << "test: " << procedureName() << " already in progress (Esc to cancel)"; + return; + } + // IMU leaves need an IMU; drop them (with a warning) if absent. + if ((mask & T_NEEDS_IMU) && !imu) { + LOG_WARN << "test: IMU tests skipped (set [Features] enable_imu)"; + mask &= ~T_NEEDS_IMU; + } + // Motor leaves need a homed gimbal. + if (mask & T_NEEDS_MOTOR) { + MotorTelemetry tel = motor->telemetry(); + if (!tel.yaw.ready() && !(tel.pitch_present && tel.pitch.ready())) { + LOG_WARN << "test: gimbal not READY (home first)"; + return; + } + } + if (mask == 0) { LOG_WARN << "test: nothing to run"; return; } + + TestProfile profile; + if (const TestProfile* p = cfg.test.find(profileName)) profile = *p; + else profile.name = profileName; // unknown name => built-in defaults + test_drift_warn_ = profile.get("drift_warn_pct", 15.0); + test_drift_gate_ = profile.getBool("drift_gates_pass", false); + + stopCapture(); // free the serial link; don't fight the routine's moves + LOG_INFO << "starting hardware test (profile '" << profile.name << "')"; + tester = std::make_unique(*motor, imu.get(), cfg.geometry, mask, profile, + cfg.paths.output_dir); + tester->start(); + } + + void listTestProfiles() { + LOG_INFO << "test profiles (default: " << cfg.test.default_profile << "):"; + if (cfg.test.profiles.empty()) LOG_INFO << " (none configured; built-in defaults used)"; + for (const auto& [name, p] : cfg.test.profiles) + LOG_INFO << " " << name << " (" << p.params.size() << " params)"; + } + + std::string baselinePath() const { + return cfg.test.baseline_file.empty() + ? (paths::defaultLogDir() + "/test_baseline.txt") + : cfg.test.baseline_file; + } + + void promoteBaseline() { + if (last_test_report_path.empty()) { + LOG_WARN << "test baseline: no test report from this session to promote"; + return; + } + std::error_code ec; + std::filesystem::copy_file(last_test_report_path, baselinePath(), + std::filesystem::copy_options::overwrite_existing, ec); + if (ec) LOG_WARN << "test baseline: copy failed: " << ec.message(); + else LOG_INFO << "test baseline set from " << last_test_report_path << " -> " << baselinePath(); + } + + // Read+parse a report file; returns valid()==false-ish empty report on failure. + static bool loadReport(const std::string& path, TestReport& out) { + std::ifstream f(path); + if (!f) return false; + std::ostringstream ss; + ss << f.rdbuf(); + out = parseTestReport(ss.str()); + return !out.sections.empty(); + } + + // Most recent prior "test_*.log" in the log dir (excluding `exclude`). + std::string latestPriorTestReport(const std::string& exclude) const { + namespace fs = std::filesystem; + std::string best; + std::error_code ec; + const std::string dir = paths::defaultLogDir(); + if (!fs::exists(dir, ec)) return best; + for (auto& e : fs::directory_iterator(dir, ec)) { + const std::string name = e.path().filename().string(); + if (name.rfind("test_", 0) != 0 || e.path().extension() != ".log") continue; + if (e.path().string() == exclude) continue; + if (best.empty() || name > fs::path(best).filename().string()) + best = e.path().string(); + } + return best; + } + // Pick up results produced by background work (calibration thread, DIAG // capture) — runs on the control thread each tick so all geometry/state // mutation stays single-threaded. @@ -860,6 +1083,42 @@ struct Application::Impl { if (rep.valid && !opts.config_path.empty()) calib_save_pending_ = true; } } + if (tester) { + if (auto rep = tester->takeReport()) { + TestReport r = std::move(*rep); + // Compare against the pinned baseline and the most recent prior run. + TestReport baseline, prev; + bool have_baseline = loadReport(baselinePath(), baseline); + bool have_prev = loadReport(latestPriorTestReport(""), prev); + applyComparison(r, have_baseline ? &baseline : nullptr, + have_prev ? &prev : nullptr, test_drift_warn_, test_drift_gate_); + + std::string text = formatTestReport(r); + std::string path = paths::writeLogFile(paths::timestampedLogName("test"), text); + if (!path.empty()) { + last_test_report_path = path; + LOG_INFO << "test report written: " << path; + } + // Surface a concise per-section summary in the log + activity strip. + last_test_summary.clear(); + for (const auto& s : r.sections) { + if (!s.ran) continue; + std::string l = s.id + ": " + (s.pass ? "PASS" : "FAIL"); + last_test_summary.push_back(l); + LOG_INFO << "test " << l; + } + LOG_INFO << "test overall: " << (r.all_pass ? "PASS" : "FAIL") + << " (profile " << r.profile << ")"; + last_test_pass = r.all_pass; + last_test_ts = nowEpochMs(); + last_test_view = TestResultView{}; + last_test_view.has = true; + last_test_view.ts_ms = last_test_ts; + last_test_view.pass = r.all_pass; + last_test_view.profile = r.profile; + last_test_view.summary = last_test_summary; + } + } unsigned seq = motor->diagSeq(); if (seq != last_diag_seq) { last_diag_seq = seq; @@ -907,14 +1166,33 @@ struct Application::Impl { diag_running_ = false; } + // Commands that would disturb a running procedure (motion, capture, a second + // long op, or a DUMP over the same serial link). Ignored while busy. + static bool interferingVerb(const std::string& verb) { + return verb == "gimbal" || verb == "start" || verb == "calib" || + verb == "test" || verb == "refresh"; + } + void handleCommand(const std::string& line) { Command c = parseCommand(line); if (c.empty()) return; LOG_TRACE_CAT(LogCat::Control) << "cmd " << line; + // Lock-out: while a test/calibration/homing runs, ignore interfering + // commands (Esc, or `stop`/`cancel`, cancels the procedure instead). + if (procedureBusy() && interferingVerb(c.verb)) { + LOG_WARN << "ignored '" << c.verb << "': " << procedureName() + << " in progress (Esc to cancel)"; + return; + } + if (c.verb == "help") { // c.device holds the optional topic (first token after "help"). for (const auto& l : renderHelp(c.device)) LOG_INFO << l; + } else if (c.verb == "test") { + handleTest(line); + } else if (c.verb == "cancel") { + cancelActiveProcedure(); } else if (c.verb == "gimbal") { handleGimbal(line); } else if (c.verb == "calib") { @@ -929,8 +1207,9 @@ struct Application::Impl { } else if (c.verb == "start") { startCapture(); } else if (c.verb == "stop") { - cancelCalibration(); // `stop` also aborts a running calibration - stopCapture(); + // `stop`/`x` cancels any running procedure, else stops capture. + if (procedureBusy()) cancelActiveProcedure(); + else stopCapture(); } else if (c.verb == "debug") { bool on = Logger::level() != LogLevel::Debug; Logger::setLevel(on ? LogLevel::Debug : LogLevel::Info); @@ -1099,6 +1378,7 @@ struct Application::Impl { } LOG_INFO << "Shutting down"; + if (tester) tester->cancel(); // stop any in-flight self-test before teardown if (calib) calib->cancel(); // stop any in-flight calibration before teardown if (ui) ui->stop(); pipeline->stop(); diff --git a/src/core/Config.cpp b/src/core/Config.cpp index 3c52dd1..460ce5e 100644 --- a/src/core/Config.cpp +++ b/src/core/Config.cpp @@ -84,6 +84,45 @@ double AppConfig::image_rate() const { return general.image_interval > 0 ? 1.0 / general.image_interval : 0.0; } +double TestProfile::get(const std::string& key, double fallback) const { + auto it = params.find(key); + return it != params.end() ? it->second : fallback; +} + +int TestProfile::geti(const std::string& key, int fallback) const { + auto it = params.find(key); + return it != params.end() ? static_cast(it->second) : fallback; +} + +bool TestProfile::getBool(const std::string& key, bool fallback) const { + auto it = text.find(key); + if (it == text.end() || it->second.empty()) return fallback; + const std::string& v = it->second; + return v == "1" || v == "true" || v == "yes" || v == "on"; +} + +std::string TestProfile::gets(const std::string& key, const std::string& fallback) const { + auto it = text.find(key); + return (it != text.end() && !it->second.empty()) ? it->second : fallback; +} + +std::vector TestProfile::getLongList(const std::string& key) const { + std::vector out; + auto it = text.find(key); + if (it == text.end()) return out; + std::stringstream ss(it->second); + std::string tok; + while (std::getline(ss, tok, ',')) { + try { out.push_back(std::stol(tok)); } catch (...) {} + } + return out; +} + +const TestProfile* TestConfig::find(const std::string& name) const { + auto it = profiles.find(name); + return it != profiles.end() ? &it->second : nullptr; +} + AppConfig ConfigLoader::fromMap(const std::map& kv) { AppConfig cfg; @@ -119,6 +158,33 @@ AppConfig ConfigLoader::fromMap(const std::map& kv) { cfg.imu.device = get(kv, "IMU.device", cfg.imu.device); cfg.imu.baud = static_cast(getInt(kv, "IMU.baud", cfg.imu.baud)); + // [Test] + [TestProfile.]: discover profiles by scanning the flattened + // keys. inih gives us "TestProfile.." => value; every numeric value + // lands in that profile's open param map for the test modules to read. + cfg.test.default_profile = get(kv, "Test.profile", cfg.test.default_profile); + cfg.test.baseline_file = get(kv, "Test.baseline_file"); + if (!cfg.test.baseline_file.empty()) + cfg.test.baseline_file = paths::expandUser(cfg.test.baseline_file); + { + const std::string prefix = "TestProfile."; + for (const auto& [key, val] : kv) { + if (key.rfind(prefix, 0) != 0) continue; + auto dot = key.find('.', prefix.size()); + if (dot == std::string::npos) continue; + std::string pname = key.substr(prefix.size(), dot - prefix.size()); + std::string pkey = key.substr(dot + 1); + if (pname.empty() || pkey.empty() || val.empty()) continue; + auto& prof = cfg.test.profiles[pname]; + prof.name = pname; + prof.text[pkey] = val; // raw string (covers comma lists) + try { + prof.params[pkey] = std::stod(val); // also numeric where possible + } catch (...) { + // list/non-numeric value: the raw string in `text` is what modules read + } + } + } + cfg.logging.level = get(kv, "Logging.level", cfg.logging.level); cfg.logging.trace = get(kv, "Logging.trace", cfg.logging.trace); diff --git a/src/core/HelpText.cpp b/src/core/HelpText.cpp index a4d5f9d..d432f27 100644 --- a/src/core/HelpText.cpp +++ b/src/core/HelpText.cpp @@ -53,10 +53,15 @@ const std::vector& helpCatalog() { "Captured DUMP BEGIN..END block (build, uptime, reset cause, per-axis", "TMC5160 registers); shown in the gimbal 'g' expanded view.", "Equivalent offline tool: ./firmware/dump.sh"}}, - {"gimbal diag [y|p|all]", - "Run the firmware motor self-test; results stream to the LOG + a logfile.", { - "Each axis is swept at several speeds/directions (DG lines). A PASS/FAIL", - "summary is logged and saved to logs/diag_*.log. Axes must be homed first."}}, + {"test [ []] []", + "Run the hardware self-test suite; a text report is saved to logs/test_*.log.", { + "Modular: 'test' runs everything; 'test gimbal' / 'test imu' / 'test host'", + "narrow it; 'test gimbal homing' runs one leaf. A trailing token names a", + "config profile (repetition counts + thresholds). Each run is compared to a", + "pinned baseline and the previous run (drift flagged). Gimbal/IMU leaves need", + "a homed gimbal / the IMU; 'test host' (thermal/disk/memory) needs neither.", + "Esc cancels a running test. 'test baseline' pins the last report; 'test list'", + "shows profiles."}}, {"gimbal calib", "Calibrate steps<->degrees using the IMU (auto-homes; ~minutes).", { "Needs the IMU. Homes first if needed, calibrates PITCH at the first yaw", diff --git a/src/core/HostMetrics.cpp b/src/core/HostMetrics.cpp new file mode 100644 index 0000000..9cf4316 --- /dev/null +++ b/src/core/HostMetrics.cpp @@ -0,0 +1,117 @@ +#include "fgc/HostMetrics.h" + +#include +#include +#include +#include +#include + +#include +#include + +namespace fgc::hostmetrics { + +namespace fs = std::filesystem; + +namespace { + +std::string readFile(const fs::path& p) { + std::ifstream f(p); + if (!f) return {}; + std::ostringstream ss; + ss << f.rdbuf(); + return ss.str(); +} + +} // namespace + +double parseMilliCelsius(const std::string& s) { + try { + return std::stod(s) / 1000.0; // sysfs reports milli-degrees C + } catch (...) { + return 0.0; + } +} + +MemInfo parseMeminfo(const std::string& text) { + MemInfo m; + std::istringstream in(text); + std::string line; + double mem_total_kb = 0, mem_avail_kb = 0, swap_total_kb = 0, swap_free_kb = 0; + bool have_total = false, have_avail = false; + while (std::getline(in, line)) { + std::istringstream ls(line); + std::string key, val, unit; + if (!(ls >> key >> val)) continue; + double kb = 0; + try { kb = std::stod(val); } catch (...) { continue; } + if (key == "MemTotal:") { mem_total_kb = kb; have_total = true; } + else if (key == "MemAvailable:") { mem_avail_kb = kb; have_avail = true; } + else if (key == "SwapTotal:") swap_total_kb = kb; + else if (key == "SwapFree:") swap_free_kb = kb; + } + m.ok = have_total && have_avail; + m.total_mb = mem_total_kb / 1024.0; + m.avail_mb = mem_avail_kb / 1024.0; + m.swap_used_mb = (swap_total_kb - swap_free_kb) / 1024.0; + return m; +} + +ThermalInfo readThermal() { + ThermalInfo t; + const fs::path root = "/sys/class/thermal"; + std::error_code ec; + if (!fs::exists(root, ec)) return t; + for (auto& e : fs::directory_iterator(root, ec)) { + const auto name = e.path().filename().string(); + if (name.rfind("thermal_zone", 0) != 0) continue; + std::string temp_s = readFile(e.path() / "temp"); + if (temp_s.empty()) continue; + double c = parseMilliCelsius(temp_s); + if (!t.ok || c > t.max_temp_c) { + t.ok = true; + t.max_temp_c = c; + std::string type = readFile(e.path() / "type"); + // strip trailing newline + while (!type.empty() && (type.back() == '\n' || type.back() == '\r')) type.pop_back(); + t.hottest_zone = type.empty() ? name : type; + } + } + return t; +} + +DiskInfo readDisk(const std::string& path) { + DiskInfo d; + d.path = path; + struct statvfs vfs {}; + if (statvfs(path.c_str(), &vfs) != 0) return d; + const double frsize = static_cast(vfs.f_frsize); + const double total = static_cast(vfs.f_blocks) * frsize; + const double avail = static_cast(vfs.f_bavail) * frsize; + const double gb = 1024.0 * 1024.0 * 1024.0; + d.ok = true; + d.total_gb = total / gb; + d.free_gb = avail / gb; + d.used_pct = total > 0 ? 100.0 * (total - avail) / total : 0.0; + return d; +} + +MemInfo readMeminfo() { + return parseMeminfo(readFile("/proc/meminfo")); +} + +LoadInfo readLoad() { + LoadInfo l; + double avg[3] = {0, 0, 0}; + if (getloadavg(avg, 3) == 3) { + l.ok = true; + l.load1 = avg[0]; + l.load5 = avg[1]; + l.load15 = avg[2]; + } + long n = sysconf(_SC_NPROCESSORS_ONLN); + l.cpus = (n > 0) ? static_cast(n) : 0; + return l; +} + +} // namespace fgc::hostmetrics diff --git a/src/core/TestReport.cpp b/src/core/TestReport.cpp new file mode 100644 index 0000000..08c6239 --- /dev/null +++ b/src/core/TestReport.cpp @@ -0,0 +1,170 @@ +#include "fgc/TestReport.h" + +#include +#include +#include + +namespace fgc { + +namespace { + +// Format a double compactly: integers print without a decimal point, otherwise +// up to 4 significant decimals with trailing zeros trimmed. +std::string num(double v) { + if (std::isfinite(v) && v == std::floor(v) && std::fabs(v) < 1e15) { + char b[32]; + std::snprintf(b, sizeof(b), "%lld", static_cast(v)); + return b; + } + char b[32]; + std::snprintf(b, sizeof(b), "%.4f", v); + std::string s(b); + auto dot = s.find('.'); + if (dot != std::string::npos) { + size_t last = s.find_last_not_of('0'); + if (last == dot) last--; // drop the dot too + s.erase(last + 1); + } + return s; +} + +} // namespace + +const TestMetric* TestReport::find(const std::string& section_id, + const std::string& metric) const { + for (const auto& s : sections) { + if (s.id != section_id) continue; + for (const auto& m : s.metrics) + if (m.name == metric) return &m; + } + return nullptr; +} + +std::string formatTestReport(const TestReport& r) { + std::ostringstream o; + o << "# Fire Gimbal Control - hardware test report\n"; + o << "ts_ms = " << r.ts_ms << "\n"; + o << "profile = " << r.profile << "\n"; + o << "host = " << r.host << "\n"; + o << "fw = " << r.fw << "\n"; + o << "result = " << (r.all_pass ? "PASS" : "FAIL") << "\n"; + + for (const auto& s : r.sections) { + o << "\n[" << s.id << "] " << (s.ran ? (s.pass ? "PASS" : "FAIL") : "SKIP") + << " " << num(s.duration_ms) << " ms"; + if (!s.title.empty()) o << " # " << s.title; + o << "\n"; + for (const auto& m : s.metrics) { + o << " " << m.name << " = " << num(m.value); + if (!m.unit.empty()) o << " " << m.unit; + o << " " << (m.pass ? "PASS" : "FAIL"); + if (m.has_baseline) o << " base=" << num(m.baseline); + if (m.has_prev) o << " prev=" << num(m.prev); + if (m.has_baseline || m.has_prev) + o << " drift=" << num(m.drift_pct) << "%"; + if (m.drift_flag) o << " DRIFT"; + o << "\n"; + } + for (const auto& n : s.notes) o << " ; " << n << "\n"; + } + return o.str(); +} + +TestReport parseTestReport(const std::string& text) { + TestReport r; + std::istringstream in(text); + std::string line; + TestSection* cur = nullptr; + while (std::getline(in, line)) { + // strip trailing CR + if (!line.empty() && line.back() == '\r') line.pop_back(); + // find first non-space + size_t i = line.find_first_not_of(" \t"); + if (i == std::string::npos) continue; + if (line[i] == '#' || line[i] == ';') continue; // comment / note + + if (line[i] == '[') { // section header + size_t end = line.find(']', i); + if (end == std::string::npos) continue; + r.sections.push_back(TestSection{}); + cur = &r.sections.back(); + cur->id = line.substr(i + 1, end - i - 1); + cur->ran = line.find("SKIP", end) == std::string::npos; + cur->pass = line.find("FAIL", end) == std::string::npos && cur->ran; + continue; + } + + // key = value [unit] [VERDICT] ... + std::istringstream ls(line.substr(i)); + std::string name, eq, valtok; + if (!(ls >> name >> eq >> valtok) || eq != "=") continue; + double value = 0.0; + try { value = std::stod(valtok); } catch (...) { continue; } + + if (!cur) { // header block (ts_ms / profile / host / fw / result) + if (name == "ts_ms") r.ts_ms = static_cast(value); + continue; + } + std::string rest; + ls >> rest; // unit or verdict (we only need name+value for comparison) + TestMetric m; + m.name = name; + m.value = value; + if (rest != "PASS" && rest != "FAIL") m.unit = rest; + cur->metrics.push_back(m); + } + // recover header fields that aren't numeric (profile/host/fw/result) + { + std::istringstream in2(text); + std::string l; + while (std::getline(in2, l)) { + auto eq = l.find('='); + if (eq == std::string::npos) continue; + auto key = l.substr(0, eq); + auto val = l.substr(eq + 1); + auto trim = [](std::string s) { + size_t a = s.find_first_not_of(" \t\r"); + size_t b = s.find_last_not_of(" \t\r"); + return a == std::string::npos ? std::string() : s.substr(a, b - a + 1); + }; + key = trim(key); + val = trim(val); + if (key == "profile") r.profile = val; + else if (key == "host") r.host = val; + else if (key == "fw") r.fw = val; + else if (key == "result") r.all_pass = (val == "PASS"); + } + } + return r; +} + +void applyComparison(TestReport& cur, const TestReport* baseline, const TestReport* prev, + double drift_warn_pct, bool drift_gates_pass) { + for (auto& s : cur.sections) { + for (auto& m : s.metrics) { + const TestMetric* b = baseline ? baseline->find(s.id, m.name) : nullptr; + const TestMetric* p = prev ? prev->find(s.id, m.name) : nullptr; + if (b) { m.has_baseline = true; m.baseline = b->value; } + if (p) { m.has_prev = true; m.prev = p->value; } + + const double ref = b ? m.baseline : (p ? m.prev : 0.0); + if (b || p) { + if (std::fabs(ref) > 1e-9) + m.drift_pct = 100.0 * (m.value - ref) / std::fabs(ref); + else + m.drift_pct = (std::fabs(m.value) < 1e-9) ? 0.0 : 100.0; + // Only an adverse move counts as drift: worse = larger when + // lower_is_better, smaller otherwise. + const double adverse = m.lower_is_better ? m.drift_pct : -m.drift_pct; + if (drift_warn_pct > 0.0 && adverse > drift_warn_pct) { + m.drift_flag = true; + if (drift_gates_pass) m.pass = false; + } + } + if (!m.pass) s.pass = false; + } + if (s.ran && !s.pass) cur.all_pass = false; + } +} + +} // namespace fgc diff --git a/src/core/TestRunner.cpp b/src/core/TestRunner.cpp new file mode 100644 index 0000000..2c91fe5 --- /dev/null +++ b/src/core/TestRunner.cpp @@ -0,0 +1,760 @@ +#include "fgc/TestRunner.h" + +#include "fgc/DiagParser.h" +#include "fgc/DumpParser.h" +#include "fgc/HostMetrics.h" +#include "fgc/IImuSource.h" +#include "fgc/IMotorController.h" +#include "fgc/Logger.h" + +#include +#include +#include +#include +#include +#include + +#include // gethostname + +namespace fgc { + +using namespace std::chrono_literals; +using clock_t_ = std::chrono::steady_clock; + +namespace { + +long long nowEpochMs() { + return std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()) + .count(); +} + +std::string hostName() { + char buf[256] = {0}; + if (gethostname(buf, sizeof(buf) - 1) == 0) return buf; + return "unknown"; +} + +// ---- small stats over a sample vector ---- +struct Stats { + double mean = 0, sd = 0, min = 0, max = 0, spread = 0; + int n = 0; +}; +Stats stats(const std::vector& v) { + Stats s; + s.n = static_cast(v.size()); + if (v.empty()) return s; + s.min = s.max = v.front(); + double sum = 0; + for (double x : v) { sum += x; s.min = std::min(s.min, x); s.max = std::max(s.max, x); } + s.mean = sum / v.size(); + double acc = 0; + for (double x : v) acc += (x - s.mean) * (x - s.mean); + s.sd = v.size() > 1 ? std::sqrt(acc / (v.size() - 1)) : 0.0; + s.spread = s.max - s.min; + return s; +} + +// ---- metric builders ---- +TestMetric& add(TestSection& s, const std::string& name, double value, + const std::string& unit, bool lower_is_better = true) { + s.metrics.push_back(TestMetric{}); + TestMetric& m = s.metrics.back(); + m.name = name; + m.value = value; + m.unit = unit; + m.lower_is_better = lower_is_better; + return m; +} +void thresh(TestMetric& m, double t) { + m.has_threshold = true; + m.threshold = t; + m.pass = m.lower_is_better ? (m.value <= t) : (m.value >= t); +} +void rollup(TestSection& s) { + s.ran = true; + s.pass = true; + for (const auto& m : s.metrics) + if (!m.pass) s.pass = false; +} + +} // namespace + +bool resolveTestSelection(const std::string& subsystem, const std::string& test, + uint32_t& mask, std::string& err) { + auto leaf = [&](const char* s, const char* t, uint32_t bit, + uint32_t& m) -> bool { + if (subsystem == s && (test.empty() || test == t)) { m |= bit; return true; } + return false; + }; + if (subsystem.empty()) { mask = T_ALL; return true; } + + uint32_t m = 0; + bool sub_ok = false; + if (subsystem == "gimbal") { + sub_ok = true; + if (test.empty()) m = T_GIMBAL_ALL; + else if (test == "homing") m = T_GIMBAL_HOMING; + else if (test == "encoder") m = T_GIMBAL_ENCODER; + else if (test == "friction") m = T_GIMBAL_FRICTION; + else if (test == "backlash") m = T_GIMBAL_BACKLASH; + else if (test == "balance") m = T_GIMBAL_BALANCE; + else { err = "unknown gimbal test: " + test; return false; } + } else if (subsystem == "imu") { + sub_ok = true; + if (test.empty()) m = T_IMU_ALL; + else if (test == "config") m = T_IMU_CONFIG; + else if (test == "health") m = T_IMU_HEALTH; + else if (test == "drift") m = T_IMU_DRIFT; + else { err = "unknown imu test: " + test; return false; } + } else if (subsystem == "host") { + sub_ok = true; + if (test.empty()) m = T_HOST_ALL; + else if (test == "thermal") m = T_HOST_THERMAL; + else if (test == "disk") m = T_HOST_DISK; + else if (test == "memory") m = T_HOST_MEMORY; + else if (test == "load") m = T_HOST_LOAD; + else { err = "unknown host test: " + test; return false; } + } + (void)leaf; + if (!sub_ok) { err = "unknown test subsystem: " + subsystem; return false; } + mask = m; + return true; +} + +TestRunner::TestRunner(IMotorController& motor, IImuSource* imu, Geometry geo, + uint32_t selection, TestProfile profile, std::string disk_path) + : motor_(motor), imu_(imu), geo_(geo), selection_(selection), + profile_(std::move(profile)), disk_path_(std::move(disk_path)) {} + +TestRunner::~TestRunner() { + cancel_ = true; + if (thread_.joinable()) thread_.join(); +} + +bool TestRunner::start() { + if (running_.exchange(true)) { + LOG_WARN << "test already running"; + return false; + } + cancel_ = false; + { + std::lock_guard lk(result_mutex_); + report_.reset(); + } + thread_ = std::thread([this] { run(); }); + return true; +} + +void TestRunner::cancel() { cancel_ = true; } + +std::optional TestRunner::takeReport() { + std::lock_guard lk(result_mutex_); + if (!report_) return std::nullopt; + auto out = std::move(report_); + report_.reset(); + return out; +} + +TestProgress TestRunner::progress() const { + std::lock_guard lk(progress_mutex_); + return progress_; +} + +void TestRunner::setProgress(const std::string& section, int step, int total, + const char* phase) { + std::lock_guard lk(progress_mutex_); + progress_.running = true; + progress_.section = section; + progress_.step = step; + progress_.total = total; + progress_.phase = phase; +} + +void TestRunner::run() { + struct Done { + TestRunner* self; + ~Done() { + std::lock_guard lk(self->progress_mutex_); + self->progress_ = TestProgress{}; + self->running_ = false; + } + } done{this}; + + TestReport r; + r.ts_ms = nowEpochMs(); + r.profile = profile_.name.empty() ? "default" : profile_.name; + r.host = hostName(); + r.fw = ""; // populated from a DUMP build string if available + + // A homing run also surfaces the firmware build id for the report header. + if (!cancelled() && (selection_ & T_GIMBAL_HOMING)) testHoming(r); + if (!cancelled() && (selection_ & T_GIMBAL_ENCODER)) testEncoder(r); + if (!cancelled() && (selection_ & T_GIMBAL_FRICTION)) testFriction(r); + if (!cancelled() && (selection_ & T_GIMBAL_BACKLASH)) testBacklash(r); + if (!cancelled() && (selection_ & T_GIMBAL_BALANCE)) testBalance(r); + if (!cancelled() && (selection_ & T_IMU_CONFIG)) testImuConfig(r); + if (!cancelled() && (selection_ & T_IMU_HEALTH)) testImuHealth(r); + if (!cancelled() && (selection_ & T_IMU_DRIFT)) testImuDrift(r); + if (!cancelled() && (selection_ & T_HOST_ALL)) testHost(r); + + r.all_pass = true; + for (const auto& s : r.sections) + if (s.ran && !s.pass) r.all_pass = false; + + { + std::lock_guard lk(result_mutex_); + report_ = std::move(r); + } + if (cancelled()) LOG_WARN << "test cancelled"; +} + +// --------------------------------------------------------------------------- +// Motor helpers +// --------------------------------------------------------------------------- +namespace { + +// Poll telemetry until `pred` is true or `timeout` elapses. Returns elapsed ms, +// or -1 on timeout/cancel. Calls `tick` each poll for sampling. +template +double pollUntil(IMotorController& motor, const std::atomic& cancel, + Pred pred, Tick tick, std::chrono::milliseconds timeout, + std::chrono::milliseconds period = 20ms) { + const auto t0 = clock_t_::now(); + const auto deadline = t0 + timeout; + while (clock_t_::now() < deadline) { + if (cancel.load()) return -1; + MotorTelemetry t = motor.telemetry(); + tick(t); + if (pred(t)) + return std::chrono::duration(clock_t_::now() - t0).count(); + std::this_thread::sleep_for(period); + } + return -1; +} + +bool axisReady(const MotorTelemetry& t) { + return t.yaw.ready() && (!t.pitch_present || t.pitch.ready()); +} + +} // namespace + +void TestRunner::testHoming(TestReport& r) { + TestSection s; + s.id = "homing"; + s.title = "homing reproducibility"; + const auto t_start = clock_t_::now(); + const int reps = std::max(2, profile_.geti("reps", 5)); + const auto home_to = std::chrono::milliseconds(profile_.geti("home_timeout_ms", 65000)); + + motor_.sendCommand("ENABLE Y"); + motor_.sendCommand("ENABLE P"); + + std::vector yaw_neg, yaw_pos, pit_neg, pit_pos, durations; + int failures = 0; + bool pitch_present = false; + + for (int i = 0; i < reps && !cancelled(); ++i) { + setProgress("homing", i + 1, reps, "homing"); + motor_.sendCommand("HOME"); + // wait for homing to begin (axis leaves READY), tolerating EEPROM fast-path + pollUntil(motor_, cancel_, [](const MotorTelemetry& t) { return !axisReady(t); }, + [](const MotorTelemetry&) {}, + std::chrono::milliseconds(profile_.geti("home_begin_timeout_ms", 3000))); + double dur = pollUntil( + motor_, cancel_, + [](const MotorTelemetry& t) { + if (t.yaw.state == AxisState::Error || + (t.pitch_present && t.pitch.state == AxisState::Error)) + return true; + return axisReady(t); + }, + [](const MotorTelemetry&) {}, home_to); + MotorTelemetry t = motor_.telemetry(); + if (dur < 0 || t.yaw.state == AxisState::Error || + (t.pitch_present && t.pitch.state == AxisState::Error)) { + ++failures; + continue; + } + durations.push_back(dur); + + // Read the homed endstop limits from a fresh DUMP. + motor_.sendCommand("DUMP"); + std::this_thread::sleep_for(150ms); + DumpData d = parseDump(motor_.lastDump()); + if (r.fw.empty() && !d.build.empty()) r.fw = d.build; + for (const auto& ax : d.axes) { + if (ax.axis == 'Y') { yaw_neg.push_back(ax.lim_neg); yaw_pos.push_back(ax.lim_pos); } + else if (ax.axis == 'P') { + pitch_present = true; + pit_neg.push_back(ax.lim_neg); + pit_pos.push_back(ax.lim_pos); + } + } + } + + const double spread_max = profile_.get("home_spread_max_counts", 80); + if (!yaw_neg.empty()) { + thresh(add(s, "yaw_lim_neg_spread", stats(yaw_neg).spread, "counts"), spread_max); + thresh(add(s, "yaw_lim_pos_spread", stats(yaw_pos).spread, "counts"), spread_max); + } + if (pitch_present) { + thresh(add(s, "pitch_lim_neg_spread", stats(pit_neg).spread, "counts"), spread_max); + thresh(add(s, "pitch_lim_pos_spread", stats(pit_pos).spread, "counts"), spread_max); + } + if (!durations.empty()) { + Stats ds = stats(durations); + add(s, "home_ms_mean", ds.mean, "ms"); + add(s, "home_ms_max", ds.max, "ms"); + auto& tm = add(s, "home_ms_spread", ds.spread, "ms"); + if (profile_.get("home_ms_max", 0) > 0) + thresh(tm, profile_.get("home_ms_max", 0) - profile_.get("home_ms_min", 0)); + } + thresh(add(s, "homing_failures", failures, "count"), 0); + if (yaw_neg.empty() && failures == 0) s.notes.push_back("no DUMP limits parsed (mock or no encoder)"); + + s.duration_ms = std::chrono::duration(clock_t_::now() - t_start).count(); + rollup(s); + r.sections.push_back(std::move(s)); +} + +void TestRunner::testEncoder(TestReport& r) { + TestSection s; + s.id = "encoder"; + s.title = "encoder tracking + repeatability"; + const auto t_start = clock_t_::now(); + + // (a) firmware DIAG tracking quality, repeated for spread. + const int reps = std::max(1, profile_.geti("encoder_diag_reps", 2)); + std::vector y_peak, y_still, p_peak, p_still; + bool any_diag = false; + for (int i = 0; i < reps && !cancelled(); ++i) { + setProgress("encoder", i + 1, reps, "diag"); + unsigned seq0 = motor_.diagSeq(); + motor_.sendCommand("DIAG"); + // wait for a fresh diag completion + const auto deadline = clock_t_::now() + 60s; + while (clock_t_::now() < deadline && !cancelled() && motor_.diagSeq() == seq0) + std::this_thread::sleep_for(50ms); + if (motor_.diagSeq() == seq0) { s.notes.push_back("DIAG did not complete"); continue; } + any_diag = true; + DiagResult dr = parseDiag(motor_.lastDiag()); + for (const auto& ax : dr.axes) { + long peak = -1, still = -1; + for (const auto& tst : ax.tests) { + if (tst.err_peak < 0) continue; + peak = std::max(peak, tst.err_peak); + still = std::max(still, tst.err_still); + } + if (peak < 0) continue; + if (ax.axis == 'Y') { y_peak.push_back(peak); y_still.push_back(still); } + else if (ax.axis == 'P') { p_peak.push_back(peak); p_still.push_back(still); } + } + } + const double err_peak_max = profile_.get("encoder_err_peak_max_counts", 0); + auto emit = [&](const char* ax, std::vector& peak, std::vector& still) { + if (peak.empty()) return; + auto& mp = add(s, std::string(ax) + "_err_peak", stats(peak).max, "counts"); + if (err_peak_max > 0) thresh(mp, err_peak_max); + add(s, std::string(ax) + "_err_peak_spread", stats(peak).spread, "counts"); + add(s, std::string(ax) + "_err_still", stats(still).max, "counts"); + }; + emit("yaw", y_peak, y_still); + emit("pitch", p_peak, p_still); + if (!any_diag) s.notes.push_back("no DIAG data captured"); + + // (c) hold integrity: hold-corrector delta across a short window. + motor_.sendCommand("DUMP"); + std::this_thread::sleep_for(150ms); + DumpData d0 = parseDump(motor_.lastDump()); + std::this_thread::sleep_for(std::chrono::milliseconds(profile_.geti("hold_window_ms", 2000))); + if (cancelled()) { s.duration_ms = 0; rollup(s); r.sections.push_back(std::move(s)); return; } + motor_.sendCommand("DUMP"); + std::this_thread::sleep_for(150ms); + DumpData d1 = parseDump(motor_.lastDump()); + auto holdOf = [](const DumpData& d, char ax) -> long { + for (const auto& a : d.axes) if (a.axis == ax) return a.hold_corrections; + return 0; + }; + if (d0.valid && d1.valid) { + thresh(add(s, "yaw_hold_corrections", holdOf(d1, 'Y') - holdOf(d0, 'Y'), "count"), + profile_.get("hold_corrections_max", 0)); + } + + s.duration_ms = std::chrono::duration(clock_t_::now() - t_start).count(); + rollup(s); + r.sections.push_back(std::move(s)); +} + +// Sample motor load (cs/pwm peak, sg min) and time a move to a target. Returns +// elapsed ms (-1 on timeout). `axis` is 'Y' or 'P'. +namespace { +struct LoadResult { + double ms = -1; + double cs_peak = 0, pwm_peak = 0, sg_min = 1e9; +}; +LoadResult moveAndSampleLoad(IMotorController& motor, const std::atomic& cancel, + char axis, long target, std::chrono::milliseconds timeout) { + LoadResult lr; + motor.sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target)); + std::this_thread::sleep_for(40ms); // let it leave standstill + lr.ms = pollUntil( + motor, cancel, + [axis](const MotorTelemetry& t) { + const AxisTelemetry& a = (axis == 'P') ? t.pitch : t.yaw; + return a.standstill; + }, + [&](const MotorTelemetry& t) { + const AxisTelemetry& a = (axis == 'P') ? t.pitch : t.yaw; + lr.cs_peak = std::max(lr.cs_peak, (double)a.cs); + lr.pwm_peak = std::max(lr.pwm_peak, (double)a.pwm); + lr.sg_min = std::min(lr.sg_min, (double)a.sg); + }, + timeout); + return lr; +} +} // namespace + +void TestRunner::testFriction(TestReport& r) { + TestSection s; + s.id = "friction"; + s.title = "full-range traverse time + load"; + const auto t_start = clock_t_::now(); + const auto move_to = std::chrono::milliseconds(profile_.geti("settle_timeout_ms", 20000)); + const int reps = std::max(1, profile_.geti("friction_reps", profile_.geti("reps", 5))); + s.notes.push_back("load via ST sampling (firmware LP not implemented)"); + + auto runAxis = [&](char axis, double min_deg, double max_deg) { + long lo = (axis == 'P') ? geo_.pitch.toCounts(min_deg) : geo_.yaw.toCounts(min_deg); + long hi = (axis == 'P') ? geo_.pitch.toCounts(max_deg) : geo_.yaw.toCounts(max_deg); + std::vector t_fwd, t_rev, l_fwd, l_rev; + for (int i = 0; i < reps && !cancelled(); ++i) { + setProgress("friction", i + 1, reps, axis == 'P' ? "pitch" : "yaw"); + LoadResult f = moveAndSampleLoad(motor_, cancel_, axis, hi, move_to); + LoadResult b = moveAndSampleLoad(motor_, cancel_, axis, lo, move_to); + if (f.ms > 0) { t_fwd.push_back(f.ms); l_fwd.push_back(f.cs_peak); } + if (b.ms > 0) { t_rev.push_back(b.ms); l_rev.push_back(b.cs_peak); } + } + const std::string a = (axis == 'P') ? "pitch" : "yaw"; + if (!t_fwd.empty()) { + add(s, a + "_traverse_ms_fwd", stats(t_fwd).mean, "ms"); + add(s, a + "_traverse_ms_rev", stats(t_rev).mean, "ms"); + add(s, a + "_load_peak_fwd", stats(l_fwd).max, "cs"); + add(s, a + "_load_peak_rev", stats(l_rev).max, "cs"); + double asym = std::fabs(stats(t_fwd).mean - stats(t_rev).mean); + add(s, a + "_traverse_asym_ms", asym, "ms"); + } + }; + runAxis('Y', geo_.yaw.min_deg, geo_.yaw.max_deg); + { + MotorTelemetry t = motor_.telemetry(); + if (t.pitch_present) runAxis('P', geo_.pitch.min_deg, geo_.pitch.max_deg); + } + + s.duration_ms = std::chrono::duration(clock_t_::now() - t_start).count(); + rollup(s); + r.sections.push_back(std::move(s)); +} + +void TestRunner::testBacklash(TestReport& r) { + TestSection s; + s.id = "backlash"; + s.title = "reversal dead-band"; + const auto t_start = clock_t_::now(); + const auto move_to = std::chrono::milliseconds(profile_.geti("settle_timeout_ms", 20000)); + std::vector steps = profile_.getLongList("backlash_step_counts"); + if (steps.empty()) steps = {500}; + + auto followErr = [&](char axis) -> long { + MotorTelemetry t = motor_.telemetry(); + const AxisTelemetry& a = (axis == 'P') ? t.pitch : t.yaw; + return std::labs(a.xactual - a.xenc); + }; + auto stepMove = [&](char axis, long delta) { + MotorTelemetry t = motor_.telemetry(); + const AxisTelemetry& a = (axis == 'P') ? t.pitch : t.yaw; + long target = a.xenc + delta; + moveAndSampleLoad(motor_, cancel_, axis, target, move_to); + }; + + auto runAxis = [&](char axis) { + const std::string a = (axis == 'P') ? "pitch" : "yaw"; + for (long step : steps) { + if (cancelled()) return; + setProgress("backlash", 1, (int)steps.size(), a.c_str()); + // advance a few steps forward, then reverse and read the dead-band + for (int k = 0; k < 3; ++k) stepMove(axis, +step); + long mid_err = followErr(axis); + stepMove(axis, -step); + long reversal_err = followErr(axis); + auto& m = add(s, a + "_deadband_s" + std::to_string(step), reversal_err - mid_err, "counts"); + if (profile_.get("backlash_deadband_max_counts", 0) > 0) + thresh(m, profile_.get("backlash_deadband_max_counts", 0)); + } + }; + runAxis('Y'); + { + MotorTelemetry t = motor_.telemetry(); + if (t.pitch_present) runAxis('P'); + } + + s.duration_ms = std::chrono::duration(clock_t_::now() - t_start).count(); + rollup(s); + r.sections.push_back(std::move(s)); +} + +void TestRunner::testBalance(TestReport& r) { + TestSection s; + s.id = "balance"; + s.title = "pitch up/down current symmetry"; + const auto t_start = clock_t_::now(); + MotorTelemetry tp = motor_.telemetry(); + if (!tp.pitch_present) { + s.notes.push_back("no pitch axis present; skipped"); + s.ran = false; + r.sections.push_back(std::move(s)); + return; + } + const auto move_to = std::chrono::milliseconds(profile_.geti("settle_timeout_ms", 20000)); + const int N = std::max(2, profile_.geti("balance_steps", 10)); + const double lo = geo_.pitch.min_deg, hi = geo_.pitch.max_deg; + + std::vector up(N, 0), down(N, 0); + // upward + for (int i = 0; i < N && !cancelled(); ++i) { + setProgress("balance", i + 1, N, "up"); + double deg = lo + (hi - lo) * (i + 1) / N; + LoadResult lr = moveAndSampleLoad(motor_, cancel_, 'P', geo_.pitch.toCounts(deg), move_to); + up[i] = lr.cs_peak; + } + // downward (reverse order) + for (int i = N - 1; i >= 0 && !cancelled(); --i) { + setProgress("balance", N - i, N, "down"); + double deg = lo + (hi - lo) * i / N; + LoadResult lr = moveAndSampleLoad(motor_, cancel_, 'P', geo_.pitch.toCounts(deg), move_to); + down[i] = lr.cs_peak; + } + double max_asym = 0, sum_asym = 0; + for (int i = 0; i < N; ++i) { + double d = std::fabs(up[i] - down[i]); + max_asym = std::max(max_asym, d); + sum_asym += d; + add(s, "step" + std::to_string(i) + "_up", up[i], "cs"); + add(s, "step" + std::to_string(i) + "_down", down[i], "cs"); + } + add(s, "asym_mean", N ? sum_asym / N : 0, "cs"); + thresh(add(s, "asym_max", max_asym, "cs"), profile_.get("balance_asym_max", 4)); + + s.duration_ms = std::chrono::duration(clock_t_::now() - t_start).count(); + rollup(s); + r.sections.push_back(std::move(s)); +} + +// --------------------------------------------------------------------------- +// IMU modules +// --------------------------------------------------------------------------- +void TestRunner::testImuConfig(TestReport& r) { + TestSection s; + s.id = "imu_config"; + s.title = "IMU configuration"; + if (!imu_) { s.notes.push_back("no IMU"); s.ran = false; r.sections.push_back(std::move(s)); return; } + setProgress("imu_config", 1, 1, "reading"); + imu_->refreshConfig(); + auto cfg = imu_->config(); + if (!cfg || !cfg->valid) { + s.notes.push_back("device config unavailable"); + thresh(add(s, "config_readable", 0, ""), 1); // lower_is_better default => fails (0<=1 true) + s.metrics.back().lower_is_better = false; + s.metrics.back().pass = false; + s.ran = true; s.pass = false; + r.sections.push_back(std::move(s)); + return; + } + auto& c = *cfg; + s.notes.push_back("device: " + c.product_code + " fw " + c.firmware); + // Expected sample rate. + double exp_hz = profile_.get("imu_expected_hz", 100); + auto& mh = add(s, "sample_rate_hz", c.sample_rate_hz, "Hz", false); + mh.has_threshold = true; mh.threshold = exp_hz * 0.95; + mh.pass = std::fabs(c.sample_rate_hz - exp_hz) <= exp_hz * 0.05; + // Output content present. + auto& mo = add(s, "orientation_output", c.out_orientation ? 1 : 0, "", false); + mo.has_threshold = true; mo.threshold = 1; mo.pass = c.out_orientation; + // Active XKF profile matches expectation, if configured. + std::string want = profile_.gets("imu_expected_profile"); + if (!want.empty()) { + auto& mp = add(s, "xkf_profile_match", c.scenario_label == want ? 1 : 0, "", false); + mp.has_threshold = true; mp.threshold = 1; mp.pass = (c.scenario_label == want); + if (!mp.pass) s.notes.push_back("XKF profile is '" + c.scenario_label + "', expected '" + want + "'"); + } + rollup(s); + r.sections.push_back(std::move(s)); +} + +namespace { +// Collect IMU samples for `window`. Returns euler vectors + temp + dropped count. +struct ImuStats { + std::vector roll, pitch, yaw, temp; + int dropped = 0, n = 0; + double accel_norm_mean = 0; +}; +ImuStats sampleImu(IImuSource& imu, const std::atomic& cancel, + std::chrono::milliseconds window) { + ImuStats st; + const auto deadline = clock_t_::now() + window; + int last_counter = -1; + double accel_sum = 0; + int accel_n = 0; + while (clock_t_::now() < deadline && !cancel.load()) { + auto smp = imu.sample(); + if (smp && smp->valid) { + st.roll.push_back(smp->roll_deg); + st.pitch.push_back(smp->pitch_deg); + st.yaw.push_back(smp->yaw_deg); + st.temp.push_back(smp->temp_c); + double an = std::sqrt(smp->acc[0] * smp->acc[0] + smp->acc[1] * smp->acc[1] + + smp->acc[2] * smp->acc[2]); + accel_sum += an; + ++accel_n; + int cnt = smp->sample_counter; + if (last_counter >= 0) { + int gap = (cnt - last_counter) & 0xFFFF; + if (gap > 1) st.dropped += gap - 1; + } + last_counter = cnt; + ++st.n; + } + std::this_thread::sleep_for(5ms); + } + st.accel_norm_mean = accel_n ? accel_sum / accel_n : 0; + return st; +} +} // namespace + +void TestRunner::testImuHealth(TestReport& r) { + TestSection s; + s.id = "imu_health"; + s.title = "IMU health (static)"; + if (!imu_) { s.notes.push_back("no IMU"); s.ran = false; r.sections.push_back(std::move(s)); return; } + const auto window = std::chrono::milliseconds(profile_.geti("imu_sample_window_ms", 3000)); + setProgress("imu_health", 1, 1, "sampling"); + ImuStats st = sampleImu(*imu_, cancel_, window); + if (st.n == 0) { + s.notes.push_back("no IMU samples"); + s.ran = true; s.pass = false; + r.sections.push_back(std::move(s)); + return; + } + double secs = window.count() / 1000.0; + add(s, "sample_rate_hz", secs > 0 ? st.n / secs : 0, "Hz", false); + thresh(add(s, "dropped_samples", st.dropped, "count"), profile_.get("imu_dropped_max", 0)); + thresh(add(s, "yaw_noise", stats(st.yaw).sd, "deg"), profile_.get("imu_yaw_noise_max_deg", 0.5)); + add(s, "pitch_noise", stats(st.pitch).sd, "deg"); + add(s, "roll_noise", stats(st.roll).sd, "deg"); + thresh(add(s, "accel_norm_err", std::fabs(st.accel_norm_mean - 9.81), "m/s2"), + profile_.get("imu_accel_norm_err_max", 0.5)); + auto& mt = add(s, "temperature", st.temp.empty() ? 0 : stats(st.temp).mean, "C"); + if (profile_.get("imu_temp_max_c", 0) > 0) thresh(mt, profile_.get("imu_temp_max_c", 0)); + rollup(s); + r.sections.push_back(std::move(s)); +} + +void TestRunner::testImuDrift(TestReport& r) { + TestSection s; + s.id = "imu_drift"; + s.title = "IMU yaw drift (static)"; + if (!imu_) { s.notes.push_back("no IMU"); s.ran = false; r.sections.push_back(std::move(s)); return; } + const auto window = std::chrono::milliseconds(profile_.geti("imu_drift_window_ms", 60000)); + setProgress("imu_drift", 1, 1, "drift window"); + + // Sample yaw vs time; least-squares slope (deg/min). + std::vector ts, yaw; + const auto t0 = clock_t_::now(); + const auto deadline = t0 + window; + double peak_min = 1e9, peak_max = -1e9; + while (clock_t_::now() < deadline && !cancelled()) { + auto smp = imu_->sample(); + if (smp && smp->valid) { + double tmin = std::chrono::duration(clock_t_::now() - t0).count() / 60.0; + ts.push_back(tmin); + yaw.push_back(smp->yaw_deg); + peak_min = std::min(peak_min, (double)smp->yaw_deg); + peak_max = std::max(peak_max, (double)smp->yaw_deg); + } + std::this_thread::sleep_for(20ms); + } + if (ts.size() < 3) { + s.notes.push_back("insufficient IMU samples for drift fit"); + s.ran = true; s.pass = false; + r.sections.push_back(std::move(s)); + return; + } + // slope = cov(t,yaw)/var(t) + double mt = std::accumulate(ts.begin(), ts.end(), 0.0) / ts.size(); + double my = std::accumulate(yaw.begin(), yaw.end(), 0.0) / yaw.size(); + double cov = 0, var = 0; + for (size_t i = 0; i < ts.size(); ++i) { + cov += (ts[i] - mt) * (yaw[i] - my); + var += (ts[i] - mt) * (ts[i] - mt); + } + double slope = var > 1e-12 ? cov / var : 0.0; // deg per minute + thresh(add(s, "yaw_drift_deg_min", std::fabs(slope), "deg/min"), + profile_.get("imu_yaw_drift_max_deg_min", 1.0)); + add(s, "yaw_peak_excursion", peak_max - peak_min, "deg"); + s.duration_ms = std::chrono::duration(clock_t_::now() - t0).count(); + rollup(s); + r.sections.push_back(std::move(s)); +} + +// --------------------------------------------------------------------------- +// Host module +// --------------------------------------------------------------------------- +void TestRunner::testHost(TestReport& r) { + using namespace hostmetrics; + if (selection_ & T_HOST_THERMAL) { + TestSection s; s.id = "host_thermal"; s.title = "host CPU temperature"; + ThermalInfo t = readThermal(); + if (t.ok) { + auto& m = add(s, "cpu_temp", t.max_temp_c, "C"); + if (profile_.get("cpu_temp_max_c", 0) > 0) thresh(m, profile_.get("cpu_temp_max_c", 85)); + add(s, "throttled", t.throttled ? 1 : 0, ""); + s.notes.push_back("hottest zone: " + t.hottest_zone); + } else s.notes.push_back("no thermal zones readable"); + rollup(s); r.sections.push_back(std::move(s)); + } + if (selection_ & T_HOST_DISK) { + TestSection s; s.id = "host_disk"; s.title = "image partition free space"; + DiskInfo d = readDisk(disk_path_.empty() ? "." : disk_path_); + if (d.ok) { + auto& mf = add(s, "free_gb", d.free_gb, "GB", false); + if (profile_.get("disk_free_min_gb", 0) > 0) thresh(mf, profile_.get("disk_free_min_gb", 5)); + add(s, "used_pct", d.used_pct, "%"); + } else s.notes.push_back("statvfs failed for " + d.path); + rollup(s); r.sections.push_back(std::move(s)); + } + if (selection_ & T_HOST_MEMORY) { + TestSection s; s.id = "host_memory"; s.title = "host memory"; + MemInfo m = readMeminfo(); + if (m.ok) { + auto& ma = add(s, "ram_avail_mb", m.avail_mb, "MB", false); + if (profile_.get("ram_avail_min_mb", 0) > 0) thresh(ma, profile_.get("ram_avail_min_mb", 256)); + auto& ms = add(s, "swap_used_mb", m.swap_used_mb, "MB"); + if (profile_.get("swap_used_max_mb", 0) > 0) thresh(ms, profile_.get("swap_used_max_mb", 0)); + } else s.notes.push_back("/proc/meminfo unreadable"); + rollup(s); r.sections.push_back(std::move(s)); + } + if (selection_ & T_HOST_LOAD) { + TestSection s; s.id = "host_load"; s.title = "host load average"; + LoadInfo l = readLoad(); + if (l.ok) { + add(s, "load1", l.load1, ""); + add(s, "load5", l.load5, ""); + add(s, "cpus", l.cpus, ""); + if (l.cpus > 0) thresh(add(s, "load1_per_cpu", l.load1 / l.cpus, ""), + profile_.get("load_per_cpu_max", 1.5)); + } else s.notes.push_back("load average unavailable"); + rollup(s); r.sections.push_back(std::move(s)); + } +} + +} // namespace fgc diff --git a/src/ui/TuiUi.cpp b/src/ui/TuiUi.cpp index 04c1c7b..cb898ca 100644 --- a/src/ui/TuiUi.cpp +++ b/src/ui/TuiUi.cpp @@ -729,6 +729,10 @@ void TuiUi::uiLoop() { if (e == Event::ArrowUp) { help_sel = (help_sel - 1 + n) % n; return true; } } if (overlay != Overlay::None && e == Event::Escape) { overlay = Overlay::None; return true; } + // Esc with no overlay open cancels a running procedure (test/calib/homing). + if (overlay == Overlay::None && e == Event::Escape && sink_) { + if (snapshot_ && snapshot_().activity.cancelable) { sink_("cancel"); return true; } + } // Arrow keys nudge the gimbal in steps (only when no overlay is open): // Left/Right = yaw -/+5%, Up/Down = pitch -/+10% of travel. if (overlay == Overlay::None && sink_) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0670fbe..edcd9b0 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -34,6 +34,9 @@ add_executable(fgc_tests test_diagparser.cpp test_calibration.cpp test_calibroutine.cpp + test_testreport.cpp + test_hostmetrics.cpp + test_testrunner.cpp ) target_link_libraries(fgc_tests PRIVATE fgc_core doctest::doctest) diff --git a/tests/test_hostmetrics.cpp b/tests/test_hostmetrics.cpp new file mode 100644 index 0000000..c71b095 --- /dev/null +++ b/tests/test_hostmetrics.cpp @@ -0,0 +1,38 @@ +#include + +#include "fgc/HostMetrics.h" + +using namespace fgc::hostmetrics; + +TEST_CASE("parseMeminfo extracts totals and swap usage") { + const char* meminfo = + "MemTotal: 16384000 kB\n" + "MemFree: 2000000 kB\n" + "MemAvailable: 8192000 kB\n" + "Buffers: 100000 kB\n" + "SwapTotal: 2048000 kB\n" + "SwapFree: 1024000 kB\n"; + MemInfo m = parseMeminfo(meminfo); + CHECK(m.ok); + CHECK(m.total_mb == doctest::Approx(16384000.0 / 1024.0)); + CHECK(m.avail_mb == doctest::Approx(8192000.0 / 1024.0)); + CHECK(m.swap_used_mb == doctest::Approx((2048000.0 - 1024000.0) / 1024.0)); +} + +TEST_CASE("parseMeminfo: missing fields => not ok") { + MemInfo m = parseMeminfo("MemFree: 100 kB\n"); + CHECK_FALSE(m.ok); +} + +TEST_CASE("parseMilliCelsius converts sysfs milli-degrees") { + CHECK(parseMilliCelsius("52000\n") == doctest::Approx(52.0)); + CHECK(parseMilliCelsius("") == doctest::Approx(0.0)); +} + +TEST_CASE("readDisk on the working tree returns sane values") { + DiskInfo d = readDisk("."); + CHECK(d.ok); + CHECK(d.total_gb > 0.0); + CHECK(d.used_pct >= 0.0); + CHECK(d.used_pct <= 100.0); +} diff --git a/tests/test_testreport.cpp b/tests/test_testreport.cpp new file mode 100644 index 0000000..001e3e2 --- /dev/null +++ b/tests/test_testreport.cpp @@ -0,0 +1,88 @@ +#include + +#include "fgc/TestReport.h" + +using namespace fgc; + +namespace { +TestReport makeReport() { + TestReport r; + r.ts_ms = 1719312345678LL; + r.profile = "standard"; + r.host = "towerpc"; + r.fw = "fgc-1.2.3"; + TestSection s; + s.id = "homing"; + s.title = "homing reproducibility"; + s.ran = true; + s.duration_ms = 1234; + TestMetric m1; + m1.name = "yaw_lim_neg_spread"; m1.value = 12; m1.unit = "counts"; + m1.has_threshold = true; m1.threshold = 80; m1.pass = true; + s.metrics.push_back(m1); + TestMetric m2; + m2.name = "home_ms_mean"; m2.value = 8200.5; m2.unit = "ms"; m2.pass = true; + s.metrics.push_back(m2); + s.pass = true; + r.sections.push_back(s); + r.all_pass = true; + return r; +} +} // namespace + +TEST_CASE("formatTestReport round-trips through parseTestReport") { + TestReport r = makeReport(); + std::string text = formatTestReport(r); + TestReport back = parseTestReport(text); + + CHECK(back.ts_ms == r.ts_ms); + CHECK(back.profile == "standard"); + CHECK(back.host == "towerpc"); + CHECK(back.fw == "fgc-1.2.3"); + CHECK(back.all_pass == true); + REQUIRE(back.sections.size() == 1); + CHECK(back.sections[0].id == "homing"); + + const TestMetric* m = back.find("homing", "yaw_lim_neg_spread"); + REQUIRE(m != nullptr); + CHECK(m->value == doctest::Approx(12)); + CHECK(m->unit == "counts"); + const TestMetric* m2 = back.find("homing", "home_ms_mean"); + REQUIRE(m2 != nullptr); + CHECK(m2->value == doctest::Approx(8200.5)); +} + +TEST_CASE("applyComparison computes drift and flags regressions") { + TestReport baseline = makeReport(); // yaw_lim_neg_spread = 12 + TestReport prev = makeReport(); + prev.sections[0].metrics[0].value = 13; + + TestReport cur = makeReport(); + cur.sections[0].metrics[0].value = 18; // +50% vs baseline (12) + + applyComparison(cur, &baseline, &prev, /*warn_pct=*/15, /*gates=*/true); + + const TestMetric* m = cur.find("homing", "yaw_lim_neg_spread"); + REQUIRE(m != nullptr); + CHECK(m->has_baseline); + CHECK(m->baseline == doctest::Approx(12)); + CHECK(m->has_prev); + CHECK(m->prev == doctest::Approx(13)); + CHECK(m->drift_pct == doctest::Approx(50.0)); + CHECK(m->drift_flag == true); + CHECK(m->pass == false); // drift gate trips the metric + CHECK(cur.sections[0].pass == false); + CHECK(cur.all_pass == false); +} + +TEST_CASE("applyComparison: improvement (lower is better) does not flag") { + TestReport baseline = makeReport(); // 12 + TestReport cur = makeReport(); + cur.sections[0].metrics[0].value = 6; // -50%, an improvement + + applyComparison(cur, &baseline, nullptr, 15, true); + const TestMetric* m = cur.find("homing", "yaw_lim_neg_spread"); + REQUIRE(m != nullptr); + CHECK(m->drift_flag == false); + CHECK(m->pass == true); +} diff --git a/tests/test_testrunner.cpp b/tests/test_testrunner.cpp new file mode 100644 index 0000000..db56428 --- /dev/null +++ b/tests/test_testrunner.cpp @@ -0,0 +1,110 @@ +#include + +#include "fgc/TestRunner.h" +#include "fgc/mock/MockImuSource.h" +#include "fgc/mock/MockMotorController.h" + +#include +#include + +using namespace fgc; + +namespace { +// A tiny, fast profile so a full run completes in well under a second. +TestProfile fastProfile() { + TestProfile p; + p.name = "test"; + p.params["reps"] = 2; + p.params["home_begin_timeout_ms"] = 80; + p.params["home_timeout_ms"] = 2000; + p.params["settle_timeout_ms"] = 2000; + p.params["encoder_diag_reps"] = 1; + p.params["hold_window_ms"] = 50; + p.params["balance_steps"] = 3; + p.params["imu_sample_window_ms"] = 80; + p.params["imu_drift_window_ms"] = 80; + p.text["backlash_step_counts"] = "500"; + return p; +} + +Geometry unitGeometry() { + Geometry g; + g.yaw.counts_per_deg = 1000; g.yaw.zero_count = 0; g.yaw.min_deg = -90; g.yaw.max_deg = 90; + g.pitch.counts_per_deg = 1000; g.pitch.zero_count = 0; g.pitch.min_deg = 0; g.pitch.max_deg = 60; + return g; +} + +std::optional runToCompletion(TestRunner& tr, int max_ms = 8000) { + REQUIRE(tr.start()); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(max_ms); + while (std::chrono::steady_clock::now() < deadline) { + if (auto rep = tr.takeReport()) return rep; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + return std::nullopt; +} +} // namespace + +TEST_CASE("resolveTestSelection maps subsystem/test tokens") { + uint32_t m = 0; + std::string err; + CHECK(resolveTestSelection("", "", m, err)); + CHECK(m == T_ALL); + CHECK(resolveTestSelection("gimbal", "", m, err)); + CHECK(m == T_GIMBAL_ALL); + CHECK(resolveTestSelection("gimbal", "homing", m, err)); + CHECK(m == T_GIMBAL_HOMING); + CHECK(resolveTestSelection("host", "disk", m, err)); + CHECK(m == T_HOST_DISK); + CHECK_FALSE(resolveTestSelection("bogus", "", m, err)); + CHECK_FALSE(resolveTestSelection("gimbal", "bogus", m, err)); +} + +TEST_CASE("TestRunner host-only run completes and produces sections") { + MockMotorController motor; + TestRunner tr(motor, nullptr, unitGeometry(), T_HOST_ALL, fastProfile(), "."); + auto rep = runToCompletion(tr); + REQUIRE(rep.has_value()); + CHECK_FALSE(tr.running()); + CHECK(rep->find("host_memory", "ram_avail_mb") != nullptr); + CHECK(rep->find("host_disk", "free_gb") != nullptr); +} + +TEST_CASE("TestRunner full run with mocks completes with all selected sections") { + MockMotorController motor; + MockImuSource imu; + imu.start(); + TestRunner tr(motor, &imu, unitGeometry(), T_ALL, fastProfile(), "."); + auto rep = runToCompletion(tr); + REQUIRE(rep.has_value()); + + auto has = [&](const char* id) { + for (const auto& s : rep->sections) if (s.id == id) return true; + return false; + }; + CHECK(has("homing")); + CHECK(has("encoder")); + CHECK(has("friction")); + CHECK(has("backlash")); + CHECK(has("balance")); + CHECK(has("imu_config")); + CHECK(has("imu_health")); + CHECK(has("imu_drift")); + CHECK(has("host_thermal")); +} + +TEST_CASE("TestRunner is cancellable") { + MockMotorController motor; + MockImuSource imu; + imu.start(); + TestProfile p = fastProfile(); + p.params["imu_drift_window_ms"] = 5000; // long enough to cancel mid-run + TestRunner tr(motor, &imu, unitGeometry(), T_ALL, p, "."); + REQUIRE(tr.start()); + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + tr.cancel(); + const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(6); + while (tr.running() && std::chrono::steady_clock::now() < deadline) + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + CHECK_FALSE(tr.running()); +}