Pandas FDD Cookbook

For analyst workflows outside Open-FDD. Open-FDD edge runs Rust + Apache Arrow + DataFusion SQL only. This cookbook mirrors every rule in the DataFusion SQL cookbook for notebooks, CSV exports, RCx studies, and parity testing.


Table of contents

  1. Setup & shared helpers
  2. Fault confirmation delay (default 5 minutes)
  3. Sensor validation
  4. Air handling units (FC1–FC15)
  5. VAV zones
  6. Economizer & ventilation
  7. Central plants
  8. Heat pumps
  9. Weather station
  10. Trim & respond advisory
  11. Extended rule families (v2)
  12. Extended rule families (P2)
  13. Framework docs
  14. Export to Open-FDD SQL

Setup & shared helpers

import pandas as pd
import numpy as np

# Wide historian export (one row per timestamp × equipment)
df = pd.read_json("telemetry_pivot.jsonl", lines=True)
# Or: df = pd.read_feather("telemetry_pivot.feather")

df["timestamp"] = pd.to_datetime(df["timestamp"], utc=True)
df = df.sort_values(["equipment_id", "timestamp"])

EQUIP = "equip:your-ahu"
d = df[df["equipment_id"] == EQUIP].copy()

Normalize command 0–100 → 0–1

def norm_cmd(s: pd.Series) -> pd.Series:
    s = pd.to_numeric(s, errors="coerce")
    return np.where(s > 1.0, s / 100.0, s)

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 (shared with SQL cookbook)

def macro_fan_proven_on(d: pd.DataFrame) -> pd.Series:
    cmd = norm_cmd(d["fan_cmd"]) if "fan_cmd" in d else pd.Series(0.0, index=d.index)
    on = cmd >= 0.05
    if "fan_status" in d.columns:
        st = d["fan_status"]
        return on & (st.isna() | st.astype(bool))
    return on

def macro_override_suppression(d: pd.DataFrame) -> pd.Series:
    mask = pd.Series(True, index=d.index)
    for col in ("override_active", "hand_mode", "bypass_active"):
        if col in d.columns:
            mask &= ~d[col].fillna(False).astype(bool)
    return mask

See prerequisite macros for full library.


Fault confirmation delay (default 5 minutes)

Every rule below uses an adjustable delay before a fault is considered confirmed. Default matches Open-FDD edge: 5 minutes.

