Add back-calculation anti-windup for PID controller#101
Draft
lnagel wants to merge 7 commits into
Draft
Conversation
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #101 +/- ##
==========================================
+ Coverage 96.61% 96.69% +0.07%
==========================================
Files 20 20
Lines 1715 1755 +40
Branches 257 268 +11
==========================================
+ Hits 1657 1697 +40
Misses 36 36
Partials 22 22
|
f0faba1 to
d8d2ae4
Compare
Correct the PID integral at observation period boundaries by comparing actual valve delivery against commanded duty cycle. This prevents integral windup when the duty cycle is too small for the minimum run time, avoiding excessive overshoot when demand later rises. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Use a period-start snapshot (commanded_duty_cycle) instead of the end-of-period PID duty cycle as u_commanded. This prevents sign errors when the duty cycle drifts during the observation period — e.g., when the PI outputs 15% at period start but drops to 6% as the room warms. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Update data flow diagram to reflect back-calculation step during period transition. Fix pid.py docstring that still referenced "period end" after the snapshot refactor. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The single period-start snapshot was too coarse — the PID drifts during the period, and when a valve delivers its quota and closes, the stale snapshot can cause false corrections. Replace with paired fields (last_action_at, last_requested_duration) bumped forward at PWM convergence points (valve open, valve close, period start). These fields are also persisted, fixing incorrect corrections after restart. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
d8d2ae4 to
5b6d70a
Compare
The harness was missing the mid-period convergence point tracking that the coordinator performs after evaluate(). This ensures TURN_ON/TURN_OFF events update last_requested_duration for accurate back-calculation at period boundaries. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- assert_integral_converged helper: checks integral average stays below a threshold after settling time - test_low_kp_integral_converges: kp=10 at outdoor=19°C, verifies integral settles proportional to steady-state duty (~3.73%) - test_demand_transition_bounded_overshoot: outdoor 20→0°C cold snap, verifies temperature stays within setpoint ± 2°C - Strengthen test_sustained_under_delivery with integral level check (max_value=15 vs theoretical duty ~2.5%) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
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.
Problem
When the PID commands a small duty cycle (e.g., 5%), the resulting
requested_duration(360s) falls belowmin_run_time(540s), so the valve never opens. But the PID doesn't know this — it keeps accumulating integral error as if 5% was being delivered. Over many observation periods, the integral winds up excessively and causes overshoot when demand eventually rises above the delivery threshold.The existing integral clamping (
integral_min/integral_max) limits the absolute bounds but doesn't address the mismatch between what the PID commands and what the actuator actually delivers.Solution
Back-calculation anti-windup corrects the integral at each observation period boundary by comparing what was actually delivered vs. what was commanded:
where:
Kt = Ki/Kp(tracking gain, equals1/Tiper Åström & Hägglund)u_actual = (used_duration / observation_period) × 100u_commanded= duty cycle snapshotted at period startThe correction is applied once per 2-hour observation period, matching the actuator's time granularity. With 10–30 hour thermal time constants, this is well within the required correction bandwidth.
Architecture
The implementation is layered across three files, each with a single responsibility:
1.
core/pid.py—PIDController.apply_saturation_correction()Low-level integral correction. Computes
Kt = ki/kpinternally, appliesintegral += Kt × (u_actual − u_commanded) × dt, clamps to bounds, creates new frozenPIDStatepreserving all other fields. Guards: no-op forstate is None,dt ≤ 0,kp == 0, or zero correction.2.
core/zone.py—ZoneRuntime.apply_period_end_back_calculation()Zone-level orchestration. Computes
u_actualfromused_duration, readsu_commandedfromcommanded_duty_cycle(period-start snapshot), and delegates to the PID. Guards: skip if no PID state or zone disabled (PID is paused when disabled, no windup to correct).Also adds
snapshot_commanded_duty_cycle()— captures the current PID duty cycle at period start so the back-calculation later compares against what was actually commanded, not the drifted end-of-period value.3.
core/controller.py— Period transition hookIn
handle_observation_period_transition(), thenew_periodblock now:used_durationandcommanded_duty_cyclebefore resetused_durationfor the new periodcommanded_duty_cyclefor next periodSkips correction on first period transition (
last_force_update is None) to avoid spurious correction from partial startup period.Why snapshot at period start?
Using the end-of-period duty cycle as
u_commandedcauses sign errors when the duty cycle drifts during the observation period. Consider: PI outputs 15% at period start, valve opens for min_run_time (12.5% actual), room warms, duty cycle drops to 6% by period end. With end-of-period value: correction =Kt × (12.5 − 6)= positive (wrong — integral increases as if there was under-delivery). With period-start snapshot: correction =Kt × (12.5 − 15)= negative (correct — small downward nudge reflecting actual under-delivery).Design decisions
1/Tiper Åström & HägglundZoneStateTest plan
TestApplySaturationCorrection(8 tests) — PID-level: exact math, guards (no state, zero/negative dt, zero kp), clamping, field preservation, no-op when equal, positive correctionTestZoneRuntimeBackCalculation(5 tests) — Zone-level: correction applied, disabled skip, full delivery no-op, no PID state, drifting duty cycle scenario (verifies snapshot vs end-of-period gives correct sign)TestZoneRuntimeSnapshotCommandedDutyCycle(2 tests) — Snapshot captures duty cycle or defaults to 0TestHandleObservationPeriodTransition(2 new tests) — Controller-level: correction + snapshot + reset ordering on period transition, first period skips correction but takes snapshot🤖 Generated with Claude Code