AHU-0018 — Unoccupied override — equipment running during off-hours
| Status | verified — engine e2ff2f8, cxf:fnv1a128:964ec962a0c3d75681dcc1c4e0f6c66b, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 1 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-04, CLU-08 |
| Suppresses | — |
| Suppressed by | — |
| Related | SYS-0003, SYS-0004, AHU-0026 |
| Playbooks | after-hours-operation |
| Source | HVAC FDD Reference v1.0 §9, AHU-0018; PNNL-27338 §2; Mazzetto 2025 |
| 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, override_active
Outputs:
yFault— True while the supply fan has been running unoccupied, past the grace period, with no active override, for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
grace_period | 1800.0 | s | unoccGrace.delayTime | Time after the occupied period ends before unoccupied operation counts against the rule |
alarm_delay | 900.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts |
Description
The supply fan is operating outside the defined occupancy schedule when no active override or demand condition justifies it. While this fault is active, essentially the entire AHU energy draw — fan power plus any heating or cooling of outdoor air — is waste. Roughly 15% of buildings exhibit it, usually from schedule misconfiguration or a stuck override, and it is the trigger rule for both the After-Hours Operation (CLU-04) and Schedule Dysfunction (CLU-08) clusters. It is also among the fastest faults to pay back: the fix is almost always a $0 schedule or override correction.
Detection Logic
yFault = sf_status
AND (NOT occ_schedule sustained for grace_period)
AND NOT override_active
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
unoccGrace implements the reference’s grace period: the unoccupied state
must persist for grace_period before it counts, so normal operation
continuing briefly past schedule end never alarms. persist then requires the
full justification-free condition (fan on · unoccupied past grace · no
override) to hold for alarm_delay. Any re-occupancy, fan stop, or override
activation resets the corresponding timer. Worst-case time to alarm after
schedule end is grace_period + alarm_delay (default 45 min).
Possible Diagnoses
- Schedule misconfiguration or incorrect time zone (DST mismatches are a common culprit)
- Stuck override in the BAS (BACnet priority array holding the fan on)
- Fan relay or contactor stuck closed
- Occupancy sensor triggering unnecessarily
- Night setback / morning warmup running too long
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. While active, the entire
AHU draw is waste: waste_kw = ahu_fan_design_kw × (sf_speed/100)³ + active heating/cooling (cubed fan law on speed; thermal penalty typically 1.5–3× the
fan energy). Savings range 3–16% of site energy (PNNL-25985 EEM-04 shortened
schedules / EEM-16 night setbacks). Climate-neutral: waste scales with
operating hours, not weather. Prevalence ~15%.
Emissions Impact
Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; typical 3,000–20,000 kg CO₂e/yr (full AHU energy during unoccupied hours). After-hours waste lands in evening/overnight hours when the marginal grid generator is often coal or gas peaking — in solar-heavy regions the nighttime MOER can be 2–3× the midday value, so this fault’s emissions rank can exceed its energy-cost rank. Avoided-emissions basis: MOER.
Deviations
- The reference’s logic calls
in_occupied_schedule(current_time, occ_schedule)— a schedule-evaluation function over a schedule object. Our rule consumes the host-evaluated booleanocc_schedulepoint instead; schedule interpretation (time zone, calendar, holiday exceptions) is a host concern, consistent with this library’s precondition philosophy and with 223P/Brick having no schedule vocabulary to describe one (seepoints/ahu.points.jsonnotes). - The reference lists
grace_periodas “minutes after schedule end to allow.” We implement it as aTrueDelayon the unoccupied signal, which grants the same grace after schedule end and equally after any occupied→unoccupied transition — equivalent for schedule-driven hosts, and safer for hosts that driveocc_schedulefrom occupancy sensing. delayOnInit = trueon both timers: a controller restart mid-condition still waits out the full grace + persistence window (same startup-alarm rationale as AHU-0016).
Notes
Remote fix succeeds ~95% of the time at $0 (schedule correction, override release, setback enablement). When this rule fires, check SYS-0003 (lighting) and SYS-0004 (exhaust fans) — they frequently share the same master-schedule root cause, which is why this rule triggers two clusters.
Test Vectors
6 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
normal_occupied | Reference vector: fan on during scheduled occupancy |
normal_unoccupied | Reference vector: fan off while unoccupied |
running_after_hours | Reference vector: fan on, unoccupied, no override; alarms after grace_period (1800 s) + alarm_delay (900 s) |
override_justifies_run | Reference vector: fan on, unoccupied, but an active override justifies it |
fan_stops_within_grace | Fan shuts down before the grace period elapses; never alarms |
reoccupied_resets_persistence | Occupancy resumes after grace elapsed but before alarm_delay completes; timers reset, never alarms |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "normal_occupied",
"description": "Reference vector: fan on during scheduled occupancy",
"inputs": {
"sf_status": true,
"occ_schedule": true,
"override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "normal_unoccupied",
"description": "Reference vector: fan off while unoccupied",
"inputs": {
"sf_status": false,
"occ_schedule": false,
"override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "running_after_hours",
"description": "Reference vector: fan on, unoccupied, no override; alarms after grace_period (1800 s) + alarm_delay (900 s)",
"inputs": {
"sf_status": true,
"occ_schedule": false,
"override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2640,
"equals": false
},
{
"output": "yFault",
"from_s": 2760,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "override_justifies_run",
"description": "Reference vector: fan on, unoccupied, but an active override justifies it",
"inputs": {
"sf_status": true,
"occ_schedule": false,
"override_active": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "fan_stops_within_grace",
"description": "Fan shuts down before the grace period elapses; never alarms",
"inputs": {
"sf_status": [
{
"t": 0,
"value": true
},
{
"t": 1500,
"value": false
}
],
"occ_schedule": false,
"override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "reoccupied_resets_persistence",
"description": "Occupancy resumes after grace elapsed but before alarm_delay completes; timers reset, never alarms",
"inputs": {
"sf_status": true,
"occ_schedule": [
{
"t": 0,
"value": false
},
{
"t": 2000,
"value": true
}
],
"override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
}
]
}