#include #include "fgc/CalibrationRoutine.h" #include "fgc/Geometry.h" #include "fgc/IImuSource.h" #include "fgc/IMotorController.h" #include #include #include #include #include #include using namespace fgc; namespace { // A two-axis firmware DUMP with soft limits (subset of the real format; enough // for parseDump to expose lim_neg/lim_pos per axis). const char* kDump = "DUMP BEGIN build=test uptime=1000 mcusr=0x01 free_ram=1852\n" "DUMP Y state=3 hsub=10 enabled=1 lim_neg=-80000 lim_pos=80000 hold_target=0 " "speed=50000 eeprom_restored=0 has_encoder=1\n" "DUMP Y TMC GCONF=0x0000000C DRV_STATUS=0x80084000\n" "DUMP P state=3 hsub=10 enabled=1 lim_neg=0 lim_pos=600000 hold_target=0 " "speed=150000 eeprom_restored=0 has_encoder=1\n" "DUMP P TMC GCONF=0x0000000C DRV_STATUS=0x80084000\n" "DUMP END\n"; // Counts-per-degree the fake IMU reports, so a clean sweep recovers this slope. constexpr double kFakeCpd = 10000.0; // Fake motor: records every command in order, and "executes" HOME/MOVE instantly // (axis jumps to target, standstill + READY) so waitSettle/waitHomed return at // once. IMU angle is derived from the live encoder position (see FakeImu). struct FakeImu; struct FakeMotor : IMotorController { mutable std::mutex mtx; MotorTelemetry tel; std::vector events; // unified, ordered log (motor + imu) bool pitch_present = true; bool have_dump = true; void start() override {} void stop() override {} void record(const std::string& s) { std::lock_guard lk(mtx); events.push_back(s); } void sendCommand(const std::string& c) override { record(c); std::lock_guard lk(mtx); std::istringstream iss(c); std::string verb; iss >> verb; if (verb == "HOME") { tel.yaw.state = AxisState::Ready; tel.yaw.standstill = true; tel.pitch.state = AxisState::Ready; tel.pitch.standstill = true; tel.pitch_present = pitch_present; } else if (verb == "MOVE") { std::string axis; long target = 0; iss >> axis >> target; AxisTelemetry& a = (axis == "Y") ? tel.yaw : tel.pitch; a.xenc = target; a.standstill = true; a.state = AxisState::Ready; } } MotorTelemetry telemetry() override { std::lock_guard lk(mtx); return tel; } std::string lastDump() override { return have_dump ? kDump : ""; } std::string lastDiag() override { return ""; } unsigned diagSeq() const override { return 0; } bool connected() const override { return true; } }; // Fake IMU: pitch is the live pitch encoder / kFakeCpd; yaw is the live yaw // encoder offset by the heading-reset zero, / kFakeCpd. Records noRotation / // headingReset into the shared motor event log so ordering can be asserted. struct FakeImu : IImuSource { FakeMotor& motor; long yaw_zero = 0; int scenario_type = 39; // starts on a mag-using profile bool has_profiles = true; // expose an available list (incl. a no-mag one) explicit FakeImu(FakeMotor& m) : motor(m) {} void start() override {} void stop() override {} bool connected() const override { return true; } std::optional sample() override { MotorTelemetry t = motor.telemetry(); ImuSample s; s.valid = true; s.pitch_deg = static_cast(t.pitch.xenc / kFakeCpd); double y = (t.yaw.xenc - yaw_zero) / kFakeCpd; s.yaw_deg = static_cast(y); return s; } std::optional config() const override { ImuDeviceConfig c; c.valid = true; c.has_scenario = true; c.scenario_type = static_cast(scenario_type); if (has_profiles) c.available_profiles = {{39, 11, "General"}, {40, 11, "High_mag_dep"}, {53, 11, "VRU_general"}}; return c; } void noRotation(int seconds) override { motor.record("noRotation:" + std::to_string(seconds)); } void headingReset() override { motor.record("headingReset"); std::lock_guard lk(motor.mtx); yaw_zero = motor.tel.yaw.xenc; // current pose becomes yaw 0 } bool setFilterProfile(int type) override { motor.record("setProfile:" + std::to_string(type)); scenario_type = type; return true; } }; Geometry uncalibrated() { Geometry g; g.yaw = {1.0, 0, -100000.0, 100000.0}; g.pitch = {1.0, 0, -100000.0, 100000.0}; return g; } // Fast timings so a full run completes in well under a second. CalibParams fastParams() { CalibParams p; p.positions = 4; p.dwell_ms = 4; p.sample_ms = 1; p.settle_timeout_ms = 200; p.home_timeout_ms = 500; p.norotation_s = 0; p.reset_settle_ms = 2; return p; } // Run to completion (or fail the test on timeout). void runToCompletion(CalibrationRoutine& cal) { REQUIRE(cal.start()); auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); while (cal.running()) { if (std::chrono::steady_clock::now() > deadline) { FAIL("calibration did not finish"); } std::this_thread::sleep_for(std::chrono::milliseconds(5)); } } int indexOf(const std::vector& v, const std::string& needle) { for (int i = 0; i < static_cast(v.size()); ++i) if (v[i].find(needle) != std::string::npos) return i; return -1; } int lastIndexOf(const std::vector& v, const std::string& needle) { int found = -1; for (int i = 0; i < static_cast(v.size()); ++i) if (v[i].find(needle) != std::string::npos) found = i; return found; } } // namespace TEST_CASE("calibration homes first when the gimbal is not READY") { FakeMotor motor; // starts in Boot (not ready) motor.tel.pitch_present = true; FakeImu imu(motor); CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); runToCompletion(cal); const auto& ev = motor.events; REQUIRE_FALSE(ev.empty()); CHECK(ev.front() == "HOME"); // homing is the first action CHECK(indexOf(ev, "HOME") < indexOf(ev, "MOVE P")); // home before any pitch move } TEST_CASE("calibration does NOT home when already READY") { FakeMotor motor; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true; motor.tel.pitch_present = true; FakeImu imu(motor); CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); runToCompletion(cal); CHECK(indexOf(motor.events, "HOME") == -1); // no homing issued } TEST_CASE("calibration runs pitch-at-first-yaw, then drift-correct+reset, then yaw") { FakeMotor motor; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true; motor.tel.pitch_present = true; FakeImu imu(motor); CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); runToCompletion(cal); const auto& ev = motor.events; // Yaw is parked at its first position before the pitch sweep begins. const int first_yaw_move = indexOf(ev, "MOVE Y"); const int first_pitch_move = indexOf(ev, "MOVE P"); REQUIRE(first_yaw_move >= 0); REQUIRE(first_pitch_move >= 0); CHECK(first_yaw_move < first_pitch_move); // No-mag profile switch, drift correction, then heading reset happen AFTER the // pitch sweep and BEFORE the yaw sweep reaches its far end. const int prof = indexOf(ev, "setProfile:53"); // VRU_general from the fake list const int norot = indexOf(ev, "noRotation"); const int reset = indexOf(ev, "headingReset"); REQUIRE(prof >= 0); REQUIRE(norot >= 0); REQUIRE(reset >= 0); CHECK(first_pitch_move < prof); // pitch calibrated before the profile switch CHECK(prof < norot); // switch to no-mag, then correct drift CHECK(norot < reset); // correct drift, then reset CHECK(reset < lastIndexOf(ev, "MOVE Y")); // yaw sweep continues after the reset // Pitch is commanded to 0 deg before the heading reset. CHECK(indexOf(ev, "MOVE P 0") >= 0); CHECK(indexOf(ev, "MOVE P 0") < reset); } TEST_CASE("calibration skips the profile switch when no no-mag profile exists") { FakeMotor motor; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true; motor.tel.pitch_present = true; FakeImu imu(motor); imu.has_profiles = false; // device reports no available profiles CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); runToCompletion(cal); const auto& ev = motor.events; CHECK(indexOf(ev, "setProfile") == -1); // nothing to switch to CHECK(indexOf(ev, "headingReset") >= 0); // still resets + sweeps yaw CHECK(cal.report().valid); } TEST_CASE("calibration recovers the fake IMU slope for both axes") { FakeMotor motor; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.pitch.state = AxisState::Ready; motor.tel.pitch.standstill = true; motor.tel.pitch_present = true; FakeImu imu(motor); CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); runToCompletion(cal); CalibReport rep = cal.report(); REQUIRE(rep.valid); CHECK(rep.all_ok); REQUIRE(rep.axes.size() == 2); for (const auto& ax : rep.axes) { CHECK(ax.ok); CHECK(ax.counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001)); CHECK(ax.r2 == doctest::Approx(1.0).epsilon(0.001)); } // The new geometry is published for the session to apply. auto geo = cal.takeResult(); REQUIRE(geo.has_value()); CHECK(geo->yaw.counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001)); CHECK(geo->pitch.counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001)); } TEST_CASE("calibration handles a yaw-only gimbal (no pitch axis)") { FakeMotor motor; motor.pitch_present = false; motor.tel.yaw.state = AxisState::Ready; motor.tel.yaw.standstill = true; motor.tel.pitch_present = false; FakeImu imu(motor); CalibrationRoutine cal(motor, imu, uncalibrated(), fastParams()); runToCompletion(cal); const auto& ev = motor.events; CHECK(indexOf(ev, "MOVE P") == -1); // no pitch moves CHECK(indexOf(ev, "headingReset") >= 0); // still resets + sweeps yaw CHECK(indexOf(ev, "MOVE Y") >= 0); CalibReport rep = cal.report(); REQUIRE(rep.valid); REQUIRE(rep.axes.size() == 1); CHECK(rep.axes[0].axis == 'Y'); CHECK(rep.axes[0].counts_per_deg == doctest::Approx(kFakeCpd).epsilon(0.001)); }