Skip to content

VAV / zone-terminal drift detection

The chiller (CHILLER-DRIFT.md), pump (PUMP-DRIFT.md), and AHU (AHU-DRIFT.md) families watch the plant and the air handler. The VAV family asks the same "is this slowly getting worse than it used to be, at matched load?" question of the zone terminal — the VAV box's damper, airflow tracking, and reheat coil — reusing the exact same load-normalized frozen-baseline engine (camber.chillerbaseline, camber.chillerdrift), with the box's own commanded airflow as the load normalizer.

flowchart TD
    base["load-normalized baseline (camber.chillerbaseline, load = commanded airflow)"] --> det
    subgraph det["per-detector drift + CUSUM (camber.chillerdrift)"]
        d1["VavAirflowDrift (damper creep)"]
        d2["VavReheatValveDrift (reheat valve creep)"]
    end
    det --> box["diagnose_vav_drift (per-box locus, upstream-vs-box)"]
    box --> report["site report / export"]
    sim["camber.vavsim (LocusConfusion)"] -. validates .-> box

Command-normalized baselines feed the damper- and reheat-valve creep detectors; co-movement rolls up per-box, with vavsim as the physics check.

Like the other families, each detector 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, and declines loudly (never reads healthy) when an instrumented point is missing. They are the leading indicators to the existing instantaneous zone rules (airflow_tracking, reheat_penalty, reheat_minimization_g36, overcooling_min_flow, unmet_setpoint_hours) the way the chiller drift rules lead the static approach check.

The detector family

Detector Signal Normalizer Sided Catches
VavAirflowDrift damper position commanded airflow (cfm) up flow-authority loss — a slipping/worn actuator, a stuck linkage, or rising upstream duct-static starvation: the damper creeps open to hold the same commanded flow, weeks before airflow_tracking sees an undershoot
VavReheatValveDrift reheat valve position reheat duty ≈ airflow × ΔT (cfm·°F) up reheat-coil heat-transfer loss — waterside fouling/scale, low HW flow/ΔT, air bypass, or valve-authority loss: the reheat valve creeps open to deliver the same reheat, weeks before the box misses setpoint and reheat_penalty / reheat_minimization_g36 see it

Airflow tracking is the leading indicator to airflow_tracking. A healthy box drives its damper to make measured airflow track the commanded airflow setpoint. As the actuator/linkage wears or the box is starved of upstream static, it spends its reserve damper authority — the damper creeps further open while flow still tracks — so the instantaneous airflow_tracking undershoot check sees nothing until the damper saturates near 100%. VavAirflowDrift freezes a damper ~ f(commanded airflow) baseline and scores the current period's damper residual at matched command: a creep is authority loss. It is one-sided up (needing less damper is an authority gain, not a fault), and it is the terminal-box analog of the coil-valve-creep → SAT-control relationship.

