ADR 0042 Drive Calibration - lukislp/mowdeck GitHub Wiki
Accepted (2026-10-15); amends ADR 0039 (stick scale, steering gains, time limit) and ADR 0041 (time share of the transit).
The first real spot run (a LUBA 2 AWD on firmware 1.30.31.19, Bluetooth through the bridge, a dry run) showed that the stick-to-ground scale of ADR 0039 was a guess. Positions arrived at 1 Hz as designed, but the mower turned in place at about 1 °/s while the run commanded angular up to 0.4 (turn_max) and crept forward at about 0.05 m/s with linear capped at 0.5. The run assumed 0.5 m/s at full stick and a yaw rate of 1 rad/s, so everything took about six times longer than planned, and the run hit its time limit (min(2 × estimate + 60 s, 20 min), here 147 s) while mowing, with none of the six points done.
Three things decide what a stick value means on the ground: the vendor's own mapping of the stick (dead zone, wire range), the mower's speed setting for manual driving (the vendor app's "movement speed", by which the mower scales the stick) and the model. None of them is visible to MowDeck without measuring, and guessing constants per manufacturer would break the rule that spot runs know no manufacturer.
MowDeck measures the scale with a drive calibration over the generic drive channel, stores the result with the mower and applies it to every spot run. The time limit of a run becomes progress based.
The calibration (DriveCalibrationRun, libs/mowdeck-control/src/mowdeck_control/calibration.py, command DriveCalibrate{step_s = 6}, 5 to 12 s; the method was revised after the first hardware run, see below).
-
Steps. Forward at
linear0.3, 0.5, 0.7 and 1.0 (low to high), then turns in place atangular+0.3, −0.3, +0.6, −0.6, +1.0 and −1.0, each up tostep_slong at 5 frames per second, followed by 2 s of zero frames (pause_s) so the mower stops before the next step. The blades are off in every frame. -
Measurement. Only the steady window of a step counts: its reports from 1.5 s (
settle_s) after the first frame to the last frame, because the mower reacts late. The ground speed is the distance between the first and the last report of the window divided by the time between them; the yaw rate (left positive) is the unwrapped heading change over the same reports divided by that time. A window shorter than 3 s (min_window_s) or with fewer than 4 reports (min_reports) makes the stepinvalid: it has no value and never counts as standing still. The step records its outcome, the reason (note), the reports in the window, their mean interval, the window's length, the distance and the heading change. -
Plausibility. A turn and its opposite turn of the same magnitude must have opposite signs and agree in magnitude within 50 % (
turn_pair_tolerance), else both areinconsistent; a turn whose opposite has no value is kept. Speed and yaw rate must not fall as the stick grows: of the values sorted by stick, the longest chain where no value falls more than 15 % (monotonic_tolerance, plus the standing threshold) below an earlier one is kept (of two equally long chains the faster one), the others areoutlier. Inconsistent steps and outliers keep their value on screen but are left out of the fit. -
Fit. Least squares of
speed(c) = full_speed × (c − dead_zone) / (1 − dead_zone)over the moving forward steps, the same for the yaw rate over the turn magnitudes (both directions averaged), givingfull_speed_mps,full_yaw_rate_dps,dead_zoneandturn_dead_zone. Each axis needs at least two moving points and a rising line, else the calibration fails withno_fitand a detail that names the axis, the usable steps and what became of the others (for exampleonly 1 of 4 forward steps usable, 2 needed (3 skipped); too fast for the 1.5 m forward budget, lower the speed for manual driving and calibrate again). Turns against the command fail it withturn_direction(left must be positive). -
Report rate. While the calibration runs, the controller asks a connection that implements the optional
ReportRateCapable(set_drive_report_interval) for reports every 0.5 s (report_interval_s) and restores the adapter's own drive interval at the end. -
Safety. The controller accepts it only for an idle mower out of its dock with a state at most 60 s old, a fixed RTK position, more than 20 % battery, manual driving, no drive session, spot run or pending confirmation, and 2 m of lawn straight ahead inside the geofence (the mapped areas inset by 0.3 m minus the no-go zones and obstacles). Before the first step it sends zero frames until a fresh fixed pose arrives (at most 30 s,
rtk_lost) and checks the room again from it. The forward steps stop once 1.5 m were covered: a step is shortened when the speeds measured so far (proportional to the last step, or the line through the last two when that is faster, times 1.25) predict it would pass the budget, skipped with all later forward steps when it would become shorter than the settling time plus a window with 4 reports at the report interval seen so far, ends early once its window is valid and it has used an equal share of the remaining budget, and is cut when the last report plus the expected run-on (the current speed for 1.5 s) reaches the budget. Every report is checked against the geofence and the fix.spot_stop, an emergency stop, a sensor stop, an activity other than idle and a refused frame end it; every end sends a zero frame and closes the drive session. -
Controller (rule 13). Like a spot run:
acceptedat once, frames as internalManualDriveenvelopes (correlation_id = calibration:<command_id>) throughhandle(), so the dead-man switch and the sensor-stop handling apply; rule 12 applies while it runs (onlyemergency_stop,spot_stopandset_lightpass). The status, the measured steps and the result are merged intoMowerState.drive_calibration(DriveCalibrationStatus). -
Device-Gateway. Publishes the
acceptedresult and, at the end,executedwith the fit orfailedwith the reason, like a spot run.
Storage. The fit is a DriveCalibration (full_speed_mps, full_yaw_rate_dps, dead_zone, turn_dead_zone, drive_speed_setting, calibrated_at) stored as options of the mower's drive link, the Bluetooth bridge (drive_full_speed_mps, drive_full_yaw_rate_dps, drive_dead_zone, drive_turn_dead_zone, drive_speed_setting, calibrated_at); a mower without a link keeps them in its connection options where the kind accepts them (the simulator). Fleet declares the keys as managed options of the ble_bridge schema, keeps them when a client replaces the link and stores them with PUT /mowers/{id}/drive-calibration (DELETE removes them); the web app's "Save result" and the owner script call it. The Device-Gateway takes the keys off the options before it creates the connection, so no adapter sees them, and a registration update that changes only them does not reconnect the mower: the controller reads the calibration from the current registration on every spot run.
Spot runs use it (SpotRunConfig.calibrated). With a calibration the run assumes the calibrated full-scale speed (for the governor's start and dead reckoning) and yaw rate (dead reckoning), may command linear up to 1.0 (the governor still keeps the measured ground speed at the segment speed, at most 0.3 m/s), chooses turn_max so a turn in place reaches about 20 °/s (at most 1.0), scales the steering gains by the ratio of the default to the calibrated yaw rate, and adds the dead zones to every stick value (dead_zone + (1 − dead_zone) × fraction, so small corrections still move the mower; min_linear is the dead zone plus 0.05). Without a calibration the cautious defaults of ADR 0039 apply.
The speed setting. A connection that can read and change the vendor's speed setting for manual driving declares MowerCapabilities.drive_speed_setting (min_mps, max_mps, step_mps), reports the value in MowerState.drive_speed_mps and implements the optional protocol DriveSpeedCapable (get_drive_speed, set_drive_speed). The command SetDriveSpeed{speed_mps} changes it (snapped to the range and step; like set_light it keeps an open drive session; rule 12 rejects it during a run). The controller reads the value once after connecting. A calibration records the setting it was measured at; when the setting differs at the start of a run, the run scales the calibrated full-scale speed proportionally (the vendor scales the stick by it) and keeps the yaw rate, and the web app recommends a new calibration. MowDeck never changes the setting by itself, also not during a run.
Time limit. A run ends stalled when it makes no progress for stall_s (60 s): progress is the distance along the transit and the plan growing by at least 0.2 m, the distance to the next waypoint shrinking by at least 0.2 m, reaching a waypoint, or, while turning in place, the heading error shrinking by at least 10°. A pause for the RTK fix is not a stall (it has its own limit). The overall cap is max(20 min, 3 × the estimated driving time), the estimate at the speed the mower can reach (the segment speed, at most the full-scale speed at max_linear), counted from the start of the transit; it ends the run timeout. Both are configurable (stall_s, stall_progress_m, stall_heading_deg, max_run_floor_s, max_run_factor). stalled is a new abort reason in the contracts and in the web app's run bar (German and English); a stalled run leaves the mower healthy, so it is sent home like after timeout.
The adapter's manual-drive speed (Mammotion). Reviewing the adapter after the first run showed that its drive session never sent the vendor's speed limit for manual driving. The vendor app's joystick sends operate_on_device (DrvMowCtrlByHand) with max_run_speed; the movement frames carry none. The Bluetooth drive session sent nothing when it opened and only movement frames per frame, and operate_on_device only when the blades changed (never with the blades off, so never in a spot run) and in its stop, with 1.2 m/s copied from PyMammotion's blade helper; the emergency stop sends it with 0.0. PyMammotion's own manual-drive helpers send only movement frames as well. The run therefore drove at whatever limit the mower held from an earlier command, which fits the crawl. A drive session now opens with one operate_on_device, blades off, carrying max_run_speed from the bridge link option manual_drive_speed_mps (0.1 to 1.0), else the mower's own speed setting for manual driving, else 1.0, never above 1.0 m/s, and repeats it on blade changes and in its stop. Fleet declares the option in the ble_bridge schema and BridgeLinkIn; the web app's bridge sheet keeps it when it replaces the link. The wire constants for the stick stay as they are, and MowDeck does not assume whether max_run_speed scales or only caps the stick: the calibration measures the effect, and it has to be repeated after this change.
Owner script. try_real_mower.py --calibrate [--step-s S] runs the calibration through the bridge and prints the steps (command, measured speed or yaw rate, reports, report interval) and the fit; --calibration-out FILE writes it to a file; --save --fleet-url URL --mower-id ID stores it through the Fleet API with the token from MOWDECK_TOKEN, otherwise the JSON is printed to paste. --get-speed and --set-speed X read and change the speed setting, --use-calibration FILE applies a calibration to --spot-dry-run (or is the source for --save without driving).
The first calibration on the owner's LUBA 2 AWD (Bluetooth through the ESPHome bridge, speed setting 0.30 m/s, step_s 2.5) gave unusable numbers:
| Step | Measured | Reports |
|---|---|---|
| forward 0.3 | 0.008 m/s | 3 |
| forward 0.5 | 0.000 m/s | 2 |
| forward 0.7 | 0.000 m/s | 2 |
| forward 1.0 | 0.224 m/s | 3 |
| turn +0.3 | 2.0 °/s | |
| turn −0.3 | −5.2 °/s | |
| turn +0.6 | 2.0 °/s | |
| turn −0.6 | −3.0 °/s | |
| turn +1.0 | 0.0 °/s | 2 |
| turn −1.0 | 0.0 °/s | 2 |
The fit gave a full speed of 0.224 m/s, a full yaw rate of 4.2 °/s and a dead zone of 0.27. Ten minutes earlier a spot run on the same mower turned at about 10 °/s with angular around 0.4 and drove at about 0.10 m/s with a modest linear, so the calibration was wrong. Three causes: position and heading reports arrived only every 0.8 to 1 s, so a 2.5 s step held two or three of them; the mower follows the stick about 1 to 2 s late, so each step's motion spilled into the next one (the 1.5 s pause was too short) and the start of a step measured a standing mower; and a step with two reports (often both taken before the mower reacted) was scored as 0 instead of as unmeasured, which made the fit see a large dead zone.
The method changed accordingly (the decision above describes the result): 6 s steps measured over their steady window without the first 1.5 s, 2 s of standstill between steps, at least 4 reports in at least 3 s or the step is invalid (shown as skipped with "too few reports", never 0), the turn pair and growth checks, at least two points per axis, and faster reports while the calibration runs. During a spot run and a calibration the drive session's poll was the same, an undebounced request whenever no report arrived for 1 s; a calibration now asks for 0.5 s, and whether the bridge delivers that has to be checked on the next run. With the new rules the numbers above give no fit at all (every step had fewer than 4 reports; even with enough reports, forward 0.3 is an outlier, the turn pair at 0.3 is inconsistent, and only one forward step moves) instead of a wrong one.
Generic, the same for every manufacturer: the contracts (DriveCalibrate, SetDriveSpeed, DriveCalibration and its link option keys, DriveCalibrationStatus, DriveSpeedSetting, MowerState.drive_calibration, MowerState.drive_speed_mps, the reason stalled), the calibration run with its steps, measurement, fit and safety checks, controller rule 13, the calibrated spot-run configuration and the progress-based time limit, the gateway's stripping and result publishing, Fleet's storage and gates, the web app's calibration block and speed slider, and the simulator's implementation (configurable drive_max_speed_mps, drive_max_turn_rad_s and speed setting). The calibration and spot-run tests in mowdeck-control and the gateway import no manufacturer package; against a simulator that reports once a second and follows the stick 1.5 s late (drive_response_delay_s) the calibration recovers the configured speed and yaw rate within 15 %, a step with two reports is invalid, an inconsistent turn pair and an outlier are dropped, and a run on a slow simulated mower (0.1 m/s and 3 °/s at full stick) completes with the calibration instead of timing out.
Adapter work, once per manufacturer: nothing for the calibration itself (it uses the drive session and the state every spot-capable adapter already has). Optionally ReportRateCapable for an adapter that polls its reports while driving; for Mammotion the drive mode's undebounced poll then runs every 0.5 s instead of every second (MIN_DRIVE_POLL_S, the floor so requests do not queue behind each other on the Bluetooth link). Optionally the speed setting: capabilities.drive_speed_setting, MowerState.drive_speed_mps and DriveSpeedCapable. For Mammotion that is PyMammotion's get_speed and set_speed (MctlDriver.bidire_speed_read_set, the reply in the device's travel speed), sent over the command channel (the cloud on a dual link), not as a drive frame. The adapter's stick-to-wire constants stay as they are; the calibration measures the effective result. The drive session's opening operate_on_device with max_run_speed is adapter work as well (see above): other manufacturers have their own way to set the manual-driving speed, or none.
Easier: a spot run on a slow mower plans and steers with the real scale and finishes instead of hitting a time limit made for a fast one; a run that gets nowhere ends after a minute instead of after an estimate. The owner sees what the mower really does per stick value, and a changed speed setting no longer silently invalidates the run's assumptions.
Harder: the owner has to run the calibration once per mower (and again after firmware or setting changes), on a free stretch of lawn with RTK fixed. At about 1 Hz reports and a slow mower a step covers only a few centimetres or degrees, so heading and position noise limit the accuracy of the low stick values; step_s can be raised to 12 s. A fast mower (above roughly 0.3 m/s at full stick) fits only one forward step into 1.5 m with windows of at least 3 s, so its calibration fails with no_fit until the speed setting for manual driving is lowered; the budget stays, because the room ahead is checked for 2 m only. The proportional rescale for a changed speed setting is an assumption about the vendor's mapping; a new calibration is the reliable answer.
To check on hardware: whether the opening max_run_speed alone removes the crawl (a calibration before and after, or --turn-check), the calibration table of the LUBA 2 at the current speed setting (the expected result is a large dead zone and a few degrees per second at full turn), whether the full-scale speed changes proportionally with the speed setting, and a dry spot run with the stored calibration.
Mirrored from docs/adr/0042-drive-calibration.md at commit 6a87264c6123f07c8474e153053525dc1816feb7. Edit the file in the repository, not this wiki page.