Why Does Weather-Compensated Heating Control Drift 1 °C?

Erik Lindqvist11 min read
HMI ProgrammingOther ManufacturerTroubleshooting
Licensed PE Working through this on a live machine? A Maine-licensed engineer can take it from here — included with IMD hardware, by the hour for everything else. Book an engineer

This weather-compensated program has a 1 °C dead zone (49.5–50.5 °C) whenever the heating curve delivers a whole-degree setpoint. When the curve delivers a half-degree setpoint, it has no dead zone at all. One line causes both behaviours. It rounds the measured supply temperature to an integer before comparing it with a REAL setpoint:

temp_pod_i := REAL_TO_INT(temp_pod);

The heating-curve table, the outdoor-index arithmetic and the integrator gain do not set the band width. The quantity that decides it is the fractional part of the setpoint ust_temp_f. A fraction of 0 gives a 1 °C band. A fraction of 0.5 removes the dead zone.

Fixes that leave the 1 °C band in place

Most attempts go after the parts of the program that look complicated. The fault sits in a plain assignment that looks harmless.

Attempted fix Why the band stays at 1 °C
Rewriting the heating-curve array temp[0..78] The table only supplies the setpoint. The band width comes from the rounding inside the comparison, so any whole-degree table entry still produces the 1 °C dead zone.
Suspecting the ×2 scaling or the type cast in ind_temp The index maps correctly. Index 0 corresponds to +8 °C and index 78 to −31 °C, which matches the comment rows above the table. A wrong index would pick the wrong setpoint; it would not change the band width.
Shifting every table entry to an x.5 value This narrows the band, but it moves the whole curve by 0.5 °C. It also breaks again as soon as pod_ust or a manual setpoint ust_temp_z restores a whole-degree result.
Making the integrator more aggressive (dividing by less than 500) Inside the dead zone the integrator branch never runs, so a higher gain changes nothing there. Outside it, the higher gain adds overshoot.
Changing ton1.PT from t#10m The loop fires on ton1.ET > t#30s and then resets the timer. The 10-minute preset is never reached.
Rounding the setpoint as well "for consistency" This forces every setpoint onto the integer grid and makes the 1 °C band permanent at all outdoor temperatures.
Rewriting the whole program Only two comparisons need to change. A rewrite risks the working sensor-fault handling and the retained output.

Integer rounding of the supply temperature as the deadband source

The integrator is gated by two comparisons:

  • temp_pod_i > ust_temp raises the output.
  • temp_pod_i < ust_temp lowers the output.

The step size itself is computed from the unrounded REAL value temp_pod. So the error magnitude is exact, but the decision whether to act uses a value quantized to 1 °C.

Whole-degree setpoint (e.g., 50.0 °C). Every supply reading from roughly 49.5 to 50.5 °C rounds to 50. Neither comparison is true, so NaprReg freezes. The supply temperature then wanders across that full 1 °C span, driven by load changes, with no correction.

Half-degree setpoint (e.g., 50.5 °C). An integer can never equal x.5, so one of the two branches is always true and the integrator acts every cycle. The ±0.25 °C swing observed at 50.5 °C is not a coded tolerance. It is the limit cycle of an integral-only loop working against the transport delay of the hydraulic circuit.

temp_pod (°C) temp_pod_i Setpoint 50.0: NaprReg step Setpoint 50.5: NaprReg step
49.4 49 −0.0012 −0.0022
49.6 50 none (frozen) −0.0018
50.2 50 none (frozen) −0.0006
50.4 50 none (frozen) −0.0002
50.6 51 +0.0012 +0.0002

Step values are computed as |temp_pod − ust_temp_f| / 500 per cycle, exactly as the program calculates delta. At exactly x.5 °C the result depends on how this runtime's REAL_TO_INT handles ties. That edge disappears once the comparison uses REAL values.

Heating-curve table and outdoor index arithmetic

The outdoor temperature selects a table entry through this calculation:

ind_temp := 16 - REAL_TO_INT(temp_nv * 2);
  • Each index step is 0.5 °C outdoor.
  • Index 16 corresponds to 0 °C outdoor, which gives a 40.0 °C supply setpoint.
  • The index is clamped to 0–78. Outdoor temperatures of +8 °C and above use 35 °C. Outdoor temperatures of −31 °C and below use 68 °C.

On the sloped parts of the curve the table alternates between whole and half degrees at every index step. So the control band flips between 1 °C and zero dead zone each time the outdoor reading crosses a 0.5 °C boundary. That is why the precision appears to depend on the weather.

