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-0002 — HW flow with no heating demand

Statusverified — engine e2ff2f8, cxf:fnv1a128:893d380590509f14b9632129cdc3322b, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceHIGH
EstimationDIRECT_MEASUREMENT
G36
ClustersCLU-07
Suppresses
Suppressed by
RelatedSYS-0001, HW-0003, HW-0004, AHU-0015, VAV-0003
Playbooksunnecessary-plant-operation, stuck-actuator, hot-water-plant-faults
SourceHVAC FDD Reference v1.0 §16, SYS-0002 (pdf pp. 140-141) — equation, required points, the same structure as SYS-050 tunables row, the three diagnoses, and both impact profiles; The reference’s own provenance line for that card: PNNL AIRCx; SYS-0001, the CHW rule this one mirrors block for block and parameter for parameter; Library precedent: CHW-0003 (chw_valve_max, the served-set valve aggregate this card mirrors), VAV-0001 and HP-0001 (parameters shipped as documented placeholders because the reference publishes a fitting rule rather than a number)
Operating statesall

Preconditions (host-enforced): ahu_htg_vlv_max must span every heating load the loop serves. On a HW plant that usually means more than the air handlers: zone reheat valves, perimeter radiation, cabinet and unit heaters, and any preheat coil are all real demand, and a maximum taken over the AHU coils alone reads 0% while a hundred reheat valves modulate. That is the aggregate most likely to be built wrong, because the reheat valves live on zone controllers rather than on the plant. A plant that also makes domestic hot water must not be bound to this rule as it stands: a boiler holding 60 °C for service water circulates with every space-heating valve shut, and nothing in two points separates that from waste — bind a heating-only loop, gate host-side on the DHW load, or exclude the rule, the same decision HW-0003 and HW-0007 force. Loops that circulate on purpose with no call for heat are the other exclusion: freeze protection in an unconditioned space, a glycol run-around loop, and the pump exercise cycles some plants run weekly are all sequence working as designed, and the host owns those windows because the graph cannot see them. no_demand_flow_threshold ships as a placeholder in L/s and MUST be fitted to roughly 10% of the loop’s design flow before any verdict means anything (see Deviations); a HW loop moves far less water than a CHW loop of the same capacity, because it runs a design delta-T two to three times larger, so the CHW-derived shipped value is too high for most heating loops rather than too low. hw_flow must be in L/s — the rule converts nothing — and must read a true zero on a dead loop: a meter with a standing zero offset holds the flow conjunct true forever and turns this into a permanent alarm on a plant that is off. The loop must be variable-flow on modulating two-way valves; a three-way-valve loop circulates near design flow with every coil diverted to its bypass, and the rule fires continuously and means nothing on it. When the aggregate is stale, partial, or missing the verdict is NO_EVAL, not healthy: there is no in-rule evaluability output, since a stale feed and a genuinely shut valve are the same number at the boundary.

Points: hw_flow, ahu_htg_vlv_max

Outputs:

  • yFault — True while the HW distribution loop has carried more than no_demand_flow_threshold with every served heating valve commanded below valve_closed_threshold, continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
no_demand_flow_threshold5.0L/sflowHigh.tDistribution flow above which the loop counts as circulating rather than resting. PER-LOOP SITE CONFIGURATION — the reference’s default is 10% of design, a commissioning-fitted quantity rather than a constant, and a CXF literal has to be one number in one unit. The shipped 5.0 L/s is SYS-0001’s value, kept because the reference’s tunables row for this fault reads same structure as SYS-050; on a hot water loop it is generous, since a 1 MW plant at an 11 K design delta-T circulates about 22 L/s and 10% of that is 2.2 L/s. It is not a site value.
valve_closed_threshold2.0%valvesShut.tHeating valve command at or below which a coil counts as closed (the reference’s own 2%, inherited from SYS-0001). Applied to the served-set maximum, so it is the whole demand test. Sites whose valve commands park at a nonzero rest position must retune it above that position or accept a standing alarm.
alarm_delay900.0spersist.delayTimeContinuous flow-without-demand required before the alarm asserts (the reference’s AlarmDelay, 15 min). It is what separates the fault from the minutes after the last valve shuts, while the loop coasts down and the plant sequence runs.

