Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

RTU-0003 — Discharge and mixed air temperature inconsistency (AFDD0)

Statusverified — engine e2ff2f8, cxf:fnv1a128:bbf281b458fc8b62a9a6634ae288429e, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
Clusters
SuppressesRTU-0002, RTU-0004
Suppressed by
RelatedRTU-0002, RTU-0004, AHU-0028, RTU-0010, RTU-0011
Playbookssensor-drift
SourceHVAC FDD Reference v1.0 §11, RTU-0003; PNNL-23790 (AFDD0); Schein et al. 2006
Operating statesall active modes — idle, mechanical cooling, and heating each get their own test

Preconditions (host-enforced): Supply fan running and both sensors present; with the fan off there is no air to measure and the verdict is NO_EVAL, not healthy. The host must also hold evaluation off for min_stage_runtime (10 min) after any compressor or heater stage change, while the coil or heat exchanger is still coming up to temperature and the two sensors legitimately disagree with the new state.

Points: sat, mat, comp_status, htg_status

Outputs:

  • yFault — True while the supply/mixed air temperature relationship has contradicted the compressor and heater state for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
consistency_threshold3.0°CidleDev.tMaximum
cooling_direction_threshold1.0°CcoolDir.tHow far supply air may rise above mixed air with a compressor running before the cooling direction counts as contradicted
heating_direction_threshold1.0°ChtgDir.tHow far supply air may fall below mixed air with heat energized before the heating direction counts as contradicted
alarm_delay1800.0spersist.delayTimeContinuous inconsistency required before the alarm asserts (30 min)

Description

Air leaving a rooftop unit has to be explained by what the unit is doing. With no compressor and no heat, supply air should arrive at the mixed-air condition give or take fan heat and sensor error; with a compressor running it must be colder than the mixture; with gas or electric heat energized it must be warmer. Half an hour of the temperatures saying otherwise means either a sensor is lying or the equipment is not doing what its status point claims. This is PNNL’s AFDD0 (PNNL-23790), the prerequisite the rest of the RTU chapter is built on — the diagnostics it gates read the same two sensors it is checking. Present on roughly 15% of units.

Detection Logic

dev_sm = sat − mat
idle   = NOT comp_status AND NOT htg_status

yFault = ( idle        AND |dev_sm|      > consistency_threshold       )
      OR ( comp_status AND  dev_sm       > cooling_direction_threshold )
      OR ( htg_status  AND  (mat − sat)  > heating_direction_threshold ),
         sustained for alarm_delay

Block graph (rule.cxf.jsonld):

RTU-0003 block graph

The state gating is what makes the three branches safe to OR: a 12 °C spread between supply and mixed air is a gross violation with nothing running and the expected result with the compressor on, and idle holds the magnitude test off in every active mode so the same numbers are never read by two branches with opposite expectations. All three comparisons are strict > against positive thresholds, so a deviation sitting exactly on one reads healthy — 3.0 °C while idle is not a fault, 3.1 °C is. persist requires 30 continuous minutes and resets on any tick where no branch is violated, which rides out the pull-down after a stage start. A violation that hands off from one branch to another within a tick does not reset the timer — the right reading of the physics, and also why the host’s min_stage_runtime precondition earns its keep, since the minutes after a stage change are when a lagging sensor is most likely to carry a stale disagreement across the handoff. delayOnInit = true holds the window across a controller restart.

Possible Diagnoses

  1. sat or mat sensor out of calibration
  2. Supply air temperature sensor in the wrong location — reading a stratified slice of the discharge, or radiant heat from the heat exchanger
  3. Compressor running with no refrigerant flow: lost charge, failed compressor, or a stuck reversing valve on a heat pump
  4. Heater energized with no heat output: failed ignition, tripped high-limit, closed gas valve, or an open electric heat element
  5. Sensor wiring: swapped sat and mat leads, a shorted or open sensor, or a status point wired to the wrong stage

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. Nothing here is directly computable: a mis-read temperature burns no fuel by itself, and a compressor running without refrigerant burns plenty but this rule has no capacity data to price it. PNNL EEM-01 (sensor recalibration) covers the sensor half at 0–5% of site energy across a whole sensor population. The value of the rule is the accuracy it restores to RTU-0002 and RTU-0004, which read the same two sensors, plus the mechanical failures it catches on the way.

Emissions Impact

QUALITATIVE_EMISSIONS, LOW confidence. Scope is recorded as 1|2 because it depends on which half of the unit is wrong: a heating-branch violation on a gas-fired RTU points at scope 1 combustion, a cooling-branch violation at scope 2 electricity, and an idle violation at whichever subsystem the bad reading later misdirects. Avoided-emissions basis: N/A.

