VAV-0008 — Zone temperature CUSUM
| Status | verified — engine e2ff2f8, cxf:fnv1a128:ffbf4740924e29e25d13a4474bfb7aab, 2026-08-18 |
| Severity | 3 |
| Method | statistical |
| Phase | 3 |
| Category | COMFORT_ENERGY |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | VAV-0003, VAV-0007, VAV-0009 |
| Playbooks | vav-min-flow-reheat |
| Source | Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.1 — the normalized statistic (eq. 1) and the two-sided CUSUM recursion (eqs. 2-3); Figure 9’s k = 0.5 / h = 5 pair, stated there as a synthetic illustration; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.3 — the three generic VPACC error signals, the Temperror piecewise, the faults the temperature chart is claimed to detect, and the occupied-only / first-hour-excluded computation rule; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.4 — VPACC’s point requirements: room temperature, both setpoints and occupancy status are already in the local VAV controller; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.1.5 — slack parameters come from normal-operation data and alarm limits from fault-injection data, collected per VAV box type; no portable values are published; Bushby, Castro, Schein, House (2001), NIST/CEC PIER Project 2.3, §5.2 — Iowa Energy Center fault-injection validation, 8:00-17:00 occupied schedule, data collected at 1-minute intervals; Harness calibration (committed method): tools/simharness/harness.py vavcal mode and tools/simharness/README.md — healthy occupied Temperror measured across 15 zones of B2B OfficeMedium-4004 for a July and a January week; Library-authored: the HVAC FDD Reference v1.0 publishes no CUSUM card, so name, severity, category and every parameter default are argued on this card; Sibling precedent: SYS-0010 (the library’s only prior Discrete.UnitDelay), SYS-0006 (statistical card with sub-condition direction flags), HP-0001 and VAV-0001 (commissioning parameters shipped as documented placeholders); Engine pin e2ff2f8: crates/oce-blocks/src/discrete.rs (UnitDelay sample grid, loop-cut contract, y_start seeding), crates/oce-graph/src/topo.rs (the emit-before sort that admits this feedback path) |
| Operating states | occupied, gated in-graph rather than host-side. While occ_scheduled is false — and for exclusion_time after it goes true — both accumulators are forced to zero, which is what CUSUM semantics require and what the source specifies. yArmedOk publishes that state. |
Preconditions (host-enforced): Four host obligations, then the commissioning one. (1) Bind the ACTIVE occupied-mode setpoints the zone loop is tracking, not schedule defaults, and guarantee zone_temp_sp_htg <= zone_temp_sp_clg — crossed setpoints turn the piecewise into nonsense rather than into a wider band (see Deviations). (2) The AHU serving this box must be delivering air at its setpoint; a failed air handler puts every zone on the trunk out of band at once and this rule will blame each box in turn. Suppress while the AHU is down or off schedule. (3) The zone temperature sensor must be trustworthy — a sensor drifting 1 °C produces exactly this signature with the box in perfect health, which is what VAV-0010 (reserved, neighbor-comparison drift) is for. (4) Tick on the sample_period grid: both unit delays advance on that clock, and a host ticking faster publishes an un-advanced preview of the accumulator between sample instants. Finally, slack_k, alarm_limit_h and error_sigma are commissioning values, not portable constants — §5.1.5 says so in as many words, and until a site has set them from its own data the verdict means only what the shipped illustrative pair means. yArmedOk is the in-rule evaluability output: false means NO_EVAL, not healthy.
Points: zone_temp, zone_temp_sp_htg, zone_temp_sp_clg, occ_scheduled
Outputs:
yFault— True while either cumulative sum is strictly above alarm_limit_h — the zone has been outside its occupied deadband, in one direction, for long enough that the accumulated normalized error passed the limityHigh— Sub-condition flag, undelayed — the S (upper) chart is above the limit: the zone has been riding above its cooling setpoint. Not an evaluability output; a false yHigh never means NO_EVALyLow— Sub-condition flag, undelayed — the T (lower) chart is above the limit: the zone has been riding below its heating setpoint. Not an evaluability outputyArmedOk— Evaluability flag — true only while the schedule is occupied AND exclusion_time has elapsed since it went occupied. FALSE MEANS NO_EVAL: the accumulators are held at zero and yFault is silent by construction, not by evidence
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
slack_k | 0.5 | 1 (standard deviations per sample) | slackK.k | Slack. The normalized error must exceed k before it accumulates at all, which is what filters normal-operation variation out of the sums. COMMISSIONING PARAMETER: §5.1.5 says slack parameters come from normal-operation data collected across VAV box types and publishes none, so 0.5 is Figure 9’s synthetic illustration and nothing stronger. One constant feeds both charts. |
alarm_limit_h | 5.0 | 1 (accumulated standard deviations) | sAlarm.t, tAlarm.t | Alarm limit. Either sum strictly above it means out of control. COMMISSIONING PARAMETER on the same terms as slack_k — §5.1.5 derives alarm limits from fault-injection data; 5 is Figure 9’s pair-mate to k = 0.5. One card parameter binds both charts; a site wanting the source’s per-chart limits sets sAlarm.t and tAlarm.t individually and records the split. |
error_sigma | 0.5 | °C | sigmaK.k | Normalization denominator, eq. 1’s sigma-hat. NOT a measured healthy standard deviation and it cannot be one — measured healthy Temperror is identically zero (see Deviations) and division by zero is undefined. It is a FLOOR: the smallest sustained deviation the site is willing to treat as noise, in degrees. With slack_k it sets the detection floor, since nothing below slack_k × error_sigma (0.25 °C at the defaults) ever accumulates. |
error_mean | 0.0 | °C | meanK.k | Expected Temperror under normal operation, eq. 1’s x-bar. 0.0 is measured rather than assumed: the piecewise is zero everywhere inside the deadband, and a healthy occupied zone stays there (harness vavcal, 15 zones × 2 seasons). A site whose zones habitually ride a setpoint edge can raise it, accepting that the charts then measure departure from that habit. |
sample_period | 300.0 | s | sPrev.samplePeriod, tPrev.samplePeriod | The CUSUM clock. Both unit delays advance on this grid and each accumulator gains one increment per period, so time-to-alarm is roughly alarm_limit_h / (z − slack_k) periods. SET IT TO THE HOST’S TICK INTERVAL and set both paths together. The source’s own Iowa Energy Center data was 1-minute (§5.2); a site trending at 60 s and leaving the limit alone alarms five times sooner than this card’s worked numbers. |
exclusion_time | 3600.0 | s | occArmed.delayTime | How long after each occupied-period start the accumulators stay held at zero, so steady-state conditions can develop — the source’s first hour (§5.1.3), taken literally. Also covers a controller restart, since the delay is armed from init. |
Description
A zone that misses its setpoints by a little, all day, is invisible to a threshold. Half a degree past the cooling setpoint is not worth an alarm on any one reading, and no fixed band will catch it without also catching every transient. A cumulative sum chart catches it by accumulating: the deviation is normalized, a slack term is subtracted so ordinary variation nets out, and what survives is added to a running total that only moves in one direction until the zone comes back inside its band. Small and persistent beats large and brief, which is the opposite of what every other VAV card in this library does.
Two totals run, one per direction, so the finding names which way the zone is failing — a zone stuck above its cooling setpoint and a zone stuck below its heating setpoint have almost no repairs in common. The accumulators are held at zero outside the occupied schedule and for the first hour inside it: an unoccupied zone is supposed to drift, and a zone still recovering from setback has not yet failed at anything.
Detection Logic
Temperror = max(0, zone_temp − zone_temp_sp_clg) above the cooling setpoint
+ min(0, zone_temp − zone_temp_sp_htg) below the heating setpoint
both zero inside the deadband
z_i = (Temperror_i − error_mean) / error_sigma
armed_i = occ_scheduled held continuously true for exclusion_time
S_i = armed_i ? max(0, z_i − slack_k + S_{i−1}) : 0
T_i = armed_i ? max(0, −z_i − slack_k + T_{i−1}) : 0
yHigh = S_i > alarm_limit_h yLow = T_i > alarm_limit_h
yFault = yHigh OR yLow
Block graph (rule.cxf.jsonld):
The two accumulators are the library’s first feedback loops. Each closes
through a Discrete.UnitDelay, whose output at tick i is its input at tick
i−1 by construction — that is what makes the cycle legal rather than an
algebraic loop, and the engine’s block-level sort admits it because a
loop-breaker’s input is read only by the deferred state update. Both delays run
on sample_period, which must be the host’s tick interval: between sample
instants a delay holds its last value, so the accumulator recomputes from the
same history instead of advancing, and the published sum is a preview.
There is no persistence timer, because the accumulation is the persistence — a
disturbance large enough to add several increments still has to survive
alarm_limit_h / slack_k quiet samples before the sum drains back to zero.
That drain is the flip side of the sensitivity: after a repair the alarm clears
about ten samples later at the defaults, not immediately. Both comparisons are
strict, so a sum landing exactly on the limit reads healthy.
The occupancy reset is applied to the published sum and to the delay input together, so an unoccupied tick emits a true zero rather than a one-tick recomputation from zero — CUSUM needs the state cleared, not the output muted.
Possible Diagnoses
The source attributes this channel to damper faults, valve faults and temperature sensor faults (§5.1.3); the direction flags split them.
- Damper fault —
yHighwith a stuck or hunting damper that will not open past minimum starves the zone of cooling;yLowwith a damper stuck open overcools it on primary air. Check position feedback against command before anything else. - Reheat valve fault —
yLowwhere the valve cannot open (failed actuator, no hot water, air-bound coil) and the zone never reaches its heating setpoint;yHighwhere it leaks or is driven open, which is VAV-0003’s fault seen from the temperature side rather than the valve side. - Zone temperature sensor drift or bad placement — the sensor is wrong and the box is fine. A sensor in a supply-air stream, above a copier, or on a sunlit wall produces a permanent one-sided error that no repair to the box will clear. VAV-0010 is the rule that names this; until it ships, a sibling-zone comparison does the same job by hand.
- Primary air wrong or absent — supply air temperature off its setpoint, duct static starved, fan down. Every box on the trunk trips in the same direction within a few samples of each other, which is the tell.
- Load beyond the box’s capacity — a re-purposed or over-occupied zone whose terminal was sized for something else. A commissioning finding, not a fault, and the only one on this list that a work order cannot close.
Energy Impact
COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. The rule reports that a zone spent hours outside its band and in which direction; it cannot say what that cost, because the cost depends entirely on which of the five diagnoses is true. A starved zone wastes almost nothing directly and delivers the discomfort the whole system exists to prevent; a zone held above its cooling setpoint by a leaking reheat coil is burning heat and then paying to remove it, which is VAV-0003’s accounting. Confidence is MEDIUM rather than LOW because the method is not speculative — the source validated VPACC against physically injected faults on instrumented boxes at the Iowa Energy Center (§5.2) — and not HIGH because the shipped slack and alarm limits are an illustration, so the false-positive rate of this parameterization is uncharacterized. Climate- neutral: a zone out of band is out of band in every climate.
Emissions Impact
Scope 1 or 2 depending on which side trips and what serves the box,
QUALITATIVE_EMISSIONS, MEDIUM confidence, avoided-emissions basis N/A. A yLow
finding on a hydronic box points at heating energy, Scope 1 where the boiler
burns gas; a yHigh finding points at cooling and fan energy, Scope 2. The
split is real and this card does not collapse it, because the direction flag
that decides it is one of the two things the rule actually knows.
Deviations
- Temperror is signed here; the source’s third branch is not. §5.1.3 writes
Temperror = HSP − Temproombelow the heating setpoint, a positive magnitude, so its Temperror is non-negative in both directions. Taken literally that makes eq. 3’s T chart dead on this channel — with a non-negative error and the measured x-bar of zero,−z − kis never positive andyLowcould never assert. This card negates that branch (zone_temp − zone_temp_sp_htg), which leaves the magnitude identical to the source’s and puts the source’s own two-sided recursion back to work. The cost is that a zone alternating between hot and cold excursions accumulates in neither chart as fast as a magnitude signal would; that oscillation is VAV-0005’s fault, not this one’s. - The piecewise is built as
max(0, e_clg) + min(0, e_htg)rather than as a three-way selection. Forzone_temp_sp_htg <= zone_temp_sp_clgthe two terms are never both non-zero and the sum reproduces all three branches exactly, in five blocks with no boolean routing. It relies on that ordering: crossed setpoints make both terms live and the sum meaningless, which is why the frontmatter demands the ordering rather than clamping it in-graph. error_sigmais a floor, not a statistic. The committed harness method (vavcal) computes exactly this piecewise over occupied ticks and reports the cross-zone median mean and standard deviation; on B2B OfficeMedium-4004, 15 zones, one July and one January week, healthy Temperror came back identically zero in both seasons. That is a fact about a well-controlled simulated thermostat as much as about the piecewise, and simulation noise (order 0.01- 0.03 °C) is cleaner than any real sensor — but it does establish that there is no healthy standard deviation to divide by. So sigma ships as a documented floor in degrees, and the consequence is stated plainly: any sustained deviation aboveslack_k × error_sigmaaccumulates without limit.slack_k = 0.5andalarm_limit_h = 5are Figure 9’s illustrative pair. §5.1.5 declines to publish production values and describes a per-box-type calibration campaign instead; Figure 9’s numbers annotate a synthetic normal-distribution example, not a VAV box. They ship because a card needs defaults, and the parameter descriptions say exactly what they are. Contrast the APAR half of the same report, which commits a full threshold table.- The occupancy reset is in the graph, against the library’s usual stance.
Operating-state gating is normally host-side, but suppressing the output is
not the same as resetting the state: a frozen non-zero accumulator would carry
yesterday afternoon’s excursion across the night and alarm on the first armed
tick. The source is explicit that the CUSUMs reset to zero (§5.1.3), so the
reset must be where the state is. Binding
occ_scheduledas an ordinary point follows AHU-0026 and SYS-0008. - The reset drives the published sum, not only the feedback path. Switching
only the delay’s input would leave the current tick free to publish
max(0, z − k)while unoccupied, so a single large unoccupied excursion could assertyFaultatalarm_limit_hbelow 1. The Switch sits between the clamp and both consumers instead. - The first hour is a
Logical.TrueDelayonocc_scheduled, not a host-side convention:armedgoes true only after the schedule has been true continuously forexclusion_time, and falls immediately when it drops.delayOnInit = true(CDL defaultfalse) is the library’s standing choice and does real work here — a controller restarting mid-afternoon serves a fresh exclusion hour rather than arming empty accumulators into a running zone. sample_periodbinds two CXF paths and couples the rule to the host’s tick.Discrete.UnitDelayadvances on a grid derived from the first tick time; between instants it holds, so a host ticking faster than the period sees the accumulator recompute from an un-advanced history rather than climb. Ticks slower than the period degrade gracefully to a one-tick delay. Set both paths together, and set them to the tick.UnitDelayseeds fromy_startfor up to two sample periods when the run starts off-grid (engine-verified: the block samples onwhen sampleTriggerwith noinitial()clause, so a mid-interval start stages nothing until the next true instant). Aty_start = 0— the default, left unset — that is indistinguishable from the reset state, so the quirk costs nothing here; it would matter to any future card seeding a delay non-zero.- No alarm delay on
yFault. Every other timing-bearing card in this library ends in aLogical.TrueDelay; this one does not, because the accumulation already is the persistence test and stacking a timer on top would make the effective detection time two unrelated parameters deep. The source alarms when the sum exceeds the limit, full stop. - Slack, limit, mean and sigma are
Reals.Sources.Constantvalues, notAddParameter/MultiplyByParametergains. The memo’s sketch usedAddParameter(p = −k), which would make the shipped tunable a negative number the retuner has to remember to negate. Constants keep every published parameter positive and in its natural unit, the same reason SYS-0006 squares its noise threshold in-graph rather than publishing the square. - One
alarm_limit_hbinds both charts. §5.1.5 speaks of alarm limits for the S and T sums separately, so an asymmetric site is within the source’s intent; the card exposes one parameter because symmetric is the sane default, and both CXF paths are listed so a host can split them deliberately. - Both comparisons are strict (
>), matching “exceeds” in §5.1.1. The edge is pinned exactly rather than by bracketing, because at the shipped defaults a 0.5 °C excursion produces an increment of exactly 0.5 and the sum lands on 5.0 and then 5.5 on consecutive samples with no floating-point slack. - Three extra boundary outputs, and they are not the same kind.
yHighandyLoware sub-condition flags in SYS-0006’s sense — diagnostic detail, read withyFault, and a false one never means NO_EVAL.yArmedOkis an evaluability flag in AHU-0006’s sense: the occupancy gate lives inside this graph, so without it a host cannot tell a silent healthy zone from a zone nobody was watching. - Severity 3,
category: COMFORT_ENERGY,confidence: MEDIUMand the name are library-authored. The HVAC FDD Reference has no CUSUM card to transcribe and the source assigns no severity to anything. Severity 3 matches VAV-0003, the neighbouring zone-comfort card; the category follows the fault’s character (a zone out of band costs comfort first and energy second). g36: nulland no G36 clause insource. G36 sequences terminal units, but this logic is 2001 statistical-process-control work that predates it, and SCHEMA.md reserves theg36field for the 001-049 range regardless.
Notes
The engine accepted the feedback loop without complaint on the first load, which
is worth recording because it was the open question for the whole family:
Discrete.UnitDelay declares no feedthrough from u to y, so the connector
DAG never sees a cycle and the block-level emit-before sort skips the cut input
entirely. Nothing about the loop needed working around.
VAV-0010 is reserved for zone temperature sensor drift by neighbour
comparison. It is deliberately not in related, because it does not co-occur
with this rule — it is the rule that decides whether this one’s input can be
believed, and diagnosis 3 is the whole of the overlap.
The family splits by error signal, not by fault: VAV-0007 accumulates airflow error, this card zone temperature error, VAV-0009 the reheat-coil temperature rise. A box in real trouble usually trips more than one, and which ones it trips is most of the diagnosis.
Test Vectors
11 scenarios, clock step 300 s over 36000 s.
| Scenario | Description |
|---|---|
healthy_inside_deadband_silent | Zone parked mid-band all day. Temperror is identically zero (the deadband branch), so both accumulators sit at their floor and nothing ever arrives at the alarm limit. |
excursion_below_slack_stays_silent | 0.2 degC above the cooling setpoint forever: z = 0.4 is under the slack k = 0.5, so every increment is negative and the Max clamp holds S at zero. The point of k. |
excursion_exactly_at_slack_never_accumulates | 0.25 degC above the cooling setpoint is exactly k * sigma, so z - k is exactly zero and S never leaves the floor however long it runs. The slack edge, pinned exactly (0.25 and 0.5 are binary-exact). |
small_warm_bias_accumulates_to_alarm | 0.3 degC above the cooling setpoint — a bias no fixed threshold in this library would report — accumulates at 0.1 per sample and reaches the alarm limit after about 50 samples. This is what CUSUM buys. |
warm_bias_trips_high_side_at_alarm_limit | 0.5 degC above the cooling setpoint gives z = 1 and an increment of exactly 0.5, so S lands on 5.0 at t=6300 (not above the limit, strict >) and 5.5 at t=6600. Both sides of h at the accumulator level. |
cold_bias_trips_low_side_at_alarm_limit | 0.5 degC below the heating setpoint: the heating branch of the piecewise gives a negative Temperror, the T accumulator carries it, and the same 5.0 / 5.5 edge appears on the low side while the high side stays at zero. |
first_hour_of_occupancy_excluded | A 2 degC excursion present from the first tick would cross the alarm limit two samples in without the exclusion; the accumulators stay clamped to zero until t=3600 and only then start, so the alarm lands at 3900. |
unoccupied_never_accumulates | 6 degC above the cooling setpoint for ten hours with the schedule off. The Switch feeds zero into both accumulators and into their unit delays, so the rule is silent and yArmedOk says why. |
occupancy_reset_clears_mid_accumulation | The same 0.5 degC bias, interrupted: S has reached 3.0 when the schedule drops at t=5400, the reset zeroes it, and the second occupied period has to serve its own exclusion hour and climb from zero again — so the alarm arrives at 13800 instead of 6600. |
alarm_drains_after_zone_returns_to_deadband | Warm bias until t=7200, then the zone recovers. S drains by k per sample once the error is back inside the deadband and the alarm clears at 7500 — CUSUM forgets at the slack rate, not instantly. |
setpoint_edges_are_zero_error | Zone parked exactly on the heating setpoint, then exactly on the cooling setpoint, then mid-band. All three branches of the piecewise evaluate to zero at and inside the edges, so ten occupied hours produce nothing. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 36000
},
"scenarios": [
{
"name": "healthy_inside_deadband_silent",
"description": "Zone parked mid-band all day. Temperror is identically zero (the deadband branch), so both accumulators sit at their floor and nothing ever arrives at the alarm limit.",
"inputs": {
"zone_temp": 22.0,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yHigh",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yLow",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yArmedOk",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yArmedOk",
"from_s": 3600,
"to_s": 36000,
"equals": true
}
]
},
{
"name": "excursion_below_slack_stays_silent",
"description": "0.2 degC above the cooling setpoint forever: z = 0.4 is under the slack k = 0.5, so every increment is negative and the Max clamp holds S at zero. The point of k.",
"inputs": {
"zone_temp": 24.2,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yHigh",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yLow",
"from_s": 0,
"to_s": 36000,
"equals": false
}
]
},
{
"name": "excursion_exactly_at_slack_never_accumulates",
"description": "0.25 degC above the cooling setpoint is exactly k * sigma, so z - k is exactly zero and S never leaves the floor however long it runs. The slack edge, pinned exactly (0.25 and 0.5 are binary-exact).",
"inputs": {
"zone_temp": 24.25,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yHigh",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yLow",
"from_s": 0,
"to_s": 36000,
"equals": false
}
]
},
{
"name": "small_warm_bias_accumulates_to_alarm",
"description": "0.3 degC above the cooling setpoint \u2014 a bias no fixed threshold in this library would report \u2014 accumulates at 0.1 per sample and reaches the alarm limit after about 50 samples. This is what CUSUM buys.",
"inputs": {
"zone_temp": 24.3,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 17400,
"equals": false
},
{
"output": "yFault",
"from_s": 19800,
"to_s": 36000,
"equals": true
},
{
"output": "yHigh",
"from_s": 19800,
"to_s": 36000,
"equals": true
},
{
"output": "yLow",
"from_s": 0,
"to_s": 36000,
"equals": false
}
]
},
{
"name": "warm_bias_trips_high_side_at_alarm_limit",
"description": "0.5 degC above the cooling setpoint gives z = 1 and an increment of exactly 0.5, so S lands on 5.0 at t=6300 (not above the limit, strict >) and 5.5 at t=6600. Both sides of h at the accumulator level.",
"inputs": {
"zone_temp": 24.5,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 6300,
"equals": false
},
{
"output": "yFault",
"from_s": 6600,
"to_s": 36000,
"equals": true
},
{
"output": "yHigh",
"from_s": 0,
"to_s": 6300,
"equals": false
},
{
"output": "yHigh",
"from_s": 6600,
"to_s": 36000,
"equals": true
},
{
"output": "yLow",
"from_s": 0,
"to_s": 36000,
"equals": false
}
]
},
{
"name": "cold_bias_trips_low_side_at_alarm_limit",
"description": "0.5 degC below the heating setpoint: the heating branch of the piecewise gives a negative Temperror, the T accumulator carries it, and the same 5.0 / 5.5 edge appears on the low side while the high side stays at zero.",
"inputs": {
"zone_temp": 19.5,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 6300,
"equals": false
},
{
"output": "yFault",
"from_s": 6600,
"to_s": 36000,
"equals": true
},
{
"output": "yLow",
"from_s": 0,
"to_s": 6300,
"equals": false
},
{
"output": "yLow",
"from_s": 6600,
"to_s": 36000,
"equals": true
},
{
"output": "yHigh",
"from_s": 0,
"to_s": 36000,
"equals": false
}
]
},
{
"name": "first_hour_of_occupancy_excluded",
"description": "A 2 degC excursion present from the first tick would cross the alarm limit two samples in without the exclusion; the accumulators stay clamped to zero until t=3600 and only then start, so the alarm lands at 3900.",
"inputs": {
"zone_temp": 26.0,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 3900,
"to_s": 36000,
"equals": true
},
{
"output": "yArmedOk",
"from_s": 0,
"to_s": 3300,
"equals": false
},
{
"output": "yArmedOk",
"from_s": 3600,
"to_s": 36000,
"equals": true
}
]
},
{
"name": "unoccupied_never_accumulates",
"description": "6 degC above the cooling setpoint for ten hours with the schedule off. The Switch feeds zero into both accumulators and into their unit delays, so the rule is silent and yArmedOk says why.",
"inputs": {
"zone_temp": 30.0,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yHigh",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yLow",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yArmedOk",
"from_s": 0,
"to_s": 36000,
"equals": false
}
]
},
{
"name": "occupancy_reset_clears_mid_accumulation",
"description": "The same 0.5 degC bias, interrupted: S has reached 3.0 when the schedule drops at t=5400, the reset zeroes it, and the second occupied period has to serve its own exclusion hour and climb from zero again \u2014 so the alarm arrives at 13800 instead of 6600.",
"inputs": {
"zone_temp": 24.5,
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": [
{
"t": 0,
"value": true
},
{
"t": 5400,
"value": false
},
{
"t": 7200,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 13500,
"equals": false
},
{
"output": "yFault",
"from_s": 13800,
"to_s": 36000,
"equals": true
},
{
"output": "yArmedOk",
"from_s": 3600,
"to_s": 5100,
"equals": true
},
{
"output": "yArmedOk",
"from_s": 5400,
"to_s": 10500,
"equals": false
},
{
"output": "yArmedOk",
"from_s": 10800,
"to_s": 36000,
"equals": true
}
]
},
{
"name": "alarm_drains_after_zone_returns_to_deadband",
"description": "Warm bias until t=7200, then the zone recovers. S drains by k per sample once the error is back inside the deadband and the alarm clears at 7500 \u2014 CUSUM forgets at the slack rate, not instantly.",
"inputs": {
"zone_temp": [
{
"t": 0,
"value": 24.5
},
{
"t": 7200,
"value": 22.0
}
],
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 6300,
"equals": false
},
{
"output": "yFault",
"from_s": 6600,
"to_s": 7200,
"equals": true
},
{
"output": "yFault",
"from_s": 7500,
"to_s": 36000,
"equals": false
},
{
"output": "yLow",
"from_s": 0,
"to_s": 36000,
"equals": false
}
]
},
{
"name": "setpoint_edges_are_zero_error",
"description": "Zone parked exactly on the heating setpoint, then exactly on the cooling setpoint, then mid-band. All three branches of the piecewise evaluate to zero at and inside the edges, so ten occupied hours produce nothing.",
"inputs": {
"zone_temp": [
{
"t": 0,
"value": 20.0
},
{
"t": 12000,
"value": 24.0
},
{
"t": 24000,
"value": 22.0
}
],
"zone_temp_sp_htg": 20.0,
"zone_temp_sp_clg": 24.0,
"occ_scheduled": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yHigh",
"from_s": 0,
"to_s": 36000,
"equals": false
},
{
"output": "yLow",
"from_s": 0,
"to_s": 36000,
"equals": false
}
]
}
]
}