Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

AHU-0029 — AHU operating mode mismatch

Statusverified — engine e2ff2f8, cxf:fnv1a128:502800a60ac3786128ffd6f563283d3b, 2026-08-17
Severity3
Methodrule
Phase2
CategoryCRITICAL_WASTE
ConfidenceMEDIUM
EstimationPROXY_ESTIMATION
G36
ClustersCLU-01
Suppresses
Suppressed by
RelatedAHU-0016, AHU-0025
Playbookssimultaneous-hc
SourceHVAC FDD Reference v1.0 §9, AHU-0029; Torabi et al. 2022; Gunay et al. 2022; G36 §5.16; PNNL-25985 EEM-38
Operating statesall occupied modes (host-gated)

Preconditions (host-enforced): The host derives expected_mode per G36 §5.16 from OAT, zone demands, and the occupancy schedule, and reports NO_EVAL — never healthy — whenever that derivation is unavailable, the unit is in a mode this rule does not encode (warmup, cooldown, setback, unoccupied), or the unit is inside a mode-transition window where actuators are still stroking to their new positions. The OAT sensor feeding the derivation must be trustworthy: a biased reading yields the wrong expected mode and this rule then reports a mismatch that the sequence did not commit (diagnosis 4), so OAT data quality gates this rule even though oat is not an input to the graph.

Points: expected_mode, htg_vlv_cmd, clg_vlv_cmd, oa_dmpr_cmd

Outputs:

  • yFault — True while an actuator has contradicted the expected operating mode continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
heating_mode_code11kHeat.kValue of expected_mode meaning HEATING
econ_mode_code21kEcon.kValue of expected_mode meaning ECONOMIZER
mech_cooling_mode_code31kMech.kValue of expected_mode meaning MECHANICAL_COOLING_MIN_OA
valve_open_threshold5.0%clgOpen.t, htgOpen.tValve command above which a coil counts as active; binds both the heating and the cooling test
econ_damper_threshold30.0%dmprEcon.tOA damper command above which the damper counts as economizing rather than sitting at its minimum position
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (30 min)

Description

Valve and damper positions contradict the operating mode the current conditions call for: the cooling coil open while the unit should be heating, the heating coil open while it should be economizing, the outdoor damper economizing while it should hold minimum outdoor air. Each actuator tracks its own loop faithfully, so nothing looks broken from the equipment side — only the sequencing that arbitrates between them is wrong, and comfort is usually maintained while two subsystems run against each other. Within CLU-01 this rule complements the trigger AHU-0016: FC-050 sees both coils fighting each other, this one sees a single subsystem fighting the mode, and a damper economizing during minimum-outdoor-air operation is invisible to FC-050 entirely.

Detection Logic

clg_open  = clg_vlv_cmd > valve_open_threshold
htg_open  = htg_vlv_cmd > valve_open_threshold
dmpr_econ = oa_dmpr_cmd > econ_damper_threshold

mismatch = (expected_mode = heating_mode_code      AND (clg_open OR dmpr_econ))
        OR (expected_mode = mech_cooling_mode_code AND (htg_open OR dmpr_econ))
        OR (expected_mode = econ_mode_code         AND (clg_open OR htg_open))

yFault = mismatch sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0029 block graph

expected_mode is the host’s answer to “what should this unit be doing right now”, per G36 §5.16; the graph only asks whether the actuators agree with it. Three integer constants (kHeat, kEcon, kMech) decode the mode into three mutually exclusive booleans, and three threshold tests decode the actuators. Each mode gate then ANDs its mode flag with the disjunction of the actuators that contradict it, so each test appears in exactly the two modes where it means something: an open cooling valve is wrong in HEATING and ECONOMIZER but right in MECHANICAL_COOLING_MIN_OA, and an open outdoor damper is wrong in HEATING and MECHANICAL_COOLING_MIN_OA but is the entire point of ECONOMIZER. All three comparisons are strict, so a valve parked at exactly 5% or a damper at exactly 30% is not a violation. persist requires 30 minutes of continuous mismatch, giving actuators half an hour to catch up after a mode flip; delayOnInit = true holds that window across a controller restart. If expected_mode matches none of the three codes, every mode gate is false and the rule is silent no matter what the actuators are doing — that silence is NO_EVAL territory, not a health claim (see Deviations).

