Skip to content

drv/wheel_control: add a per-wheel speed loop, brake to zero, and correct the v3 distance per count - #32

Merged
geonnave merged 19 commits into
DotBots:mainfrom
geonnave:wheel-control
Sep 25, 2026
Merged

geonnave merged 19 commits into
DotBots:mainfrom
geonnave:wheel-control

Conversation

@geonnave

@geonnave geonnave commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Adds a per-wheel speed loop, drv/wheel_control, so a DotBot can be told "left wheel at 150 mm/s, right at 100 mm/s" and hold it, instead of being given a raw motor duty and hoping. This is the inner loop of a cascade: a later outer loop (waypoints, heading) will output wheel-speed setpoints to it rather than PWM.

Problem

Every driving path today writes H-bridge duty directly (db_motors_set_speed, which despite its name is duty). The speed a duty produces moves with carpet, battery and the individual motor, and there is a dead zone at the bottom: on the office carpet a turning wheel is not driven at all below about 32 duty, and breakaway from rest is anywhere from 37 to 45 depending on where the wheel came to rest. Measured on an untethered v3, an equal-duty open-loop drive stopped 76 to 230 mm off the intended endpoint of a 0.45 to 0.6 m straight, and did not start at all in 11 of 45 runs.

Approach

  • drv/wheel_control: one PI per wheel in mm/s, stepped every 10 ms with dt taken from elapsed scheduler ticks (so a dropped tick divides by 20 ms instead of pretending two steps happened). The only unit change is inside the step, counts to mm/s via DB_MM_PER_COUNT. On top of the PI:
    • a feedforward that follows the sign of the setpoint and is zero at zero: a running line u_run + k_run x |v| once the wheel turns, and a kick of at least u_breakaway that ramps while the wheel stays stalled;
    • the P term acts on the whole setpoint while stalled, but the integral only accumulates inside an error zone i_zone, so the sparse counts of a start do not wind it up;
    • the error uses the mean of the last two speeds, which removed a limit cycle at the step rate seen on the bench;
    • on a turning wheel more than i_zone over its setpoint, a command that lands inside the dead zone is pushed past it so the wheel brakes instead of coasting;
    • a zero setpoint returns zero duty and raises a per-wheel brake flag while the wheel still turns, so a stop shorts that motor until it stands, then coasts;
    • stall protection: a wheel held at or above stall_pwm duty with no encoder counts for stall_ms is flagged stalled and outputs zero duty without braking, so its motor coasts instead of sitting at full duty against a blocked wheel. The flag clears only on a changed setpoint or a reset (a stop, a mode change or the deadman), not on the same setpoint sent again: a host streaming one command at 20 Hz would otherwise re-drive a held wheel at full duty for 0.5 s of every 0.55 s. The firmware ships 80 duty and 500 ms; stall_ms = 0 disables it. It cannot fire during a start from rest (the kick holds full duty for 20 to 55 ms before the first counts) or at slow speed (20 mm/s gives about 2 counts per step).
      It makes no hardware calls, so it builds on the host.
  • Host tests: tests/test_wheel_control.c drives the real wheel_control.c against a first-order motor model with stiction, a dead zone and quantised counts: no creep at zero, a step from rest settles without sustained oscillation, no windup after saturation, the two-tick dt (alone and against the plant), the feedforward sign, the stall push, dead-zone braking in both directions, stop-then-release, the brake flag staying off at boot and on a standing wheel, reset() mid-motion, and stall protection (a held wheel coasts and is flagged; a new setpoint recovers; no false trigger on hard starts, saturation or sparse counts). The whole suite runs twice, on the model-tuned gains and on the gains the firmware ships (full duty, no slew limit). make test runs it (150 checks) and a new host-tests CI job runs it and gates the release job. These are the first behavioural host-side C tests in the repo. The model proves the logic, not the tuning.
  • drv/motors: db_motors_set_speed becomes db_motors_set_pwm, since the argument is duty; db_motors_coast() joins the existing db_motors_brake(); db_motors_set_pwm_brake() brakes each motor on its own. The two in-repo callers are renamed. This is a breaking rename, and the firmware PR carries the call-site changes.
  • bsp/qdec: the debounce filter is turned off and db_qdec_read_and_clear_dbl() also returns the double-transition count. With the filter on, a count was only guaranteed below about 3900 counts/s (roughly 370 mm/s on a v3) and was suppressed entirely near full speed; the encoders give clean edges and the filter added a sample of delay. db_wheel_control_counts() credits each double transition as two steps in the accumulator's direction.
  • drv/protocol.h: DB_PROTOCOL_CMD_WHEEL_VELOCITY = 15 with protocol_wheel_velocity_command_t { int16_t left_mm_s; int16_t right_mm_s; }.
  • drv/geometry.h: the v3 wheel is 43 mm (caliper, unloaded) and the gearbox 51:1, not the 50:1 it is sold as. Distance per count moves from 0.0987 to 0.0946 mm, so the old constant read every distance about 4.2 per cent long. v1 and v2 keep 50:1. This reaches every v3 consumer of DB_MM_PER_COUNT, not only the new loop: the distances drv/move drives (apps-sandbox/move), control_loop_get_geometry(), and the EKF predict in drv/control_loop (control_loop.c:197, compiled only with DOTBOT_CONTROL_LOOP_USE_EKF, which no firmware target sets today; PyDotBot's host simulator can). The shipped dotbot apps' PD steering does not use it. A units rule is stated at the top of the header (mm, mm/s, degrees).
  • drv/move: marked deprecated in prose and its distance docstring corrected from centimetres to the millimetres the code uses.

Validation

  • make test: 150 passed. All targets build.
  • Untethered bench, two v3 robots on the office carpet, 2026-09-23, driven over the air through the firmware PR's app with per-step radio telemetry:
    • Holding speed, 10 s circle holds (outer / inner wheel): 99.9 / 50.0, 200.1 / 100.0 and 300.1 / 150.0 mm/s, within 0.1 per cent, 100 ms ripple 1.2 to 2.1 mm/s.
    • Step response, time to 90 per cent from rest: 20, 40 and 55 ms at 100, 200 and 300 mm/s; 300 down to 100 in 30 ms; median overshoot 11 per cent. Well inside a 100 ms outer-loop period.
    • Against open loop on the same robot and trajectories: straights stop 14 to 18 mm off against 76 to 230 mm, and the loop never failed to start (open loop failed in 11 of 45 runs). A 180 degree spin stays within about 3 mm of its spot.
    • Distance per count: 1430 +/- 1.5 counts per wheel turn by hand (8 trials, both wheels) against 28 x 51 = 1428, and 0.0948 +/- 0.001 mm per count from 24 lighthouse-measured straight legs, within 0.3 per cent of the header's value.
    • Braking to zero, untethered on both robots with the final gains: a stop runs on about 0.05 s times the speed, 3.4 mm from 100 mm/s, 6.0 from 150, 15 from 300 and 32 from 600, standing within 100 to 210 ms (open loop coasting ran on 12 to 41 mm).
    • Speed range: each wheel tracks within 1 per cent from 20 to 600 mm/s. 600 holds with under 6 per cent of steps at the duty ceiling; 700 holds but spends 30 to 40 per cent of steps there on a charged battery; 750 and 800 saturate. Below 20: 15 mm/s holds within 5 per cent, 10 runs 12 per cent slow, 5 runs 40 to 47 per cent slow.
    • Battery: no measurable change in tracking as the cell fell from 2.95 to 1.0 V; the duty at 100 mm/s stayed at 38 to 40 throughout, so the loop absorbs the discharge.
    • Straights, shortened by the space (0.5 to 0.8 m, two robots): 6 runs each at 150 mm/s end 4 mm right of the line on average (sd 7 and 10 mm), heading change under 2 degrees; 4 runs at 400 end 11 mm left (sd 2).
    • No reset or brownout on either robot. Not run: 2 m straight on three robots, speeds above 700 mm/s.
  • Turning comes up short with each wheel on its setpoint, which is carpet, not this loop. 229 spins and arcs against the lighthouse give an effective track of 80.1 mm for a spin at 50 mm/s per wheel, 81.0 at 100, 83.0 at 200, 86.7 at 300 and 94 to 107 from 400 to 600, and 84.9 (sd 1.5) for arcs of 100 mm radius or more, against the 78 mm geometric one. Direction and robot change it by under 1 mm. That calibration belongs to a later twist-to-wheel layer, not to this loop.

Merge order

  1. This PR first.
  2. DotBot-firmware (apps-sandbox/dotbot-next: drive the wheels through the speed loop and pace adverts on the min TX interval DotBot-firmware#425) pins its dotbot-libs submodule to this branch's tip, a commit that exists only on the fork until this merges. A rebase or squash merge gives new commit ids, so that submodule has to be re-bumped to the resulting main commit before the firmware PR merges.
  3. PyDotBot (dotbot: add the wheel velocity command and set the v3 wheel to 43 mm on a 51:1 gearbox PyDotBot#298) mirrors the new geometry and command; its CI builds a geometry check against DotBot-libs main, so it stays red until this merges. Merge this PR and #298 back to back: between the two, the host and the firmware disagree on distance per count by 4.2 per cent.
File + -
drv/motors.h +32 -11
drv/motors/motors.c +36 -48
drv/wheel_control.h +143 -0
drv/wheel_control/wheel_control.c +159 -0
tests/test_wheel_control.c +615 -0
14 small files: .github/workflows/build.yml, .gitignore, Makefile, bsp/nrf/qdec_default.c, bsp/nrf/qdec_stub.c, bsp/qdec.h, doc/sphinx/drv.md, drv/drv.emProject, drv/geometry.h, drv/move.h, drv/move/move.c, drv/protocol.h, projects/01drv_motors/main.c, projects/01drv_pid/01drv_pid.c +107 -31
Total, 19 files +1092 -90

Follow-up: #33 (draft) adds the pose estimator on top of this branch and merges after it.

Merge chain

One PR at a time, in this order: this PR, DotBots/PyDotBot#298, DotBots/DotBot-firmware#425, #33, DotBots/DotBot-firmware#426, #34, DotBots/DotBot-firmware#427, #35, DotBots/DotBot-firmware#428, DotBots/PyDotBot#301, DotBots/PyDotBot#302. Next after this one: DotBots/PyDotBot#298. Rebase and squash merges both give the DotBot-libs commits new ids, so each firmware PR's dotbot-libs submodule has to be re-bumped to the merged libs main commit before it merges.

Breaking: db_motors_set_speed() is now db_motors_set_pwm(), since the
value is the H-bridge duty and never was a speed. Every caller in
DotBot-firmware has to follow in the same submodule bump.

AI-assisted: Claude Opus 5
With the filter on, a quadrature state must hold for two 128 us samples
to count, so counts are only guaranteed below about 3900/s (386 mm/s on
a v3), and a step inside one sample lands in ACCDBL, which nothing read.

AI-assisted: Claude Opus 5
On the floor a wheel from rest turns in sparse single counts for about
100 ms, and the integral charged over that start was the overshoot
after it: +58% at 100 mm/s with no zone.

AI-assisted: Claude Opus 5
The duty that frees a wheel on the floor changes with where it came to
rest (44 started both wheels in one sweep, 45 started neither in the
next run), so a fixed kick can leave a wheel stalled for good.

AI-assisted: Claude Opus 5
On an untethered DotBot v3 at 200-300 mm/s the loop limit-cycled at the
step rate (duty 29/69, counts 30/18) at kp 0.5 and still at kp 0.25; with
the two-step mean the same holds read within 0.2% at 2 mm/s ripple. The
host model plant does not reproduce that cycle, so the check feeds it
directly, and the fixture now uses the gains the firmware ships.

AI-assisted: Claude Opus 5.5
With the kick alone a step from rest held 44 duty until the first counts,
and on an untethered v3 reaching 90% of 100 mm/s took 120 ms. Taking the
P term on the whole setpoint starts the wheel at the push it needs (80 ms
at kp 0.5); the integral still stays out of the stall.

AI-assisted: Claude Opus 5.5
Below about 32 duty the motor does not drive, so a wheel stepped down
from 300 to 100 mm/s was left at -9 duty and only coasted, taking 170 ms
to reach 90% of the step. Carrying such a command past u_run brakes it.
Only outside the integral zone: the inner wheel of a turn, carried by the
body, otherwise flipped between -32 and +32 duty on every step.

AI-assisted: Claude Opus 5.5
AI-assisted: Claude Opus 5.5
A zero setpoint left the wheel to coast, and an untethered v3 ran on
12 mm after a stop from 150 mm/s and 41 mm from 300. The brake is
released once the wheel has stood for a stall's worth of steps, so a
standing robot is left coasting as before.

AI-assisted: Claude Opus 5.5
A blocked wheel otherwise ramps to full duty and stays there. Only a
changed setpoint or a reset clears the stall: a host streaming the same
command would otherwise re-drive a held wheel at full duty for 0.5 s of
every command period.

AI-assisted: Claude Opus 5.5
@geonnave
geonnave merged commit caa788b into DotBots:main Sep 25, 2026
17 checks passed
@geonnave
geonnave deleted the wheel-control branch September 25, 2026 06:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant