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

VAV-0010 — Zone temperature sensor drift (neighbor-median)

Statusverified — engine e2ff2f8, cxf:fnv1a128:633c71d93a7cb6a8f5f86e6f18b81077, 2026-08-18
Severity3
Methodstatistical
Phase3
CategoryCOMFORT_ENERGY
ConfidenceLOW
EstimationQUALITATIVE_ONLY
G36
ClustersCLU-09
Suppresses
Suppressed by
RelatedSYS-0005, SYS-0009, SYS-0010, VAV-0008
Playbookssensor-drift
SourceYang, H., Cho, S., Tae, C.-S., Zaheeruddin, M. (2008). Sequential rule based algorithms for temperature sensor fault detection in air handling units. Energy Conversion and Management 49(8), 2291-2306. doi:10.1016/j.enconman.2008.01.029 — drift as a distinct sensor-fault class detected by rule-based comparison against a reference, and the isolation caveat this card inherits; Library-authored: no reference card exists for a neighbor-comparison drift rule; name, severity, thresholds and the median reference are argued here. This is the rule VAV-0008’s diagnosis 3 and faults/vav/README.md have pointed at since the CUSUM batch; Library precedent: SYS-0005 (pairwise cross-validation, ambiguous verdict — this card is its fleet-referenced descendant with a NAMED verdict), SYS-0009/SYS-0010 (single-sensor adjudication form), HP-0001 (commissioning parameters shipped as documented placeholders); points/vav.points.json zone_temp_neighbor_median — the self-excluding median contract (population >= ~5 zones, divergent-purpose zones excluded) this rule stands on
Operating statesoccupied, host-enforced. Unoccupied setback diverges zones legitimately — different masses recover at different rates — so the comparison means something only while every zone is holding an occupied setpoint. This rule is stateless, so the gate is the host’s standard job (contrast the VAV-0007/0008/0009 accumulators, which had to reset in-graph).

Preconditions (host-enforced): The median contract does the heavy lifting — read points/vav.points.json zone_temp_neighbor_median before instantiating: the median EXCLUDES this zone (a drifting sensor must not pull its own reference), covers >= ~5 sibling zones on the same AHU, and omits zones whose setpoints legitimately diverge (server rooms, vestibules, unconditioned buffers). drift_threshold must exceed the site’s occupied SETPOINT SPREAD plus normal zone-to-zone scatter: zones commanded to different setpoints differ by design, and a threshold inside that spread alarms on the design. Delivery quality is resolved before this rule runs — a held-over stale value reads as divergence, and the rule is right about the number and wrong about the sensor (SYS-0005’s caveat, inherited). Per the sensor-health family’s standing constraint, no other card may list this rule in its suppresses.

Points: zone_temp, zone_temp_neighbor_median

Outputs:

  • yFault — True while this zone’s temperature has stayed more than drift_threshold from its neighbor median, in either direction, continuously for persist_time. The verdict names THIS zone’s sensor — see adjudicates
  • yHigh — Sub-condition flag, undelayed — the zone reads above the median by more than drift_threshold right now. Not an evaluability output; false never means NO_EVAL
  • yLow — Sub-condition flag, undelayed — the zone reads below the median by more than drift_threshold. Same kind as yHigh

Parameters:

NameDefaultUnitCXF pathDescription
drift_threshold3.0°ChighCmp.t, lowCmp.tDivergence from the neighbor median beyond which this zone is the outlier. COMMISSIONING PLACEHOLDER: it must clear the site’s occupied setpoint spread plus honest zone-to-zone scatter, which no source publishes as a portable number. 3.0 is deliberately wider than SYS-0005’s 2.0 pair band — a median of different rooms is a looser reference than a co-located pair. One value feeds both directions through the in-graph negation.
persist_time7200.0spersist.delayTimeContinuous divergence required before the alarm asserts (2 h). Drift is permanent, so latency is cheap and false positives are not: two hours rides out lunch loads, solar swings, and a propped-open door. delayOnInit = true serves the full persistence on a controller restart.

Description

A zone temperature sensor that drifts takes its whole zone with it: the box dutifully conditions the room to a wrong number, the occupants adjust the setpoint to fight it, and every VAV diagnostic on that zone inherits the lie. A single drifted sensor is invisible from inside its own control loop — the loop closes on the sensor, so the trend looks healthy. What exposes it is the rest of the fleet: sibling zones on the same air handler see the same supply air and roughly the same weather, so a zone that reads persistently far from its neighbors’ median is the outlier, and the median — unlike SYS-0005’s two-sensor pair — says which sensor to distrust.

