Skip to content

Pump / hydronic drift detection

The chiller drift family (CHILLER-DRIFT.md) asks "is this machine slowly getting worse than it used to be, at matched load?" The pump / hydronic family asks the same question of the distribution side — pumps and the loops they serve — reusing the exact same load-normalized frozen-baseline engine (camber.chillerbaseline, camber.chillerdrift), only with a duty normalizer (pump speed or flow) in place of thermal tons.

flowchart TD
    base["load-normalized baseline (camber.chillerbaseline, duty = speed/flow)"] --> det
    subgraph det["per-detector drift + CUSUM (camber.chillerdrift)"]
        d1[PumpFlowDrift]
        d2[PumpHeadDrift]
        d3[LoopDeltaTDrift]
        d4[LoopDPDrift]
        d5[PumpPowerDrift]
    end
    det --> loop["diagnose_pump_drift (per-loop locus, flow-vs-head)"]
    loop --> plant["diagnose_pump_plant (per-plant verdict)"]
    plant --> report["site report / export"]
    sim["camber.pumpsim (affinity laws)"] -. validates .-> loop

Duty-normalized baselines feed five per-detector drifts; co-movement rolls up per-loop, then per-plant, with pumpsim as the physics check.

Like the chiller detectors, each is a period rule (Registry.run_periods), freezes a load-normalized baseline into a BaselineStore on first use, reports a period statistic and a sustained-shift CUSUM alarm, labels its thresholds screening-grade / provisional-untuned (camber.driftthresholds), and declines loudly (never reads healthy) when an instrumented point is missing. They are loop-parameterized — one class serves a chilled-water or hot-water loop; the equip identifies which pump.

The detector family

Detector Signal Normalizer Sided Catches
PumpFlowDrift flow pump speed (Q ∝ N) down pump wear (impeller/wear-ring), clogged strainer, cavitation, entrained air — a flow deficit at matched speed
PumpHeadDrift differential head pump speed (H ∝ N²) down pump wear directly — a head deficit at matched speed; with flow, disambiguates pump-wear from system-resistance
LoopDeltaTDrift loop ΔT (return − supply) flow (or speed proxy) both low-ΔT syndrome (collapse: overpumping, fouled coils, stuck valves, bypass) vs underflow/starvation (widen)
LoopDPDrift loop differential pressure flow (system curve DP ∝ Q²) both rising system resistance / valve-authority loss vs bypass / stuck-open — with the DP-reset schedule subtracted out
PumpPowerDrift electrical power flow (P ∝ Q³) up wire-to-water efficiency loss (bearing/seal drag, degrading motor/drive, recirculation) — a power excess at matched flow

Flow-at-matched-speed is the pump-wear signal. A healthy VFD pump delivers a repeatable flow at a given speed; a deficit means the pump (or its suction) has degraded. It is one-sided down — only a deficit is a fault — so ApproachDriftMonitor gained a direction="down" mode for it (alongside the existing up and both).

The confound is stated, not hidden. A flow deficit at matched speed is ambiguous between pump wear and a rise in system resistance (a throttled or stuck-closed valve downstream). Load normalization doesn't remove it, so when a loop differential-pressure point is mapped the rule reports the concurrent DP shift and caveats a deficit that co-moves with rising DP — the resistance signature, not pump wear. (The head-drift detector and the loop diagnosis, coming next in the family, resolve it further: flow↓ and head↓ → the pump; flow↓ with head steady → the distribution.)

One per-loop verdict

The four detectors fail independently (a worn impeller, a throttled valve, a fouled coil, and a lost DP setpoint are different faults) but corroborate when a problem is loop-wide. camber.pumpdrift.diagnose_pump_drift(findings) reads them and returns one localized PumpDriftDiagnosis — naming each cause, flagging corroboration when two or more agree, and running the flow-vs-head disambiguation that no single signal can do:

  • flow deficit and head deficit at matched speed → the pump itself (impeller / wear-ring / cavitation), corroborated;
  • flow deficit with head steady → the distribution (a throttled / stuck-closed valve downstream), not pump wear — look at the loop, not the impeller;
  • flow deficit with no head point → called ambiguous rather than asserted.

It splits the loop into a mechanical (pump) and a hydraulic (distribution) side and reports a locus (steady · pump · distribution · loop-wide) with a loop_wide flag — the pump-side analog of condenserdrift's tower-disambiguates-head-pressure check. Screening-grade; pure over Findings.

One per-plant verdict

Real plants run several pumps. camber.pumpplantdiag.diagnose_pump_plant(diagnoses) rolls the per-loop PumpDriftDiagnosis objects into one plant verdict with the cross-pump reasoning: exactly one pump drifting → single-pump (stage the spare, schedule that impeller); two or more loops on the distribution side → a shared/central hydraulic cause (plant-wide low-ΔT, a decoupler bypass, a control problem) is more likely than several independent pumps; two or more pumps otherwise → plant-wide (a common-mode cause — suction, water chemistry, a shared drive). It reports a plant locus (steady · single-pump · distribution · plant-wide) and a plain-language recommendation.

Surfacing the verdict

The per-loop and per-plant verdicts flow downstream like the chiller ones: camber.integrate.export.pump_diagnoses_to_frame / export_pump_diagnoses write one row per loop (locus · severity · loop_wide · corroborated · causes · fingerprint) to CSV/JSON/Parquet, and camber.report.pump_diagnosis_table renders a worst-first HTML table. build_site_report(..., pump_diagnoses=[...]) splices that table into the owner-facing site report, alongside the chiller verdict table.

Running the family

From a config, no Python needed — add a drift section naming this family ("family": "pump") and run camber drift freeze once to establish the references, then camber drift run to score. camber run folds the verdicts into the ordinary audit report. Only freeze (and the attributed accept_new_normal) ever writes a baseline; scoring is read-only. See CLI.md.

camber drift freeze config.json      # establish the references (refuses to overwrite)
camber drift run    config.json      # score current vs baseline, worst-first
camber drift accept config.json --equip <EQ> --by <NAME> --reason "<what changed>"

Calibration

Thresholds are constructor arguments (screening-grade); the CUSUM parameters are provisional-untuned. As with the chiller family, camber.driftvalidation (LabeledCase / evaluate / sweep) tunes them once labelled pump-fault periods exist, and the physics generator camber.pumpsim (affinity laws + system curve) characterizes the family end-to-end without a dataset — on clear faults the loop diagnosis localizes to the right locus at ~100% with no false alarms on healthy loops or a DP-reset schedule, and it proves the flow-vs-head disambiguation (impeller wear → pump, clogged strainer → distribution).