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

SYS-0007 — Zone heating active during summer / warm weather

Statusverified — engine e2ff2f8, cxf:fnv1a128:81170d88e808d51ed3e4980ee1e99faa, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-05
Suppresses
Suppressed by
RelatedVAV-0006, VAV-0003, VAV-0001, AHU-0016, SYS-0002
Playbooksvav-min-flow-reheat
SourceHVAC FDD Reference v1.0 §16, SYS-0007 (pdf pp. 145-146) — equation, all three tunables, the four diagnoses, the whole impact profile, and the truncated notes line; The reference’s own provenance line for that card: PNNL RetuningOpps Z01; ~20% prevalence (PNNL 151-building study); Library precedent: VAV-0006 (the zone-level reheat rule this one overlaps and does not duplicate — see Deviations), VAV-0003, AHU-0016
Operating statesall. There is no occupied gate and no mode gate, because the reference specifies none and because the fault is defined by the weather rather than by the schedule: a zone reheating at 26 degC outdoors is waste at 03:00 as surely as at 15:00, and a night-setback heating cycle at that outdoor temperature is itself the finding.

Preconditions (host-enforced): Instantiated PER ZONE. rht_vlv_cmd is one VAV box’s or one FCU’s reheat command, not a building aggregate, and the deployment runs one instance per zone (the reference files this rule under SYS and scopes it to VAV and FCU equipment). rht_vlv_cmd must be the modulating command in percent of travel; a two-position coil that reports only on/off should be bound as 0/100 and the threshold left where it is, and a coil whose command is in some other unit needs reheat_active_threshold retuned to it. oat must be a fresh, shaded site outdoor air temperature: this rule’s standing false positive is a sun-baked wall sensor reading 23 degC on a 17 degC afternoon, and the sensor-health rules (SYS-0005/SYS-0009/SYS-0010) are what adjudicate that before the finding is believed. Where a zone has a legitimate reason to heat above the lockout — a freezer anteroom, a pool hall, a humidity-controlled space with reheat downstream of a dehumidification coil — the binding is wrong rather than the rule, and that zone should not be instantiated.

Points: rht_vlv_cmd, oat

Outputs:

  • yFault — True while this zone’s reheat command has stayed above reheat_active_threshold with outdoor air above zone_heating_lockout_temp, continuously for alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
reheat_active_threshold10.0%rhtOn.tReheat command above which the coil counts as actively consuming energy rather than leaking through a shut valve (the reference’s own 10%, and the same number VAV-0006 uses for the same test)
zone_heating_lockout_temp21.0°CwarmOut.tOutdoor air temperature above which no zone should be calling for heat — the temperature an OAT heating lockout would be programmed at (the reference’s own 21 °C / 70 °F). Note this is 3 K above VAV-0006’s cooling_season_oat and asks a different question; see Deviations
alarm_delay1800.0spersist.delayTimeContinuous persistence required before the alarm asserts (30 min). This is the reference’s published AlarmDelay and also stands in for the lockout_check_duration its equation names but never publishes — one knob, see Deviations

Description

A reheat coil is heating a zone while it is 26 degC outside. Nothing about the building needs that heat, and whatever the coil delivers the plant paid to cool the air first — simultaneous heating and cooling seen from the zone end, which PNNL found in about a fifth of the buildings it retuned. The usual cause is the absence of a control decision rather than the failure of one: most zone sequences call for reheat any time the space falls under its heating setpoint and nothing in that logic knows what the weather is doing, so a zone under an overcooled supply duct in July asks for heat all afternoon and gets it. The fix is a line of programming — lock the reheat valve out above an outdoor air temperature — which is why one instance usually means the whole zone family is affected. Instantiated per zone: rht_vlv_cmd is one box’s valve.

Detection Logic

yFault = rht_vlv_cmd > reheat_active_threshold
     AND oat > zone_heating_lockout_temp
     sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0007 block graph

Four blocks, and either conjunct blocks the fault on its own.

Both comparisons are strict, which is what the reference writes (> in both terms) rather than a reinterpretation of it, so a valve reported at exactly 10.0% and an outdoor air temperature of exactly 21.0 degC each fail their term. Those exact values matter more than usual here, because 10% and 21 degC are round numbers a retuned site will park on deliberately.

