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

HW-0003 — Boiler or HW pump operating above OAT lockout temperature

Statusverified — engine e2ff2f8, cxf:fnv1a128:d6dd066552fff48e4a1b0ebab1cff9e6, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
Clusters
Suppresses
Suppressed by
RelatedHW-0001, HW-0002, AHU-0018
Playbookshot-water-plant-faults
SourceHVAC FDD Reference v1.0 §14 (ch. ‘Hot Water Plants’, pdf pp. 126-127), HW-0003; PNNL RetuningOpps H01; >25% prevalence (PNNL 151-building study)
Operating statesall

Preconditions (host-enforced): oat must be a trustworthy outdoor reading. The reference’s own diagnosis 5 is an OAT sensor reading low, and this rule cannot tell that from a correctly-programmed lockout — a sensor in afternoon sun or above a warm roof fails the other way and hides the fault instead. Cross-check against a nearby weather station or a second outdoor sensor before dispatching on a fleet of these. The plant must not be serving a domestic hot water load: diagnosis 4 is DHW demand legitimately keeping a boiler firing in July, and nothing in three points distinguishes a boiler making 60 °C service water from a boiler heating an empty building. Sites with combined heating/DHW plants must exclude this rule, gate it on the DHW valve position host-side, or accept that the summer months are noise. boiler_status and hw_pump_status must belong to the same plant as oat; on a multi-boiler plant either may legitimately be bound to the OR across boilers, which is the one place in this library where that aggregation is correct (contrast HW-0001, where it destroys the measurement). Both status points should be proof of operation — a current switch or a flow proof — rather than the command echoed back from a relay.

Points: boiler_status, hw_pump_status, oat

Outputs:

  • yFault — True while the boiler or the HW pump has been running with the outdoor air temperature above heating_plant_lockout_temp, continuously for lockout_check_duration

Parameters:

NameDefaultUnitCXF pathDescription
heating_plant_lockout_temp16.0°CmildOat.tOutdoor air temperature above which the heating plant should be locked out. 16 °C (61 °F) is the reference’s default and a common code and retro-commissioning value; sites with high-mass buildings or 24-hour perimeter loads sometimes set it higher, and that is a finding rather than a reason to retune
lockout_check_duration3600.0ssustained.delayTimeHow long the plant must run above the lockout temperature before the alarm asserts (60 min). This is the rule’s only timer — the reference states no separate AlarmDelay for this card, so the duration is the persistence (see Deviations)

Description

The reference calls the outdoor air lockout one of the simplest and highest-return fixes in its catalogue, and the arithmetic is why: above about 16 °C a commercial building does not need its heating plant, so every therm the boiler burns and every kilowatt the pump draws is waste in full — not a percentage of waste, the whole thing. By the PNNL 151-building study it is present in more than a quarter of buildings: lockouts left out of a sequence, set to a temperature nobody revisited, overridden one cold April morning and never released. The pump half matters as much as the boiler half and is easier to miss — a circulator running all summer costs its full draw, keeps the piping warm and pushes heat into ceilings the cooling plant then removes, which is why the equation is a disjunction rather than a boiler test with a pump footnote.

Detection Logic

yFault = (boiler_status OR hw_pump_status)
     AND oat > heating_plant_lockout_temp
     sustained continuously for lockout_check_duration

Block graph (rule.cxf.jsonld):

HW-0003 block graph

plantOn is evaluated tick by tick rather than per point, so a lead pump handing over to a boiler keeps the condition continuously true across the handover; a rule written as two per-point tests would miss that hour. mildOat is strict, so exactly 16.0 °C is not above the lockout.

sustained is the whole timing story: one Logical.TrueDelay at 60 minutes, which is simultaneously the reference’s lockout_check_duration and the only persistence this card has (see Deviations). An hour rides out a morning warm-up finishing as the sun comes up, a boiler completing the cycle it was in when the outdoor temperature crossed, and a flow proof chattering on a pump changeover. Persistence is continuous, not accumulated — a dip back under the lockout discards the elapsed time rather than pausing it — and delayOnInit = true, so a plant already running above its lockout at restart waits out the full hour. The alarm falls the instant the plant stops, with no release delay.

