VAV-0002 — Rogue zone driving AHU reset
| Status | verified — engine e2ff2f8, cxf:fnv1a128:37348fd287c6a793b5a1d0b6f46b350a, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0019, AHU-0023, AHU-0024 |
| Playbooks | — |
| Source | HVAC FDD Reference v1.0 §10, VAV-0002; PNNL retuning measures; PNNL-25985 EEM-15 |
| Operating states | Cooling modes |
Preconditions (host-enforced): AHU running and serving multiple zones — a reset loop with two or three boxes on it has no majority to be rogue against. satisfied_zone_fraction must be fresh and computed over the zones on this AHU’s reset loop; when the aggregate is stale, or covers too few zones for a fraction to mean anything, the verdict is NO_EVAL rather than healthy. The zone’s own request must be reaching the AHU reset logic: a box whose requests never arrive cannot be driving anything, and its saturated request is a comms finding instead.
Points: zone_clg_request, satisfied_zone_fraction
Outputs:
yFault— True once this zone’s cooling request has been saturated with more than satisfied_threshold of sibling zones satisfied, continuously for rogue_duration plus alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
request_max_threshold | 99.0 | % | reqMax.t | Cooling request above which the zone counts as asking for maximum cooling |
satisfied_threshold | 0.8 | 1 | mostHappy.t | Fraction of sibling zones satisfied (0-1) above which this zone is the outlier rather than the messenger |
rogue_duration | 3600.0 | s | rogue.delayTime | Continuous duration of the saturated request with most sibling zones satisfied before the zone counts as rogue (60 min) |
alarm_delay | 1800.0 | s | persist.delayTime | Further persistence required after rogue_duration before the alarm asserts (30 min) |
Description
One zone holds its cooling request at maximum while the rest of the building is comfortable. Trim-and-respond does what it was written to do: the AHU answers the loudest zone, walks its supply temperature or duct static setpoint to the aggressive end of the reset range, and leaves it there. The cost lands on every other box on that air handler — colder air than they asked for, which they reheat, or more static pressure than they need, which the fan pays for cubed — while the rogue zone itself usually stays uncomfortable, because whatever is wrong with it is not something more cooling fixes. The whole design turns on telling a rogue zone from a messenger: a saturated request means “I need more cooling”, and what makes it a fault is the company it keeps. With half the building unsatisfied the same request is a correct report of a building-wide shortfall, and firing on it would send a technician to the wrong end of the system.
Detection Logic
yFault = zone_clg_request > request_max_threshold
AND satisfied_zone_fraction > satisfied_threshold
sustained continuously for rogue_duration
and then held a further alarm_delay
Block graph (rule.cxf.jsonld):
Two threshold tests feed one conjunction and then two delays in series: rogue
turns true only after the conjunction has held for rogue_duration (60 min),
and persist requires a further alarm_delay (30 min), so a zone that never
lets go alarms at 5400 s. Any break in either term drops both timers and
discards the accumulated time — the alarm only ever describes one continuous
rogue episode, and a zone that recovers between morning warm-up peaks never
raises it. Both comparisons are strict, so a request sitting exactly on 99.0%
and a satisfaction fraction of exactly 0.80 both stay clear. Both delayOnInit
flags are true, so a rogue condition already present at load waits out the
full 90 minutes; the trade is that in-rule timing does not survive a restart,
which is the conservative direction.
Possible Diagnoses
- Zone has an internal load the design never accounted for — a server closet, a copy room, a tenant fit-out that added people or equipment
- Zone thermostat miscalibrated or badly sited — direct sun on the sensor, a diffuser blowing across it, or a drift the zone cannot argue with
- VAV box undersized for the actual zone load, so full cooling airflow still cannot hold the setpoint
- Stuck VAV damper: the box commands full open, the blade does not move, and the zone stays hot no matter what the AHU sends it (confirm with VAV-0004 before believing the load story)
Energy Impact
EXCESS_CONSUMPTION, MEDIUM confidence, PROXY_ESTIMATION. The reference gives
3–10% of AHU energy, mapped to PNNL-25985 EEM-15. Estimation is PROXY because
the waste is a counterfactual — ahu_penalty_kw ≈ ahu_actual_energy − ahu_optimal_energy, what the AHU would have consumed at the setpoint the rest
of the building would have permitted — and neither term is measured directly.
Climate sensitivity is both: a rogue zone dragging supply air down costs chiller
energy in summer and reheat energy in winter, and a rogue airflow request costs
fan energy year-round. The multiplier is what makes it worth catching: one box
holds a reset that serves thirty or three hundred.
Emissions Impact
Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 100–800 kg CO₂e/yr for the AHU-level penalty one zone imposes. Scope 2 because the penalty is dominated by fan and chiller electricity; a site whose reheat is gas or steam moves the reheat share of the consequential waste into scope 1, which this card leaves to VAV-0003 and VAV-0006 to account for. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
zone_tempandzone_temp_spare dropped. The reference lists both in its points table but its equation consumes neither — they are inputs to the host’s derivation of “satisfied”, which the equation reads only through the ratio. Inputs to a host derivation are not rule points (precedent: AHU-0029 consumesexpected_moderather than theoatbehind it).satisfied_zones / total_zonesbecomes the host-derived pointsatisfied_zone_fraction. Library v1 avoids array boundary points, so the host counts satisfied siblings and feeds one fraction, flaggedderivedin the point dictionary. Same pattern aszone_reheat_fractionin AHU-0019.satisfied_thresholdis a fraction 0–1, not a percent. The reference states 80%; the point it compares against carries unit1, so the default is 0.8. A host feeding a 0–100 percentage will never fire this rule.MAX_COOLINGis implemented as a threshold at 99.0%, an adopted value the reference does not state. Equality against 100.0 is the wrong test for a real signal: a request scaled from a 16-bit analog value lands on 99.6 and an equality test would never fire.> 99.0reads “saturated at its ceiling” while excluding a genuine 99% request one count short of maximum, andrequest_max_thresholdis exposed so a site with a coarser signal can lower it.- The
satisfied_zone_fractioncomparison is strict>, which is what the reference writes — no boundary deviation on that term, though it is the boundary where two hosts’ definitions of “satisfied” will disagree. - Two delays in series rather than one.
rogue_durationandAlarmDelayare separate tunables in the reference, so they stay separately tunable here even though a single 5400 s delay behaves identically at the defaults (precedent: AHU-0027). rogue.delayOnInitandpersist.delayOnInitare bothtrue(Modelica/CDL default isfalse), the library’s standing choice against alarming on the first tick after a controller restart.- The reference tags this fault for VAV and AHU. This card is the VAV-family instance, bound to one box’s request and its siblings’ satisfaction (precedent: AHU-0025); the AHU-side view is covered by AHU-0019, AHU-0023 and AHU-0024, which see the reset stuck at its limit without knowing which zone is holding it there.
- Operating-state gating (cooling modes) and the multi-zone precondition are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.
Notes
playbooks is empty because no playbook here covers the zone-side diagnosis
this fault opens; the nearest relative is
missing-reset, whose step 1.3 (do zone
requests reach the AHU controller?) is this rule’s precondition check read from
the other end.
Fix order matters when this rule fires alongside AHU-0023 or AHU-0024. A rogue zone is the classic reason a correctly programmed reset still looks dead in trend data — the setpoint sits at the aggressive end all day because one box keeps voting for it — which is exactly the signature those two statistical rules detect. Fix the zone first; the reset should then start modulating within an occupied day and the AHU-side faults clear on their own.
G36 sites run discrete cooling requests, not a continuous 0–100 signal. The
point dictionary’s binding note for zone_clg_request covers the mapping: bind
100 to importance-weighted requests at their maximum and record the convention
with the point. This rule only tests for saturation at the ceiling, so it
survives the translation as long as the ceiling is the same number on both sides.
Test Vectors
10 scenarios, clock step 300 s over 21600 s.
| Scenario | Description |
|---|---|
normal_diversity | Reference vector: the zone asks for 80% cooling while 70% of its siblings are satisfied. A box working partway up its request range is the system doing its job — normal load diversity, no rogue. |
rogue_zone_others_satisfied | Reference vector: the zone pins its request at 100% while 90% of the building is satisfied, held for 90 min. One box is dragging the AHU reset for everyone; the alarm lands at rogue_duration (3600 s) + alarm_delay (1800 s) = 5400 s. |
many_zones_unsatisfied | Reference vector: the same saturated request, but only 50% of zones are satisfied. Half the building is hot — the AHU should be resetting its setpoint down, and the zone asking for it is right. A building-wide cooling shortfall is an AHU or plant finding, not a rogue zone. |
request_exactly_at_threshold | Boundary: the request sits exactly on request_max_threshold (99.0%) with 90% of zones satisfied. The test is strict >, so a request one count short of the adopted ceiling is not saturation and nothing fires. |
quantized_request_just_above_threshold | Boundary from the other side: a box reporting 99.2% — a scaled 16-bit request that never renders as exactly 100 — clears the 99.0 threshold and alarms on the same 5400 s schedule as a clean 100%. This is why the threshold is 99.0 rather than 100.0. |
satisfaction_exactly_at_threshold | Boundary: exactly 80% of zones satisfied — satisfied_threshold on the nose — against a saturated request. The reference writes >, so the fraction must exceed the threshold and a four-in-five split stays clear. |
satisfaction_just_above_threshold | Boundary from the other side: 81% satisfied with the request saturated crosses satisfied_threshold and alarms at 5400 s. |
request_released_before_rogue_duration | Transient: the zone rides at maximum request for 50 min after a warm morning and then falls back to 60% at t=3000 s, 600 s short of rogue_duration. A zone that recovers on its own was tracking real load, so the duration timer discards the accumulated time and nothing is ever raised. |
satisfaction_drops_mid_hold | Transient: the request stays pinned, but the rest of the building loses ground at t=4200 s (0.90 to 0.60) and recovers at t=4800 s. rogue_duration had already elapsed and the alarm delay was running; the interruption drops both timers, so the alarm restarts from 4800 s and lands at 10200 s rather than 5400 s. |
rogue_zone_alarms_then_clears | Recovery: the alarm asserts at 5400 s, then the zone is fixed at t=10800 s — the thermostat is re-sited out of the sun and the request falls to 40%. yFault drops on that tick, since TrueDelay only delays the rising edge. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 21600
},
"scenarios": [
{
"name": "normal_diversity",
"description": "Reference vector: the zone asks for 80% cooling while 70% of its siblings are satisfied. A box working partway up its request range is the system doing its job \u2014 normal load diversity, no rogue.",
"inputs": {
"zone_clg_request": 80.0,
"satisfied_zone_fraction": 0.7
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "rogue_zone_others_satisfied",
"description": "Reference vector: the zone pins its request at 100% while 90% of the building is satisfied, held for 90 min. One box is dragging the AHU reset for everyone; the alarm lands at rogue_duration (3600 s) + alarm_delay (1800 s) = 5400 s.",
"inputs": {
"zone_clg_request": 100.0,
"satisfied_zone_fraction": 0.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5100,
"equals": false
},
{
"output": "yFault",
"from_s": 5700,
"to_s": 21600,
"equals": true
}
]
},
{
"name": "many_zones_unsatisfied",
"description": "Reference vector: the same saturated request, but only 50% of zones are satisfied. Half the building is hot \u2014 the AHU should be resetting its setpoint down, and the zone asking for it is right. A building-wide cooling shortfall is an AHU or plant finding, not a rogue zone.",
"inputs": {
"zone_clg_request": 100.0,
"satisfied_zone_fraction": 0.5
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "request_exactly_at_threshold",
"description": "Boundary: the request sits exactly on request_max_threshold (99.0%) with 90% of zones satisfied. The test is strict `>`, so a request one count short of the adopted ceiling is not saturation and nothing fires.",
"inputs": {
"zone_clg_request": 99.0,
"satisfied_zone_fraction": 0.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "quantized_request_just_above_threshold",
"description": "Boundary from the other side: a box reporting 99.2% \u2014 a scaled 16-bit request that never renders as exactly 100 \u2014 clears the 99.0 threshold and alarms on the same 5400 s schedule as a clean 100%. This is why the threshold is 99.0 rather than 100.0.",
"inputs": {
"zone_clg_request": 99.2,
"satisfied_zone_fraction": 0.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5100,
"equals": false
},
{
"output": "yFault",
"from_s": 5700,
"to_s": 21600,
"equals": true
}
]
},
{
"name": "satisfaction_exactly_at_threshold",
"description": "Boundary: exactly 80% of zones satisfied \u2014 satisfied_threshold on the nose \u2014 against a saturated request. The reference writes `>`, so the fraction must exceed the threshold and a four-in-five split stays clear.",
"inputs": {
"zone_clg_request": 100.0,
"satisfied_zone_fraction": 0.8
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "satisfaction_just_above_threshold",
"description": "Boundary from the other side: 81% satisfied with the request saturated crosses satisfied_threshold and alarms at 5400 s.",
"inputs": {
"zone_clg_request": 100.0,
"satisfied_zone_fraction": 0.81
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5100,
"equals": false
},
{
"output": "yFault",
"from_s": 5700,
"to_s": 21600,
"equals": true
}
]
},
{
"name": "request_released_before_rogue_duration",
"description": "Transient: the zone rides at maximum request for 50 min after a warm morning and then falls back to 60% at t=3000 s, 600 s short of rogue_duration. A zone that recovers on its own was tracking real load, so the duration timer discards the accumulated time and nothing is ever raised.",
"inputs": {
"zone_clg_request": [
{
"t": 0,
"value": 100.0
},
{
"t": 3000,
"value": 60.0
}
],
"satisfied_zone_fraction": 0.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "satisfaction_drops_mid_hold",
"description": "Transient: the request stays pinned, but the rest of the building loses ground at t=4200 s (0.90 to 0.60) and recovers at t=4800 s. rogue_duration had already elapsed and the alarm delay was running; the interruption drops both timers, so the alarm restarts from 4800 s and lands at 10200 s rather than 5400 s.",
"inputs": {
"zone_clg_request": 100.0,
"satisfied_zone_fraction": [
{
"t": 0,
"value": 0.9
},
{
"t": 4200,
"value": 0.6
},
{
"t": 4800,
"value": 0.9
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 9900,
"equals": false
},
{
"output": "yFault",
"from_s": 10500,
"to_s": 21600,
"equals": true
}
]
},
{
"name": "rogue_zone_alarms_then_clears",
"description": "Recovery: the alarm asserts at 5400 s, then the zone is fixed at t=10800 s \u2014 the thermostat is re-sited out of the sun and the request falls to 40%. yFault drops on that tick, since TrueDelay only delays the rising edge.",
"inputs": {
"zone_clg_request": [
{
"t": 0,
"value": 100.0
},
{
"t": 10800,
"value": 40.0
}
],
"satisfied_zone_fraction": 0.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5100,
"equals": false
},
{
"output": "yFault",
"from_s": 5700,
"to_s": 10500,
"equals": true
},
{
"output": "yFault",
"from_s": 11100,
"to_s": 21600,
"equals": false
}
]
}
]
}