Skip to content

improvement!: fix loop timing and own the control law as an Nx kernel - #75

Merged
jimsynz merged 2 commits into
mainfrom
feature/bb-loop
Aug 3, 2026
Merged

improvement!: fix loop timing and own the control law as an Nx kernel#75
jimsynz merged 2 commits into
mainfrom
feature/bb-loop

Conversation

@jimsynz

@jimsynz jimsynz commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

Requires bb ~> 0.26 for BB.Loop.

Addresses #43. Two commits, both breaking.

Warning

Existing loops must be retuned. Gain semantics, derivative behaviour and anti-windup behaviour all change. Details at the bottom.

1. Loop timing and a measured dt

The tick was re-armed with a fixed div(1000, rate) delay after the PID step and publish, so handler duration and scheduler latency both pushed the period out. Measured over 300 ticks at a nominal 10ms that is ~13% slow — a "100Hz" loop running at about 88Hz. div/2 truncation compounded it (rate: 60 gave 16ms, i.e. 62.5Hz), and rate above 1000 produced a delay of 0 and a scheduler-saturating tight loop.

PIDControl.step/3 was then called with the library default t: 1.0, so the integral and derivative terms were computed as though each step were one second apart. The two defects compounded: the loop didn't run at the configured rate, and the maths assumed a rate it wasn't achieving anyway.

Now a BB.Loop — absolute deadlines, missed periods dropped rather than fired as catch-up ticks, and per-tick interval and skip counts on [:bb, :loop, :tick].

2. pid_control dropped for BB.PID.Kernel

The control law is now an Nx.Defn kernel written elementwise over its tensors, so the same code advances one loop or a batch. Gains and state are tensors of matching shape — {} for one loop, {n} for n — with no reshaping or per-loop branching. BB.PID.Controller is the {} case.

Written scalar-shaped a defn would be dispatch cost with no payoff; batch-shaped, the vectorisation and composition bb#147 is after are available as soon as something wants them. A test asserts a 2-loop batch matches the same loops stepped individually to 1e-9.

Owning the algorithm fixes two defects pid_control had:

Derivative on measurement, not error. With e = sp - pv, differentiating the error puts a spike of kd · Δsp/dt through the output on any setpoint step — derivative kick — which pid_control could only blunt with zero_d_on_set_point_change. Differentiating pv and negating responds identically to disturbances while being wholly insensitive to setpoint changes.

Back-calculation anti-windup. The saturation error is folded back into the integrator, holding p + i + d == output exactly while clamped. pid_control clamped the integrator to the output range, which bounds the wrong quantity: with a large kp the proportional term alone fills the range, leaving the integrator free to wind to a limit it must then unwind before the output responds at all.

handle_options/2 now retunes via put_gains/2, replacing only the named gains. Rebuilding the struct previously discarded the integrator, stepping the output by whatever it had accumulated.

Precision

Everything the kernel builds is :f64, and numbers and lists passed to step/4 are converted to :f64 too. That has to happen at the boundary: defn converts a plain float argument at the Nx default of :f32 on entry, and casting inside the defn preserves the rounding rather than undoing it. Hence the deftransform, which also keeps step/4 callable from within another defn. A tensor the caller built at :f32 is upcast but keeps its rounding — documented and tested.

Breaking changes

Before After
ki / kd per step, against a nominal 1s timestep per second of real elapsed time
Derivative on error — kicks on setpoint steps on measurement — no kick
Anti-windup integrator clamped to output range back-calculation
Retune integrator discarded integrator preserved

At the default rate: 100 the integral term accumulates 100× more slowly and the derivative is 100× larger for the same gains. Option names and meanings are unchanged (kp, ki, kd, tau, output_min, output_max, rate).

Happy to add a CHANGELOG migration note before release if you'd like one.

Not in this PR

The measurement-staleness guard and the trigger: mode from #43 are still outstanding.

mix check --no-retry green; 52 tests (28 of them new, covering the control law directly).

jimsynz added 2 commits August 3, 2026 13:41
…d `dt`

Two related timing defects, both of which made the loop's actual period differ
from the configured one without anything reporting it.