Detection Logic

err     = zone_temp − zone_temp_neighbor_median

yHigh   = err  > drift_threshold          this zone reads above the fleet
yLow    = −err > drift_threshold          below the fleet (in-graph negation)

yFault  = (yHigh OR yLow) continuously for persist_time

Block graph (rule.cxf.jsonld):

VAV-0010 block graph

Six blocks, stateless. The median is computed by the host per the point contract — self-excluding, so a drifting sensor cannot drag its own reference toward itself. Trunk-level events are free immunity: a failed AHU or a building-wide swing moves the zone and the median together, and the subtraction sees only the residual (median_moves_with_zone pins it). The Or sits before the single persistence delay, so a sensor that flips from reading high to reading low without ever closing is one continuous fault. Both comparisons are strict; equality at the threshold reads healthy.

Possible Diagnoses

  1. Sensor drift or failed calibration — the finding this card names. Verify with a handheld reference at the thermostat; recalibrate or replace, per the sensor-drift playbook.
  2. Bad placement rather than bad electronics — a sensor over a copier, in supply-air wash, or on a sun-struck wall drifts with the source, not with age. Same signature, different work order: move it.
  3. A genuinely divergent zone that belongs out of the population — a space repurposed since commissioning (new server load, new wall). The fix is the median population list, not the sensor.
  4. The zone really is out of control — starved airflow or a failed box holds the space off setpoint while the sensor tells the truth. The VAV family’s own rules (VAV-0004, VAV-0008) fire alongside this one in that case; this card alone, with the box quiet, points at the sensor.

Energy Impact

COMFORT_ENERGY, LOW confidence, QUALITATIVE_ONLY. The drifted sensor itself consumes nothing; the waste is whatever the wrong number drives — typically over-conditioning of one zone and setpoint-fighting by its occupants — and that is counted by the downstream rules this card adjudicates. Confidence is LOW on SYS-0005’s grounds: the mechanism is literature-backed but the shipped thresholds are placeholders with no fault-injection validation behind them yet. Climate-neutral.

Emissions Impact

Scope 1 or 2 by what the false reading drives (reheat vs cooling), QUALITATIVE_EMISSIONS. No direct term; abatement rides the downstream fix.

Deviations

  • The median names the verdict; the pair could not. SYS-0005 ships verdict: ambiguous because two sensors that disagree carry no majority. Five or more siblings do: this card adjudicates zone_temp as invalid_while_active, the single-sensor form SYS-0009/0010 established. The cost is the median contract itself — population size and composition are host obligations the graph cannot check.
  • drift_threshold 3.0 °C and persist_time 7200 s are library defaults, not literature values. Yang et al. (2008) validate pairwise comparison thresholds on co-located AHU sensors; no source publishes a neighbor-median band. Shipped wider (3.0 vs the pair’s 2.0) and slower (2 h single delay vs the pair’s chained 60 + 30 min) because rooms are a looser reference than a shared duct; both retune at commissioning against the measured occupied spread.
  • One delay, not SYS-0005’s chained two. The reference specified that card’s drift_duration + AlarmDelay split, so it was transcribed; this card has no source to honor and one persist_time is one fewer number to commission wrong.
  • The negation is in-graph (MultiplyByParameter · k = −1) so the one published threshold stays positive and feeds both comparators — the same reason VAV-0008 keeps its constants positive rather than shipping a negative the retuner must remember.
  • Host-side occupancy gating, against the batch-19 precedent and for the house default. The CUSUM trio gated in-graph because accumulators need their STATE reset; this rule is stateless, so operating_states: occupied does the job with zero blocks. The persistence delay does run through unoccupied hours — a 3 a.m. assertion is possible and the host discards it as NO_EVAL, which is suppression of output, not state, and therefore safe here.
  • delayOnInit = true (CDL default false), the library’s standing choice, does real work: a controller restart serves the full two hours before re-alarming rather than re-asserting into a zone that may have been fixed.
  • Severity 3, category: COMFORT_ENERGY, name are library-authored; mirrored from SYS-0005, the nearest shipped relative. g36: null — G36 has no fleet-relative sensor check.

Notes

This closes the reservation VAV-0008’s Notes and the family README have carried since batch 19: the drift rule that decides whether the CUSUM cards’ zone_temp input can be believed. Run it beside them — a zone that trips VAV-0008 and this card is a sensor problem wearing a comfort problem’s clothes.

Test Vectors

8 scenarios, clock step 60 s over 10800 s.

