Pandas FDD Cookbook

Oracle / documentation catalog: packaged as open_fdd.rules on PyPI (pip install "open-fdd[oracle]") — 62 executable diagnostics. Source of truth: open_fdd/rules/cookbook_catalog.py. Consumers pin the wheel rather than maintaining a second copy.

This cookbook is intentionally maintained. Production Open-FDD FDD math runs Rust + Apache Arrow + DataFusion SQL (sql_rules/registry.yaml, 66 entries = 62 twins + 4 SQL analytics). Use this pandas catalog for notebooks, CSV exports, RCx studies, and SQL↔pandas parity testing.

See also the DataFusion SQL cookbook, parity matrix, generated parity report, and P0 rule catalog.

Updated: 2026-08-15 · PyPI open-fdd 4.4.1 (open_fdd.rules)


Table of contents

  1. Setup & shared helpers
  2. Fault confirmation delay (default 5 minutes)
  3. Sensor validation (sweep)
  4. Control-loop hunting
  5. Air handling units
  6. VAV terminals
  7. Central plant / condenser water
  8. Heat pumps
  9. Weather station
  10. Trim & respond advisory
  11. Schedule & occupancy
  12. Not yet in validated catalog
  13. Framework docs

Setup & shared helpers

import pandas as pd
from open_fdd.rules import RULES, RULES_BY_ID, run_rule
from open_fdd.rules.cookbook_catalog import CookbookRule  # catalog types
from open_fdd.rules.base import confirm_fault, hours_true
import numpy as np

df = pd.read_csv("your_building_data.csv")
ts_col = "timestamp"
if ts_col in df.columns:
    df[ts_col] = pd.to_datetime(df[ts_col], utc=True, errors="coerce")
    df = df.dropna(subset=[ts_col]).sort_values(ts_col)
d = df.copy()
POLL_SECONDS = 60

Normalize command 0–100 → 0–1

def norm_cmd(s: pd.Series | None) -> pd.Series:
    if s is None:
        return pd.Series(dtype=float)
    s = pd.to_numeric(s, errors="coerce")
    return s.where(s <= 1.0, s / 100.0)

Apply fault mask helper

def apply_fault(d: pd.DataFrame, mask: pd.Series, col: str = "fault_raw") -> pd.DataFrame:
    d = d.copy()
    d[col] = mask.fillna(False)
    return d

Prerequisite macros

Operational gates (fan proven-on, occupancy, override suppression) wrap many rules in the validated catalog. See prerequisite macros.

def confirm_fault(raw: pd.Series, min_rows: int) -> pd.Series:
    groups = (raw != raw.shift()).cumsum()
    streak = raw.groupby(groups).cumcount() + 1
    return raw & (streak >= min_rows)

Fault confirmation delay (default 5 minutes)

Every validated rule carries a default confirm_seconds (usually 300). At 60 s poll, that is ~5 consecutive True samples — matching SQL confirmation_seconds: 300.


Sensor validation (sweep)

Sensor-sweep rules apply to every modeled sensor present on the equipment (not a single fixed point).

SV-RANGE — Sensor out of hard range

Family: sensor · Equipment: ahu, vav, chiller, boiler, weather, zone, heatpump
Mode: sensor sweep (applies to every modeled sensor present)
Equation: Any modeled sensor reads outside its physical hard range (e.g. OAT −60–130°F, SAT 30–150°F, CHWS 30–80°F).
Default confirmation: 300 s

Tunable params

Param Label Unit Default Range
range_scale_temperature Temp range scale x 1.0 0.5–2.0
range_scale_humidity Humidity range scale x 1.0 0.5–2.0
range_scale_pressure Pressure range scale x 1.0 0.5–2.0
FAULT_CONFIRM_SECONDS = 300

def _sweep_range(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    idx = d.index
    mask = _false(idx)
    per_role: dict[str, pd.Series] = {}
    for role in SWEEP_SENSOR_ROLES:
        if role not in d.columns:
            continue
        s = pd.to_numeric(d[role], errors="coerce")
        lim = SENSOR_LIMITS[role]
        type_scale = _role_type_scale(p, role, kind="range")
        # Widen limits when scale > 1 (more forgiving); shrink when scale < 1.
        mid = (lim["lo"] + lim["hi"]) / 2.0
        half = (lim["hi"] - lim["lo"]) / 2.0 * max(type_scale, 1e-6)
        lo, hi = mid - half, mid + half
        role_mask = s.notna() & ((s < lo) | (s > hi))
        per_role[role] = role_mask
        mask = mask | role_mask
    _stash_sweep_evidence(d, per_role, poll=poll, rule_tag="SV-RANGE")
    return mask

d = apply_fault(d, _sweep_range(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

SV-FLATLINE — Sensor flatline (stuck)

Family: sensor · Equipment: ahu, vav, chiller, boiler, weather, zone, heatpump
Mode: sensor sweep (applies to every modeled sensor present)
Equation: Sensor value unchanged (Δ ≤ tolerance) across the flatline window — stuck / frozen sensor.
Default confirmation: 300 s

Tunable params

Param Label Unit Default Range
flatline_tol Flatline tolerance °F 0.1 0.02–1.0
flatline_hours Flatline window h 1.0 0.5–8.0
FAULT_CONFIRM_SECONDS = 300

def _sweep_flatline(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    idx = d.index
    tol = _f(p, "flatline_tol", 0.10)
    hours = _f(p, "flatline_hours", 1.0)
    window = max(2, int(round(hours * 3600 / max(poll, 1))))
    mask = _false(idx)
    per_role: dict[str, pd.Series] = {}
    for role in FLATLINE_SENSOR_ROLES:
        if role not in d.columns:
            continue
        s = pd.to_numeric(d[role], errors="coerce")
        role_mask = flatline_mask(s, tol=tol, window=window)
        per_role[role] = role_mask
        mask = mask | role_mask
    _stash_sweep_evidence(d, per_role, poll=poll, rule_tag="SV-FLATLINE")
    return mask

d = apply_fault(d, _sweep_flatline(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

SV-SPIKE — Sensor rate-of-change spike

Family: sensor · Equipment: ahu, vav, chiller, boiler, weather, zone, heatpump
Mode: sensor sweep (applies to every modeled sensor present)
Equation: Sample-to-sample jump exceeds the physical spike limit for the sensor type.
Default confirmation: 300 s

Tunable params

Param Label Unit Default Range
spike_scale Spike limit scale (global) x 1.0 0.25–3.0
spike_scale_temperature Temp spike scale x 1.0 0.25–3.0
spike_scale_humidity Humidity spike scale x 1.0 0.25–3.0
spike_scale_pressure Pressure spike scale x 1.0 0.25–3.0
FAULT_CONFIRM_SECONDS = 300

def _sweep_spike(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    idx = d.index
    scale = _f(p, "spike_scale", 1.0)
    mask = _false(idx)
    per_role: dict[str, pd.Series] = {}
    for role in SWEEP_SENSOR_ROLES:
        if role not in d.columns:
            continue
        s = pd.to_numeric(d[role], errors="coerce")
        type_scale = _role_type_scale(p, role, kind="spike")
        limit = SENSOR_LIMITS[role]["spike"] * scale * type_scale
        role_mask = s.notna() & (s.diff().abs() > limit)
        per_role[role] = role_mask
        mask = mask | role_mask
    _stash_sweep_evidence(d, per_role, poll=poll, rule_tag="SV-SPIKE")
    return mask

d = apply_fault(d, _sweep_spike(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

SV-STALE — Stale data (no fresh samples)

Family: sensor · Equipment: ahu, vav, chiller, boiler, weather, zone, heatpump
Mode: sensor sweep (applies to every modeled sensor present)
Equation: All modeled sensors unchanged over the stale window — data feed likely dropped.
Default confirmation: 300 s

Tunable params

Param Label Unit Default Range
stale_hours Stale window h 2.0 0.5–12.0
FAULT_CONFIRM_SECONDS = 300

def _sweep_stale(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    """Flag runs where all sweep sensors are unchanged (no fresh data)."""
    idx = d.index
    hours = _f(p, "stale_hours", 2.0)
    window = max(2, int(round(hours * 3600 / max(poll, 1))))
    present = [r for r in FLATLINE_SENSOR_ROLES if r in d.columns]
    if not present:
        _stash_sweep_evidence(d, {}, poll=poll, rule_tag="SV-STALE")
        return _false(idx)
    stale = pd.Series(True, index=idx)
    per_role: dict[str, pd.Series] = {}
    for role in present:
        s = pd.to_numeric(d[role], errors="coerce")
        role_flat = flatline_mask(s, tol=1e-9, window=window)
        per_role[role] = role_flat  # each role's stuck mask; equipment fault is AND
        stale = stale & role_flat
    # For stale, the firing evidence is the AND mask applied to each present role
    and_masks = {role: stale.copy() for role in present}
    _stash_sweep_evidence(d, and_masks, poll=poll, rule_tag="SV-STALE")
    return stale

d = apply_fault(d, _sweep_stale(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

SV-RATE — Context-aware sensor rate of change

Family: sensor · Equipment: ahu, vav, chiller, boiler, weather, zone, heatpump
Mode: sensor sweep (applies to every modeled sensor present)
Equation: Implausible sustained rate-of-change for mapped sensors. Thresholds depend on quantity, location, and operating state (steady vs startup/shutdown transient). Engineering screening defaults — tune per site. Alias: SV-SLEW. Distinct from SV-SPIKE (one-sample jump), SV-RANGE, SV-FLATLINE, and PID-HUNT-1.
Default confirmation: 600 s

Tunable params

Param Label Unit Default Range
persistence_min Fault persistence min 10.0 5.0–60.0
transition_window_min Transition window min 20.0 5.0–60.0
max_gap_hours Max sample gap h 2.0 0.25–6.0
design_flow Design flow (flow profiles) cfm 0.0 0.0–100000.0
sensor_span Sensor span (flow/pressure) eng 0.0 0.0–100000.0
FAULT_CONFIRM_SECONDS = 600

def _sv_rate_compute(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    from app.rules.sensor_rate import sv_rate_compute

    return sv_rate_compute(d, p, poll)

d = apply_fault(d, _sv_rate_compute(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

Control-loop hunting

PID-HUNT-1 — Suspected control-output hunting

Family: control · Equipment: ahu, vav, chiller, boiler, heatpump
Mode: control-output sweep (dampers, valves, speeds, heat/cool cmds)
Equation: Rolling 1h total variation of any 0–100% control output (dampers, valves, fan speeds, heat/cool cmds) with span ≥20%, TV ≥500 %·pts, ≥2.5 equivalent cycles, ≥4 reversals — suspected loop hunting (not proof of bad PID alone).
Default confirmation: 0 s (rolling 1h window is its own persistence)

Optional roles: loop-enabled

Tunable params

Param Label Unit Default Range
change_deadband_pct Ignore changes below % out 1.0 0.0–10.0
minimum_span_pct Minimum observed span % out 20.0 5.0–100.0
total_variation_fault_pct Total travel threshold %/h 500.0 50.0–2000.0
minimum_equivalent_cycles Min equivalent cycles cyc/h 2.5 0.5–20.0
minimum_reversals Min direction reversals count 4 1–40
minimum_coverage_pct Minimum data coverage % 80.0 25.0–100.0
FAULT_CONFIRM_SECONDS = 0  # rolling 1h window supplies persistence

def _pid_hunt_1(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    """Suspected control-output hunting across any present 0–100% analog roles."""
    from app.rules.pid_hunting import PidHuntingParams, hunting_fault_mask, iter_control_output_series

    params = PidHuntingParams(
        change_deadband_pct=_f(p, "change_deadband_pct", 1.0),
        minimum_span_pct=_f(p, "minimum_span_pct", 20.0),
        total_variation_fault_pct=_f(p, "total_variation_fault_pct", 500.0),
        minimum_equivalent_cycles=_f(p, "minimum_equivalent_cycles", 2.5),
        minimum_reversals=int(_f(p, "minimum_reversals", 4)),
        minimum_coverage_pct=_f(p, "minimum_coverage_pct", 80.0),
    )
    mask = _false(d.index)
    enable_col = "loop-enabled" if "loop-enabled" in d.columns else None
    for _label, series in iter_control_output_series(d):
        enabled = d[enable_col] if enable_col else None
        fault, _ = hunting_fault_mask(
            series,
            params=params,
            poll_seconds=poll,
            enabled=enabled,
        )
        mask = mask | fault.reindex(d.index).fillna(False)
    return mask

d = apply_fault(d, _pid_hunt_1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

Air handling units

Includes GL36 FC1–FC15, AHU auxiliaries, economizer/ventilation, leakage, and outdoor-air screens.

GL36 shared helpers

The FC rules read canonical GL36 epsilon variables (eps_*) with legacy session-param fallbacks (mix_tol, duct_static_err, …), and suspend faulting for mode_delay_min minutes after an AHU operating-state change:

MIX_TOL = 1.15
SUPPLY_TOL = 1.15
AHU_MIN_OA_DPR = 0.05
DELTA_SUPPLY_FAN = 0.55
FAN_ON_MIN = 0.01

def _f(p: dict, key: str, default: float) -> float:
    try:
        v = p.get(key, default)
        return float(v) if v is not None else float(default)
    except (TypeError, ValueError):
        return float(default)

def _false(index) -> pd.Series:
    return pd.Series(False, index=index)

def _fan(d: pd.DataFrame) -> pd.Series:
    if "fan-cmd" in d.columns:
        return norm_cmd(d["fan-cmd"]).fillna(0)
    if "fan-status" in d.columns:
        return as_bool(d["fan-status"]).astype(float)
    return pd.Series(1.0, index=d.index)

def _gl36_value(p: dict, key: str, legacy_key: str | None, default: float) -> float:
    """Read a canonical GL36 variable, retaining old session-param compatibility."""
    if key in p:
        return float(p[key])
    if legacy_key and legacy_key in p:
        return float(p[legacy_key])
    return float(default)

def _gl36_mode_stable(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    """False during GL36 ModeDelay after an AHU operating-state change."""
    delay_min = _f(p, "mode_delay_min", 0.0)
    if delay_min <= 0 or d.empty:
        return pd.Series(True, index=d.index)
    htg = norm_cmd(d["heating-valve"]).fillna(0) if "heating-valve" in d else pd.Series(0.0, index=d.index)
    clg = norm_cmd(d["cooling-valve"]).fillna(0) if "cooling-valve" in d else pd.Series(0.0, index=d.index)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0) if "outside-air-damper" in d else pd.Series(0.0, index=d.index)
    fan = _fan(d)
    fan_on = _gl36_value(p, "fan_on_min", None, FAN_ON_MIN)
    econ_min = _gl36_value(p, "econ_min_pos", None, AHU_MIN_OA_DPR)
    clg_on = _gl36_value(p, "clg_on_min", None, 0.01)
    htg_on = _gl36_value(p, "htg_on_min", None, 0.01)
    state = pd.Series(0, index=d.index, dtype=int)
    state[(fan > fan_on) & (htg > htg_on) & (clg <= clg_on)] = 1
    state[(fan > fan_on) & (htg <= htg_on) & (clg <= clg_on) & (econ > econ_min)] = 2
    state[(fan > fan_on) & (htg <= htg_on) & (clg > clg_on) & (econ > econ_min)] = 3
    state[(fan > fan_on) & (htg <= htg_on) & (clg > clg_on) & (econ <= econ_min)] = 4
    changed = state.ne(state.shift()).fillna(False)
    if len(changed):
        changed.iloc[0] = False
    samples = max(1, int(np.ceil(delay_min * 60.0 / max(float(poll), 1.0))))
    recent_change = changed.astype(int).rolling(samples, min_periods=1).max().astype(bool)
    return ~recent_change

def _gl36_fault(raw: pd.Series, d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    return raw.fillna(False) & _gl36_mode_stable(d, p, poll)

FC1 — Duct static below SP at full fan (GL36 A)

Family: ahu · Equipment: ahu
Equation: DSP < DSPSP − εDSP AND VFDSPD ≥ 100% − εVFDSPD.
Default confirmation: 300 s

Required roles: duct-static-pressure, duct-static-pressure-sp, fan-cmd

Tunable params

Param Label Unit Default Range
eps_dsp Duct-static error εDSP (GL36 default 0.1 in.w.c.) in. w.c. 0.12 0.0–0.5
eps_vfd_spd VFD speed error εVFDSPD (GL36 default 5%) frac 0.13 0.0–0.5
duct_static_err Legacy duct-static error (sets εDSP) in. w.c. 0.12 0.0–0.5
fan_hi Legacy fan-high threshold (sets εVFDSPD) frac 0.87 0.5–1.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 300

def fc1(d, p, poll):
    err = _gl36_value(p, "eps_dsp", "duct_static_err", 0.12)
    speed_err = _gl36_value(p, "eps_vfd_spd", None, 0.13)
    fan_hi = _gl36_value(p, "fan_hi", None, 1.0 - speed_err)
    fan = _fan(d)
    raw = (
        d["duct-static-pressure"].notna() & d["duct-static-pressure-sp"].notna()
        & (d["duct-static-pressure"] < d["duct-static-pressure-sp"] - err)
        & (fan >= fan_hi)
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC2 — MAT below OAT/RAT envelope (GL36 B)

Family: ahu · Equipment: ahu
Equation: MATavg + εMAT < min(RATavg − εRAT, OATavg − εOAT).
Default confirmation: 600 s

Required roles: mixed-air-temp, outside-air-temp, return-air-temp, fan-cmd

Tunable params

Param Label Unit Default Range
eps_mat MAT sensor error εMAT (GL36 default 5°F) °F 1.15 0.0–10.0
eps_rat RAT sensor error εRAT (GL36 default 2°F) °F 1.15 0.0–10.0
eps_oat OAT sensor error εOAT (GL36 default 2°F local / 5°F global) °F 1.15 0.0–10.0
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
fan_on_min Fan-on command threshold frac 0.01 0.0–0.25
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc2(d, p, poll):
    """MAT below OAT/RAT mixing envelope (GL36 B).

    ``mix_tol`` widens the envelope on both sides:
    ``mat + tol < min(rat - tol, oat - tol)`` ≡ ``mat < min(rat, oat) - 2·tol``.
    Never subtract the same tol from both sides of the inequality (that cancels).
    """
    eps_mat = _gl36_value(p, "eps_mat", "mix_tol", MIX_TOL)
    eps_rat = _gl36_value(p, "eps_rat", "mix_tol", MIX_TOL)
    eps_oat = _gl36_value(p, "eps_oat", "mix_tol", MIX_TOL)
    fan_on = _gl36_value(p, "fan_on_min", None, FAN_ON_MIN)
    fan = _fan(d)
    raw = (
        (fan > fan_on)
        & d["mixed-air-temp"].notna() & d["outside-air-temp"].notna() & d["return-air-temp"].notna()
        & ((d["mixed-air-temp"] + eps_mat) < np.minimum(d["return-air-temp"] - eps_rat, d["outside-air-temp"] - eps_oat))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc2(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC3 — MAT above OAT/RAT envelope (GL36 C)

Family: ahu · Equipment: ahu
Equation: MATavg − εMAT > max(RATavg + εRAT, OATavg + εOAT).
Default confirmation: 600 s

Required roles: mixed-air-temp, outside-air-temp, return-air-temp, fan-cmd

Tunable params

Param Label Unit Default Range
eps_mat MAT sensor error εMAT (GL36 default 5°F) °F 1.15 0.0–10.0
eps_rat RAT sensor error εRAT (GL36 default 2°F) °F 1.15 0.0–10.0
eps_oat OAT sensor error εOAT (GL36 default 2°F local / 5°F global) °F 1.15 0.0–10.0
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
fan_on_min Fan-on command threshold frac 0.01 0.0–0.25
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc3(d, p, poll):
    eps_mat = _gl36_value(p, "eps_mat", "mix_tol", MIX_TOL)
    eps_rat = _gl36_value(p, "eps_rat", "mix_tol", MIX_TOL)
    eps_oat = _gl36_value(p, "eps_oat", "mix_tol", MIX_TOL)
    fan_on = _gl36_value(p, "fan_on_min", None, FAN_ON_MIN)
    fan = _fan(d)
    raw = (
        (fan > fan_on)
        & d["mixed-air-temp"].notna() & d["outside-air-temp"].notna() & d["return-air-temp"].notna()
        & ((d["mixed-air-temp"] - eps_mat) > np.maximum(d["return-air-temp"] + eps_rat, d["outside-air-temp"] + eps_oat))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc3(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC4 — PID hunting (operating-state oscillation)

Family: ahu · Equipment: ahu
Equation: ΔOS > ΔOSmax during the prior 60-minute moving window.
Default confirmation: 3600 s

Required roles: outside-air-damper, cooling-valve, fan-cmd

Tunable params

Param Label Unit Default Range
delta_os_max Max mode changes/hr ΔOSmax (GL36 default 7) count 5 1–30
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 3600

def fc4(d, p, poll):
    """PID hunting — operating-state entry transitions per hour."""
    delta_os_max = _f(p, "delta_os_max", 5.0)
    htg = norm_cmd(d["heating-valve"]).fillna(0) if "heating-valve" in d else pd.Series(0.0, index=d.index)
    clg = norm_cmd(d["cooling-valve"]).fillna(0) if "cooling-valve" in d else pd.Series(0.0, index=d.index)
    fan = _fan(d)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0) if "outside-air-damper" in d else pd.Series(0.0, index=d.index)
    modes = pd.DataFrame(index=d.index)
    modes["heating"] = ((htg > 0) & (clg == 0) & (fan > 0) & (econ <= AHU_MIN_OA_DPR)).astype(int)
    modes["econ_only"] = ((htg == 0) & (clg == 0) & (fan > 0) & (econ > AHU_MIN_OA_DPR)).astype(int)
    modes["econ_mech"] = ((htg == 0) & (clg > 0) & (fan > 0) & (econ > AHU_MIN_OA_DPR)).astype(int)
    modes["mech_only"] = ((htg == 0) & (clg > 0) & (fan > 0) & (econ <= AHU_MIN_OA_DPR)).astype(int)
    # Loader moves timestamp into the DatetimeIndex — prefer index over a column.
    if isinstance(d.index, pd.DatetimeIndex):
        ts = pd.DatetimeIndex(d.index)
    elif "timestamp" in d.columns:
        ts = pd.DatetimeIndex(pd.to_datetime(d["timestamp"], utc=True, errors="coerce"))
    else:
        return _false(d.index)
    entries = (modes.eq(1) & modes.shift().ne(1))
    entries.index = ts
    hourly = entries.resample("1h").sum()
    flagged_hours = hourly[(hourly > delta_os_max).any(axis=1)].index
    floor = ts.floor("1h")
    return pd.Series(floor.isin(flagged_hours), index=d.index)

d = apply_fault(d, fc4(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC5 — SAT cold when heating commanded (GL36 D)

Family: ahu · Equipment: ahu
Equation: SATavg + εSAT ≤ MATavg − εMAT + ΔTSF while heating is commanded.
Default confirmation: 600 s

Required roles: discharge-air-temp, mixed-air-temp, fan-cmd, heating-valve

Tunable params

Param Label Unit Default Range
eps_sat SAT sensor error εSAT (GL36 default 2°F) °F 1.15 0.0–10.0
eps_mat MAT sensor error εMAT (GL36 default 5°F) °F 1.15 0.0–10.0
delta_supply_fan Supply-fan heat rise ΔTSF (GL36 default 2°F) °F 0.55 0.0–5.0
htg_on_min Heating-command ON threshold frac 0.01 0.0–0.25
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
fan_on_min Fan-on command threshold frac 0.01 0.0–0.25
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc5(d, p, poll):
    """SAT cold when heating commanded (GL36 D). ``mix_tol`` applies to both SAT and MAT."""
    eps_sat = _gl36_value(p, "eps_sat", "mix_tol", MIX_TOL)
    eps_mat = _gl36_value(p, "eps_mat", "mix_tol", MIX_TOL)
    delta_tsf = _f(p, "delta_supply_fan", DELTA_SUPPLY_FAN)
    fan_on = _gl36_value(p, "fan_on_min", None, FAN_ON_MIN)
    htg_on = _f(p, "htg_on_min", 0.01)
    fan = _fan(d)
    htg = norm_cmd(d["heating-valve"]).fillna(0)
    raw = (
        d["discharge-air-temp"].notna() & d["mixed-air-temp"].notna()
        & (fan > fan_on) & (htg > htg_on)
        & ((d["discharge-air-temp"] + eps_sat) <= (d["mixed-air-temp"] - eps_mat + delta_tsf))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc5(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC6 — Estimated OA fraction mismatch

Family: ahu · Equipment: ahu
Equation: |RATavg−OATavg| ≥ ΔTmin AND |estimated OA% − design min OA%| > εF.
Default confirmation: 600 s

Required roles: mixed-air-temp, outside-air-temp, return-air-temp, vav-total-airflow

Tunable params

Param Label Unit Default Range
eps_airflow Airflow error εF (GL36 default 30%) frac 0.15 0.05–1.0
delta_t_min Minimum |OAT−RAT| ΔTmin (GL36 default 10°F) °F 5.0 0.0–30.0
airflow_err Legacy OA-fraction error (sets εF) frac 0.15 0.05–1.0
oat_rat_delta_min Legacy OAT/RAT guard (sets ΔTmin) °F 5.0 0.0–30.0
min_cfm_design Design min OA CFM cfm 5000 500–20000
fan_on_min Fan-on command threshold frac 0.01 0.0–0.25
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc6(d, p, poll):
    airflow_err = _gl36_value(p, "eps_airflow", "airflow_err", 0.15)
    oat_rat_min = _gl36_value(p, "delta_t_min", "oat_rat_delta_min", 5.0)
    design_cfm = _f(p, "min_cfm_design", 5000.0)
    fan_on = _gl36_value(p, "fan_on_min", None, FAN_ON_MIN)
    fan = _fan(d)
    rat_minus_oat = (d["return-air-temp"] - d["outside-air-temp"]).abs()
    pct_oa = ((d["mixed-air-temp"] - d["return-air-temp"]) / (d["outside-air-temp"] - d["return-air-temp"]).replace(0, np.nan)).clip(lower=0)
    perc_oamin = design_cfm / d["vav-total-airflow"].replace(0, np.nan)
    oa_err = (pct_oa - perc_oamin).abs()
    raw = (
        d["mixed-air-temp"].notna() & d["outside-air-temp"].notna() & d["return-air-temp"].notna() & d["vav-total-airflow"].notna()
        & (rat_minus_oat >= oat_rat_min) & (oa_err > airflow_err) & (fan > fan_on)
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc6(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC7 — SAT low with full heating (GL36 E)

Family: ahu · Equipment: ahu
Equation: SATavg < SATSP − εSAT AND HC ≥ full-heating threshold.
Default confirmation: 600 s

Required roles: discharge-air-temp, discharge-air-temp-sp, fan-cmd, heating-valve

Tunable params

Param Label Unit Default Range
eps_sat SAT sensor error εSAT (GL36 default 2°F) °F 1.0 0.0–10.0
sat_err Legacy SAT error (sets εSAT) °F 1.0 0.0–10.0
htg_full_min Full-heating threshold (GL36 99%) frac 0.9 0.5–1.0
fan_on_min Fan-on command threshold frac 0.01 0.0–0.25
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc7(d, p, poll):
    sat_err = _gl36_value(p, "eps_sat", "sat_err", 1.0)
    fan_on = _gl36_value(p, "fan_on_min", None, FAN_ON_MIN)
    htg_full = _f(p, "htg_full_min", 0.9)
    fan = _fan(d)
    htg = norm_cmd(d["heating-valve"]).fillna(0)
    raw = (
        d["discharge-air-temp"].notna() & d["discharge-air-temp-sp"].notna()
        & (fan > fan_on) & (d["discharge-air-temp"] < d["discharge-air-temp-sp"] - sat_err) & (htg >= htg_full)
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc7(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC8 — SAT/MAT mismatch in economizer (GL36 F)

Family: ahu · Equipment: ahu
Equation: |SATavg − ΔTSF − MATavg| > √(εSAT² + εMAT²) in OS#2.
Default confirmation: 600 s

Required roles: discharge-air-temp, mixed-air-temp, outside-air-damper, cooling-valve

Tunable params

Param Label Unit Default Range
eps_sat SAT sensor error εSAT (GL36 default 2°F) °F 1.15 0.0–10.0
eps_mat MAT sensor error εMAT (GL36 default 5°F) °F 1.15 0.0–10.0
delta_supply_fan Supply-fan heat rise ΔTSF (GL36 default 2°F) °F 0.55 0.0–5.0
econ_min_pos Economizer minimum-position threshold frac 0.05 0.0–0.5
clg_inactive_max Cooling-command inactive ceiling frac 0.1 0.0–0.5
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
supply_tol Legacy SAT tolerance master (sets εSAT) °F 1.15 0.0–10.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc8(d, p, poll):
    eps_mat = _gl36_value(p, "eps_mat", "mix_tol", MIX_TOL)
    eps_sat = _gl36_value(p, "eps_sat", "supply_tol", SUPPLY_TOL)
    delta_tsf = _f(p, "delta_supply_fan", DELTA_SUPPLY_FAN)
    econ_min = _f(p, "econ_min_pos", AHU_MIN_OA_DPR)
    clg_inactive = _f(p, "clg_inactive_max", 0.1)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    sat_mat_err = (d["discharge-air-temp"] - delta_tsf - d["mixed-air-temp"]).abs()
    sqrt_tol = float(np.sqrt(eps_sat**2 + eps_mat**2))
    raw = (
        d["discharge-air-temp"].notna() & d["mixed-air-temp"].notna()
        & (econ > econ_min) & (clg < clg_inactive) & (sat_mat_err > sqrt_tol)
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc8(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC9 — OAT too warm for free cooling (GL36 G)

Family: ahu · Equipment: ahu
Equation: OATavg − εOAT > SATSP − ΔTSF + εSAT in OS#2.
Default confirmation: 600 s

Required roles: outside-air-temp, discharge-air-temp-sp, outside-air-damper, cooling-valve

Tunable params

Param Label Unit Default Range
eps_oat OAT sensor error εOAT (GL36 default 2°F local / 5°F global) °F 1.15 0.0–10.0
eps_sat SAT sensor error εSAT (GL36 default 2°F) °F 1.15 0.0–10.0
delta_supply_fan Supply-fan heat rise ΔTSF (GL36 default 2°F) °F 0.55 0.0–5.0
econ_min_pos Economizer minimum-position threshold frac 0.05 0.0–0.5
clg_inactive_max Cooling-command inactive ceiling frac 0.1 0.0–0.5
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc9(d, p, poll):
    eps_oat = _gl36_value(p, "eps_oat", "mix_tol", MIX_TOL)
    eps_sat = _gl36_value(p, "eps_sat", "mix_tol", MIX_TOL)
    delta_tsf = _f(p, "delta_supply_fan", DELTA_SUPPLY_FAN)
    econ_min = _f(p, "econ_min_pos", AHU_MIN_OA_DPR)
    clg_inactive = _f(p, "clg_inactive_max", 0.1)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    raw = (
        d["outside-air-temp"].notna() & d["discharge-air-temp-sp"].notna()
        & (econ > econ_min) & (clg < clg_inactive)
        & ((d["outside-air-temp"] - eps_oat) > (d["discharge-air-temp-sp"] - delta_tsf + eps_sat))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc9(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC10 — OAT/MAT mismatch + mech cooling (GL36 H)

Family: ahu · Equipment: ahu
Equation: |MATavg − OATavg| > √(εMAT² + εOAT²) in OS#3.
Default confirmation: 600 s

Required roles: mixed-air-temp, outside-air-temp, outside-air-damper, cooling-valve

Tunable params

Param Label Unit Default Range
eps_mat MAT sensor error εMAT (GL36 default 5°F) °F 1.15 0.0–10.0
eps_oat OAT sensor error εOAT (GL36 default 2°F local / 5°F global) °F 1.15 0.0–10.0
econ_full_open Economizer full-open threshold frac 0.9 0.5–1.0
clg_on_min Cooling-command ON threshold frac 0.01 0.0–0.25
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc10(d, p, poll):
    eps_mat = _gl36_value(p, "eps_mat", "mix_tol", MIX_TOL)
    eps_oat = _gl36_value(p, "eps_oat", "mix_tol", MIX_TOL)
    econ_full = _f(p, "econ_full_open", 0.9)
    clg_on = _f(p, "clg_on_min", 0.01)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    abs_mat_oat = (d["mixed-air-temp"] - d["outside-air-temp"]).abs()
    sqrt_tol = float(np.sqrt(eps_mat**2 + eps_oat**2))
    raw = d["mixed-air-temp"].notna() & d["outside-air-temp"].notna() & (clg > clg_on) & (econ > econ_full) & (abs_mat_oat > sqrt_tol)
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc10(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC11 — OAT/MAT mismatch economizer-only (GL36 I)

Family: ahu · Equipment: ahu
Equation: OATavg + εOAT < SATSP − ΔTSF − εSAT in OS#3.
Default confirmation: 600 s

Required roles: outside-air-temp, discharge-air-temp-sp, outside-air-damper, cooling-valve

Tunable params

Param Label Unit Default Range
eps_oat OAT sensor error εOAT (GL36 default 2°F local / 5°F global) °F 1.15 0.0–10.0
eps_sat SAT sensor error εSAT (GL36 default 2°F) °F 1.15 0.0–10.0
delta_supply_fan Supply-fan heat rise ΔTSF (GL36 default 2°F) °F 0.55 0.0–5.0
econ_full_open Economizer full-open threshold frac 0.9 0.5–1.0
clg_on_min Cooling-command ON threshold frac 0.01 0.0–0.25
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc11(d, p, poll):
    eps_oat = _gl36_value(p, "eps_oat", "mix_tol", MIX_TOL)
    eps_sat = _gl36_value(p, "eps_sat", "mix_tol", MIX_TOL)
    delta_tsf = _f(p, "delta_supply_fan", DELTA_SUPPLY_FAN)
    econ_full = _f(p, "econ_full_open", 0.9)
    clg_on = _f(p, "clg_on_min", 0.01)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    raw = (
        d["outside-air-temp"].notna() & d["discharge-air-temp-sp"].notna() & (clg > clg_on) & (econ > econ_full)
        & ((d["outside-air-temp"] + eps_oat) < (d["discharge-air-temp-sp"] - delta_tsf - eps_sat))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc11(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC12 — SAT above blend in cooling (GL36 J)

Family: ahu · Equipment: ahu
Equation: SATavg − εSAT − ΔTSF ≥ MATavg + εMAT in OS#3/OS#4.
Default confirmation: 600 s

Required roles: discharge-air-temp, mixed-air-temp, outside-air-damper, cooling-valve

Tunable params

Param Label Unit Default Range
eps_sat SAT sensor error εSAT (GL36 default 2°F) °F 1.15 0.0–10.0
eps_mat MAT sensor error εMAT (GL36 default 5°F) °F 1.15 0.0–10.0
delta_supply_fan Supply-fan heat rise ΔTSF (GL36 default 2°F) °F 0.55 0.0–5.0
econ_min_pos Economizer minimum-position threshold frac 0.05 0.0–0.5
econ_full_open Economizer full-open threshold frac 0.9 0.5–1.0
clg_on_min Cooling-command ON threshold frac 0.01 0.0–0.25
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
supply_tol Legacy SAT tolerance master (sets εSAT) °F 1.15 0.0–10.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc12(d, p, poll):
    eps_mat = _gl36_value(p, "eps_mat", "mix_tol", MIX_TOL)
    eps_sat = _gl36_value(p, "eps_sat", "supply_tol", SUPPLY_TOL)
    delta_tsf = _f(p, "delta_supply_fan", DELTA_SUPPLY_FAN)
    econ_min = _f(p, "econ_min_pos", AHU_MIN_OA_DPR)
    econ_full = _f(p, "econ_full_open", 0.9)
    clg_on = _f(p, "clg_on_min", 0.01)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    sat_check = d["discharge-air-temp"] - eps_sat - delta_tsf
    mat_check = d["mixed-air-temp"] + eps_mat
    raw = (
        d["discharge-air-temp"].notna() & d["mixed-air-temp"].notna() & (clg > clg_on)
        & (sat_check > mat_check) & ((econ <= econ_min) | (econ > econ_full))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc12(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC13 — SAT above SP at full cooling (GL36 K)

Family: ahu · Equipment: ahu
Equation: SATavg > SATSP + εSAT AND CC ≥ full-cooling threshold in OS#3/OS#4.
Default confirmation: 600 s

Required roles: discharge-air-temp, discharge-air-temp-sp, outside-air-damper, cooling-valve

Tunable params

Param Label Unit Default Range
eps_sat SAT sensor error εSAT (GL36 default 2°F) °F 1.0 0.0–10.0
sat_err Legacy SAT error (sets εSAT) °F 1.0 0.0–10.0
clg_full_min Full-cooling threshold (GL36 99%) frac 0.01 0.5–1.0
econ_min_pos Economizer minimum-position threshold frac 0.05 0.0–0.5
econ_full_open Economizer full-open threshold frac 0.9 0.5–1.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc13(d, p, poll):
    sat_err = _gl36_value(p, "eps_sat", "sat_err", 1.0)
    econ_min = _f(p, "econ_min_pos", AHU_MIN_OA_DPR)
    econ_full = _f(p, "econ_full_open", 0.9)
    clg_full = _f(p, "clg_full_min", 0.01)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    raw = (
        d["discharge-air-temp"].notna() & d["discharge-air-temp-sp"].notna() & (clg >= clg_full)
        & (d["discharge-air-temp"] > d["discharge-air-temp-sp"] + sat_err) & ((econ <= econ_min) | (econ > econ_full))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc13(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC14 — CHW coil ΔT when inactive (GL36 L)

Family: ahu · Equipment: ahu
Equation: Cooling-coil ΔT ≥ √(εCCET² + εCCLT²) + ΔTSF while coil should be inactive.
Default confirmation: 600 s

Required roles: cooling-coil-entering-temp, cooling-coil-leaving-temp, outside-air-damper, cooling-valve

Tunable params

Param Label Unit Default Range
eps_ccet Cooling-coil entering sensor error εCCET °F 1.15 0.0–10.0
eps_cclt Cooling-coil leaving sensor error εCCLT °F 1.15 0.0–10.0
delta_supply_fan Supply-fan heat rise ΔTSF (GL36 default 2°F) °F 0.55 0.0–5.0
econ_min_pos Economizer minimum-position threshold frac 0.05 0.0–0.5
clg_inactive_max Cooling-command inactive ceiling frac 0.1 0.0–0.5
htg_on_min Heating-command ON threshold frac 0.01 0.0–0.25
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc14(d, p, poll):
    eps_ccet = _gl36_value(p, "eps_ccet", "mix_tol", MIX_TOL)
    eps_cclt = _gl36_value(p, "eps_cclt", "mix_tol", MIX_TOL)
    delta_tsf = _f(p, "delta_supply_fan", DELTA_SUPPLY_FAN)
    econ_min = _f(p, "econ_min_pos", AHU_MIN_OA_DPR)
    clg_inactive = _f(p, "clg_inactive_max", 0.1)
    htg_on = _f(p, "htg_on_min", 0.01)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    htg = norm_cmd(d["heating-valve"]).fillna(0) if "heating-valve" in d else pd.Series(0.0, index=d.index)
    fan = _fan(d)
    delta = d["cooling-coil-entering-temp"] - d["cooling-coil-leaving-temp"]
    tol = float(np.sqrt(eps_ccet**2 + eps_cclt**2)) + delta_tsf
    raw = (
        d["cooling-coil-entering-temp"].notna() & d["cooling-coil-leaving-temp"].notna()
        & (delta >= tol)
        & (((econ > econ_min) & (clg < clg_inactive)) | ((htg > htg_on) & (fan > 0)))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc14(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

FC15 — HW coil ΔT when inactive (GL36 M)

Family: ahu · Equipment: ahu
Equation: Heating-coil ΔT ≥ √(εHCET² + εHCLT²) + ΔTSF while coil should be inactive.
Default confirmation: 600 s

Required roles: heating-coil-entering-temp, heating-coil-leaving-temp, outside-air-damper, cooling-valve

Tunable params

Param Label Unit Default Range
eps_hcet Heating-coil entering sensor error εHCET °F 1.15 0.0–10.0
eps_hclt Heating-coil leaving sensor error εHCLT °F 1.15 0.0–10.0
delta_supply_fan Supply-fan heat rise ΔTSF (GL36 default 2°F) °F 0.55 0.0–5.0
econ_min_pos Economizer minimum-position threshold frac 0.05 0.0–0.5
econ_full_open Economizer full-open threshold frac 0.9 0.5–1.0
clg_inactive_max Cooling-command inactive ceiling frac 0.1 0.0–0.5
clg_on_min Cooling-command ON threshold frac 0.01 0.0–0.25
mix_tol Legacy master sensor tolerance (sets all ε values) °F 1.15 0.0–10.0
mode_delay_min Mode-change suspension (GL36 default 30) min 0.0 0.0–60.0
FAULT_CONFIRM_SECONDS = 600

def fc15(d, p, poll):
    eps_hcet = _gl36_value(p, "eps_hcet", "mix_tol", MIX_TOL)
    eps_hclt = _gl36_value(p, "eps_hclt", "mix_tol", MIX_TOL)
    delta_tsf = _f(p, "delta_supply_fan", DELTA_SUPPLY_FAN)
    econ_min = _f(p, "econ_min_pos", AHU_MIN_OA_DPR)
    econ_full = _f(p, "econ_full_open", 0.9)
    clg_inactive = _f(p, "clg_inactive_max", 0.1)
    clg_on = _f(p, "clg_on_min", 0.01)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    delta = d["heating-coil-entering-temp"] - d["heating-coil-leaving-temp"]
    tol = float(np.sqrt(eps_hcet**2 + eps_hclt**2)) + delta_tsf
    raw = (
        d["heating-coil-entering-temp"].notna() & d["heating-coil-leaving-temp"].notna()
        & (delta >= tol)
        & (((econ > econ_min) & (clg < clg_inactive)) | ((clg > clg_on) & (econ <= econ_min)) | ((clg > clg_on) & (econ > econ_full)))
    )
    return _gl36_fault(raw, d, p, poll)

d = apply_fault(d, fc15(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

AHU-SATDEV — SAT deviation from setpoint

Family: ahu · Equipment: ahu
Equation: abs(SAT − SAT SP) > 5°F.
Default confirmation: 600 s

Required roles: discharge-air-temp, discharge-air-temp-sp

Tunable params

Param Label Unit Default Range
sat_dev_err SAT deviation °F 5.0 1.0–15.0
FAULT_CONFIRM_SECONDS = 600

def ahu_sat_dev(d, p, poll):
    err = _f(p, "sat_dev_err", 5.0)
    return d["discharge-air-temp"].notna() & d["discharge-air-temp-sp"].notna() & (d["discharge-air-temp"].sub(d["discharge-air-temp-sp"]).abs() > err)

d = apply_fault(d, ahu_sat_dev(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

AHU-DUCTHI — Duct static pressure high

Family: ahu · Equipment: ahu
Equation: Duct static > static SP + margin. Evaluates when fan is proven on OR duct static itself exceeds pressure_on_min (catches high static with fan-status off).
Default confirmation: 300 s

Required roles: duct-static-pressure, duct-static-pressure-sp

Tunable params

Param Label Unit Default Range
duct_high_margin High margin in. w.c. 0.25 0.05–1.0
pressure_on_min Pressure-on evidence in. w.c. 0.2 0.05–1.0
FAULT_CONFIRM_SECONDS = 300

def ahu_duct_high(d, p, poll):
    """Duct static above SP + margin. Gate (not equation) decides fan-vs-pressure active window."""
    margin = _f(p, "duct_high_margin", 0.25)
    return (
        d["duct-static-pressure"].notna()
        & d["duct-static-pressure-sp"].notna()
        & (d["duct-static-pressure"] > d["duct-static-pressure-sp"] + margin)
    )

d = apply_fault(d, ahu_duct_high(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

AHU-SIMUL — Heating and cooling simultaneous

Family: ahu · Equipment: ahu
Equation: Heating valve > 10% AND cooling valve > 10% at once.
Default confirmation: 300 s

Required roles: heating-valve, cooling-valve

Tunable params

Param Label Unit Default Range
valve_open_pct Valve open threshold frac 0.1 0.05–0.5
FAULT_CONFIRM_SECONDS = 300

def ahu_simul_heat_cool(d, p, poll):
    thr = _f(p, "valve_open_pct", 0.10)
    htg = norm_cmd(d["heating-valve"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    return (htg > thr) & (clg > thr)

d = apply_fault(d, ahu_simul_heat_cool(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

OAT-METEO — BAS outdoor-air sensor vs Open-Meteo

Family: ahu · Equipment: ahu
Equation: BAS OAT sensor differs from Open-Meteo dry bulb by more than 5°F.
Default confirmation: 900 s

Required roles: outside-air-temp, web-outside-air-temp

Tunable params

Param Label Unit Default Range
oat_err Max OAT disagreement °F 5.0 2.0–20.0
FAULT_CONFIRM_SECONDS = 900

def oat_vs_meteo(d, p, poll):
    """BAS outdoor-air sensor disagrees with Open-Meteo dry bulb by more than the threshold."""
    if "web-outside-air-temp" not in d.columns:
        return _false(d.index)
    err = _f(p, "oat_err", 5.0)
    return d["outside-air-temp"].notna() & d["web-outside-air-temp"].notna() & (d["outside-air-temp"].sub(d["web-outside-air-temp"]).abs() > err)

d = apply_fault(d, oat_vs_meteo(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

ECON-1 — Economizer stuck closed

Family: ahu · Equipment: ahu
Equation: Fan on, OA damper < 5%, OAT > 55°F (should be economizing).
Default confirmation: 600 s

Required roles: fan-cmd, outside-air-damper, outside-air-temp

Tunable params

Param Label Unit Default Range
econ1_oat_min Favorable OAT °F 55.0 45.0–70.0
FAULT_CONFIRM_SECONDS = 600

def econ1(d, p, poll):
    oat_min = _f(p, "econ1_oat_min", 55.0)
    fan = _fan(d)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    return (fan > FAN_ON_MIN) & d["outside-air-damper"].notna() & d["outside-air-temp"].notna() & (econ < 0.05) & (d["outside-air-temp"] > oat_min)

d = apply_fault(d, econ1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

ECON-2 — Economizing when outdoor unfavorable

Family: ahu · Equipment: ahu
Equation: OAT > 63°F AND OA damper > 42% (should be at minimum).
Default confirmation: 300 s

Required roles: outside-air-temp, outside-air-damper

Tunable params

Param Label Unit Default Range
econ2_oat_hi OAT high cutoff °F 63.0 55.0–80.0
econ2_damper Damper open frac frac 0.42 0.2–0.9
FAULT_CONFIRM_SECONDS = 300

def econ2(d, p, poll):
    oat_hi = _f(p, "econ2_oat_hi", 63.0)
    dmpr = _f(p, "econ2_damper", 0.42)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    return d["outside-air-temp"].notna() & d["outside-air-damper"].notna() & (d["outside-air-temp"] > oat_hi) & (econ > dmpr)

d = apply_fault(d, econ2(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

ECON-3 — Mech cooling without integrated economizer

Family: ahu · Equipment: ahu
Equation: Web free-cooling opportunity: 60°F ≤ dry-bulb < 72°F AND dewpoint < 60°F (dewpoint from web sensor or calculated from web DB+RH). Fault when cooling valve is open while OA damper is below the integrated-economizer threshold (default 90%). No BAS OAT fallback. Screenable engineering defaults — not code limits.
Default confirmation: 300 s

Required roles: outside-air-damper, cooling-valve (plus web OAT/RH or dewpoint columns)

Tunable params

Param Label Unit Default Range
econ3_db_min Free-cool OA dry-bulb min °F 60.0 50.0–68.0
econ3_db_max Free-cool OA dry-bulb max °F 72.0 65.0–80.0
econ3_dp_max Free-cool OA dew point max °F 60.0 45.0–68.0
econ3_damper_hi Integrated economizer damper frac 0.9 0.5–1.0
FAULT_CONFIRM_SECONDS = 300

# Validated compute lives in economizer_weather.econ3_compute
# (catalog entry uses a placeholder; the React engine substitutes this function).
def econ3_compute(d: pd.DataFrame, p: dict, poll: float, wx_ok: bool = True) -> pd.Series:
    """Fault: mech cooling while free cooling available but OA damper not integrated-open.

    Weather: strict web dry-bulb + dewpoint (calculated from web RH if needed).
    Band: 60 ≤ DB < 72°F and DP < 60°F. Damper must be ≥ integrated threshold (default 90%).
    """
    del wx_ok  # web presence is resolved explicitly below
    if not {"outside-air-damper", "cooling-valve"}.issubset(d.columns):
        return _false(d.index)
    db, dp, src = resolve_web_drybulb_dewpoint(d)
    if db is None or dp is None:
        d.attrs["econ3_weather_source"] = src
        return _false(d.index)

    db_min = _f(p, "econ3_db_min", 60.0)
    db_max = _f(p, "econ3_db_max", 72.0)
    dp_max = _f(p, "econ3_dp_max", 60.0)
    damper_hi = _f(p, "econ3_damper_hi", 0.90)

    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    opportunity = free_cool_opportunity_mask(db, dp, db_min=db_min, db_max=db_max, dp_max=dp_max)
    mech = clg > 0.01
    not_integrated = econ < damper_hi
    raw = opportunity & mech & not_integrated
    d.attrs["econ3_weather_source"] = src
    return raw.fillna(False)

d = apply_fault(d, econ3_compute(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

ECON-4 — Low estimated OA fraction

Family: ahu · Equipment: ahu
Equation: Fan on, abs(RAT−OAT) > 2.2°F, estimated OA fraction < 21%.
Default confirmation: 600 s

Required roles: mixed-air-temp, return-air-temp, outside-air-temp, fan-cmd

Tunable params

Param Label Unit Default Range
oa_min_pct Min OA fraction % 21.0 5.0–40.0
FAULT_CONFIRM_SECONDS = 600

def econ4(d, p, poll):
    oa_min_pct = _f(p, "oa_min_pct", 21.0)
    fan = _fan(d)
    oa_frac = (d["mixed-air-temp"] - d["return-air-temp"]) / (d["outside-air-temp"] - d["return-air-temp"]).replace(0, np.nan) * 100.0
    return (
        (fan > FAN_ON_MIN) & d["mixed-air-temp"].notna() & d["return-air-temp"].notna() & d["outside-air-temp"].notna()
        & ((d["return-air-temp"] - d["outside-air-temp"]).abs() > 2.2) & (oa_frac < oa_min_pct)
    )

d = apply_fault(d, econ4(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

ECON-5 — Preheat over-conditioning

Family: ahu · Equipment: ahu
Equation: Preheat leaving air > 2.2°F above target while preheat active.
Default confirmation: 600 s

Required roles: preheat-leaving-temp, discharge-air-temp-sp, outside-air-temp, heating-valve

Tunable params

Param Label Unit Default Range
preheat_over_f Preheat over ΔT °F 2.2 0.5–8.0
FAULT_CONFIRM_SECONDS = 600

def econ5(d, p, poll):
    over = _f(p, "preheat_over_f", 2.2)
    return (
        d["preheat-leaving-temp"].notna() & d["discharge-air-temp-sp"].notna() & d["outside-air-temp"].notna() & d["heating-valve"].notna()
        & (norm_cmd(d["heating-valve"]).fillna(0) > 0.01)
        & (
            ((d["outside-air-temp"] > d["discharge-air-temp-sp"]) & (d["preheat-leaving-temp"] - d["outside-air-temp"] > over))
            | ((d["outside-air-temp"] < d["discharge-air-temp-sp"]) & (d["preheat-leaving-temp"] - d["discharge-air-temp-sp"] > over))
        )
    )

d = apply_fault(d, econ5(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

ECON-6 — Economizing in freezing weather

Family: ahu · Equipment: ahu
Equation: Web dry-bulb < 25°F AND OA damper above winter min-OA ceiling (default 25%). AHU should be at minimum OA in cold weather.
Default confirmation: 600 s

Required roles: outside-air-damper

Optional roles: web-outside-air-temp

Tunable params

Param Label Unit Default Range
econ6_oat_max_f Winter OAT ceiling °F 25.0 15.0–40.0
econ6_damper_max Winter min-OA damper frac 0.25 0.05–0.5
FAULT_CONFIRM_SECONDS = 600

def econ6_compute(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    """Fault: OA damper above winter min-OA ceiling while web OAT < 25°F."""
    del poll
    if "outside-air-damper" not in d.columns:
        return _false(d.index)
    db, _dp, src = resolve_web_drybulb_dewpoint(d)
    d.attrs["econ6_weather_source"] = src
    if db is None:
        return _false(d.index)
    oat_max = _f(p, "econ6_oat_max_f", 25.0)
    damper_max = _f(p, "econ6_damper_max", 0.25)
    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    return (db.notna() & (db < oat_max) & (econ > damper_max)).fillna(False)

d = apply_fault(d, econ6_compute(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

ECON-7 — Economizer OK but not economizing

Family: ahu · Equipment: ahu
Equation: Economizer-OK web weather: dew point < 60°F AND dry-bulb < 72°F (above a 35°F freeze-guard floor; dewpoint from web sensor or calculated from web DB+RH). Fault when there is cooling demand (cooling valve open or proven DX/chiller cooling) but the OA damper stays below the economizing threshold (default 50%). Expected: economizer-only below 60°F DB (MECH-OAT-1) and mech + integrated economizer in the 60–72°F band (ECON-3). All thresholds are imperial sliders.
Default confirmation: 600 s

Required roles: outside-air-damper

Optional roles: web-outside-air-temp, web-outside-air-dewpoint, web-outside-air-humidity, cooling-valve, compressor-status, dx-cool-cmd

Tunable params

Param Label Unit Default Range
econ7_db_min Econ-OK dry-bulb floor (freeze guard) °F 35.0 20.0–50.0
econ7_db_max Econ-OK dry-bulb max °F 72.0 65.0–80.0
econ7_dp_max Econ-OK dew point max °F 60.0 45.0–68.0
econ7_damper_min Economizing damper threshold frac 0.5 0.2–0.9
FAULT_CONFIRM_SECONDS = 600

def econ7_compute(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    """Fault: economizer-OK web weather with cooling demand, but OA damper not economizing.

    Economizer OK: web dew point < 60°F AND dry-bulb < 72°F (above a freeze-guard
    floor, default 35°F). Dewpoint comes from the web sensor or is calculated from
    web DB+RH. Cooling demand: cooling valve open OR proven DX/chiller cooling.
    Expected operation: economizer-only below 60°F DB (see MECH-OAT-1) and
    mech + integrated economizer in the 60–72°F band (see ECON-3).
    """
    del poll
    if "outside-air-damper" not in d.columns:
        return _false(d.index)
    db, dp, src = resolve_web_drybulb_dewpoint(d)
    d.attrs["econ7_weather_source"] = src
    if db is None or dp is None:
        return _false(d.index)

    db_min = _f(p, "econ7_db_min", 35.0)
    db_max = _f(p, "econ7_db_max", 72.0)
    dp_max = _f(p, "econ7_dp_max", 60.0)
    damper_min = _f(p, "econ7_damper_min", 0.50)

    econ_ok = free_cool_opportunity_mask(db, dp, db_min=db_min, db_max=db_max, dp_max=dp_max)

    demand = _false(d.index)
    has_demand_signal = False
    if "cooling-valve" in d.columns and d["cooling-valve"].notna().any():
        demand = demand | (norm_cmd(d["cooling-valve"]).fillna(0) > 0.05)
        has_demand_signal = True
    mech, kind = mechanical_proof_mask(d)
    d.attrs["econ7_proof"] = kind
    if kind:
        demand = demand | mech
        has_demand_signal = True
    if not has_demand_signal:
        d.attrs["econ7_skip"] = "missing_cooling_demand_signal"
        return _false(d.index)

    econ = norm_cmd(d["outside-air-damper"]).fillna(0)
    return (econ_ok & demand & (econ < damper_min)).fillna(False)

d = apply_fault(d, econ7_compute(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

MECH-OAT-1 — Mechanical cooling below 60°F web OAT

Family: ahu · Equipment: ahu, chiller, heatpump
Equation: Proven DX/chiller mechanical cooling while web dry-bulb < 60°F. Uses compressor/chiller/pump/amps/power proof — not AHU cooling-valve alone. Below 60°F is outside the free-cool + integrated economizer band.
Default confirmation: 600 s

Optional roles: web-outside-air-temp, compressor-status, chiller-status, chw-pump-status, dx-cool-cmd

Tunable params

Param Label Unit Default Range
mech_oat_max_f Mech-cool OAT ceiling °F 60.0 45.0–65.0
FAULT_CONFIRM_SECONDS = 600

def mech_oat1_compute(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    """Fault: proven mechanical cooling while web dry-bulb < 60°F."""
    del poll
    db, _dp, src = resolve_web_drybulb_dewpoint(d)
    d.attrs["mech_oat1_weather_source"] = src
    if db is None:
        return _false(d.index)
    oat_max = _f(p, "mech_oat_max_f", 60.0)
    run, kind = mechanical_proof_mask(d)
    d.attrs["mech_oat1_proof"] = kind
    if not kind:
        return _false(d.index)
    return (db.notna() & (db < oat_max) & run).fillna(False)

d = apply_fault(d, mech_oat1_compute(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

RESET-1 — SAT reset not tracking outdoor air

Family: ahu · Equipment: ahu
Equation: Fan on AND |SAT SP − (52 + 0.25×(OAT−65))| > 3°F.
Default confirmation: 900 s

Required roles: discharge-air-temp-sp, outside-air-temp

Tunable params

Param Label Unit Default Range
reset_err_f Reset error °F 3.0 0.5–10.0
reset_sat_at_65 SAT SP at 65°F OAT °F 52.0 45.0–65.0
reset_slope Reset slope °F/°F 0.25 0.05–1.0
reset_oat_ref Reset OAT ref °F 65.0 50.0–80.0
FAULT_CONFIRM_SECONDS = 900

def reset1(d, p, poll):
    err = _f(p, "reset_err_f", 3.0)
    intercept = _f(p, "reset_sat_at_65", 52.0)
    slope = _f(p, "reset_slope", 0.25)
    oat_ref = _f(p, "reset_oat_ref", 65.0)
    oat = pd.to_numeric(d["outside-air-temp"], errors="coerce")
    sat_sp = pd.to_numeric(d["discharge-air-temp-sp"], errors="coerce")
    expected = intercept + slope * (oat - oat_ref)
    fan = pd.Series(True, index=d.index)
    if "fan-status" in d.columns and d["fan-status"].notna().any():
        fan = as_bool(d["fan-status"])
    elif "fan-cmd" in d.columns:
        fan = norm_cmd(d["fan-cmd"]).fillna(0) > 0.05
    return sat_sp.notna() & oat.notna() & fan.fillna(False) & ((sat_sp - expected).abs() > err)

d = apply_fault(d, reset1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

CMD-1 — Fan cmd/status mismatch

Family: ahu · Equipment: ahu
Equation: Fan command and proven status disagree.
Default confirmation: 600 s

Required roles: fan-cmd, fan-status

Tunable params

No tunable thresholds beyond confirmation delay.

FAULT_CONFIRM_SECONDS = 600

def cmd1(d, p, poll):
    cmd_on = norm_cmd(d["fan-cmd"]).fillna(0) >= 0.05
    return d["fan-status"].notna() & (cmd_on != as_bool(d["fan-status"]))

d = apply_fault(d, cmd1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

OA-1 — Low OA fraction

Family: ahu · Equipment: ahu
Equation: Estimated OA fraction < 15% with adequate OAT/RAT split.
Default confirmation: 900 s

Required roles: mixed-air-temp, return-air-temp, outside-air-temp, fan-status

Tunable params

Param Label Unit Default Range
min_oa_frac Min OA fraction frac 0.15 0.05–0.4
oat_rat_guard Min abs(RAT−OAT) guard °F 2.2 0.5–6.0
FAULT_CONFIRM_SECONDS = 900

def oa1(d, p, poll):
    min_oa = _f(p, "min_oa_frac", 0.15)
    guard = _f(p, "oat_rat_guard", 2.2)
    oa_frac = (d["mixed-air-temp"] - d["return-air-temp"]) / (d["outside-air-temp"] - d["return-air-temp"]).replace(0, np.nan)
    fan = _fan(d)
    return (
        (fan > FAN_ON_MIN) & d["outside-air-temp"].notna() & d["return-air-temp"].notna() & d["mixed-air-temp"].notna()
        & ((d["return-air-temp"] - d["outside-air-temp"]).abs() > guard) & (oa_frac < min_oa)
    )

d = apply_fault(d, oa1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

DMP-1 — OA damper leakage

Family: ahu · Equipment: ahu
Equation: Damper ≤ 5% but MAT tracks OAT within 2°F — leaking OA damper.
Default confirmation: 900 s

Required roles: outside-air-temp, mixed-air-temp, outside-air-damper

Tunable params

Param Label Unit Default Range
leak_delta Leak ΔT °F 2.0 0.5–6.0
FAULT_CONFIRM_SECONDS = 900

def dmp1(d, p, poll):
    leak_delta = _f(p, "leak_delta", 2.0)
    dmp = norm_cmd(d["outside-air-damper"]).fillna(0)
    return d["outside-air-temp"].notna() & d["mixed-air-temp"].notna() & (dmp <= 0.05) & (d["mixed-air-temp"].sub(d["outside-air-temp"]).abs() < leak_delta)

d = apply_fault(d, dmp1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VLV-1 — Cooling valve leakage

Family: ahu · Equipment: ahu
Equation: Cooling valve ≤ 5% AND (SAT < sat_sp − sat_err OR SAT < MAT − mat_leak_delta). Fan proven on when fan_status/fan_cmd present (operational gate).
Default confirmation: 900 s

Required roles: discharge-air-temp, discharge-air-temp-sp, cooling-valve

Optional roles: mixed-air-temp, fan-status, fan-cmd

Tunable params

Param Label Unit Default Range
sat_err SAT vs SP leak ΔT °F 2.0 0.5–8.0
mat_leak_delta SAT vs MAT leak ΔT °F 2.0 0.5–12.0
FAULT_CONFIRM_SECONDS = 900

def vlv1(d, p, poll):
    """Cooling valve leak: valve closed AND (SAT low vs SP or SAT low vs MAT).

    Fan proven-on is enforced by the VLV-1 operational gate when fan_status/fan_cmd exist.
    """
    sat_err = _f(p, "sat_err", 2.0)
    mat_delta = _f(p, "mat_leak_delta", 2.0)
    clg = norm_cmd(d["cooling-valve"]).fillna(0)
    closed = clg <= 0.05
    sat = pd.to_numeric(d["discharge-air-temp"], errors="coerce")
    sat_sp = pd.to_numeric(d["discharge-air-temp-sp"], errors="coerce")
    below_sp = sat.notna() & sat_sp.notna() & (sat < sat_sp - sat_err)
    below_mat = pd.Series(False, index=d.index)
    if "mixed-air-temp" in d.columns and d["mixed-air-temp"].notna().any():
        mat = pd.to_numeric(d["mixed-air-temp"], errors="coerce")
        below_mat = sat.notna() & mat.notna() & (sat < mat - mat_delta)
    return closed & (below_sp | below_mat)

d = apply_fault(d, vlv1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV terminals

VAV-1 — Zone comfort band

Family: vav · Equipment: vav, zone
Equation: Zone temp < 70°F or > 75°F.
Default confirmation: 900 s

Required roles: zone-air-temp

Tunable params

Param Label Unit Default Range
zone_lo Zone low °F 70.0 55.0–72.0
zone_hi Zone high °F 75.0 72.0–85.0
FAULT_CONFIRM_SECONDS = 900

def vav1(d, p, poll):
    lo = _f(p, "zone_lo", 70.0)
    hi = _f(p, "zone_hi", 75.0)
    return d["zone-air-temp"].notna() & ((d["zone-air-temp"] < lo) | (d["zone-air-temp"] > hi))

d = apply_fault(d, vav1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV-2 — Night setback miss

Family: vav · Equipment: vav, zone
Equation: Unoccupied AND zone temp > setback_hi (default 68°F) — heating setback not taken.
Default confirmation: 900 s

Required roles: zone-air-temp, occupied

Tunable params

Param Label Unit Default Range
setback_hi Setback high °F 68.0 55.0–72.0
FAULT_CONFIRM_SECONDS = 900

def vav2(d, p, poll):
    hi = _f(p, "setback_hi", 68.0)
    zt = pd.to_numeric(d["zone-air-temp"], errors="coerce")
    return zt.notna() & _unoccupied_mask(d) & (zt > hi)

d = apply_fault(d, vav2(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV-3 — Excessive reheat during warm weather

Family: vav · Equipment: vav
Equation: Air flowing AND OAT > 78°F AND reheat valve > 52%.
Default confirmation: 300 s

Required roles: outside-air-temp, reheat-valve

Tunable params

Param Label Unit Default Range
reheat_oat Warm OAT °F 78.0 65.0–90.0
reheat_pct Reheat frac frac 0.52 0.1–1.0
flow_on_min Airflow-on min cfm 25.0 0.0–200.0
FAULT_CONFIRM_SECONDS = 300

def vav3(d, p, poll):
    oat_hi = _f(p, "reheat_oat", 78.0)
    reheat_thr = _f(p, "reheat_pct", 0.52)
    flow_min = _f(p, "flow_on_min", 25.0)
    reheat = norm_cmd(d["reheat-valve"]).fillna(0)
    return _vav_air_on(d, flow_min) & d["outside-air-temp"].notna() & (d["outside-air-temp"] > oat_hi) & (reheat > reheat_thr)

d = apply_fault(d, vav3(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV-4 — Damper stuck at full open

Family: vav · Equipment: vav
Equation: Air flowing AND damper > 97.5% sustained across the window.
Default confirmation: 900 s

Required roles: damper

Tunable params

Param Label Unit Default Range
full_open_pct Full open frac frac 0.975 0.8–1.0
sustain_hours Sustain window h 1.5 0.5–6.0
flow_on_min Airflow-on min cfm 25.0 0.0–200.0
FAULT_CONFIRM_SECONDS = 900

def vav4(d, p, poll):
    full_open = _f(p, "full_open_pct", 0.975)
    hours = _f(p, "sustain_hours", 1.5)
    flow_min = _f(p, "flow_on_min", 25.0)
    roll = max(2, int(round(hours * 3600 / max(poll, 1))))
    dmp = norm_cmd(d["damper"]).fillna(0)
    return (
        _vav_air_on(d, flow_min) & dmp.notna() & (dmp > full_open)
        & (dmp.rolling(roll, min_periods=roll).min() > full_open)
    )

d = apply_fault(d, vav4(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV-5 — Airflow sensor bias

Family: vav · Equipment: vav
Equation: Airflow > 50 cfm while damper < 10% (implausible flow).
Default confirmation: 900 s

Required roles: zone-airflow, damper

Tunable params

No tunable thresholds beyond confirmation delay.

FAULT_CONFIRM_SECONDS = 900

def vav5(d, p, poll):
    dmp = norm_cmd(d["damper"]).fillna(0)
    return d["zone-airflow"].notna() & (d["zone-airflow"] > 50.0) & (dmp < 0.10)

d = apply_fault(d, vav5(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV-6 — Reheat when cooling available

Family: vav · Equipment: vav
Equation: OAT < 65°F AND reheat valve > 25% (optional cooling-available must be true when mapped).
Default confirmation: 900 s

Required roles: outside-air-temp, reheat-valve

Tunable params

Param Label Unit Default Range
free_cool_oat Free-cool OAT °F 65.0 45.0–75.0
reheat_pct Reheat frac frac 0.25 0.05–1.0
FAULT_CONFIRM_SECONDS = 900

def vav6(d, p, poll):
    oat_hi = _f(p, "free_cool_oat", 65.0)
    reheat_thr = _f(p, "reheat_pct", 0.25)
    reheat = norm_cmd(d["reheat-valve"]).fillna(0)
    oat = pd.to_numeric(d["outside-air-temp"], errors="coerce")
    clg = pd.Series(True, index=d.index)
    if "cooling-available" in d.columns and d["cooling-available"].notna().any():
        clg = as_bool(d["cooling-available"])
    return oat.notna() & clg.fillna(False) & (oat < oat_hi) & (reheat > reheat_thr)

d = apply_fault(d, vav6(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV-REHEAT — Reheat valve stuck / no temp rise

Family: vav · Equipment: vav
Equation: Air flowing AND reheat valve > 30% AND box discharge temp rises < 3°F above duct inlet (air from AHU) — stuck or failed reheat valve/coil.
Default confirmation: 900 s

Required roles: reheat-valve, vav-discharge-air-temp, vav-inlet-air-temp

Tunable params

Param Label Unit Default Range
reheat_cmd Reheat open frac frac 0.3 0.1–1.0
min_rise Min temp rise °F 3.0 0.5–15.0
flow_on_min Airflow-on min cfm 25.0 0.0–200.0
FAULT_CONFIRM_SECONDS = 900

def vav_reheat_stuck(d, p, poll):
    """Reheat valve commanded open but the box's discharge air never warms above inlet.

    Inlet temp = duct air arriving from the AHU (≈ AHU discharge). Discharge temp = air
    leaving the box after the reheat coil. Reheat open + air flowing + no rise → stuck /
    failed reheat valve or coil. Fully computed from VAV-local sensors.
    """
    cmd_thr = _f(p, "reheat_cmd", 0.30)
    min_rise = _f(p, "min_rise", 3.0)
    flow_min = _f(p, "flow_on_min", 25.0)
    reheat = norm_cmd(d["reheat-valve"]).fillna(0)
    rise = d["vav-discharge-air-temp"] - d["vav-inlet-air-temp"]
    return (
        _vav_air_on(d, flow_min)
        & d["vav-discharge-air-temp"].notna() & d["vav-inlet-air-temp"].notna()
        & (reheat > cmd_thr) & (rise < min_rise)
    )

d = apply_fault(d, vav_reheat_stuck(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV-AHU-LEAVE — VAV leave vs parent AHU SAT (fedBy)

Family: vav · Equipment: vav
Equation: Air flowing AND |VAV discharge − parent AHU SAT| > band. Needs package topology (vav_to_ahu) so ahu_sat is enriched from the fedBy AHU; otherwise SKIPPED_MISSING_ROLES. Flags broken reheat, bad sensors, or rogue zones.
Default confirmation: 900 s

Required roles: vav-discharge-air-temp, ahu-discharge-air-temp

Tunable params

Param Label Unit Default Range
delta_f Leave Δ vs AHU SAT °F 8.0 2.0–25.0
flow_on_min Airflow-on min cfm 25.0 0.0–200.0
FAULT_CONFIRM_SECONDS = 900

def vav_vs_ahu_leave(d, p, poll):
    """VAV leave temp far from parent AHU SAT (fedBy) — broken reheat or sensor/rogue box.

    Requires topology enrich: ``ahu_sat`` copied from parent AHU onto the VAV frame.
    Without ``ahu_sat``, the rule is SKIPPED_MISSING_ROLES by the engine.
    """
    band = _f(p, "delta_f", 8.0)
    flow_min = _f(p, "flow_on_min", 25.0)
    leave = pd.to_numeric(d["vav-discharge-air-temp"], errors="coerce")
    ahu = pd.to_numeric(d["ahu-discharge-air-temp"], errors="coerce")
    return (
        _vav_air_on(d, flow_min)
        & leave.notna()
        & ahu.notna()
        & ((leave - ahu).abs() > band)
    )

d = apply_fault(d, vav_vs_ahu_leave(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

VAV-7 — Min airflow / fixed high flow

Family: vav · Equipment: vav
Equation: Flow below min SP (when mapped), OR airflow stays flat (low rolling std) at a high mean while air is on (mins too high / box never modulates), OR min_flow_sp itself is excessively high.
Default confirmation: 900 s

Required roles: zone-airflow

Tunable params

Param Label Unit Default Range
flow_on_min Airflow-on min cfm 25.0 0.0–200.0
fixed_flow_max_std Fixed-flow max std cfm 15.0 1.0–80.0
fixed_flow_min_mean Fixed-flow min mean cfm 200.0 50.0–2000.0
high_min_flow_sp High min-flow SP cfm 250.0 50.0–2000.0
FAULT_CONFIRM_SECONDS = 900

def vav7(d, p, poll):
    """Min-flow violation OR fixed/high airflow while air is moving (mins too high / no modulate)."""
    under = (
        d["zone-airflow"].notna() & d["min-flow-sp"].notna() & (d["zone-airflow"] < d["min-flow-sp"])
        if "min-flow-sp" in d.columns
        else _false(d.index)
    )
    flow = pd.to_numeric(d["zone-airflow"], errors="coerce") if "zone-airflow" in d.columns else None
    if flow is None:
        return under
    flow_min = _f(p, "flow_on_min", 25.0)
    air_on = _vav_air_on(d, flow_min)
    window = max(6, int(round(3600.0 / max(float(poll), 1.0))))
    roll_std = flow.rolling(window, min_periods=max(3, window // 2)).std()
    roll_mean = flow.rolling(window, min_periods=max(3, window // 2)).mean()
    max_std = _f(p, "fixed_flow_max_std", 15.0)
    min_mean = _f(p, "fixed_flow_min_mean", 200.0)
    fixed_high = air_on & flow.notna() & (roll_std < max_std) & (roll_mean > min_mean)
    high_min = _false(d.index)
    if "min-flow-sp" in d.columns:
        high_min_thr = _f(p, "high_min_flow_sp", 250.0)
        high_min = (
            air_on
            & d["min-flow-sp"].notna()
            & (pd.to_numeric(d["min-flow-sp"], errors="coerce") > high_min_thr)
            & (roll_std < max_std)
        )
    return under.fillna(False) | fixed_high.fillna(False) | high_min.fillna(False)

d = apply_fault(d, vav7(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

Central plant / condenser water

CHW-NOLOAD-1 — Chiller running with no building load

Family: plant · Equipment: chiller
Equation: Chiller/plant proven running while building load is satisfied: all mapped zones inside comfort band OR all mapped AHU SAT within sat_band of setpoint. Default confirm 30 min.
Default confirmation: 1800 s

Optional roles: chiller-status, chw-pump-status, compressor-status, building-zone-load-satisfied, building-ahu-load-satisfied

Tunable params

Param Label Unit Default Range
comfort_low_f Comfort low °F 70.0 60.0–78.0
comfort_high_f Comfort high °F 75.0 68.0–85.0
sat_band_f AHU SAT≈SP band °F 2.0 0.5–6.0
FAULT_CONFIRM_SECONDS = 1800

def chw_noload1_compute(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    """Fault: chiller proven running while building load is satisfied.

    Satisfaction: ``building-zone-load-satisfied`` OR ``building-ahu-load-satisfied``
    (injected by load_satisfaction before batch run).
    """
    del poll
    run, kind = mechanical_proof_mask(d, equipment_type="CHILLER")
    d.attrs["chw_noload_proof"] = kind
    if not kind:
        return _false(d.index)

    zone_ok = None
    ahu_ok = None
    if "building-zone-load-satisfied" in d.columns and d["building-zone-load-satisfied"].notna().any():
        zone_ok = as_bool(d["building-zone-load-satisfied"])
    if "building-ahu-load-satisfied" in d.columns and d["building-ahu-load-satisfied"].notna().any():
        ahu_ok = as_bool(d["building-ahu-load-satisfied"])
    if zone_ok is None and ahu_ok is None:
        d.attrs["chw_noload_skip"] = "missing_load_satisfaction"
        return _false(d.index)

    satisfied = _false(d.index)
    if zone_ok is not None:
        satisfied = satisfied | zone_ok.reindex(d.index).fillna(False)
    if ahu_ok is not None:
        satisfied = satisfied | ahu_ok.reindex(d.index).fillna(False)
    return (run & satisfied).fillna(False)

d = apply_fault(d, chw_noload1_compute(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

CHW-1 — Low chilled-water ΔT

Family: plant · Equipment: chiller
Equation: Pump on AND (CHWR − CHWS) < 4°F.
Default confirmation: 900 s

Required roles: chilled-water-supply-temp, chilled-water-return-temp

Tunable params

Param Label Unit Default Range
min_dt Min ΔT °F 4.0 1.0–12.0
FAULT_CONFIRM_SECONDS = 900

def chw1(d, p, poll):
    min_dt = _f(p, "min_dt", 4.0)
    dt = d["chilled-water-return-temp"] - d["chilled-water-supply-temp"]
    if "chw-pump-cmd" in d.columns:
        pump = norm_cmd(d["chw-pump-cmd"]).fillna(0) > 0.05
    else:
        pump = pd.Series(True, index=d.index)
    return d["chilled-water-supply-temp"].notna() & d["chilled-water-return-temp"].notna() & pump & (dt < min_dt)

d = apply_fault(d, chw1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

CHW-2 — DP below SP at max pump speed

Family: plant · Equipment: chiller
Equation: Pump ≥ 87% AND CHW DP < DP SP − 2.2.
Default confirmation: 300 s

Required roles: chw-diff-pressure, chw-diff-pressure-sp, chw-pump-cmd

Tunable params

Param Label Unit Default Range
dp_margin DP margin psi 2.2 0.5–6.0
pump_hi Pump high-speed threshold frac 0.87 0.5–1.0
FAULT_CONFIRM_SECONDS = 300

def chw2(d, p, poll):
    margin = _f(p, "dp_margin", 2.2)
    pmp_hi = _f(p, "pump_hi", 0.87)
    pump = norm_cmd(d["chw-pump-cmd"]).fillna(0)
    return (
        d["chw-diff-pressure"].notna() & d["chw-diff-pressure-sp"].notna()
        & (d["chw-diff-pressure"] < d["chw-diff-pressure-sp"] - margin) & (pump >= pmp_hi)
    )

d = apply_fault(d, chw2(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

CHW-3 — Plant supply temp outside deadband

Family: plant · Equipment: chiller
Equation: Pump on AND |CHWS − CHWS SP| > 2.2°F.
Default confirmation: 300 s

Required roles: chilled-water-supply-temp, chilled-water-supply-temp-sp, chw-pump-cmd

Tunable params

Param Label Unit Default Range
sp_band SP band °F 2.2 0.5–6.0
FAULT_CONFIRM_SECONDS = 300

def chw3(d, p, poll):
    band = _f(p, "sp_band", 2.2)
    pump = norm_cmd(d["chw-pump-cmd"]).fillna(0)
    return (
        (pump > 0.01) & d["chilled-water-supply-temp"].notna() & d["chilled-water-supply-temp-sp"].notna()
        & ((d["chilled-water-supply-temp"] < d["chilled-water-supply-temp-sp"] - band) | (d["chilled-water-supply-temp"] > d["chilled-water-supply-temp-sp"] + band))
    )

d = apply_fault(d, chw3(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

CHW-4 — Flow high at max pump

Family: plant · Equipment: chiller
Equation: Pump ≥ 87% AND CHW flow > 1100 gpm.
Default confirmation: 300 s

Required roles: chw-flow, chw-pump-cmd

Tunable params

Param Label Unit Default Range
flow_hi Flow high gpm 1100 200–3000
pump_hi Pump high-speed threshold frac 0.87 0.5–1.0
FAULT_CONFIRM_SECONDS = 300

def chw4(d, p, poll):
    flow_hi = _f(p, "flow_hi", 1100.0)
    pmp_hi = _f(p, "pump_hi", 0.87)
    pump = norm_cmd(d["chw-pump-cmd"]).fillna(0)
    return d["chw-flow"].notna() & (d["chw-flow"] > flow_hi) & (pump >= pmp_hi)

d = apply_fault(d, chw4(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

CW-OPT-1 — Condenser water not optimized vs wet-bulb

Family: plant · Equipment: chiller, cooling_tower
Equation: CW supply significantly colder than web wet-bulb + design approach (Stull WB) — tower over-cooling / not optimized.
Default confirmation: 900 s

Required roles: condenser-water-supply-temp

Tunable params

Param Label Unit Default Range
cw_approach Design approach °F 7.0 3.0–15.0
cw_slack Slack below target °F 2.0 0.5–6.0
FAULT_CONFIRM_SECONDS = 900

def cw_opt(d, p, poll):
    """Condenser-water not optimized vs wet-bulb (Stull) — CW colder than WB + approach."""
    if "condenser-water-supply-temp" not in d.columns:
        return _false(d.index)
    wb = d["web-outside-air-wetbulb"] if "web-outside-air-wetbulb" in d.columns else None
    if wb is None or wb.notna().sum() == 0:
        return _false(d.index)
    approach = _f(p, "cw_approach", 7.0)
    slack = _f(p, "cw_slack", 2.0)
    # Over-cooled tower water: supply significantly below wet-bulb + design approach
    return (
        d["condenser-water-supply-temp"].notna()
        & wb.notna()
        & (pd.to_numeric(d["condenser-water-supply-temp"], errors="coerce") < (wb + approach - slack))
    )

d = apply_fault(d, cw_opt(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

CW-APR-1 — High CW approach at full tower fan

Family: plant · Equipment: chiller, cooling_tower
Equation: At full tower fan speed, leaving CW − web wet-bulb exceeds approach_max (default 8°F, typically 5–10°F). Suspect OA→wet-bulb / CW sensor mismatch or cooling-tower performance degradation.
Default confirmation: 900 s

Required roles: condenser-water-supply-temp

Optional roles: tower-fan-cmd, cw-fan-cmd, fan-cmd, web-outside-air-wetbulb

Tunable params

Param Label Unit Default Range
approach_max_f Max approach at full fan °F 8.0 5.0–15.0
tower_fan_hi Tower fan full-speed threshold frac 0.95 0.8–1.0
FAULT_CONFIRM_SECONDS = 900

def cw_apr(d, p, poll):
    """High CW approach at full tower fan — sensors or tower degradation."""
    if "condenser-water-supply-temp" not in d.columns:
        return _false(d.index)
    if "web-outside-air-wetbulb" not in d.columns or d["web-outside-air-wetbulb"].notna().sum() == 0:
        return _false(d.index)
    if not any(r in d.columns and d[r].notna().any() for r in ("tower-fan-cmd", "cw-fan-cmd", "fan-cmd")):
        return _false(d.index)
    limit = _f(p, "approach_max_f", 8.0)
    apr = _cw_approach_f(d)
    return (
        d["condenser-water-supply-temp"].notna()
        & d["web-outside-air-wetbulb"].notna()
        & _tower_fan_full_mask(d, p)
        & (apr > limit)
    )

d = apply_fault(d, cw_apr(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

CW-FAN-1 — Excess tower fan energy vs wet-bulb limit

Family: plant · Equipment: chiller, cooling_tower
Equation: Tower fans at full speed while leaving CW is well above web wet-bulb + design approach (approach + excess_beyond). Fans are chasing a CW temp that is theoretically hard/impossible — excess fan energy.
Default confirmation: 900 s

Required roles: condenser-water-supply-temp

Optional roles: tower-fan-cmd, cw-fan-cmd, fan-cmd, web-outside-air-wetbulb

Tunable params

Param Label Unit Default Range
cw_approach Design approach °F 7.0 3.0–15.0
excess_beyond_approach_f Excess beyond approach °F 5.0 2.0–20.0
tower_fan_hi Tower fan full-speed threshold frac 0.95 0.8–1.0
FAULT_CONFIRM_SECONDS = 900

def cw_fan_excess(d, p, poll):
    """Excess tower-fan energy — CW well above theoretical WB+approach at full fan."""
    if "condenser-water-supply-temp" not in d.columns:
        return _false(d.index)
    if "web-outside-air-wetbulb" not in d.columns or d["web-outside-air-wetbulb"].notna().sum() == 0:
        return _false(d.index)
    if not any(r in d.columns and d[r].notna().any() for r in ("tower-fan-cmd", "cw-fan-cmd", "fan-cmd")):
        return _false(d.index)
    approach = _f(p, "cw_approach", 7.0)
    excess = _f(p, "excess_beyond_approach_f", 5.0)
    apr = _cw_approach_f(d)
    return (
        d["condenser-water-supply-temp"].notna()
        & d["web-outside-air-wetbulb"].notna()
        & _tower_fan_full_mask(d, p)
        & (apr > (approach + excess))
    )

d = apply_fault(d, cw_fan_excess(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

Heat pumps

HP-1 — Discharge cold when heating

Family: heatpump · Equipment: heatpump
Equation: Fan on, zone < 69°F, discharge SAT < 85°F.
Default confirmation: 600 s

Required roles: discharge-air-temp, zone-air-temp, fan-cmd

Tunable params

Param Label Unit Default Range
min_sat Min heating SAT °F 85.0 70.0–110.0
zone_cold Zone cold °F 69.0 60.0–72.0
FAULT_CONFIRM_SECONDS = 600

def hp1(d, p, poll):
    min_sat = _f(p, "min_sat", 85.0)
    zone_cold = _f(p, "zone_cold", 69.0)
    fan = _fan(d)
    return (
        d["discharge-air-temp"].notna() & d["zone-air-temp"].notna() & (fan > FAN_ON_MIN)
        & (d["zone-air-temp"] < zone_cold) & (d["discharge-air-temp"] < min_sat)
    )

d = apply_fault(d, hp1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

Weather station

WX-1 — OA temperature spike

Family: weather · Equipment: weather
Equation: OAT sample-to-sample jump > 16°F.
Default confirmation: 300 s

Required roles: outside-air-temp

Tunable params

Param Label Unit Default Range
spike_limit Spike limit °F 16.0 4.0–40.0
FAULT_CONFIRM_SECONDS = 300

def wx1(d, p, poll):
    """OAT sample-to-sample spike; limit scales with the sample gap vs poll."""
    spike = _f(p, "spike_limit", 16.0)
    s = pd.to_numeric(d["outside-air-temp"], errors="coerce")
    jump = s.diff().abs()
    if isinstance(d.index, pd.DatetimeIndex) and len(d.index) > 1:
        dt_s = d.index.to_series().diff().dt.total_seconds()
        scale = (dt_s / max(float(poll), 1.0)).fillna(1.0).clip(lower=1.0)
        return s.notna() & (jump > (spike * scale))
    return s.notna() & (jump > spike)

d = apply_fault(d, wx1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

Trim & respond advisory

TRIM-1 — Duct static trim advisory

Family: trim · Equipment: ahu
Equation: Duct static high (> 1.35 in.w.c.) while VAV pressure requests are low.
Default confirmation: 1800 s

Required roles: duct-static-pressure, vav-pressure-request-sum

Tunable params

Param Label Unit Default Range
duct_hi Duct static high in. w.c. 1.35 0.5–3.0
request_lo Request sum low count 1.0 0.0–10.0
FAULT_CONFIRM_SECONDS = 1800

def trim1(d, p, poll):
    duct_hi = _f(p, "duct_hi", 1.35)
    req_lo = _f(p, "request_lo", 1.0)
    return (
        d["duct-static-pressure"].notna() & d["vav-pressure-request-sum"].notna()
        & (d["duct-static-pressure"] > duct_hi) & (d["vav-pressure-request-sum"] < req_lo)
    )

d = apply_fault(d, trim1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

TRIM-3 — HWST trim advisory

Family: trim · Equipment: boiler
Equation: HW supply > 160°F while reset requests are low.
Default confirmation: 1800 s

Required roles: hot-water-supply-temp, hw-reset-request-sum

Tunable params

Param Label Unit Default Range
hwst_hi HWST high °F 160.0 120.0–200.0
request_lo Request sum low count 1.0 0.0–10.0
FAULT_CONFIRM_SECONDS = 1800

def trim3(d, p, poll):
    hwst_hi = _f(p, "hwst_hi", 160.0)
    req_lo = _f(p, "request_lo", 1.0)
    return (
        d["hot-water-supply-temp"].notna() & d["hw-reset-request-sum"].notna()
        & (d["hot-water-supply-temp"] > hwst_hi) & (d["hw-reset-request-sum"] < req_lo)
    )

d = apply_fault(d, trim3(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

TRIM-4 — CHW plant reset advisory

Family: trim · Equipment: chiller
Equation: CHW supply < 45°F while reset requests are low.
Default confirmation: 1800 s

Required roles: chilled-water-supply-temp, chw-reset-request-sum

Tunable params

Param Label Unit Default Range
chw_lo CHWS low °F 45.0 35.0–55.0
request_lo Request sum low count 1.0 0.0–10.0
FAULT_CONFIRM_SECONDS = 1800

def trim4(d, p, poll):
    chw_lo = _f(p, "chw_lo", 45.0)
    req_lo = _f(p, "request_lo", 1.0)
    return (
        d["chilled-water-supply-temp"].notna() & d["chw-reset-request-sum"].notna()
        & (d["chilled-water-supply-temp"] < chw_lo) & (d["chw-reset-request-sum"] < req_lo)
    )

d = apply_fault(d, trim4(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

Schedule & occupancy

SCHED-1 — Unoccupied runtime

Family: schedule · Equipment: ahu
Equation: Fan running while occupancy is unoccupied (Overview calendar → occ_mode). When zone_t is mapped, also require zone inside comfort_low_f…comfort_high_f (defaults 70–75°F; synced from Overview zone band).
Default confirmation: 1800 s

Required roles: occupied, fan-status

Optional roles: zone-air-temp

Tunable params

Param Label Unit Default Range
comfort_low_f Comfort low °F 70.0 60.0–78.0
comfort_high_f Comfort high °F 75.0 68.0–85.0
FAULT_CONFIRM_SECONDS = 1800

def sched1(d, p, poll):
    """Unoccupied fan runtime; optional zone comfort band when zone_t is mapped."""
    if "occupied" not in d or "fan-status" not in d:
        return _false(d.index)
    base = (d["occupied"].astype(str).str.lower() == "unoccupied") & as_bool(d["fan-status"])
    if "zone-air-temp" not in d.columns or d["zone-air-temp"].notna().sum() == 0:
        return base
    lo = _f(p, "comfort_low_f", 70.0)
    hi = _f(p, "comfort_high_f", 75.0)
    zt = pd.to_numeric(d["zone-air-temp"], errors="coerce")
    in_band = zt.notna() & (zt >= lo) & (zt <= hi)
    return base & in_band

d = apply_fault(d, sched1(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

SCHED-247 — Always-on fan or pump runtime

Family: schedule · Equipment: ahu, vav, chiller, boiler, heatpump
Equation: Ranked proof (status/current > command) is on for ≥ always_on_pct of the analysis window. Pressure is inferred runtime only and does not trip this rule ID (4.3 migration).
Default confirmation: 3600 s

Optional roles: fan-status, pump-status, chw-pump-status, hw-pump-status, chiller-status, compressor-status, tower-fan-cmd, cw-fan-cmd, fan-cmd, chw-pump-cmd, hw-pump-cmd, duct-static-pressure, chw-diff-pressure

Tunable params

Param Label Unit Default Range
always_on_pct Always-on fraction frac 0.95 0.8–1.0
pressure_on_min Pressure-on evidence eng 0.2 0.05–2.0
FAULT_CONFIRM_SECONDS = 3600

def _sched247(d: pd.DataFrame, p: dict, poll: float) -> pd.Series:
    """Equipment essentially always-on (fan/pump/compressor status) over the window.

    When the always-on fraction is exceeded, return the actual on-mask so fault-hours
    equal run hours (not the full analysis window). Pressure sensors (duct static /
    differential) above ``pressure_on_min`` also count as on — catches VAV systems
    where fan cmd/status mismatch but the duct is pressurized.
    """
    thr = _f(p, "always_on_pct", 0.95)
    proofs: list[pd.Series] = []
    for role in (
        "fan-status",
        "pump-status",
        "chw-pump-status",
        "hw-pump-status",
        "chiller-status",
        "compressor-status",
        "tower-fan-cmd",
        "cw-fan-cmd",
        "fan-cmd",
        "chw-pump-cmd",
        "hw-pump-cmd",
    ):
        if role not in d.columns or not d[role].notna().any():
            continue
        if role.endswith("-cmd"):
            proofs.append(norm_cmd(d[role]).fillna(0) > SCHED247_CMD_ON_FRAC)
        else:
            proofs.append(as_bool(d[role]))
    press = _pressure_on_mask(d, p)
    if press is not None:
        proofs.append(press)
    if not proofs:
        return _false(d.index)
    on = proofs[0].fillna(False).astype(bool)
    for s in proofs[1:]:
        on = on | s.fillna(False).astype(bool)
    frac = float(on.mean()) if len(on) else 0.0
    if frac >= thr:
        return on.reindex(d.index).fillna(False)
    return _false(d.index)

d = apply_fault(d, _sched247(d, params, POLL_SECONDS))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS))

Not yet in validated catalog

The following rules remain documented for continuity but are not in the current validated vibe19 RULES catalog. Do not treat them as React SPA-parity-tested until they are added to the catalog.

ID Title Family Notes
TOWER-1 Cooling tower approach high plant Tower leaving CW approach above design vs wet-bulb.
CTRL-2 Generic control loop hunting control Simplified duct-static hunting screen (pandas-complete logic differs).
OVR-1 Persistent override ahu Manual override / hand mode held beyond confirm window.
OA-2 DCV minimum OA not met ahu Estimated OA fraction below DCV/minimum OA setpoint.
PLANT-1 CHW DP reset missing plant CHW differential pressure SP not resetting with load.
SP-HIGH Occupied setpoint too high vav Occupied zone SP above comfort policy.
SP-LOW Occupied setpoint too low vav Occupied zone SP below comfort policy.
KPI-1 Performance score (advisory) site Site-level advisory KPI aggregation.
TRIM-2 Chiller plant enable advisory trim Trim/respond chiller enable advisory.
WX-2 Gust lower than sustained wind weather Wind gust reading inconsistent with sustained wind.

Framework docs