Possible Diagnoses

  1. Sequencing logic error in the BAS programming — the mode arbitration itself is wrong, so the unit is executing the wrong sequence correctly
  2. Mode transition deadband too narrow: the unit hunts between modes and actuators never settle where the current mode wants them
  3. Valve stuck in the wrong position — actuator or linkage failure, or a normally-open valve with no signal
  4. Incorrect OAT sensor causing wrong mode selection: the sequence and the actuators are both fine and the derived expected mode is the thing that is wrong
  5. Manual override left on a valve or damper command (BACnet priority array)

Energy Impact

CRITICAL_WASTE, MEDIUM confidence, PROXY_ESTIMATION. There is no single waste term, because the cost depends on which subsystem is running against the mode. The reference gives 5–20% of the affected subsystem’s energy while the unit runs in the wrong mode (PNNL-25985 EEM-38), and the worst case — both coils active — is bounded by the AHU-0016 formula: waste_kw = htg_vlv_cmd/100 × ahu_htg_capacity_kw + clg_vlv_cmd/100 × ahu_clg_capacity_kw. PROXY rather than DIRECT because the rule observes commands and a derived mode, not thermal quantities, and the counterfactual is not measured. Sensitive to both climates.

Emissions Impact

Scope 1 + 2, PROXY_EMISSIONS, MEDIUM confidence; typical 500–5,000 kg CO₂e/yr. The split between inventories follows whichever subsystem is running in the wrong mode — gas at the boiler is scope 1, chiller and fan electricity are scope 2 — and a mismatch usually engages both. Avoided-emissions basis: marginal operating emissions rate (MOER).

Deviations

  • This rule consumes expected_mode where the reference lists oat. The reference’s own logic opens with expected_mode = determine_g36_mode(OAT, zone_demands, schedule) and annotates it host-side; deriving the mode needs zone demands and the schedule, neither of which is an AHU point, so the graph consumes the host’s output (precedent: AHU-0026 and occ_schedule). OAT survives as a precondition.
  • The integer encoding (1 = HEATING, 2 = ECONOMIZER, 3 = MECHANICAL_COOLING_MIN_OA) is this library’s convention — the reference names the modes without numbering them. The three codes are parameters, so a host with its own mode enum rebinds the constants instead of editing the graph.
  • An expected_mode outside the three codes leaves the rule structurally silent: all three gates go false and both coils can be wide open with no fault raised. Warmup, cooldown, setback, and unoccupied operation are states this rule cannot judge, and the host must report NO_EVAL for them. An explicit “unmapped mode” output was rejected — the host already knows which mode it derived.
  • All three comparisons are strict (>); the reference says nothing about boundary behavior, so a valve at exactly 5% and a damper on its 30% minimum-position setpoint stay out of the alarm.
  • valve_open_threshold is one card parameter bound to two CXF paths (clgOpen.t, htgOpen.t), matching the reference’s single tunable. Hosts must set both together; per-coil thresholds are a documented divergence.
  • The reference states three independent IF expected_mode = … branches; this rule ORs them behind one persistence timer, the shape every other card uses. Only one gate can be true at a time, so nothing is lost logically, but the output does not say which mode or actuator raised it — trend the four points.
  • persist.delayTime = 1800 s with delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a mismatch already present at load waits out the full 30 minutes rather than alarming on the first tick after a controller restart.
  • Severity 3 (warning) and the MEDIUM/PROXY grades come from the reference’s chapter 9 card, its only severity statement for this fault (the §5.8.1 index carries no severity column). This chapter’s README index still shows severity 2 and needs the same correction AHU-0025 received.
  • Frontmatter g36 is null even though G36 §5.16 is in source: SCHEMA.md reserves that field for the 001–049 G36-derived rules, and this is a research-backed 050-range rule citing G36 for the mode definitions only.