ScenarioDescription
zones_agreeThe zone reads 0.4 degC over its neighbor median — inside any sane drift_threshold. Nothing asserts for three hours.
reads_high_drift_alarmsA 3.5 degC standing excess over the median: yHigh asserts undelayed from the first tick, and yFault exactly at persist_time (delayOnInit = true, divergence present from init).
reads_low_drift_alarmsThe mirror direction, 3.5 degC under the median through the in-graph negation: yLow undelayed, yFault at persist_time.
threshold_equality_is_silentExactly drift_threshold apart on each side in turn (+3.0 then -3.0). Both comparisons are strict, so equality reads healthy and nothing ever accumulates toward an alarm.
transient_never_alarmsA one-hour 4.5 degC excursion — a real disturbance, not drift. yHigh reports it live; the excursion ends before persist_time and yFault stays silent.
direction_flip_keeps_persistenceDivergence flips sign at one hour without ever closing. The Or sits before the delay, so the persistence survives the flip and yFault still asserts at persist_time from the start — a sensor that is simply wrong, whichever way, is one fault.
median_moves_with_zoneA building-wide swing: the zone climbs 5 degC and the neighbor median climbs with it. The subtraction sees only the small residual, which is the point of a fleet-relative reference — trunk-level events move both terms.
recovery_resets_delayDivergence holds for 100 minutes, closes for 10, then returns. TrueDelay starts over on the false gap, so the second run has not reached persist_time by the horizon: no alarm without CONTINUOUS divergence.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 10800
  },
  "scenarios": [
    {
      "name": "zones_agree",
      "description": "The zone reads 0.4 degC over its neighbor median \u2014 inside any sane drift_threshold. Nothing asserts for three hours.",
      "inputs": {
        "zone_temp": 22.4,
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "reads_high_drift_alarms",
      "description": "A 3.5 degC standing excess over the median: yHigh asserts undelayed from the first tick, and yFault exactly at persist_time (delayOnInit = true, divergence present from init).",
      "inputs": {
        "zone_temp": 25.5,
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "reads_low_drift_alarms",
      "description": "The mirror direction, 3.5 degC under the median through the in-graph negation: yLow undelayed, yFault at persist_time.",
      "inputs": {
        "zone_temp": 18.5,
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "threshold_equality_is_silent",
      "description": "Exactly drift_threshold apart on each side in turn (+3.0 then -3.0). Both comparisons are strict, so equality reads healthy and nothing ever accumulates toward an alarm.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 25.0
          },
          {
            "t": 5400,
            "value": 19.0
          }
        ],
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_never_alarms",
      "description": "A one-hour 4.5 degC excursion \u2014 a real disturbance, not drift. yHigh reports it live; the excursion ends before persist_time and yFault stays silent.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 26.5
          },
          {
            "t": 3600,
            "value": 22.0
          }
        ],
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 3600,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_flip_keeps_persistence",
      "description": "Divergence flips sign at one hour without ever closing. The Or sits before the delay, so the persistence survives the flip and yFault still asserts at persist_time from the start \u2014 a sensor that is simply wrong, whichever way, is one fault.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 3600,
            "value": 18.0
          }
        ],
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 3600,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 3540,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 3600,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 7140,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 7200,
          "to_s": 10800,
          "equals": true
        }
      ]
    },
    {
      "name": "median_moves_with_zone",
      "description": "A building-wide swing: the zone climbs 5 degC and the neighbor median climbs with it. The subtraction sees only the small residual, which is the point of a fleet-relative reference \u2014 trunk-level events move both terms.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 23.0
          },
          {
            "t": 5400,
            "value": 28.0
          }
        ],
        "zone_temp_neighbor_median": [
          {
            "t": 0,
            "value": 22.8
          },
          {
            "t": 5400,
            "value": 27.6
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        },
        {
          "output": "yLow",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    },
    {
      "name": "recovery_resets_delay",
      "description": "Divergence holds for 100 minutes, closes for 10, then returns. TrueDelay starts over on the false gap, so the second run has not reached persist_time by the horizon: no alarm without CONTINUOUS divergence.",
      "inputs": {
        "zone_temp": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 6000,
            "value": 22.0
          },
          {
            "t": 6600,
            "value": 26.0
          }
        ],
        "zone_temp_neighbor_median": 22.0
      },
      "expect": [
        {
          "output": "yHigh",
          "from_s": 0,
          "to_s": 5940,
          "equals": true
        },
        {
          "output": "yHigh",
          "from_s": 6000,
          "to_s": 6540,
          "equals": false
        },
        {
          "output": "yHigh",
          "from_s": 6600,
          "to_s": 10800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 10800,
          "equals": false
        }
      ]
    }
  ]
}