SYS-0008 — Exhaust fan schedule misalignment with AHU
| Status | verified — engine e2ff2f8, cxf:fnv1a128:763b463070b0a7478aca2cc03d4d225e, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-08 |
| Suppresses | — |
| Suppressed by | — |
| Related | SYS-0004, AHU-0018, SYS-0003 |
| Playbooks | exhaust-fan-schedule-misalignment |
| Source | HVAC FDD Reference v1.0 §16, SYS-0008 (pdf pp. 146-148) — both conditions, misalignment_duration 30 min, AlarmDelay 15 min, severity 3 (warning), the five diagnoses, the published 4-row test-vector table, and the whole impact profile; The reference’s own provenance line for that card: PNNL RetuningOpps S08 (~35% prevalence); PNNL-25985; PNNL EEM-07 (exhaust fan control) — the reference’s PNNL cross-reference; Library precedent: SYS-0005 and VFD-0001 (two published delays chained rather than summed); AHU-0018 (host-evaluated occupancy boolean) |
| Operating states | all — the rule judges alignment between the two fans in every hour, and the occupied qualifier applies to condition 2 only |
Preconditions (host-enforced): One instance per exhaust-fan/AHU pair, and the pairing is a site claim the graph cannot check: sf_status must be the supply fan that pressurizes the space this exhaust fan draws from. A toilet exhaust bound to the wrong air handler on a multi-AHU floor reports misalignment forever, correctly by its own arithmetic and about nothing. Fans that are legitimately independent of any AHU — continuous code-required exhaust, elevator machine rooms, dedicated process exhaust, garage CO-driven fans — are excluded by not instantiating the rule against them, since no conjunct in the graph can exempt them. Both statuses should be proven run status (current switch, differential pressure, VFD run feedback) rather than start commands: diagnosis 5 is a fan running on a VFD fault, which a command point cannot see. occ_scheduled is host-evaluated for the space the pair serves. Where a site runs its exhaust on purpose during unoccupied hours (night flush, a scheduled purge), that intent is invisible here and condition 1 will report it — SYS-0004 carries demand_override_active for exactly that case and this rule has no equivalent input, because the reference gives it none.
Points: ef_status, sf_status, occ_scheduled
Outputs:
yFault— True while either misalignment condition has been sustained for misalignment_duration and then held a further alarm_delayyExhaustWithoutSupply— Condition 1 sustained: the exhaust fan has been running with the supply fan off for misalignment_duration. Diagnostic direction flag — the building is being depressurized. Not an evaluability flagySupplyWithoutExhaust— Condition 2 sustained: the supply fan has been running with the exhaust fan off, during occupied hours, for misalignment_duration. Diagnostic direction flag — the building is over-pressurized and under-ventilated. Not an evaluability flag
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
misalignment_duration | 1800.0 | s | efOnlyHeld.delayTime, sfOnlyHeld.delayTime | Continuous misalignment each condition must show before it counts (30 min). The reference’s own misalignment_duration, applied per condition; hosts must set both paths together. |
alarm_delay | 900.0 | s | persist.delayTime | Further persistence required after a condition matures before the alarm asserts (15 min). The reference’s own separate AlarmDelay; 45 min to alarm at the shipped defaults. |
Description
Exhaust and supply are supposed to move together. When they do not, the building stops being a balanced system and becomes a pump: exhaust without supply pulls the floor negative and drags unconditioned air in through every door and window frame, and supply without exhaust pushes it positive and leaves the spaces that need extraction sharing their air with everyone else. Neither shows up on a temperature trend; both show up as draughty entrances, doors that will not latch, and a heating bill nobody can explain. The reference gives it ~35% prevalence, one of the highest numbers in the document, and the cause is organisational: exhaust fans are installed by a different trade, commissioned at a different time, and often run from a local timeclock no BAS point touches. The rule is deliberately two-sided and the sides are not symmetric — exhaust running with the supply fan off is a fault at any hour, while supply running with the exhaust off is a fault only during occupied hours, because an AHU cycling overnight for setback with the toilet exhaust properly shut down is correct.
Detection Logic
C1 = ef_status AND NOT sf_status sustained misalignment_duration
C2 = sf_status AND NOT ef_status AND occ_scheduled sustained misalignment_duration
yFault = (C1 held OR C2 held) sustained a further alarm_delay
Block graph (rule.cxf.jsonld):
Nine blocks in two branches and a join, each branch carrying its own
misalignment_duration sustain before the shared persist.
Timing. The two delays are chained, not summed, and chained TrueDelays on
one steady signal add exactly: a misalignment that starts at T asserts its
direction flag at T + 1800 s and yFault at T + 2700 s — 45 minutes at the
shipped defaults, which is what the reference’s two published tunables come to
when both are kept. Each stage asserts at exactly T + delayTime, so every
realized test is “strictly more than” its delay at tick resolution, and
delayOnInit = true on all three (CDL default false) makes a restart into an
already-misaligned pair wait out the full 45 minutes.
Per-condition sustain is not the same rule as sustaining the Or. A pair
misaligned on every tick of a two-hour run that flips direction every twenty
minutes matures neither branch and is never reported; under a single
TrueDelay(1800) on the Or it would alarm at 2700 s. The reference’s wording —
each condition “sustained for duration” — picks the first reading, and that is
what ships.
The two direction flags are diagnostic, not evaluability flags. Both are
false when the rule is healthy, and whichever is true alongside yFault says
which way the building is being pushed. They mature 900 s before yFault, so a
host wanting an early warning has one. Unlike the y…Ok outputs elsewhere in
this library, false does not mean NO_EVAL.
Possible Diagnoses
The reference’s five, in its order:
- Exhaust fan schedule not synchronized with the AHU — the ordinary case, a $0 BAS edit
- Exhaust fan on an independent timer or switch — not on the BAS at all, so the finding is real and the remote fix will not work (see Notes)
- Exhaust fan override left active — a manual hold from a service call
- BAS programming error — the interlock was written and is wrong: inverted logic, wrong AHU referenced, or a start/stop pair missing the exhaust side
- Exhaust fan VFD fault keeping the fan running — the drive lost its command and runs on a local reference or fault-state default
Read the direction flags against that list: yExhaustWithoutSupply points at 2,
3 and 5, ySupplyWithoutExhaust at 1 and 4.
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile.
The fan term is direct: waste_kw = ef_rated_kw × (ef_speed/100)³ for every
hour the exhaust runs alone. The pressurization term is the larger and looser
one, which the reference puts at 1-3% of site energy: infiltration through an
unbalanced envelope, conditioned in whichever direction the season demands,
which is why climate sensitivity is “both” rather than heating-dominant like
SYS-0004. The supply-without-exhaust half wastes little fan energy and is
mostly a ventilation-compliance and comfort finding; it shares the card and the
severity because the reference put it there and the fix is the same work order.
Emissions Impact
Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s range is 200-2,000 kg CO₂e/yr for the fan plus the pressurization penalty. Avoided-emissions basis MOER (marginal). Where the infiltration penalty is met by a fuel-fired heating plant the honest scope is 1 + 2; the reference assigns the card Scope 2 and this transcribes that assignment rather than splitting it.
Deviations
- Two delays in series, not one. The reference lists
misalignment_duration(30 min) and a separateAlarmDelay(15 min) for one rule and does not say how they compose. Both are kept and chained — the SYS-0005 and VFD-0001 shape — so a steady single-branch misalignment alarms at T + 2700 s. A single 2700 s delay behaves identically as shipped; the chain is what lets a site keep a 30-minute misalignment window and a two-hour alarm hold, or the reverse. misalignment_durationbinds two CXF paths. One card parameter, one delay per branch, and SCHEMA.md’s list form forparams.*.cxfrequires hosts to set both together. Retuning one branch alone would make the rule quietly asymmetric in a way the reference never describes.- Per-condition sustain, so continuous misalignment that alternates direction is not caught. This follows the reference’s wording and is a real blind spot rather than an implementation artifact; a fan pair oscillating that way is a controls problem the reference has no rule for.
- Two extra boundary outputs, and they are not the library’s usual
y…Okevaluability flags. SCHEMA.md allows additional outputs for sub-condition flags and these are that: both are false in the healthy case and true means the named condition has matured. Hosts that treat every non-yFaultoutput as an evaluability gate will get this exactly backwards. occ_scheduledreplaces the reference’socc_scheduleschedule object. The block graph has no clock or calendar, so the host evaluates the schedule and feeds the boolean, as AHU-0018 does. The same concept is spelledocc_scheduleinpoints/ahu.points.json— one concept, two dictionary names, worth resolving library-wide.- The asymmetry between the two conditions is the reference’s, transcribed. Condition 1 has no occupancy qualifier and condition 2 does, so exhaust running with the supply fan off is a fault at 03:00 while the mirror case is not. The engineering reason is in the Description; the authority is the reference.
- No override input, unlike SYS-0004. That card carries
demand_override_activebecause its reference entry names the point; this one does not, so a legitimate scheduled night purge trips condition 1. Adding an override conjunct would be an invention, so the exclusion lives inpreconditions— exclude those fans at binding. - No thresholds, so the library’s strict-comparison deviation does not apply.
Every input is a boolean and the graph contains no
Realsblock. - The reference’s four published test vectors are scenarios 1-4 of
vectors.json, transcribed with its own column values; the rest are authored. - Overlaps SYS-0004 and neither rule suppresses the other. An exhaust fan
running unoccupied with its AHU off satisfies SYS-0004 at 900 s and this
rule’s condition 1 at 2700 s. Both are true and their fixes differ, so
suppressesstays empty in both directions; CLU-08 groups them. - The rule sees run status, never speed or power.
ef_rated_kwandef_speedinruntime_estimationare host-side, and the pressurization term is not computable from these three booleans at all. Accumulation is 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
Establish first whether the BAS can actually stop this fan, because diagnosis 2
changes what the work order costs. The cheapest test is to command it off and
watch ef_status. A fan on a local timeclock or a janitor’s wall switch does not
answer, and the
exhaust-fan-schedule-misalignment
playbook files reprogramming that timer under Step 2 “Remote fix” — which it is
not. Its own better answer is in the same step: an interlock relay that makes the
exhaust follow supply status, a small capital job worth naming in the work order.
Where this rule and SYS-0004 both fire on the same fan they are one problem with two views — SYS-0004 says the fan runs when the building is empty, this one says it runs when its air handler is not — and fixing the interlock usually clears both, which is what CLU-08 expresses.
Building pressure is the confirmation measurement and hardly anyone has the sensor. If the site has one, an unbalanced pair shows up as a sustained offset tracking the misalignment window exactly. If not, the door test costs nothing: a lobby door that pulls hard against you at 07:00 and swings freely at noon is the same finding in physical form.
Test Vectors
13 scenarios, clock step 60 s over 7200 s.
| Scenario | Description |
|---|---|
both_on_occupied | Reference test-vector row 1 (sf ON, ef ON, occupied): aligned, no fault. |
both_off_unoccupied | Reference test-vector row 2 (sf OFF, ef OFF, unoccupied): aligned, no fault. |
exhaust_on_supply_off | Reference test-vector row 3 (sf OFF, ef ON, unoccupied): condition 1. The branch sustains at 1800 s and the alarm follows 900 s later at 2700 s — the chained delays sum exactly. |
supply_on_exhaust_off_occupied | Reference test-vector row 4 (sf ON, ef OFF, occupied): condition 2, same 1800 + 900 s timing on the mirror branch. The two flags are mutually exclusive by construction. |
supply_on_exhaust_off_unoccupied | The occupancy conjunct blocking on its own — the asymmetry between the two conditions. Supply running without exhaust is only a fault during occupied hours; unoccupied it is a normal night-cycle or warmup state. |
both_off_during_occupied_hours | Both fans down inside the occupied schedule. That is somebody else’s fault (AHU-0026 and friends) — this rule judges alignment, not whether the equipment should be running at all. |
exhaust_stops_on_the_sustain_tick | The misalignment_duration edge from below: the exhaust fan stops at exactly 1800 s, so the sustain never matures and neither does anything downstream of it. |
exhaust_stops_one_tick_after_the_sustain | The regression test for keeping both published delays: a 31-minute misalignment matures misalignment_duration (one tick of yExhaustWithoutSupply at 1800 s) and dies immediately, so the 900 s alarm hold never runs. A rule that used misalignment_duration alone, with no separate alarm hold, would have alarmed here. |
misalignment_clears_one_tick_before_the_alarm | The alarm edge from below: the exhaust fan stops at 2640 s, one tick short of the 2700 s the chain needs, and the fault is never reported. |
misalignment_clears_one_tick_after_the_alarm | The same edge from above: one tick of yFault at 2700 s, then clear. |
supply_fan_starts_and_realigns | Recovery: the exhaust-only alarm asserts at 2700 s and the AHU starts at 3600 s. Both fans running is aligned, so every output drops on that tick — TrueDelay delays the rising edge only. |
occupancy_starts_the_supply_only_clock | The supply-only branch is armed by the schedule, not by the fans. An AHU running with its exhaust off is silent overnight and starts counting when the occupied period opens at 1800 s: sustain at 3600 s, alarm at 4500 s. |
direction_alternates_so_neither_branch_sustains | The consequence of sustaining each condition separately rather than sustaining their Or. The pair is misaligned on every single tick of this two-hour run, but it flips direction every 20 minutes, so neither branch ever reaches misalignment_duration and nothing is reported. A TrueDelay(1800) placed on Or(C1, C2) instead would have alarmed at 2700 s. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 7200
},
"scenarios": [
{
"name": "both_on_occupied",
"description": "Reference test-vector row 1 (sf ON, ef ON, occupied): aligned, no fault.",
"inputs": {
"sf_status": true,
"ef_status": true,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "ySupplyWithoutExhaust",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "both_off_unoccupied",
"description": "Reference test-vector row 2 (sf OFF, ef OFF, unoccupied): aligned, no fault.",
"inputs": {
"sf_status": false,
"ef_status": false,
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "ySupplyWithoutExhaust",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "exhaust_on_supply_off",
"description": "Reference test-vector row 3 (sf OFF, ef ON, unoccupied): condition 1. The branch sustains at 1800 s and the alarm follows 900 s later at 2700 s \u2014 the chained delays sum exactly.",
"inputs": {
"sf_status": false,
"ef_status": true,
"occ_scheduled": false
},
"expect": [
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yExhaustWithoutSupply",
"from_s": 1800,
"to_s": 7200,
"equals": true
},
{
"output": "ySupplyWithoutExhaust",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2640,
"equals": false
},
{
"output": "yFault",
"from_s": 2700,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "supply_on_exhaust_off_occupied",
"description": "Reference test-vector row 4 (sf ON, ef OFF, occupied): condition 2, same 1800 + 900 s timing on the mirror branch. The two flags are mutually exclusive by construction.",
"inputs": {
"sf_status": true,
"ef_status": false,
"occ_scheduled": true
},
"expect": [
{
"output": "ySupplyWithoutExhaust",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "ySupplyWithoutExhaust",
"from_s": 1800,
"to_s": 7200,
"equals": true
},
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 2640,
"equals": false
},
{
"output": "yFault",
"from_s": 2700,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "supply_on_exhaust_off_unoccupied",
"description": "The occupancy conjunct blocking on its own \u2014 the asymmetry between the two conditions. Supply running without exhaust is only a fault during occupied hours; unoccupied it is a normal night-cycle or warmup state.",
"inputs": {
"sf_status": true,
"ef_status": false,
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "ySupplyWithoutExhaust",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "both_off_during_occupied_hours",
"description": "Both fans down inside the occupied schedule. That is somebody else's fault (AHU-0026 and friends) \u2014 this rule judges alignment, not whether the equipment should be running at all.",
"inputs": {
"sf_status": false,
"ef_status": false,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "ySupplyWithoutExhaust",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "exhaust_stops_on_the_sustain_tick",
"description": "The misalignment_duration edge from below: the exhaust fan stops at exactly 1800 s, so the sustain never matures and neither does anything downstream of it.",
"inputs": {
"sf_status": false,
"ef_status": [
{
"t": 0,
"value": true
},
{
"t": 1800,
"value": false
}
],
"occ_scheduled": false
},
"expect": [
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "exhaust_stops_one_tick_after_the_sustain",
"description": "The regression test for keeping both published delays: a 31-minute misalignment matures misalignment_duration (one tick of yExhaustWithoutSupply at 1800 s) and dies immediately, so the 900 s alarm hold never runs. A rule that used misalignment_duration alone, with no separate alarm hold, would have alarmed here.",
"inputs": {
"sf_status": false,
"ef_status": [
{
"t": 0,
"value": true
},
{
"t": 1860,
"value": false
}
],
"occ_scheduled": false
},
"expect": [
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yExhaustWithoutSupply",
"from_s": 1800,
"to_s": 1800,
"equals": true
},
{
"output": "yExhaustWithoutSupply",
"from_s": 1860,
"to_s": 7200,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "misalignment_clears_one_tick_before_the_alarm",
"description": "The alarm edge from below: the exhaust fan stops at 2640 s, one tick short of the 2700 s the chain needs, and the fault is never reported.",
"inputs": {
"sf_status": false,
"ef_status": [
{
"t": 0,
"value": true
},
{
"t": 2640,
"value": false
}
],
"occ_scheduled": false
},
"expect": [
{
"output": "yExhaustWithoutSupply",
"from_s": 1800,
"to_s": 2580,
"equals": true
},
{
"output": "yExhaustWithoutSupply",
"from_s": 2640,
"to_s": 7200,
"equals": false
},
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "misalignment_clears_one_tick_after_the_alarm",
"description": "The same edge from above: one tick of yFault at 2700 s, then clear.",
"inputs": {
"sf_status": false,
"ef_status": [
{
"t": 0,
"value": true
},
{
"t": 2760,
"value": false
}
],
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2640,
"equals": false
},
{
"output": "yFault",
"from_s": 2700,
"to_s": 2700,
"equals": true
},
{
"output": "yFault",
"from_s": 2760,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "supply_fan_starts_and_realigns",
"description": "Recovery: the exhaust-only alarm asserts at 2700 s and the AHU starts at 3600 s. Both fans running is aligned, so every output drops on that tick \u2014 TrueDelay delays the rising edge only.",
"inputs": {
"ef_status": true,
"sf_status": [
{
"t": 0,
"value": false
},
{
"t": 3600,
"value": true
}
],
"occ_scheduled": false
},
"expect": [
{
"output": "yExhaustWithoutSupply",
"from_s": 1800,
"to_s": 3540,
"equals": true
},
{
"output": "yExhaustWithoutSupply",
"from_s": 3600,
"to_s": 7200,
"equals": false
},
{
"output": "yFault",
"from_s": 2700,
"to_s": 3540,
"equals": true
},
{
"output": "yFault",
"from_s": 3600,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "occupancy_starts_the_supply_only_clock",
"description": "The supply-only branch is armed by the schedule, not by the fans. An AHU running with its exhaust off is silent overnight and starts counting when the occupied period opens at 1800 s: sustain at 3600 s, alarm at 4500 s.",
"inputs": {
"sf_status": true,
"ef_status": false,
"occ_scheduled": [
{
"t": 0,
"value": false
},
{
"t": 1800,
"value": true
}
]
},
"expect": [
{
"output": "ySupplyWithoutExhaust",
"from_s": 0,
"to_s": 3540,
"equals": false
},
{
"output": "ySupplyWithoutExhaust",
"from_s": 3600,
"to_s": 7200,
"equals": true
},
{
"output": "yFault",
"from_s": 0,
"to_s": 4440,
"equals": false
},
{
"output": "yFault",
"from_s": 4500,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "direction_alternates_so_neither_branch_sustains",
"description": "The consequence of sustaining each condition separately rather than sustaining their Or. The pair is misaligned on every single tick of this two-hour run, but it flips direction every 20 minutes, so neither branch ever reaches misalignment_duration and nothing is reported. A TrueDelay(1800) placed on Or(C1, C2) instead would have alarmed at 2700 s.",
"inputs": {
"occ_scheduled": true,
"ef_status": [
{
"t": 0,
"value": true
},
{
"t": 1200,
"value": false
},
{
"t": 2400,
"value": true
},
{
"t": 3600,
"value": false
},
{
"t": 4800,
"value": true
},
{
"t": 6000,
"value": false
}
],
"sf_status": [
{
"t": 0,
"value": false
},
{
"t": 1200,
"value": true
},
{
"t": 2400,
"value": false
},
{
"t": 3600,
"value": true
},
{
"t": 4800,
"value": false
},
{
"t": 6000,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "yExhaustWithoutSupply",
"from_s": 0,
"to_s": 7200,
"equals": false
},
{
"output": "ySupplyWithoutExhaust",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
}
]
}