The airflow-setpoint confound is neutralized by construction. A VAV setpoint moves constantly (Guideline-36 dual-max, zone demand, reset) — but the setpoint is the load axis here, so normal setpoint motion just walks the box along the same frozen curve and scores ~0 residual. (Unlike duct_static_drift, where the confounder is in the metric's own unit and a residual subtraction is right, here the confounder is the x-axis and the matched-command geometry does the work.)

Upstream starvation is surfaced, not blamed. A damper can creep because its own actuator is failing or because upstream duct static is low (an AHU/fan problem). When a building-level DUCT_STATIC point is mapped (via the runner's shared channel), the rule reports the concurrent static shift and caveats a creep that co-moves with a static fall (vav_upstream_starvation_suspected), so it never silently blames the box for a plant problem. Reuses only existing roles (DAMPER / AIRFLOW_SP; load_role=AIRFLOW is a constructor option for boxes without a mapped setpoint). Freezes under model kind vav_damper.

Reheat-valve creep is the leading indicator to reheat_penalty / reheat_minimization_g36. A box's hot-water reheat coil losing heat-transfer capacity (waterside fouling/scale, low HW flow or ΔT, air bypass, valve-authority loss) opens its valve further to deliver the same reheat — weeks before it runs out of valve and misses setpoint. VavReheatValveDrift freezes a valve ~ f(reheat duty) baseline and scores the current period's valve residual at matched duty, one-sided up (a valve fall is a capacity gain, not a fault). The load is the reheat duty ≈ airflow × ΔT, not ΔT alone: unlike an AHU coil at fixed design airflow, a VAV box's airflow varies (Q ∝ airflow × ΔT), and — contrary to the tempting "G36 pins reheat at min flow" simplification — flow rises toward heating-max in the high heating loop (exactly the regime reheat_minimization_g36 flags), so duty is correct across both regimes while ΔT-alone is confounded. The box's entering primary air is mapped to MIXED_AIR_TEMP (the coil-valve heating convention: warm = SUPPLY_AIR_TEMP discharge, cool = MIXED_AIR_TEMP entering, ΔT = warm − cool) since the box's own discharge already owns SUPPLY_AIR_TEMP; no new role. A colder HW-supply reset is caveated (it needs more valve for the same reheat), not subtracted. load_basis="deltat" is a constructor option for boxes without a mapped flow. Freezes under model kind vav_reheat_valve.

One per-box verdict

The two detectors fail independently (a failing damper actuator and a fouling reheat coil are different faults) but corroborate when a box is broadly degrading. camber.vavdrift.diagnose_vav_drift(findings) reads them and returns one localized VavDriftDiagnosis — naming each cause, flagging corroboration when both agree, and running the upstream-vs-box disambiguation that no single signal can settle (the terminal-box twin of diagnose_ahu_drift's fan-power disambiguation):

  • damper creep with vav_upstream_starvation_suspected (the creep co-moves with an upstream duct-static fall) → the drift is a plant symptom — the AHU can't hold static, so the box damper creeps open; locus upstream, "fix the plant, not the box";
  • damper creep without it → the box's own flow-authority loss; locus airflow;
  • reheat-valve creep → reheat coil fouling / HW starvation / valve-authority loss; locus reheat (a co-moving HW-supply fall is caveated as a possible waterside-reset effect).

It splits the box into an airflow (damper authority) and a reheat (coil) subsystem, reports a locus (steady · airflow · reheat · upstream · box-wide) with a box_wide flag. An upstream verdict is a plant symptom and is deliberately excluded from box_wide — an AHU static problem must not read as a broadly-failing box, so upstream + reheat resolves to locus reheat (with an upstream caveat), and only airflow + reheat (two real box faults) is box-wide. Screening-grade; pure over Findings.

Surfacing the verdict. The per-box verdicts flow downstream like the chiller, condenser, evaporator, pump, and AHU ones: camber.integrate.export.vav_diagnoses_to_frame / export_vav_diagnoses write one row per box (locus · severity · box_wide · corroborated · causes · caveat count · fingerprint) to CSV/JSON/Parquet, and camber.report.vav_diagnosis_table renders a worst-first HTML table. build_site_report(..., vav_diagnoses=[...]) splices that table into the owner-facing site report, alongside the plant, pump, and AHU verdict tables.

Running the family

From a config, no Python needed — add a drift section naming this family ("family": "vav") 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 other families, camber.driftvalidation tunes them once labelled VAV-fault periods exist, and the physics generator camber.vavsim characterizes the family end-to-end without a dataset. Because the diagnosis returns a locus, it scores a LocusConfusion over the five loci (steady · airflow · reheat · upstream · box-wide) — like ahusim/pumpsim, not the CauseConfusion of the condenser/evaporator sims.

The generator models a single two-regime diurnal box: occupied-daytime cooling (a swept command + modulating damper + closed reheat) feeds VavAirflowDrift, and night/morning heating (minimum airflow + a modulating reheat valve) feeds VavReheatValveDrift — each detector's gating carves out its own regime. The upstream-vs-box disambiguation is directly measured: damper_authority_loss → airflow and upstream_starvation → upstream inject the same damper creep, and only the latter also drops the upstream DUCT_STATIC (tripping vav_upstream_starvation_suspected); box_wide → box-wide. On clear faults the diagnosis localizes at ~100% with no false alarms on healthy boxes.

One honest asymmetry: the airflow detector cleanly re-routes an upstream cause to a distinct locus, but the reheat detector only caveats a hot-water-reset creep (its HW confound is a caveat, not a locus demotion). So the generator carries a mild hw_reset as a steady negative, and the fire-with-caveat behavior of a strong HW reset is covered by a dedicated test.

from camber.vavsim import make_cases, locus_confusion

lc = locus_confusion(make_cases(), min_severity=3)
print(lc.accuracy, lc.as_dict()["matrix"])