Skip to content

Add back-calculation anti-windup for PID controller#101

Draft
lnagel wants to merge 7 commits into
mainfrom
back-calculation-anti-windup
Draft

Add back-calculation anti-windup for PID controller#101
lnagel wants to merge 7 commits into
mainfrom
back-calculation-anti-windup

Conversation

@lnagel
Copy link
Copy Markdown
Owner

@lnagel lnagel commented Feb 10, 2026

Problem

When the PID commands a small duty cycle (e.g., 5%), the resulting requested_duration (360s) falls below min_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:

correction = Kt × (u_actual − u_commanded) × observation_period

where:

  • Kt = Ki/Kp (tracking gain, equals 1/Ti per Åström & Hägglund)
  • u_actual = (used_duration / observation_period) × 100
  • u_commanded = duty cycle snapshotted at period start

The 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.pyPIDController.apply_saturation_correction()

Low-level integral correction. Computes Kt = ki/kp internally, applies integral += Kt × (u_actual − u_commanded) × dt, clamps to bounds, creates new frozen PIDState preserving all other fields. Guards: no-op for state is None, dt ≤ 0, kp == 0, or zero correction.

2. core/zone.pyZoneRuntime.apply_period_end_back_calculation()

Zone-level orchestration. Computes u_actual from used_duration, reads u_commanded from commanded_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 hook

In handle_observation_period_transition(), the new_period block now:

  1. Apply correction — uses old used_duration and commanded_duty_cycle before reset
  2. Reset — clears used_duration for the new period
  3. Snapshot — captures current duty cycle as commanded_duty_cycle for next period

Skips 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_commanded causes 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

  • Per-period correction: Applied once at observation period boundary (every 2 hours), matching the actuator's time granularity
  • Kt = ki/kp (auto-computed): No new config parameter. Equals 1/Ti per Åström & Hägglund
  • Skip first period: First period transition after startup covers a partial period
  • Skip disabled zones: PID is paused when disabled, no windup to correct
  • No schema/migration changes: Only one new default-zero field on ZoneState

Test plan

  • TestApplySaturationCorrection (8 tests) — PID-level: exact math, guards (no state, zero/negative dt, zero kp), clamping, field preservation, no-op when equal, positive correction
  • TestZoneRuntimeBackCalculation (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 0
  • TestHandleObservationPeriodTransition (2 new tests) — Controller-level: correction + snapshot + reset ordering on period transition, first period skips correction but takes snapshot
  • All 603 tests pass, 100% diff coverage

🤖 Generated with Claude Code

@codecov
Copy link
Copy Markdown

codecov Bot commented Feb 10, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 96.69%. Comparing base (9b36085) to head (5a9bcce).
✅ All tests successful. No failed tests found.

Additional details and impacted files

Impacted file tree graph

@@            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              
Files with missing lines Coverage Δ
custom_components/ufh_controller/coordinator.py 94.74% <100.00%> (+0.06%) ⬆️
...ustom_components/ufh_controller/core/controller.py 100.00% <100.00%> (ø)
custom_components/ufh_controller/core/pid.py 100.00% <100.00%> (ø)
custom_components/ufh_controller/core/zone.py 100.00% <100.00%> (ø)

@lnagel lnagel force-pushed the back-calculation-anti-windup branch 4 times, most recently from f0faba1 to d8d2ae4 Compare February 14, 2026 16:39
lnagel and others added 5 commits February 22, 2026 13:20
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>
@lnagel lnagel force-pushed the back-calculation-anti-windup branch from d8d2ae4 to 5b6d70a Compare February 22, 2026 11:20
lnagel and others added 2 commits February 22, 2026 13:36
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant