DeterministicESPAsyncWebServer v6.27.1
Zero-allocation, bounded-execution async HTTP server for ESP32
Loading...
Searching...
No Matches
control.h
Go to the documentation of this file.
1// 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 (DETWS_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 DETERMINISTICESPASYNCWEBSERVER_CONTROL_H
33#define DETERMINISTICESPASYNCWEBSERVER_CONTROL_H
34
35#include "ServerConfig.h"
36
37#if DETWS_ENABLE_CONTROL
38
39#include <stddef.h>
40#include <stdint.h>
41
42#define CONTROL_UNBOUNDED 1e30f ///< sentinel for "no clamp" (well outside any real actuator range)
43
44// PID-run log for offline tuning with tools/pid_tune.py, in two interchangeable formats:
45//
46// 1. CSV (human / serial friendly) - one row per control step, these columns:
47#define CONTROL_LOG_HEADER "t_s,setpoint,measurement,output,dt_s"
48//
49// 2. Dense binary (little-endian) for high-rate loops - self-describing (the header carries the
50// gains, limits, and sample period the run used, so the tuner needs no flags) and 16 B/sample:
51// header (PID_LOG_HEADER_LEN): "DPID" | ver u8=1 | flags u8 | reserved u16 |
52// dt_s f32 | kp f32 | ki f32 | kd f32 | kff f32 | out_min f32 | out_max f32
53// record (PID_LOG_RECORD_LEN): setpoint f32 | measurement f32 | output f32 |
54// status u32 (bit 0 = output was saturated this step, so the tuner can drop it from the
55// plant identification fit).
56#define PID_LOG_MAGIC "DPID"
57#define PID_LOG_VERSION 1
58#define PID_LOG_HEADER_LEN 36
59#define PID_LOG_RECORD_LEN 16
60#define PID_LOG_STATUS_SATURATED 0x1u
61
62/**
63 * @brief A single-loop PID controller. Zero its bytes or call pid_init() before first use; the
64 * runtime-state fields below the gains are owned by pid_update() / pid_reset().
65 */
66struct Pid
67{
68 // gains
69 float kp; ///< proportional gain
70 float ki; ///< integral gain (per second)
71 float kd; ///< derivative gain (seconds)
72 float kff; ///< feed-forward gain applied to the setpoint
73 // limits
74 float out_min; ///< output lower clamp
75 float out_max; ///< output upper clamp
76 float integ_min; ///< integral accumulator lower clamp
77 float integ_max; ///< integral accumulator upper clamp
78 float d_alpha; ///< derivative low-pass smoothing in [0,1); 0 = raw (unfiltered) derivative
79 // fixed-rate cache (set by pid_set_rate; lets pid_update_fixed() run with no divide)
80 float dt; ///< cached sample period, 0 until pid_set_rate()
81 float inv_dt; ///< cached 1/dt
82 // runtime state (owned by pid_update / pid_reset)
83 float integ; ///< integral accumulator
84 float prev_meas; ///< previous measurement (for derivative-on-measurement)
85 float d_filt; ///< filtered derivative
86 bool primed; ///< false until the first update supplies prev_meas (no derivative on step 1)
87};
88
89// --- inline control-law primitives (dedup of the arithmetic every loop reaches for) ---
90
91/// Clamp @p v to [lo, hi].
92static inline float control_clamp(float v, float lo, float hi)
93{
94 return v < lo ? lo : (v > hi ? hi : v);
95}
96
97/// Deadband: return 0 within +/- @p band, else @p v shifted toward 0 by @p band (continuous).
98static inline float control_deadband(float v, float band)
99{
100 if (v > band)
101 return v - band;
102 if (v < -band)
103 return v + band;
104 return 0.0f;
105}
106
107/// Slew-rate limit: move @p current toward @p target by at most @p max_step this call.
108static inline float control_slew(float target, float current, float max_step)
109{
110 float d = target - current;
111 if (d > max_step)
112 return current + max_step;
113 if (d < -max_step)
114 return current - max_step;
115 return target;
116}
117
118/// One step of a single-pole low-pass: blend @p sample into @p prev by @p alpha in [0,1].
119static inline float control_lpf(float prev, float sample, float alpha)
120{
121 return prev + alpha * (sample - prev);
122}
123
124/// Initialize with the three gains; feed-forward 0, no derivative filter, output/integral
125/// unbounded (CONTROL_UNBOUNDED). Clears the runtime state.
126void pid_init(Pid *p, float kp, float ki, float kd);
127
128/// Clamp the controller output to [lo, hi] (the actuator's range).
129void pid_set_output_limits(Pid *p, float lo, float hi);
130
131/// Hard clamp the integral accumulator to [lo, hi] (a secondary anti-windup bound).
132void pid_set_integral_limits(Pid *p, float lo, float hi);
133
134/// Set the derivative low-pass smoothing factor in [0,1); 0 disables the filter.
135void pid_set_derivative_filter(Pid *p, float alpha);
136
137/// Set the feed-forward gain (the command output includes kff * setpoint).
138void pid_set_feedforward(Pid *p, float kff);
139
140/// Cache the sample period @p dt (and 1/dt) for the zero-divide pid_update_fixed() fast path. Call
141/// once at setup for a fixed-rate loop; the single reciprocal is computed here, not per tick.
142void pid_set_rate(Pid *p, float dt);
143
144/// Clear the integrator, derivative memory, and prime flag (e.g. when re-enabling the loop).
145void pid_reset(Pid *p);
146
147/// Internal shared step, used by pid_update() and pid_update_fixed(): the whole control law with
148/// @p dt and its reciprocal @p inv_dt supplied, so there is no divide inside. Call an entry point
149/// below, not this directly.
150static inline float pid_step_(Pid *p, float setpoint, float measurement, float dt, float inv_dt)
151{
152 float error = setpoint - measurement;
153
154 // Derivative on measurement (no setpoint-change "kick"): d(error)/dt = -d(measurement)/dt when
155 // the setpoint is held. Skip the first update (no prev_meas yet), optionally low-pass filter it.
156 float deriv = 0.0f;
157 if (p->primed)
158 {
159 deriv = -(measurement - p->prev_meas) * inv_dt; // multiply by 1/dt, no divide
160 p->d_filt = (p->d_alpha > 0.0f) ? p->d_filt + p->d_alpha * (deriv - p->d_filt) : deriv;
161 }
162 p->prev_meas = measurement;
163 p->primed = true;
164
165 // Tentative integration, hard-clamped to the accumulator bounds (a secondary safety limit).
166 float integ_next = control_clamp(p->integ + p->ki * error * dt, p->integ_min, p->integ_max);
167
168 // FMA chain -> madd.s on the FPU: kp*error + integ + kd*d_filt + kff*setpoint.
169 float unclamped = p->kp * error + integ_next + p->kd * p->d_filt + p->kff * setpoint;
170 float out = control_clamp(unclamped, p->out_min, p->out_max);
171
172 // Anti-windup by conditional integration: commit the new integral unless the output is
173 // saturated AND integrating further this direction would push it deeper into the rail - then
174 // freeze the accumulator instead, so it never winds up past what the actuator can deliver.
175 bool worsen_high = (unclamped > p->out_max) && (error > 0.0f);
176 bool worsen_low = (unclamped < p->out_min) && (error < 0.0f);
177 if (!worsen_high && !worsen_low)
178 p->integ = integ_next;
179
180 return out;
181}
182
183/**
184 * @brief Advance the loop one step: returns the (clamped) control output for @p setpoint given the
185 * measured process value @p measurement over the elapsed time @p dt seconds (dt <= 0 -> 0).
186 *
187 * Inline (no call overhead) and FPU-accelerated (single-precision, FMA-folded). Variable-rate: it
188 * computes 1/dt each call. For a fixed-rate loop use pid_update_fixed() to skip that divide.
189 */
190static inline float pid_update(Pid *p, float setpoint, float measurement, float dt)
191{
192 if (!p || dt <= 0.0f)
193 return 0.0f;
194 return pid_step_(p, setpoint, measurement, dt, 1.0f / dt);
195}
196
197/**
198 * @brief Zero-divide fixed-rate step: same law as pid_update() but uses the dt / 1-over-dt cached
199 * by pid_set_rate(), so the hot path is all multiplies (the fastest form). Returns 0 until
200 * pid_set_rate() has supplied a positive dt.
201 */
202static inline float pid_update_fixed(Pid *p, float setpoint, float measurement)
203{
204 if (!p || p->dt <= 0.0f)
205 return 0.0f;
206 return pid_step_(p, setpoint, measurement, p->dt, p->inv_dt);
207}
208
209/// Batched multi-axis update: run @p n loops from contiguous arrays in one tight, FPU-bound,
210/// SIMD-friendly pass (motion masters run several drives off one control tick). Each
211/// out[i] = pid_update(&p[i], setpoint[i], measurement[i], dt).
212void pid_update_n(Pid *p, const float *setpoint, const float *measurement, float dt, float *out, uint8_t n);
213
214/// Write the self-describing 36-octet dense-binary log header from @p p's gains + limits and the
215/// sample period @p dt (see the PID_LOG_* format above). Returns PID_LOG_HEADER_LEN, or 0 if cap
216/// is too small. Emit once, then a pid_log_record() per control step.
217size_t pid_log_header(uint8_t *buf, size_t cap, const Pid *p, float dt);
218
219/// Write one 16-octet dense-binary log record. Returns PID_LOG_RECORD_LEN, or 0 if cap too small.
220size_t pid_log_record(uint8_t *buf, size_t cap, float setpoint, float measurement, float output, bool saturated);
221
222#endif // DETWS_ENABLE_CONTROL
223
224#endif // DETERMINISTICESPASYNCWEBSERVER_CONTROL_H
User-facing configuration for DeterministicESPAsyncWebServer.