AHU-0005 — SAT too low vs MAT in heating
| Status | verified — engine e2ff2f8, cxf:fnv1a128:23c2dc854e3fd583e7f98f40f298a24a, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 1 |
| Category | EXCESS_CONSUMPTION |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | §5.16.14 FC#5 |
| Clusters | CLU-01 |
| Suppresses | — |
| Suppressed by | AHU-0028 |
| Related | AHU-0016, AHU-0007, AHU-0028 |
| Playbooks | simultaneous-hc |
| Source | HVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#5 (text per Addendum u public review); NISTIR 7365 (defaults provenance) |
| Operating states | OS#1 (heating) — host-gated |
Preconditions (host-enforced): The unit must have a MAT sensor; G36 omits FC#5 where there is none. Evaluate only in OS#1, whose actuator signature per G36 Table 5.16.14.2 is heating coil > 0, cooling coil = 0, OA damper at minimum position — outside it a SAT below MAT is the intended result of cooling, not a fault. Supply fan running, since neither MAT nor SAT means anything in still air. Suspend evaluation for ModeDelay (30 min) after any operating-state change, while the coils and damper are still stroking and the sensors still report the previous state’s mixture. Silence this rule while AHU-0028 is active: MAT is an input here and FC-062 is its integrity gate. When any gate is unmet the verdict is NO_EVAL, not healthy.
Outputs:
yFault— True while SAT has stayed more than mat_sat_gap_threshold below MAT for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
mat_sat_gap_threshold | 3.0 | °C | gapBig.t | Shortfall of SAT below MAT that counts as heat being removed rather than sensor and fan-rise uncertainty. Composed from the G36 §5.16.14 internal variables as eSAT + eMAT − dTSF = 1 + 3 − 1; a site that retunes any of the three recomputes the sum (a larger measured fan rise lowers the threshold, a looser sensor band raises it) |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min) |
Description
In heating, air leaves the unit warmer than it arrived: it crosses the supply fan, which adds about a degree of shaft work, and then a heating coil the sequence has called for. SAT sitting several degrees below MAT under those conditions is not a control error — something in the unit is taking heat back out. A cooling coil valve leaking or stuck open, a DX circuit stuck on, or a sensor pair that disagrees by more than either instrument is rated for.
This is G36 §5.16.14 FC#5, applicable in OS#1 (heating) only; elsewhere a SAT below MAT is what the sequence asked for. The leaking-cooling-coil diagnosis is simultaneous heating and cooling seen from the air side, which is why the fault sits in CLU-01 behind AHU-0016: FC-050 catches two valve commands overlapping, this rule catches one device passing water or refrigerant with no command raised.
Detection Logic
gap = mat − sat
yFault = gap > mat_sat_gap_threshold,
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
The threshold is G36’s SAT_AVG + eSAT <= MAT_AVG − eMAT + dTSF rearranged so
one positive number carries the whole allowance (see Deviations). It is not a
sensor tolerance: it is the two sensor bands minus the fan rise the air gets
for free, so the rule fires on heat removal that neither instrument error nor
the fan can explain. The comparison is strict, so a gap sitting exactly on
3.0 °C reads healthy where G36’s <= would call it a fault. persist requires
30 minutes of continuous violation — enough to ride out a mode change, a
hot-water loop coming up to temperature, or a valve stroking through its span —
and any interruption restarts the timer.
Nothing about the size of the heating call reaches this rule. A unit whose heating coil does nothing at all still shows SAT about a fan rise above MAT, so the gap runs slightly negative and this rule reads healthy; that failure belongs to the inactive-coil rules.
Possible Diagnoses
G36 §5.16.14 FC#5, transcribed:
- SAT sensor error
- MAT sensor error
- Cooling coil valve leaking or stuck open
- Heating coil valve stuck closed or actuator failure
- Fouled or undersized heating coil
- HW temperature too low or HW unavailable
- Gas or electric heat unavailable
- DX cooling stuck on
Read the list as two groups. Either the sensors are lying (1, 2), or a heat sink is running against the heating call (3, 8) — and a dead coil behind it (4–7) makes the gap wide enough to clear the threshold sooner. Diagnoses 4 through 7 explain a missing rise on their own, but not a rise of the wrong sign, so they reach this rule only in company.
Energy Impact
EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION, all three from the reference’s §5.8.1 index row. The waste is a cancellation: the heating source pays to warm air and a leaking cooling coil or stuck DX circuit immediately unpays it, so both sides bill.
waste_kw >= supply_airflow_m3s × 1.2 × 1.005 × ((mat + dTSF) − sat)
A floor, not an equality — it credits the heating coil with no rise at all, and design airflow stands in for a measured one. The index puts the recoverable range at 1–4% of site energy against PNNL-25985 EEM-05 (supply air temperature reset), the nearest catalog measure rather than a study of this failure, so treat it as an order-of-magnitude bound. Heating-dominant: the rule is only evaluable in OS#1, so its hours are heating hours.
Emissions Impact
PROXY_EMISSIONS, MEDIUM confidence. Scope 1+2 because the two halves of the
exchange run at once and land in different inventories: hot water from a gas
boiler or furnace section is Scope 1, electric resistance or a heat pump is
Scope 2, and the cooling side eating the heat is Scope 2 in nearly every
building. Avoided-emissions basis: marginal operating emissions rate (MOER) for
the electric half, static combustion factor for the fuel half.
Deviations
- The reference card is abbreviated; G36 is the normative source. HVAC FDD Reference v1.0 carries AHU-0005 as a §5.8.1 index row only — no equation, tunables, diagnoses, operating-state applicability, or severity. Detection Logic and Possible Diagnoses are transcribed from ASHRAE Guideline 36 §5.16.14 FC#5 as it appears in Addendum u to Guideline 36-2018 (first public review, 2021), with the Table 5.16.14.5 defaults (NISTIR 7365 provenance, which the addendum notes are biased toward minimizing false alarms).
- Severity 3 is the library’s. No reference card states one and the §5.8.1 index carries no severity column; the value matches this chapter’s scaffold row and every other G36 comparison rule here.
- The energy profile is the index row’s; the emissions split is the
library’s.
category,confidence,estimation_method, andsavings_rangeare copied from §5.8.1. The index has no emissions column, soscope: 1+2follows AHU-0016’s convention for simultaneous heating-and-cooling waste (matching mirror card AHU-0012), and the runtime formula is mirrored from AHU-0016’s waste term. - Combined-epsilon threshold.
MAT − SAT >= eSAT + eMAT − dTSFbinds one positive threshold to one CXF path: 1 + 3 − 1 = 3.0 °C at the Table 5.16.14.5 defaults. A site that retunes any input recomputes the sum rather than the parameter — a measured 2 K fan rise drops the threshold to 2.0 °C, a looser eMAT of 4 K for a poorly mixed plenum raises it to 4.0 °C. Same rearrangement as AHU-0028 and AHU-0001. - G36’s
<=becomes a strict>. CDLRealsoffers no greater-or-equal comparison; the disagreement has measure zero on a real temperature pair and errs toward silence. - Instantaneous samples instead of averaged signals. G36 computes every
signal as a five-minute rolling average of one-minute samples; this rule
compares raw samples and leans on the 30-minute
persistdelay. The two are not equivalent — averaging tolerates a signal that keeps crossing back while its mean stays outside the band, whereas persistence resets on every compliant tick, so an oscillating gap can hide indefinitely. A steady offset, which is what a leaking valve or drifted sensor produces, reads the same either way. Same note as AHU-0002. - Operating-state applicability and ModeDelay are frontmatter, not graph. G36 scopes FC#5 to OS#1 and suspends every fault condition for 30 minutes after a mode change; both are host concerns under this library’s stance (precedent AHU-0029), and a verdict outside OS#1 or inside the transition window is NO_EVAL, never healthy. The OS#1 signature the host gates on is G36 Table 5.16.14.2’s: heating coil > 0, cooling coil = 0, OA damper at minimum.
- Suppression is declared, not encoded. MAT is an input, so AHU-0028 —
the mixing-box integrity gate — silences this rule while active. The engine is
status-blind, so it lives in
suppressed_byfor the host to enforce. persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: a gap already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.
Notes
This rule and AHU-0007 are the two G36 heating-side SAT tests, and they fail differently on purpose: FC-007 says the unit cannot make the air it was asked for, this one says the unit is making air colder than it found it. A fouled coil or dead boiler trips FC-007 alone; a leaking cooling valve trips both, which points at diagnosis 3 or 8 before anything else. Order of work: confirm MAT first — AHU-0028 runs a shorter delay and suppresses this rule — then take the leaking-valve half of the list to the simultaneous-hc playbook.
Test Vectors
10 scenarios, clock step 300 s over 5400 s.
| Scenario | Description |
|---|---|
heating_air_leaves_warmer_than_it_arrived | Healthy OS#1 operation: 15 °C mixed air leaves at 25 °C after the fan and an active heating coil. The gap runs negative, which no > against a positive threshold can satisfy |
sensor_spread_absorbed_by_threshold | SAT reads 2 °C below MAT — inside the combined eSAT + eMAT − dTSF band, so a pair of sensors at their rated accuracy plus a fan that adds less rise than assumed cannot fire the rule |
cooling_coil_leaking_through_heating | SAT 6 °C below MAT while the unit is in heating: a leaking or stuck-open cooling coil valve is stripping more heat than the fan and heating coil put in. Alarms one alarm_delay (1800 s) after the condition appears |
dx_stuck_on_deep_gap | SAT 10 °C below MAT — DX cooling stuck on against a heating call. Same verdict as a leaking valve; the rule reports the contradiction, not which device caused it |
gap_exactly_at_threshold | Threshold edge: mat − sat = 3.0 °C exactly. gapBig is a strict >, so a gap sitting precisely on mat_sat_gap_threshold reads healthy — G36’s <= would call this a fault |
gap_just_over_threshold | Threshold edge, other side: mat − sat = 3.1 °C clears the strict comparison and alarms after alarm_delay |
no_temperature_rise_is_not_this_fault | SAT equals MAT — the heating coil is contributing nothing, which is a real problem and not this one. The gap is zero, so this rule stays silent and the finding belongs to the inactive-coil rules |
transient_gap_clears_before_delay | Transient: a 6 °C gap opens at load and closes at t = 1200 s as the hot-water loop comes up to temperature. Twenty minutes is short of alarm_delay, so nothing alarms |
oscillating_gap_never_persists | Honesty case for the instantaneous-sampling deviation: SAT alternates every 600 s between 6 °C below MAT and 2 °C above it. The mean sits outside the band, so G36’s 5-minute rolling average would fault; persistence resets on every compliant tick and this rule never does |
fault_clears_on_recovery | A sustained 6 °C gap alarms at 1800 s; the leaking cooling valve is isolated at t = 3000 s and SAT climbs above MAT, dropping the alarm on that tick — TrueDelay has no off-delay |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 5400
},
"scenarios": [
{
"name": "heating_air_leaves_warmer_than_it_arrived",
"description": "Healthy OS#1 operation: 15 \u00b0C mixed air leaves at 25 \u00b0C after the fan and an active heating coil. The gap runs negative, which no `>` against a positive threshold can satisfy",
"inputs": {
"mat": 15.0,
"sat": 25.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "sensor_spread_absorbed_by_threshold",
"description": "SAT reads 2 \u00b0C below MAT \u2014 inside the combined eSAT + eMAT \u2212 dTSF band, so a pair of sensors at their rated accuracy plus a fan that adds less rise than assumed cannot fire the rule",
"inputs": {
"mat": 20.0,
"sat": 18.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "cooling_coil_leaking_through_heating",
"description": "SAT 6 \u00b0C below MAT while the unit is in heating: a leaking or stuck-open cooling coil valve is stripping more heat than the fan and heating coil put in. Alarms one alarm_delay (1800 s) after the condition appears",
"inputs": {
"mat": 20.0,
"sat": 14.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "dx_stuck_on_deep_gap",
"description": "SAT 10 \u00b0C below MAT \u2014 DX cooling stuck on against a heating call. Same verdict as a leaking valve; the rule reports the contradiction, not which device caused it",
"inputs": {
"mat": 18.0,
"sat": 8.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "gap_exactly_at_threshold",
"description": "Threshold edge: mat \u2212 sat = 3.0 \u00b0C exactly. `gapBig` is a strict `>`, so a gap sitting precisely on mat_sat_gap_threshold reads healthy \u2014 G36's `<=` would call this a fault",
"inputs": {
"mat": 20.0,
"sat": 17.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "gap_just_over_threshold",
"description": "Threshold edge, other side: mat \u2212 sat = 3.1 \u00b0C clears the strict comparison and alarms after alarm_delay",
"inputs": {
"mat": 20.0,
"sat": 16.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "no_temperature_rise_is_not_this_fault",
"description": "SAT equals MAT \u2014 the heating coil is contributing nothing, which is a real problem and not this one. The gap is zero, so this rule stays silent and the finding belongs to the inactive-coil rules",
"inputs": {
"mat": 18.0,
"sat": 18.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "transient_gap_clears_before_delay",
"description": "Transient: a 6 \u00b0C gap opens at load and closes at t = 1200 s as the hot-water loop comes up to temperature. Twenty minutes is short of alarm_delay, so nothing alarms",
"inputs": {
"mat": 20.0,
"sat": [
{
"t": 0,
"value": 14.0
},
{
"t": 1200,
"value": 22.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "oscillating_gap_never_persists",
"description": "Honesty case for the instantaneous-sampling deviation: SAT alternates every 600 s between 6 \u00b0C below MAT and 2 \u00b0C above it. The mean sits outside the band, so G36's 5-minute rolling average would fault; persistence resets on every compliant tick and this rule never does",
"inputs": {
"mat": 20.0,
"sat": [
{
"t": 0,
"value": 14.0
},
{
"t": 600,
"value": 22.0
},
{
"t": 1200,
"value": 14.0
},
{
"t": 1800,
"value": 22.0
},
{
"t": 2400,
"value": 14.0
},
{
"t": 3000,
"value": 22.0
},
{
"t": 3600,
"value": 14.0
},
{
"t": 4200,
"value": 22.0
},
{
"t": 4800,
"value": 14.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "fault_clears_on_recovery",
"description": "A sustained 6 \u00b0C gap alarms at 1800 s; the leaking cooling valve is isolated at t = 3000 s and SAT climbs above MAT, dropping the alarm on that tick \u2014 TrueDelay has no off-delay",
"inputs": {
"mat": 20.0,
"sat": [
{
"t": 0,
"value": 14.0
},
{
"t": 3000,
"value": 22.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
}
]
}
]
}