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

AHU-0012 — SAT too high vs MAT in cooling

Statusverified — engine e2ff2f8, cxf:fnv1a128:d771011dc1df5622a20da08a347948cc, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36§5.16.14 FC#12
ClustersCLU-01
Suppresses
Suppressed byAHU-0028
RelatedAHU-0016, AHU-0005, AHU-0013, AHU-0028, AHU-0032
Playbookssimultaneous-hc
SourceHVAC 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 statesOS#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.

Points: sat, mat

Outputs:

  • yFault — True while SAT has stayed more than sat_mat_gap_threshold above MAT for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
sat_mat_gap_threshold5.0°CgapBig.tHow 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_delay1800.0spersist.delayTimeContinuous 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):

AHU-0012 block graph

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:

  1. SAT sensor error
  2. MAT sensor error
  3. Cooling coil valve stuck closed or actuator failure
  4. Fouled or undersized cooling coil
  5. CHW temperature too high or CHW unavailable
  6. DX cooling unavailable
  7. Gas or electric heat stuck on
  8. 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 >. CDL Reals offers 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 retune sat_mat_gap_threshold to 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 sensor qualifier is a deployment decision of the same kind and lives in preconditions too.
  • 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, and savings_range are 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 is false), 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.

ScenarioDescription
cooling_coil_doing_its_jobMinimum-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_demandSAT 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_thresholdThreshold 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_thresholdThreshold 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_coolingThe 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_lowA 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_delayTransient: 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_recoveryA 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_alarmsA 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
        }
      ]
    }
  ]
}