Introduced harware in-situ testing
This commit is contained in:
parent
2c05c08f24
commit
bdb4b82885
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 => <logdir>/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).
|
||||
|
|
|
|||
|
|
@ -179,8 +179,21 @@ Handled in `Application::Impl::handleCommand`.
|
|||
| `set camera fps <v>` | Camera acquisition frame rate (real camera only) |
|
||||
| `set fps <v>` | Capture interval rate (images/second) |
|
||||
| `set motorctl <cmd>` | Forward a raw command to the motor controller (e.g. `set motorctl MOVE Y 20000`) |
|
||||
| `test [<subsystem> [<leaf>]] [<profile>]` | 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.<name>]` 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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
@ -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 [<subsystem> [<leaf>]] [<profile>]
|
||||
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.<name>]`
|
||||
(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 `<logdir>/test_baseline.txt`); subsequent runs
|
||||
show `base=` columns against it.
|
||||
|
|
@ -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 <decideg> RH <deci%> 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.
|
||||
|
|
@ -77,6 +77,30 @@ struct ScanConfig {
|
|||
std::string pitch_levels = "0"; // generated: comma list of pitch elevations (deg)
|
||||
};
|
||||
|
||||
// [Test] / [TestProfile.<name>]: 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<std::string, double> params; // numeric knobs: reps, *_window_ms, *_max_*
|
||||
std::map<std::string, std::string> 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<long> getLongList(const std::string& key) const; // parse "200,500,1000"
|
||||
};
|
||||
|
||||
struct TestConfig {
|
||||
std::string default_profile = "standard";
|
||||
std::string baseline_file; // empty => <logdir>/test_baseline.txt
|
||||
std::map<std::string, TestProfile> 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;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,49 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
#pragma once
|
||||
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
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<TestMetric> metrics;
|
||||
std::vector<std::string> 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<TestSection> 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
|
||||
|
|
@ -0,0 +1,111 @@
|
|||
#pragma once
|
||||
|
||||
#include "fgc/Config.h" // TestProfile
|
||||
#include "fgc/Geometry.h"
|
||||
#include "fgc/TestReport.h"
|
||||
|
||||
#include <atomic>
|
||||
#include <cstdint>
|
||||
#include <mutex>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <thread>
|
||||
|
||||
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<TestReport> 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<bool> running_{false};
|
||||
std::atomic<bool> cancel_{false};
|
||||
|
||||
mutable std::mutex result_mutex_;
|
||||
std::optional<TestReport> report_;
|
||||
|
||||
mutable std::mutex progress_mutex_;
|
||||
TestProgress progress_;
|
||||
};
|
||||
|
||||
} // namespace fgc
|
||||
|
|
@ -187,6 +187,7 @@ struct ActivityView {
|
|||
std::vector<std::string> 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<DiagAxisView> 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<std::string> 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) ----
|
||||
|
|
|
|||
|
|
@ -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<CaptureScheduler> scheduler;
|
||||
std::unique_ptr<IUserInterface> ui;
|
||||
std::unique_ptr<CalibrationRoutine> calib;
|
||||
std::unique_ptr<TestRunner> 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<std::string> 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<std::string> 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<bool> homing_active_{false};
|
||||
std::atomic<bool> 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<bool>& 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 <move|steps|nudge|home|stop|reset|enable|disable|"
|
||||
"speed|setpos|status|dump|diag|calib|raw>";
|
||||
"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<long>(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<std::string> local;
|
||||
{
|
||||
std::lock_guard<std::mutex> 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<char>(std::tolower((unsigned char)ch));
|
||||
return s;
|
||||
}
|
||||
|
||||
void handleTest(const std::string& line) {
|
||||
std::istringstream iss(line);
|
||||
std::vector<std::string> 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 [<leaf>]] [<profile>] (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<TestRunner>(*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();
|
||||
|
|
|
|||
|
|
@ -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<int>(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<long> TestProfile::getLongList(const std::string& key) const {
|
||||
std::vector<long> 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<std::string, std::string>& kv) {
|
||||
AppConfig cfg;
|
||||
|
||||
|
|
@ -119,6 +158,33 @@ AppConfig ConfigLoader::fromMap(const std::map<std::string, std::string>& kv) {
|
|||
cfg.imu.device = get(kv, "IMU.device", cfg.imu.device);
|
||||
cfg.imu.baud = static_cast<unsigned>(getInt(kv, "IMU.baud", cfg.imu.baud));
|
||||
|
||||
// [Test] + [TestProfile.<name>]: discover profiles by scanning the flattened
|
||||
// keys. inih gives us "TestProfile.<name>.<key>" => 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);
|
||||
|
||||
|
|
|
|||
|
|
@ -53,10 +53,15 @@ const std::vector<HelpSection>& 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 [<subsystem> [<leaf>]] [<profile>]",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,117 @@
|
|||
#include "fgc/HostMetrics.h"
|
||||
|
||||
#include <cstdlib>
|
||||
#include <filesystem>
|
||||
#include <fstream>
|
||||
#include <sstream>
|
||||
#include <string>
|
||||
|
||||
#include <sys/statvfs.h>
|
||||
#include <unistd.h>
|
||||
|
||||
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<double>(vfs.f_frsize);
|
||||
const double total = static_cast<double>(vfs.f_blocks) * frsize;
|
||||
const double avail = static_cast<double>(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<int>(n) : 0;
|
||||
return l;
|
||||
}
|
||||
|
||||
} // namespace fgc::hostmetrics
|
||||
|
|
@ -0,0 +1,170 @@
|
|||
#include "fgc/TestReport.h"
|
||||
|
||||
#include <cmath>
|
||||
#include <cstdio>
|
||||
#include <sstream>
|
||||
|
||||
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<long long>(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<long long>(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
|
||||
|
|
@ -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 <algorithm>
|
||||
#include <chrono>
|
||||
#include <cmath>
|
||||
#include <cstdlib>
|
||||
#include <numeric>
|
||||
#include <thread>
|
||||
|
||||
#include <unistd.h> // 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::milliseconds>(
|
||||
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<double>& v) {
|
||||
Stats s;
|
||||
s.n = static_cast<int>(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<std::mutex> lk(result_mutex_);
|
||||
report_.reset();
|
||||
}
|
||||
thread_ = std::thread([this] { run(); });
|
||||
return true;
|
||||
}
|
||||
|
||||
void TestRunner::cancel() { cancel_ = true; }
|
||||
|
||||
std::optional<TestReport> TestRunner::takeReport() {
|
||||
std::lock_guard<std::mutex> lk(result_mutex_);
|
||||
if (!report_) return std::nullopt;
|
||||
auto out = std::move(report_);
|
||||
report_.reset();
|
||||
return out;
|
||||
}
|
||||
|
||||
TestProgress TestRunner::progress() const {
|
||||
std::lock_guard<std::mutex> lk(progress_mutex_);
|
||||
return progress_;
|
||||
}
|
||||
|
||||
void TestRunner::setProgress(const std::string& section, int step, int total,
|
||||
const char* phase) {
|
||||
std::lock_guard<std::mutex> 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<std::mutex> 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<std::mutex> 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 <typename Pred, typename Tick>
|
||||
double pollUntil(IMotorController& motor, const std::atomic<bool>& 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<double, std::milli>(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<double> 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<double, std::milli>(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<double> 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<double>& peak, std::vector<double>& 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<double, std::milli>(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<bool>& 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<double> 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<double, std::milli>(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<long> 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<double, std::milli>(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<double> 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<double, std::milli>(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<double> roll, pitch, yaw, temp;
|
||||
int dropped = 0, n = 0;
|
||||
double accel_norm_mean = 0;
|
||||
};
|
||||
ImuStats sampleImu(IImuSource& imu, const std::atomic<bool>& 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<double> 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<double>(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<double, std::milli>(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
|
||||
|
|
@ -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_) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,38 @@
|
|||
#include <doctest/doctest.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
|
|
@ -0,0 +1,88 @@
|
|||
#include <doctest/doctest.h>
|
||||
|
||||
#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);
|
||||
}
|
||||
|
|
@ -0,0 +1,110 @@
|
|||
#include <doctest/doctest.h>
|
||||
|
||||
#include "fgc/TestRunner.h"
|
||||
#include "fgc/mock/MockImuSource.h"
|
||||
#include "fgc/mock/MockMotorController.h"
|
||||
|
||||
#include <chrono>
|
||||
#include <thread>
|
||||
|
||||
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<TestReport> 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());
|
||||
}
|
||||
Loading…
Reference in New Issue