Outdoor (°C) ind_temp temp[ind] (°C) Band with current code
+7.5 to +4.0 1–8 36.0 (plateau) 1 °C throughout
−2.0 20 42.0 1 °C
−2.5 21 42.5 no dead zone
−9.0 to −10.0 34–36 49.0 (plateau) 1 °C
−11.0 to −12.0 38–40 50.0 (plateau) 1 °C
−13.0 to −14.0 42–44 51.0 (plateau) 1 °C

Plateaus. Between −9 and −14 °C most entries are whole degrees, so the loop sits on the 1 °C band for most of that range. The curve is also flatter there: about 0.2 °C of supply per 0.5 °C outdoor, against 0.5 °C elsewhere. Check that shape against the building's heating design separately. It affects the supply setpoint, not the precision.

Offsets. The final setpoint is temp[ind_temp] + pod_ust. Open the global variable list and read the declared types of pod_ust and ust_temp_z.

  • If pod_ust is an INT, it never changes the whole/half parity of the table value.
  • The commented-out declarations show an earlier INT version of ust_temp_z. If the active declaration is still INT, manual mode (mode = 0) has always run with the 1 °C dead zone.

Integrating output stage: step size, cycle time and clamps

The regulator is a floating, integral-only controller with no proportional term. It works as follows:

  • Cycle time. Every time ton1.ET exceeds 30 s, the program adds (temp_pod − ust_temp_f)/500 to NaprReg, then resets ton1. The effective cycle is about 30 s plus one scan.
  • Clamps. NaprReg is limited to 0.0–0.9.
  • Output. NaprReg is sent out as napr_s = NaprReg × 1000 (0–900).
  • Display. NaprReg is shown as Uуп = NaprReg × 10 (0–9.0).

Signal direction. The output rises when the supply is above setpoint. For the loop to work, a higher signal must therefore reduce heat input, meaning the actuator path is reverse-acting. If the actuator or its wiring changes, re-check this sign.

Integration rate (derived from the 30 s cycle and the /500 divisor):

  • At a 0.25 °C error, it moves 0.0005 per cycle, or 0.06 per hour. On the display this is a change of 0.005 in Uуп per cycle.

Because corrections this small are slow, a dead zone lets the temperature drift freely until the error crosses its edge. Removing the dead zone does not make the loop faster. It only makes it start correcting sooner.

Quantity Value or limit in the code Where to read it
Supply temperature temp_pod Target band ±0.25 °C around setpoint Online monitor (the Тп display shows only one decimal)
Setpoint ust_temp_f temp[ind_temp] + pod_ust in mode 1; ust_temp_z in mode 0 Online monitor
Outdoor index ind_temp 0–78, 0.5 °C per step, 16 = 0 °C Online monitor
Output NaprReg 0.0–0.9; retained in NaprReg_r Uуп on the display (× 10) or napr_s (× 1000)
Integrator step error / 500 per cycle Watch delta online
Cycle period about 30 s ton1.ET
Sensor status 2000 / 2001 / 2002 = fault temp_pod_s, temp_nv_s

Code change for a fixed 0.5 °C band

First decide how large the dead zone should be.

  • Half-width 0.0. This compares REAL against REAL with no dead zone. It reproduces the half-degree behaviour at every setpoint, and the valve moves every 30 s cycle.
  • Half-width 0.25. This gives an explicit total band of 0.5 °C. The actuator rests while the supply temperature is within ±0.25 °C, which reduces valve wear, at the cost of a small fixed tolerance.

Either way, the band width no longer depends on the fractional part of the setpoint.

  1. Back up the project and write down the current values of NaprReg_r, mode, pod_ust and ust_temp_z.
  2. Add two local variables to the VAR block of PLC_PRG: err : REAL; and db_half : REAL := 0.25;. Use 0.0 for the no-dead-zone variant.
  3. Replace the whole IF ton1.ET > t#30s THEN ... END_IF; block with the code below. The line temp_pod_i := REAL_TO_INT(temp_pod); can stay for other uses, but nothing in the control decision should read it.
  4. Compile and download. NaprReg is restored from the retained NaprReg_r, so the valve position carries over.
  5. Run the tests in the last section before leaving the loop in automatic mode.
IF ton1.ET > t#30s THEN
  IF (temp_pod_s < 2000) AND ((temp_nv_s < 2000) OR (mode = 0)) THEN
    err := temp_pod - ust_temp_f;          (* REAL vs REAL, no rounding *)
    IF err > db_half THEN
      delta := err / 500;
      NaprReg := NaprReg + delta;
    ELSIF err < -db_half THEN
      delta := -err / 500;
      NaprReg := NaprReg - delta;
    END_IF;
    IF NaprReg > 0.90 THEN NaprReg := 0.90; END_IF;
    IF NaprReg < 0.0 THEN NaprReg := 0.0; END_IF;
  END_IF;
  ton1(IN := FALSE);
END_IF;