Deviations

  • Two Subtract blocks rather than one difference and a negation. The heating test is mat − sat against a positive threshold, i.e. −dev_sm, and CDL has no unary negate. This library keeps negative parameter values out of rule documents (precedent: AHU-0021’s designConst); algebraically identical.
  • Three independent thresholds, two of which default to the same number. The cooling and heating direction thresholds are both 1.0 °C but stay separate because the reference lists them separately and they are not the same physical quantity — evaporator approach on one side, heat exchanger effectiveness on the other.
  • min_stage_runtime (10 min) is a host precondition, not a block. It gates on a stage transition the block graph cannot see, and this library keeps state gating host-side (the treatment the G36-derived AHU rules give ModeDelay). The 30-minute persistence does not substitute: a unit that stages up and stays up starts the timer at the moment of the change.
  • Fan-running is a precondition too. With the fan off both sensors read stagnant air in different parts of a cabinet and the comparison means nothing.
  • Suppression is declared, not encoded. The reference’s “when active, suppresses RTU-0002 and RTU-0004” lives in suppresses and is enforced by the host; the engine is status-blind and each rule is an independent composite. Same treatment as AHU-0028, whose CLU-09 role this rule plays for the RTU chapter without being a cluster member — the reference defines no RTU sensor-integrity cluster, so clusters is empty rather than reusing an AHU cluster ID.
  • Simultaneous heating and cooling is reported, not refereed. With both statuses true the idle branch is held off and both directional branches evaluate, so whichever direction the temperatures contradict raises the alarm. A unit heating and cooling at once is already broken; deciding which half is at fault belongs to whoever opens the panel.
  • persist.delayOnInit = true (CDL default is false), the library’s standing choice: an inconsistency already present when the controller starts waits out the full 30 minutes rather than alarming on the first tick.

Notes

The suppression contract is the point of this card. While yFault is true, RTU-0002 and RTU-0004 are computing on numbers known to be wrong and the host must report them as NO_EVAL rather than healthy — a silenced rule is not a passing rule. RTU-0002 consumes sat in its temperature split against an 8 °C baseline, where a 3 °C sensor bias moves the ratio by 37 percentage points: the entire distance from a clean coil to an alarm.

Check the sensors first, because it is the cheap end — two thermometers and ten minutes settle whether sat and mat agree with reality, and the sensor-drift playbook covers the fix ($30–$80 per sensor). Only once both read true does the alarm point at diagnoses 3 and 4, and then it means a compressor or a heater is running and producing nothing.

Test Vectors

