RTU-0003 — Discharge and mixed air temperature inconsistency (AFDD0)
| Status | verified — engine e2ff2f8, cxf:fnv1a128:bbf281b458fc8b62a9a6634ae288429e, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | LOW |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | RTU-0002, RTU-0004 |
| Suppressed by | — |
| Related | RTU-0002, RTU-0004, AHU-0028, RTU-0010, RTU-0011 |
| Playbooks | sensor-drift |
| Source | HVAC FDD Reference v1.0 §11, RTU-0003; PNNL-23790 (AFDD0); Schein et al. 2006 |
| Operating states | all 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:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
consistency_threshold | 3.0 | °C | idleDev.t | Maximum |
cooling_direction_threshold | 1.0 | °C | coolDir.t | How far supply air may rise above mixed air with a compressor running before the cooling direction counts as contradicted |
heating_direction_threshold | 1.0 | °C | htgDir.t | How far supply air may fall below mixed air with heat energized before the heating direction counts as contradicted |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous 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):
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
satormatsensor out of calibration- Supply air temperature sensor in the wrong location — reading a stratified slice of the discharge, or radiant heat from the heat exchanger
- Compressor running with no refrigerant flow: lost charge, failed compressor, or a stuck reversing valve on a heat pump
- Heater energized with no heat output: failed ignition, tripped high-limit, closed gas valve, or an open electric heat element
- Sensor wiring: swapped
satandmatleads, 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
Subtractblocks rather than one difference and a negation. The heating test ismat − satagainst 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’sdesignConst); 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
suppressesand 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, soclustersis 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 isfalse), 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.
| Scenario | Description |
|---|---|
idle_consistent | Reference vector: nothing running, sat 22 °C equals mat 22 °C — air passes through the unit unchanged |
idle_inconsistent | Reference 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_consistent | Reference vector: compressor on, sat 13 °C is 12 °C below mat 25 °C — the evaporator is doing exactly what it should |
cooling_inconsistent | Reference 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_consistent | Heating branch, healthy: burner on, sat 35 °C is 15 °C above mat 20 °C |
heating_inconsistent | Heating 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_threshold | Threshold edge: |
edge_idle_deviation_just_over_threshold | Threshold edge: |
edge_cooling_deviation_equals_threshold | Threshold edge: sat − mat = 1.0 °C exactly with the compressor on; strict comparison, no fault |
edge_cooling_deviation_just_over_threshold | Threshold edge: sat − mat = 1.1 °C with the compressor on alarms after alarm_delay |
edge_heating_deviation_equals_threshold | Threshold edge: mat − sat = 1.0 °C exactly with the burner on; strict comparison, no fault |
edge_heating_deviation_just_over_threshold | Threshold edge: mat − sat = 1.1 °C with the burner on alarms after alarm_delay |
idle_threshold_not_applied_while_cooling | Mode 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_cooling | Compressor 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_alarms | Idle inconsistency lasts 1200 s — shorter than alarm_delay — then clears; the persistence timer resets and no alarm is raised |
stage_start_resets_persistence | 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 |
fault_clears_on_recovery | Sustained 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
}
]
}
]
}