BB.NSK.Wheel (bb_nsk v0.1.2)

Copy Markdown View Source

Drives one wheel through a DRV8837 H-bridge.

The bridge takes two inputs. Holding one low and pulsing the other turns the motor in the corresponding direction, so direction is which of the two channels carries the duty cycle, and speed is how much. Both low is coast.

There is no encoder, so this is open loop: a velocity is turned into a duty cycle in proportion to max_velocity, and a commanded velocity is a request rather than a promise — nothing here knows how fast the wheel is actually going.

max_velocity defaults to the 300 rpm N20's rating at the 6.18 V the boost converter measures under load: 31.4 rad/s, or 0.67 m/s at a 21.5 mm wheel. That is a no-load figure, and the real one under the robot's weight is lower.

It is deliberately not a tunable parameter, because it is a fact about the motor rather than a knob: changing it silently rescales every gain in the balance controller, so anyone reaching for it to change the robot's behaviour wants a gain instead. Fitting a different motor is a rebuild.

The gains survive a motor swap, which is the point of the control law speaking rad/s rather than duty: a command of 7 rad/s is 13% duty on a 500 rpm motor and 22% on a 300, and both turn the wheel at 7 rad/s.

Why a 300 rpm motor

Chosen over the 500 it replaced, and over the 200, for the deadband rather than for speed or torque. Catching a thirty degree lean wants about 16 rad/s, so the 300 has twice what it needs and the 200 would saturate during exactly the recoveries that matter. What the 500 cost was the width of the deadband, which near balance the controller spends its time crossing in both directions: 5% of duty on a 500, which is 2.6 rad/s of wheel speed, against 1% on a 300, which is 0.3. Nine times narrower, where the argument for swapping had only predicted a third — more gearing multiplies motor torque against roughly the same output friction, so a slower motor breaks away at a lower duty rather than the higher one stiction would suggest.

Torque never came into it: recovering from thirty degrees needs about 10 mN·m a wheel and every variant in the drawer has several times that.

Command.Position and Command.Effort are deliberately not accepted. Without an encoder this driver could only pretend to honour them, and BB refuses a payload the driver doesn't declare before it ever arrives.

Stop coasts, hold brakes

Driving both of the bridge's inputs high is its brake mode: both outputs go low, shorting the motor through the two low side FETs so its own back-EMF opposes rotation. Stop coasts and Hold brakes, which is BB's distinction between becoming passive and resisting an external force.

The brake is not a position hold, and this driver's Hold is weaker than the one BB describes. Braking torque comes from back-EMF, so it falls away with speed and is zero at a standstill: it resists motion, not displacement. Push a braked wheel and it yields, it just fights being turned quickly. Holding an actual position needs an encoder to know the position has been lost, which is the same reason Command.Position is refused. Brake is the closest the hardware gets, and on a robot on wheels the difference between rolling away and not is worth having.

The bridge's sleep pin is held low until something asks the wheel to move, and disarm/1 drives it low again. That is the state with the motor outputs in high impedance, so a disarmed robot cannot drive the motor no matter what the PWM channels are left doing.

The watchdog

A duty cycle written to the kernel stays written. Nothing about a PWM channel expires, so a wheel told to run at full speed runs at full speed until something tells it otherwise — and the thing doing the telling is a controller that can crash, block, or decide it has fallen over and stop talking. That is how a robot on its side ends up with its wheels at full speed.

So a wheel that hears nothing for command_timeout coasts. It is not a substitute for a controller that stops properly, and it is deliberately not a brake: the case it exists for is the one where nobody knows what is going on, and going passive is the honest response to that.

Summary

Functions

Move a duty ratio above the duty the motor ignores.

The duty cycle a velocity asks for, as a fraction of full scale.

Functions

compensate(ratio, deadband)

@spec compensate(float(), float()) :: float()

Move a duty ratio above the duty the motor ignores.

Measured on the bench, unloaded: in fast decay the 500 rpm motors needed 40% duty to keep turning and 60% to start from rest; slow decay took that to 5%, and fitting 300 rpm motors took it to 1%. A balance controller lives near zero output, so whatever is left of the deadband sits squarely across the part of the range it actually uses, and without this the loop is dead until the robot is already falling.

Squashing the range instead of shifting it keeps full duty meaning full duty. The response is discontinuous at zero — the smallest non-zero command jumps straight to the deadband — which is the honest trade: a step you can see beats a dead zone you can't.

drive_ratio(velocity, max_velocity)

@spec drive_ratio(float(), float()) :: float()

The duty cycle a velocity asks for, as a fraction of full scale.

Signed: negative turns the wheel backwards. Anything beyond what the wheel can do is clamped rather than wrapped, so asking for twice the top speed gives full duty rather than something surprising.

Rounded to 500 steps, so that a command which has barely moved doesn't cost a syscall to tell the kernel so.