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

CHW-0003 — CHW loop DP reset not functioning

Statusverified — engine e2ff2f8, cxf:fnv1a128:89ddf1254d82b6820f9b536b32c1c5d9, 2026-08-17
Severity3
Methodstatistical
Phase2
CategoryEXCESS_CONSUMPTION
ConfidenceHIGH
EstimationPROXY_ESTIMATION
G36
Clusters
Suppresses
Suppressed by
RelatedCHW-0002, CHW-0004, AHU-0024
Playbooksmissing-reset
SourceHVAC FDD Reference v1.0 ch.13, CHW-0003 (pdf pp. 121-122); PNNL RetuningOpps C03; PNNL-25985 EEM-10
Operating statesCHW 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:

NameDefaultUnitCXF pathDescription
evaluation_window259200.0sspRef.samplePeriod, spFlatHeld.delayTime, vlvLowHeld.delayTimeWindow over which setpoint flatness and low valve demand are assessed (3 days)
sp_flat_tolerance7.5kPaspFlat.tMax deviation of dp_sp from its sampled baseline to count as flat (half the reference’s 15 kPa min_expected_sp_range)
high_valve_threshold90.0%vlvLow.tPosition 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_delay86400.0spersist.delayTimeFault 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):

CHW-0003 block graph

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

  1. DP reset never programmed
  2. DP reset disabled or overridden
  3. Valve position feedback not connected
  4. 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-derived chw_valve_max (scalar). Library v1 has no vector boundary points, so the host aggregates and feeds one scalar (derived: true in the point dictionary). AHU-0024’s zone_dmpr_pos_max is the precedent, and the cost is in preconditions: 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.MovingAverage rejected, and the tick band that follows. Its fixed 64-checkpoint ring needs dt ≥ 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_threshold is adopted, not transcribed. The chapter names it in the equation but its tunables line lists only evaluation_window, min_expected_sp_range and AlarmDelay. 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 carrying chw_valve_max < high_valve_threshold would only echo one boundary input through a threshold, so the rule ships yFault alone and the staleness question stays in preconditions.
  • AlarmDelay = 24 h implemented as TrueDelay on the fault conjunction; the evaluation window itself is enforced by the two dwells. delayOnInit = true on every TrueDelay (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”; name carries the shorter index spelling from faults/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.

ScenarioDescription
reset_workingDP_SP swings 100 kPa with load; flatness dwell resets every 6 h, no fault
fixed_sp_low_valveDP_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_thresholdMost-open valve exactly at high_valve_threshold (90%) is not low demand (strict <), so a flat setpoint is not a fault
valve_just_below_thresholdMost-open valve at 89.9% is below the threshold; the same flat setpoint alarms at 345600 s
sp_at_flat_toleranceSetpoint deviates from its baseline by exactly sp_flat_tolerance (7.5 kPa): strict < makes that not flat, so no fault
sp_within_flat_toleranceSetpoint jitter of 7 kPa stays inside sp_flat_tolerance, so it still counts as flat; fault at 345600 s
valve_drops_mid_runCoil 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_resetsLow 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
        }
      ]
    }
  ]
}