persist starts on the crossing, not at midnight, so a zone already reheating when the weather crosses the lockout alarms 30 minutes after the crossing. Sustained means continuous — a ten-minute dip below the lockout discards the elapsed time rather than pausing it — and the fault clears on the tick the valve shuts, because TrueDelay delays the rising edge only. It asserts at exactly T + delayTime, and delayOnInit = true (CDL default false) makes a zone already reheating at controller restart wait out the full 30 minutes.

Possible Diagnoses

The reference’s four, in its order:

  1. Zone heating lockout not programmed by OAT — the sequence has no weather term at all. The common case, the cheap fix, and why findings arrive in batches
  2. Reheat valve stuck open — mechanical, and distinguishable from the trend: a stuck valve reads the same command all day and ignores a commanded close
  3. Zone controller demanding heat due to sensor error — a zone temperature sensor reading low makes the box genuinely believe the space is cold, and every part of the control chain then behaves correctly
  4. Perimeter heating operating independently of BAS — baseboard or radiant perimeter on its own thermostat or reset curve, which the BAS neither commands nor sees

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile. The waste term needs no counterfactual: waste_kw = rht_vlv_cmd/100 × vav_rht_capacity_kw for every hour the condition holds, all of it pure loss because the heat is applied to air the plant just paid to cool. The reference puts it at 100% of reheat energy while active and up to 20% of the zone’s annual energy, at ~20% prevalence across the PNNL 151-building study. Cooling-dominant by climate, but the multiplier is what matters: the defect is nearly always systemic, so the site number is the per-zone number times the count of zones sharing the sequence.

Emissions Impact

Scope 1, DIRECT_EMISSIONS, HIGH confidence; the reference gives 500-4,000 kg CO₂e/yr for reheat waste during warm weather, on a static Scope 1 factor. That assumes hot-water reheat from a gas-fired boiler, the common case. Electric reheat, or hot water from a heat pump or district loop, moves the same kilowatts into Scope 2 — the quantity is unchanged and the inventory line is not, so hosts should follow the actual heating source (the same caveat VAV-0006 carries).

Deviations

  • lockout_check_duration and AlarmDelay are treated as one knob. The reference’s equation ends “sustained for lockout_check_duration” but its tunables table publishes three parameters, none named that, including AlarmDelay = 30 min. Both names describe how long the condition must hold and only one has a published number, so the graph carries a single TrueDelay at 30 minutes. This is the opposite call from VAV-0006 and SYS-0005, which chain two timers because their references publish two numbers; inventing a second here would put an unsupported duration in the card.
  • Both comparisons are strict, matching the reference’s own operators, so the library’s standing >=> deviation does not apply on this card.
  • Filed under SYS, instantiated per VAV or FCU zone. The reference’s header reads Equipment: VAV, FCU while the ID is SYS-0007 — a tension in the source, not in this card. Deployment is one instance per zone with a host-side rollup, and rht_vlv_cmd and oat are duplicated into points/sys.points.json with matched groundings, because lint resolves a card’s points against its own family dictionary.
  • No cross-zone aggregation in the graph, and the count is where the real diagnosis lives. One zone reheating in summer is a zone problem; half the zones on an air handler is a supply-air-temperature problem, and the vav-min-flow-reheat playbook’s step 1.4 turns that ratio into the discriminator. The graph cannot express a variable-width zone vector, so the rollup is the host’s.
  • Overlaps VAV-0006 deliberately, and the two ask different questions. VAV-0006 is reheat at minimum flow during the cooling season: three terms, an 18 degC season threshold, and a damper term that separates waste from a zone answering a genuine load. This card has no damper term and a 21 degC threshold, so it fires on a zone that is genuinely cold and genuinely being heated — because above 21 degC outdoors the reference’s claim is that no zone should be heating at all. A box tripping this one alone is reheating with its damper open, which points at diagnosis 3 or an overcooled supply duct. Both are CLU-05 members with VAV-0006 as trigger, so the cluster encodes the fix order.
  • No occupancy gate, no mode gate, no supply-fan gate. The reference specifies none, and adding one would change the fault: unoccupied reheat above 21 degC outdoors is a worse instance of it, not an exception. The one defensible gate — a reheat coil downstream of a dehumidification coil, where warm-weather reheat is design intent — is a binding decision, so it lives in preconditions.
  • oat drift is the standing false positive and this card does not solve it. A sun-baked or drifted outdoor sensor reading 3 K high manufactures the fault across every zone at once, which is also the tell. SYS-0005, SYS-0009 and SYS-0010 adjudicate oat directly; where a host runs them, an active sensor finding on the bound oat makes this rule NO_EVAL through the adjudicates fan-out.
  • clusters: [CLU-05] is a declaration, not an edit. CLU-05 already lists SYS-0007 as a member with VAV-0006 as trigger. playbooks/vav-min-flow-reheat.md already names SYS-0007 in its step 2.3 text but not in its Applies-To row; adding the ID there is the playbook owner’s edit, flagged rather than made.
  • The reference’s Notes block is truncated mid-sentence in the source document — “Found in ~20% of buildings. In perimeter zones this is especially” — and is quoted below as far as the source runs. The perimeter-zone claim is not recoverable from the chapter text and has not been reconstructed.
  • Severity 3, method: rule, phase 2 and the whole impact profile are the chapter card’s, matching the provisional row in faults/sys/README.md; the reference’s §5.8.1 index carries no severity column, so the chapter card governs.
  • Operating states and preconditions are declared in frontmatter for host enforcement rather than encoded in the block graph, per the library’s design stance.

