AHU-0032 — SAT too high vs RAT in cooling
| Status | verified — engine e2ff2f8, cxf:fnv1a128:f49e1d4d822154e274cde27cdf1ec318, 2026-08-18 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | CLU-01 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0012, AHU-0007, AHU-0013 |
| Playbooks | sensor-drift, simultaneous-hc |
| Source | Bushby, Castro, Schein, House (2001), NIST task report for CEC PIER Project 2.3 ‘Air Handling Unit and VAV Box Diagnostics’, §4.2 Table 1 rules 6, 12 and 17 — the rule expression, and Table 2’s zone-subsystem grouping recording that the three are identical; Same report, §4.2.3 — the threshold defaults: εt = 1.7 °C (3 °F) for every temperature-comparison rule, ∆Trf = 1.1 °C (2 °F) for the return-fan rise; House, Vaezi-Nejad, Whitcomb (2001), ‘An Expert Rule Set for Fault Detection in Air-Handling Units’, ASHRAE Transactions 107(1) — the paper the PIER report credits with deriving APAR; not consulted for this card; Sibling precedent: AHU-0012 (the MAT-based rise test this card mirrors onto RAT), AHU-0005 (the same comparison with the heating-mode sign), HW-0004 (library-extension framing, assembled limit); Library extension: the HVAC FDD Reference v1.0 index (§5.8.1) runs to AHU-0031 — see faults/ahu/README.md |
| Operating states | APAR Modes 2-4, equivalently G36 OS#2-#4 (any cooling-side state) — host-gated |
Preconditions (host-enforced): Supply fan running, the unit occupied, and the unit in a cooling-side state identified the way APAR identifies it — from the actuator signals alone: heating valve closed and either the OA damper modulating between minimum and full with both coils off (Mode 2 / OS#2), or the cooling valve open with the OA damper at 100% (Mode 3 / OS#3) or at minimum (Mode 4 / OS#4). Suspend evaluation for a mode-transition window (30 min, G36’s ModeDelay) after any change of mode or operating state, while the actuators are still stroking.
ratmust be the air actually coming back from the zones this AHU serves: a single return sensor on a multi-zone unit reads a flow-weighted mixture and this rule inherits that averaging, and a sensor sitting in a ceiling plenum reads roof and lighting heat rather than the space, which biases the comparison toward silence.return_fan_risemust match the installation, not the shipped 1.1 °C — set it to 0 on a unit with no return fan and on any unit whose return-air sensor is upstream of the return fan, since in both cases there is no fan heat in the reading to credit back. Both temperatures must be in °C; the rule converts nothing. Unlike its MAT-based cousins this rule needs no mixed-air sensor and is not disturbed by a faulted one, so AHU-0028 does not silence it. When any gate is unmet the verdict is NO_EVAL, not healthy.
Outputs:
yFault— True while supply air has stayed more than epsilon_t above the return-air temperature corrected for return_fan_rise, continuously for at least alarm_delay. The rule’s only output — it has no evaluability flag, because it has no in-rule gate; every condition under which the verdict is NO_EVAL is a host precondition
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
epsilon_t | 1.7 | °C | tooWarm.t | Temperature-comparison allowance. 1.7 °C (3 °F) is the value APAR §4.2.3 applies flat to every one of its temperature-comparison rules, covering the combined uncertainty of the two sensors being compared. The report calls the number heuristic and names uncertainty composition (εt = εT1 + εT2) as the more rigorous replacement it had not yet adopted — a site with calibrated sensors composes its own sum, exactly as the G36-lineage cards in this chapter do |
return_fan_rise | 1.1 | °C | excess.p | Temperature rise across the return fan, credited back so the comparison is against the air the zones returned rather than the air after the fan has warmed it. 1.1 °C (2 °F) is APAR §4.2.3’s typical value (∆Trf), which the report offers as a fixed stand-in for a model correlated to airflow or fan signal. SITE VALUE: set it to 0 on a unit with no return fan and on any unit whose return-air sensor is mounted upstream of the fan |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous violation required before the alarm asserts (30 min). ADOPTED — APAR specifies no alarm persistence; it evaluates its rules on hourly data. 30 min matches the AHU comparison family in this chapter (AHU-0005, AHU-0012, AHU-0013) |
Description
An air handler in a cooling mode has one job at the air stream: deliver air
colder than the space it serves. Return air is the best available measure of
that space, once the return fan’s own heat is taken back off the reading. When
supply air is not below that corrected return temperature, the unit is running
its fans and — in the mechanical modes — its coil, and the building is getting
no cooling out of the exchange. The distinct thing about this rule is the sensor
it does not need: nine of APAR’s 28 rules drop out entirely on a unit with no
mixed-air sensor, and G36 marks the equivalent MAT test (AHU-0012) omit if no MAT sensor, so on that large population a SAT-versus-RAT comparison is the
only whole-unit temperature check left. The trade is resolution — this one
brackets the whole loop, return path included, so a dead coil, a heating source
that never shut off, a warm-air economizer, or either sensor being wrong all
land in the same alarm. Library extension: the reference’s AHU index stops at
AHU-0031, so the logic and both threshold defaults come from APAR rules 6, 12
and 17 and the graph shape from AHU-0012.
Detection Logic
gap = sat − rat
excess = gap + return_fan_rise (= sat − (rat − return_fan_rise))
yFault = excess > epsilon_t,
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
APAR writes the rule as Tsa > Tra − ∆Trf + εt, a threshold on supply air that
moves with the return temperature. Moving ∆Trf to the other side turns it into a
threshold on a difference — (Tsa − Tra) + ∆Trf > εt — and that is what the
graph computes: gap for the difference, excess to credit the return-fan rise
back, tooWarm against the 1.7 °C allowance. The two source constants stay
separate parameters rather than collapsing into one 0.6 °C threshold, because
they are retuned for unrelated reasons: return_fan_rise is a fact about the
installation and is 0 on any unit without a return fan, while epsilon_t is a
sensor-uncertainty allowance. The comparison is strict, as in APAR, and at the
shipped defaults the boundary is not decidable in floating point (see
Deviations) — an ambiguity of femtokelvins on instruments rated to ±0.5 K.
persist requires 30 continuous minutes, which separates a unit that cannot
cool from a chilled-water valve still stroking after a mode change; recovery is
immediate, and delayOnInit = true holds the window across a restart.
Possible Diagnoses
Library-authored. APAR detects rather than diagnoses; §4.2.2’s broad classes read against this comparison give:
- Cooling coil valve stuck closed, or an actuator that no longer strokes
- Chilled water unavailable or too warm at the coil — a plant problem, and the case where every AHU on the plant reports together
- DX stage or compressor not running when the sequence says it should be
- Coil fouled, air-bound, or too small for the load it now serves
- Heating source still active in a cooling mode — a leaking valve or a stage that never shut off; the simultaneous-heating-and-cooling diagnosis
- Economizer holding outdoor air warmer than the building (AHU-0009 tests that directly against setpoint)
- SAT sensor reading high, or RAT sensor reading low — nothing in the rule says which of the two moved
- Return-air sensor not measuring the space: ceiling-plenum mounted, or on a unit whose zones no longer return through the path it sits in
Energy Impact
EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The first cost is the
whole fan energy of a unit conditioning nothing; in Modes 3 and 4 mechanical
cooling is being paid for on top, and downstream VAV boxes that never see their
zones satisfied drive dampers open and reheat on.
undelivered_cooling_kw = supply_airflow_m3s × 1.2 × 1.005 × (sat − (rat − return_fan_rise)) sizes the sensible capacity the unit should be
removing from the return stream and is instead adding to it; design airflow
standing in for a measured one is what keeps it a proxy. The 2–5% savings range
is carried across from AHU-0012’s reference row, since APAR publishes no
savings figures. MEDIUM because the rule cannot separate its waste diagnoses
from its sensor diagnoses — a SAT sensor reading 3 K high draws this trace and
wastes nothing. Cooling-dominant.
Emissions Impact
Scope 2, PROXY_EMISSIONS, MEDIUM confidence. The dominant term is purchased
electricity — fans moving air that does no work, plus chiller or compressor
energy in the mechanical modes — so a marginal operating emissions rate is the
right basis, and the load lands across occupied daytime hours where that rate is
highest in most grids. Diagnosis 5 is the exception: a gas or electric heating
source that never shut off gives the fault a scope 1 half, matching AHU-0012’s
1+2. The frontmatter records the scope this rule usually carries.
Deviations
- This rule is a library extension, not a transcription: the reference’s AHU index (§5.8.1) stops at AHU-0031. The rule expression and both threshold defaults are APAR’s; the ID, name, severity, phase, category, energy figures and diagnosis list are authored here, as in HW-0004.
- The scope is cooling-only and there is no heating mirror to write. APAR places the return-air comparison in Modes 2–4 and nowhere else, and Mode 1 has no return-air rule at all. The neighbouring coil-subsystem group (rules 1, 7, 11, 16) does flip its relational sign by mode, which makes a signed-by-mode reading of this rule a natural but wrong guess.
- Three APAR rules, one card: rules 6, 12 and 17 differ only in the mode they are evaluated under, and mode applicability is host-side here. Precedent — AHU-0012 spans rules 11 and 16, AHU-0013 spans 13 and 19.
- Rearranged into gap form with the two constants kept separate rather than
pre-composed into a single 0.6 °C threshold (as AHU-0012 composes its
three). One of the two terms is 0 for an entire population — units with no
return fan, and units whose return-air sensor sits upstream of it — and asking
a host to recompute
1.7 − 0.0by hand is worse than one extra block. - The strict
>is APAR’s own, but at the shipped defaults the boundary is not decidable: near room temperature the difference of two doubles moves in steps of ~3.6 × 10⁻¹⁵ K, so reachable values ofexcessstraddlefl(1.7)without hitting it and a nominal 0.60 K gap reads healthy or faulted depending on which operands produced it. Both sides are pinned as vectors so the behaviour cannot change silently; the ambiguity is femtokelvin-scale. - Instantaneous samples with a persistence timer, against APAR’s hourly evaluation. The two are not equivalent: a supply temperature oscillating about the allowance never alarms here, because persistence restarts on every compliant tick. A steady offset — what a dead coil and a drifted sensor both produce — reads the same either way.
alarm_delay = 1800 sis adopted; APAR specifies no alarm persistence. 30 minutes is what AHU-0005, AHU-0012 and AHU-0013 use for the same class of comparison, and rides out a chilled-water valve stroking after a mode change.- Mode gating is host-side, matching the source’s own architecture: APAR classifies its five modes from the valve and damper signals alone, then selects rules by mode and evaluates them on temperatures. Nothing about the mode appears in this graph, and a verdict outside Modes 2–4, outside occupancy, or inside a transition window is NO_EVAL rather than healthy.
- Severity 3 and phase 2 are the library’s; APAR assigns no severities. Warning matches every other temperature-comparison rule in this chapter and is honest for a finding whose most likely single cause is a sensor.
- The energy profile is authored and its savings range borrowed:
category,confidenceandestimation_methodare this card’s judgment,savings_rangeis AHU-0012’s reference row carried across — the weakest number on the card, labelled as such in its own field. - No evaluability output. The rule is a single comparison with no in-rule gate,
so
yFaultis the only boundary output; everything that makes a verdict untrustworthy is a host precondition and none of it is separable in the graph. - APAR publishes rule expressions and threshold values, not test cases, so every
scenario in
vectors.jsonis authored. - The alarm-delay edge is asserted on the boundary tick rather than a step away,
against SCHEMA.md’s usual margin, because
Logical.TrueDelayasserts at exactlyT + delayTimeat the pinned engine revision and that is the fact worth pinning. persist.delayOnInit = true(the Modelica/CDL default isfalse), the library’s standing choice: a violation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.clusters: [CLU-01]on the strength of diagnosis 5, the same grounds on which AHU-0012 is a member. The dominant reading remains cooling not delivered — CLU-01 groups the investigation, it does not redefine the fault.playbookscites two:sensor-driftfirst, because diagnoses 7 and 8 are the cheapest to eliminate and among the most likely to be right, thensimultaneous-hcfor diagnosis 5.- No suppression edge to AHU-0028. The MAT-based rules are silenced while the mixing-box rule is active; this rule never reads MAT, which is the same property that makes the card worth having.
Notes
Read this rule and AHU-0012 as one test with two instruments: FC-012 brackets the coil section and localises better, this one brackets the whole unit and covers more. A MAT fault does not disturb it, so on a unit whose mixing-box sensor has failed this rule still reports while the rest of the temperature family has gone quiet.
Check the two sensors first — a portable reference against SAT and RAT costs an hour and eliminates diagnoses 7 and 8 (sensor drift playbook). Note where the return sensor sits: plenum-mounted, it picks up lighting and roof heat, reads high, and biases this comparison toward silence. If the sensors check out, the cooling valve command discriminates — wide open with no temperature drop points at diagnoses 1–4 and the plant, closed with the air warming anyway at diagnosis 5 and the simultaneous-hc playbook.
Test Vectors
12 scenarios, clock step 300 s over 5400 s.
| Scenario | Description |
|---|---|
cooling_air_well_below_the_space | Minimum-OA mechanical cooling on a warm day: the space returns at 24 °C and the coil delivers 13 °C. Supply air is 11 K colder than the return, which is the sign every cooling-side mode expects, so there is nothing to report |
supply_inside_the_fan_and_sensor_allowance | Supply air reads 0.5 K warmer than return air. The return-fan rise credit alone (1.1 K) more than covers that, so the comparison lands at 1.6 K against a 1.7 K allowance and the single conjunct is false — the rule’s only blocking condition, exercised alone |
excess_just_inside_the_allowance | Threshold edge, healthy side: a 0.59 K gap puts the comparison at 1.69 K, one hundredth of a kelvin inside epsilon_t. Reachable as an exact double from these operands, unlike a nominal 0.60 (see the next two scenarios) |
nominal_edge_operands_land_healthy | A gap of nominally 0.60 K — the exact trip line for the shipped defaults — written as 24.7 − 24.1. The subtraction lands on 0.5999999999999979, the comparison on 1.699999999999998, and the strict > reads healthy. Paired with the next scenario, which is the same nominal gap on the other side of the line |
nominal_edge_operands_land_faulted | The same nominal 0.60 K gap written as 24.6 − 24.0 lands on 0.6000000000000014 instead, the comparison on 1.7000000000000015, and the alarm asserts. The trip line falls between two doubles no realistic temperature pair can reach, so at exactly epsilon_t the verdict is decided by the ulp of the subtraction rather than by the rule — see Deviations |
excess_just_outside_the_allowance | Threshold edge, faulted side: a 0.61 K gap puts the comparison at 1.71 K, and the alarm follows one alarm_delay later. Ten millikelvin either side of the line is decided the way the equation says |
alarm_delay_edge_asserts_at_1800s | Both sides of the TrueDelay edge, pinned to the tick. A 2 K gap violates from load with delayOnInit true, so yFault is false through 1500 s and asserts at exactly 1800 s |
chilled_water_lost_during_mechanical_cooling | The unit is in a cooling mode and supply air leaves 1.5 K warmer than the return — the coil is passing no useful capacity, so the fan is delivering the building’s own heat back to it. Comparison lands at 2.6 K against 1.7 K |
return_sensor_reading_low | A plausible 22 °C supply against a 19 °C return in a building nowhere near 19 °C: the same 3 K violation, but here the return-air sensor is what is wrong. The rule reports the contradiction and cannot say which of the two sensors is lying |
transient_clears_before_alarm_delay | The gap holds at 2 K for 1200 s while the chilled-water valve strokes open after a mode change, then supply air falls to 14 °C. Anything shorter than alarm_delay never reaches the operator |
fault_clears_on_recovery | A sustained 2 K gap alarms at 1800 s; chilled water returns at t = 3000 and supply air falls to 14 °C. TrueDelay has no off-delay, so the alarm drops on that tick |
oscillating_excess_never_alarms | A cooling loop hunting on a 10-minute period drives supply air between 26 °C and 20 °C. Its mean sits well outside the allowance and an hourly evaluation of averaged data would report the fault, but persistence restarts on every compliant tick, so this rule stays silent — the limit of substituting TrueDelay for a data window (see Deviations) |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 5400
},
"scenarios": [
{
"name": "cooling_air_well_below_the_space",
"description": "Minimum-OA mechanical cooling on a warm day: the space returns at 24 \u00b0C and the coil delivers 13 \u00b0C. Supply air is 11 K colder than the return, which is the sign every cooling-side mode expects, so there is nothing to report",
"inputs": {
"sat": 13.0,
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "supply_inside_the_fan_and_sensor_allowance",
"description": "Supply air reads 0.5 K warmer than return air. The return-fan rise credit alone (1.1 K) more than covers that, so the comparison lands at 1.6 K against a 1.7 K allowance and the single conjunct is false \u2014 the rule's only blocking condition, exercised alone",
"inputs": {
"sat": 24.5,
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "excess_just_inside_the_allowance",
"description": "Threshold edge, healthy side: a 0.59 K gap puts the comparison at 1.69 K, one hundredth of a kelvin inside epsilon_t. Reachable as an exact double from these operands, unlike a nominal 0.60 (see the next two scenarios)",
"inputs": {
"sat": 24.59,
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "nominal_edge_operands_land_healthy",
"description": "A gap of nominally 0.60 K \u2014 the exact trip line for the shipped defaults \u2014 written as 24.7 \u2212 24.1. The subtraction lands on 0.5999999999999979, the comparison on 1.699999999999998, and the strict `>` reads healthy. Paired with the next scenario, which is the same nominal gap on the other side of the line",
"inputs": {
"sat": 24.7,
"rat": 24.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "nominal_edge_operands_land_faulted",
"description": "The same nominal 0.60 K gap written as 24.6 \u2212 24.0 lands on 0.6000000000000014 instead, the comparison on 1.7000000000000015, and the alarm asserts. The trip line falls between two doubles no realistic temperature pair can reach, so at exactly epsilon_t the verdict is decided by the ulp of the subtraction rather than by the rule \u2014 see Deviations",
"inputs": {
"sat": 24.6,
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "excess_just_outside_the_allowance",
"description": "Threshold edge, faulted side: a 0.61 K gap puts the comparison at 1.71 K, and the alarm follows one alarm_delay later. Ten millikelvin either side of the line is decided the way the equation says",
"inputs": {
"sat": 24.61,
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "alarm_delay_edge_asserts_at_1800s",
"description": "Both sides of the TrueDelay edge, pinned to the tick. A 2 K gap violates from load with delayOnInit true, so yFault is false through 1500 s and asserts at exactly 1800 s",
"inputs": {
"sat": 26.0,
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 1800,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "chilled_water_lost_during_mechanical_cooling",
"description": "The unit is in a cooling mode and supply air leaves 1.5 K warmer than the return \u2014 the coil is passing no useful capacity, so the fan is delivering the building's own heat back to it. Comparison lands at 2.6 K against 1.7 K",
"inputs": {
"sat": 25.5,
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "return_sensor_reading_low",
"description": "A plausible 22 \u00b0C supply against a 19 \u00b0C return in a building nowhere near 19 \u00b0C: the same 3 K violation, but here the return-air sensor is what is wrong. The rule reports the contradiction and cannot say which of the two sensors is lying",
"inputs": {
"sat": 22.0,
"rat": 19.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "transient_clears_before_alarm_delay",
"description": "The gap holds at 2 K for 1200 s while the chilled-water valve strokes open after a mode change, then supply air falls to 14 \u00b0C. Anything shorter than alarm_delay never reaches the operator",
"inputs": {
"sat": [
{
"t": 0,
"value": 26.0
},
{
"t": 1200,
"value": 14.0
}
],
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "fault_clears_on_recovery",
"description": "A sustained 2 K gap alarms at 1800 s; chilled water returns at t = 3000 and supply air falls to 14 \u00b0C. TrueDelay has no off-delay, so the alarm drops on that tick",
"inputs": {
"sat": [
{
"t": 0,
"value": 26.0
},
{
"t": 3000,
"value": 14.0
}
],
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 2700,
"equals": true
},
{
"output": "yFault",
"from_s": 3300,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "oscillating_excess_never_alarms",
"description": "A cooling loop hunting on a 10-minute period drives supply air between 26 \u00b0C and 20 \u00b0C. Its mean sits well outside the allowance and an hourly evaluation of averaged data would report the fault, but persistence restarts on every compliant tick, so this rule stays silent \u2014 the limit of substituting TrueDelay for a data window (see Deviations)",
"inputs": {
"sat": [
{
"t": 0,
"value": 26.0
},
{
"t": 600,
"value": 20.0
},
{
"t": 1200,
"value": 26.0
},
{
"t": 1800,
"value": 20.0
},
{
"t": 2400,
"value": 26.0
},
{
"t": 3000,
"value": 20.0
},
{
"t": 3600,
"value": 26.0
},
{
"t": 4200,
"value": 20.0
},
{
"t": 4800,
"value": 26.0
}
],
"rat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
}
]
}