AHU-0007 — SAT too low at full heating
| Status | verified — engine e2ff2f8, cxf:fnv1a128:c388701604d442eb0bf2425055d91402, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 1 |
| Category | EXCESS_CONSUMPTION |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | §5.16.14 FC#7 |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0005, AHU-0013, AHU-0023, AHU-0032, AHU-0033 |
| Playbooks | — |
| Source | HVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#7 (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 heating coil; G36 omits FC#7 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 — in any cooling state SAT is meant to sit below its heating setpoint. Supply fan running, since SAT means nothing in still air. Suspend evaluation for ModeDelay (30 min) after any operating-state change, while the coil is still stroking and SAT still reflects the previous state. sat_sp must be the setpoint the unit is actively controlling to: a host that binds a design constant while the sequence follows a reset schedule is comparing SAT against a number nothing is chasing. When any gate is unmet the verdict is NO_EVAL, not healthy.
Points: sat, sat_sp, htg_vlv_cmd
Outputs:
yFault— True while SAT has stayed more than sat_error_threshold below setpoint with the heating coil commanded above hc_full_threshold, for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
sat_error_threshold | 1.0 | °C | spMiss.t | Shortfall below the supply air temperature setpoint that counts as a real miss rather than sensor error; the G36 §5.16.14 internal variable eSAT at its Table 5.16.14.5 default |
hc_full_threshold | 99.0 | % | htgFull.t | Heating coil command above which the coil is treated as having no capacity left (G36 HC >= 99%). Hosts whose valve command is quantized to whole percent should retune to 98.9, since the comparison is strict |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min) |
Description
The heating coil is wide open and the air is still coming out cold. Two signals settle it: the coil command has reached its stop, so the control loop has already asked for everything it has, and SAT is below setpoint by more than the sensor could be wrong about. What is left is a capacity, supply, or sensing problem — the coil cannot deliver, the heat source is not there to deliver, or the measurement is lying about what did get delivered. None of it is a tuning question, which is what makes the saturated-valve half of the test worth carrying: SAT below setpoint at 60% valve command is an ordinary loop working through a load change. This is G36 §5.16.14 FC#7, applicable in OS#1 (heating) only, and G36 omits it entirely on units with no heating coil.
Detection Logic
sp_gap = sat_sp − sat
yFault = (sp_gap > sat_error_threshold) SAT short of setpoint by more than sensor error
AND (htg_vlv_cmd > hc_full_threshold) coil has no capacity left
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
The gap form is G36’s SAT_AVG < SATSP − eSAT rearranged so the tolerance
stays a positive number on one CXF path (see Deviations). Both comparisons are
strict, so an error sitting exactly on 1.0 °C and a coil reported at exactly
99.0% both read healthy. persist requires 30 minutes of continuous violation
and any interruption restarts the timer, which separates a broken coil from a
morning warm-up, a step in the setpoint reset schedule, or the recovery after a
mode change.
The rule reads the coil command, not a position feedback, and that is deliberate: a valve commanded to 100% while its actuator has failed closed is diagnosis 3, and reading the command is what catches it. The cost is that the rule cannot distinguish a coil genuinely wide open and starved from one that never moved.
Possible Diagnoses
G36 §5.16.14 FC#7, transcribed:
- SAT 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
- Leaking or stuck economizer damper or actuator
The list spans three failure modes that look identical from these three points: the heat never arrives (3–6), something removes it after it arrives (2, 7, 8), or it arrived and the sensor did not see it (1). Trending MAT alongside SAT splits the first two groups — that comparison is AHU-0005.
Energy Impact
EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION, all three from the
reference’s §5.8.1 index row, which puts the range at 2–5% of AHU energy and
maps no PNNL measure. The waste depends on the cause and the three points
cannot tell the causes apart, which is why the estimate is a proxy. Where
something removes heat (diagnoses 2, 7, 8) the unit pays twice and AHU-0016’s
simultaneous heating and cooling term sizes it. Where the coil is starved or
fouled (3–6) the AHU wastes little on its own and the deficit moves downstream:
makeup_kw = supply_airflow_m3s × 1.2 × 1.005 × (sat_sp − sat), delivered by
terminal reheat at worse efficiency than the central coil, or not at all in
zones with no terminal heat. Heating-dominant, since the rule is only evaluable
in OS#1.
Emissions Impact
PROXY_EMISSIONS, MEDIUM confidence. Scope 1|2 because the inventory follows
the heat source: hot water from a gas boiler, a gas furnace section, or a steam
coil is Scope 1, while electric resistance, a heat pump, and the terminal
reheat making up the deficit are usually Scope 2. A site with a gas boiler and
electric reheat pays into both, and the cooling-side diagnoses (2, 7) add a
Scope 2 term of their own. Avoided-emissions basis: static combustion factor
for the fuel half, marginal operating emissions rate (MOER) for the electric
half.
Deviations
- The reference card is abbreviated; G36 is the normative source. HVAC FDD Reference v1.0 carries AHU-0007 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#7 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 the other G36 comparison rules 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-0020’s convention for a fault whose fuel depends on which diagnosis is true; the cause-dependent runtime formula is the library’s, its simultaneous-heating-and-cooling branch AHU-0016’s term. SAT_AVG < SATSP − eSATrewritten in gap form. Subtracting first and testingsat_sp − sat > eSATkeeps the tolerance the positive number G36 publishes, retunable at one CXF path, instead of a negative offset ahead of the comparison. Same rearrangement as AHU-0001 and AHU-0028, with the same one-ulp caveat that no temperature sensor resolves.HC >= 99%becomes a strict>. CDLRealshas only strict comparisons, so a coil command parked at exactly 99.000% reads as not-at-full-capacity and the rule stays silent — the same deviation AHU-0001 documents for fan speed. A host whose valve command is quantized to whole percent should retunehc_full_thresholdto 98.9.- 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. Not equivalent — persistence resets on every compliant tick, so an oscillating SAT can hide indefinitely, while the steady shortfall of a starved or fouled coil reads the same either way. Same note as AHU-0002; a SAT swinging across the band rather than sitting under it is AHU-0022’s finding. - Operating-state applicability and ModeDelay are frontmatter, not graph.
G36 scopes FC#7 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 graph’s
htg_vlv_cmd > 99test is not that gate — it is the saturation half of the fault condition, and OS#1 admits any heating command above zero. persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: a shortfall already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.
Notes
Check the setpoint before the coil. A SAT setpoint that never resets (AHU-0023) can hold a heating call the unit was never sized to meet, and a reset schedule stepping up faster than the coil can follow produces this signature for a few minutes at every step. Then work the supply side: hot water temperature and pump status cost nothing to read, and diagnoses 5 and 6 take a whole building’s heating rather than one unit’s. AHU-0013 is this fault mirrored into cooling; AHU-0005 is its companion in the same state, and FC-007 with FC-005 points at a heat sink running against the heating call. No playbook covers coil capacity and heat-supply work yet.
Test Vectors
11 scenarios, clock step 300 s over 5400 s.
| Scenario | Description |
|---|---|
part_load_heating_holding_setpoint | Healthy OS#1 operation: the coil is at 60% and SAT is 0.5 °C under a 21.5 °C setpoint. Neither half of the conjunction is true — the loop has capacity left and the error is inside eSAT |
full_heating_still_on_setpoint | The valve is wide open on a design-day morning but SAT is only 0.5 °C short — the unit is sized close to its design point, not broken. Saturation alone is not a fault |
part_load_missing_setpoint | SAT is 6 °C short of setpoint with the valve at 60% — that is a control-loop finding (tuning, or a reset the loop is chasing), not a capacity finding, and this rule stays silent by design |
full_heating_missing_setpoint | The reportable case: valve at 100% and SAT 6 °C below setpoint. The coil is saturated and still losing, so the shortfall is capacity, supply, or sensing. Alarms one alarm_delay (1800 s) after the condition appears |
error_exactly_at_threshold | Threshold edge: sat_sp − sat = 1.0 °C exactly with the valve at 100%. spMiss is a strict >, so an error sitting precisely on eSAT reads healthy |
error_just_over_threshold | Threshold edge, other side: sat_sp − sat = 1.1 °C clears the strict comparison and alarms after alarm_delay |
valve_exactly_at_full_threshold | Threshold edge: the valve reports exactly 99.0% with a 6 °C error present. htgFull is a strict >, so G36’s HC >= 99% reads as not-full here — the case a host with integer-quantized valve feedback must retune hc_full_threshold to 98.9 to catch |
valve_just_over_full_threshold | Threshold edge, other side: 99.5% clears the strict comparison, so the same 6 °C error alarms after alarm_delay |
morning_warmup_clears_before_delay | Transient: the coil pins at 100% and SAT sits 6 °C short during warm-up, then reaches 20.5 °C at t = 1200 s. Twenty minutes is short of alarm_delay, so a cold start never alarms |
valve_modulates_off_before_delay | Transient on the other input: the 6 °C error holds but the valve backs off to 70% at t = 1200 s, which means the loop was never out of capacity. Both halves must be true at once, so persistence restarts and no alarm follows |
fault_clears_on_recovery | A sustained shortfall alarms at 1800 s; hot water returns at t = 3000 s and SAT reaches 20.5 °C, 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": "part_load_heating_holding_setpoint",
"description": "Healthy OS#1 operation: the coil is at 60% and SAT is 0.5 \u00b0C under a 21.5 \u00b0C setpoint. Neither half of the conjunction is true \u2014 the loop has capacity left and the error is inside eSAT",
"inputs": {
"sat": 21.0,
"sat_sp": 21.5,
"htg_vlv_cmd": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "full_heating_still_on_setpoint",
"description": "The valve is wide open on a design-day morning but SAT is only 0.5 \u00b0C short \u2014 the unit is sized close to its design point, not broken. Saturation alone is not a fault",
"inputs": {
"sat": 20.5,
"sat_sp": 21.0,
"htg_vlv_cmd": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "part_load_missing_setpoint",
"description": "SAT is 6 \u00b0C short of setpoint with the valve at 60% \u2014 that is a control-loop finding (tuning, or a reset the loop is chasing), not a capacity finding, and this rule stays silent by design",
"inputs": {
"sat": 15.0,
"sat_sp": 21.0,
"htg_vlv_cmd": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "full_heating_missing_setpoint",
"description": "The reportable case: valve at 100% and SAT 6 \u00b0C below setpoint. The coil is saturated and still losing, so the shortfall is capacity, supply, or sensing. Alarms one alarm_delay (1800 s) after the condition appears",
"inputs": {
"sat": 15.0,
"sat_sp": 21.0,
"htg_vlv_cmd": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "error_exactly_at_threshold",
"description": "Threshold edge: sat_sp \u2212 sat = 1.0 \u00b0C exactly with the valve at 100%. `spMiss` is a strict `>`, so an error sitting precisely on eSAT reads healthy",
"inputs": {
"sat": 20.0,
"sat_sp": 21.0,
"htg_vlv_cmd": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "error_just_over_threshold",
"description": "Threshold edge, other side: sat_sp \u2212 sat = 1.1 \u00b0C clears the strict comparison and alarms after alarm_delay",
"inputs": {
"sat": 19.9,
"sat_sp": 21.0,
"htg_vlv_cmd": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "valve_exactly_at_full_threshold",
"description": "Threshold edge: the valve reports exactly 99.0% with a 6 \u00b0C error present. `htgFull` is a strict `>`, so G36's `HC >= 99%` reads as not-full here \u2014 the case a host with integer-quantized valve feedback must retune hc_full_threshold to 98.9 to catch",
"inputs": {
"sat": 15.0,
"sat_sp": 21.0,
"htg_vlv_cmd": 99.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "valve_just_over_full_threshold",
"description": "Threshold edge, other side: 99.5% clears the strict comparison, so the same 6 \u00b0C error alarms after alarm_delay",
"inputs": {
"sat": 15.0,
"sat_sp": 21.0,
"htg_vlv_cmd": 99.5
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "morning_warmup_clears_before_delay",
"description": "Transient: the coil pins at 100% and SAT sits 6 \u00b0C short during warm-up, then reaches 20.5 \u00b0C at t = 1200 s. Twenty minutes is short of alarm_delay, so a cold start never alarms",
"inputs": {
"sat": [
{
"t": 0,
"value": 15.0
},
{
"t": 1200,
"value": 20.5
}
],
"sat_sp": 21.0,
"htg_vlv_cmd": 100.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "valve_modulates_off_before_delay",
"description": "Transient on the other input: the 6 \u00b0C error holds but the valve backs off to 70% at t = 1200 s, which means the loop was never out of capacity. Both halves must be true at once, so persistence restarts and no alarm follows",
"inputs": {
"sat": 15.0,
"sat_sp": 21.0,
"htg_vlv_cmd": [
{
"t": 0,
"value": 100.0
},
{
"t": 1200,
"value": 70.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "fault_clears_on_recovery",
"description": "A sustained shortfall alarms at 1800 s; hot water returns at t = 3000 s and SAT reaches 20.5 \u00b0C, dropping the alarm on that tick \u2014 TrueDelay has no off-delay",
"inputs": {
"sat": [
{
"t": 0,
"value": 15.0
},
{
"t": 3000,
"value": 20.5
}
],
"sat_sp": 21.0,
"htg_vlv_cmd": 100.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
}
]
}
]
}