Notes

The rule tests only for actuators open when the mode says they should be shut. The converse — a damper stuck at minimum in ECONOMIZER, a cooling valve that will not open under mechanical cooling — is a failure to deliver, not to sequence, and belongs to AHU-0017 and the temperature-control rules. A clear yFault means no actuator is contradicting the mode, nothing more.

Check diagnosis 4 before anyone edits a sequence: this rule consumes a derived point, so a bad OAT reading arrives pre-laundered as a wrong mode and looks exactly like a programming error. AHU-0028’s envelope test is built from oat, so an outdoor sensor bad enough to select the wrong mode often shows up there too. Within CLU-01, clear the trigger (AHU-0016) first — a valve held open by a fighting control loop also contradicts whichever mode is active (playbook simultaneous-hc, steps 2.1–2.2).

Test Vectors

11 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
heating_mode_consistentReference vector (oat 0 °C staged as expected_mode = 1, HEATING): heating valve at 40%, cooling valve shut, OA damper at its 10% minimum. Every actuator agrees with the mode, so nothing fires.
heating_mode_cooling_valve_openReference vector: HEATING mode with the cooling valve at 20% — the chiller is fighting the mode. Violation is present from t=0, so delayOnInit makes the alarm land 1800 s in.
econ_mode_consistentReference vector (oat 15 °C staged as expected_mode = 2, ECONOMIZER): both coils shut, OA damper at 80%. Free cooling done right — the damper test is not applied in ECONOMIZER, where an open damper is the point.
econ_mode_heating_valve_openReference vector: ECONOMIZER mode with the heating valve at 25% — the boiler is reheating air the damper just brought in for free cooling. Alarms after alarm_delay.
mech_cooling_heating_valve_openMECHANICAL_COOLING_MIN_OA with the cooling valve at 60% (legitimate) but the heating valve at 30% — classic simultaneous heating and cooling seen from the mode side. Damper at its 10% minimum is correct for this mode.
heating_mode_damper_economizingHEATING mode with the OA damper at 40% — the unit is pulling in cold outdoor air while the heating coil pays for it. Both coils are otherwise consistent with the mode; the damper alone raises the fault.
thresholds_exactly_at_edgeBoundary: in HEATING mode the cooling valve sits exactly at valve_open_threshold (5.0%) and the damper exactly at econ_damper_threshold (30.0%). Both tests are strict >, so neither counts as a violation and the rule stays clear.
thresholds_just_above_edgeBoundary from the other side: the same HEATING case with the cooling valve at 5.1% and the damper at 30.1%. Both tests now pass, so the alarm lands one alarm_delay after t=0.
unmapped_mode_stays_silentexpected_mode = 0 matches none of the three code constants, so every mode gate is false and both coils wide open with the damper at 100% raise nothing. Structural silence, not a health claim — the host owes a NO_EVAL here.
mode_switch_heals_before_delayCooling valve holds 60% while the derived mode is HEATING — a violation — until the mode flips to MECHANICAL_COOLING_MIN_OA at t=1200 s, where 60% cooling is exactly right. The violation ends 600 s short of alarm_delay, so the timer resets and no alarm is ever raised.
violation_alarms_then_clearsHEATING mode with the cooling valve at 20% asserts the alarm at 1800 s; the valve is driven shut at t=3000 s and yFault drops on that same tick, since TrueDelay only delays the rising edge.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "heating_mode_consistent",
      "description": "Reference vector (oat 0 \u00b0C staged as expected_mode = 1, HEATING): heating valve at 40%, cooling valve shut, OA damper at its 10% minimum. Every actuator agrees with the mode, so nothing fires.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "heating_mode_cooling_valve_open",
      "description": "Reference vector: HEATING mode with the cooling valve at 20% \u2014 the chiller is fighting the mode. Violation is present from t=0, so delayOnInit makes the alarm land 1800 s in.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 20.0,
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "econ_mode_consistent",
      "description": "Reference vector (oat 15 \u00b0C staged as expected_mode = 2, ECONOMIZER): both coils shut, OA damper at 80%. Free cooling done right \u2014 the damper test is not applied in ECONOMIZER, where an open damper is the point.",
      "inputs": {
        "expected_mode": 2,
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 80.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "econ_mode_heating_valve_open",
      "description": "Reference vector: ECONOMIZER mode with the heating valve at 25% \u2014 the boiler is reheating air the damper just brought in for free cooling. Alarms after alarm_delay.",
      "inputs": {
        "expected_mode": 2,
        "htg_vlv_cmd": 25.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 80.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "mech_cooling_heating_valve_open",
      "description": "MECHANICAL_COOLING_MIN_OA with the cooling valve at 60% (legitimate) but the heating valve at 30% \u2014 classic simultaneous heating and cooling seen from the mode side. Damper at its 10% minimum is correct for this mode.",
      "inputs": {
        "expected_mode": 3,
        "htg_vlv_cmd": 30.0,
        "clg_vlv_cmd": 60.0,
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "heating_mode_damper_economizing",
      "description": "HEATING mode with the OA damper at 40% \u2014 the unit is pulling in cold outdoor air while the heating coil pays for it. Both coils are otherwise consistent with the mode; the damper alone raises the fault.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 45.0,
        "clg_vlv_cmd": 0.0,
        "oa_dmpr_cmd": 40.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "thresholds_exactly_at_edge",
      "description": "Boundary: in HEATING mode the cooling valve sits exactly at valve_open_threshold (5.0%) and the damper exactly at econ_damper_threshold (30.0%). Both tests are strict `>`, so neither counts as a violation and the rule stays clear.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 5.0,
        "oa_dmpr_cmd": 30.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "thresholds_just_above_edge",
      "description": "Boundary from the other side: the same HEATING case with the cooling valve at 5.1% and the damper at 30.1%. Both tests now pass, so the alarm lands one alarm_delay after t=0.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": 5.1,
        "oa_dmpr_cmd": 30.1
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "unmapped_mode_stays_silent",
      "description": "expected_mode = 0 matches none of the three code constants, so every mode gate is false and both coils wide open with the damper at 100% raise nothing. Structural silence, not a health claim \u2014 the host owes a NO_EVAL here.",
      "inputs": {
        "expected_mode": 0,
        "htg_vlv_cmd": 80.0,
        "clg_vlv_cmd": 80.0,
        "oa_dmpr_cmd": 100.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "mode_switch_heals_before_delay",
      "description": "Cooling valve holds 60% while the derived mode is HEATING \u2014 a violation \u2014 until the mode flips to MECHANICAL_COOLING_MIN_OA at t=1200 s, where 60% cooling is exactly right. The violation ends 600 s short of alarm_delay, so the timer resets and no alarm is ever raised.",
      "inputs": {
        "expected_mode": [
          {
            "t": 0,
            "value": 1
          },
          {
            "t": 1200,
            "value": 3
          }
        ],
        "htg_vlv_cmd": 0.0,
        "clg_vlv_cmd": 60.0,
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "violation_alarms_then_clears",
      "description": "HEATING mode with the cooling valve at 20% asserts the alarm at 1800 s; the valve is driven shut at t=3000 s and yFault drops on that same tick, since TrueDelay only delays the rising edge.",
      "inputs": {
        "expected_mode": 1,
        "htg_vlv_cmd": 40.0,
        "clg_vlv_cmd": [
          {
            "t": 0,
            "value": 20.0
          },
          {
            "t": 3000,
            "value": 0.0
          }
        ],
        "oa_dmpr_cmd": 10.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 3300,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}