Skip to content

AHU / air-side drift detection

The chiller (CHILLER-DRIFT.md) and pump (PUMP-DRIFT.md) families watch the refrigerant and hydronic sides. The AHU family asks the same "is this slowly getting worse than it used to be, at matched load?" question of the air side — supply fans, coils, filters, and duct-static control — reusing the exact same load-normalized frozen-baseline engine (camber.chillerbaseline, camber.chillerdrift), with an air-side duty normalizer (airflow) in place of thermal tons.

flowchart TD
    base["load-normalized baseline (camber.chillerbaseline, duty = airflow)"] --> det
    subgraph det["per-detector drift + CUSUM (camber.chillerdrift)"]
        d1[FanEfficiencyDrift]
        d2[FilterLoadingDrift]
        d3[DuctStaticControlDrift]
        d4[CoilValveDrift]
        d5[EconomizerDamperDrift]
    end
    det --> ahu["diagnose_ahu_drift (per-AHU locus, fan-power disambig)"]
    ahu --> report["site report / export"]
    sim["camber.ahusim (fan laws)"] -. validates .-> ahu

Airflow-normalized baselines feed five per-detector drifts; co-movement rolls up into one per-AHU locus, with ahusim 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 complement the existing static air-side rules (economizer_lockout, satreset, staticreset, airflow) the way the chiller drift rules complement the static approach check.

The detector family

Detector Signal Normalizer Sided Catches
FanEfficiencyDrift supply-fan power airflow (cfm) up wire-to-air efficiency loss — a slipping/worn belt, bearing drag, a degrading motor/VFD, or the fan pushed off its curve; a power excess at matched airflow
FilterLoadingDrift filter differential pressure airflow (cfm) up filter loading (dirty filter) — a DP rise at matched airflow; a fall is a filter change
DuctStaticControlDrift duct static pressure airflow (cfm) both fall = fan cannot hold setpoint (degradation/leakage) vs rise = over-pressurization (sensor-low/stuck damper) — with the static-reset schedule subtracted out
CoilValveDrift cool/heat valve position delivered air-ΔT (MAT↔SAT) up coil fouling / waterside starvation / air bypass / valve-authority loss — valve creep before SAT control fails (econ-gated; waterside-reset caveated)
EconomizerDamperDrift outdoor-air fraction (temp-inferred) OA-damper command (%) both up = damper leaking / stuck-open (excess OA) vs down = damper stuck/slipping closed (lost free cooling / under-ventilation) — degenerate-mixing gated, MAT-stratification caveated

Fan efficiency is the air-side energy signal. A healthy fan draws a repeatable power at a given airflow; more power at matched airflow is efficiency loss. It is one-sided up and reuses the generic Role.POWER on the AHU equip-frame (the equip identifies the fan) with Role.AIRFLOW as the normalizer — the air-side twin of PumpPowerDrift. Its confound is stated: fan power also rises when the duct-static setpoint is raised (the fan works harder to hold a higher static), so when a duct-static point is mapped the rule reports the concurrent static shift and caveats a power excess that co-moves with rising static.

The economizer detector watches OA delivery, not OA logic. A healthy OA damper delivers a repeatable outdoor-air fraction for a given command; EconomizerDamperDrift freezes an OAF ~ f(command) baseline — where OAF = 100·(RAT−MAT)/(RAT−OAT) (camber.oafraction) — and scores the current period's OA-fraction residual at matched command, so mechanical drift (linkage slipping, seals leaking, the blade sticking, minimum-position creep) shows up as delivery moving while the command stays put. It is two-sided: more OA than baseline = a leaking / stuck-open damper (excess outdoor air), less OA = a stuck or slipping-closed damper (lost free cooling, possible under-ventilation). It is not a sequence check — an economizer commanded wrong for the conditions is the job of economizer_lockout_rule and freecoolingmissed_rule. Two confounds are handled: the mixed-air sensor stratifies badly and sits in the numerator, so a standing caveat (Sellers, Relative Accuracy) flags that and the magnitude floor is set high above it; and where outdoor and return air are too close (|RAT−OAT| small) the ratio is ill-conditioned, so those rows are excluded before the fit. Reuses OAT / RETURN_AIR_TEMP / MIXED_AIR_TEMP / OA_DAMPER; no new role. diagnose_ahu_drift consumes it as the fifth outdoor-air locus (see below).

One per-AHU verdict

The five detectors fail independently (a slipping belt, a dirty filter, a lost static setpoint, a fouled coil, and a drifting OA damper are different faults) but corroborate when a problem is AHU-wide. camber.ahudrift.diagnose_ahu_drift(findings) reads them and returns one localized AhuDriftDiagnosis — naming each cause, flagging corroboration when two or more agree, and running the fan-power disambiguation that no single signal can do (the air-side twin of pumpdrift's flow-vs-head check):

  • fan-power excess with a loading filter or a rising duct static → the air path (fix the filter / check the ductwork first; the fan power is corroborating, not a separate fan fault);
  • fan-power excess with the duct static falling below setpoint → fan degradation;
  • fan-power excess with a clean filter and steady static → the fan itself;
  • fan-power excess with no filter or static point → called ambiguous rather than asserted.

It splits the AHU into fan (mechanical) / air-path (filter + static) / coil / outdoor-air (economizer OA mixing) sides, reports a locus (steady · fan · air-path · coil · outdoor-air · ahu-wide) with an ahu_wide flag, and names a cooling and a heating coil separately. The economizer is an independent side (like a coil): it corroborates and can make the verdict AHU-wide, but it is deliberately outside the fan-power disambiguation, because its signal is outdoor-air fraction, not fan power. Screening-grade; pure over Findings. (The outdoor-air locus is exercised end-to-end by ahusim's confusion matrix via an OA/RA mixing regime — see Calibration.)

Surfacing the verdict

The per-AHU verdicts flow downstream like the chiller and pump ones: camber.integrate.export.ahu_diagnoses_to_frame / export_ahu_diagnoses write one row per AHU (locus · severity · ahu_wide · corroborated · causes · fingerprint) to CSV/JSON/Parquet, and camber.report.ahu_diagnosis_table renders a worst-first HTML table. build_site_report(..., ahu_diagnoses=[...]) splices that table into the owner-facing site report, alongside the chiller and pump verdict tables.

Running the family

From a config, no Python needed — add a drift section naming this family ("family": "ahu") 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 AHU-fault periods exist, and the physics generator camber.ahusim (system curve ΔP ∝ Q² + fan laws) characterizes the family end-to-end without a dataset — on clear faults the AHU diagnosis localizes to the right locus at ~100% with no false alarms on healthy AHUs or a static-reset schedule, and it proves the fan-power disambiguation (filter_loading → air-path vs fan_belt_slip → fan). All five loci are exercised, including outdoor-air: the generator models a genuine OA/RA mixing box (MAT a real mix of a swept OA-damper command) with two economizer faults — a leaking / stuck-open damper (over-delivery) and a stuck / slipping-closed one (under-delivery). To keep the coil signal invariant under the mix, SUPPLY_AIR_TEMP is derived as MAT − dt, so the cooling-coil air-ΔT stays dt by construction while MAT floats — an economizer fault localizes to outdoor-air alone and leaves the other four families untouched.

from camber.ahusim import make_cases, locus_confusion

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