Description

The heating loop is circulating and no coil is asking for heat. Hot water leaves the plant, travels the building, and returns at close to the temperature it left, so the pump energy moves water that delivers nothing and the distribution losses along the way are paid for out of fuel; a boiler still enabled holds a hot jacket and a set of controls alive for a load that does not exist. This is the heating mirror of SYS-0001, and the reference writes it that way — “same structure as SYS-050” in place of a tunables table. What differs is scale and season: a HW loop carries far less water for the same capacity (its design delta-T is two to three times a chilled loop’s), the standby term is fuel rather than electricity, and a heating plant left circulating through a summer produces no complaint, no alarm and no comfort signature until someone reads the flow meter.

Detection Logic

flow_high   = hw_flow > no_demand_flow_threshold
valves_shut = ahu_htg_vlv_max < valve_closed_threshold

yFault = (flow_high AND valves_shut) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

SYS-0002 block graph

ahu_htg_vlv_max carries the reference’s all(htg_vlv_cmd <= valve_closed_threshold for ahu in served_ahus) quantifier as a host-computed maximum, because CXF has no variable-width input and the served set is a site property. max < t is exactly all < t, so the substitution is an identity; what moves is the obligation. On a heating loop that obligation is heavier than on the CHW side, because the served set usually includes zone reheat valves the plant controller has never heard of — see preconditions.

valves_shut is strict where the reference writes <= (CDL Reals has no LessEqual), so a served-set maximum of exactly 2.0% reads as demand and blocks the fault. flow_high is strict in the reference too and needed no change.

persist is a TrueDelay asserting at exactly T + delayTime, so the realized test is “flow with no demand for strictly more than alarm_delay” at tick resolution, and a dip discards the elapsed time rather than pausing it. delayOnInit = true (CDL default false) makes a loop already circulating at engine start wait out the full 15 minutes.

Possible Diagnoses

The reference’s three, in its order:

  1. HW pump running unnecessarily — enabled by a schedule, a hand switch, or a start command nobody revoked; on a heating plant this is frequently seasonal
  2. Leaking heating coil valve(s) — a valve commanded shut that does not seat passes hot water continuously, putting heat into supply air that then has to be cooled back down; AHU-0015 sees it from the air side
  3. Bypass valve stuck open — a minimum-flow or pressure-bypass valve that never closed, keeping the loop circulating whatever the coils do

The reference lists no control-sequence item here, unlike its CHW card. In practice a loop with no logic to stop the pumps when demand goes away shows up under diagnosis 1, every hour, by design.

Energy Impact

CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile. The affected subsystem is the distribution pump plus boiler standby, and the savings figure is 100% of both while the condition holds, because the load being served is zero by construction. waste_kw = hw_pump_kw + boiler_standby_kw is the reference’s runtime term and both quantities are the host’s. The halves are different kinds of energy: pump power is electricity, usually metered or reported by the drive; boiler standby is fuel — jacket and flue losses plus the short cycles that hold temperature — which on most plants is a nameplate-and-efficiency estimate, so DIRECT_MEASUREMENT holds only as far as the host’s instrumentation does. Climate sensitivity is Both, per the reference.

Emissions Impact

Scope 1 + 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s typical range is 1,500-10,000 kg CO₂e/yr for the pump plus boiler standby, on a “Static Scope 1 + MOER” basis. The split follows the two subsystems: fuel burned to hold a boiler warm is Scope 1 on a static factor, pump electricity is Scope 2 on the marginal rate for the hour. An electric or heat-pump boiler moves the whole quantity into Scope 2 and onto MOER.