The minimal edit gives the same result as db_half := 0.0. In the original two IF conditions, replace temp_pod_i > ust_temp with temp_pod > ust_temp_f, and temp_pod_i < ust_temp with temp_pod < ust_temp_f. Nothing else changes.

Sensor-fault gating and retained output behaviour

Each analog value passes through DECODE_FLOAT. Its _ERR output is mapped to status sentinels:

_ERR Meaning in the program Status value
12 Sensor short circuit 2000
13 Sensor open circuit 2001
6–11, 14, 15 Controller error 2002

Any status of 2000 or higher on the supply sensor stops the integrator. NaprReg then holds its last value while the supply temperature drifts. An outdoor-sensor fault stops the integrator only in mode 1; mode 0 bypasses it because it does not use the curve. DEF_VALUE feeds back the last good reading, so the display can look plausible while the loop is frozen. Check temp_pod_s and temp_nv_s, not just the displayed temperatures.

Restart pitfall. On first start the program replaces a retained NaprRegof exactly 0.0 with 0.5. A loop that had legitimately driven to its lower clamp therefore restarts at mid-range after a power cycle, and the supply temperature jumps until the integrator winds back down. If this matters on site, replace the equality test with a separate retained BOOL that records whether the output was ever initialised.

Confirming the supply temperature stays inside ±0.25 °C

Test first in manual mode, so the outdoor temperature cannot move the setpoint during the test.

  1. Set mode = 0 and ust_temp_z = 50 (a whole-degree value). This is the case that failed before the change.
  2. Log temp_pod, err and NaprReg once per 30 s cycle for several hours under steady load. With db_half = 0.25, temp_pod should settle between 49.75 and 50.25 °C, and NaprReg should stop changing while it stays there.
  3. Repeat with a half-degree value such as 50.5, if ust_temp_z accepts one. The band must be the same width.
  4. Return to mode = 1 and watch at least one outdoor transition across a 0.5 °C boundary. The band should no longer change when ind_temp steps.
  5. Confirm that NaprReg is not pinned at 0.0 or 0.9 during the test. A pinned output means the actuator or heat source has run out of range, and no code change can hold the band in that condition.

If the band is still wide after the change, work out whether the cause is logic or physics:

Symptom Cause Deciding check
1 °C band only at whole-degree setpoints Integer rounding in the comparison (logic) Fractional part of ust_temp_f; confirm no decision reads temp_pod_i
Band wider than 0.5 °C at every setpoint, slow oscillation over many cycles Integrator acting against hydraulic transport delay (physics) Compare the oscillation period with the 30 s cycle; if it spans many cycles, reduce the step (larger divisor) or widen db_half
Temperature off setpoint with NaprReg at 0.0 or 0.9 Actuator stroke or heat-source capacity limit (physics) Actuator position feedback; primary supply temperature
NaprReg frozen while temperature drifts Sensor fault gating temp_pod_s / temp_nv_s of 2000 or higher
Display appears to jump in 0.1 °C steps Display format %3.1f Read temp_pod in the online monitor instead

FAQ

Why does my PLC hold a 1 °C band at 50 °C but 0.5 °C at 50.5 °C?

The program rounds the supply temperature with REAL_TO_INT before comparing it with the setpoint. Every reading from about 49.5 to 50.5 °C becomes 50 and matches a 50.0 setpoint, so the integrator stops. An integer never equals 50.5, so at that setpoint the loop always acts.

Why does REAL_TO_INT create a deadband in a temperature comparison?

It quantizes the process value to 1 °C steps. Any setpoint that lands on that integer grid gets a ±0.5 °C equality zone. Compare REAL against REAL, with an explicit half-width such as 0.25 °C, to get a band that does not depend on the setpoint.

Why does the heating-curve setpoint alternate between whole and half degrees?

The table has one entry per 0.5 °C of outdoor temperature (ind_temp = 16 − REAL_TO_INT(temp_nv × 2)), and on sloped segments the entries alternate x.0 and x.5 °C. With the old code this made the band flip each time the outdoor reading crossed a 0.5 °C boundary.

Why does the control output stop changing when a sensor fails?

A DECODE_FLOAT error code of 12, 13, 6–11, 14 or 15 sets the status to 2000, 2001 or 2002, and any status of 2000 or higher blocks the integrator. NaprReg holds its last value until the sensor reads valid again. In mode 1 an outdoor-sensor fault has the same effect.

When should I stop editing the program and contact the controller manufacturer?

Escalate if DECODE_FLOAT reports controller-error codes (6–11, 14, 15) repeatedly, or if the input reading itself jumps by more than the target band while the loop is frozen. Contact the controller manufacturer's official technical support with the project file, the runtime version shown in the programming software, and a log of temp_pod, NaprReg and the status values.

Back to blog