AHU-0036 — Duct static pressure too low for the zone population
| Status | verified — engine e2ff2f8, cxf:fnv1a128:f9559a3e8059a405d2ac854fab19db5a, 2026-08-18 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0001, AHU-0031, AHU-0024 |
| Playbooks | missing-reset |
| Source | PNNL-27338 §2.6, §2.6.2-2.6.3 (low duct static pressure AIRCx: the sorted-population test and its 90% / 25% thresholds), §2.6.1 and §2.6.3 step 6 (auto-correction, its increment and its cap); Library-authored: the HVAC FDD Reference has no card for this fault. Name, severity, category and the persistence form are argued here; Library precedent: AHU-0019 (host-derived fleet aggregate as an ordinary Real input), AHU-0031 (fan-status conjunct in the graph) |
| Operating states | Occupied, fan running |
Preconditions (host-enforced): AHU serves multiple zones under duct static pressure control; zone damper feedback available and aggregated by the host into the sorted pair zone_dmpr_high_avg / zone_dmpr_low_avg. The population must cover the zones this AHU actually serves and be large enough for halves to mean anything — below about four reporting zones the two averages stop being distinguishable. Morning warm-up is excluded (the reference’s main process holds every diagnostic off until warm_up_time has elapsed after fan start), since pull-down drives every damper open legitimately. When the zone data is missing, stale, or too thin, the verdict is NO_EVAL, not healthy.
Points: zone_dmpr_high_avg, zone_dmpr_low_avg, sf_status
Outputs:
yFault— True while both halves of the sorted zone-damper population have stayed above their thresholds with the fan running, for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
zone_high_damper_threshold | 90.0 | % | highOpen.t | Top-half average damper command above which the busiest zones are effectively wide open |
zone_low_damper_threshold | 25.0 | % | lowOpen.t | Bottom-half average damper command above which even the quiet zones are drawing on the trunk |
alarm_delay | 900.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (15 min, the reference’s data_window) |
Description
Sort every zone damper command the air handler serves, average the top half and the bottom half, and both averages come back high. The busiest zones being wide open is ordinary on a design day; the quiet zones also holding their dampers open is not, and the two together say the trunk itself is short of pressure rather than a few rooms being hot. Boxes downstream run wide open and still miss their airflow setpoints — the “starved box” condition — so zone temperatures wander and the complaints that follow get answered with overrides. This is a comfort finding, not an energy one: raising the setpoint costs fan power, and PNNL-27338 §2.6 is explicit that fixing low duct static pressure saves none.
Detection Logic
yFault = zone_dmpr_high_avg > zone_high_damper_threshold top half of the population
AND zone_dmpr_low_avg > zone_low_damper_threshold bottom half too
AND sf_status
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
The host does the sort and hands the rule two ordinary Reals (see the point
dictionary); the graph adds thresholds, the fan gate, and persistence. The
second conjunct is the whole point. A single stuck-open damper, or a handful of
zones at real load, lifts only the top half — it sits in that half by
construction and cannot move the bottom-half average at all — so the population
form separates a starved trunk from a busy one, which neither AHU-0001 (one
pressure pair plus fan speed) nor AHU-0031 (the single zone_dmpr_pos_max) can
do. Both comparisons are strict, so a half sitting exactly on its threshold
reads healthy. sf_status is wired ahead of persist rather than left to the
host: dampers drive open against a dead duct, so the fault signature is present
every night, and the gate has to reset the timer rather than merely mask its
output. persist requires 15 minutes of continuous violation and
delayOnInit = true holds that window across a controller restart.
Possible Diagnoses
- DSP setpoint left too low — an override, a noise complaint, or an energy-saving tweak that outlived the person who made it
- Trim-and-respond clamped: maximum setpoint below design static, or a respond magnitude too small to answer the airflow requests arriving from the boxes
- Duct breach or a disconnected branch downstream of the pressure sensor — the sensor is satisfied while the trunk beyond it is not (PNNL-27338 §2.6 names failed ductwork as a cause worth inspecting above the ceiling)
- Fan or drive out of capacity — if AHU-0001 fires alongside this rule, the loop has already asked for everything and no setpoint change will help
- Zone-side inflation — oversized minimum airflow setpoints, or damper feedback scaled wrong — makes the population read open when it is not
Energy Impact
COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. There is no savings term to publish, and the reference declines to claim one: raising duct static pressure raises fan power by the cube law, so the direct energy effect of the fix is negative. The recoverable energy is second-order — fan hours extended to chase zone temperatures, a supply air setpoint dropped to compensate for airflow that never arrives, and overrides layered on to quiet complaints. Confidence is MEDIUM rather than HIGH because the thresholds are published and the mechanism is well described, but nothing quantifies the impact and the test is only as good as the host’s zone list.
Emissions Impact
Scope 2, QUALITATIVE_EMISSIONS, MEDIUM confidence. No direct term, and the sign is not guaranteed: correcting the setpoint spends fan energy to buy back airflow. Any net credit comes from the compensating measures the starved condition provoked — extended fan schedules and a depressed SAT setpoint — both electric at the common site. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
- The population sort happens host-side. The reference consumes the whole
per-zone damper array; library v1 avoids array boundary points, so the host
sorts once and feeds
zone_dmpr_high_avgandzone_dmpr_low_avg(both flaggedderivedin the point dictionary), exactly as AHU-0019 consumeszone_reheat_fraction. The counting is host configuration, not a rule parameter. - The sort is across zones, not across time. PNNL-27338 §2.6.2 describes
zn_dmpr_arras a per-timestep average across the terminal boxes, which would make §2.6.3 step 3 a sort of the time series; step 3’s own wording (“the largest 50% of the zone terminal box damper commands”) and the Figure 2.14/2.15 discussion read it across the zone population. This card takes the across-zones reading — it is the one that makes a two-threshold test a statement about zones — and carries the time dimension inpersistinstead. - Window evaluation becomes continuous persistence. The reference tests
once per 15-minute
data_window;alarm_delayrequires the condition continuously across the same 15 minutes, which is the stricter form (a continuous violation implies the windowed average clears the threshold, not the reverse). Sites whose host does not implement the reference’s warm-up exclusion should raisealarm_delaypast their morning pull-down — AHU-0031 ships 1800 s for the same reason. - The fan conjunct is in the graph, against AHU-0019’s host-side choice.
The reference puts fan status in its main process, so either placement is
faithful; the deciding argument is timer state. With the fan off, boxes park
their dampers open and the population reads exactly like a starved trunk, so
a host-side gate would suppress the output while
persistcharged all night and asserted on the first occupied tick. Wired, the fan stop resets it (fan_cycle_restarts_persistence). Same placement as AHU-0031. - Auto-correction is out of scope. The reference’s AIRCx process writes the setpoint back; this library detects and reports. The retuning schedule is carried as prose (see Notes) so a host that implements the write path owns the override checks and the cap along with it.
- No cluster membership. CLU-02 is the missing-reset syndrome, whose trigger fix (programming trim-and-respond) resolves a setpoint parked at its design value — the AHU-0031 direction. This fault is the opposite sign and is not cleared by that fix, so it stays out of the cluster while sharing its playbook.
missing-resetis the playbook, where AHU-0001 binds none. AHU-0001’s diagnoses are all mechanical repairs with no desk step. Here the first fix is a setpoint change and the second is trim-and-respond configuration, which is that playbook’s step 1.2 and step 2.3-2.4 verbatim.- Severity 3,
category: COMFORT_ENERGY,estimation_method: QUALITATIVE_ONLYare library-authored, following AHU-0001’s treatment of a fault with no computable waste term. The reference’s own statement that the fix saves no fan energy is what rules out an EXCESS_CONSUMPTION framing. - Data sufficiency stays a precondition. The reference guards its window
with
no_required_data— at least five samples inside the 15 minutes — and this card adds a zone-count floor of its own, since two halves of a two-zone population say nothing. Both are host NO_EVAL tests on derived points, and neither is visible to the block graph. persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: a violation already present at load waits out the full 15 minutes instead of alarming on the first tick after a controller restart.
Notes
Retuning is incremental by design. PNNL-27338 §2.6.1-2.6.3 raises the setpoint by 0.15 in. w.g. per diagnostic cycle (15 minutes) toward a hard cap of 2.5 in. w.g., re-evaluating the damper population after each step and stopping early once the zones settle — a rate chosen not to destabilize the fan loop. Walk the setpoint the same way by hand, and watch for the fault clearing well below the cap; reaching the cap with both halves still open is the tell for diagnosis 3 or 4, not for more pressure. Check AHU-0001 first: it and this rule firing together mean the fan is already at the stop, and the work is mechanical.
Test Vectors
10 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
good_operation_dampers_50_to_75 | Reference vector (PNNL-27338 Figure 2.15, good operation): most dampers between 50% and 75%, so the top half averages 72% and the bottom half 55% — the bottom half clears its threshold but the top half does not, and no fault is reported |
whole_population_wide_open | Reference vector (PNNL-27338 Figure 2.14, bad operation): nearly every damper near 100%, top half 98% and bottom half 92% — the trunk is starved and the alarm lands at alarm_delay (900 s) |
busy_zones_only_bottom_half_closed | The discrimination this rule exists for: the top half averages 95% (a few zones at load, or one damper stuck open) while the bottom half sits at 15% — pressure is reaching the zones that are not asking, so the population test stays silent where a max-based test would fire |
high_avg_exactly_at_threshold | Edge case: the top half sits exactly on zone_high_damper_threshold (90%) with the bottom half well above its threshold — the strict > keeps the boundary out of the alarm |
low_avg_exactly_at_threshold | Edge case: the bottom half sits exactly on zone_low_damper_threshold (25%) with the top half at 97% — same strict >, so the quiet half must exceed the threshold rather than sit on it |
both_halves_just_above_thresholds | Edge case, other side: 90.1% and 25.1% clear both thresholds by a tenth of a percent — the alarm still lands at 900 s |
fan_off_dampers_parked_open | Gate: the fan is off and the boxes have driven their dampers open against a dead duct — the damper pattern reads exactly like a starved trunk, and sf_status suppresses the rule outright |
fan_cycle_restarts_persistence | Gate transient: the fan stops at t=300 and restarts at t=600 with the damper pattern unchanged — the timer restarts from the fan restart, so the alarm lands at 1500 s rather than 900 s |
retuning_raises_pressure_before_delay | Transient: the setpoint is raised at t=600 and the boxes throttle back to 70% / 45% — the condition clears inside alarm_delay, which is what a successful re-tuning cycle looks like from the rule’s side |
bottom_half_dip_restarts_persistence | Transient: the bottom half drops to 20% at t=420 and returns to 92% at t=780 — the conjunction breaks and the persistence window restarts, so the alarm lands at 1680 s |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "good_operation_dampers_50_to_75",
"description": "Reference vector (PNNL-27338 Figure 2.15, good operation): most dampers between 50% and 75%, so the top half averages 72% and the bottom half 55% \u2014 the bottom half clears its threshold but the top half does not, and no fault is reported",
"inputs": {
"zone_dmpr_high_avg": 72.0,
"zone_dmpr_low_avg": 55.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "whole_population_wide_open",
"description": "Reference vector (PNNL-27338 Figure 2.14, bad operation): nearly every damper near 100%, top half 98% and bottom half 92% \u2014 the trunk is starved and the alarm lands at alarm_delay (900 s)",
"inputs": {
"zone_dmpr_high_avg": 98.0,
"zone_dmpr_low_avg": 92.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "busy_zones_only_bottom_half_closed",
"description": "The discrimination this rule exists for: the top half averages 95% (a few zones at load, or one damper stuck open) while the bottom half sits at 15% \u2014 pressure is reaching the zones that are not asking, so the population test stays silent where a max-based test would fire",
"inputs": {
"zone_dmpr_high_avg": 95.0,
"zone_dmpr_low_avg": 15.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "high_avg_exactly_at_threshold",
"description": "Edge case: the top half sits exactly on zone_high_damper_threshold (90%) with the bottom half well above its threshold \u2014 the strict `>` keeps the boundary out of the alarm",
"inputs": {
"zone_dmpr_high_avg": 90.0,
"zone_dmpr_low_avg": 60.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "low_avg_exactly_at_threshold",
"description": "Edge case: the bottom half sits exactly on zone_low_damper_threshold (25%) with the top half at 97% \u2014 same strict `>`, so the quiet half must exceed the threshold rather than sit on it",
"inputs": {
"zone_dmpr_high_avg": 97.0,
"zone_dmpr_low_avg": 25.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "both_halves_just_above_thresholds",
"description": "Edge case, other side: 90.1% and 25.1% clear both thresholds by a tenth of a percent \u2014 the alarm still lands at 900 s",
"inputs": {
"zone_dmpr_high_avg": 90.1,
"zone_dmpr_low_avg": 25.1,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "fan_off_dampers_parked_open",
"description": "Gate: the fan is off and the boxes have driven their dampers open against a dead duct \u2014 the damper pattern reads exactly like a starved trunk, and sf_status suppresses the rule outright",
"inputs": {
"zone_dmpr_high_avg": 98.0,
"zone_dmpr_low_avg": 92.0,
"sf_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "fan_cycle_restarts_persistence",
"description": "Gate transient: the fan stops at t=300 and restarts at t=600 with the damper pattern unchanged \u2014 the timer restarts from the fan restart, so the alarm lands at 1500 s rather than 900 s",
"inputs": {
"zone_dmpr_high_avg": 98.0,
"zone_dmpr_low_avg": 92.0,
"sf_status": [
{
"t": 0,
"value": true
},
{
"t": 300,
"value": false
},
{
"t": 600,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1440,
"equals": false
},
{
"output": "yFault",
"from_s": 1560,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "retuning_raises_pressure_before_delay",
"description": "Transient: the setpoint is raised at t=600 and the boxes throttle back to 70% / 45% \u2014 the condition clears inside alarm_delay, which is what a successful re-tuning cycle looks like from the rule's side",
"inputs": {
"zone_dmpr_high_avg": [
{
"t": 0,
"value": 98.0
},
{
"t": 600,
"value": 70.0
}
],
"zone_dmpr_low_avg": [
{
"t": 0,
"value": 92.0
},
{
"t": 600,
"value": 45.0
}
],
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "bottom_half_dip_restarts_persistence",
"description": "Transient: the bottom half drops to 20% at t=420 and returns to 92% at t=780 \u2014 the conjunction breaks and the persistence window restarts, so the alarm lands at 1680 s",
"inputs": {
"zone_dmpr_high_avg": 98.0,
"zone_dmpr_low_avg": [
{
"t": 0,
"value": 92.0
},
{
"t": 420,
"value": 20.0
},
{
"t": 780,
"value": 92.0
}
],
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1620,
"equals": false
},
{
"output": "yFault",
"from_s": 1740,
"to_s": 3600,
"equals": true
}
]
}
]
}