The tick was re-armed with a fixed `div(1000, rate)` delay *after* the PID step
and publish had run, so the handler's duration and scheduler latency both
pushed the period out. Measured over 300 ticks at a nominal 10ms that is ~13%
slow - a "100Hz" loop running at about 88Hz. `div/2` also truncates, so any
rate that doesn't divide 1000 was wrong before drift even started (`rate: 60`
gave 16ms, i.e. 62.5Hz), and `rate` above 1000 produced a delay of 0 and a
scheduler-saturating tight loop.

`PIDControl.step/3` was then called with the library's default time base of
`t: 1.0`, so the integral and derivative terms were computed as though each
step were one second apart. The two defects compounded: the loop didn't run at
the configured rate, and the maths assumed a rate that was wrong anyway.

The loop is now a `BB.Loop` - absolute deadlines, missed periods dropped rather
than fired as catch-up ticks, and per-tick interval and skip counts on
`[:bb, :loop, :tick]`. `use_system_t: true` makes the PID derive its time base
from the time actually elapsed between steps.

BREAKING: `ki` and `kd` change meaning. They were previously applied per step
against a nominal 1-second timestep; they are now per second of real elapsed
time. At the default `rate: 100` the integral term accumulates 100x more slowly
and the derivative term is 100x larger for the same gains, so existing loops
must be retuned. This is a correctness fix - the old behaviour made a gain's
effect depend on a timestep the loop was not actually achieving - but it is not
source-compatible with existing tuning.

Addresses the period-drift and missing-`dt` parts of #43. The measurement
staleness guard, the trigger mode, and replacing `pid_control` are not in this
change; nor is the integrator reset in `handle_options/2`.

Requires `bb` ~> 0.26 for `BB.Loop`.
Replaces the `pid_control` dependency with `BB.PID.Kernel`, a `defn` written
elementwise over its tensors so the same code advances one loop or a batch of
them. Gains and state are tensors of matching shape - `{}` for a single loop,
`{n}` for `n` - with no reshaping or per-loop branching. `BB.PID.Controller` is
now just the `{}` case wired into the DSL.

Expressing the law as a `defn` rather than burying it in a GenServer is the
point: it can be JIT-compiled, vectorised over a batch axis, and composed into a
larger Nx computation, none of which is possible for a control law reachable only
by message-passing. Written scalar-shaped it would have been dispatch cost with
no payoff; batch-shaped, the payoff is available as soon as something wants it.

Owning the algorithm also lets us fix two defects `pid_control` had:

The derivative now acts on the measurement rather than the error. With
`e = sp - pv`, differentiating the error puts a spike of `kd * dsp/dt` through
the output whenever the setpoint steps - derivative kick - which `pid_control`
could only mitigate with the blunt `zero_d_on_set_point_change` flag.
Differentiating `pv` and negating responds identically to disturbances while
being insensitive to setpoint changes entirely.

Anti-windup is now by back-calculation: the saturation error is folded back into
the integrator, holding `p + i + d == output` exactly while clamped.
`pid_control` clamped the integrator to the *output* range, which bounds the
wrong quantity - with a large `kp` the proportional term alone fills the range,
leaving the integrator free to wind to a limit it must then unwind before the
output responds at all.

`handle_options/2` now retunes via `put_gains/2`, which replaces only the named
gains. Rebuilding the struct previously discarded the integrator, stepping the
output by whatever it had accumulated.

Everything the kernel builds is `:f64`, and numbers and lists passed to `step/4`
are converted to `:f64` as well, so `step(kernel, [0.3, 0.9], ...)` is exact.
That conversion has to happen at the boundary: `defn` converts a plain float
argument at the `Nx` default of `:f32` on entry, and casting inside the `defn`
preserves the rounding rather than undoing it. Hence the `deftransform`, which
also keeps `step/4` callable from within another `defn`. A tensor the caller
built at `:f32` is upcast but keeps its rounding; that is documented and tested.

BREAKING: the derivative and anti-windup changes alter the response of any
tuned loop, on top of the per-second gain semantics from the previous commit.
Loops must be retuned. `tau`, `kp`, `ki`, `kd`, `output_min` and `output_max`
keep their names and meanings.

Closes the `pid_control` removal and the `defn` implementation from #43.
@jimsynz
jimsynz merged commit 9a7e6af into main Aug 3, 2026
15 checks passed
@jimsynz
jimsynz deleted the feature/bb-loop branch August 3, 2026 02:08
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