improvement!: fix loop timing and own the control law as an Nx kernel - #75
Merged
Conversation
…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.
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.
Requires
bb ~> 0.26forBB.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
dtThe 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/2truncation compounded it (rate: 60gave 16ms, i.e. 62.5Hz), andrateabove 1000 produced a delay of0and a scheduler-saturating tight loop.PIDControl.step/3was then called with the library defaultt: 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_controldropped forBB.PID.KernelThe control law is now an
Nx.Defnkernel 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}forn— with no reshaping or per-loop branching.BB.PID.Controlleris the{}case.Written scalar-shaped a
defnwould be dispatch cost with no payoff; batch-shaped, the vectorisation and compositionbb#147is 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_controlhad:Derivative on measurement, not error. With
e = sp - pv, differentiating the error puts a spike ofkd · Δsp/dtthrough the output on any setpoint step — derivative kick — whichpid_controlcould only blunt withzero_d_on_set_point_change. Differentiatingpvand 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 == outputexactly while clamped.pid_controlclamped the integrator to the output range, which bounds the wrong quantity: with a largekpthe 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/2now retunes viaput_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 tostep/4are converted to:f64too. That has to happen at the boundary:defnconverts a plain float argument at theNxdefault of:f32on entry, and casting inside thedefnpreserves the rounding rather than undoing it. Hence thedeftransform, which also keepsstep/4callable from within anotherdefn. A tensor the caller built at:f32is upcast but keeps its rounding — documented and tested.Breaking changes
ki/kdAt the default
rate: 100the 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-retrygreen; 52 tests (28 of them new, covering the control law directly).