SYS-0003 — Lighting on with no occupancy
| Status | verified — engine e2ff2f8, cxf:fnv1a128:763feed2eb268c080053a6ce13419359, 2026-08-17 |
| Severity | 4 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-04 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0018, SYS-0004, SYS-0008 |
| Playbooks | after-hours-operation |
| Source | HVAC FDD Reference v1.0 §16, SYS-0003 (pdf pp. 140-141) — equation, AlarmDelay 30 min, severity 4 (info), the four diagnoses, and the whole impact profile; The reference’s own provenance line for that card: Mazzetto 2025 — 1,149 occurrences in 10 months at one facility; PNNL EEM-18 (lighting occupancy sensors) — the reference’s PNNL cross-reference for the 15-20% annual figure; Library precedent: AHU-0018 (the host-evaluated occupancy boolean, and the same Not/And/TrueDelay shape) |
| Operating states | all — the conjunction is self-gating, since two of its three terms are the unoccupied test |
Preconditions (host-enforced): The occupancy sensor and the lighting circuit must cover the same space. This is a per-instance binding claim the graph cannot check, and getting it wrong is the rule’s main false-positive path: a corridor PIR paired with an open-plan circuit reports a fault every time the corridor empties. occ_scheduled is host-evaluated from the lighting schedule for THAT space, not the AHU’s — a building whose HVAC and lighting schedules differ needs the lighting one here, and schedule provenance that is stale or unknown is NO_EVAL rather than unoccupied. lighting_status should be a proven circuit status (current sensor, relay auxiliary contact, panel feedback) and not the command: a command point makes this rule an audit of the BAS’s intent, which is exactly the thing diagnosis 1 says has already failed. Occupancy-sensor timeout is the host’s to reconcile — a sensor whose timeout is long relative to alarm_delay delays the finding, and one that drops a stationary occupant produces a fault that is true given the data and wrong about the building.
Points: lighting_status, occ_sensor, occ_scheduled
Outputs:
yFault— True while the lighting circuit has been energized with the occupancy sensor unoccupied and the schedule closed, continuously for alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
alarm_delay | 1800.0 | s | persist.delayTime | Continuous persistence required before the alarm asserts (30 min). The reference’s only tunable for this rule. |
Description
Lights burning in an empty building after hours. The rule wants two independent witnesses before it says the space is empty — the occupancy sensor and the schedule — because either alone is wrong often enough to be useless: a schedule says nothing about the person working late, a PIR says nothing about the person sitting still. That is what makes a finding worth dispatching, and it is also why the rule under-reports: lights on all night in a room whose sensor has failed to a permanent “occupied” are invisible here. Prevalence is the reason the card exists — Mazzetto (2025) logged 1,149 occurrences in ten months at a single facility, and every hour of it is 100% waste. It is the only card in the library that leaves the mechanical plant, and it earns its place because the schedule that is wrong here is usually the master schedule AHU-0018 is failing on (hence CLU-04) and the fix is the same BAS work order.
Detection Logic
yFault = lighting_status
AND NOT occ_sensor
AND NOT occ_scheduled
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Five blocks, no arithmetic and no thresholds — every input is already a boolean, so there is nothing to compare and nothing to tune but the delay.
Each of the three conjuncts blocks the fault by itself, and any of them going
the other way drops a live alarm on the same tick: TrueDelay delays the rising
edge only, so a schedule that opens mid-fault clears the finding immediately and
a later unoccupied period starts a fresh 30 minutes.
persist asserts at exactly T + delayTime, so the realized test is “lit and
unoccupied for strictly more than alarm_delay” at tick resolution. Continuous
means continuous — someone crossing the room discards the elapsed time rather
than pausing it, which is the intended trade: a full timer restart per PIR trip
means an intermittent sensor produces silence rather than a stream of 30-minute
findings. delayOnInit = true (CDL default false) makes a controller restart
into an already-lit empty building wait out the full 30 minutes.
Possible Diagnoses
The reference’s four, in its order:
- Lighting control override active — a panel in HAND, or a BACnet priority-array entry holding the circuit on. Most common, cheapest to fix
- Occupancy sensor bypassed — disconnected, taped over, or decommissioned in software after nuisance-switching complaints
- Timer or photocell failure — a local astronomic timeclock that drifted or lost its battery, or a photocell reading a lit interior
- BAS schedule misconfiguration — the lighting schedule never edited from the default, or wrong holidays and time zone (the same root cause as AHU-0018)
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile.
While the fault is active the whole circuit is waste: waste_kw = lighting_circuit_kw, with no thermal term and no baseline to model, which is
why confidence is HIGH on a rule this simple. EEM-18 puts occupancy-based
lighting control at 15-20% of annual lighting energy. Climate-neutral: the waste
scales with unoccupied hours, not weather. The reference’s severity 4 (info)
against CRITICAL_WASTE answers a different question — every kilowatt-hour is
unnecessary, but nothing breaks and nobody is uncomfortable, so it is a work
order for the next scheduled visit rather than a callout.
Emissions Impact
Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s range is 500-5,000 kg CO₂e/yr, and its parenthetical is the point — “lighting waste, high MOER overnight.” Avoided-emissions basis MOER (marginal). The fault runs almost entirely when solar is off the grid and the marginal generator is gas or coal, so its emissions rank routinely beats its energy-cost rank in regions with cheap overnight power.
Deviations
- Severity 4, from the chapter, against the family README’s 3.
faults/sys/README.mdlists this rule at severity 3 and its own note says the SYS-0001-057 rows are provisional transcriptions to be re-verified when each card is authored. The chapter says “Severity: 4 (info)” and wins; the README row needs updating by whoever owns that file. 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 (time zone, holidays, exceptions) and feeds the boolean, 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. Cards bind by exact name within their own family dictionary so nothing breaks, but it is worth resolving library-wide. - One delay, not two. This card’s reference entry lists a single tunable,
AlarmDelay = 30 min, so there is oneTrueDelayand time-to-alarm is 30 minutes flat. AHU-0018 carries agrace_periodbecause its own entry gives it one; none was invented here to match the sibling’s shape. - No thresholds, so the library’s strict-comparison deviation does not apply.
Every input is a boolean and the graph contains no
Realsblock, so there is nothing to retune per binding. delayOnInit = true(CDL defaultfalse), the library’s standing choice: a controller restart into an already-lit empty building waits out the full 30 minutes rather than alarming on the first tick.TrueDelayasserts at exactlyT + delayTime, so the realized test is “lit and unoccupied for strictly more thanalarm_delay” at tick resolution.- The rule sees status, never power.
lighting_circuit_kwinruntime_estimationis a host-side nameplate or metered value; no such point is bound and the graph produces a boolean. 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
Two failure paths lead to different trades. If occ_sensor and occ_scheduled
disagree night after night, the schedule is the suspect and the fix is a BAS
edit at $0. If they agree and the circuit stays on anyway, the suspect is
downstream of the BAS — a HAND switch, a welded relay, a local timeclock — and
the fix needs an electrician.
Where this fires alongside AHU-0018 on the same nights, treat it as one finding. The after-hours-operation playbook is shared for that reason, and CLU-04 exists to make the master schedule the thing that gets fixed rather than three symptoms of it.
Test Vectors
9 scenarios, clock step 60 s over 7200 s.
| Scenario | Description |
|---|---|
lights_on_during_occupied_hours | The normal case the rule must never touch: lights on, people detected, inside the occupied schedule. |
lights_on_in_an_empty_room_during_scheduled_hours | The schedule conjunct blocking on its own. Lights on and the sensor sees nobody, but the building is inside its occupied schedule — an empty conference room mid-morning is not this fault. |
lights_on_after_hours_with_someone_present | The sensor conjunct blocking on its own. Out of schedule, but the occupancy sensor sees someone — a cleaner or someone working late gets light, not a work order. |
lights_off_after_hours | The lighting conjunct blocking on its own: nobody there, nothing scheduled, and the circuit is off. Correct operation. |
lights_on_with_no_occupancy | The reference condition. Circuit energized, sensor unoccupied, outside the schedule; TrueDelay asserts at exactly alarm_delay (1800 s) and holds. |
lights_switch_off_on_the_alarm_tick | The delay edge from below: the circuit de-energizes at exactly 1800 s, so the condition is false on the tick the delay would have matured and nothing is ever reported. |
lights_switch_off_one_tick_later | The same edge from above: one tick of alarm at 1800 s, then clear. The realized test is ‘on and unoccupied for strictly more than alarm_delay’ at tick resolution. |
occupancy_detected_restarts_the_clock | Continuous means continuous. Someone walks through at 900 s and leaves at 1200 s; the elapsed 900 s is discarded rather than paused, so the alarm lands a full 1800 s after the room goes quiet again. |
schedule_resumes_then_ends_again | Recovery and re-arm on one timeline: the alarm asserts at 1800 s, the occupied schedule opens at 3000 s and drops it on that tick (TrueDelay delays the rising edge only), and the schedule closing again at 3600 s starts a fresh 1800 s to the second alarm at 5400 s. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 7200
},
"scenarios": [
{
"name": "lights_on_during_occupied_hours",
"description": "The normal case the rule must never touch: lights on, people detected, inside the occupied schedule.",
"inputs": {
"lighting_status": true,
"occ_sensor": true,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "lights_on_in_an_empty_room_during_scheduled_hours",
"description": "The schedule conjunct blocking on its own. Lights on and the sensor sees nobody, but the building is inside its occupied schedule \u2014 an empty conference room mid-morning is not this fault.",
"inputs": {
"lighting_status": true,
"occ_sensor": false,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "lights_on_after_hours_with_someone_present",
"description": "The sensor conjunct blocking on its own. Out of schedule, but the occupancy sensor sees someone \u2014 a cleaner or someone working late gets light, not a work order.",
"inputs": {
"lighting_status": true,
"occ_sensor": true,
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "lights_off_after_hours",
"description": "The lighting conjunct blocking on its own: nobody there, nothing scheduled, and the circuit is off. Correct operation.",
"inputs": {
"lighting_status": false,
"occ_sensor": false,
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "lights_on_with_no_occupancy",
"description": "The reference condition. Circuit energized, sensor unoccupied, outside the schedule; TrueDelay asserts at exactly alarm_delay (1800 s) and holds.",
"inputs": {
"lighting_status": true,
"occ_sensor": false,
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1800,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "lights_switch_off_on_the_alarm_tick",
"description": "The delay edge from below: the circuit de-energizes at exactly 1800 s, so the condition is false on the tick the delay would have matured and nothing is ever reported.",
"inputs": {
"lighting_status": [
{
"t": 0,
"value": true
},
{
"t": 1800,
"value": false
}
],
"occ_sensor": false,
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "lights_switch_off_one_tick_later",
"description": "The same edge from above: one tick of alarm at 1800 s, then clear. The realized test is 'on and unoccupied for strictly more than alarm_delay' at tick resolution.",
"inputs": {
"lighting_status": [
{
"t": 0,
"value": true
},
{
"t": 1860,
"value": false
}
],
"occ_sensor": false,
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1800,
"to_s": 1800,
"equals": true
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 7200,
"equals": false
}
]
},
{
"name": "occupancy_detected_restarts_the_clock",
"description": "Continuous means continuous. Someone walks through at 900 s and leaves at 1200 s; the elapsed 900 s is discarded rather than paused, so the alarm lands a full 1800 s after the room goes quiet again.",
"inputs": {
"lighting_status": true,
"occ_sensor": [
{
"t": 0,
"value": false
},
{
"t": 900,
"value": true
},
{
"t": 1200,
"value": false
}
],
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2940,
"equals": false
},
{
"output": "yFault",
"from_s": 3000,
"to_s": 7200,
"equals": true
}
]
},
{
"name": "schedule_resumes_then_ends_again",
"description": "Recovery and re-arm on one timeline: the alarm asserts at 1800 s, the occupied schedule opens at 3000 s and drops it on that tick (TrueDelay delays the rising edge only), and the schedule closing again at 3600 s starts a fresh 1800 s to the second alarm at 5400 s.",
"inputs": {
"lighting_status": true,
"occ_sensor": false,
"occ_scheduled": [
{
"t": 0,
"value": false
},
{
"t": 3000,
"value": true
},
{
"t": 3600,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1800,
"to_s": 2940,
"equals": true
},
{
"output": "yFault",
"from_s": 3000,
"to_s": 5340,
"equals": false
},
{
"output": "yFault",
"from_s": 5400,
"to_s": 7200,
"equals": true
}
]
}
]
}