VAV-0006 — Reheat waste during cooling season
| Status | verified — engine e2ff2f8, cxf:fnv1a128:f9e05984e4f2c96f769f4460a41807b7, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-05 |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0019, VAV-0001, VAV-0003 |
| Playbooks | vav-min-flow-reheat |
| Source | HVAC FDD Reference v1.0 §10, VAV-0006; Torabi et al. 2022; PNNL-25985 EEM-15/EEM-16 |
| Operating states | Occupied cooling season. Outside the cooling season the rule reads false because the question is meaningless, not because the box is healthy — the host reports NO_EVAL there, from the same OAT the rule consumes. |
Preconditions (host-enforced): Supply fan running and the box under occupied control; a night-setback reheat cycle is not this fault.
oatmust be the site outdoor air temperature, fresh and shaded — a sun-baked wall sensor reads warm enough to put the building in a cooling season the weather is not in.zone_dmpr_posshould be position feedback where the box provides it; on command-only boxes the damper term reads intent rather than blade position, and a blade stuck open under a minimum-flow command will read as this fault.
Points: rht_vlv_cmd, oat, zone_dmpr_pos
Outputs:
yFault— True once the reheat valve has stayed above reheat_active_threshold with the damper below damper_at_minimum_margin and OAT above cooling_season_oat, continuously for eval_duration plus alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
reheat_active_threshold | 10.0 | % | rhtOn.t | Reheat valve command above which the coil counts as actively consuming energy |
cooling_season_oat | 18.0 | °C | warmOut.t | Outdoor air temperature above which the building is in cooling season (65 °F) |
damper_at_minimum_margin | 35.0 | % | dmprMin.t | Damper position below which the box is passing minimum flow rather than answering a load |
eval_duration | 3600.0 | s | hold.delayTime | Continuous duration of the reheat-at-minimum-flow condition before it counts as waste (60 min) |
alarm_delay | 900.0 | s | persist.delayTime | Further persistence required after eval_duration before the alarm asserts (15 min) |
Description
A reheat coil is running in July. The zone is passing the minimum airflow its configuration insists on, that air arrives at the supply temperature the chiller worked to produce, and the box heats it back up before it reaches the space — simultaneous heating and cooling seen from the zone end, which is where much of it happens in a VAV building. The damper term is what separates waste from load: reheat with the damper open and modulating is a box answering a genuine heating demand, and any finding there sits at the air handler. Reheat with the damper parked at minimum is a configuration defect that persists every occupied hour until someone changes a setpoint. The season term keeps the rule from arguing with winter. This rule is the trigger of CLU-05 (Zone Heating & Cooling Conflict); VAV-0003 and SYS-0007 are members that should clear behind it.
Detection Logic
yFault = rht_vlv_cmd > reheat_active_threshold
AND oat > cooling_season_oat (cooling season)
AND zone_dmpr_pos < damper_at_minimum_margin
sustained continuously for eval_duration
and then held a further alarm_delay
Block graph (rule.cxf.jsonld):
Three threshold tests, two conjunctions, two delays. hold turns true only
after the conjunction has been continuously true for eval_duration (60 min)
and persist adds alarm_delay (15 min) on top, so a box that reheats through
the afternoon alarms at 4500 s. Any break in any of the three terms drops both
timers, so a damper stroke, a passing cloud on the OAT sensor, or a valve that
closes for five minutes restarts the clock. All three comparisons are strict, as
the reference writes them: a valve reported at exactly 10.0%, an outdoor air
temperature of exactly 18.0 °C, and a damper sitting exactly on 35.0% each fail
their term. Both delayOnInit flags are true, so a condition already present
at load waits out the full 75 minutes rather than alarming on the first tick
after a restart.
Possible Diagnoses
- VAV minimum airflow setpoint too high for this zone — the common case, and the one VAV-0001 confirms directly against the ventilation requirement
- AHU supply air temperature setpoint too low, so every box on the air handler has to temper its minimum flow (AHU-0019)
- SAT reset responding to a rogue zone: the reset is working, one zone is holding it at the cold end, and this box is paying for it (VAV-0002)
- Zone has low internal loads but a high minimum flow requirement — a corner office or a perimeter zone sized for a load that never materialized
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The waste is on the wire:
waste_kw = rht_vlv_cmd/100 × vav_rht_capacity_kw for every hour the condition
holds, with no counterfactual to model — and the cooling energy spent making the
air it undoes is waste on top. The reference gives 5–20% of zone thermal energy
and PNNL-25985 maps the fix to EEM-15 (VAV minimum flow reduction) and EEM-16.
Cooling-dominant by climate, though the multiplier is what matters: a building
runs dozens to hundreds of these boxes, and EEM-15 is the highest-impact
individual measure across all building types in the PNNL study at 5–16% of site
energy, 7.7% nationally.
Emissions Impact
Scope 1, DIRECT_EMISSIONS, HIGH confidence; typically 500–3,000 kg CO₂e/yr per zone. The reference reports scope 1 because hot-water reheat is usually fed by a gas-fired boiler, so the waste is on-site combustion. Boxes with electric reheat coils, or hot water from a heat pump or district loop, move the same kilowatts into scope 2 — hosts should follow the heating source rather than this default. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
cooling_seasonis implemented as the OAT comparison the reference puts in parentheses, not as a host-supplied season flag. It is one comparison on a point the rule needs no help obtaining; consuming a host “cooling season” boolean would hidecooling_season_oatfromset_paramand make the rule’s behavior depend on a definition the library cannot see.- The reference’s heating-season vector expects NO_EVAL, and this rule reports
it as a false
yFault. Out of season the rule reads false because the question is meaningless, not because the box is healthy, and publishing that distinction is the host’s job viaoperating_states. No evaluability output is exposed, unlike AHU-0021’syTempDeltaOk: there the condition is computed from the rule’s inputs and the host cannot check it without redoing the arithmetic, whereas here the gate is an input the host already holds. - The precondition
OAT > cooling_season_oatis deliberately stated twice — once inpreconditions, once in the block graph — because the reference states it twice. The in-graph term is what makes the rule safe to run continuously; the precondition is what tells the host when a false output means anything. - Two delays in series rather than one.
eval_durationandAlarmDelayare separate tunables in the reference, so they stay separately tunable even though a single 4500 s delay behaves identically at the defaults (precedent: AHU-0027). A site on 15-minute trend data changes one parameter. - All three comparisons are strict (
>,>,<), matching the reference’s own operators — no boundary reinterpretation, though the exact values are where a retuned site will sit. hold.delayOnInitandpersist.delayOnInitare bothtrue(Modelica/CDL default isfalse), the library’s standing choice against alarming on the first tick after a controller restart.- Operating-state gating (occupied cooling mode) and the fan-running precondition are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.
- The reference’s Notes block is truncated in the source document mid-sentence. It is quoted below as far as the source runs and no further; the Torabi finding it was introducing has not been reconstructed.
Notes
Reference note, quoted as far as the source runs: “Check AHU SAT first — Torabi et al. (2022) found zone-level reheat”.
Fix order within CLU-05 follows that advice. Diagnoses 2 and 3 both live at the air handler and are cheaper to check than anything at the box: pull the supply air temperature setpoint and its reset trend (AHU-0019, AHU-0023) and check whether one zone is holding the reset at its cold end (VAV-0002). A supply air temperature raised into its reset band clears this fault across every box at once, while lowering one box’s minimum flow fixes one box; the vav-min-flow-reheat playbook’s counting rule discriminates — more than half the boxes flagged points at the air handler, one to three at zone configuration. Its step 2.3 (a summer reheat lockout) is the remote fix and step 2.1 (minimum airflow down to the ASHRAE 62.1 requirement) is the durable one; both are $0 and batchable.
The rule reads zone_dmpr_pos as evidence of what the box is doing, not what it
was told. On boxes exposing only the damper command, a blade stuck open while
the command sits at minimum satisfies the damper term and this rule reports
waste at a box passing full flow — VAV-0004 is what separates those two.
Test Vectors
10 scenarios, clock step 300 s over 21600 s.
| Scenario | Description |
|---|---|
normal_cooling_no_reheat | Reference vector: 25 °C outside, damper at 60%, reheat valve shut. The box is cooling in cooling weather — exactly what it is for. |
reheat_at_minimum_flow_cooling_season | Reference vector: 28 °C outside, damper parked at 20% (its minimum), reheat valve at 40%, held for 90 min. The box is heating air the chiller just cooled, at the lowest flow it is allowed to pass. Alarms at eval_duration (3600 s) + alarm_delay (900 s) = 4500 s. |
reheat_in_heating_season | Reference vector: the same 40% reheat at a 20% damper, but 5 °C outside. The season term holds yFault false — and false here means unevaluable, not healthy. The reference expects NO_EVAL for this case and the host reports it from the same OAT the rule reads; reheat in January is the reheat coil doing its job. |
reheat_with_damper_open | The damper term earning its place: 40% reheat at 28 °C outside, but the damper is at 60% and modulating. The box is passing well above minimum flow, so it is answering a real heating load rather than tempering excess cold air. That is a diagnosis for the air handler, not waste at this box. |
thresholds_exactly_at_edges | Boundary: reheat exactly at reheat_active_threshold (10.0%), OAT exactly at cooling_season_oat (18.0 °C), damper exactly at damper_at_minimum_margin (35.0%). All three tests are strict, so a signal sitting on any edge fails its term and nothing fires. |
thresholds_just_inside_edges | Boundary from the other side: reheat 10.1%, OAT 18.1 °C, damper 34.9%. Every term is now inside its threshold by a tenth and the alarm lands on the same 4500 s schedule. |
morning_reheat_burst_clears | Transient: a warm-up reheat burst on a 28 °C day at minimum flow ends when the valve closes at t=3000 s, 600 s short of eval_duration. The hold timer discards the elapsed time, so a morning burst never alarms. |
damper_opens_mid_hold | Transient: reheat and weather hold, but the damper opens to 60% at t=3900 s — a real load arrives — and falls back to 20% at t=4500 s. The interruption drops both timers; the alarm restarts from 4500 s and lands at 9000 s rather than 4500 s. |
oat_crosses_into_cooling_season | The season term as a gate on live weather: reheat at 40% and the damper at minimum from t=0, but a 15 °C morning only crosses cooling_season_oat at t=3600 s (22 °C). The clock starts at the crossing, so the alarm lands at 8100 s. |
reheat_waste_alarms_then_clears | Recovery: the alarm asserts at 4500 s, then a summer reheat lockout is enabled at t=10800 s and drives the valve shut. 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_cooling_no_reheat",
"description": "Reference vector: 25 \u00b0C outside, damper at 60%, reheat valve shut. The box is cooling in cooling weather \u2014 exactly what it is for.",
"inputs": {
"rht_vlv_cmd": 0.0,
"zone_dmpr_pos": 60.0,
"oat": 25.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "reheat_at_minimum_flow_cooling_season",
"description": "Reference vector: 28 \u00b0C outside, damper parked at 20% (its minimum), reheat valve at 40%, held for 90 min. The box is heating air the chiller just cooled, at the lowest flow it is allowed to pass. Alarms at eval_duration (3600 s) + alarm_delay (900 s) = 4500 s.",
"inputs": {
"rht_vlv_cmd": 40.0,
"zone_dmpr_pos": 20.0,
"oat": 28.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 4200,
"equals": false
},
{
"output": "yFault",
"from_s": 4800,
"to_s": 21600,
"equals": true
}
]
},
{
"name": "reheat_in_heating_season",
"description": "Reference vector: the same 40% reheat at a 20% damper, but 5 \u00b0C outside. The season term holds yFault false \u2014 and false here means unevaluable, not healthy. The reference expects NO_EVAL for this case and the host reports it from the same OAT the rule reads; reheat in January is the reheat coil doing its job.",
"inputs": {
"rht_vlv_cmd": 40.0,
"zone_dmpr_pos": 20.0,
"oat": 5.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "reheat_with_damper_open",
"description": "The damper term earning its place: 40% reheat at 28 \u00b0C outside, but the damper is at 60% and modulating. The box is passing well above minimum flow, so it is answering a real heating load rather than tempering excess cold air. That is a diagnosis for the air handler, not waste at this box.",
"inputs": {
"rht_vlv_cmd": 40.0,
"zone_dmpr_pos": 60.0,
"oat": 28.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "thresholds_exactly_at_edges",
"description": "Boundary: reheat exactly at reheat_active_threshold (10.0%), OAT exactly at cooling_season_oat (18.0 \u00b0C), damper exactly at damper_at_minimum_margin (35.0%). All three tests are strict, so a signal sitting on any edge fails its term and nothing fires.",
"inputs": {
"rht_vlv_cmd": 10.0,
"zone_dmpr_pos": 35.0,
"oat": 18.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "thresholds_just_inside_edges",
"description": "Boundary from the other side: reheat 10.1%, OAT 18.1 \u00b0C, damper 34.9%. Every term is now inside its threshold by a tenth and the alarm lands on the same 4500 s schedule.",
"inputs": {
"rht_vlv_cmd": 10.1,
"zone_dmpr_pos": 34.9,
"oat": 18.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 4200,
"equals": false
},
{
"output": "yFault",
"from_s": 4800,
"to_s": 21600,
"equals": true
}
]
},
{
"name": "morning_reheat_burst_clears",
"description": "Transient: a warm-up reheat burst on a 28 \u00b0C day at minimum flow ends when the valve closes at t=3000 s, 600 s short of eval_duration. The hold timer discards the elapsed time, so a morning burst never alarms.",
"inputs": {
"rht_vlv_cmd": [
{
"t": 0,
"value": 40.0
},
{
"t": 3000,
"value": 0.0
}
],
"zone_dmpr_pos": 20.0,
"oat": 28.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 21600,
"equals": false
}
]
},
{
"name": "damper_opens_mid_hold",
"description": "Transient: reheat and weather hold, but the damper opens to 60% at t=3900 s \u2014 a real load arrives \u2014 and falls back to 20% at t=4500 s. The interruption drops both timers; the alarm restarts from 4500 s and lands at 9000 s rather than 4500 s.",
"inputs": {
"rht_vlv_cmd": 40.0,
"zone_dmpr_pos": [
{
"t": 0,
"value": 20.0
},
{
"t": 3900,
"value": 60.0
},
{
"t": 4500,
"value": 20.0
}
],
"oat": 28.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 8700,
"equals": false
},
{
"output": "yFault",
"from_s": 9300,
"to_s": 21600,
"equals": true
}
]
},
{
"name": "oat_crosses_into_cooling_season",
"description": "The season term as a gate on live weather: reheat at 40% and the damper at minimum from t=0, but a 15 \u00b0C morning only crosses cooling_season_oat at t=3600 s (22 \u00b0C). The clock starts at the crossing, so the alarm lands at 8100 s.",
"inputs": {
"rht_vlv_cmd": 40.0,
"zone_dmpr_pos": 20.0,
"oat": [
{
"t": 0,
"value": 15.0
},
{
"t": 3600,
"value": 22.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7800,
"equals": false
},
{
"output": "yFault",
"from_s": 8400,
"to_s": 21600,
"equals": true
}
]
},
{
"name": "reheat_waste_alarms_then_clears",
"description": "Recovery: the alarm asserts at 4500 s, then a summer reheat lockout is enabled at t=10800 s and drives the valve shut. yFault drops on that tick, since TrueDelay only delays the rising edge.",
"inputs": {
"rht_vlv_cmd": [
{
"t": 0,
"value": 40.0
},
{
"t": 10800,
"value": 0.0
}
],
"zone_dmpr_pos": 20.0,
"oat": 28.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 4200,
"equals": false
},
{
"output": "yFault",
"from_s": 4800,
"to_s": 10500,
"equals": true
},
{
"output": "yFault",
"from_s": 11100,
"to_s": 21600,
"equals": false
}
]
}
]
}