Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AHU-0026 — Outdoor air damper not closed during unoccupied periods

Statusverified — engine e2ff2f8, cxf:fnv1a128:162a0ed14faa4a16fc9bb66a7c1eed88, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-04
Suppresses
Suppressed by
RelatedAHU-0018, AHU-0017
Playbooksafter-hours-operation
SourceHVAC FDD Reference v1.0 §9, AHU-0026; PNNL RetuningOpps A11; PNNL-25985 EEM-06
Operating statesall

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:

NameDefaultUnitCXF pathDescription
oa_closed_threshold5.0%dmprOpen.tDamper command at or below which the outdoor air damper counts as closed
alarm_delay900.0spersist.delayTimeContinuous 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):

AHU-0026 block graph

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

  1. Minimum OA position not set to 0% in the unoccupied sequence — the damper holds its occupied minimum around the clock
  2. OA damper stuck open (seized linkage, failed actuator, disconnected crank arm)
  3. 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_schedule point, leaving time zone, calendar, and holiday interpretation to the host (see points/ahu.points.json notes on occ_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 = true on persist: 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.

ScenarioDescription
unoccupied_damper_openReference vector: fan on, damper 30%, unoccupied; alarms after alarm_delay (900 s)
unoccupied_damper_closedReference vector: fan on unoccupied with the OA damper commanded shut — the intended night sequence
occupied_damper_openReference vector: fan on, damper 30%, occupied — minimum OA is required
fan_off_damper_openDamper left open unoccupied but the fan is off; no air is being drawn, so no ventilation waste
damper_at_thresholdThreshold edge: damper exactly at oa_closed_threshold (5%); the comparison is strict, so leakage-band positions do not alarm
damper_just_above_thresholdThreshold edge: damper 0.1% above oa_closed_threshold alarms on the same schedule as a wide-open damper
damper_closes_within_alarm_delayTransient: the unoccupied sequence drives the damper shut at t=600 s, before alarm_delay completes; never alarms
damper_reopens_restarts_persistenceTransient: damper closes at t=600 s then reopens at t=1200 s; persistence restarts from the reopen, alarming at 2100 s
occupancy_resumes_resets_persistenceTransient: 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
        }
      ]
    }
  ]
}