Deviations

  • The reference’s all(...) quantifier becomes one host-derived aggregate. max < t is exactly all < t, so the substitution is an identity; it is needed because the reference’s required points list a per-AHU htg_vlv_cmd and a CXF block has a fixed number of inputs. Precedent is CHW-0003’s chw_valve_max; the dictionary entry for ahu_htg_vlv_max carries the instruction that reheat valves belong in the set where the plant feeds them.
  • <= becomes a strict <. CDL Reals has no LessEqual, so valve_closed_threshold is applied as LessThreshold with t = 2.0 and a served-set maximum of exactly 2.0% reads as demand where the reference would call it closed. Standing library convention: pin the threshold at the boundary and take the strict form, which is the conservative direction for a waste rule.
  • no_demand_flow_threshold ships as a placeholder, and the mirror makes it worse here. The reference gives SYS-0001 10% of design and gives this fault “same structure as SYS-050”, so the tunable is inherited along with its problem. The shipped 5.0 L/s is a CHW-scale figure: a hot water loop at an 11 K design delta-T moves about 22 L/s per MW, so 5.0 L/s is 10% of design only near 2.3 MW and exceeds the entire design flow of a small plant — which fails silent. Fit it per loop before deployment. Precedent: VAV-0001’s ventilation_requirement.
  • The valve aggregate is built from commands, not feedback, following the reference’s own point (htg_vlv_cmd). The command states what the control system is asking for, which is what “no heating demand” means, and it is what keeps diagnoses 2 and 3 visible: a leaking or stuck-open valve reads 0% on the command while it passes water; bind feedback and it reads 20%, the demand conjunct blocks, and the rule goes quiet on two of its three diagnoses.
  • Three diagnoses, not SYS-0001’s four. The reference drops “control sequence not shutting down the loop” from this card’s list, and the list is transcribed rather than harmonised with the CHW card.
  • No schedule, occupancy, or OAT gate. The reference puts none in this equation, and the weather-based version of the fault is HW-0003 (plant operating above the OAT lockout), a separate rule with its own point and threshold. Nothing here consumes oat or occ_scheduled.
  • AlarmDelay = 15 min becomes persist.delayTime = 900 s with delayOnInit = true (CDL default false), the library’s standing choice: a loop already circulating with no demand at controller restart waits out the full 15 minutes rather than alarming on the first tick.
  • TrueDelay asserts at exactly T + delayTime, verified against the engine at the pin rather than assumed, so the realized test is “strictly more than alarm_delay” at tick resolution.
  • Playbook binding. Primary is unnecessary-plant-operation, CLU-07’s declared slug; stuck-actuator stays bound for the valve half of the diagnoses and hot-water-plant-faults for the plant half (boiler OAT lockout, DHW exclusion).
  • 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

Settle the domestic hot water question before dispatching anything. On a combined plant this rule fires every summer hour, right about the numbers and wrong about the building, and the check is a drawing rather than a trend: does this loop feed a service water heat exchanger. If it does, the binding is the defect — a heating-only loop or a host-side gate, not a work order.

After that the finding is a question about the pump before it is a question about a valve. Pump commanded on is diagnosis 1 and BAS work, often a seasonal changeover nobody performed; pump off with flow on the meter leaves diagnoses 2 and 3. Pull HW-0003 alongside: it asks whether the plant is running above its OAT lockout, this rule asks whether anything is calling for heat, and a site that trips both has no demand-side shutdown at all. Check the chilled water side too — CLU-07’s trigger is SYS-0001, and the sequence gap is usually written once and copied.

Test Vectors

13 scenarios, clock step 60 s over 3600 s.