Possible Diagnoses

Transcribed from the reference’s HW-0003 card:

  1. Boiler lockout sequence never programmed — the most common cause and a $0 remote fix
  2. Lockout setpoint set too high — a plant locked out at 21 °C looks programmed and behaves almost as badly; this survives a casual review of the sequence
  3. Lockout overridden by an operator, usually a shoulder-season cold snap with no expiry on the override
  4. Domestic hot water demand keeping the boiler on — not a fault, and invisible to this rule; a host precondition rather than a diagnosis (see Deviations)
  5. OAT sensor reading incorrectly low — also not a plant fault, also invisible here, and free to rule out against a weather station first

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT. While the fault is active the entire plant draw is waste — waste_kw = boiler_current_kw + hw_pump_kw, the reference’s formula — because there is no heating load for any of it to serve. No baseline, no efficiency, no proxy. Climate sensitivity is “both”: the cost peaks in the swing seasons, when outdoor temperatures spend weeks above the lockout, and in a cooling-dominant climate the shed pipe heat also loads the chillers. Confidence is HIGH because both halves are directly measured, subject to the sensor caveat that is diagnosis 5.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s typical range is 2,000–15,000 kg CO₂e/yr for a plant running above its lockout. The split is worth keeping: the boiler’s fuel is Scope 1 on a static combustion factor, the pump’s electricity is Scope 2 on the marginal operating emissions rate, and the two respond to different levers. A site that has decarbonised its electricity still owns the whole Scope 1 half.

Deviations

  • There is one timer, and it is the reference’s lockout_check_duration. The tunables line ends at “lockout_check_duration = 60 min,” and no AlarmDelay appears for this card anywhere in the chapter. Rather than adopt one and stack two hours of delay on a fault that is fully decided in one, the rule reads the duration as the persistence, which is what “sustained for lockout_check_duration” says in the equation. A host wanting a separate alarm persistence must add it downstream.
  • Diagnoses 4 and 5 are host preconditions, not detections. A boiler firing in July for a DHW load and one firing because the lockout was never programmed produce identical values on all three points, as do a correctly locked-out plant and one whose OAT sensor reads 8 °C low. The DHW case generates false positives at scale — combined heating/DHW plants are common in older buildings — and the OAT case generates silent misses, which is worse and has no in-rule remedy. Both are recorded in preconditions so a host can gate or cross-check.
  • Strict > at the lockout temperature, as the reference writes it; CDL Reals has no GreaterEqual in any case. A plant running at exactly 16.0 °C reads clear. The disagreement is measure-zero and errs toward silence, but a BAS that quantises outdoor temperature to whole degrees will sit on the boundary often, and should set the parameter between two quantisation levels.
  • The disjunction is plant-level, and the OR across boilers is correct here. This rule asks whether anything is running, so an OR of individual boiler statuses preserves the question exactly — the opposite of HW-0001, which counts transitions of a specific burner and is destroyed by the same aggregation. Both cards state the constraint.
  • TrueDelay asserts at exactly T + delayTime, so the realized test is “above the lockout for strictly more than lockout_check_duration” at tick resolution: a plant that stops on the maturity tick is never reported, and one that runs a single tick longer asserts for that tick and clears.
  • operating_states: all, deliberately. Every other rule in the hot water chapter is gated to the heating season; this one detects a plant behaving as though it were the heating season when it is not, so gating it that way would delete it. The graph needs no operating state — two proofs of operation and a temperature.
  • clusters: []. clusters/clusters.json defines no cluster containing a hot water plant rule. CLU-07 (Unnecessary Plant Operation) is the syndrome this fault belongs to on the heating side and this card is a candidate member; adding it is the cluster owner’s edit.
  • The energy formula’s inputs are not this rule’s inputs. waste_kw = boiler_current_kw + hw_pump_kw needs a fuel measurement and a pump power measurement, and the hot water point dictionary carries neither as a plant-power point. The host supplies them — fuel_power from HW-0002’s point set where metered, pump draw from the pump or drive family — and the formula is otherwise transcribed unchanged.
  • No test vectors are transcribed, because the reference publishes none. All twelve scenarios in vectors.json are authored from the equation and replayed against the pinned engine rev.
  • Severity 3, phase 2, method: rule, category: CRITICAL_WASTE and both tunable defaults are the reference’s chapter 14 card. g36: null — a PNNL retro-commissioning finding (RetuningOpps H01), not a G36 clause.

