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-0005 — SAT too low vs MAT in heating

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

Points: mat, sat

Outputs:

  • yFault — True while SAT has stayed more than mat_sat_gap_threshold below MAT for at least alarm_delay

Parameters:

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

AHU-0005 block graph

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:

  1. SAT sensor error
  2. MAT sensor error
  3. Cooling coil valve leaking or stuck open
  4. Heating coil valve stuck closed or actuator failure
  5. Fouled or undersized heating coil
  6. HW temperature too low or HW unavailable
  7. Gas or electric heat unavailable
  8. 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, and savings_range are copied from §5.8.1. The index has no emissions column, so scope: 1+2 follows 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 − dTSF binds 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 >. CDL Reals offers 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 persist delay. 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_by for the host to enforce.
  • persist.delayOnInit = true (Modelica/CDL default is false), 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.

ScenarioDescription
heating_air_leaves_warmer_than_it_arrivedHealthy 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_thresholdSAT 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_heatingSAT 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_gapSAT 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_thresholdThreshold 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_thresholdThreshold edge, other side: mat − sat = 3.1 °C clears the strict comparison and alarms after alarm_delay
no_temperature_rise_is_not_this_faultSAT 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_delayTransient: 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_persistsHonesty 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_recoveryA 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
        }
      ]
    }
  ]
}