ScenarioDescription
plant_shut_down_with_no_demandThe healthy no-demand case: every heating valve is shut and the HW loop is shut down with it. Residual flow of 0.5 L/s is well under the no-demand threshold, so the flow conjunct blocks the fault on its own.
flow_with_real_heating_demandThe other healthy case: 40 L/s circulating while the busiest heating valve sits at 65%. One perimeter zone calling for reheat is enough to justify the whole loop, and the valve conjunct blocks the fault on its own.
flow_with_every_valve_shutThe fault: 12 L/s moving through the distribution loop while the maximum heating valve command across every served coil is 0%. Both conjuncts hold from the first tick, so persist matures at exactly 900 s.
flow_exactly_at_the_no_demand_thresholdBoundary from the reference’s own strict inequality: flow sitting at exactly no_demand_flow_threshold (5.0 L/s) is not above it. Reals.GreaterThreshold is strict, which is what the reference writes for this term, so the loop reads healthy.
flow_just_above_the_no_demand_thresholdThe same line from above: 5.1 L/s against a 5.0 L/s threshold with every valve shut, alarm at 900 s. Whether a real plant is faulted at 0.1 L/s of margin is a question about the fitted threshold, not about the rule.
valve_max_exactly_at_the_closed_thresholdThe strict-comparison deviation, pinned. The reference writes htg_vlv_cmd <= valve_closed_threshold; CDL Reals has no LessEqual, so the shipped test is a strict LessThreshold at 2.0 and a served set whose maximum sits at exactly 2.0% reads as demand rather than as closed. The pin is on the conservative side: no alarm.
valve_max_just_below_the_closed_thresholdThe same line from below: a maximum of 1.9% is inside the closed band, so the fault matures at 900 s. The gap between this scenario and the previous one is the whole cost of the strict pin.
one_open_valve_holds_the_aggregate_upThe reference’s all(…) quantifier written as a maximum: one zone reheat valve is trimming at 8% while every other heating valve is shut, so the aggregate never enters the closed band and the plant is serving a real load. On a HW loop that feeds VAV reheat this is the scenario the host’s aggregate most often gets wrong, because the reheat valves are counted in hundreds and live on the zone controllers rather than on the AHU.
demand_stops_while_the_pump_runsThe transition the rule exists to catch: the last zone comes off heat at t=600 s as the building warms up, but the HW pump keeps 12 L/s moving. The delay starts on that tick, so the alarm lands at 1500 s rather than at 900 s.
demand_returns_before_the_delay_maturesA 14-minute lull in the heating load is not a fault. A reheat valve reopens to 30% at 840 s, one tick short of maturity, and shuts again at 1200 s. Continuous means continuous: the elapsed time is discarded rather than paused, so the alarm lands a full 900 s after the second shut, at 2100 s.
demand_returns_on_the_maturity_tickThe delay edge from below: a valve reopens at exactly 900 s. TrueDelay asserts at exactly T + delayTime and the falling edge passes straight through, so the two land on the same tick and the fault is never reported.
demand_returns_one_tick_after_maturityThe same edge from above: the valve reopens one tick later and leaves exactly one tick of alarm at 900 s. The realized test is therefore ‘no demand with flow for strictly more than alarm_delay’ at tick resolution.
pump_stops_and_the_fault_clearsRecovery: the alarm asserts at 900 s, someone stops the HW pump at 1800 s, and flow falls to 0.5 L/s. TrueDelay delays the rising edge only, so the finding drops on that tick with no lag.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 3600
  },
  "scenarios": [
    {
      "name": "plant_shut_down_with_no_demand",
      "description": "The healthy no-demand case: every heating valve is shut and the HW loop is shut down with it. Residual flow of 0.5 L/s is well under the no-demand threshold, so the flow conjunct blocks the fault on its own.",
      "inputs": {
        "hw_flow": 0.5,
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_with_real_heating_demand",
      "description": "The other healthy case: 40 L/s circulating while the busiest heating valve sits at 65%. One perimeter zone calling for reheat is enough to justify the whole loop, and the valve conjunct blocks the fault on its own.",
      "inputs": {
        "hw_flow": 40.0,
        "ahu_htg_vlv_max": 65.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_with_every_valve_shut",
      "description": "The fault: 12 L/s moving through the distribution loop while the maximum heating valve command across every served coil is 0%. Both conjuncts hold from the first tick, so persist matures at exactly 900 s.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "flow_exactly_at_the_no_demand_threshold",
      "description": "Boundary from the reference's own strict inequality: flow sitting at exactly no_demand_flow_threshold (5.0 L/s) is not above it. Reals.GreaterThreshold is strict, which is what the reference writes for this term, so the loop reads healthy.",
      "inputs": {
        "hw_flow": 5.0,
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_just_above_the_no_demand_threshold",
      "description": "The same line from above: 5.1 L/s against a 5.0 L/s threshold with every valve shut, alarm at 900 s. Whether a real plant is faulted at 0.1 L/s of margin is a question about the fitted threshold, not about the rule.",
      "inputs": {
        "hw_flow": 5.1,
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "valve_max_exactly_at_the_closed_threshold",
      "description": "The strict-comparison deviation, pinned. The reference writes htg_vlv_cmd <= valve_closed_threshold; CDL Reals has no LessEqual, so the shipped test is a strict LessThreshold at 2.0 and a served set whose maximum sits at exactly 2.0% reads as demand rather than as closed. The pin is on the conservative side: no alarm.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "valve_max_just_below_the_closed_threshold",
      "description": "The same line from below: a maximum of 1.9% is inside the closed band, so the fault matures at 900 s. The gap between this scenario and the previous one is the whole cost of the strict pin.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": 1.9
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "one_open_valve_holds_the_aggregate_up",
      "description": "The reference's all(...) quantifier written as a maximum: one zone reheat valve is trimming at 8% while every other heating valve is shut, so the aggregate never enters the closed band and the plant is serving a real load. On a HW loop that feeds VAV reheat this is the scenario the host's aggregate most often gets wrong, because the reheat valves are counted in hundreds and live on the zone controllers rather than on the AHU.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": 8.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "demand_stops_while_the_pump_runs",
      "description": "The transition the rule exists to catch: the last zone comes off heat at t=600 s as the building warms up, but the HW pump keeps 12 L/s moving. The delay starts on that tick, so the alarm lands at 1500 s rather than at 900 s.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": [
          {
            "t": 0,
            "value": 65.0
          },
          {
            "t": 600,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1440,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1500,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "demand_returns_before_the_delay_matures",
      "description": "A 14-minute lull in the heating load is not a fault. A reheat valve reopens to 30% at 840 s, one tick short of maturity, and shuts again at 1200 s. Continuous means continuous: the elapsed time is discarded rather than paused, so the alarm lands a full 900 s after the second shut, at 2100 s.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 840,
            "value": 30.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2040,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 3600,
          "equals": true
        }
      ]
    },
    {
      "name": "demand_returns_on_the_maturity_tick",
      "description": "The delay edge from below: a valve reopens at exactly 900 s. TrueDelay asserts at exactly T + delayTime and the falling edge passes straight through, so the two land on the same tick and the fault is never reported.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 900,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "demand_returns_one_tick_after_maturity",
      "description": "The same edge from above: the valve reopens one tick later and leaves exactly one tick of alarm at 900 s. The realized test is therefore 'no demand with flow for strictly more than alarm_delay' at tick resolution.",
      "inputs": {
        "hw_flow": 12.0,
        "ahu_htg_vlv_max": [
          {
            "t": 0,
            "value": 0.0
          },
          {
            "t": 960,
            "value": 30.0
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 900,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 3600,
          "equals": false
        }
      ]
    },
    {
      "name": "pump_stops_and_the_fault_clears",
      "description": "Recovery: the alarm asserts at 900 s, someone stops the HW pump at 1800 s, and flow falls to 0.5 L/s. TrueDelay delays the rising edge only, so the finding drops on that tick with no lag.",
      "inputs": {
        "hw_flow": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 0.5
          }
        ],
        "ahu_htg_vlv_max": 0.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 840,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 900,
          "to_s": 1740,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1800,
          "to_s": 3600,
          "equals": false
        }
      ]
    }
  ]
}