AHU-0026 — Outdoor air damper not closed during unoccupied periods
| Status | verified — engine e2ff2f8, cxf:fnv1a128:162a0ed14faa4a16fc9bb66a7c1eed88, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-04 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0018, AHU-0017 |
| Playbooks | after-hours-operation |
| Source | HVAC FDD Reference v1.0 §9, AHU-0026; PNNL RetuningOpps A11; PNNL-25985 EEM-06 |
| Operating states | all |
Preconditions (host-enforced): Occupancy schedule data available and current; the host evaluates the schedule (time zone, calendar, holidays) into the boolean occ_schedule point. When schedule provenance is unknown or stale, the verdict is NO_EVAL, not healthy.
Points: sf_status, occ_schedule, oa_dmpr_cmd
Outputs:
yFault— True while the supply fan has run unoccupied with the OA damper commanded above oa_closed_threshold for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
oa_closed_threshold | 5.0 | % | dmprOpen.t | Damper command at or below which the outdoor air damper counts as closed |
alarm_delay | 900.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts |
Description
The air handler runs outside the occupancy schedule with the outdoor air damper still commanded open. Unoccupied operation — night setback, morning warmup, a tenant override — needs no ventilation: there is nobody to ventilate for, so every cubic metre of outdoor air drawn in is heated or cooled to no purpose. A damper left at its occupied minimum (typically 15–30%) through a winter night puts the full outdoor-to-return enthalpy difference on the coils for the entire run. Prevalence is about 10%, and the fix is usually a single line in the unoccupied sequence. This is the ventilation half of the after-hours pair: AHU-0018 asks whether the fan should be running at all, this one asks whether it is dragging in outdoor air while it runs.
Detection Logic
yFault = sf_status
AND NOT occ_schedule
AND oa_dmpr_cmd > oa_closed_threshold
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
unoccRun establishes that the fan is running while the schedule says
unoccupied; dmprOpen tests the damper command against the closed threshold
with a strict comparison, so a damper parked at exactly 5% (leakage band, or a
minimum-position parameter never zeroed but within tolerance) does not alarm.
persist requires the combination to hold for alarm_delay, which rides out
the damper stroke at a scheduled occupied→unoccupied transition. Any fan stop,
return to occupancy, or damper close resets the timer, and a damper that reopens
restarts the full 15 min; delayOnInit = true holds the window across a
controller restart.
Possible Diagnoses
- Minimum OA position not set to 0% in the unoccupied sequence — the damper holds its occupied minimum around the clock
- OA damper stuck open (seized linkage, failed actuator, disconnected crank arm)
- Economizer logic overriding unoccupied damper control — the economizer sees a favorable outdoor condition and modulates the damper open without consulting occupancy
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is the
conditioning load on unnecessary outdoor air: waste_kw = (oa_dmpr_cmd/100) × design_oa_flow × cp × |OAT − RAT|. With the damper open, 100% of ventilation
energy during the unoccupied run is waste; measured against total off-hours
energy the correction typically returns 3–10% (PNNL-25985 EEM-06, OA damper
faults; PNNL RetuningOpps A11). Heating-dominant: the winter night is when
|OAT − RAT| is largest.
Emissions Impact
Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 1,000–8,000 kg CO₂e/yr for the ventilation energy alone. The split follows the heating source — scope 1 for the gas burned to temper unnecessary outdoor air, scope 2 for the electric heat or cooling. Because the fault runs overnight, the electric share should be valued at the marginal rather than average grid rate. Avoided-emissions basis: MOER.
Deviations
- No grace period, matching the reference, where AHU-0018 grants 30 minutes. The difference is deliberate: legitimate unoccupied fan operation — setback, warmup, an authorized override — should still run with the OA damper closed, so there is no state this rule needs to forgive.
- The reference’s logic uses a schedule-evaluation predicate over an occupancy
schedule object; as in AHU-0018 we consume the host-evaluated boolean
occ_schedulepoint, leaving time zone, calendar, and holiday interpretation to the host (seepoints/ahu.points.jsonnotes onocc_schedule). - Severity 3 (warning), per the reference’s chapter 9 card — its only severity statement for this fault, since the §5.8.1 index carries no severity column. This chapter’s README previously mistranscribed it as 2, corrected alongside this card.
- The reference tags this fault for both AHU and RTU. This is the AHU-family instance; an RTU sibling would reuse the same block graph against the RTU point dictionary.
delayOnInit = trueonpersist: a controller restart mid-condition still waits out the full persistence window (same startup-alarm rationale as AHU-0016 and AHU-0018).
Notes
The remote fix is a sequence edit — zero the minimum OA position in unoccupied mode and gate the economizer on occupancy — Step 2 of the after-hours-operation playbook. Firing alongside AHU-0018 (the CLU-04 trigger), fix the schedule first: restoring the unoccupied period often closes the damper as a side effect. Firing without FC-052, the schedule is right and the damper sequence or the actuator is wrong.
Test Vectors
9 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
unoccupied_damper_open | Reference vector: fan on, damper 30%, unoccupied; alarms after alarm_delay (900 s) |
unoccupied_damper_closed | Reference vector: fan on unoccupied with the OA damper commanded shut — the intended night sequence |
occupied_damper_open | Reference vector: fan on, damper 30%, occupied — minimum OA is required |
fan_off_damper_open | Damper left open unoccupied but the fan is off; no air is being drawn, so no ventilation waste |
damper_at_threshold | Threshold edge: damper exactly at oa_closed_threshold (5%); the comparison is strict, so leakage-band positions do not alarm |
damper_just_above_threshold | Threshold edge: damper 0.1% above oa_closed_threshold alarms on the same schedule as a wide-open damper |
damper_closes_within_alarm_delay | Transient: the unoccupied sequence drives the damper shut at t=600 s, before alarm_delay completes; never alarms |
damper_reopens_restarts_persistence | Transient: damper closes at t=600 s then reopens at t=1200 s; persistence restarts from the reopen, alarming at 2100 s |
occupancy_resumes_resets_persistence | Transient: the occupied period starts at t=600 s while the damper is open; the timer resets and never alarms |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "unoccupied_damper_open",
"description": "Reference vector: fan on, damper 30%, unoccupied; alarms after alarm_delay (900 s)",
"inputs": {
"sf_status": true,
"oa_dmpr_cmd": 30.0,
"occ_schedule": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "unoccupied_damper_closed",
"description": "Reference vector: fan on unoccupied with the OA damper commanded shut \u2014 the intended night sequence",
"inputs": {
"sf_status": true,
"oa_dmpr_cmd": 0.0,
"occ_schedule": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "occupied_damper_open",
"description": "Reference vector: fan on, damper 30%, occupied \u2014 minimum OA is required",
"inputs": {
"sf_status": true,
"oa_dmpr_cmd": 30.0,
"occ_schedule": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "fan_off_damper_open",
"description": "Damper left open unoccupied but the fan is off; no air is being drawn, so no ventilation waste",
"inputs": {
"sf_status": false,
"oa_dmpr_cmd": 30.0,
"occ_schedule": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_at_threshold",
"description": "Threshold edge: damper exactly at oa_closed_threshold (5%); the comparison is strict, so leakage-band positions do not alarm",
"inputs": {
"sf_status": true,
"oa_dmpr_cmd": 5.0,
"occ_schedule": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_just_above_threshold",
"description": "Threshold edge: damper 0.1% above oa_closed_threshold alarms on the same schedule as a wide-open damper",
"inputs": {
"sf_status": true,
"oa_dmpr_cmd": 5.1,
"occ_schedule": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "damper_closes_within_alarm_delay",
"description": "Transient: the unoccupied sequence drives the damper shut at t=600 s, before alarm_delay completes; never alarms",
"inputs": {
"sf_status": true,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 30.0
},
{
"t": 600,
"value": 0.0
}
],
"occ_schedule": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_reopens_restarts_persistence",
"description": "Transient: damper closes at t=600 s then reopens at t=1200 s; persistence restarts from the reopen, alarming at 2100 s",
"inputs": {
"sf_status": true,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 30.0
},
{
"t": 600,
"value": 0.0
},
{
"t": 1200,
"value": 30.0
}
],
"occ_schedule": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2040,
"equals": false
},
{
"output": "yFault",
"from_s": 2160,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "occupancy_resumes_resets_persistence",
"description": "Transient: the occupied period starts at t=600 s while the damper is open; the timer resets and never alarms",
"inputs": {
"sf_status": true,
"oa_dmpr_cmd": 30.0,
"occ_schedule": [
{
"t": 0,
"value": false
},
{
"t": 600,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
}
]
}