VAV-0003 — Reheat valve open with zone satisfied
| Status | verified — engine e2ff2f8, cxf:fnv1a128:ea366afd07e1888e9fd147a855249bcc, 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-0016, VAV-0001, VAV-0006 |
| Playbooks | vav-min-flow-reheat |
| Source | HVAC FDD Reference v1.0 §10, VAV-0003; PNNL retuning; PNNL-25985 EEM-15/38 |
| Operating states | deadband / satisfied (host-gated) |
Preconditions (host-enforced): The AHU serving this VAV box is running — with no supply air moving, a reheat valve position means nothing thermally and the zone temperature is drifting on envelope loads rather than on anything the box is doing. The host must bind the ACTIVE occupied-mode heating and cooling setpoints the zone loop is tracking, not schedule defaults: during setback the effective band is wider than the occupied one and reheat inside the occupied band can be legitimate. The zone temperature sensor must be trustworthy — VAV-0010 territory — since a sensor reading high while the space is genuinely cold produces exactly this signature with nothing wrong.
Points: rht_vlv_cmd, zone_temp, zone_temp_sp_htg, zone_temp_sp_clg
Outputs:
yFault— True while the reheat valve is open past its threshold with the zone inside the satisfied band, continuously for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
reheat_open_threshold | 15.0 | % | rhtOn.t | Reheat valve command above which the coil counts as open |
zone_deadband | 0.5 | °C | aboveHtg.t, belowClg.t | Tolerance widening the satisfied band past each setpoint; binds both the lower and the upper bound |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
The zone is comfortable — sitting between its heating and cooling setpoints,
asking for nothing — and the reheat coil is running anyway. Unlike an oversized
minimum flow, where the reheat at least offsets air the ventilation code
required, every kilowatt through this coil is waste with no offsetting benefit,
which is why the category is CRITICAL_WASTE and the runtime estimate has no
efficiency term: waste_kw = rht_vlv_cmd/100 × vav_rht_capacity_kw, the whole
coil load. The fault hides well, because a satisfied zone with extra heat in it
drifts up until the cooling loop opens the damper and takes the heat back out —
the box masks its own symptom, and the only trace is two subsystems working
against each other at a scale too small to see on a meter until you multiply by
the number of boxes in the building.
Detection Logic
rht_on = rht_vlv_cmd > reheat_open_threshold
satisfied = zone_temp > (zone_temp_sp_htg − zone_deadband)
AND zone_temp < (zone_temp_sp_clg + zone_deadband)
yFault = (rht_on AND satisfied) sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Both band bounds are computed as a positive gap against the deadband — gapH is
zone_temp_sp_htg − zone_temp, gapC is zone_temp − zone_temp_sp_clg — which
keeps zone_deadband a single positive parameter instead of a value added on
one side and subtracted on the other. Both bounds read that one value, so a host
retuning it must set both CXF paths together. At the shipped defaults and the
reference’s 21/23 °C setpoints the satisfied band runs 20.5 to 23.5 °C,
exclusive at both ends (see Deviations): below it the zone is genuinely calling
for heat and the coil is doing its job, above it the zone is in cooling
territory and reheat there belongs to VAV-0006. The 30-minute persist delay
covers ordinary overshoot — a heating loop that carries the zone a little past
setpoint before releasing the coil shows this signature for a few minutes on
every recovery cycle, and that is control, not a fault. delayOnInit = true
holds the window across a restart.
Possible Diagnoses
- Reheat valve stuck or leaking — the actuator is not where the command says,
or the seat passes flow at closed (hands off to the
stuck-actuatorplaybook) - Control sequence not releasing reheat when the zone is satisfied: the loop or the mode logic keeps a heating output alive past its drop-out point
- Incorrect valve stroke calibration — the actuator honors the command but the linkage maps 0% to a partly open plug
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. The reference gives 5–15% of zone thermal energy, under PNNL-25985’s EEM-15 and EEM-38 (minimum flow reduction and eliminating simultaneous heating and cooling). Estimation is DIRECT because the valve command is the waste: with the zone inside its deadband there is no useful heating output to subtract, so coil load scaled by command position is the whole figure and the only inference left is the coil’s rated capacity. Heating-dominant, though the waste itself does not care about the weather and in cooling season it compounds by loading the chiller as well.
Emissions Impact
Scope 1, DIRECT_EMISSIONS, HIGH confidence; typically 300–2,000 kg CO₂e/yr per zone. Scope 1 covers the usual case of a hydronic coil on a gas boiler. Sites with electric reheat or a heat-pump-fed hot water loop should read this as scope 2 and rescale — the kilowatts are the same but the inventory is not. Avoided-emissions basis: marginal operating emissions rate (MOER).
Deviations
- The reference’s
>=and<=band bounds become strict. CDLRealshas noGreaterEqualorLessEqual, so both bounds areLessThresholdon a gap and a zone sitting exactly on either edge reads as outside the satisfied band where the reference reads it inside. The disagreement has measure zero on a real temperature signal and errs toward silence. A host binding coarsely quantized zone temperatures — integer °C, or a BAS that rounds to 0.5 — should widenzone_deadbandslightly rather than rely on the signal landing off the boundary. zone_deadbandis one card parameter bound to two CXF paths (aboveHtg.t,belowClg.t), matching the reference’s single tunable. Hosts must set both together; a site wanting an asymmetric band retunes the paths individually and notes the divergence. Precedent: AHU-0025’svalve_open_threshold.- Band bounds are expressed as gaps rather than shifted setpoints.
Implementing
zone_temp >= zone_temp_sp_htg − zone_deadbandliterally would need a constant subtracted from the setpoint and a two-input comparison per bound; the gap form gets the same answer with oneSubtractand one threshold per bound and keepszone_deadbanda single positiveset_parampath. Algebraically identical apart from the strictness above. AlarmDelay = 30 minbecomespersist.delayTime = 1800 swithdelayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice against alarming on the first tick after a controller restart.- The rule does not test whether the AHU is running, whether the zone is occupied, or whether the zone temperature sensor is trustworthy; all three are frontmatter preconditions for host enforcement. The sensor one bites hardest — a zone sensor reading 2 °C high puts a genuinely cold space inside the satisfied band with the coil correctly responding, and this rule calls that a fault.
g36: null, and no G36 clause appears insource. G36 sequences terminal-unit reheat, but the reference derives this logic from the PNNL retuning work, and SCHEMA.md reserves theg36field for the 001–049 range regardless.- Severity 3 (warning) is the reference’s chapter 10 value, kept despite the CRITICAL_WASTE category: the category describes the character of the waste, the severity the response urgency, and a leaking reheat valve on one box is not an emergency. The reference’s §5.8.2 index carries no severity column.
Notes
The remote discriminator is the vav-min-flow-reheat
playbook’s step 2.2: command the valve to 0% and watch the zone. Temperature
still climbing means the valve is not where it says it is (diagnosis 1, hand off
to stuck-actuator); temperature responding and reheat returning once the
override is released means the sequence is the problem (diagnosis 2, a
programming fix); a valve that responds but never fully closes is diagnosis 3.
Within CLU-05 this rule is a member and VAV-0006 is the trigger. Where both fire on one box, fix the trigger first — reheat during cooling season usually traces to an oversized minimum or a too-low supply air temperature, and correcting either can quiet this rule without touching the valve. All three siblings watch the same valve: VAV-0001 asks whether the box is configured to force reheat, VAV-0006 whether reheat runs while the building is trying to cool, and this rule whether reheat runs when the zone wanted nothing at all.
Test Vectors
12 scenarios, clock step 300 s over 5400 s.
| Scenario | Description |
|---|---|
zone_satisfied_no_reheat | Reference vector: zone at 22 °C between a 21 °C heating and 23 °C cooling setpoint, reheat valve shut. The zone is satisfied and nothing is being spent on it. |
zone_satisfied_reheat_on | Reference vector: the same satisfied 22 °C zone with the reheat valve at 30%. Every kilowatt through that coil is waste. Both terms hold from t=0, so delayOnInit makes the alarm land one alarm_delay in. |
zone_cold_reheat_on | Reference vector: zone at 19 °C, 2 °C below its heating setpoint, reheat at 30%. The coil is doing exactly what it is for — the lower band bound fails and the rule stays silent. |
zone_hot_reheat_on | The other side of the band: zone at 24 °C, a full degree above the cooling setpoint, with reheat at 30%. This is a worse fault than the one being tested — reheat during a call for cooling — but it is VAV-0006’s, not this rule’s: satisfied fails on the upper bound and yFault stays clear. |
zone_at_lower_band_edge | Boundary: zone exactly at zone_temp_sp_htg − zone_deadband (21.0 − 0.5 = 20.5 °C) with reheat at 30%. The gap is exactly 0.5 and aboveHtg tests < 0.5, so the zone reads as still calling for heat and the rule stays silent — the reference’s >= would have called it satisfied. |
zone_just_inside_lower_band_edge | Boundary from the other side: 20.6 °C, one tenth of a degree inside the band, same 30% reheat. The zone now reads satisfied and the alarm lands after alarm_delay. |
zone_at_upper_band_edge | Boundary: zone exactly at zone_temp_sp_clg + zone_deadband (23.0 + 0.5 = 23.5 °C) with reheat at 30%. belowClg tests < 0.5 on a gap of exactly 0.5, so the zone reads as outside the band and the rule stays silent — the mirror of the lower-edge deviation. |
zone_just_inside_upper_band_edge | Boundary from the other side: 23.4 °C, one tenth of a degree inside the upper bound, same 30% reheat. Both band bounds hold and the alarm lands after alarm_delay. |
reheat_exactly_at_threshold | Boundary on the valve term: satisfied zone with the valve reported at exactly reheat_open_threshold (15.0%). Strict >, so a valve sitting on the threshold does not raise the alarm. |
reheat_just_above_threshold | Boundary from the other side: the same satisfied zone with the valve at 15.1%. The valve test passes and the alarm lands after alarm_delay. |
transient_reheat_pulse | The zone is satisfied throughout, but reheat runs only for the first 1500 s — a control loop overshooting past setpoint before it releases the coil — which is 300 s short of the 1800 s alarm_delay. The timer resets and no alarm is raised. |
alarm_clears_when_valve_closes | Recovery: the alarm asserts at 1800 s, then the valve is commanded shut at t=3000 s (playbook step 2.2 — the discriminator between a stuck valve and a sequence bug) and it obeys. yFault drops on that same tick, since TrueDelay only delays the rising edge. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 5400
},
"scenarios": [
{
"name": "zone_satisfied_no_reheat",
"description": "Reference vector: zone at 22 \u00b0C between a 21 \u00b0C heating and 23 \u00b0C cooling setpoint, reheat valve shut. The zone is satisfied and nothing is being spent on it.",
"inputs": {
"rht_vlv_cmd": 0.0,
"zone_temp": 22.0,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "zone_satisfied_reheat_on",
"description": "Reference vector: the same satisfied 22 \u00b0C zone with the reheat valve at 30%. Every kilowatt through that coil is waste. Both terms hold from t=0, so delayOnInit makes the alarm land one alarm_delay in.",
"inputs": {
"rht_vlv_cmd": 30.0,
"zone_temp": 22.0,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "zone_cold_reheat_on",
"description": "Reference vector: zone at 19 \u00b0C, 2 \u00b0C below its heating setpoint, reheat at 30%. The coil is doing exactly what it is for \u2014 the lower band bound fails and the rule stays silent.",
"inputs": {
"rht_vlv_cmd": 30.0,
"zone_temp": 19.0,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "zone_hot_reheat_on",
"description": "The other side of the band: zone at 24 \u00b0C, a full degree above the cooling setpoint, with reheat at 30%. This is a worse fault than the one being tested \u2014 reheat during a call for cooling \u2014 but it is VAV-0006's, not this rule's: `satisfied` fails on the upper bound and yFault stays clear.",
"inputs": {
"rht_vlv_cmd": 30.0,
"zone_temp": 24.0,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "zone_at_lower_band_edge",
"description": "Boundary: zone exactly at zone_temp_sp_htg \u2212 zone_deadband (21.0 \u2212 0.5 = 20.5 \u00b0C) with reheat at 30%. The gap is exactly 0.5 and `aboveHtg` tests `< 0.5`, so the zone reads as still calling for heat and the rule stays silent \u2014 the reference's `>=` would have called it satisfied.",
"inputs": {
"rht_vlv_cmd": 30.0,
"zone_temp": 20.5,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "zone_just_inside_lower_band_edge",
"description": "Boundary from the other side: 20.6 \u00b0C, one tenth of a degree inside the band, same 30% reheat. The zone now reads satisfied and the alarm lands after alarm_delay.",
"inputs": {
"rht_vlv_cmd": 30.0,
"zone_temp": 20.6,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "zone_at_upper_band_edge",
"description": "Boundary: zone exactly at zone_temp_sp_clg + zone_deadband (23.0 + 0.5 = 23.5 \u00b0C) with reheat at 30%. `belowClg` tests `< 0.5` on a gap of exactly 0.5, so the zone reads as outside the band and the rule stays silent \u2014 the mirror of the lower-edge deviation.",
"inputs": {
"rht_vlv_cmd": 30.0,
"zone_temp": 23.5,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "zone_just_inside_upper_band_edge",
"description": "Boundary from the other side: 23.4 \u00b0C, one tenth of a degree inside the upper bound, same 30% reheat. Both band bounds hold and the alarm lands after alarm_delay.",
"inputs": {
"rht_vlv_cmd": 30.0,
"zone_temp": 23.4,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "reheat_exactly_at_threshold",
"description": "Boundary on the valve term: satisfied zone with the valve reported at exactly reheat_open_threshold (15.0%). Strict `>`, so a valve sitting on the threshold does not raise the alarm.",
"inputs": {
"rht_vlv_cmd": 15.0,
"zone_temp": 22.0,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "reheat_just_above_threshold",
"description": "Boundary from the other side: the same satisfied zone with the valve at 15.1%. The valve test passes and the alarm lands after alarm_delay.",
"inputs": {
"rht_vlv_cmd": 15.1,
"zone_temp": 22.0,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 5400,
"equals": true
}
]
},
{
"name": "transient_reheat_pulse",
"description": "The zone is satisfied throughout, but reheat runs only for the first 1500 s \u2014 a control loop overshooting past setpoint before it releases the coil \u2014 which is 300 s short of the 1800 s alarm_delay. The timer resets and no alarm is raised.",
"inputs": {
"rht_vlv_cmd": [
{
"t": 0,
"value": 30.0
},
{
"t": 1500,
"value": 0.0
}
],
"zone_temp": 22.0,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 5400,
"equals": false
}
]
},
{
"name": "alarm_clears_when_valve_closes",
"description": "Recovery: the alarm asserts at 1800 s, then the valve is commanded shut at t=3000 s (playbook step 2.2 \u2014 the discriminator between a stuck valve and a sequence bug) and it obeys. yFault drops on that same tick, since TrueDelay only delays the rising edge.",
"inputs": {
"rht_vlv_cmd": [
{
"t": 0,
"value": 30.0
},
{
"t": 3000,
"value": 0.0
}
],
"zone_temp": 22.0,
"zone_temp_sp_htg": 21.0,
"zone_temp_sp_clg": 23.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 2700,
"equals": true
},
{
"output": "yFault",
"from_s": 3300,
"to_s": 5400,
"equals": false
}
]
}
]
}