ProtoCore v1.0.16
Deterministic, zero-heap network stack for embedded targets
Loading...
Searching...
No Matches
control.h
Go to the documentation of this file.
1// ProtoCore v1.0.16 - Copyright (C) 2026 Douglas Quigg (dstroy0) <dquigg123@gmail.com>
2// SPDX-License-Identifier: AGPL-3.0-or-later
3
4/**
5 * @file control.h
6 * @brief Closed-loop control law (PROTOCORE_ENABLE_CONTROL) - a zero-heap PID controller plus a
7 * handful of inline control-law primitives, for driving an actuator toward a setpoint.
8 *
9 * The PID is the textbook parallel form with the corrections that matter on real hardware:
10 * - derivative-on-measurement (the derivative acts on the measurement, not the error, so a
11 * step change in the setpoint does not produce a "derivative kick"),
12 * - an optional single-pole low-pass on the derivative (measurement noise otherwise dominates
13 * the D term),
14 * - output clamping to the actuator's range, and
15 * - anti-windup by conditional integration (the integrator is frozen while the output is
16 * saturated and integrating would push it deeper into the rail, so it never winds up past
17 * what the actuator can deliver), plus a hard integral clamp as a secondary bound,
18 * - a feed-forward term (kff * setpoint) for the part of the command known in advance.
19 *
20 * pid_update() is defined inline (below): the whole law folds into the caller with no function-
21 * call overhead, and on the ESP32 / ESP32-S3 the single-precision-float math maps to the FPU
22 * (single-cycle add/mul + `madd.s` fused multiply-add, never the soft-float double path). Put your
23 * control loop in IRAM if you need deterministic latency free of flash-cache stalls.
24 *
25 * Pure float arithmetic - no heap, no <math.h>. Pair it with a plant it can command (a CiA 402
26 * drive via `services/cia402`, a dshot ESC, a heater PWM) and a sensor it can read back.
27 *
28 * @author Douglas Quigg (dstroy0)
29 * @date 2026
30 */
31
32#ifndef PROTOCORE_CONTROL_H
33#define PROTOCORE_CONTROL_H
34
35#include "protocore_config.h" // the entry point: protocore_types.h for the widths
36
37#if PROTOCORE_ENABLE_CONTROL
38
40
41// This module holds nothing between calls, so it carves no borrow and states none. An entry
42// takes one all the same, and never reads it, so every namespace in the tree is invoked the
43// same way.
44
45#define CONTROL_UNBOUNDED 1e30f ///< sentinel for "no clamp" (well outside any real actuator range)
46
47// PID-run log for offline tuning with tools/pid_tune.py, in two interchangeable formats:
48//
49// 1. CSV (human / serial friendly) - one row per control step, these columns:
50#define CONTROL_LOG_HEADER "t_s,setpoint,measurement,output,dt_s"
51
52//
53// 2. Dense binary (little-endian) for high-rate loops - self-describing (the header carries the
54// gains, limits, and sample period the run used, so the tuner needs no flags) and 16 B/sample:
55// header (PID_LOG_HEADER_LEN): "DPID" | ver u8=1 | flags u8 | reserved u16 |
56// dt_s f32 | kp f32 | ki f32 | kd f32 | kff f32 | out_min f32 | out_max f32
57// record (PID_LOG_RECORD_LEN): setpoint f32 | measurement f32 | output f32 |
58// status u32 (bit 0 = output was saturated this step, so the tuner can drop it from the
59// plant identification fit).
60#define PID_LOG_MAGIC "DPID"
61#define PID_LOG_VERSION 1
62#define PID_LOG_HEADER_LEN 36
63#define PID_LOG_RECORD_LEN 16
64#define PID_LOG_STATUS_SATURATED 0x1u
65
66/**
67 * @brief A single-loop PID controller. Zero its bytes or call pid_init() before first use; the
68 * runtime-state fields below the gains are owned by pid_update() / pid_reset().
69 */
70typedef struct
71{
72 // gains
73 float kp; ///< proportional gain
74 float ki; ///< integral gain (per second)
75 float kd; ///< derivative gain (seconds)
76 float kff; ///< feed-forward gain applied to the setpoint
77 // limits
78 float out_min; ///< output lower clamp
79 float out_max; ///< output upper clamp
80 float integ_min; ///< integral accumulator lower clamp
81 float integ_max; ///< integral accumulator upper clamp
82 float d_alpha; ///< derivative low-pass smoothing in [0,1); 0 = raw (unfiltered) derivative
83 // fixed-rate cache (set by pid_set_rate; lets pid_update_fixed() run with no divide)
84 float dt; ///< cached sample period, 0 until pid_set_rate()
85 float inv_dt; ///< cached 1/dt
86 // runtime state (owned by pid_update / pid_reset)
87 float integ; ///< integral accumulator
88 float prev_meas; ///< previous measurement (for derivative-on-measurement)
89 float d_filt; ///< filtered derivative
90 proto_bool primed; ///< false until the first update supplies prev_meas (no derivative on step 1)
91} Pid;
92
93/// Clamp @p v to [lo, hi].
94static inline float control_clamp(float v, float lo, float hi)
95{
96 return v < lo ? lo : (v > hi ? hi : v);
97}
98
99/// Deadband: return 0 within +/- @p band, else @p v shifted toward 0 by @p band (continuous).
100static inline float control_deadband(float v, float band)
101{
102 if (v > band)
103 {
104 return v - band;
105 }
106 if (v < -band)
107 {
108 return v + band;
109 }
110 return 0.0f;
111}
112
113/// Slew-rate limit: move @p current toward @p target by at most @p max_step this call.
114static inline float control_slew(float target, float current, float max_step)
115{
116 float d = target - current;
117 if (d > max_step)
118 {
119 return current + max_step;
120 }
121 if (d < -max_step)
122 {
123 return current - max_step;
124 }
125 return target;
126}
127
128/// One step of a single-pole low-pass: blend @p sample into @p prev by @p alpha in [0,1].
129static inline float control_lpf(float prev, float sample, float alpha)
130{
131 return prev + alpha * (sample - prev);
132}
133
134/// Internal shared step, used by pid_update() and pid_update_fixed(): the whole control law with
135/// @p dt and its reciprocal @p inv_dt supplied, so there is no divide inside. Call an entry point
136/// below, not this directly.
137static inline float pid_step_(Pid *p, float setpoint, float measurement, float dt, float inv_dt)
138{
139 float error = setpoint - measurement;
140
141 // Derivative on measurement (no setpoint-change "kick"): d(error)/dt = -d(measurement)/dt when
142 // the setpoint is held. Skip the first update (no prev_meas yet), optionally low-pass filter it.
143 float deriv = 0.0f;
144 if (p->primed)
145 {
146 deriv = -(measurement - p->prev_meas) * inv_dt; // multiply by 1/dt, no divide
147 p->d_filt = (p->d_alpha > 0.0f) ? p->d_filt + p->d_alpha * (deriv - p->d_filt) : deriv;
148 }
149 p->prev_meas = measurement;
150 p->primed = PROTO_TRUE;
151
152 // Tentative integration, hard-clamped to the accumulator bounds (a secondary safety limit).
153 float integ_next = control_clamp(p->integ + p->ki * error * dt, p->integ_min, p->integ_max);
154
155 // FMA chain -> madd.s on the FPU: kp*error + integ + kd*d_filt + kff*setpoint.
156 float unclamped = p->kp * error + integ_next + p->kd * p->d_filt + p->kff * setpoint;
157 float out = control_clamp(unclamped, p->out_min, p->out_max);
158
159 // Anti-windup by conditional integration: commit the new integral unless the output is
160 // saturated AND integrating further this direction would push it deeper into the rail - then
161 // freeze the accumulator instead, so it never winds up past what the actuator can deliver.
162 proto_bool worsen_high = (unclamped > p->out_max) && (error > 0.0f);
163 proto_bool worsen_low = (unclamped < p->out_min) && (error < 0.0f);
164 if (!worsen_high && !worsen_low)
165 {
166 p->integ = integ_next;
167 }
168
169 return out;
170}
171
172/**
173 * @brief Advance the loop one step: returns the (clamped) control output for @p setpoint given the
174 * measured process value @p measurement over the elapsed time @p dt seconds (dt <= 0 -> 0).
175 *
176 * Inline (no call overhead) and FPU-accelerated (single-precision, FMA-folded). Variable-rate: it
177 * computes 1/dt each call. For a fixed-rate loop use pid_update_fixed() to skip that divide.
178 */
179static inline float pid_update(Pid *p, float setpoint, float measurement, float dt)
180{
181 if (!p || dt <= 0.0f)
182 {
183 return 0.0f;
184 }
185 return pid_step_(p, setpoint, measurement, dt, 1.0f / dt);
186}
187
188/**
189 * @brief Zero-divide fixed-rate step: same law as pid_update() but uses the dt / 1-over-dt cached
190 * by pid_set_rate(), so the hot path is all multiplies (the fastest form). Returns 0 until
191 * pid_set_rate() has supplied a positive dt.
192 */
193static inline float pid_update_fixed(Pid *p, float setpoint, float measurement)
194{
195 if (!p || p->dt <= 0.0f)
196 {
197 return 0.0f;
198 }
199 return pid_step_(p, setpoint, measurement, p->dt, p->inv_dt);
200}
201
202/** @brief What pid_init takes: p, kp, ki, kd. */
203typedef struct
204{
205 Pid *p;
206 float kp;
207 float ki;
208 float kd;
209} ControlPidInitArgs;
210
211/** @brief What pid_set_output_limits takes: p, lo, hi. */
212typedef struct
213{
214 Pid *p;
215 float lo;
216 float hi;
217} ControlPidSetOutputLimitsArgs;
218
219/** @brief What pid_set_integral_limits takes: p, lo, hi. */
220typedef struct
221{
222 Pid *p;
223 float lo;
224 float hi;
225} ControlPidSetIntegralLimitsArgs;
226
227/** @brief What pid_set_derivative_filter takes: p, alpha. */
228typedef struct
229{
230 Pid *p;
231 float alpha;
232} ControlPidSetDerivativeFilterArgs;
233
234/** @brief What pid_set_feedforward takes: p, kff. */
235typedef struct
236{
237 Pid *p;
238 float kff;
239} ControlPidSetFeedforwardArgs;
240
241/** @brief What pid_set_rate takes: p, dt. */
242typedef struct
243{
244 Pid *p;
245 float dt;
246} ControlPidSetRateArgs;
247
248/** @brief What pid_reset takes: p. */
249typedef struct
250{
251 Pid *p;
252} ControlPidResetArgs;
253
254/** @brief What pid_update_n takes: p, setpoint, measurement, dt, out, ... */
255typedef struct
256{
257 Pid *p;
258 const float *setpoint;
259 const float *measurement;
260 float dt;
261 float *out;
262 uint8_t n;
263} ControlPidUpdateNArgs;
264
265/** @brief What pid_log_header takes: buf, cap, p, dt. */
266typedef struct
267{
268 uint8_t *buf;
269 size_t cap;
270 const Pid *p;
271 float dt;
272} ControlPidLogHeaderArgs;
273
274/** @brief What pid_log_record takes: buf, cap, setpoint, measurement, ... */
275typedef struct
276{
277 uint8_t *buf;
278 size_t cap;
279 float setpoint;
280 float measurement;
281 float output;
282 proto_bool saturated;
283} ControlPidLogRecordArgs;
284
285/**
286 * @brief Closed-loop control law (PROTOCORE_ENABLE_CONTROL) - a zero-heap PID controller plus a handful of inline
287 * control-law primitives, for driving an actuator toward a setpoint.
288 *
289 * A caller sets the members a call takes, invokes it through ::Control with the bytes it runs
290 * out of, and reads the outcome off the same handle.
291 *
292 * Control.pid_init_args.p = ...;
293 * Control.pid_init_args.kp = ...;
294 * Control.pid_init_args.ki = ...;
295 * Control.pid_init_args.kd = ...;
296 * Control.pid_init(work);
297 *
298 * @var ControlNs::pid_init_args what pid_init takes: p, kp, ki, kd
299 * @var ControlNs::pid_set_output_limits_args what pid_set_output_limits takes: p, lo, hi
300 * @var ControlNs::pid_set_integral_limits_args what pid_set_integral_limits takes: p, lo, hi
301 * @var ControlNs::pid_set_derivative_filter_args what pid_set_derivative_filter takes: p, alpha
302 * @var ControlNs::pid_set_feedforward_args what pid_set_feedforward takes: p, kff
303 * @var ControlNs::pid_set_rate_args what pid_set_rate takes: p, dt
304 * @var ControlNs::pid_reset_args what pid_reset takes: p
305 * @var ControlNs::pid_update_n_args what pid_update_n takes: p, setpoint, measurement, dt, out,
306 * @var ControlNs::pid_log_header_args what pid_log_header takes: buf, cap, p, dt
307 * @var ControlNs::pid_log_record_args what pid_log_record takes: buf, cap, setpoint, measurement,
308 * @var ControlNs::ok a call's true/false outcome
309 * @var ControlNs::n the count a call reports
310 * @var ControlNs::pid_init pid_init
311 * @var ControlNs::pid_set_output_limits pid_set_output_limits
312 * @var ControlNs::pid_set_integral_limits pid_set_integral_limits
313 * @var ControlNs::pid_set_derivative_filter pid_set_derivative_filter
314 * @var ControlNs::pid_set_feedforward pid_set_feedforward
315 * @var ControlNs::pid_set_rate pid_set_rate
316 * @var ControlNs::pid_reset pid_reset
317 * @var ControlNs::pid_update_n pid_update_n
318 * @var ControlNs::pid_log_header pid_log_header
319 * @var ControlNs::pid_log_record pid_log_record
320 *
321 * @c work is bytes the CALLER holds. This module reads none of them: it carries nothing
322 * between calls, so there is no state to keep and nothing to wipe. The parameter is there so
323 * a caller drives every namespace the same way.
324 */
325typedef struct
326{
327 ControlPidInitArgs pid_init_args;
328 ControlPidSetOutputLimitsArgs pid_set_output_limits_args;
329 ControlPidSetIntegralLimitsArgs pid_set_integral_limits_args;
330 ControlPidSetDerivativeFilterArgs pid_set_derivative_filter_args;
331 ControlPidSetFeedforwardArgs pid_set_feedforward_args;
332 ControlPidSetRateArgs pid_set_rate_args;
333 ControlPidResetArgs pid_reset_args;
334 ControlPidUpdateNArgs pid_update_n_args;
335 ControlPidLogHeaderArgs pid_log_header_args;
336 ControlPidLogRecordArgs pid_log_record_args;
337 proto_bool ok;
338 size_t n;
339} ControlVars;
340
341/** @brief The operands and the outcome. */
342extern ControlVars ControlV;
343
344/** @brief The entries. */
345typedef struct
346{
347 void (*const pid_init)(uint8_t *work);
348 void (*const pid_set_output_limits)(uint8_t *work);
349 void (*const pid_set_integral_limits)(uint8_t *work);
350 void (*const pid_set_derivative_filter)(uint8_t *work);
351 void (*const pid_set_feedforward)(uint8_t *work);
352 void (*const pid_set_rate)(uint8_t *work);
353 void (*const pid_reset)(uint8_t *work);
354 void (*const pid_update_n)(uint8_t *work);
355 void (*const pid_log_header)(uint8_t *work);
356 void (*const pid_log_record)(uint8_t *work);
357} ControlNs;
358
359// What the table binds, defined once in the .c and taking one parameter each: everything
360// else an entry needs is an operand in ControlV or a region of the borrow at a fixed offset.
361void protocore_control_pid_init(uint8_t *work);
362void protocore_control_pid_set_output_limits(uint8_t *work);
363void protocore_control_pid_set_integral_limits(uint8_t *work);
364void protocore_control_pid_set_derivative_filter(uint8_t *work);
365void protocore_control_pid_set_feedforward(uint8_t *work);
366void protocore_control_pid_set_rate(uint8_t *work);
367void protocore_control_pid_reset(uint8_t *work);
368void protocore_control_pid_update_n(uint8_t *work);
369void protocore_control_pid_log_header(uint8_t *work);
370void protocore_control_pid_log_record(uint8_t *work);
371
372// `static const`, initialised HERE rather than `extern` against a definition in the .c: a
373// const object whose initializer every translation unit can see is a COMPILE-TIME FACT, so
374// `Control.pid_init(work)` resolves to a named function and becomes a DIRECT call. An extern table
375// leaves the call indirect and the symbol live at every level, -O2 -flto included.
376static const ControlNs Control __attribute__((unused)) = {
377 .pid_init = protocore_control_pid_init,
378 .pid_set_output_limits = protocore_control_pid_set_output_limits,
379 .pid_set_integral_limits = protocore_control_pid_set_integral_limits,
380 .pid_set_derivative_filter = protocore_control_pid_set_derivative_filter,
381 .pid_set_feedforward = protocore_control_pid_set_feedforward,
382 .pid_set_rate = protocore_control_pid_set_rate,
383 .pid_reset = protocore_control_pid_reset,
384 .pid_update_n = protocore_control_pid_update_n,
385 .pid_log_header = protocore_control_pid_log_header,
386 .pid_log_record = protocore_control_pid_log_record,
387};
388
390
391#endif // PROTOCORE_ENABLE_CONTROL
392
393#endif // PROTOCORE_CONTROL_H
#define PROTOCORE_BEGIN_DECLS
Give a header's declarations C linkage, so their symbol names carry no parameter types.
Definition types.h:96
_Bool proto_bool
The truth value.
Definition types.h:64
#define PROTO_TRUE
the true value, spelled so a caller never writes a bare 1
Definition types.h:67
#define PROTOCORE_END_DECLS
Definition types.h:97