AHU-0035 — Supply air temperature too high for the zone population
| Status | verified — engine e2ff2f8, cxf:fnv1a128:e915d6c24ba9e0afb73391fb57df7b3f, 2026-08-18 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | CLU-02 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0019, AHU-0012, AHU-0013, AHU-0023 |
| Playbooks | missing-reset |
| Source | PNNL-27338 §2.3 (High SAT AIRCx) — the two-conjunct zone-population test and its published thresholds: percent_damper_threshold 60%, percent_reheat_threshold 25%, zone_high_damper_threshold 90%, reheat_on_threshold 10%, data_window 15 min; PNNL-27338 §2.3.1 — the auto-correction bounds (SAT setpoint lowered by sat_retuning = 1 °F per cycle, floored at min_sat_stpt = 50 °F), carried here as retuning guidance rather than graph; PNNL-25985 EEM-05 (SAT reset) — the savings basis shared with AHU-0019 and AHU-0023; Sibling precedent: AHU-0019, the cold half of the same PNNL SAT AIRCx pair — graph shape, occupied gating, and the fraction-unit convention are mirrored from it; AHU-0024 for a host-derived zone aggregate as a boundary point; Library extension: the HVAC FDD Reference v1.0 ch.9 carries no high-SAT population rule — see faults/ahu/README.md |
| Operating states | Occupied (OS 2, 3, 4) with the supply fan running — host-gated. PNNL-27338 §2.3 states the test for a running fan; dampers parked at an unoccupied position carry no cooling demand to read. |
Preconditions (host-enforced): AHU serving multiple zones, and both fractions aggregated by the host over the SAME zone population from the same scan — a damper fraction counted over all boxes against a reheat fraction counted over only the reheat-capable ones compares two different denominators and biases the rule toward firing. When the zone data is missing, stale, or covers too few zones for either fraction to mean anything, the verdict is NO_EVAL, not healthy. Written for single-duct VAV AHUs whose heating happens at the terminal box (PNNL-27338 §2.3): on a unit with its own heating coil, zone reheat is no longer the whole heating story and the second conjunct loses its meaning. Hosts should also suspend evaluation through morning pull-down, which produces this exact population signature while the building is still catching up.
Points: zone_dmpr_fraction_open, zone_reheat_fraction
Outputs:
yFault— True while more than damper_fraction_threshold of zones have held their dampers above the host’s high-open counting threshold with fewer than reheat_fraction_threshold of zones reheating, for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
damper_fraction_threshold | 0.6 | 1 | dmprHigh.t | Fraction of served zones (0-1) above the host’s high-open damper counting threshold beyond which the population is demonstrably starved for cooling. PNNL-27338 §2.3’s percent_damper_threshold = 60%, against a per-zone zone_high_damper_threshold of 90% that lives in host configuration |
reheat_fraction_threshold | 0.25 | 1 | rhtLow.t | Fraction of served zones (0-1) reheating below which no zone population is asking for warmer air. PNNL-27338 §2.3’s percent_reheat_threshold = 25%; the report is explicit that above it the setpoint must not be lowered |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) — two of PNNL-27338’s 15-minute data_window cycles. See Deviations for why this rule sits between the source’s cadence and AHU-0019’s hour |
Description
Most of the zones this air handler serves are holding their dampers near wide open trying to make their space setpoints, and almost none of them are reheating. Supply air that warm forces every box toward maximum flow to deliver the same cooling: the zones drift off setpoint anyway, the fan runs harder than the load requires, and the air reaching the space is wetter than it needs to be. The low reheat fraction is the corroboration — PNNL-27338 §2.3 is explicit that the setpoint must not be lowered while more than a quarter of the zones are reheating, because that population is telling the opposite story. This is the warm half of the SAT retuning pair AHU-0019 covers from the cold side, and it costs comfort and fan energy before it costs anything else.
Detection Logic
yFault = zone_dmpr_fraction_open > damper_fraction_threshold
AND zone_reheat_fraction < reheat_fraction_threshold
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Two threshold tests feed one conjunction and one timer, the mirror of AHU-0019
with both comparisons reversed. Note what this rule does not read: no supply
air temperature and no setpoint. PNNL-27338 §2.3 infers “too high” entirely from
the zone population, which means the rule fires just as readily on a unit that
cannot make a correct setpoint as on one whose setpoint is set wrong — AHU-0013
and AHU-0012 are the single-unit tests that split those two, and neither can see
this fault, because a unit sitting exactly on a bad setpoint looks healthy to
every sensor it owns. Both fractions are 0–1, not percent, and both comparisons
are strict, so exactly 60% of dampers open or exactly 25% of zones reheating
reads healthy. persist requires 30 continuous minutes, and delayOnInit = true
serves that window in full after a controller restart.
Possible Diagnoses
- SAT setpoint parked too high — set for a load profile that no longer exists, or a reset whose upper bound is above what the zones can absorb. The missing-reset playbook carries this heuristic verbatim at step 2.5.
- The AHU cannot make its setpoint — chilled-water valve not modulating, chiller off, CHW pumps failing (PNNL-27338 §2.3 names all three). Identical population signature, entirely different work order; AHU-0013 and AHU-0012 separate it from the AHU’s own sensors, and both should be checked before anyone touches the setpoint.
- Genuine peak load — at design conditions, wide dampers with no reheat is correct operation. Confirm the setpoint is not already at its low limit before treating this as a retuning opportunity.
- Starved airflow rather than warm air — a duct static pressure setpoint too low leaves dampers open with the SAT perfectly correct (AHU-0001, and the DSP half of CLU-02 through AHU-0024).
Energy Impact
COMFORT_ENERGY, MEDIUM confidence, PROXY_ESTIMATION. There is no waste integral to compute here — nothing is being simultaneously added and removed, as it is on the cold side. The cost is airflow: every zone that has to open further to compensate for warm supply air buys fan power on the cube law, and the comfort it buys is negative, because the zones are losing the space while they do it. Correcting the setpoint recovers 1–4.4% of site energy on the EEM-05 basis (2.5% national median), partly offset by the extra chiller load colder air implies — PNNL-27338 §2.3 names that trade explicitly. Cooling-dominant, and the latent benefit of drier supply air grows with humidity.
Emissions Impact
Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typical 500–3,000 kg CO₂e/yr, net of the chiller load the correction adds back. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
- The auto-correction is card prose, not graph. PNNL-27338 §2.3.1 lowers the
setpoint by
sat_retuning= 1 °F per cycle and floors it atmin_sat_stpt= 50 °F, guarding the low-limit thermostat and the economizer’s cold-air path. This library detects; those bounds belong in the retune, and the floor is the number to check first when this rule will not clear. - PNNL’s windowed averages are replaced by a persistence delay. The source
averages
percent_dmprandpercent_rhtover a ≥15-minutedata_windowwith at least 5 samples. ATrueDelayon the conjunction is the engine-native equivalent and is stricter — it requires the condition to hold every tick rather than on average — which suits a detect-only rule that must not cry wolf. alarm_delay= 1800 s, between the source’s cadence and AHU-0019’s hour. Onedata_window(900 s) reproduces PNNL’s correction cadence, but PNNL nudges 1 °F while this card raises an alarm, and morning pull-down shows this exact signature for longer than one window. AHU-0019’s 3600 s is too slow in the other direction: warm supply air is a comfort complaint already in progress, so latency has an occupant cost the cold side does not carry. Sites wanting the literal source cadence set 900.- No
sat_spinput, unlike AHU-0019. The source’s high-SAT test reads the setpoint array only to decide whether auto-correction is possible (§2.3.3 step 6), never as a detection term. Adding a setpoint threshold would have made the two cards symmetric and the rule wrong: a unit whose setpoint is correct and whose coil has failed is exactly the case this population test is good at. - Both fractions are 0–1, not percent. The source states 60% and 25%; the
points compared against carry unit
1. Hosts feeding 0–100 never fire the damper conjunct and always fire the reheat one — that is, the rule goes permanently silent rather than noisy, which is the worse failure. - Per-zone counting thresholds stay in host configuration. The 90% damper and
10% reheat thresholds define
zone_dmpr_fraction_openandzone_reheat_fractioninpoints/ahu.points.json, not this rule; library v1 avoids array boundary points, so the counting happens host-side (thezone_reheat_fractionprecedent AHU-0019 set). confidence: MEDIUM, against AHU-0019’s HIGH. The population evidence is unambiguous about the symptom and the source publishes every threshold, but three unrelated causes produce it (diagnoses 2–4) and the graph cannot rank them.severity: 3andcategory: COMFORT_ENERGYfollow the mirror asymmetry: the cold side burns fuel to undo cooling, the warm side loses the space first and the fan bill second.- Strict comparisons (
>,<); the source does not specify boundary behavior, so the library’s strict convention applies. - Occupied/fan-on gating and the multi-zone precondition are frontmatter, for host enforcement rather than block graph, per the library’s design stance.
persist.delayOnInit = true(CDL defaultfalse), the library’s standing choice: a condition already present at load waits out the full 30 minutes instead of alarming on the first tick after a restart.
Notes
Bound to missing-reset rather than a playbook of its own: step 2.5 of that
playbook already carries both PNNL heuristics, this one included, and the fix is
the same trim-and-respond programming AHU-0023 asks for — from the other end of
the reset band. Run the pair. AHU-0019 firing on the same unit at a different
hour is not a contradiction; it is a reset whose band is wrong in both
directions, and CLU-02’s trigger is the rule to fix. Membership in CLU-02 is
declared here and belongs in clusters/clusters.json alongside AHU-0019.
Test Vectors
10 scenarios, clock step 60 s over 5400 s.
| Scenario | Description |
|---|---|
dampers_open_few_reheating | Reference vector: 75% of zones above the high-open damper threshold with 10% reheating — cooling starvation with nobody reheating; alarms after alarm_delay (1800 s), served in full from load because delayOnInit is true |
dampers_moderate_few_reheating | Reference vector: 45% of zones wide open with 10% reheating — light reheat alone is not a finding; the damper population never clears its threshold |
dampers_open_widespread_reheat | Reference vector: 75% of zones wide open but 40% reheating — a mixed-load hour, not warm supply air; PNNL-27338 forbids lowering the setpoint here |
damper_fraction_exactly_at_threshold | Edge case: the open-damper fraction sits exactly on damper_fraction_threshold (0.60) with 10% reheating — the strict > keeps it out of the alarm |
reheat_fraction_exactly_at_threshold | Edge case: exactly a quarter of zones reheating (0.25) against 75% wide-open dampers — the strict < means the fraction must fall below the threshold |
morning_pulldown_clears_before_delay | Transient: the pull-down signature — every damper open, nothing reheating — relaxes to 0.40 open at t=1200, inside alarm_delay, so no alarm |
reheat_returns_before_delay | Transient: dampers stay at 0.75 but the reheating fraction climbs back to 0.35 at t=1200 — the corroborating conjunct drops out before the alarm can assert |
damper_dip_restarts_persistence | Transient: the open-damper fraction dips to 0.50 at t=900 and returns to 0.75 at t=1200 — persistence restarts from the return, so the alarm lands at 3000 s, not 1800 s |
reheat_gate_opens_late | Gate edge: dampers held at 0.75 throughout while the reheating fraction falls from 0.40 to 0.10 at t=900 — persistence starts at the reheat crossing, so the alarm lands at 2700 s |
both_gates_arrive_together | Gate edge: both conjuncts start false (0.45 open, 0.40 reheating) and both cross at t=600 to 0.80 open and 0.05 reheating — the alarm lands at 2400 s |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 5400
},
"scenarios": [
{
"name": "dampers_open_few_reheating",
"description": "Reference vector: 75% of zones above the high-open damper threshold with 10% reheating \u2014 cooling starvation with nobody reheating; alarms after alarm_delay (1800 s), served in full from load because delayOnInit is true",
"inputs": {
"zone_dmpr_fraction_open": 0.75,
"zone_reheat_fraction": 0.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "dampers_moderate_few_reheating",
"description": "Reference vector: 45% of zones wide open with 10% reheating \u2014 light reheat alone is not a finding; the damper population never clears its threshold",
"inputs": {
"zone_dmpr_fraction_open": 0.45,
"zone_reheat_fraction": 0.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "dampers_open_widespread_reheat",
"description": "Reference vector: 75% of zones wide open but 40% reheating \u2014 a mixed-load hour, not warm supply air; PNNL-27338 forbids lowering the setpoint here",
"inputs": {
"zone_dmpr_fraction_open": 0.75,
"zone_reheat_fraction": 0.4
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "damper_fraction_exactly_at_threshold",
"description": "Edge case: the open-damper fraction sits exactly on damper_fraction_threshold (0.60) with 10% reheating \u2014 the strict `>` keeps it out of the alarm",
"inputs": {
"zone_dmpr_fraction_open": 0.6,
"zone_reheat_fraction": 0.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "reheat_fraction_exactly_at_threshold",
"description": "Edge case: exactly a quarter of zones reheating (0.25) against 75% wide-open dampers \u2014 the strict `<` means the fraction must fall below the threshold",
"inputs": {
"zone_dmpr_fraction_open": 0.75,
"zone_reheat_fraction": 0.25
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "morning_pulldown_clears_before_delay",
"description": "Transient: the pull-down signature \u2014 every damper open, nothing reheating \u2014 relaxes to 0.40 open at t=1200, inside alarm_delay, so no alarm",
"inputs": {
"zone_dmpr_fraction_open": [
{
"t": 0,
"value": 0.75
},
{
"t": 1200,
"value": 0.4
}
],
"zone_reheat_fraction": 0.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "reheat_returns_before_delay",
"description": "Transient: dampers stay at 0.75 but the reheating fraction climbs back to 0.35 at t=1200 \u2014 the corroborating conjunct drops out before the alarm can assert",
"inputs": {
"zone_dmpr_fraction_open": 0.75,
"zone_reheat_fraction": [
{
"t": 0,
"value": 0.1
},
{
"t": 1200,
"value": 0.35
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "damper_dip_restarts_persistence",
"description": "Transient: the open-damper fraction dips to 0.50 at t=900 and returns to 0.75 at t=1200 \u2014 persistence restarts from the return, so the alarm lands at 3000 s, not 1800 s",
"inputs": {
"zone_dmpr_fraction_open": [
{
"t": 0,
"value": 0.75
},
{
"t": 900,
"value": 0.5
},
{
"t": 1200,
"value": 0.75
}
],
"zone_reheat_fraction": 0.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2940,
"equals": false
},
{
"output": "yFault",
"from_s": 3060,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "reheat_gate_opens_late",
"description": "Gate edge: dampers held at 0.75 throughout while the reheating fraction falls from 0.40 to 0.10 at t=900 \u2014 persistence starts at the reheat crossing, so the alarm lands at 2700 s",
"inputs": {
"zone_dmpr_fraction_open": 0.75,
"zone_reheat_fraction": [
{
"t": 0,
"value": 0.4
},
{
"t": 900,
"value": 0.1
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2640,
"equals": false
},
{
"output": "yFault",
"from_s": 2760,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "both_gates_arrive_together",
"description": "Gate edge: both conjuncts start false (0.45 open, 0.40 reheating) and both cross at t=600 to 0.80 open and 0.05 reheating \u2014 the alarm lands at 2400 s",
"inputs": {
"zone_dmpr_fraction_open": [
{
"t": 0,
"value": 0.45
},
{
"t": 600,
"value": 0.8
}
],
"zone_reheat_fraction": [
{
"t": 0,
"value": 0.4
},
{
"t": 600,
"value": 0.05
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2340,
"equals": false
},
{
"output": "yFault",
"from_s": 2460,
"to_s": 5400,
"equals": true
}
]
}
]
}