# --- ADJUSTABLE: default 5 min fault delay ---
POLL_SECONDS = 60                    # historian poll interval
FAULT_CONFIRM_SECONDS = 300          # default 5 minutes — change per rule
FAULT_CONFIRM_ROWS = max(1, FAULT_CONFIRM_SECONDS // POLL_SECONDS)

def confirm_fault(raw: pd.Series, min_rows: int = FAULT_CONFIRM_ROWS) -> pd.Series:
    """True only after `min_rows` consecutive True samples."""
    groups = (raw != raw.shift()).cumsum()
    streak = raw.groupby(groups).cumcount() + 1
    return raw & (streak >= min_rows)

# Example: FC4 PID hunting often needs longer delay
FC4_CONFIRM_SECONDS = 3600
FC4_CONFIRM_ROWS = FC4_CONFIRM_SECONDS // POLL_SECONDS

At 60 s poll, FAULT_CONFIRM_ROWS=5 ≈ 300 s — same as SQL confirmation_seconds: 300.


Sensor validation

SV-1 — Zone temperature out of range

FAULT_CONFIRM_SECONDS = 300  # adjustable

mask = d["zone_t"].notna() & ((d["zone_t"] < 55.0) | (d["zone_t"] > 90.0))
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

SV-2 — OA temperature out of range

FAULT_CONFIRM_SECONDS = 300

mask = d["oa_t"].notna() & ((d["oa_t"] < 40.0) | (d["oa_t"] > 110.0))
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

SV-3 — OA humidity out of range

FAULT_CONFIRM_SECONDS = 300

mask = d["oa_h"].notna() & ((d["oa_h"] < 10.0) | (d["oa_h"] > 95.0))
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

SV-4 — Mixing envelope (MAT vs OAT/RAT)

Prefer envelope checks when OAT, RAT, and MAT are present — mirrors FC2/FC3 logic.

FAULT_CONFIRM_SECONDS = 300
ENV = 2.2

mask = (
    d["mat"].notna() & d["oa_t"].notna() & d["rat"].notna()
    & (
        (d["mat"] < d[["oa_t", "rat"]].min(axis=1) - ENV)
        | (d["mat"] > d[["oa_t", "rat"]].max(axis=1) + ENV)
    )
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

SV-5 — Stale data (no recent samples)

FAULT_CONFIRM_SECONDS = 300
STALE_MINUTES = 30

last_ts = d.groupby("equipment_id")["timestamp"].transform("max")
stale = (d["timestamp"] == last_ts) & (
    (pd.Timestamp.utcnow() - last_ts).dt.total_seconds() > STALE_MINUTES * 60
)
d = apply_fault(d, stale)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

SV-6 — Flatline (stuck sensor)

FAULT_CONFIRM_SECONDS = 300
FLATLINE_SAMPLES = 12  # ~1 h @ 5-min poll

def flatline_mask(series: pd.Series, tol: float = 0.10, window: int = FLATLINE_SAMPLES) -> pd.Series:
    roll_min = series.rolling(window, min_periods=window).min()
    roll_max = series.rolling(window, min_periods=window).max()
    return series.notna() & ((roll_max - roll_min) <= tol)

d = apply_fault(d, flatline_mask(d["oa_t"], tol=0.10))
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

SV-7 — Rate-of-change spike

FAULT_CONFIRM_SECONDS = 300
SPIKE_LIMIT = 16.0  # °F per sample

mask = d["oa_t"].notna() & (d["oa_t"].diff().abs() > SPIKE_LIMIT)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

Air handling units

Shared tunables (match SQL cookbook):

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

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

FAULT_CONFIRM_SECONDS = 300
DUCT_STATIC_ERR = 0.12
FAN_HI = 0.87

fan = norm_cmd(d["fan_cmd"])
mask = (
    d["duct_static"].notna() & d["duct_static_sp"].notna()
    & (d["duct_static"] < d["duct_static_sp"] - DUCT_STATIC_ERR)
    & (fan >= FAN_HI)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

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

FAULT_CONFIRM_SECONDS = 600

fan = norm_cmd(d["fan_cmd"])
mask = (
    fan > FAN_ON_MIN
    & d["mat"].notna() & d["oat"].notna() & d["rat"].notna()
    & ((d["mat"] - MIX_TOL) < np.minimum(d["rat"] - MIX_TOL, d["oat"] - MIX_TOL))
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

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

FAULT_CONFIRM_SECONDS = 600

fan = norm_cmd(d["fan_cmd"])
mask = (
    fan > FAN_ON_MIN
    & d["mat"].notna() & d["oat"].notna() & d["rat"].notna()
    & ((d["mat"] - MIX_TOL) > np.maximum(d["rat"] + MIX_TOL, d["oat"] + MIX_TOL))
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

FC4 — PID hunting (operating-state oscillation)

Reference implementation matching Open-FDD AHU FaultConditionFour. Counts operating-mode entry transitions per hour; flags when any hour exceeds DELTA_OS_MAX.

FAULT_CONFIRM_SECONDS = 3600  # PID hunting: use 1 h+ confirmation
DELTA_OS_MAX = 5
AHU_MIN_OA_DPR = 0.05

htg = norm_cmd(d["htg_valve_pct"])
clg = norm_cmd(d["clg_valve_pct"])
fan = norm_cmd(d["fan_cmd"])
econ = norm_cmd(d["oa_damper_pct"])

d["heating_mode"] = (
    (htg > 0) & (clg == 0) & (fan > 0) & (econ == AHU_MIN_OA_DPR)
).astype(int)
d["econ_only_cooling_mode"] = (
    (htg == 0) & (clg == 0) & (fan > 0) & (econ > AHU_MIN_OA_DPR)
).astype(int)
d["econ_plus_mech_cooling_mode"] = (
    (htg == 0) & (clg > 0) & (fan > 0) & (econ > AHU_MIN_OA_DPR)
).astype(int)
d["mech_cooling_only_mode"] = (
    (htg == 0) & (clg > 0) & (fan > 0) & (econ == AHU_MIN_OA_DPR)
).astype(int)

mode_cols = [
    "heating_mode", "econ_only_cooling_mode",
    "econ_plus_mech_cooling_mode", "mech_cooling_only_mode",
]
hourly = d.set_index("timestamp")[mode_cols].resample("H").apply(
    lambda x: (x.eq(1) & x.shift().ne(1)).sum()
)
raw_hourly = hourly[hourly.columns].gt(DELTA_OS_MAX).any(axis=1).astype(int)
# Broadcast hourly flag back to sample index (forward-fill within hour)
d["fault_raw"] = False
for ts, flag in raw_hourly.items():
    if flag:
        d.loc[d["timestamp"].dt.floor("H") == ts.floor("H"), "fault_raw"] = True

d["fault_confirmed"] = confirm_fault(
    d["fault_raw"], min_rows=FC4_CONFIRM_ROWS
)

FC5 — SAT cold when heating commanded (GL36 Rule D)

FAULT_CONFIRM_SECONDS = 600

fan = norm_cmd(d["fan_cmd"])
htg = norm_cmd(d["htg_valve_pct"])
mask = (
    d["sat"].notna() & d["mat"].notna()
    & (fan > FAN_ON_MIN) & (htg > 0.01)
    & ((d["sat"] + SUPPLY_TOL) <= (d["mat"] - MIX_TOL + DELTA_SUPPLY_FAN))
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

FC6 — Estimated OA fraction mismatch

FAULT_CONFIRM_SECONDS = 600
AIRFLOW_ERR = 0.15
OAT_RAT_DELTA_MIN = 5.0
AHU_MIN_CFM_DESIGN = 5000.0  # tune per site

fan = norm_cmd(d["fan_cmd"])
htg = norm_cmd(d["htg_valve_pct"])
clg = norm_cmd(d["clg_valve_pct"])
econ = norm_cmd(d["oa_damper_pct"])

d["rat_minus_oat"] = (d["rat"] - d["oat"]).abs()
d["percent_oa_calc"] = ((d["mat"] - d["rat"]) / (d["oat"] - d["rat"]).replace(0, np.nan)).clip(lower=0)
d["perc_OAmin"] = AHU_MIN_CFM_DESIGN / d["vav_total_flow"].replace(0, np.nan)
d["oa_err"] = (d["percent_oa_calc"] - d["perc_OAmin"]).abs()

os1 = (htg > 0) & (fan > 0)
os4 = (htg == 0) & (clg > 0) & (fan > 0) & (econ == AHU_MIN_OA_DPR)

mask = (
    d["mat"].notna() & d["oat"].notna() & d["rat"].notna() & d["vav_total_flow"].notna()
    & (d["rat_minus_oat"] >= OAT_RAT_DELTA_MIN)
    & (d["oa_err"] > AIRFLOW_ERR)
    & (os1 | os4)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

FC7 — SAT low with full heating (GL36 Rule E)

FAULT_CONFIRM_SECONDS = 600
SAT_ERR = 1.0

fan = norm_cmd(d["fan_cmd"])
htg = norm_cmd(d["htg_valve_pct"])
mask = (
    d["sat"].notna() & d["sat_sp"].notna()
    & (fan > FAN_ON_MIN)
    & (d["sat"] < d["sat_sp"] - SAT_ERR)
    & (htg > 0.9)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

FC8 — SAT above blend in economizer mode (GL36 Rule F)

FAULT_CONFIRM_SECONDS = 600

econ = norm_cmd(d["oa_damper_pct"])
clg = norm_cmd(d["clg_valve_pct"])
d["sat_mat_err"] = (d["sat"] - DELTA_SUPPLY_FAN - d["mat"]).abs()
d["sqrt_tol"] = np.sqrt(SUPPLY_TOL**2 + MIX_TOL**2)

mask = (
    d["sat"].notna() & d["mat"].notna()
    & (econ > AHU_MIN_OA_DPR) & (clg < 0.1)
    & (d["sat_mat_err"] > d["sqrt_tol"])
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

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

FAULT_CONFIRM_SECONDS = 600

econ = norm_cmd(d["oa_damper_pct"])
clg = norm_cmd(d["clg_valve_pct"])

mask = (
    d["oat"].notna() & d["sat_sp"].notna()
    & (econ > AHU_MIN_OA_DPR) & (clg < 0.1)
    & ((d["oat"] - MIX_TOL) > (d["sat_sp"] - DELTA_SUPPLY_FAN + MIX_TOL))
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

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

FAULT_CONFIRM_SECONDS = 600

econ = norm_cmd(d["oa_damper_pct"])
clg = norm_cmd(d["clg_valve_pct"])
d["abs_mat_oat"] = (d["mat"] - d["oat"]).abs()
d["sqrt_tol"] = np.sqrt(MIX_TOL**2 + MIX_TOL**2)

mask = (
    d["mat"].notna() & d["oat"].notna()
    & (clg > 0.01) & (econ > 0.9)
    & (d["abs_mat_oat"] > d["sqrt_tol"])
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

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

FAULT_CONFIRM_SECONDS = 600

econ = norm_cmd(d["oa_damper_pct"])
clg = norm_cmd(d["clg_valve_pct"])

mask = (
    d["oat"].notna() & d["sat_sp"].notna()
    & (clg > 0.01) & (econ > 0.9)
    & ((d["oat"] + MIX_TOL) < (d["sat_sp"] - DELTA_SUPPLY_FAN - MIX_TOL))
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

FC12 — SAT above blend in cooling (GL36 Rule J)

FAULT_CONFIRM_SECONDS = 600

econ = norm_cmd(d["oa_damper_pct"])
clg = norm_cmd(d["clg_valve_pct"])
sat_check = d["sat"] - SUPPLY_TOL - DELTA_SUPPLY_FAN
mat_check = d["mat"] + MIX_TOL

mask = (
    d["sat"].notna() & d["mat"].notna() & (clg > 0.01)
    & (sat_check > mat_check)
    & ((econ == AHU_MIN_OA_DPR) | (econ > 0.9))
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

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

FAULT_CONFIRM_SECONDS = 600
SAT_ERR = 1.0

econ = norm_cmd(d["oa_damper_pct"])
clg = norm_cmd(d["clg_valve_pct"])

mask = (
    d["sat"].notna() & d["sat_sp"].notna() & (clg > 0.01)
    & (d["sat"] > d["sat_sp"] + SAT_ERR)
    & ((econ == AHU_MIN_OA_DPR) | (econ > 0.9))
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

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

FAULT_CONFIRM_SECONDS = 600
COIL_TOL = 1.15

econ = norm_cmd(d["oa_damper_pct"])
clg = norm_cmd(d["clg_valve_pct"])
htg = norm_cmd(d["htg_valve_pct"])
fan = norm_cmd(d["fan_cmd"])

d["clg_delta"] = d["clg_coil_enter_t"] - d["clg_coil_leave_t"]
d["clg_sqrt"] = np.sqrt(COIL_TOL**2 + COIL_TOL**2) + DELTA_SUPPLY_FAN

mask = (
    d["clg_coil_enter_t"].notna() & d["clg_coil_leave_t"].notna()
    & (d["clg_delta"] >= d["clg_sqrt"])
    & (((econ > AHU_MIN_OA_DPR) & (clg < 0.1)) | ((htg > 0) & (fan > 0)))
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

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

FAULT_CONFIRM_SECONDS = 600
COIL_TOL = 1.15

econ = norm_cmd(d["oa_damper_pct"])
clg = norm_cmd(d["clg_valve_pct"])

d["htg_delta"] = d["htg_coil_enter_t"] - d["htg_coil_leave_t"]
d["htg_sqrt"] = np.sqrt(COIL_TOL**2 + COIL_TOL**2) + DELTA_SUPPLY_FAN

mask = (
    d["htg_coil_enter_t"].notna() & d["htg_coil_leave_t"].notna()
    & (d["htg_delta"] >= d["htg_sqrt"])
    & (
        ((econ > AHU_MIN_OA_DPR) & (clg < 0.1))
        | ((clg > 0.01) & (econ == AHU_MIN_OA_DPR))
        | ((clg > 0.01) & (econ > 0.9))
    )
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

AHU — Additional patterns

SAT deviation from setpoint

FAULT_CONFIRM_SECONDS = 600
ERR = 5.0

mask = d["sat"].notna() & d["sat_sp"].notna() & (d["sat"].sub(d["sat_sp"]).abs() > ERR)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

Duct static pressure high

FAULT_CONFIRM_SECONDS = 300
MARGIN = 0.25

mask = d["duct_static"].notna() & d["duct_static_sp"].notna() & (
    d["duct_static"] > d["duct_static_sp"] + MARGIN
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

Fan off but duct still warm

FAULT_CONFIRM_SECONDS = 600
DELTA = 15.0

mask = (
    d["fan_cmd"].notna() & d["duct_t"].notna() & d["oa_t"].notna()
    & (d["fan_cmd"] == False) & (d["duct_t"] > d["oa_t"] + DELTA)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

Heating and cooling simultaneous

FAULT_CONFIRM_SECONDS = 300

mask = d["htg_valve_pct"].notna() & d["clg_valve_pct"].notna() & (
    (d["htg_valve_pct"] > 10.0) & (d["clg_valve_pct"] > 10.0)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

VAV zones

VAV-1 — Zone comfort band

FAULT_CONFIRM_SECONDS = 900

v = df[df["equipment_id"] == "equip:your-vav"].copy()
mask = v["zone_t"].notna() & ((v["zone_t"] < 68.0) | (v["zone_t"] > 76.0))
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

VAV-2 — Night setback miss

FAULT_CONFIRM_SECONDS = 1800

mask = v["zone_t"].notna() & v["occ_mode"].notna() & (
    (v["occ_mode"] == "unoccupied") & (v["zone_t"] > 78.0)
)
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

VAV-3 — Excessive reheat during warm weather

FAULT_CONFIRM_SECONDS = 300

reheat = norm_cmd(v["reheat_valve_pct"])
mask = v["oa_t"].notna() & (v["oa_t"] > 78.0) & (reheat > 0.52)
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

VAV-4 — Damper stuck at full open

FAULT_CONFIRM_SECONDS = 900
FULL_OPEN = 97.5
ROLL = 105  # samples sustained

mask = (
    v["damper_pct"].notna()
    & (v["damper_pct"] > FULL_OPEN)
    & (v["damper_pct"].rolling(ROLL, min_periods=ROLL).min() > FULL_OPEN)
)
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

Economizer & ventilation

ECON-1 — Economizer stuck closed

FAULT_CONFIRM_SECONDS = 600

mask = (
    d["fan_cmd"].astype(bool)
    & d["oa_damper_pct"].notna() & d["oa_t"].notna()
    & (d["oa_damper_pct"] < 5.0) & (d["oa_t"] > 55.0)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

ECON-2 — Economizing when outdoor unfavorable

FAULT_CONFIRM_SECONDS = 300

mask = d["oa_t"].notna() & d["oa_damper_pct"].notna() & (
    (d["oa_t"] > 63.0) & (d["oa_damper_pct"] > 0.42)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

ECON-3 — Mech cooling when econ available

FAULT_CONFIRM_SECONDS = 300

mask = (
    d["oa_t"].notna() & d["oa_damper_pct"].notna() & d["clg_valve_pct"].notna()
    & (d["oa_t"] < 63.0) & (d["oa_damper_pct"] < 0.32) & (d["clg_valve_pct"] > 0.01)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

ECON-4 — Low estimated OA fraction

FAULT_CONFIRM_SECONDS = 600
OA_MIN_PCT = 21.0

fan = norm_cmd(d["fan_cmd"])
d["oa_frac"] = (d["mat"] - d["rat"]) / (d["oat"] - d["rat"]).replace(0, np.nan) * 100.0

mask = (
    fan > FAN_ON_MIN
    & d["mat"].notna() & d["rat"].notna() & d["oat"].notna()
    & ((d["rat"] - d["oat"]).abs() > 2.2)
    & (d["oa_frac"] < OA_MIN_PCT)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

ECON-5 — Preheat over-conditioning

FAULT_CONFIRM_SECONDS = 300

mask = (
    d["preheat_leave_t"].notna() & d["sat_sp"].notna() & d["oa_t"].notna() & d["htg_valve_pct"].notna()
    & (d["htg_valve_pct"] > 0.01)
    & (
        ((d["oa_t"] > d["sat_sp"]) & (d["preheat_leave_t"] - d["oa_t"] > 2.2))
        | ((d["oa_t"] < d["sat_sp"]) & (d["preheat_leave_t"] - d["sat_sp"] > 2.2))
    )
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

Central plants

CHW-1 — Low chilled-water delta-T

FAULT_CONFIRM_SECONDS = 900
MIN_DT = 4.0

p = df[df["equipment_id"] == "equip:your-chiller-plant"].copy()
p["chw_dt"] = p["chw_return_t"] - p["chw_supply_t"]
mask = (
    p["chw_supply_t"].notna() & p["chw_return_t"].notna()
    & p["chw_pump_cmd"].astype(bool)
    & (p["chw_dt"] < MIN_DT)
)
p = apply_fault(p, mask)
p["fault_confirmed"] = confirm_fault(p["fault_raw"])

CHW-2 — DP below SP at max pump speed

FAULT_CONFIRM_SECONDS = 300
DP_MARGIN = 2.2
PMP_HI = 0.87

pump = norm_cmd(p["chw_pump_cmd"])
mask = (
    p["chw_dp"].notna() & p["chw_dp_sp"].notna()
    & (p["chw_dp"] < p["chw_dp_sp"] - DP_MARGIN)
    & (pump >= PMP_HI)
)
p = apply_fault(p, mask)
p["fault_confirmed"] = confirm_fault(p["fault_raw"])

CHW-3 — Plant supply temp outside deadband

FAULT_CONFIRM_SECONDS = 300
SP_BAND = 2.2

pump = norm_cmd(p["chw_pump_cmd"])
mask = (
    pump > 0.01
    & p["chw_supply_t"].notna() & p["chw_supply_t_sp"].notna()
    & (
        (p["chw_supply_t"] < p["chw_supply_t_sp"] - SP_BAND)
        | (p["chw_supply_t"] > p["chw_supply_t_sp"] + SP_BAND)
    )
)
p = apply_fault(p, mask)
p["fault_confirmed"] = confirm_fault(p["fault_raw"])

CHW-4 — Flow high at max pump

FAULT_CONFIRM_SECONDS = 300
FLOW_HI = 1100.0
PMP_HI = 0.87

pump = norm_cmd(p["chw_pump_cmd"])
mask = p["chw_flow"].notna() & (p["chw_flow"] > FLOW_HI) & (pump >= PMP_HI)
p = apply_fault(p, mask)
p["fault_confirmed"] = confirm_fault(p["fault_raw"])

Heat pumps

HP-1 — Discharge cold when heating

FAULT_CONFIRM_SECONDS = 600
MIN_SAT = 85.0
ZONE_COLD = 69.0

hp = df[df["equipment_id"] == "equip:your-hp"].copy()
fan = norm_cmd(hp["fan_cmd"])
mask = (
    hp["sat"].notna() & hp["zone_t"].notna()
    & (fan > FAN_ON_MIN)
    & (hp["zone_t"] < ZONE_COLD)
    & (hp["sat"] < MIN_SAT)
)
hp = apply_fault(hp, mask)
hp["fault_confirmed"] = confirm_fault(hp["fault_raw"])

Weather station

WX-1 — Temperature spike

FAULT_CONFIRM_SECONDS = 300
SPIKE_LIMIT = 16.0

wx = df[df["equipment_id"] == "equip:weather-station"].copy()
mask = wx["oa_t"].notna() & (wx["oa_t"].diff().abs() > SPIKE_LIMIT)
wx = apply_fault(wx, mask)
wx["fault_confirmed"] = confirm_fault(wx["fault_raw"])

WX-2 — Gust lower than sustained wind

FAULT_CONFIRM_SECONDS = 300

mask = wx["wind_gust"].notna() & wx["wind_speed"].notna() & (wx["wind_gust"] < wx["wind_speed"])
wx = apply_fault(wx, mask)
wx["fault_confirmed"] = confirm_fault(wx["fault_raw"])

Trim & respond advisory

Use FAULT_CONFIRM_SECONDS = 1800 or higher for advisory rules.

TRIM-1 — Duct static trim advisory

FAULT_CONFIRM_SECONDS = 1800

mask = (
    d["duct_static"].notna() & d["vav_press_req_sum"].notna()
    & (d["duct_static"] > 0.80) & (d["vav_press_req_sum"] < 1.0)
    & (d["duct_static"] > 1.35)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=FAULT_CONFIRM_SECONDS // POLL_SECONDS)

TRIM-2 — Chiller plant enable advisory

FAULT_CONFIRM_SECONDS = 1800

p = df[df["equipment_id"] == "equip:your-chiller-plant"].copy()
mask = p["chw_valve_req_count"].notna() & (p["chw_valve_req_count"] < 2)
p = apply_fault(p, mask)
p["fault_confirmed"] = confirm_fault(p["fault_raw"], min_rows=FAULT_CONFIRM_SECONDS // POLL_SECONDS)

TRIM-3 — HWST trim advisory

FAULT_CONFIRM_SECONDS = 1800

hw = df[df["equipment_id"] == "equip:your-hw-plant"].copy()
mask = hw["hw_supply_t"].notna() & hw["hw_reset_req_sum"].notna() & (
    (hw["hw_supply_t"] > 160.0) & (hw["hw_reset_req_sum"] < 1.0)
)
hw = apply_fault(hw, mask)
hw["fault_confirmed"] = confirm_fault(hw["fault_raw"], min_rows=FAULT_CONFIRM_SECONDS // POLL_SECONDS)

TRIM-4 — CHW plant reset advisory

FAULT_CONFIRM_SECONDS = 1800

mask = p["chw_supply_t"].notna() & p["chw_reset_req_sum"].notna() & (
    (p["chw_supply_t"] < 45.0) & (p["chw_reset_req_sum"] < 1.0)
)
p = apply_fault(p, mask)
p["fault_confirmed"] = confirm_fault(p["fault_raw"], min_rows=FAULT_CONFIRM_SECONDS // POLL_SECONDS)

Extended rule families (v2)

Mirrors DataFusion SQL v2 section. Import prerequisite macros helpers as needed.

RESET-1 — SAT reset not tracking outdoor air

FAULT_CONFIRM_SECONDS = 900
SAT_RESET_ERR = 3.0

def expected_sat_sp(oat: pd.Series) -> pd.Series:
    return 52.0 + 0.25 * (oat - 65.0)

base = macro_fan_proven_on(d) if "fan_cmd" in d else pd.Series(True, index=d.index)
mask = (
    base & d["sat_sp"].notna() & d["oat"].notna()
    & (d["sat_sp"].sub(expected_sat_sp(d["oat"])).abs() > SAT_RESET_ERR)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=FAULT_CONFIRM_SECONDS // POLL_SECONDS)

SCHED-1 — Unoccupied runtime

FAULT_CONFIRM_SECONDS = 1800
mask = d["occ_mode"].eq("unoccupied") & d["fan_status"].astype(bool)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=FAULT_CONFIRM_SECONDS // POLL_SECONDS)

OVR-1 — Persistent override

FAULT_CONFIRM_SECONDS = 3600
mask = d["override_active"].fillna(False).astype(bool)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=FAULT_CONFIRM_SECONDS // POLL_SECONDS)

CMD-1 — Fan cmd/status mismatch

FAULT_CONFIRM_SECONDS = 600
cmd_on = norm_cmd(d["fan_cmd"]) >= 0.05
mask = d["fan_status"].notna() & (cmd_on != d["fan_status"].astype(bool))
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

OA-1 — Low OA fraction

FAULT_CONFIRM_SECONDS = 900
MIN_OA = 0.15
oa_frac = (d["mat"] - d["rat"]) / (d["oa_t"] - d["rat"]).replace(0, np.nan)
mask = (
    d["fan_status"].astype(bool) & d["oa_t"].notna() & d["rat"].notna() & d["mat"].notna()
    & ((d["rat"] - d["oa_t"]).abs() > 0.5) & (oa_frac < MIN_OA)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

VLV-1 — Cooling valve leakage

FAULT_CONFIRM_SECONDS = 900
clg = norm_cmd(d["clg_valve_pct"])
mask = (
    d["sat"].notna() & d["sat_sp"].notna()
    & (clg <= 0.05) & (d["sat"] < d["sat_sp"] - 2.0)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

DMP-1 — OA damper leakage

FAULT_CONFIRM_SECONDS = 900
LEAK_DELTA = 2.0
damper = norm_cmd(d["oa_damper_pct"])
mask = (
    d["oa_t"].notna() & d["mat"].notna()
    & (damper <= 0.05) & (d["mat"].sub(d["oa_t"]).abs() < LEAK_DELTA)
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

VAV-5 — Airflow sensor bias

FAULT_CONFIRM_SECONDS = 900
damper = norm_cmd(v["damper_pct"])
mask = v["zone_flow"].notna() & (v["zone_flow"] > 50.0) & (damper < 0.10)
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

PLANT-1 — CHW DP reset missing

FAULT_CONFIRM_SECONDS = 900
pump = norm_cmd(p["chw_pump_cmd"])
mask = (
    p["chw_dp_sp"].notna() & p["chw_load_pct"].notna()
    & (pump > 0.01) & (p["chw_load_pct"] < 0.40) & (p["chw_dp_sp"] > 18.0)
)
p = apply_fault(p, mask)
p["fault_confirmed"] = confirm_fault(p["fault_raw"])

SP-HIGH — Occupied setpoint too high

FAULT_CONFIRM_SECONDS = 900
mask = (
    v["zone_t_sp"].notna() & v["occ_mode"].eq("occupied") & (v["zone_t_sp"] > 76.0)
)
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

SP-LOW — Occupied setpoint too low

FAULT_CONFIRM_SECONDS = 900
mask = (
    v["zone_t_sp"].notna() & v["occ_mode"].eq("occupied") & (v["zone_t_sp"] < 68.0)
)
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

KPI-1 — Performance score (advisory)

# Roll up confirmed fault counts by taxonomy family over 7 days — advisory only.
# See benchmark-strategy.md for weight defaults.

Extended rule families (P2)

Mirrors SQL P2 section.

VAV-6 — Reheat when cooling available

FAULT_CONFIRM_SECONDS = 300
reheat = norm_cmd(v["reheat_valve_pct"])
mask = (
    v.get("clg_available", False).astype(bool)
    & v["oa_t"].notna() & (v["oa_t"] < 65.0) & (reheat > 0.25)
)
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

VAV-7 — Minimum airflow violation

FAULT_CONFIRM_SECONDS = 900
fan = norm_cmd(v["fan_cmd"]) if "fan_cmd" in v else 1.0
mask = (
    v["zone_flow"].notna() & v["min_flow_sp"].notna()
    & (fan > 0.01) & (v["zone_flow"] < v["min_flow_sp"])
)
v = apply_fault(v, mask)
v["fault_confirmed"] = confirm_fault(v["fault_raw"])

TOWER-1 — Cooling tower approach high

FAULT_CONFIRM_SECONDS = 900
MAX_APPROACH = 7.0
t = df[df["equipment_id"] == "equip:your-cooling-tower"].copy()
fan = norm_cmd(t["tower_fan_cmd"])
mask = (
    t["tower_leaving_t"].notna() & t["wb_t"].notna()
    & (fan > 0.01) & ((t["tower_leaving_t"] - t["wb_t"]) > MAX_APPROACH)
)
t = apply_fault(t, mask)
t["fault_confirmed"] = confirm_fault(t["fault_raw"])

CTRL-2 — Generic control loop hunting

FAULT_CONFIRM_SECONDS = 3600
HUNT_REVERSALS = 8
ROLL = 60  # samples ~1 h @ 60 s poll

s = d["duct_static"].diff().apply(lambda x: 1 if x > 0 else (-1 if x < 0 else 0))
reversals = (s != s.shift(1)) & (s != 0)
mask = reversals.rolling(ROLL, min_periods=ROLL).sum() > HUNT_REVERSALS
d = apply_fault(d, mask.fillna(False))
d["fault_confirmed"] = confirm_fault(d["fault_raw"], min_rows=FAULT_CONFIRM_SECONDS // POLL_SECONDS)

SV-7 — Wrong-units heuristic

FAULT_CONFIRM_SECONDS = 300
mask = d["oa_t"].notna() & ((d["oa_t"] > 200.0) | (d["oa_t"] < -60.0))
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

OA-2 — DCV minimum OA not met

FAULT_CONFIRM_SECONDS = 900
mask = (
    d["oa_flow"].notna() & d["oa_flow_min_sp"].notna()
    & d["occ_mode"].eq("occupied") & d["fan_status"].astype(bool)
    & (d["oa_flow"] < d["oa_flow_min_sp"])
)
d = apply_fault(d, mask)
d["fault_confirmed"] = confirm_fault(d["fault_raw"])

Framework docs

Doc Purpose
Taxonomy Public fault taxonomy
Rule schema Metadata source of truth
Gap matrix Literature coverage
Parity matrix SQL ↔ Pandas audit
Roadmap Expansion priorities
Prerequisite macros Shared guards
Benchmark strategy Validation scenarios
Doc template Per-rule standard

Export to Open-FDD SQL

After validating in Pandas, port the boolean expression to DataFusion SQL for production on the edge:

Pandas DataFusion SQL
s.notna() col IS NOT NULL
a & b a AND b
a \| b a OR b
~a NOT a
s.abs() ABS(col)
np.minimum(a,b) LEAST(a,b)
np.maximum(a,b) GREATEST(a,b)
FAULT_CONFIRM_SECONDS = 300 "confirmation_seconds": 300 in API

Test SQL with POST /api/fdd-rules/{id}/test-sql before activate.


Next: DataFusion SQL cookbook — edge runtime rules for Open-FDD