#include "fgc/PitchSetup.h" #include "fgc/Calibration.h" #include "fgc/DumpParser.h" #include "fgc/IImuSource.h" #include "fgc/IMotorController.h" #include "fgc/Logger.h" #include "fgc/Paths.h" #include #include #include #include #include #include #include namespace fgc { namespace { using clock = std::chrono::steady_clock; void msleep(int ms) { std::this_thread::sleep_for(std::chrono::milliseconds(ms)); } } // namespace PitchSetupRoutine::PitchSetupRoutine(IMotorController& motor, IImuSource& imu, Geometry initial, CalibParams params) : motor_(motor), imu_(imu), initial_(std::move(initial)), params_(params) {} PitchSetupRoutine::~PitchSetupRoutine() { cancel(); if (thread_.joinable()) thread_.join(); } bool PitchSetupRoutine::start() { if (running_.exchange(true)) { LOG_WARN << "calibration already running"; return false; } cancel_ = false; if (thread_.joinable()) thread_.join(); thread_ = std::thread([this] { run(); }); return true; } void PitchSetupRoutine::cancel() { cancel_ = true; } std::optional PitchSetupRoutine::takeResult() { std::lock_guard lk(result_mutex_); std::optional r; r.swap(result_); return r; } CalibProgress PitchSetupRoutine::progress() const { std::lock_guard lk(progress_mutex_); return progress_; } CalibReport PitchSetupRoutine::report() const { std::lock_guard lk(result_mutex_); return report_; } void PitchSetupRoutine::setProgress(char axis, int step, int total, const char* phase) { std::lock_guard lk(progress_mutex_); progress_.running = true; progress_.axis = axis; progress_.step = step; progress_.total = total; progress_.phase = phase; } namespace { long long nowMs() { return std::chrono::duration_cast( std::chrono::system_clock::now().time_since_epoch()) .count(); } } // namespace namespace { // Wait until the axis is at `target` (standstill + within tol), or timeout/cancel. bool waitSettle(IMotorController& motor, char axis, long target, long tol, int timeout_ms, const std::atomic& cancel) { const auto deadline = clock::now() + std::chrono::milliseconds(timeout_ms); while (clock::now() < deadline) { if (cancel) return false; MotorTelemetry t = motor.telemetry(); const AxisTelemetry& a = (axis == 'Y') ? t.yaw : t.pitch; if (a.standstill && std::labs(a.xenc - target) <= tol) return true; msleep(100); } return false; } // Wait until both present axes report READY (homing complete), or timeout/cancel. bool waitHomed(IMotorController& motor, int timeout_ms, const std::atomic& cancel) { const auto deadline = clock::now() + std::chrono::milliseconds(timeout_ms); while (clock::now() < deadline) { if (cancel) return false; MotorTelemetry t = motor.telemetry(); if (t.yaw.ready() && (!t.pitch_present || t.pitch.ready())) return true; msleep(200); } return false; } // Dwell `dwell_ms` sampling the IMU; return the mean measured pitch in degrees. // A plain arithmetic mean is right here: pitch is a gravity-referenced elevation // over a ~40 deg range, so it cannot wrap the way a heading can. double dwellAndMeasure(IImuSource& imu, int dwell_ms, int sample_ms, const std::atomic& cancel) { std::vector vals; const auto end = clock::now() + std::chrono::milliseconds(dwell_ms); while (clock::now() < end) { if (cancel) break; if (auto s = imu.sample()) vals.push_back(s->pitch_deg); msleep(sample_ms); } if (vals.empty()) return 0.0; double m = 0; for (double v : vals) m += v; return m / vals.size(); } } // namespace void PitchSetupRoutine::run() { struct Done { PitchSetupRoutine* self; ~Done() { self->running_ = false; std::lock_guard lk(self->progress_mutex_); self->progress_.running = false; } } done{this}; const int N = params_.positions; LOG_INFO << "=== gimbal pitch setup starting ==="; setProgress('?', 0, N, "starting"); CalibReport report; // 1. Preconditions. if (!imu_.connected()) { LOG_WARN << "pitch setup aborted: IMU not connected (declare [Hardware] imu)"; return; } // 2. Home first if the gimbal is not already READY. MotorTelemetry t0 = motor_.telemetry(); bool homed = t0.yaw.ready() && (!t0.pitch_present || t0.pitch.ready()); if (!homed) { LOG_INFO << "pitch setup: gimbal not homed; homing first..."; setProgress('?', 0, N, "homing"); motor_.sendCommand("HOME"); if (!waitHomed(motor_, params_.home_timeout_ms, cancel_)) { if (cancel_) { LOG_WARN << "pitch setup cancelled"; return; } LOG_WARN << "pitch setup aborted: homing did not reach READY"; return; } t0 = motor_.telemetry(); } const bool pitch_present = t0.pitch_present; // Fetch soft limits from a fresh dump. LOG_INFO << "pitch setup: requesting soft limits (dump)..."; motor_.sendCommand("DUMP"); DumpData dump; for (int i = 0; i < 50 && !cancel_; ++i) { // up to ~5 s dump = parseDump(motor_.lastDump()); if (dump.valid && !dump.axes.empty()) break; msleep(100); } if (!dump.valid) { LOG_WARN << "pitch setup aborted: could not read soft limits"; return; } Geometry result = initial_; std::ostringstream log; log << "gimbal pitch setup\n=================\n"; // Per-axis sweep span [a..b] (inset off the hard endstops) + a settle tolerance. auto axisSpan = [&](char axis, long& a, long& b, long& tol) -> bool { const DumpAxis* da = nullptr; for (const auto& ax : dump.axes) if (ax.axis == axis) da = &ax; if (!da) return false; const long lo = da->lim_neg, hi = da->lim_pos; const long travel = std::labs(hi - lo); const long inset = static_cast(params_.inset_frac * travel); a = lo + inset; b = hi - inset; tol = std::max(500, travel / 200); return true; }; // Sweep one axis across [a..b] in N steps, dwelling to measure the IMU angle, // then least-squares fit counts vs degrees and record the result. Returns // false on cancel; on a degenerate fit it records a failed axis and returns true. auto sweepAxis = [&](char axis, AxisMap& map, long a, long b, long tol) -> bool { LOG_INFO << "measuring PITCH over [" << a << ".." << b << "] counts in " << N << " steps"; log << "\n[" << axis << "] sweep=[" << a << ".." << b << "] tol=" << tol << "\n target_counts, imu_deg\n"; std::vector> pts; // (deg, counts) for (int i = 0; i < N; ++i) { if (cancel_) { LOG_WARN << "pitch setup cancelled"; return false; } long target = a + (b - a) * i / (N - 1); setProgress(axis, i + 1, N, "moving"); motor_.sendCommand(std::string("MOVE ") + axis + " " + std::to_string(target)); if (!waitSettle(motor_, axis, target, tol, params_.settle_timeout_ms, cancel_)) { if (cancel_) return false; LOG_WARN << " position " << (i + 1) << "/" << N << " did not settle near " << target << " (continuing)"; } setProgress(axis, i + 1, N, "dwelling"); const double deg = dwellAndMeasure(imu_, params_.dwell_ms, params_.sample_ms, cancel_); pts.emplace_back(deg, static_cast(target)); LOG_INFO << " " << (i + 1) << "/" << N << " counts=" << target << " imu=" << deg << " deg"; log << " " << target << ", " << deg << "\n"; } setProgress(axis, N, N, "fitting"); LinearFit fit = linearFit(pts); if (!fit.ok) { LOG_WARN << " fit failed for axis " << axis << " (degenerate data)"; log << " FIT FAILED\n"; report.axes.push_back({axis, false, 0, 0, 0, static_cast(pts.size())}); return true; } const double old_cpd = map.counts_per_deg; const double old_dac = map.deg_at_center; // The fit gives counts as a function of degrees; the map is anchored on // the homed centre, so what we store is the angle that centre sits at. map.counts_per_deg = fit.slope; map.deg_at_center = (static_cast(map.center_count) - fit.intercept) / fit.slope; LOG_INFO << " fit: counts_per_deg " << old_cpd << " -> " << fit.slope << ", deg_at_center " << old_dac << " -> " << map.deg_at_center << " (R^2=" << fit.r2 << ")"; log << " fit: counts_per_deg=" << fit.slope << " deg_at_center=" << map.deg_at_center << " R2=" << fit.r2 << " (was cpd=" << old_cpd << " dac=" << old_dac << ")\n"; report.axes.push_back({axis, true, fit.slope, map.deg_at_center, fit.r2, fit.n}); return true; }; // Sweep pitch across its inset travel, dwelling at each step to record the // gravity-referenced IMU pitch, and fit. Yaw is NOT touched: it is tied to // north by `gimbal heading setup` against real landmarks, which needs no IMU // and does not inherit the MTi's heading drift. if (!pitch_present) { LOG_WARN << "pitch setup aborted: this rig reports no pitch axis"; return; } long pa = 0, pb = 0, ptol = 0; if (!axisSpan('P', pa, pb, ptol)) { LOG_WARN << "pitch setup aborted: no pitch soft limits in dump"; return; } if (!sweepAxis('P', result.pitch, pa, pb, ptol)) return; // cancelled // Park level, using the map just fitted - a visible confirmation that it is // right, and a sane place to leave the axis. const long plevel = result.pitch.toCounts(0.0); LOG_INFO << "pitch setup: moving to 0 deg / level (counts=" << plevel << ")"; setProgress('P', N, N, "pitch->level"); motor_.sendCommand(std::string("MOVE P ") + std::to_string(plevel)); waitSettle(motor_, 'P', plevel, ptol, params_.settle_timeout_ms, cancel_); if (cancel_) { LOG_WARN << "pitch setup cancelled"; return; } std::string path = paths::writeLogFile(paths::timestampedLogName("pitch_setup"), log.str()); if (!path.empty()) LOG_INFO << "pitch setup log written: " << path; report.valid = true; report.ts_ms = nowMs(); report.all_ok = !report.axes.empty(); for (const auto& ax : report.axes) report.all_ok = report.all_ok && ax.ok; { std::lock_guard lk(result_mutex_); result_ = result; report_ = report; } LOG_INFO << "=== gimbal pitch setup complete (applied to session) ==="; } } // namespace fgc