Notes

Reference note, quoted as far as the source runs: “Found in ~20% of buildings. In perimeter zones this is especially”.

Count the zones before dispatching anyone. This fault is systemic far more often than mechanical: diagnosis 1 is a missing line of sequence logic nobody wrote for any box, so the normal shape is dozens of zones alarming on the same warm afternoon and clearing together when one lockout is programmed. A single zone alarming while its neighbours stay quiet points at diagnoses 2, 3 and 4.

The remote fix is the vav-min-flow-reheat playbook’s step 2.3: a summer reheat lockout above an outdoor air temperature, applied in batch, at no cost. Set it at the reference’s 21 degC before arguing about the number — lower and the site fights genuine morning heating loads in the shoulder seasons, higher and it pays the difference in reheat.

Check the outdoor air sensor first, and check it once rather than per zone. It is the single input every instance shares, a 3 K error moves every finding at the site together, and verifying it against a hand-held reference takes ten minutes.

Test Vectors

13 scenarios, clock step 60 s over 5400 s.

ScenarioDescription
winter_reheat_normalReheat doing its job in cold weather: the valve is 60% open at 5 degC outdoors. The heating conjunct is satisfied and the weather conjunct is not, so nothing is reported.
warm_weather_reheat_activeThe fault: 45% reheat at 26 degC outdoors. Both conjuncts hold from the first tick and the alarm lands at exactly alarm_delay.
warm_weather_valve_closedThe reheat conjunct blocking on its own: 26 degC outdoors with the reheat valve shut. Warm weather is not a fault; reheat during warm weather is.
cold_weather_reheat_wide_openThe weather conjunct blocking on its own: the valve is 100% open at -5 degC. This is the zone working, and the mirror of warm_weather_valve_closed.
reheat_exactly_at_the_thresholdBoundary: the valve sits on exactly reheat_active_threshold (10.0%) in warm weather. Reals.GreaterThreshold is strict and CDL Reals has no GreaterEqual, so exactly on the line reads as closed and the rule stays silent.
reheat_just_above_the_thresholdThe same line from above: 10.1% of valve travel at 26 degC alarms at 1800 s. Leakage past a shut valve is not this rule’s business, and 10% is where the reference draws that line.
oat_exactly_at_the_lockoutBoundary on the weather side: OAT sits on exactly zone_heating_lockout_temp (21.0 degC) with the valve at 45%. Strict again, so 21.0 is not yet warm weather.
oat_just_above_the_lockoutThe same line from above: 21.1 degC alarms at 1800 s.
morning_warmup_into_a_hot_afternoonThe realistic trace: the zone is reheating at 18 degC outdoors and the weather crosses the lockout at 600 s. The timer starts on that crossing, not at t=0, so the alarm lands at 2400 s.
valve_closes_on_the_maturity_tickThe delay edge from below: the valve shuts at exactly 1800 s. TrueDelay asserts at T + delayTime and drops a falling edge with no lag, so the two land on the same tick and the fault is never reported.
valve_closes_one_tick_laterThe same edge from above: the valve shuts one tick after maturity, leaving exactly one tick of alarm at 1800 s. The realized test is ‘both conditions held for strictly more than alarm_delay’ at tick resolution.
cloud_cover_restarts_the_clockSustained means continuous. OAT drops back under the lockout for ten minutes at 900 s, which discards the elapsed time rather than pausing it, and the alarm lands a full 1800 s after the second crossing at 1500 s.
lockout_programmed_after_the_alarmRecovery: the alarm asserts at 1800 s, someone adds the OAT lockout to the zone sequence at 3600 s and the valve drives shut. The fault clears on that tick — TrueDelay delays the rising edge only.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "winter_reheat_normal",
      "description": "Reheat doing its job in cold weather: the valve is 60% open at 5 degC outdoors. The heating conjunct is satisfied and the weather conjunct is not, so nothing is reported.",
      "inputs": {
        "rht_vlv_cmd": 60.0,
        "oat": 5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_weather_reheat_active",
      "description": "The fault: 45% reheat at 26 degC outdoors. Both conjuncts hold from the first tick and the alarm lands at exactly alarm_delay.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "warm_weather_valve_closed",
      "description": "The reheat conjunct blocking on its own: 26 degC outdoors with the reheat valve shut. Warm weather is not a fault; reheat during warm weather is.",
      "inputs": {
        "rht_vlv_cmd": 0.0,
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cold_weather_reheat_wide_open",
      "description": "The weather conjunct blocking on its own: the valve is 100% open at -5 degC. This is the zone working, and the mirror of warm_weather_valve_closed.",
      "inputs": {
        "rht_vlv_cmd": 100.0,
        "oat": -5.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_exactly_at_the_threshold",
      "description": "Boundary: the valve sits on exactly reheat_active_threshold (10.0%) in warm weather. Reals.GreaterThreshold is strict and CDL Reals has no GreaterEqual, so exactly on the line reads as closed and the rule stays silent.",
      "inputs": {
        "rht_vlv_cmd": 10.0,
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "reheat_just_above_the_threshold",
      "description": "The same line from above: 10.1% of valve travel at 26 degC alarms at 1800 s. Leakage past a shut valve is not this rule's business, and 10% is where the reference draws that line.",
      "inputs": {
        "rht_vlv_cmd": 10.1,
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_exactly_at_the_lockout",
      "description": "Boundary on the weather side: OAT sits on exactly zone_heating_lockout_temp (21.0 degC) with the valve at 45%. Strict again, so 21.0 is not yet warm weather.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": 21.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_just_above_the_lockout",
      "description": "The same line from above: 21.1 degC alarms at 1800 s.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": 21.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "morning_warmup_into_a_hot_afternoon",
      "description": "The realistic trace: the zone is reheating at 18 degC outdoors and the weather crosses the lockout at 600 s. The timer starts on that crossing, not at t=0, so the alarm lands at 2400 s.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": [
          {
            "t": 0,
            "value": 18.0
          },
          {
            "t": 600,
            "value": 25.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2340,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2400,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_closes_on_the_maturity_tick",
      "description": "The delay edge from below: the valve shuts at exactly 1800 s. TrueDelay asserts at T + delayTime and drops a falling edge with no lag, so the two land on the same tick and the fault is never reported.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 45.0
          },
          {
            "t": 1800,
            "value": 0.0
          }
        ],
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_closes_one_tick_later",
      "description": "The same edge from above: the valve shuts one tick after maturity, leaving exactly one tick of alarm at 1800 s. The realized test is 'both conditions held for strictly more than alarm_delay' at tick resolution.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 45.0
          },
          {
            "t": 1860,
            "value": 0.0
          }
        ],
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 1800,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1860,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "cloud_cover_restarts_the_clock",
      "description": "Sustained means continuous. OAT drops back under the lockout for ten minutes at 900 s, which discards the elapsed time rather than pausing it, and the alarm lands a full 1800 s after the second crossing at 1500 s.",
      "inputs": {
        "rht_vlv_cmd": 45.0,
        "oat": [
          {
            "t": 0,
            "value": 26.0
          },
          {
            "t": 900,
            "value": 19.0
          },
          {
            "t": 1500,
            "value": 26.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "lockout_programmed_after_the_alarm",
      "description": "Recovery: the alarm asserts at 1800 s, someone adds the OAT lockout to the zone sequence at 3600 s and the valve drives shut. The fault clears on that tick \u2014 TrueDelay delays the rising edge only.",
      "inputs": {
        "rht_vlv_cmd": [
          {
            "t": 0,
            "value": 45.0
          },
          {
            "t": 3600,
            "value": 0.0
          }
        ],
        "oat": 26.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1740,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3540,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}