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

SYS-0003 — Lighting on with no occupancy

Statusverified — engine e2ff2f8, cxf:fnv1a128:763feed2eb268c080053a6ce13419359, 2026-08-17
Severity4
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-04
Suppresses
Suppressed by
RelatedAHU-0018, SYS-0004, SYS-0008
Playbooksafter-hours-operation
SourceHVAC 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 statesall — 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:

NameDefaultUnitCXF pathDescription
alarm_delay1800.0spersist.delayTimeContinuous 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):

SYS-0003 block graph

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:

  1. Lighting control override active — a panel in HAND, or a BACnet priority-array entry holding the circuit on. Most common, cheapest to fix
  2. Occupancy sensor bypassed — disconnected, taped over, or decommissioned in software after nuisance-switching complaints
  3. Timer or photocell failure — a local astronomic timeclock that drifted or lost its battery, or a photocell reading a lit interior
  4. 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.md lists 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_scheduled replaces the reference’s schedule-evaluation call. The reference writes NOT 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.json records it as a derived point.
  • The point is named occ_scheduled here and occ_schedule in points/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 one TrueDelay and time-to-alarm is 30 minutes flat. AHU-0018 carries a grace_period because 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 Reals block, so there is nothing to retune per binding.
  • delayOnInit = true (CDL default false), 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.
  • TrueDelay asserts at exactly T + delayTime, so the realized test is “lit and unoccupied for strictly more than alarm_delay” at tick resolution.
  • The rule sees status, never power. lighting_circuit_kw in runtime_estimation is 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.

ScenarioDescription
lights_on_during_occupied_hoursThe normal case the rule must never touch: lights on, people detected, inside the occupied schedule.
lights_on_in_an_empty_room_during_scheduled_hoursThe 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_presentThe 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_hoursThe lighting conjunct blocking on its own: nobody there, nothing scheduled, and the circuit is off. Correct operation.
lights_on_with_no_occupancyThe 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_tickThe 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_laterThe 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_clockContinuous 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_againRecovery 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
        }
      ]
    }
  ]
}