SYS-0004 — Exhaust fan running during unoccupied hours
| Status | verified — engine e2ff2f8, cxf:fnv1a128:bf696be4fccaa4a8f80be2adea0a2b4a, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-04, CLU-08 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0018, SYS-0003, SYS-0008 |
| Playbooks | exhaust-fan-schedule-misalignment, after-hours-operation |
| Source | HVAC FDD Reference v1.0 §16, SYS-0004 (pdf pp. 141-142) — equation, AlarmDelay 15 min, severity 3 (warning), the four diagnoses, and the whole impact profile; The reference’s own provenance line for that card: Mazzetto 2025; PNNL EEM-07 (exhaust fan control); PNNL-25985 for the 0.5-3% site figure; PNNL RetuningOpps S08 for the ~35% prevalence; Library precedent: AHU-0018 (host-evaluated occupancy boolean, override conjunct, Not/And/TrueDelay shape) |
| Operating states | all — the conjunction is self-gating on unoccupied hours with no override |
Preconditions (host-enforced): demand_override_active must aggregate EVERY legitimate hold on this fan before the rule is trusted: kitchen hood interlock, restroom or locker-room call, fume-hood or lab makeup, DCV purge, night flush, post-occupancy runout. The dictionary entry is a bare BAS flag with no Brick class behind it, so nothing checks that the host wired them all in, and each one missed is a nightly false positive with a real reason behind it. Fans that must run continuously by code — general lab exhaust, elevator machine rooms, battery and chemical storage — are not instances of this rule at all, and are excluded by not binding them rather than by anything the graph does. ef_status should be proven run status (current switch, differential pressure, VFD run feedback), not the start command, since diagnosis 3 is a relay that closed without being told to. occ_scheduled is host-evaluated from the schedule that governs THIS fan; where the exhaust fan follows its own schedule rather than the AHU’s, that is the one to feed, and unknown or stale schedule provenance is NO_EVAL rather than unoccupied.
Points: ef_status, occ_scheduled, demand_override_active
Outputs:
yFault— True while the exhaust fan has been proven running outside the occupied schedule with no demand override active, continuously for alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
alarm_delay | 900.0 | s | persist.delayTime | Continuous persistence required before the alarm asserts (15 min). The reference’s only tunable for this rule. |
Description
An exhaust fan running all night in an empty building. The fan’s own kilowatts are the small half of the bill: every cubic metre it throws away is replaced by outdoor air pulled through whatever the envelope offers, and that air gets conditioned — or it does not, and the building sits at negative pressure until morning with the heating plant chasing infiltration it was never sized for. That is why the reference calls a fan motor’s waste heating-dominant. It is also unusually common: PNNL’s retuning survey puts exhaust-fan schedule problems in roughly 35% of buildings, because exhaust fans are commissioned by a different trade than the AHU, often sit on their own timeclock, and appear on no graphic. The third term is where the engineering is — plenty of after-hours exhaust is correct, so the rule only accuses a fan running with nothing claiming responsibility for it.
Detection Logic
yFault = ef_status
AND NOT occ_scheduled
AND NOT demand_override_active
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Five blocks, all boolean: no thresholds, nothing to retune but the delay.
Each conjunct blocks the fault by itself. An override that expires while the fan
keeps running starts the clock from the expiry, not from the start of the run.
The reverse edge is instant — TrueDelay delays only the rising edge, so a
matured finding drops on the tick the occupied period opens.
persist asserts at exactly T + delayTime, so the realized test is “running
unoccupied and unjustified for strictly more than alarm_delay” at tick
resolution, and an interruption discards the elapsed time rather than pausing
it. delayOnInit = true (CDL default false) makes a controller restarting at
02:00 into a running fan wait out the full 15 minutes.
Fifteen minutes is the reference’s number and it is short for this library (most rules hold for 30). The condition it guards is a discrete state rather than a noisy analog signal, so the delay buys immunity to short legitimate runs and to a status point that flickers at startup, and nothing else.
Possible Diagnoses
The reference’s four, in its order:
- Schedule misconfiguration — the fan’s schedule was never built, or is a copy of an occupied-hours-plus-buffer schedule nobody trimmed. Most common, $0
- Override stuck in the BAS — a manual hold or a BACnet priority-array entry from a service call that nobody released
- Fan relay stuck closed — the controller is commanding off and the fan runs
anyway, the case that separates a proven
ef_statusfrom a command point - Interlock with the AHU not configured — the fan has no relationship to the air handler it belongs with, which is SYS-0008’s subject
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile.
waste_kw = ef_rated_kw × (ef_speed/100)³ + conditioning penalty: the cube law
on speed, plus the thermal cost of replacing what went out the roof. PNNL-25985
puts EEM-07 at 0.5-3% of site energy, modest per fan and additive because
buildings have many of them and roughly a third have the problem. Climate
sensitivity is heating-dominant: the fan does not care about the weather, the
makeup air does, and an unbalanced building in January pays for every cubic
metre twice — once to heat it and once in perimeter complaints next morning.
Emissions Impact
Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s range is 200-2,000 kg CO₂e/yr covering the fan plus the conditioning penalty. Avoided-emissions basis MOER (marginal) — like the other after-hours faults this one runs overnight when the marginal generator is dirtiest, so its emissions weight runs ahead of its energy cost. The conditioning half is Scope 1 wherever the makeup air is heated by a fuel-fired plant; the reference assigns the whole card Scope 2 and this transcribes that assignment rather than splitting it.
Deviations
occ_scheduledreplaces the reference’s schedule-evaluation call. The reference writesNOT in_occupied_schedule(current_time, occ_schedule), a function over a calendar. The block graph has no clock, so the host evaluates the schedule and feeds the boolean, exactly as AHU-0018 does;points/sys.points.jsonrecords it as a derived point.- The point is named
occ_scheduledhere andocc_scheduleinpoints/ahu.points.json. One concept, two spellings across dictionaries. Cards bind by exact name within their own family so nothing breaks, but it is worth resolving library-wide. demand_override_activeis a bare BAS flag with no ontology behind it. The dictionary entry carriesbrick: null, s223: nullbecause Brick 1.4.4 models no override status. The consequence lands inpreconditionsrather than in the graph: the rule treats “no override” as “no justification,” and a site that has not wired every legitimate hold into that one boolean gets nightly false positives it will learn to ignore.- One delay, not two. The reference lists a single tunable for this rule,
AlarmDelay = 15 min, so there is oneTrueDelayand no grace period. The sibling AHU-0018 has agrace_periodbecause its own entry gives it one; none was invented here to match. - No thresholds, so the library’s strict-comparison deviation does not apply.
Every input is a boolean and the graph contains no
Realsblock. delayOnInit = true(CDL defaultfalse), the library’s standing choice: a controller restarting at 02:00 into a running fan waits out the full 15 minutes rather than alarming on its first tick.TrueDelayasserts at exactlyT + delayTime, so the realized test is “running unoccupied and unjustified for strictly more thanalarm_delay” at tick resolution.- Overlaps SYS-0008 and neither rule suppresses the other. A fan running
unoccupied with its AHU off satisfies this rule and SYS-0008’s condition 1
at once; this one alarms at 900 s and that one at 2700 s. Both findings are
true and they carry different fixes — turn the fan off after hours here,
synchronize it with the AHU there — so
suppressesstays empty both ways and CLU-08 groups them. Whether the cluster should promote one to trigger is aclusters/clusters.jsonquestion for whoever owns that file (CLU-08’s trigger today is AHU-0018). - The rule sees run status, never speed or power.
ef_speedandef_rated_kwinruntime_estimationare host-side; no such point is bound, and a VFD-driven fan idling at 20% trips this rule exactly as hard as one at full speed while wasting an eighth of the energy. Accumulation and ranking are the host’s. - Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph. There is no NO_EVAL logic in the graph: it computes the fault given valid data.
Notes
Trend the fan’s status against the AHU’s for a week before touching anything. Three patterns come out of that plot with different fixes: a fan running 24/7 has no schedule at all, a fan that stops hours after the AHU has a schedule copied and never trimmed, and a fan starting at odd hours is following an override or a local switch. Only the first two are $0.
Where the finding is a local timeclock or a wall switch outside the BAS, the fix is not a schedule edit. The exhaust-fan-schedule-misalignment playbook files that case under Step 2 “Remote fix,” and it is not one — someone has to stand at the panel. Bringing the fan under BAS control is a small capital job, worth saying so in the work order rather than discovering it on site.
Test Vectors
8 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
fan_running_during_occupied_hours | The normal case: the exhaust fan runs inside the occupied schedule. |
fan_off_during_unoccupied_hours | The fan conjunct blocking on its own: unoccupied and the fan is off, which is the outcome the rule exists to produce. |
fan_running_unoccupied_with_no_override | The reference condition. Fan proven on, outside the schedule, nothing holding it; TrueDelay asserts at exactly alarm_delay (900 s). |
demand_override_justifies_the_run | The override conjunct blocking on its own: same after-hours run, but a demand override (kitchen hood, restroom call, lab purge) is holding the fan on legitimately. |
fan_stops_on_the_alarm_tick | The delay edge from below: the fan stops at exactly 900 s, so the condition is false on the tick the delay would have matured and nothing is reported. |
fan_stops_one_tick_later | The same edge from above: one tick of alarm at 900 s, then clear. |
override_drops_and_starts_the_clock | A legitimate override expires at 1200 s and the fan keeps running anyway. The 1200 s already elapsed does not count — the alarm lands 900 s after the justification disappears. |
schedule_resumes_and_clears_the_alarm | Recovery: the alarm asserts at 900 s and the occupied schedule opens at 1800 s, dropping it on that tick — TrueDelay delays the rising edge only. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "fan_running_during_occupied_hours",
"description": "The normal case: the exhaust fan runs inside the occupied schedule.",
"inputs": {
"ef_status": true,
"occ_scheduled": true,
"demand_override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "fan_off_during_unoccupied_hours",
"description": "The fan conjunct blocking on its own: unoccupied and the fan is off, which is the outcome the rule exists to produce.",
"inputs": {
"ef_status": false,
"occ_scheduled": false,
"demand_override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "fan_running_unoccupied_with_no_override",
"description": "The reference condition. Fan proven on, outside the schedule, nothing holding it; TrueDelay asserts at exactly alarm_delay (900 s).",
"inputs": {
"ef_status": true,
"occ_scheduled": false,
"demand_override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 900,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "demand_override_justifies_the_run",
"description": "The override conjunct blocking on its own: same after-hours run, but a demand override (kitchen hood, restroom call, lab purge) is holding the fan on legitimately.",
"inputs": {
"ef_status": true,
"occ_scheduled": false,
"demand_override_active": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "fan_stops_on_the_alarm_tick",
"description": "The delay edge from below: the fan stops at exactly 900 s, so the condition is false on the tick the delay would have matured and nothing is reported.",
"inputs": {
"ef_status": [
{
"t": 0,
"value": true
},
{
"t": 900,
"value": false
}
],
"occ_scheduled": false,
"demand_override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "fan_stops_one_tick_later",
"description": "The same edge from above: one tick of alarm at 900 s, then clear.",
"inputs": {
"ef_status": [
{
"t": 0,
"value": true
},
{
"t": 960,
"value": false
}
],
"occ_scheduled": false,
"demand_override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 900,
"to_s": 900,
"equals": true
},
{
"output": "yFault",
"from_s": 960,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "override_drops_and_starts_the_clock",
"description": "A legitimate override expires at 1200 s and the fan keeps running anyway. The 1200 s already elapsed does not count \u2014 the alarm lands 900 s after the justification disappears.",
"inputs": {
"ef_status": true,
"occ_scheduled": false,
"demand_override_active": [
{
"t": 0,
"value": true
},
{
"t": 1200,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2040,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "schedule_resumes_and_clears_the_alarm",
"description": "Recovery: the alarm asserts at 900 s and the occupied schedule opens at 1800 s, dropping it on that tick \u2014 TrueDelay delays the rising edge only.",
"inputs": {
"ef_status": true,
"occ_scheduled": [
{
"t": 0,
"value": false
},
{
"t": 1800,
"value": true
}
],
"demand_override_active": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 840,
"equals": false
},
{
"output": "yFault",
"from_s": 900,
"to_s": 1740,
"equals": true
},
{
"output": "yFault",
"from_s": 1800,
"to_s": 3600,
"equals": false
}
]
}
]
}