AHU-0012 — SAT too high vs MAT in cooling
| Status | verified — engine e2ff2f8, cxf:fnv1a128:d771011dc1df5622a20da08a347948cc, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 1 |
| Category | EXCESS_CONSUMPTION |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | §5.16.14 FC#12 |
| Clusters | CLU-01 |
| Suppresses | — |
| Suppressed by | AHU-0028 |
| Related | AHU-0016, AHU-0005, AHU-0013, AHU-0028, AHU-0032 |
| Playbooks | simultaneous-hc |
| Source | HVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#12 (text per Addendum u public review); NISTIR 7365 (defaults provenance) |
| Operating states | OS#2-#4 (any cooling-side state) — host-gated |
Preconditions (host-enforced): Supply fan running, and the unit in one of the cooling-side operating states G36 defines by actuator signature: OS#2 (HC = 0, CC = 0, minimum < OA damper < 100%), OS#3 (HC = 0, CC > 0, OA damper = 100%), or OS#4 (HC = 0, CC > 0, OA damper at minimum). Omit the rule on a unit with no MAT sensor — G36 marks FC#12
omit if no MAT sensor, and a MAT inferred from a mixing model rather than measured makes the comparison circular. Suspend evaluation for ModeDelay (30 min) after any mode or operating-state change in a zone group the AHU serves, while actuators are still stroking to their new positions. Silence the rule while AHU-0028 is active: a MAT outside the OAT/RAT envelope is not a number to compare anything against. When any gate is unmet the verdict is NO_EVAL, not healthy.
Outputs:
yFault— True while SAT has stayed more than sat_mat_gap_threshold above MAT for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
sat_mat_gap_threshold | 5.0 | °C | gapBig.t | How far SAT may exceed MAT before the air is warming for a reason no cooling-side state explains. Default 5.0 °C is G36’s composition eSAT + eMAT + dTSF = 1 + 3 + 1: the supply-air and mixed-air sensor allowances plus the temperature rise across the supply fan. A site retuning a sensor allowance recomputes that sum — a local calibrated MAT sensor lowers eMAT, and a measured fan rise replaces dTSF |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min) |
Description
Air crossing an air handler in a cooling-side state can gain a little heat from the supply fan and nothing else. When SAT reads more than the combined sensor and fan-heat allowance above MAT, the stream is picking up heat from a source that should not be running: a gas or electric stage stuck on, a heating valve leaking through, or a heating coil that never closed when the unit left OS#1. The alternative is that one of the two sensors is lying.
This is G36 §5.16.14 FC#12, AHU-0005 read in the mirror; both are statements about the sign of the temperature change across the unit, each evaluated only in the states where its sign is the expected one. A heating source active while the unit is cooling is simultaneous heating and cooling under another name, which puts this rule in CLU-01 behind AHU-0016. The difference is what each can see: AHU-0016 needs both valve commands and catches the conflict at the command layer, while this rule reads two temperatures and catches it at the air stream — including the case AHU-0016 misses, where the heating command reads zero and heat arrives anyway.
Detection Logic
gap = sat − mat
yFault = gap > sat_mat_gap_threshold,
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
G36 writes the test as SAT_AVG − eSAT − ΔTSF ≥ MAT_AVG + eMAT; moving the
constants to one side gives SAT − MAT ≥ eSAT + eMAT + ΔTSF, one subtraction
against the composed 5.0 °C threshold. The comparison is strict, so a gap
sitting exactly on 5.0 °C reads healthy where G36 would report the fault.
persist requires 30 minutes of continuous violation and any interruption
restarts the timer, which separates a stuck heat source from a heating stage
finishing its off-cycle purge; recovery is immediate on the tick the gap falls
back inside the allowance.
Nothing below the threshold can trip this rule, including a gap of −20 °C. A cooling coil that has stopped cooling shows up here only once the air is actively being heated; the case where SAT merely fails to reach setpoint with the valve wide open is AHU-0013’s.
Possible Diagnoses
Transcribed from G36 §5.16.14 FC#12:
- SAT sensor error
- MAT sensor error
- Cooling coil valve stuck closed or actuator failure
- Fouled or undersized cooling coil
- CHW temperature too high or CHW unavailable
- DX cooling unavailable
- Gas or electric heat stuck on
- Heating coil valve leaking or stuck open
Diagnoses 7 and 8 are what make this a waste fault rather than a capacity fault: they are the only entries that put energy into the stream. Air merely failing to be cooled lands about one fan-heat rise above MAT, not five degrees, so the cooling-side entries usually reach this rule in combination — a leaking heating coil that a working chilled-water coil had been masking becomes visible the moment the cooling capacity goes away.
Energy Impact
EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION, savings 2–5% of AHU energy — the §5.8.1 index row, the only energy statement the reference makes here (no EEM mapped). The waste has two halves, the heat someone paid to add and the cooling paid to remove it again:
waste_kw = supply_airflow_m3s × 1.2 × 1.005 × (sat − (mat + dTSF))
That sizes the first half; the second is a further charge of roughly the same magnitude at the plant’s efficiency. Design airflow substituting for a measured one keeps the term a proxy, and MEDIUM rather than HIGH because the rule cannot separate its waste diagnoses from its sensor diagnoses — a mis-calibrated SAT sensor draws the identical trace and wastes nothing. Cooling-dominant, since the rule is evaluated only in cooling-side states.
Emissions Impact
PROXY_EMISSIONS, MEDIUM confidence. Scope 1+2, following AHU-0016: when the
fault is real both inventories are paid into at the same moment — gas heat
adding energy (Scope 1) and purchased electricity driving the chiller that
removes it (Scope 2). The 1 is contingent on the heat being combustion; on an
all-electric unit the exchange collapses to Scope 2. The heating-side mirror
AHU-0005 records the same exchange as 1|2 for that contingency, so the pair
differs on notation rather than physics. When the cause is a sensor there is
nothing to attribute. 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 text. The HVAC FDD Reference carries AHU-0012 only as a §5.8.1 index row — no equation, internal variables, vectors, severity, diagnoses, or preconditions. Detection logic and the diagnosis list are transcribed from ASHRAE Guideline 36 §5.16.14 FC#12 as it appears in Addendum u to Guideline 36-2018 (First Public Review, 2021).
- Combined threshold instead of three separate allowances. eSAT = 1 °C,
eMAT = 3 °C, and ΔTSF = 1 °C (NISTIR 7365 defaults the addendum notes are
“intentionally biased toward minimizing false alarms”) compose into one
positive 5.0 °C threshold on one CXF path, so a host retunes one number
instead of three. Recompute as
eSAT + eMAT + ΔTSF: a local calibrated MAT sensor at eMAT = 1 °C gives 3.0, a measured 2 °C fan rise gives 6.0. Same rearrangement as AHU-0028 and AHU-0001. - G36’s
≥becomes a strict>. CDLRealsoffers only strict comparisons, so a gap of exactly 5.000 °C reads healthy where G36 reports the fault. Measure zero on a real temperature signal, and it errs toward silence. A host binding coarsely quantized temperatures (integer °C, or a BAS that rounds to 0.5) should retunesat_mat_gap_thresholdto 4.9. - Instantaneous samples instead of 5-minute rolling averages. G36 computes every §5.16.14 signal as a 5-minute rolling average with 1-minute sampling; this library consumes instantaneous points and lets the 30-minute AlarmDelay stand in. Not equivalent — persistence resets on every compliant tick, so an oscillating gap can hide indefinitely, while the steady offset of a stuck heat source or a drifted sensor reads the same either way. (Honesty note from AHU-0002.)
- Operating states and ModeDelay are host-side preconditions. G36 scopes
FC#12 to OS#2–#4, suspends evaluation for ModeDelay (30 min) after a mode
change in a served zone group, and suspends it entirely while the AHU is off.
None of it is in the graph, per the library’s stance (precedent AHU-0029).
G36’s
omit if no MAT sensorqualifier is a deployment decision of the same kind and lives inpreconditionstoo. - OS#2 is included on the addendum’s own authority. The published FC#12 row applies to OS#3–#4; Addendum u shows the applicability edited to OS#2–#4, which is what is transcribed here — free cooling with a modulating damper is a cooling-side state like the other two. A host running the unedited 2018 text gates OS#3–#4 only; the graph is identical either way.
- Severity 3 is the library’s. No chapter card states one and the §5.8.1 index carries no severity column. G36’s Level 3 alarm grading is a reporting priority rather than this library’s 1–4 scale, so it corroborates without supplying.
- The energy profile is the index row’s; the runtime formula and scope are
the library’s.
category,confidence,estimation_method, andsavings_rangeare copied from §5.8.1. The proxy formula is mirrored from AHU-0005 with the sign flipped — that rule counts heat removed from air the heating coil paid to warm, this one heat added to air the cooling coil pays to remove. persist.delayOnInit = true(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.
Notes
The threshold asymmetry with AHU-0005 is worth understanding before either
number is retuned. The heating-side rule tests MAT − SAT against
eSAT + eMAT − ΔTSF = 3.0 °C; this one tests SAT − MAT against
eSAT + eMAT + ΔTSF = 5.0 °C. The sensor allowances are identical and the fan
heat is what differs: expected warming excuses SAT running warm and indicts SAT
running cold. Start with the two sensor diagnoses — cheapest to eliminate and
most likely to be right — and note that an active AHU-0028 should already be
suppressing this rule. If the sensors check out, the
simultaneous-hc playbook covers the
rest: the heat source is on, and what remains is whether the command, the
valve, or the sequence is responsible.
Test Vectors
9 scenarios, clock step 300 s over 5400 s.
| Scenario | Description |
|---|---|
cooling_coil_doing_its_job | Minimum-OA mechanical cooling on a warm day: a 24 °C mixture leaves the coil at 13 °C. SAT is 11 °C below MAT, the sign the rule expects in every cooling-side state, so there is nothing to report |
fan_heat_with_no_cooling_demand | SAT sits 1.5 °C above MAT with the coil idle — about what the supply fan alone adds (dTSF = 1 °C) plus a fraction of the sensor allowances. The 5 °C threshold is built to absorb exactly this case, so the rule stays silent |
gap_exactly_at_threshold | Threshold edge: SAT − MAT = 5.0 °C exactly, the full eSAT + eMAT + dTSF allowance. gapBig is a strict >, so a gap sitting precisely on the composed allowance reads healthy |
gap_just_over_threshold | Threshold edge, other side: SAT − MAT = 5.1 °C clears the strict comparison and the alarm asserts one alarm_delay (1800 s) later |
heat_stuck_on_during_mechanical_cooling | The unit is in a cooling state and the air still gains 9 °C crossing the AHU — a gas or electric heating stage stuck on, or a heating valve leaking through. Air cannot warm itself, so this is the CLU-01 waste case: energy added and removed on the same stream |
mat_sensor_reading_low | A plausible SAT of 18 °C against a MAT of 12 °C on a day with no 12 °C air anywhere in the building — the same 6 °C violation, but here the mixed-air sensor is what is wrong. The rule reports the contradiction; it cannot say which of the two sensors is lying |
transient_clears_before_alarm_delay | Transient: the gap holds at 9 °C for 1200 s while a heating stage finishes its off-cycle purge, then SAT drops to 15 °C. Anything shorter than alarm_delay never reaches the operator |
fault_clears_on_recovery | A sustained 9 °C gap alarms at 1800 s; the stuck heating stage is switched off at t = 3000 and SAT falls to 14 °C. TrueDelay has no off-delay, so the alarm drops on that tick |
oscillating_gap_never_alarms | A staging heater cycling on a 10-minute period drives the gap between 9 °C and 0 °C. Its mean is well outside the threshold and G36’s 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent — the limit of substituting TrueDelay for averaging (see Deviations) |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 5400
},
"scenarios": [
{
"name": "cooling_coil_doing_its_job",
"description": "Minimum-OA mechanical cooling on a warm day: a 24 \u00b0C mixture leaves the coil at 13 \u00b0C. SAT is 11 \u00b0C below MAT, the sign the rule expects in every cooling-side state, so there is nothing to report",
"inputs": {
"sat": 13.0,
"mat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "fan_heat_with_no_cooling_demand",
"description": "SAT sits 1.5 \u00b0C above MAT with the coil idle \u2014 about what the supply fan alone adds (dTSF = 1 \u00b0C) plus a fraction of the sensor allowances. The 5 \u00b0C threshold is built to absorb exactly this case, so the rule stays silent",
"inputs": {
"sat": 25.5,
"mat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "gap_exactly_at_threshold",
"description": "Threshold edge: SAT \u2212 MAT = 5.0 \u00b0C exactly, the full eSAT + eMAT + dTSF allowance. `gapBig` is a strict `>`, so a gap sitting precisely on the composed allowance reads healthy",
"inputs": {
"sat": 29.0,
"mat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "gap_just_over_threshold",
"description": "Threshold edge, other side: SAT \u2212 MAT = 5.1 \u00b0C clears the strict comparison and the alarm asserts one alarm_delay (1800 s) later",
"inputs": {
"sat": 29.1,
"mat": 24.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "heat_stuck_on_during_mechanical_cooling",
"description": "The unit is in a cooling state and the air still gains 9 \u00b0C crossing the AHU \u2014 a gas or electric heating stage stuck on, or a heating valve leaking through. Air cannot warm itself, so this is the CLU-01 waste case: energy added and removed on the same stream",
"inputs": {
"sat": 31.0,
"mat": 22.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "mat_sensor_reading_low",
"description": "A plausible SAT of 18 \u00b0C against a MAT of 12 \u00b0C on a day with no 12 \u00b0C air anywhere in the building \u2014 the same 6 \u00b0C violation, but here the mixed-air sensor is what is wrong. The rule reports the contradiction; it cannot say which of the two sensors is lying",
"inputs": {
"sat": 18.0,
"mat": 12.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": "Transient: the gap holds at 9 \u00b0C for 1200 s while a heating stage finishes its off-cycle purge, then SAT drops to 15 \u00b0C. Anything shorter than alarm_delay never reaches the operator",
"inputs": {
"sat": [
{
"t": 0,
"value": 31.0
},
{
"t": 1200,
"value": 15.0
}
],
"mat": 22.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "fault_clears_on_recovery",
"description": "A sustained 9 \u00b0C gap alarms at 1800 s; the stuck heating stage is switched off at t = 3000 and SAT falls to 14 \u00b0C. `TrueDelay` has no off-delay, so the alarm drops on that tick",
"inputs": {
"sat": [
{
"t": 0,
"value": 31.0
},
{
"t": 3000,
"value": 14.0
}
],
"mat": 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
}
]
},
{
"name": "oscillating_gap_never_alarms",
"description": "A staging heater cycling on a 10-minute period drives the gap between 9 \u00b0C and 0 \u00b0C. Its mean is well outside the threshold and G36's 5-minute rolling average would report the fault, but persistence restarts on every compliant tick, so this rule stays silent \u2014 the limit of substituting TrueDelay for averaging (see Deviations)",
"inputs": {
"sat": [
{
"t": 0,
"value": 31.0
},
{
"t": 600,
"value": 22.0
},
{
"t": 1200,
"value": 31.0
},
{
"t": 1800,
"value": 22.0
},
{
"t": 2400,
"value": 31.0
},
{
"t": 3000,
"value": 22.0
},
{
"t": 3600,
"value": 31.0
},
{
"t": 4200,
"value": 22.0
},
{
"t": 4800,
"value": 31.0
}
],
"mat": 22.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
}
]
}