drv/wheel_control: add a per-wheel speed loop, brake to zero, and correct the v3 distance per count - #32
Merged
Conversation
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
AI-assisted: Claude Opus 5
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
AI-assisted: Claude Opus 5
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
AI-assisted: Claude Opus 5.5
AI-assisted: Claude Opus 5.5
This was referenced Sep 23, 2026
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
…the gaps AI-assisted: Claude Opus 5.5
AI-assisted: Claude Opus 5.5
AI-assisted: Claude Opus 5.5
This was referenced Sep 24, 2026
Merged
This was referenced Sep 24, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 withdttaken 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 viaDB_MM_PER_COUNT. On top of the PI:u_run + k_run x |v|once the wheel turns, and a kick of at leastu_breakawaythat ramps while the wheel stays stalled;i_zone, so the sparse counts of a start do not wind it up;i_zoneover its setpoint, a command that lands inside the dead zone is pushed past it so the wheel brakes instead of coasting;brakeflag while the wheel still turns, so a stop shorts that motor until it stands, then coasts;stall_pwmduty with no encoder counts forstall_msis flaggedstalledand 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 = 0disables 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.
tests/test_wheel_control.cdrives the realwheel_control.cagainst 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-tickdt(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 testruns it (150 checks) and a newhost-testsCI 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_speedbecomesdb_motors_set_pwm, since the argument is duty;db_motors_coast()joins the existingdb_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 anddb_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 = 15withprotocol_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 ofDB_MM_PER_COUNT, not only the new loop: the distancesdrv/movedrives (apps-sandbox/move),control_loop_get_geometry(), and the EKF predict indrv/control_loop(control_loop.c:197, compiled only withDOTBOT_CONTROL_LOOP_USE_EKF, which no firmware target sets today; PyDotBot's host simulator can). The shippeddotbotapps' 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.Merge order
dotbot-libssubmodule 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 resultingmaincommit before the firmware PR merges.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.drv/motors.hdrv/motors/motors.cdrv/wheel_control.hdrv/wheel_control/wheel_control.ctests/test_wheel_control.c.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.cFollow-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-libssubmodule has to be re-bumped to the merged libsmaincommit before it merges.