CHW-0003 — CHW loop DP reset not functioning
| Status | verified — engine e2ff2f8, cxf:fnv1a128:89ddf1254d82b6820f9b536b32c1c5d9, 2026-08-17 |
| Severity | 3 |
| Method | statistical |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | HIGH |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | CHW-0002, CHW-0004, AHU-0024 |
| Playbooks | missing-reset |
| Source | HVAC FDD Reference v1.0 ch.13, CHW-0003 (pdf pp. 121-122); PNNL RetuningOpps C03; PNNL-25985 EEM-10 |
| Operating states | CHW distribution running (variable-speed secondary or primary pumps enabled) with coils served |
Preconditions (host-enforced): dp_sp must be the setpoint the pumps actually control to, on the same loop as the coils feeding chw_valve_max. chw_valve_max is host-derived — the maximum across the CHW coil valves the loop serves — and the aggregate must span every coil on the loop: a maximum taken over a subset can sit at 55% while an unmonitored coil is wide open and starving, which is exactly the case the fault claims to have excluded. When the aggregate is stale, partial, or missing the verdict is NO_EVAL, not healthy; there is no in-rule evaluability output to catch it, because a stale feed is indistinguishable from a genuine low reading at the boundary. Prefer valve position feedback over valve command where both exist — a command reads low on a valve that is stuck open. The rule assumes modulating two-way valves; a loop on two-position valves or with three-way bypasses has no meaningful maximum position and must not be bound at all.
Points: dp_sp, chw_valve_max
Outputs:
yFault— True while the CHW loop differential-pressure setpoint has stayed flat over the evaluation window with every served coil valve below high_valve_threshold, for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
evaluation_window | 259200.0 | s | spRef.samplePeriod, spFlatHeld.delayTime, vlvLowHeld.delayTime | Window over which setpoint flatness and low valve demand are assessed (3 days) |
sp_flat_tolerance | 7.5 | kPa | spFlat.t | Max deviation of dp_sp from its sampled baseline to count as flat (half the reference’s 15 kPa min_expected_sp_range) |
high_valve_threshold | 90.0 | % | vlvLow.t | Position below which the most-open coil valve still has authority, so the loop is not at maximum demand and the setpoint could have come down. Adopted — the reference names high_valve_threshold in the logic but ships no default (see Deviations) |
alarm_delay | 86400.0 | s | persist.delayTime | Fault persistence before alarm (24 h) |
Description
The chilled water loop differential-pressure setpoint never moves while every coil valve on the loop is throttling. The pumps hold a design-day pressure against a building that is not asking for one, and the valves burn the difference across their seats. Pump power goes with the cube of pressure, so this is the cheapest large number in the plant: a 20% setpoint reduction is about half the pump energy, and the reset that achieves it is a sequence, not a purchase. The valve conjunct is what makes the finding safe to act on — a flat setpoint alone is also what a working reset looks like when a starving coil has pinned it at its upper limit. Found in more than 30% of buildings (PNNL 151-building study), usually alongside its supply-temperature twin CHW-0002 and for the same reason.
Detection Logic
baseline(dp_sp) = dp_sp sampled and held every evaluation_window (3 days)
sp_flat = |dp_sp − baseline(dp_sp)| < sp_flat_tolerance,
continuously for evaluation_window
low_demand = chw_valve_max < high_valve_threshold,
continuously for evaluation_window
yFault = sp_flat AND low_demand, sustained for alarm_delay
Block graph (rule.cxf.jsonld):
The setpoint chain is AHU-0024’s sampled-baseline flatness detector with the
plant’s points bound to it: a Discrete.Sampler refreshed once per window
supplies the reference value, and spFlatHeld asserts only after the setpoint
has stayed within sp_flat_tolerance of it continuously for a full window.
The demand condition needs no baseline. The reference’s
max(chw_valve_positions) < high_valve_threshold over the window is exactly
equivalent to “the most-open valve stays below the threshold continuously” —
one LessThreshold plus a dwell, an exact transformation rather than an
approximation. Both comparisons are strict, so a valve at exactly 90% is not low
demand and a setpoint deviating exactly 7.5 kPa is not flat; both boundaries
fall on the no-fault side. Every TrueDelay carries delayOnInit = true, and
worst-case time to alarm from cold start is evaluation_window + alarm_delay —
4 days.
Possible Diagnoses
- DP reset never programmed
- DP reset disabled or overridden
- Valve position feedback not connected
- DP sensor at wrong location
Energy Impact
EXCESS_CONSUMPTION, HIGH confidence, PROXY_ESTIMATION (EEM-10, PNNL-25985).
Savings 0.5–2% of site energy, climate-neutral, through the cubic pump law:
pump_waste_kw = chw_pump_kw × [1 − (1 − DP_reduction/100)³]. Prevalence above
30% of buildings. Diagnosis 4 changes the economics — moving a DP sensor to the
hydraulically most remote coil is a pipe-fitting job rather than a desk job, and
the reference lists it because a sensor at the pump discharge makes a correct
reset impossible rather than merely absent.
Emissions Impact
Scope 2, PROXY_EMISSIONS, HIGH confidence; typical 300–3,000 kg CO₂e/yr of excess pump energy. Avoided-emissions basis: MOER (marginal).
Deviations
chw_valve_positions(vector) → host-derivedchw_valve_max(scalar). Library v1 has no vector boundary points, so the host aggregates and feeds one scalar (derived: truein the point dictionary). AHU-0024’szone_dmpr_pos_maxis the precedent, and the cost is inpreconditions: the graph cannot tell a maximum over ten coils from a maximum over three.- Windowed range → deviation from a sampled baseline on the setpoint chain,
with
sp_flat_tolerance = min_expected_sp_range/2, exactly as AHU-0023/AHU-0024; CDL has no windowed min/max block. Detection is equivalent for a setpoint that moves and returns and slightly conservative for monotonic drift inside one window. The valve chain is not an approximation. Reals.MovingAveragerejected, and the tick band that follows. Its fixed 64-checkpoint ring needsdt ≥ evaluation_window/63— 4,114 s at three days — before the window stops silently dropping its oldest samples, and no BAS ticks that slowly. The sampler-and-dwell replacement has no lower bound on tick period; its upper bound is what you need to see, since an excursion shorter than one tick is invisible to the dwells. Trend at 5–15 min.high_valve_thresholdis adopted, not transcribed. The chapter names it in the equation but its tunables line lists onlyevaluation_window,min_expected_sp_rangeandAlarmDelay. The shipped 90% is deliberately permissive on the fault side — the conjunct’s only job is to exclude a loop genuinely pinned at maximum demand, and a modulating two-way valve at 90% is within a hair of having no authority left. It is looser than AHU-0024’s chapter-supplied 70% damper analog, so a site wanting that margin should set 70–80. Note the direction: raising this number makes the rule fire more often.- No evaluability output. The valve test is a conjunct of the reference’s
fault condition, not an evaluability gate (contrast CHW-0002’s
yLoadVaried, which mirrors the reference’s own NO_EVAL semantics). An output carryingchw_valve_max < high_valve_thresholdwould only echo one boundary input through a threshold, so the rule shipsyFaultalone and the staleness question stays inpreconditions. AlarmDelay= 24 h implemented asTrueDelayon the fault conjunction; the evaluation window itself is enforced by the two dwells.delayOnInit = trueon everyTrueDelay(startup conservatism per AHU-0016).- Transcription gaps in the source. The chapter gives CHW-0003 no
description paragraph, no operating-states line and no test vectors, so all
vectors here are constructed. Its Required Points line reads
“DP_SP, chw_valve_positions”; canonical names come from
points/chw.points.json. The chapter’s heading is “CHW loop differential pressure reset not functioning”;namecarries the shorter index spelling fromfaults/chw/README.md, which owns names. - Blind spots. The rule sees the setpoint, not the pressure: a loop whose setpoint resets correctly while the pumps fail to track it is a different fault. Diagnosis 4 is invisible here — a badly placed sensor produces a plausible flat setpoint this rule reports as a missing reset, which is why the playbook’s first step is to check where the sensor is. Diagnosis 3 is worse than invisible: it corrupts the input the rule leans on, and a defaulted-to-zero feedback reads as a permanent low maximum. And a loop whose pumps are off for the window holds both signals flat, which only the host’s operating-state gate can suppress.
Notes
Fix path is the missing-reset playbook.
Its worked examples are the AHU-side pair; the plant-side procedure is the same
shape one system upstream — plot dp_sp against the most-open coil valve over
the window, confirm the DP sensor is at the hydraulically most remote coil, then
program the reset.
clusters is deliberately empty: CLU-02 (“Missing Reset Strategy”) is currently
AHU-scoped and triggered by AHU-0023, and membership is
clusters/clusters.json’s to declare. A plant failing both CHW-0002 and
CHW-0003 has one root cause and should be dispatched as one visit. Expect
CHW-0004 (low delta-T) nearby for the opposite reason: low delta-T drives flow
up and can hold coil valves open, which is the condition that legitimately
suppresses this fault.
Test Vectors
8 scenarios, clock step 300 s over 432000 s.
| Scenario | Description |
|---|---|
reset_working | DP_SP swings 100 kPa with load; flatness dwell resets every 6 h, no fault |
fixed_sp_low_valve | DP_SP pinned while the most-open coil valve sits at 55%; fault at exactly evaluation_window (3 d) + alarm_delay (24 h) = 345600 s, pinned from both sides |
valve_at_threshold | Most-open valve exactly at high_valve_threshold (90%) is not low demand (strict <), so a flat setpoint is not a fault |
valve_just_below_threshold | Most-open valve at 89.9% is below the threshold; the same flat setpoint alarms at 345600 s |
sp_at_flat_tolerance | Setpoint deviates from its baseline by exactly sp_flat_tolerance (7.5 kPa): strict < makes that not flat, so no fault |
sp_within_flat_tolerance | Setpoint jitter of 7 kPa stays inside sp_flat_tolerance, so it still counts as flat; fault at 345600 s |
valve_drops_mid_run | Coil demand falls away 12 h into the run: the valve dwell starts at 43200 s, so the alarm lands at exactly 43200 + 259200 + 86400 = 388800 s |
valve_opens_resets | Low valve demand for 2.3 days, then a coil starves before the window completes; dwell resets, never alarms |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 432000
},
"scenarios": [
{
"name": "reset_working",
"description": "DP_SP swings 100 kPa with load; flatness dwell resets every 6 h, no fault",
"inputs": {
"dp_sp": [
{
"t": 0,
"value": 200.0
},
{
"t": 21600,
"value": 300.0
},
{
"t": 43200,
"value": 200.0
},
{
"t": 64800,
"value": 300.0
},
{
"t": 86400,
"value": 200.0
},
{
"t": 108000,
"value": 300.0
},
{
"t": 129600,
"value": 200.0
},
{
"t": 151200,
"value": 300.0
},
{
"t": 172800,
"value": 200.0
},
{
"t": 194400,
"value": 300.0
},
{
"t": 216000,
"value": 200.0
},
{
"t": 237600,
"value": 300.0
},
{
"t": 259200,
"value": 200.0
},
{
"t": 280800,
"value": 300.0
},
{
"t": 302400,
"value": 200.0
},
{
"t": 324000,
"value": 300.0
},
{
"t": 345600,
"value": 200.0
},
{
"t": 367200,
"value": 300.0
},
{
"t": 388800,
"value": 200.0
},
{
"t": 410400,
"value": 300.0
},
{
"t": 432000,
"value": 200.0
}
],
"chw_valve_max": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 432000,
"equals": false
}
]
},
{
"name": "fixed_sp_low_valve",
"description": "DP_SP pinned while the most-open coil valve sits at 55%; fault at exactly evaluation_window (3 d) + alarm_delay (24 h) = 345600 s, pinned from both sides",
"inputs": {
"dp_sp": 250.0,
"chw_valve_max": 55.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 345300,
"equals": false
},
{
"output": "yFault",
"from_s": 345600,
"to_s": 432000,
"equals": true
}
]
},
{
"name": "valve_at_threshold",
"description": "Most-open valve exactly at high_valve_threshold (90%) is not low demand (strict <), so a flat setpoint is not a fault",
"inputs": {
"dp_sp": 250.0,
"chw_valve_max": 90.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 432000,
"equals": false
}
]
},
{
"name": "valve_just_below_threshold",
"description": "Most-open valve at 89.9% is below the threshold; the same flat setpoint alarms at 345600 s",
"inputs": {
"dp_sp": 250.0,
"chw_valve_max": 89.9
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 345300,
"equals": false
},
{
"output": "yFault",
"from_s": 345600,
"to_s": 432000,
"equals": true
}
]
},
{
"name": "sp_at_flat_tolerance",
"description": "Setpoint deviates from its baseline by exactly sp_flat_tolerance (7.5 kPa): strict < makes that not flat, so no fault",
"inputs": {
"dp_sp": [
{
"t": 0,
"value": 250.0
},
{
"t": 21600,
"value": 257.5
},
{
"t": 43200,
"value": 250.0
},
{
"t": 64800,
"value": 257.5
},
{
"t": 86400,
"value": 250.0
},
{
"t": 108000,
"value": 257.5
},
{
"t": 129600,
"value": 250.0
},
{
"t": 151200,
"value": 257.5
},
{
"t": 172800,
"value": 250.0
},
{
"t": 194400,
"value": 257.5
},
{
"t": 216000,
"value": 250.0
},
{
"t": 237600,
"value": 257.5
},
{
"t": 259200,
"value": 250.0
},
{
"t": 280800,
"value": 257.5
},
{
"t": 302400,
"value": 250.0
},
{
"t": 324000,
"value": 257.5
},
{
"t": 345600,
"value": 250.0
},
{
"t": 367200,
"value": 257.5
},
{
"t": 388800,
"value": 250.0
},
{
"t": 410400,
"value": 257.5
},
{
"t": 432000,
"value": 250.0
}
],
"chw_valve_max": 55.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 432000,
"equals": false
}
]
},
{
"name": "sp_within_flat_tolerance",
"description": "Setpoint jitter of 7 kPa stays inside sp_flat_tolerance, so it still counts as flat; fault at 345600 s",
"inputs": {
"dp_sp": [
{
"t": 0,
"value": 250.0
},
{
"t": 21600,
"value": 257.0
},
{
"t": 43200,
"value": 250.0
},
{
"t": 64800,
"value": 257.0
},
{
"t": 86400,
"value": 250.0
},
{
"t": 108000,
"value": 257.0
},
{
"t": 129600,
"value": 250.0
},
{
"t": 151200,
"value": 257.0
},
{
"t": 172800,
"value": 250.0
},
{
"t": 194400,
"value": 257.0
},
{
"t": 216000,
"value": 250.0
},
{
"t": 237600,
"value": 257.0
},
{
"t": 259200,
"value": 250.0
},
{
"t": 280800,
"value": 257.0
},
{
"t": 302400,
"value": 250.0
},
{
"t": 324000,
"value": 257.0
},
{
"t": 345600,
"value": 250.0
},
{
"t": 367200,
"value": 257.0
},
{
"t": 388800,
"value": 250.0
},
{
"t": 410400,
"value": 257.0
},
{
"t": 432000,
"value": 250.0
}
],
"chw_valve_max": 55.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 345300,
"equals": false
},
{
"output": "yFault",
"from_s": 345600,
"to_s": 432000,
"equals": true
}
]
},
{
"name": "valve_drops_mid_run",
"description": "Coil demand falls away 12 h into the run: the valve dwell starts at 43200 s, so the alarm lands at exactly 43200 + 259200 + 86400 = 388800 s",
"inputs": {
"dp_sp": 250.0,
"chw_valve_max": [
{
"t": 0,
"value": 95.0
},
{
"t": 43200,
"value": 55.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 388500,
"equals": false
},
{
"output": "yFault",
"from_s": 388800,
"to_s": 432000,
"equals": true
}
]
},
{
"name": "valve_opens_resets",
"description": "Low valve demand for 2.3 days, then a coil starves before the window completes; dwell resets, never alarms",
"inputs": {
"dp_sp": 250.0,
"chw_valve_max": [
{
"t": 0,
"value": 55.0
},
{
"t": 200000,
"value": 95.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 432000,
"equals": false
}
]
}
]
}