All notable changes to the easyPID library will be documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
1.1.0 - 2026-08-09
Three additions to PIDTuner. All are backward compatible: existing sketches
compile and behave identically without changes. They are grouped into one minor
release because new public API cannot be a patch under semantic versioning.
-
start()takes an optionaloutputBias, the operating point the relay swings around. The output now ranges over[outputBias - relayAmplitude, outputBias + relayAmplitude].This makes the autotuner usable on unipolar hardware for the first time. The relay previously swung symmetrically about zero, so on a heater or a PWM pin half of every period was a negative drive the hardware clips to zero — the measurement never crossed the setpoint, the relay never switched, and tuning timed out with no result. The default of
0.0reproduces the old symmetric swing exactly, which suits a bipolar actuator. -
TUNER_FAILED, so a run that produced no usable result is distinguishable from one that was never started. Timeouts and unusable results previously returned the tuner toTUNER_IDLE, which is also its state at construction, so a sketch could not tell "tuning failed" from "not started yet" and one that polled onlyisComplete()would spin forever. The enumerator is appended, so the numeric values of the existing ones are unchanged.start()accepts a retry after a failed run. -
applyTunings(rule), which writes the computed gains onto the attached controller and resets it, in place ofgetTunings()+setTunings()+reset(). This is also what finally makes thePIDController&the tuner has always held do something a caller can see.
update()documents that it returns 0 outsideTUNER_RELAY_STEP, so callers checkgetState()before treating the value as a drive level.- The class documentation now shows the hysteresis-corrected
Kurelation used since 1.0.4, and a usage example covering the failure path. AutoTunePID.inouses all three additions.- Removed the stale
@version 1.0.0tags from the headers;library.propertiesis the single source of truth for the version.
library.propertiesgained alicense=MITfield, so the Library Manager listing no longer shows no license despiteLICENSEbeing present.includeslisted onlyeasyPID.h, so Sketch → Include Library never offeredPIDTuner.heven though the docs tell users to include it. NoweasyPID.h,PIDTuner.h.- The file had no terminating newline, so appending a field with
>>would have concatenated it onto theincludesline. - The
paragraphrestated everything already insentence, which the spec prepends when rendering, so the listing said "anti-windup, derivative filtering, autotuning" twice in one blurb.
- Dropped the unsupported "field-tested" / "Tested on..." claims from
library.properties,README.mdandCHANGELOG.md. The repository had no tests and no compile verification, so nothing justified them. Reworded to "developed and compiled against", and "Tested Platforms" is now "Target Platforms". README.mdno longer claims the autotuner "is only included when explicitly requested". Arduino compiles every.cppundersrc/into every sketch; the claim is true of the API and false of the build. Reworded to say the linker discards it when unused.- Documented that the three-argument
update()takes milliseconds. "dt" normally means seconds in control work, and passing0.1for 100 ms would integrate 1000x too slowly. Added a README snippet showing the correct call.
- The root
SECURITY.md. Two security policies were tracked and they contradicted each other on supported versions, reporting channel and acknowledgment SLA. GitHub surfaces.github/SECURITY.md, so that one is kept, with the root file's better content (private vulnerability reporting as the primary channel, "no public issues") merged into it. - The tracked, empty, read-only
.codexfile, which had no consumer and shipped to every user in the Library Manager archive. Added to.gitignore.
- The tuning guide's autotune snippet called
pid.reset()on every loop iteration. Anyone copying it got a controller whose integral was wiped every sample, degrading it to proportional-only with a permanent steady-state offset and a full-magnitude phantom derivative each cycle — a library defect, as far as the user could tell. It also re-applied identical gains forever. Now a one-shot latch.README.mdand the AutoTunePID sketch never had this bug. - The Ziegler-Nichols open-loop formulas were wrong. For the FOPDT model the
reaction-curve rule is
Kp = 1.2*T/(K*L),Ti = 2L,Td = 0.5L. The guide gaveKp = 1.2/(K*L), droppingTentirely, andKi = 2*Kp/T, which is wrong twice over. ForK=2, L=5 s, T=100 sit producedKp = 0.12where the correct value is12.0, andKi = 0.0024against1.2— off by 100x and 500x. OnlyKdwas right. The library's own closed-loop rules were always correct; this was an isolated documentation error.
- The anti-windup section labelled
NONEas the default when the constructor actually setsCLAMP, and the checklist implied anti-windup was off until enabled. Both corrected. - Documented that
setIntegralLimits()bounds the accumulator, with a worked example of theKi * limitscaling. - Noted the
REVERSEsign convention where the error equation is introduced, and the 0.999 upper bound on the filter alpha.
- The
@filetag insrc/easyPID.cppsaidPIDController.cpp, a name that does not exist in the repository, so Doxygen filed the translation unit under a phantom file and cross-references fromeasyPID.hresolved to nothing. - Including
easyPID.hafter a library that definesDIRECT/REVERSEas macros (PID_v1 does) made the preprocessor rewrite theControlDirectionenumerator list, and the compiler blamed easyPID for another library's macros with an unreadable parse error. A#errornow names the actual cause and the two ways out. The enumerators are unchanged; renaming them would break every existing call site, so that is a 2.0 consideration.
- Removed the "based on tracker.h lines N-M" citations throughout. That file is not in the repository, so none of the eight references could be checked. The substantive "original vs. enhanced" explanations are kept.
- The constructor block now lists the defaults it actually applies, which were
documented nowhere:
ANTIWINDUP_CLAMP,FILTER_NONE, alpha 0.8,DIRECT, 100 ms sample time, and integral limits inactive. getPterm()/getDterm()note that they are direction-adjusted and therefore carry the opposite sign togetError()underREVERSE. The terms sum to the pre-clamp output; the error is the plain physical error. Both are useful, and the difference now has a stated contract instead of being an accident.
- AutoTunePID could never induce a limit cycle. The tuner swings its output
symmetrically about zero, so half of every relay period was a negative drive
into a plant that only accepts 0-255. The measurement peaked at 22 against a
setpoint of 100, never crossed it, the relay never switched, and tuning
aborted on the timeout with no result. The sketch now centres the relay on an
OUTPUT_BIASoperating point, which is what a unipolar actuator requires. - The setpoint (100) was also above the plant's ceiling of 75, so even a working relay could not have reached it. Now 37, with the time constant raised to 1.0 s so the limit cycle spans ~18 samples instead of ~2. At the old values the measured period was near the sampling limit and the resulting gains were meaningless.
- The sketch spun forever if tuning failed, because it only ever tested
isComplete()and never noticed the tuner had returned toTUNER_IDLE. It now detects failure, explains the likely causes, and holds the output at zero. - The progress line printed on every loop iteration whose percentage happened to be a multiple of ten, repeating the same value hundreds of times. It now prints only on change.
- Braced the
switchcase bodies that declare variables, and moved the tuning rule names to flash viaF().
Simulated end to end against the sketch's own plant model: tuning completes in
9.2 s with Ku = 8.95, Pu = 1.82 s (18 samples per cycle), and all four rule
sets then drive the loop to setpoint with the expected overshoot ordering --
No-Overshoot 0.7%, Tyreus-Luyben 0%, Ziegler-Nichols 3.8%, Pessen 6.5%.
- BasicPID and MultiLoopPID chased setpoints their simulated plants could not
reach. Each plant settles at
PROCESS_GAIN * 100at full output, so BasicPID asked for 100 from a plant with a ceiling of 80, and MultiLoopPID's second loop asked for 120 from a ceiling of 70. Both pinned the output at 255 forever. The sketches advertised as demonstrations of PID control were demonstrating integral windup. Gains raised to 1.5, 1.2 and 1.6 respectively; all three loops now settle exactly on setpoint. Verified by simulating the sketches' own plant math. - Labels and values printed on separate lines throughout both sketches, from
Serial.println(F("Setpoint: "))followed bySerial.print(value). - MultiLoopPID injected its simulated noise into the plant state, making it a
random walk the integrator had to chase, and drew it from
random(-10, 10), which is asymmetric (-1.0 to +0.9, mean -0.05). Noise is now zero-mean and applied to the value handed to the controller, which is what sensor noise actually is. - MultiLoopPID's periodic P/I/D block interleaved non-CSV lines into the CSV
stream, corrupting it for Serial Plotter. It is now behind
VERBOSE_TERMS, off by default. randomSeed()is now called, so the noise is not identical on every run.
- MultiLoopPID uses the explicit-
dtoverload, both to demonstrate it and because the loop is already gated to a fixed period, so the controller and the plant simulation now agree exactly.
PIDTuner::start()validates its parameters and returnsfalsefor a non-positiverelayAmplitude(which drives nothing, so no limit cycle can form) or a negativenoiseBand(which inverts the switching thresholds and makes the relay chatter every sample). Both previously started a run that could only fail.- A tuning run that times out now computes results from the cycles it did
collect instead of discarding them.
calculateResults()already enforcedMIN_CYCLES_FOR_TUNING, but nothing ever called it on the timeout path, so that guard was unreachable and a run that gathered 4 of 5 cycles threw all of them away. getTunings()and the internalapplyTuningRule()areconst; neither mutates the tuner.
setDirection()now negates the carried integral and derivative state when the direction actually changes. Those values were accumulated under the opposite sign convention, so after a switch the integrator fought the new direction until it bled off, and the sign flip inpreviousError_produced one large spurious derivative sample. Setting the direction it already has is now a no-op rather than a state disturbance.
setOutputLimits()andsetIntegralLimits()now ignoremin >= max. Inverted limits made every output compare as saturated, which permanently inhibited integration and left the controller unable to reach setpoint.- The derivative-filter
alphais clamped strictly below 1.0. At exactly 1.0 the EMA reduces tofiltered = 1*filtered + 0*raw, freezing the filtered derivative at its initial value and killing the D term for the rest of the run. The old clamp permitted that value.
- The dead integral-limit seeding in
setOutputLimits(). It assignedintegralMin_/integralMax_whileintegralLimitsSet_was false, but those members are only read when it is true, so the assignment never had any effect. The comment claiming integral limits "default to output limits" was misleading: untilsetIntegralLimits()is called there is no integral limit.
setIntegralLimits()bounds the raw error-time accumulator, not the Ki-scaled I term. The contribution to the output isKi * limit. This was never stated and the parameter names implied otherwise.
update()fabricated a timestep when no time had passed.dtwas computed frommillis(), and when it came out as zero the controller substituted a wholesampleTime_(100 ms by default) and integrated as if that period had elapsed. The documented usage callsupdate()unconditionally fromloop(), which on any reasonably fast board runs many times per millisecond, so the integral accrued at up to 100x the true rate. Measured: ten calls with the clock frozen integrated a full second. Such calls now return the previous output unchanged, andlastTime_is not advanced, so sub-millisecond time carries into the next call instead of being discarded.- The manual-
dtoverload likewise returns the previous output for a non-positivedtMsrather than inventing a timestep. PreviouslysetSampleTime(0)made the divide-by-zero guard restoredt = 0and the derivative became inf, then NaN. setSampleTime()now ignores zero.
setSampleTime()is documented as advisory. It never gated the update rate, and now that no timestep is ever fabricated it plays no part in the control math at all. The previous note calling it "mainly for documentation" was wrong in the opposite direction: it was driving the math, on exactly the path that was broken.
ANTIWINDUP_CLAMPcould pin the output at a limit indefinitely. The rollback fired on saturation alone, with no test of the error direction, so it cancelled accumulation that would have relieved saturation just as readily as accumulation that worsened it. Once the I term alone exceeded the output limit, the integrator froze and the controller stayed on the rail regardless of the error. Reproduced: integral wound toI = 300against a ceiling of 50, then a sustained error of-100for 300 samples left the output at exactly50.000the whole time with the integral unmoved. Now only accumulation that drives further into saturation is inhibited; the same scenario unwinds toI = 100and the output leaves the rail.- The rollback restores the pre-accumulation value instead of subtracting
error * dt, making it an exact inverse whensetIntegralLimits()truncated the accumulation. Previously it removed more than had been added.
This changes the numeric response of saturating controllers using the default anti-windup mode. A loop tuned around the frozen-integral behaviour will react differently — better, but differently.
getIterm()reported the integral term as it was before anti-windup ran, so during saturation it kept climbing even though the integrator was being held. That made it look as though anti-windup was not working, which is precisely the situation the getter exists to diagnose. It now reflects the post-correction integrator state.
getError()is now alwayssetpoint - measurement, as its documentation always claimed.REVERSEmode negated the error in place, so introspection reported the internal sign-corrected control error instead: a REVERSE controller at setpoint 100 with measurement 40 reported-60rather than+60. The reported error and the error driving the terms are now separate values.
DIRECT controllers (the default) are unaffected. If you have a REVERSE
controller and were compensating for the old sign when logging or plotting
getError(), remove that compensation. Control behaviour itself is unchanged.
update(setpoint, measurement, dtMs)did not refresh the internal timing reference, so a sketch that used the manual-dtoverload and later called the automatic overload had all the intervening wall-clock counted as a single sample period. Measured: ten manual 100 ms updates spread over 10 s of real time, then one automatic update, integrated 10.1 s in one step instead of 0.1 s.reset()now also restarts the timing reference. It is typically called after a pause or a large setpoint change, and without this the next automatic update integrated the entire idle period in one step.
- Private member
autoTiming_, which was assigned in the constructor and never read. No public API change.
ANTIWINDUP_BACKCALCdivided bykiwithout guarding against zero. A PD controller (ki = 0) configured with back-calculation computed1.0f / 0.0f, wrote inf into the integral, and every subsequent output was inf or NaN -- permanently, since inf never recovers. Back-calculation is now skipped when there is no meaningful integral gain to correct.
- No more derivative kick on the first update after
begin()orreset(). WithpreviousError_still at its initial0.0, the first sample computed(error - 0) / dt, producing a derivative proportional to the entire error at exactly the moment the error is normally largest. WithKd = 10, a step to an error of 100 atdt = 0.1 sproduced a D term of 10000. The derivative now starts at zero and develops from the second sample onward.
getProgress()returned0.0after tuning finished instead of1.0, because it early-returned for any state other thanTUNER_RELAY_STEP. A sketch displaying progress showed it collapse back to zero on success.getState()no longer reportsTUNER_COMPLETEwhen the run produced no usable result. It now falls back toTUNER_IDLE, sogetState()andisComplete()can no longer disagree.TUNER_ANALYZINGwas declared but never entered. It is now set while results are computed. It is transient within a singleupdate()call, and the header documents it as such rather than implying it is externally observable.PIDTunerheld aPIDController&that was never used.start()now callsreset()on it: the relay run drives the plant directly, so any integral the controller had accumulated is stale by the time tuning completes.
- The "process not responding" timeout is now keyed to the last relay edge
rather than the last completed cycle. A slow process switches the relay twice
per period, so the old reference silently capped the tunable limit-cycle
period at
MAX_WAIT_TIME_MS(60 s) and aborted tuning on exactly the lag-dominant thermal processes autotuning is most useful for. Verified: a plant withPu = 69.6 snow tunes successfully instead of timing out.
- An absolute 15-minute deadline per tuning run, as a backstop for pathological cases where the relay keeps switching but no consistent limit cycle emerges.
- The ultimate-gain estimate now accounts for the noise band acting as relay
hysteresis:
Ku = 4d / (pi * sqrt(a^2 - h^2)). The previous ideal-relay form4d / (pi * a)ignored the hysteresis and biasedKulow, increasingly so asnoiseBandgrew relative to the oscillation. - Tuning results are now rejected when the measured swing is not larger than the noise band, instead of reporting a confident value derived from noise.
- The autotuner could never complete.
cyclesDetected_was incremented insideif (cyclesDetected_ > 0), butstart()initialises it to0, so the counter was pinned at zero forever.isComplete()never returnedtrue,getProgress()never rose above0.0, and because the timeout reference was refreshed on every relay switch the tuner never timed out either — it simply relayed indefinitely. Confirmed by simulation: 400 s of relay operation withKu = Pu = 0. - Oscillation amplitude was measured incorrectly.
peakHigh_was reset to the measurement at each switching instant and then never tracked upward, sopeakHigh_ - peakLow_recorded roughly2 x noiseBandinstead of the real limit-cycle swing. Extremes are now accumulated continuously across each cycle, which is required because the process peak lags the relay switch. - Amplitude is now the half peak-to-peak swing, matching the
aterm in the describing-function relationKu = 4d / (pi * a). The previous code passed the full peak-to-peak span, understatingKuby a factor of two.
- Private members
peakHigh_,peakLow_,peakHighTime_,peakLowTime_,lookingForPeak_,peakType_andlastPeakTime_, superseded by the cycle-window measurement. No public API change.
- Relay edge detection no longer uses a function-local
staticinsidePIDTuner::detectPeak(). That variable was shared by everyPIDTunerinstance in the sketch and was initialised only once for the lifetime of the program, so a second tuner (or a secondstart()on the same tuner) saw corrupted edge state. It is now a per-instance member. - Initialise every
PIDTunermember in the constructor, so callingupdate()beforestart()can no longer read indeterminate values.
- Stop redefining Arduino's
PImacro inPIDTuner.cpp, which emitted a "PI redefined" warning on every compilation (reproduced on AVR and ESP32). The tuner now uses a private float constant instead.
1.0.0 - 2024-01-15
-
Core PIDController class with full PID control functionality
- Proportional, Integral, and Derivative control terms
- dt-aware integral accumulation and derivative calculation
- Support for automatic timing (millis-based) and manual dt input
- Dual update patterns:
update(setpoint, measurement)andupdate(setpoint, measurement, dtMs)(milliseconds) - Alternative pattern:
setSetpoint(),setMeasurement(),compute()
-
Anti-windup protection with multiple modes
- NONE: No anti-windup (for testing)
- CLAMP: Prevent integral accumulation during saturation
- BACKCALC: Back-calculation method for advanced applications
-
Derivative filtering to reduce noise sensitivity
- NONE: Raw derivative (no filtering)
- EMA: Exponential Moving Average (1st-order low-pass filter)
- Configurable filter coefficient (alpha parameter)
-
Full state introspection for debugging and monitoring
getError(): Current control errorgetPterm(): Proportional term contributiongetIterm(): Integral term contributiongetDterm(): Derivative term contributiongetOutput(): Last computed output value
-
Runtime configuration methods
setTunings(): Update PID gains on-the-flysetOutputLimits(): Configure output saturation boundssetIntegralLimits(): Set separate integral term limitssetSampleTime(): Document expected sample periodsetDirection(): DIRECT or REVERSE control actionreset(): Clear all state (integral, derivative, errors)
-
Optional PIDTuner add-on module for automatic tuning
- Relay/limit-cycle autotuning method
- Oscillation detection and parameter extraction
- Ultimate gain (Ku) and period (Pu) calculation
- Four tuning rule options:
- Ziegler-Nichols (classic, aggressive)
- Tyreus-Luyben (less overshoot)
- Pessen Integral Rule (fast response)
- No Overshoot (conservative)
- Progress monitoring and timeout protection
- Safe oscillation amplitude and noise band configuration
-
Three comprehensive examples
- BasicPID: Single controller with simulated first-order process
- MultiLoopPID: Two independent controllers demonstrating multi-instance capability
- AutoTunePID: Complete autotuning workflow with multiple tuning rules
-
Complete documentation
- README.md with quick start, usage examples, and feature comparison
- docs/tuning_guide.md with practical tuning procedures and troubleshooting
- Inline code documentation with Doxygen-style comments
-
Arduino library metadata
- library.properties for Arduino Library Manager compatibility
- keywords.txt for Arduino IDE syntax highlighting
- MIT License (LICENSE file)
- Arduino Library Specification 1.5 compliant structure
- Multi-instance safe: No global mutable state, create unlimited controllers
- Memory efficient: Uses float (not double) for AVR compatibility
- Hardware agnostic: No dependencies on specific sensors or actuators
- AVR optimized: Developed and compiled against Arduino Uno (ATmega328P)
- Portable: Compatible with most Arduino architectures (AVR, ARM, ESP8266, ESP32)
- Arduino Core library (included with Arduino IDE)
- No external dependencies required
- Arduino Uno (ATmega328P)
- Arduino Nano
- Arduino Mega 2560
- Compatible with most Arduino boards
- Sample time consistency is user's responsibility (library doesn't enforce it in auto-timing mode)
- Autotuner requires stable process (no major disturbances during tuning)
- Derivative filtering limited to 1st-order (EMA) in v1.0.0
This is the initial release. No migration necessary.
Potential features for future versions:
- 2nd-order derivative filtering
- Derivative-on-measurement (to avoid derivative kick)
- Setpoint weighting/ramping
- Bumpless transfer support
- Additional autotuning algorithms
- EEPROM parameter persistence
Version numbers follow Semantic Versioning:
- MAJOR: Incompatible API changes
- MINOR: New functionality (backward-compatible)
- PATCH: Bug fixes (backward-compatible)
Example: v1.2.3
- 1 = Major version
- 2 = Minor version
- 3 = Patch version