AHU-0031 — Supply fan operating at excessive static pressure
| Status | verified — engine e2ff2f8, cxf:fnv1a128:7137eb21f1dba596056b75ab058fd631, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | HIGH |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | CLU-02 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0024, AHU-0001, FPB-0002 |
| Playbooks | missing-reset |
| Source | HVAC FDD Reference v1.0 §9, AHU-0031; PNNL-25985 EEM-12/EEM-15; NIST TN 2024; PNNL-27338 |
| Operating states | Occupied, fan running |
Preconditions (host-enforced): AHU serves multiple zones; zone damper feedback available and aggregated by the host into zone_dmpr_pos_max, as AHU-0024. When zone damper data is missing or stale, the verdict is NO_EVAL, not healthy.
Points: dsp, dsp_sp, zone_dmpr_pos_max, sf_status
Outputs:
yFault— True while duct static pressure has held above high_sp_fraction of its setpoint with every zone damper below low_demand_damper_threshold and the fan running, for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
high_sp_fraction | 0.95 | 1 | scaled.k | Fraction of the DSP setpoint above which the fan counts as running at the top of its pressure band |
low_demand_damper_threshold | 50.0 | % | dmprLow.t | Highest zone damper position below which no zone is asking for the pressure being delivered |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
The fan holds duct static pressure at the top of its band while every zone damper sits well below fully open. Together those facts say the setpoint is higher than the system needs: the dampers are throttling away pressure the fan spent energy producing, and the excess leaves as noise, leakage, and heat. Fan power scales with the cube of pressure, so a setpoint 20% above what the worst zone needs costs roughly half again the fan energy — and trimming it back returns that energy immediately at no capital cost. DSP reset is absent in 74% of buildings (PNNL 151-building study); this is the CLU-02 member that catches the resulting operating symptom.
Detection Logic
yFault = dsp > dsp_sp × high_sp_fraction
AND zone_dmpr_pos_max < low_demand_damper_threshold
AND sf_status
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
scaled turns the live setpoint into the top-of-band pressure — a ratio rather
than a fixed offset, so the test travels with a setpoint the reset is allowed
to move. spHigh compares the measurement against that band and dmprLow
against the host-aggregated damper maximum; demand requires both, which is the
whole diagnostic content of the rule. Note what the pair excludes: high pressure
with an open damper is a system doing its job, and low dampers at low pressure
are a reset already working. gated adds the fan-running condition, since dsp
and dsp_sp mean nothing with the fan off and a stale reading would otherwise
alarm overnight. persist requires 30 minutes of continuous violation, riding
out morning start-up and the settling period after a setpoint change;
delayOnInit = true holds that window across a controller restart.
Possible Diagnoses
- DSP setpoint configured too high — a design-static value entered once and never revisited
- Trim-and-respond active but not aggressive enough (trim magnitude too small, trim interval too long, or a minimum setpoint floor set at the old fixed value)
- DSP reset responding to one rogue zone — a single box with a stuck damper or a failed flow sensor requests pressure continuously and holds the whole system up
- Zone damper feedback not wired back to the AHU controller, so the reset has nothing to respond to and parks at maximum
Energy Impact
EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION (EEM-12). Excess fan
power follows the cube law: excess_fan_kw = ahu_fan_design_kw × [1 − (needed_dsp/actual_dsp)³], where needed_dsp is the pressure at which the
most-open zone reaches its damper target. Savings run 5–30% of fan energy
depending on how far above the true requirement the setpoint sits — the wide
range is the cube law, not measurement uncertainty. Climate-neutral.
Emissions Impact
Scope 2, PROXY_EMISSIONS, HIGH confidence; typical 300–2,000 kg CO₂e/yr (excess fan energy). Fan waste runs through the whole occupied period, so it lands across the daytime grid mix rather than concentrating in any one hour. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
- The reference’s
dsp >= dsp_sp × high_sp_fractionbecomes a strict>: CDL has noReals.GreaterEqual, the exact-equality case has measure zero on a measured pressure signal, and the strict form errs toward silence. - The reference takes
max()over a per-zone damper array; library v1 avoids array boundary points, so the host aggregates and feeds the scalarzone_dmpr_pos_max— the same point AHU-0024 consumes, flaggedderivedin the point dictionary. - The fan-running condition is in the block graph rather than the
preconditions: the library gates operating state host-side, but the reference
states
sf_status = ONas part of its detection logic andsf_statusis a canonical point, so it is wired as a boundary input (as in AHU-0018). The multi-zone and damper-data preconditions stay in frontmatter. high_sp_fractionis dimensionless (0–1), consistent with the reference’s 0.95;low_demand_damper_thresholdstays in percent becausezone_dmpr_pos_maxis a percent point.persist.delayOnInit = true(Modelica/CDL default isfalse), 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
This rule and AHU-0024 are the DSP half of CLU-02 from opposite sides. FC-058
watches the setpoint sit flat for three days and concludes the reset is absent;
this rule does not care whether a reset exists, only that the fan is pushing
pressure nobody is asking for. A misconfigured but active reset leaves FC-058
quiet and fires this rule — diagnosis 2. Both share the missing-reset playbook
and the same $0 fix per G36 §5.16.1.
Before touching the setpoint, check for a rogue zone (playbook step 1.2). Healthy operation puts most zone dampers in the 50–75% band; all near 0% with pressure at maximum is this rule’s signature, and all near 100% is the opposite fault (AHU-0001, insufficient static pressure).
Test Vectors
9 scenarios, clock step 60 s over 5400 s.
| Scenario | Description |
|---|---|
pressure_below_high_band | Reference vector: 350 Pa against a 400 Pa setpoint (scaled band 380 Pa) with the highest damper at 85% — pressure is not pinned high, nothing to report |
high_pressure_low_demand | Reference vector: 390 Pa against a 400 Pa setpoint with the highest damper at 35% — pressure held at the top of the band while no zone needs air; alarms after alarm_delay (1800 s) |
high_pressure_high_demand | Reference vector: 390 Pa against a 400 Pa setpoint with the highest damper at 95% — the pressure is being asked for, so no fault |
pressure_exactly_at_scaled_setpoint | Edge case: dsp sits exactly on dsp_sp × high_sp_fraction (380 Pa) with dampers at 35% — the strict > keeps the boundary out of the alarm |
damper_exactly_at_threshold | Edge case: the highest damper sits exactly on low_demand_damper_threshold (50%) with pressure pinned at 390 Pa — the strict < means demand must be below the threshold, not at it |
fan_off_stale_pressure | Gate: the fan is off while the pressure and damper readings still show the fault pattern — sf_status suppresses the rule outright |
fan_cycle_restarts_persistence | Transient: the fan stops at t=600 and restarts at t=900 with the pressure pattern otherwise unchanged — persistence restarts from the restart, so the alarm lands at 2700 s, not 1800 s |
reset_trims_pressure_before_delay | Transient: trim-and-respond drops the setpoint to 300 Pa at t=1200 and the fan follows to 280 Pa — the condition clears inside alarm_delay, so a working reset never alarms |
dampers_open_before_delay | Transient: a zone opens up at t=1200 and takes the highest damper to 80% while pressure stays pinned — demand appears before the timer expires, so no alarm |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 5400
},
"scenarios": [
{
"name": "pressure_below_high_band",
"description": "Reference vector: 350 Pa against a 400 Pa setpoint (scaled band 380 Pa) with the highest damper at 85% \u2014 pressure is not pinned high, nothing to report",
"inputs": {
"dsp": 350.0,
"dsp_sp": 400.0,
"zone_dmpr_pos_max": 85.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "high_pressure_low_demand",
"description": "Reference vector: 390 Pa against a 400 Pa setpoint with the highest damper at 35% \u2014 pressure held at the top of the band while no zone needs air; alarms after alarm_delay (1800 s)",
"inputs": {
"dsp": 390.0,
"dsp_sp": 400.0,
"zone_dmpr_pos_max": 35.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "high_pressure_high_demand",
"description": "Reference vector: 390 Pa against a 400 Pa setpoint with the highest damper at 95% \u2014 the pressure is being asked for, so no fault",
"inputs": {
"dsp": 390.0,
"dsp_sp": 400.0,
"zone_dmpr_pos_max": 95.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "pressure_exactly_at_scaled_setpoint",
"description": "Edge case: dsp sits exactly on dsp_sp \u00d7 high_sp_fraction (380 Pa) with dampers at 35% \u2014 the strict `>` keeps the boundary out of the alarm",
"inputs": {
"dsp": 380.0,
"dsp_sp": 400.0,
"zone_dmpr_pos_max": 35.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "damper_exactly_at_threshold",
"description": "Edge case: the highest damper sits exactly on low_demand_damper_threshold (50%) with pressure pinned at 390 Pa \u2014 the strict `<` means demand must be below the threshold, not at it",
"inputs": {
"dsp": 390.0,
"dsp_sp": 400.0,
"zone_dmpr_pos_max": 50.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "fan_off_stale_pressure",
"description": "Gate: the fan is off while the pressure and damper readings still show the fault pattern \u2014 sf_status suppresses the rule outright",
"inputs": {
"dsp": 390.0,
"dsp_sp": 400.0,
"zone_dmpr_pos_max": 35.0,
"sf_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "fan_cycle_restarts_persistence",
"description": "Transient: the fan stops at t=600 and restarts at t=900 with the pressure pattern otherwise unchanged \u2014 persistence restarts from the restart, so the alarm lands at 2700 s, not 1800 s",
"inputs": {
"dsp": 390.0,
"dsp_sp": 400.0,
"zone_dmpr_pos_max": 35.0,
"sf_status": [
{
"t": 0,
"value": true
},
{
"t": 600,
"value": false
},
{
"t": 900,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2640,
"equals": false
},
{
"output": "yFault",
"from_s": 2760,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "reset_trims_pressure_before_delay",
"description": "Transient: trim-and-respond drops the setpoint to 300 Pa at t=1200 and the fan follows to 280 Pa \u2014 the condition clears inside alarm_delay, so a working reset never alarms",
"inputs": {
"dsp": [
{
"t": 0,
"value": 390.0
},
{
"t": 1200,
"value": 280.0
}
],
"dsp_sp": [
{
"t": 0,
"value": 400.0
},
{
"t": 1200,
"value": 300.0
}
],
"zone_dmpr_pos_max": 35.0,
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "dampers_open_before_delay",
"description": "Transient: a zone opens up at t=1200 and takes the highest damper to 80% while pressure stays pinned \u2014 demand appears before the timer expires, so no alarm",
"inputs": {
"dsp": 390.0,
"dsp_sp": 400.0,
"zone_dmpr_pos_max": [
{
"t": 0,
"value": 35.0
},
{
"t": 1200,
"value": 80.0
}
],
"sf_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
}
]
}