17 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
idle_consistentReference vector: nothing running, sat 22 °C equals mat 22 °C — air passes through the unit unchanged
idle_inconsistentReference vector: nothing running but sat 22 °C sits 6 °C below mat 28 °C — no heat transfer surface is active, so one of the two sensors is wrong
cooling_consistentReference vector: compressor on, sat 13 °C is 12 °C below mat 25 °C — the evaporator is doing exactly what it should
cooling_inconsistentReference vector: compressor on but sat 27 °C is 2 °C ABOVE mat 25 °C — the coil is adding heat while nominally cooling (lost charge, failed compressor, or a swapped sensor)
heating_consistentHeating branch, healthy: burner on, sat 35 °C is 15 °C above mat 20 °C
heating_inconsistentHeating branch, faulted: burner energized but sat 18 °C is 4 °C below mat 22 °C — heat is called for and none arrives (failed ignition, tripped limit, or no gas)
edge_idle_deviation_equals_thresholdThreshold edge:
edge_idle_deviation_just_over_thresholdThreshold edge:
edge_cooling_deviation_equals_thresholdThreshold edge: sat − mat = 1.0 °C exactly with the compressor on; strict comparison, no fault
edge_cooling_deviation_just_over_thresholdThreshold edge: sat − mat = 1.1 °C with the compressor on alarms after alarm_delay
edge_heating_deviation_equals_thresholdThreshold edge: mat − sat = 1.0 °C exactly with the burner on; strict comparison, no fault
edge_heating_deviation_just_over_thresholdThreshold edge: mat − sat = 1.1 °C with the burner on alarms after alarm_delay
idle_threshold_not_applied_while_coolingMode conditionality: the same 12 °C spread that would be a gross idle violation is normal cooling, and the idle branch is held off by comp_status
simultaneous_heating_and_coolingCompressor and burner both on with sat 30 °C above mat 25 °C: the heating branch is satisfied, the cooling branch is contradicted, and the rule reports the contradiction rather than refereeing the mode
transient_inconsistency_never_alarmsIdle inconsistency lasts 1200 s — shorter than alarm_delay — then clears; the persistence timer resets and no alarm is raised
stage_start_resets_persistenceAn idle inconsistency accumulates for 900 s, then the compressor starts and the temperatures become consistent for cooling; the idle branch drops out and the timer restarts, so the earlier violation never matures into an alarm
fault_clears_on_recoverySustained idle inconsistency alarms after 1800 s; the alarm drops on the tick the sensors agree again (mat recalibrated at t = 3600 s)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "idle_consistent",
      "description": "Reference vector: nothing running, sat 22 \u00b0C equals mat 22 \u00b0C \u2014 air passes through the unit unchanged",
      "inputs": {
        "sat": 22.0,
        "mat": 22.0,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "idle_inconsistent",
      "description": "Reference vector: nothing running but sat 22 \u00b0C sits 6 \u00b0C below mat 28 \u00b0C \u2014 no heat transfer surface is active, so one of the two sensors is wrong",
      "inputs": {
        "sat": 22.0,
        "mat": 28.0,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "cooling_consistent",
      "description": "Reference vector: compressor on, sat 13 \u00b0C is 12 \u00b0C below mat 25 \u00b0C \u2014 the evaporator is doing exactly what it should",
      "inputs": {
        "sat": 13.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cooling_inconsistent",
      "description": "Reference vector: compressor on but sat 27 \u00b0C is 2 \u00b0C ABOVE mat 25 \u00b0C \u2014 the coil is adding heat while nominally cooling (lost charge, failed compressor, or a swapped sensor)",
      "inputs": {
        "sat": 27.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_consistent",
      "description": "Heating branch, healthy: burner on, sat 35 \u00b0C is 15 \u00b0C above mat 20 \u00b0C",
      "inputs": {
        "sat": 35.0,
        "mat": 20.0,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_inconsistent",
      "description": "Heating branch, faulted: burner energized but sat 18 \u00b0C is 4 \u00b0C below mat 22 \u00b0C \u2014 heat is called for and none arrives (failed ignition, tripped limit, or no gas)",
      "inputs": {
        "sat": 18.0,
        "mat": 22.0,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_idle_deviation_equals_threshold",
      "description": "Threshold edge: |sat \u2212 mat| = 3.0 \u00b0C exactly while idle; the comparison is strict, so no fault",
      "inputs": {
        "sat": 22.0,
        "mat": 25.0,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_idle_deviation_just_over_threshold",
      "description": "Threshold edge: |sat \u2212 mat| = 3.1 \u00b0C while idle clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "sat": 22.0,
        "mat": 25.1,
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_cooling_deviation_equals_threshold",
      "description": "Threshold edge: sat \u2212 mat = 1.0 \u00b0C exactly with the compressor on; strict comparison, no fault",
      "inputs": {
        "sat": 26.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_cooling_deviation_just_over_threshold",
      "description": "Threshold edge: sat \u2212 mat = 1.1 \u00b0C with the compressor on alarms after alarm_delay",
      "inputs": {
        "sat": 26.1,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "edge_heating_deviation_equals_threshold",
      "description": "Threshold edge: mat \u2212 sat = 1.0 \u00b0C exactly with the burner on; strict comparison, no fault",
      "inputs": {
        "sat": 21.0,
        "mat": 22.0,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "edge_heating_deviation_just_over_threshold",
      "description": "Threshold edge: mat \u2212 sat = 1.1 \u00b0C with the burner on alarms after alarm_delay",
      "inputs": {
        "sat": 20.9,
        "mat": 22.0,
        "comp_status": false,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "idle_threshold_not_applied_while_cooling",
      "description": "Mode conditionality: the same 12 \u00b0C spread that would be a gross idle violation is normal cooling, and the idle branch is held off by comp_status",
      "inputs": {
        "sat": 13.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "simultaneous_heating_and_cooling",
      "description": "Compressor and burner both on with sat 30 \u00b0C above mat 25 \u00b0C: the heating branch is satisfied, the cooling branch is contradicted, and the rule reports the contradiction rather than refereeing the mode",
      "inputs": {
        "sat": 30.0,
        "mat": 25.0,
        "comp_status": true,
        "htg_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_inconsistency_never_alarms",
      "description": "Idle inconsistency lasts 1200 s \u2014 shorter than alarm_delay \u2014 then clears; the persistence timer resets and no alarm is raised",
      "inputs": {
        "sat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 600,
            "value": 28.0
          },
          {
            "t": 1800,
            "value": 22.0
          }
        ],
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "stage_start_resets_persistence",
      "description": "An idle inconsistency accumulates for 900 s, then the compressor starts and the temperatures become consistent for cooling; the idle branch drops out and the timer restarts, so the earlier violation never matures into an alarm",
      "inputs": {
        "sat": [
          {
            "t": 0,
            "value": 22.0
          },
          {
            "t": 900,
            "value": 13.0
          }
        ],
        "mat": 28.0,
        "comp_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ],
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "Sustained idle inconsistency alarms after 1800 s; the alarm drops on the tick the sensors agree again (mat recalibrated at t = 3600 s)",
      "inputs": {
        "sat": 22.0,
        "mat": [
          {
            "t": 0,
            "value": 28.0
          },
          {
            "t": 3600,
            "value": 22.0
          }
        ],
        "comp_status": false,
        "htg_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3300,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}