Notes

Settle the DHW question before deployment, not after. On a combined heating/DHW plant this rule alarms every summer day, and the right response is not to widen the parameter — 16 °C is correct — but to exclude the rule, gate it host-side on the heating loop’s isolation valve or the DHW valve position, or bind boiler_status to a boiler that does not serve DHW where the plant has a dedicated one. Widening the lockout converts a false positive into a real fault.

Check the outdoor air sensor before dispatching anyone: it is diagnosis 5, it is free, and the same sensor feeds whatever reset schedules the building has, so finding it resolves more than this fault. AHU-0018 is the same finding at another scale; where both fire, fix the plant first, and expect one cause — a commissioning phase that got cut.

Test Vectors

12 scenarios, clock step 300 s over 9000 s.

ScenarioDescription
cold_day_plant_runningJanuary: 5 degC outdoors, boiler firing, pump circulating. This is the hot water plant doing its job and the rule has nothing to say about it.
warm_day_plant_offThe other healthy case: 25 degC outdoors with the plant properly locked out. Both status points are false, so the disjunction is false and the outdoor temperature never matters.
boiler_firing_above_lockoutThe fault as the reference writes it: 20 degC outdoors and the boiler firing, held for the full lockout_check_duration. The alarm asserts at t = 3600 s.
pump_circulating_above_lockoutThe second branch of the disjunction, and the one sites forget: the boiler is off but the HW pump is still circulating at 20 degC. Pump energy, distribution losses through warm pipe, and no heating load to justify either. Same alarm at 3600 s.
oat_exactly_at_the_lockout_temperatureThreshold edge from below: the outdoor air sits exactly on heating_plant_lockout_temp with the whole plant running. Reals.GreaterThreshold is strict, so 16.0 degC is not above 16.0 degC and nothing is reported.
oat_one_tenth_above_the_lockout_temperatureThreshold edge from above: a tenth of a degree warmer, same plant, and the alarm arrives at 3600 s. A measure-zero disagreement on a real-valued signal, pinned from both sides.
lead_pump_hands_over_to_the_boilerThe disjunction is continuous, not per-point: the pump runs alone until t = 1800 s, then the boiler picks up and the pump stops. Neither point is true for a whole hour but the plant is, so the timer never restarts and the alarm still lands at 3600 s. A rule written per point would miss this.
oat_rises_through_the_lockoutA spring morning: the plant runs from midnight and the outdoor temperature crosses 16 degC at t = 1800 s. The clock starts at the crossing, not at the start of the run, and the alarm lands at 5400 s.
oat_dips_below_lockout_and_restarts_the_clockPersistence is continuous, so a 600 s dip back under the lockout at t = 1800 s discards the elapsed time rather than pausing it: the alarm lands at 6000 s, one full duration after the second crossing.
plant_stops_on_the_maturity_tickDelay edge from below: the plant runs above lockout from t = 0 and both status points drop at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of unnecessary operation is never reported.
plant_stops_one_tick_laterDelay edge from above: the same run held 300 s longer asserts at exactly 3600 s and clears at 3900 s.
alarm_clears_when_the_plant_shuts_downRecovery: the lockout is programmed and the plant stops at t = 5400 s. The alarm falls on that tick — this rule has no fall delay — which is the operator’s confirmation that the fix took.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 9000
  },
  "scenarios": [
    {
      "name": "cold_day_plant_running",
      "description": "January: 5 degC outdoors, boiler firing, pump circulating. This is the hot water plant doing its job and the rule has nothing to say about it.",
      "inputs": {
        "oat": 5.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "warm_day_plant_off",
      "description": "The other healthy case: 25 degC outdoors with the plant properly locked out. Both status points are false, so the disjunction is false and the outdoor temperature never matters.",
      "inputs": {
        "oat": 25.0,
        "boiler_status": false,
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "boiler_firing_above_lockout",
      "description": "The fault as the reference writes it: 20 degC outdoors and the boiler firing, held for the full lockout_check_duration. The alarm asserts at t = 3600 s.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": true,
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "pump_circulating_above_lockout",
      "description": "The second branch of the disjunction, and the one sites forget: the boiler is off but the HW pump is still circulating at 20 degC. Pump energy, distribution losses through warm pipe, and no heating load to justify either. Same alarm at 3600 s.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": false,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_exactly_at_the_lockout_temperature",
      "description": "Threshold edge from below: the outdoor air sits exactly on heating_plant_lockout_temp with the whole plant running. `Reals.GreaterThreshold` is strict, so 16.0 degC is not above 16.0 degC and nothing is reported.",
      "inputs": {
        "oat": 16.0,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_one_tenth_above_the_lockout_temperature",
      "description": "Threshold edge from above: a tenth of a degree warmer, same plant, and the alarm arrives at 3600 s. A measure-zero disagreement on a real-valued signal, pinned from both sides.",
      "inputs": {
        "oat": 16.1,
        "boiler_status": true,
        "hw_pump_status": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "lead_pump_hands_over_to_the_boiler",
      "description": "The disjunction is continuous, not per-point: the pump runs alone until t = 1800 s, then the boiler picks up and the pump stops. Neither point is true for a whole hour but the plant is, so the timer never restarts and the alarm still lands at 3600 s. A rule written per point would miss this.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 1800,
            "value": true
          }
        ],
        "hw_pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 1800,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_rises_through_the_lockout",
      "description": "A spring morning: the plant runs from midnight and the outdoor temperature crosses 16 degC at t = 1800 s. The clock starts at the crossing, not at the start of the run, and the alarm lands at 5400 s.",
      "inputs": {
        "boiler_status": true,
        "hw_pump_status": false,
        "oat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5100,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 5700,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "oat_dips_below_lockout_and_restarts_the_clock",
      "description": "Persistence is continuous, so a 600 s dip back under the lockout at t = 1800 s discards the elapsed time rather than pausing it: the alarm lands at 6000 s, one full duration after the second crossing.",
      "inputs": {
        "boiler_status": true,
        "hw_pump_status": true,
        "oat": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 1800,
            "value": 15.0
          },
          {
            "t": 2400,
            "value": 20.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 6300,
          "to_s": 9000,
          "equals": true
        }
      ]
    },
    {
      "name": "plant_stops_on_the_maturity_tick",
      "description": "Delay edge from below: the plant runs above lockout from t = 0 and both status points drop at exactly 3600 s, the tick the timer matures on. The input is false when the timer comes due, so a full hour of unnecessary operation is never reported.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 3600,
            "value": false
          }
        ],
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "plant_stops_one_tick_later",
      "description": "Delay edge from above: the same run held 300 s longer asserts at exactly 3600 s and clears at 3900 s.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 3900,
            "value": false
          }
        ],
        "hw_pump_status": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3600,
          "to_s": 3600,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 9000,
          "equals": false
        }
      ]
    },
    {
      "name": "alarm_clears_when_the_plant_shuts_down",
      "description": "Recovery: the lockout is programmed and the plant stops at t = 5400 s. The alarm falls on that tick \u2014 this rule has no fall delay \u2014 which is the operator's confirmation that the fix took.",
      "inputs": {
        "oat": 20.0,
        "boiler_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          }
        ],
        "hw_pump_status": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 5400,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3300,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 3900,
          "to_s": 5100,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 5400,
          "to_s": 9000,
          "equals": false
        }
      ]
    }
  ]
}