SYS-0001 — CHW flow with no cooling demand
| Status | verified — engine e2ff2f8, cxf:fnv1a128:bce909255e3c56c02e9e03c9ecd80247, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | CRITICAL_WASTE |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | CLU-07 |
| Suppresses | — |
| Suppressed by | — |
| Related | SYS-0002, CHW-0004, AHU-0014, AHU-0020, PMP-0002 |
| Playbooks | unnecessary-plant-operation, stuck-actuator |
| Source | HVAC FDD Reference v1.0 §16, SYS-0001 (pdf pp. 139-140) — equation, required points, all three tunables, the four diagnoses, and both impact profiles; The reference’s own provenance line for that card: PNNL AIRCx; 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 states | all |
Preconditions (host-enforced): ahu_clg_vlv_max must span every cooling load the loop serves, not the AHUs someone remembered. A maximum taken over a subset reads 0% while an unmonitored coil is wide open, which is exactly the case the fault claims to have excluded — and on a CHW loop the unmonitored load is rarely another AHU. Computer-room units, lab equipment, chilled beams, process heat exchangers and fan coils all hold legitimate demand that an AHU-only aggregate cannot see, and a plant serving them alarms here every hour it works correctly. Either extend the aggregate to those valves or do not bind the rule. 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); the reference publishes the fitting rule, not the number. chw_flow must be in L/s — the rule converts nothing — and must read a true zero on a dead loop: a magnetic meter with a standing zero offset, or an ultrasonic meter reporting noise on an empty pipe, 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, so the rule fires continuously and means nothing; a loop whose valves are two-position has no meaningful maximum either. Windows where flow with no demand is the sequence working — a chiller pump-down, a scheduled proof-of-operation run, freeze or condensation protection — are the host’s to exclude, because the graph has no way to tell them from waste. 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: chw_flow, ahu_clg_vlv_max
Outputs:
yFault— True while the CHW distribution loop has carried more than no_demand_flow_threshold with every served cooling valve commanded below valve_closed_threshold, continuously for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
no_demand_flow_threshold | 5.0 | L/s | flowHigh.t | Distribution 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 10% of a 50 L/s design loop (about 800 gpm, a mid-size plant at 2.4 gpm/ton); it is not a site value. Fit it from the loop’s design flow, and check it against what the meter actually reports with the pumps off. |
valve_closed_threshold | 2.0 | % | valvesShut.t | Cooling valve command at or below which a coil counts as closed (the reference’s own 2%). 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_delay | 900.0 | s | persist.delayTime | Continuous 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
Water is moving and nothing is asking for it. Every cooling coil on the loop is commanded shut, so the chilled water comes back at the temperature it left and the pump energy that pushed it around the building turns into heat in the water it was supposed to cool; a chiller still enabled holds its evaporator and its controls alive for a load that is not there. Neither side can see the fault alone — the plant knows flow and nothing about demand, the AHUs know their valves are shut and nothing about the loop — which is why it is a cross-equipment rule. The waste is a base load with no comfort complaint and no plant alarm behind it: a 15 kW distribution pump left running through a shoulder season is around 65 MWh nobody notices.
Detection Logic
flow_high = chw_flow > no_demand_flow_threshold
valves_shut = ahu_clg_vlv_max < valve_closed_threshold
yFault = (flow_high AND valves_shut) sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
The demand conjunct is where the reference and a block graph have to be
reconciled. The reference writes all(clg_vlv_cmd <= valve_closed_threshold for ahu in served_ahus), a quantifier over a set whose width is a site property,
and CXF has no variable-width input. The host supplies ahu_clg_vlv_max
instead, and max < t is exactly all < t — an identity, not an
approximation. What moves is the obligation to span every cooling load on the
loop, which is what the preconditions spend their length on.
Both comparisons are single-sided and strict. flow_high is strict in the
reference too; valves_shut is not — the reference writes <= and CDL Reals
has no LessEqual — so a served-set maximum sitting at exactly 2.0% reads as
demand rather than as closed, an error of one part in fifty of a valve command
in the direction of silence.
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 any interruption 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 four, in its order:
- CHW pump running unnecessarily — enabled by a schedule, a hand switch, or a start command nobody revoked
- Leaking cooling coil valve(s) — a valve commanded shut that does not seat passes water continuously; AHU-0014 sees the same defect from the air side
- Bypass valve stuck open — a minimum-flow or pressure-bypass valve that never closed, keeping the loop circulating whatever the coils do
- Control sequence not shutting down the CHW loop — no logic stops the pumps when demand goes away, so the plant runs whenever it is enabled
Energy Impact
CRITICAL_WASTE, HIGH confidence, DIRECT_MEASUREMENT — the reference’s profile.
The affected subsystem is the distribution pump plus chiller standby, and the
savings figure is 100% of both while the condition holds, because the load
being served is zero by construction. waste_kw = chw_pump_kw + chiller_standby_kw is the reference’s runtime term and both quantities are the
host’s; this rule reads a flow meter and a valve aggregate and never sees a kW,
so DIRECT_MEASUREMENT holds only as far as the host’s pump metering does.
Climate sensitivity is Both — a loop left circulating in winter wastes as much
as one left circulating in summer.
Emissions Impact
Scope 2, DIRECT_EMISSIONS, HIGH confidence; the reference’s typical range is 1,500-10,000 kg CO₂e/yr for the pump plus chiller standby, on a marginal operating emissions rate (MOER) basis. All of it is electricity, and the marginal basis is the right one because the waste is dispatchable — it stops the moment someone stops the pump.
Deviations
- The reference’s
all(...)quantifier becomes one host-derived aggregate.max < tis exactlyall < t, so the substitution is an identity; it is needed because the reference’s required points list a per-AHUclg_vlv_cmdand a CXF block has a fixed number of inputs. Precedent is CHW-0003’schw_valve_max, and the point dictionary carries the same warning: a maximum over a subset of the served loads is worse than no rule. <=becomes a strict<. CDLRealshas noLessEqual, sovalve_closed_thresholdis applied asLessThresholdwitht = 2.0and a served-set maximum of exactly 2.0% reads as demand where the reference would call it closed. The library’s standing convention is to pin the threshold at the boundary and take the strict form; the direction is the conservative one for a waste rule.no_demand_flow_thresholdships as a placeholder, not a default. The reference gives10% of design, a fitting rule rather than a number, and a CXFS231:valueis one double in one unit. The shipped 5.0 L/s is 10% of a 50 L/s design loop and is not a site value: too low and the rule alarms on the leakage every loop has, too high and a pump at minimum speed never trips it. Same precedent as VAV-0001’sventilation_requirement.- The valve aggregate is built from commands, not feedback, unlike CHW-0003’s. The question here is what the control system is requesting, and it is also 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 the same valve reads 20%, the demand conjunct blocks, and the rule goes quiet on the case it was written to catch.
- No schedule or occupancy gate. The reference puts none in this equation and the omission is right — flow with no demand costs the same at 2 pm as at 2 am. SYS-0003 and SYS-0004 are the chapter’s schedule-gated rules.
AlarmDelay = 15 minbecomespersist.delayTime = 900 swithdelayOnInit = true(CDL defaultfalse), 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.TrueDelayasserts at exactlyT + delayTime, verified against the engine at the pin rather than assumed, so the realized test is “strictly more thanalarm_delay” at tick resolution.- Playbook binding. Primary is
unnecessary-plant-operation, CLU-07’s declared slug;stuck-actuatorstays bound as the secondary procedure for diagnoses 2 and 3. - 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
Read the finding as a question about the pump before it is a question about a valve: if the pump is commanded on, the fault is diagnosis 1 or 4 and the work is in the BAS; if the pump is off and the meter still reads flow, diagnoses 2 and 3 are what remain. PMP-0002 (deadheading) often fires on the same hour.
SYS-0002 is the mirror, and a site with one usually has both — CLU-07 exists for that pairing, with this rule as the trigger. A plant tripping CHW-0004 at the same time is not showing two independent problems: flow with no load is the cleanest possible case of low delta-T syndrome.
Test Vectors
13 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
plant_shut_down_with_no_demand | The healthy no-demand case: every cooling valve is shut and the CHW 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_cooling_demand | The other healthy case: 40 L/s circulating while the busiest coil valve sits at 65%. This is a plant doing its job, and the valve conjunct blocks the fault on its own no matter how much flow the loop carries. |
flow_with_every_valve_shut | The fault: 12 L/s moving through the distribution loop while the maximum cooling valve command across every served AHU is 0%. Both conjuncts hold from the first tick, so persist matures at exactly 900 s. |
flow_exactly_at_the_no_demand_threshold | 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. |
flow_just_above_the_no_demand_threshold | 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. |
valve_max_exactly_at_the_closed_threshold | The strict-comparison deviation, pinned. The reference writes clg_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_threshold | 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. |
one_open_valve_holds_the_aggregate_up | The reference’s all(…) quantifier written as a maximum: one AHU on the loop is trimming at 8% while every other coil valve is shut, so the aggregate never enters the closed band and the plant is serving a real load. This is the scenario the host’s aggregate has to get right — a maximum taken over a subset of the served AHUs would report 0% here and alarm on a plant that is working. |
demand_stops_while_the_pump_runs | The transition the rule exists to catch: the building coasts out of its cooling load at t=600 s and the last valve shuts, but the CHW 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_matures | A 14-minute lull in the cooling load is not a fault. A 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_tick | 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. |
demand_returns_one_tick_after_maturity | 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. |
pump_stops_and_the_fault_clears | Recovery: the alarm asserts at 900 s, someone stops the CHW 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 cooling valve is shut and the CHW 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": {
"chw_flow": 0.5,
"ahu_clg_vlv_max": 0.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "flow_with_real_cooling_demand",
"description": "The other healthy case: 40 L/s circulating while the busiest coil valve sits at 65%. This is a plant doing its job, and the valve conjunct blocks the fault on its own no matter how much flow the loop carries.",
"inputs": {
"chw_flow": 40.0,
"ahu_clg_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 cooling valve command across every served AHU is 0%. Both conjuncts hold from the first tick, so persist matures at exactly 900 s.",
"inputs": {
"chw_flow": 12.0,
"ahu_clg_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": {
"chw_flow": 5.0,
"ahu_clg_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": {
"chw_flow": 5.1,
"ahu_clg_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 clg_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": {
"chw_flow": 12.0,
"ahu_clg_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": {
"chw_flow": 12.0,
"ahu_clg_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 AHU on the loop is trimming at 8% while every other coil valve is shut, so the aggregate never enters the closed band and the plant is serving a real load. This is the scenario the host's aggregate has to get right \u2014 a maximum taken over a subset of the served AHUs would report 0% here and alarm on a plant that is working.",
"inputs": {
"chw_flow": 12.0,
"ahu_clg_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 building coasts out of its cooling load at t=600 s and the last valve shuts, but the CHW 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": {
"chw_flow": 12.0,
"ahu_clg_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 cooling load is not a fault. A 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": {
"chw_flow": 12.0,
"ahu_clg_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": {
"chw_flow": 12.0,
"ahu_clg_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": {
"chw_flow": 12.0,
"ahu_clg_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 CHW 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": {
"chw_flow": [
{
"t": 0,
"value": 12.0
},
{
"t": 1800,
"value": 0.5
}
],
"ahu_clg_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
}
]
}
]
}