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-0006 — OA fraction deviation

Statusverified — engine e2ff2f8, cxf:fnv1a128:89896c77a2581ab67082184d505bc7db, 2026-08-17
Severity3
Methodrule
Phase1
CategoryEXCESS_CONSUMPTION
ConfidenceMEDIUM
EstimationDIRECT_MEASUREMENT
G36§5.16.14 FC#6
Clusters
Suppresses
Suppressed byAHU-0028
RelatedAHU-0021, AHU-0030, AHU-0017, AHU-0028
Playbookseconomizer-failure
SourceHVAC FDD Reference v1.0 §5.8.1 (index; card abbreviated); G36 §5.16.14 FC#6 (text per Addendum u public review); NISTIR 7365 (defaults provenance); PNNL-25985 EEM-06 (OA damper faults), PNNL EEM-17 (demand control ventilation)
Operating statesOS#1 and OS#4 (minimum outdoor air states) — host-gated

Preconditions (host-enforced): Supply fan running, and the unit in one of the two minimum-OA operating states G36 defines by actuator signature: OS#1 (HC > 0, CC = 0, OA damper at minimum) or OS#4 (HC = 0, CC > 0, OA damper at minimum). The rule must not be evaluated in OS#2 or OS#3, where the outdoor-air fraction is supposed to exceed the minimum — economizing looks identical to a stuck damper from these three temperatures — nor in OS#5, where no damper position is defined at all. Suspend evaluation for ModeDelay (30 min) after any mode or operating-state change in a zone group the AHU serves, while the dampers are still stroking and the mixing box has not settled. Silence the rule while AHU-0028 is active: the fraction is a ratio of temperature differences, so a MAT outside the OAT/RAT envelope moves it directly. min_oa_fraction must be retuned to track the active minimum-OA setpoint whenever the ventilation reset moves it (see Deviations). The temperature-difference conjunct is signalled in-rule by yTempDeltaOk; when it is false the verdict is NO_EVAL, not healthy, and the same is true whenever any of the gates above is unmet. Additionally suspend evaluation (NO_EVAL) while demand-controlled ventilation or a ventilation-demand override holds outdoor-air flow above the minimum-OA state — at VAV turndown a constant ventilation flow becomes a large OA fraction and this rule fires on healthy operation (fleet-validated FP mechanism; see the validation block).

Points: mat, rat, oat

Outputs:

  • yFault — True while the outdoor air fraction has stayed further than oa_fraction_tolerance from min_oa_fraction, in either direction, for at least alarm_delay, with the outdoor-to-return temperature difference large enough to evaluate
  • yTempDeltaOk — Evaluability signal — true when |rat − oat| exceeds delta_min (G36’s dTMIN conjunct); false means NO_EVAL and the host must ignore yFault

Parameters:

NameDefaultUnitCXF pathDescription
min_oa_fraction0.151minConst.kThe %OAmin the fraction is compared against — G36’s active minimum-OA setpoint divided by actual total airflow, bound here as a static design minimum (0-1). Hosts that can read the ventilation reset should retune this through set_param as the active setpoint moves
oa_fraction_tolerance0.31devBig.tHow far the fraction may sit from min_oa_fraction in either direction before it counts as a fault. Default 0.30 is G36’s eF (airflow error threshold, 30%), a NISTIR 7365 value the addendum notes is intentionally biased toward minimizing false alarms
delta_min6.0°CdeltaOk.tMinimum
alarm_delay1800.0spersist.delayTimeContinuous fault persistence required before the alarm asserts (G36 AlarmDelay, 30 min)

Description

The unit should be sitting at its minimum outdoor-air setpoint, and the mixing-box energy balance says it is not. Too much outdoor air costs money: every extra cubic metre is heated or cooled to supply temperature for no ventilation benefit. Too little has no energy signature at all — the building is under-ventilated, an indoor-air-quality finding rather than a waste one. G36 tests both directions with one absolute value, so this rule alarms on either.

This is G36 §5.16.14 FC#6, evaluated in the two operating states where the damper is supposed to be at minimum — OS#1 (heating) and OS#4 (mechanical cooling on minimum outdoor air). In OS#2 and OS#3 the damper is opening deliberately and the same three temperatures would read as a fault, so the host gate matters more here than the equation does. The fraction is inferred from three temperatures rather than measured, which is what makes the diagnostic cheap and what makes it conditional: when outdoor and return air are close, the quotient’s denominator collapses and sensor error becomes an arbitrary answer.

Detection Logic

oaf          = (mat − rat) / (oat − rat)                      (G36 %OA)
yTempDeltaOk = |rat − oat| > delta_min                        (false ⇒ host reports NO_EVAL)
yFault       = (|oaf − min_oa_fraction| > oa_fraction_tolerance) AND yTempDeltaOk,
               sustained for alarm_delay

Block graph (rule.cxf.jsonld):

AHU-0006 block graph

minConst holds %OAmin in a Reals.Sources.Constant rather than folded into the threshold, so min_oa_fraction and oa_fraction_tolerance stay independent single-value set_param paths — which matters here because the setpoint is the parameter a host is expected to move at runtime.

The dTMIN branch goes to two places: into gate as G36’s second conjunct, and out of the block as yTempDeltaOk. A host that ignores the output still gets the right verdict; a host that reads it learns the difference between “not faulted” and “cannot tell”. That arrangement is also what makes the division safe. CDL Divide follows IEEE-754, so oat = rat yields ±∞ or NaN rather than an error, and a near-zero denominator inflates sensor noise into a fraction of any magnitude. NaN compares false everywhere and can never raise devBig; ±∞ and a noise-inflated finite fraction both can, and gate stops them, because a denominator small enough to misbehave is by construction below delta_min. Garbage arithmetic cannot assert a fault — only report the rule unevaluable.

Both comparisons are strict. The fraction is signed consistently across the year (winter, both differences negative; summer, both positive), so no seasonal branch is needed. persist requires 30 continuous minutes and any interruption restarts the timer; recovery is immediate on the tick the deviation falls back inside tolerance.

Possible Diagnoses

Transcribed from G36 §5.16.14 FC#6:

  1. RAT sensor error
  2. MAT sensor error
  3. OAT sensor error
  4. Leaking or stuck economizer damper or actuator

Three of the four are sensor errors, and the reason is arithmetic: a 1 °C bias in MAT moves the apparent outdoor air by 5 percentage points across a 20 °C outdoor-to-return spread, and by 17 points on a spread sitting at dTMIN. A sensor within its rated accuracy can account for most of a deviation this rule reports.

Energy Impact

EXCESS_CONSUMPTION, MEDIUM confidence, DIRECT_MEASUREMENT, savings 2–10% of the subsystem — the §5.8.1 index row, the only energy statement the reference makes here. The high branch is directly computable from live data, with the excess fraction already on the wire as dev.y:

excess_oa_kw = (oaf − min_oa_fraction) × supply_airflow_m3s × 1.2 × 1.005 × |oat − rat|

Payback lands in the upper half of the range in heating-dominant climates, where every extra cubic metre costs most. The index maps the fault to PNNL-25985 EEM-06 (OA damper faults) and EEM-17 (demand control ventilation). MEDIUM rather than AHU-0021’s HIGH on the same quotient, because a low-side alarm has no energy term at all — under-ventilating saves energy while failing the occupants, so a host must check the sign of dev.y before banking anything — and because three of the four diagnoses are sensor errors, which waste nothing.

Emissions Impact

Scope 1 + 2, PROXY_EMISSIONS, MEDIUM confidence. On the high branch the split follows the season, as for AHU-0021: excess outdoor air burns Scope 1 fuel at the heating coil in winter, draws Scope 2 electricity at the chiller in summer, and collapses to Scope 2 on an all-electric unit. The low branch has nothing to attribute. PROXY where the index says DIRECT because the fraction is measured but the mass flow it must be multiplied by is not — this rule is worth deploying precisely on units with three temperature sensors and no airflow station. Avoided-emissions basis: marginal operating emissions rate (MOER) for the electric half, static combustion factor for the fuel half.

Deviations

  • The reference card is abbreviated; G36 is the normative text. The HVAC FDD Reference carries AHU-0006 only as a §5.8.1 index row — a name and an energy profile. Detection logic, internal-variable defaults, and the diagnosis list are transcribed from ASHRAE Guideline 36 §5.16.14 FC#6 as it appears in Addendum u to Guideline 36-2018 (First Public Review, 2021).
  • %OAmin is a static parameter, not the active setpoint. G36 defines it as the active minimum-OA setpoint divided by actual total airflow, which a VAV system recomputes continuously; binding it as a constant (as AHU-0021 does) keeps this a three-temperature diagnostic instead of one needing a flow station or zone-flow rollup. Hosts should retune min_oa_fraction through set_param as the reset moves it: left static, an active setpoint above the design minimum makes a correctly ventilating unit read high and measures an under-ventilating one against too low a bar. G36’s defaults absorb up to 0.30 of fraction before either error surfaces.
  • The low branch is nearly unreachable at G36’s defaults, and that is G36’s arithmetic. With %OAmin = 0.15 and eF = 0.30 the low-side alarm point is a fraction below −0.15, which no physical mixing box can produce — not even a fully shut damper delivering zero ventilation. It fires only when the inferred fraction goes negative (MAT outside the OAT/RAT interval, so a sensor is lying) or when a host has retuned min_oa_fraction above the tolerance. A real under-ventilation alarm at a 15% minimum needs oa_fraction_tolerance well below eF, and the false-alarm rate NISTIR 7365 chose eF to avoid.
  • G36’s on the dTMIN conjunct becomes a strict >. FC#6 mixes inequalities (|RAT − OAT| ≥ dTMIN, |%OA − %OAmin| > eF) and CDL Reals offers only strict comparisons, so the deviation term transcribes exactly while a spread of exactly 6.000 °C is evaluable to G36 and unevaluable here. Measure zero on a real signal, and it errs toward silence on the one term whose purpose is to suppress meaningless verdicts.
  • The high-side deviation boundary is not representable in binary. A nominal 0.45 fraction against a 0.15 setpoint and 0.30 tolerance computes one ulp above 0.30, so the rule alarms where decimal arithmetic says it should not; no double gives a deviation of exactly 0.30 on the high side. The low side is exact, because the double nearest 0.15 doubles exactly into the double nearest 0.30. Read nothing into a fraction sitting on the threshold, especially with coarsely quantized temperatures.
  • Instantaneous samples instead of 5-minute rolling averages. G36 defines rolling averages for measured points and writes the dTMIN conjunct on them, but writes %OA on unsubscripted MAT, RAT, and OAT in the same clause; this library consumes instantaneous points and lets the 30-minute AlarmDelay stand in. Not equivalent — persistence resets on every compliant tick, so an oscillating deviation (a hunting damper) can hide indefinitely, while a stuck damper or drifted sensor reads the same either way. The gap is wider here than on a single-signal rule: averaging the three inputs and averaging the quotient give different numbers.
  • Operating states and ModeDelay are host-side preconditions. G36 scopes FC#6 to OS#1 and OS#4 and suspends evaluation for ModeDelay (30 min) after a mode change in a served zone group; none of it is in the graph, per the library’s stance (precedent AHU-0029). The stakes are higher here than on most rules — a high outdoor-air fraction is the correct answer in OS#2 and OS#3, so a host that evaluates while the unit is economizing gets a sustained fault of its own making.
  • Severity 3 is the library’s. No chapter card exists to state one and the §5.8.1 index carries no severity column. G36 §5.16.14 makes every reported fault condition a Level 3 alarm, but that is an alarm-priority scheme rather than this library’s 1–4 severity scale, so it corroborates without supplying.
  • The energy profile is the index row’s; the emissions block and runtime formula are the library’s. category, confidence, estimation_method, savings_range, and the EEM mapping are copied from §5.8.1; scope 1+2 and PROXY_EMISSIONS follow AHU-0021’s reading of the same physics, and the formula is mirrored from AHU-0021 with the sign check added.
  • persist.delayOnInit = true (Modelica/CDL default is false), the library’s standing choice: a deviation already present at load waits out the full 30 minutes instead of alarming on the first tick after a controller restart.

Notes

This rule and AHU-0021 measure the same quotient and are not redundant. AHU-0021 is a one-sided energy test with a 0.10 tolerance in any non-economizing occupied hour; AHU-0006 is G36’s symmetric test in the two minimum-OA states, and its contribution is the low branch — which at G36’s defaults catches a fraction gone negative, a sensor or mixing-box contradiction that AHU-0028 tests directly with a shorter delay. Expect the pair to fire together and read AHU-0028 first; a genuine under-ventilation alarm needs CO₂ or a measured outdoor-air flow. Check the minimum position setpoint before sending anyone to the roof — a minimum dialled up during a ventilation complaint is the most common high-side cause and a $0 desk fix, ahead of the economizer-failure playbook’s damper steps. If commanding the damper closed does not move the fraction, measure building pressure: an oversized exhaust fan reads the same as a stuck damper from these three points.

Test Vectors

16 scenarios, clock step 300 s over 5400 s.

ScenarioDescription
at_minimum_ventilation_setpointWinter minimum-OA operation: 2 °C outdoor air, 22 °C return, and a 19 °C mixture. The energy balance puts %OA at 0.15, exactly the setpoint, so the deviation is zero and the 20 °C spread makes the inference meaningful
excess_outdoor_air_alarmsThe mixture reads 12 °C against the same 2/22 °C pair, so %OA is 0.50 — 0.35 above the setpoint, past the 0.30 tolerance. The high branch: outdoor air being heated for no ventilation benefit. Alarms one alarm_delay (1800 s) after the deviation appears
summer_excess_oat_above_ratSame 0.50 fraction with the temperature gradient reversed — 35 °C outdoor, 24 °C return, 29.5 °C mixed. Both differences change sign together and the quotient is unchanged, so no seasonal branch is needed and the rule alarms exactly as it does in winter
damper_shut_no_ventilation_stays_silentMAT equals RAT: the unit is on full recirculation and delivering no outdoor air at all. %OA is 0.00, a deviation of 0.15, which is only half of G36’s eF = 0.30 tolerance — so a total ventilation failure sits inside the band and the rule reports nothing. This is the false-negative documented in Deviations, and the reason the low branch only bites on units whose active minimum-OA setpoint exceeds the tolerance
high_deviation_inside_toleranceTolerance band, high side: a 13.2 °C mixture puts %OA at 0.44, a deviation of 0.29. Inside the tolerance, so no fault however long it lasts
high_deviation_outside_toleranceTolerance band, high side, one hundredth further out: 12.8 °C gives %OA 0.46 and a deviation of 0.31, which clears the strict comparison and alarms after alarm_delay
high_nominal_boundary_rounds_into_alarmThe nominal high boundary: 13.0 °C gives %OA 0.45, a deviation of exactly 0.30 in decimal, which the strict > should read as healthy. It alarms instead. (13 − 22)/(2 − 22) rounds to the double just above 0.45, and subtracting the double nearest 0.15 lands one ulp above 0.30. The boundary is not representable in binary — see Deviations — so this pins where the rounding actually falls rather than pretending the decimal edge is testable
low_deviation_exactly_at_toleranceStrict-comparison pin, inclusive side: a 25 °C mixture against 22 °C return and 2 °C outdoor puts %OA at −0.15, a deviation of exactly −0.30. On the low side the arithmetic is exact — the double nearest 0.15 doubles exactly into the double nearest 0.30 — so this is the one place the decimal boundary is genuinely testable. devBig is a strict >, so it reads healthy
low_deviation_outside_toleranceThe low branch, which AHU-0021 structurally cannot produce: 25.2 °C mixed air against 22 °C return and 2 °C outdoor gives %OA −0.16, a deviation of −0.31. A mixture warmer than both streams is not mixing, so the ventilation the unit reports cannot be trusted — G36’s diagnosis list leads with the three sensor errors for exactly this reason. Alarms after alarm_delay
delta_exactly_at_dtmin_holds_fault_downNO_EVAL pin: 16 °C outdoor against 22 °C return is a spread of exactly dTMIN = 6.0 °C, and the 16.6 °C mixture reads 0.90 outdoor air — a damper stuck wide open in a minimum-OA state, a deviation of 0.75. deltaOk is a strict >, so yTempDeltaOk is false and the gate holds yFault down for the whole horizon. False here means unknown, not healthy
delta_just_over_dtmin_alarmsThe other side of the same boundary, one tenth of a degree colder outdoors: 15.9 °C against 22 °C is a 6.1 °C spread, enough to evaluate. The 16.5 °C mixture reads 0.90 outdoor air and the same stuck damper now alarms after alarm_delay
small_delta_wild_fraction_silentA 2 °C outdoor-to-return spring spread with a 12 °C mixture: the quotient reads 5.0, five hundred percent outdoor air, a deviation of 4.85. Nothing is wrong with the unit — a small denominator has amplified ordinary sensor error into an arbitrary fraction. yTempDeltaOk is false and yFault stays down, which is the whole purpose of the dTMIN conjunct
zero_denominator_infinite_fraction_silentThe degenerate case: outdoor and return air at the same 22 °C, so the denominator is exactly zero. CDL Divide follows IEEE-754 and returns −∞ rather than raising,
transient_excess_clears_before_alarm_delayTransient: the fraction sits at 0.50 for 1200 s while the outdoor damper strokes back down from a purge, then returns to 0.15. Shorter than alarm_delay, so the deviation never reaches the operator
fault_clears_on_recoveryA sustained 0.50 fraction alarms at 1800 s; the minimum-position setpoint is corrected at t = 3000 and the fraction drops to 0.15. TrueDelay has no off-delay, so the alarm releases on that tick
oscillating_deviation_never_alarmsA hunting damper swings the mixture between 12.0 °C and 13.2 °C on a 10-minute period, so the deviation alternates 0.35 (outside the tolerance) and 0.29 (inside). Its mean, 0.32, is outside — an averaged form of the test would report the fault. Persistence restarts on every compliant tick and this rule stays silent, which is the cost of substituting TrueDelay for G36’s rolling averages (see Deviations)
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 300,
    "horizon_s": 5400
  },
  "scenarios": [
    {
      "name": "at_minimum_ventilation_setpoint",
      "description": "Winter minimum-OA operation: 2 \u00b0C outdoor air, 22 \u00b0C return, and a 19 \u00b0C mixture. The energy balance puts %OA at 0.15, exactly the setpoint, so the deviation is zero and the 20 \u00b0C spread makes the inference meaningful",
      "inputs": {
        "mat": 19.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "excess_outdoor_air_alarms",
      "description": "The mixture reads 12 \u00b0C against the same 2/22 \u00b0C pair, so %OA is 0.50 \u2014 0.35 above the setpoint, past the 0.30 tolerance. The high branch: outdoor air being heated for no ventilation benefit. Alarms one alarm_delay (1800 s) after the deviation appears",
      "inputs": {
        "mat": 12.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "summer_excess_oat_above_rat",
      "description": "Same 0.50 fraction with the temperature gradient reversed \u2014 35 \u00b0C outdoor, 24 \u00b0C return, 29.5 \u00b0C mixed. Both differences change sign together and the quotient is unchanged, so no seasonal branch is needed and the rule alarms exactly as it does in winter",
      "inputs": {
        "mat": 29.5,
        "rat": 24.0,
        "oat": 35.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "damper_shut_no_ventilation_stays_silent",
      "description": "MAT equals RAT: the unit is on full recirculation and delivering no outdoor air at all. %OA is 0.00, a deviation of 0.15, which is only half of G36's eF = 0.30 tolerance \u2014 so a total ventilation failure sits inside the band and the rule reports nothing. This is the false-negative documented in Deviations, and the reason the low branch only bites on units whose active minimum-OA setpoint exceeds the tolerance",
      "inputs": {
        "mat": 22.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "high_deviation_inside_tolerance",
      "description": "Tolerance band, high side: a 13.2 \u00b0C mixture puts %OA at 0.44, a deviation of 0.29. Inside the tolerance, so no fault however long it lasts",
      "inputs": {
        "mat": 13.2,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "high_deviation_outside_tolerance",
      "description": "Tolerance band, high side, one hundredth further out: 12.8 \u00b0C gives %OA 0.46 and a deviation of 0.31, which clears the strict comparison and alarms after alarm_delay",
      "inputs": {
        "mat": 12.8,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "high_nominal_boundary_rounds_into_alarm",
      "description": "The nominal high boundary: 13.0 \u00b0C gives %OA 0.45, a deviation of exactly 0.30 in decimal, which the strict `>` should read as healthy. It alarms instead. (13 \u2212 22)/(2 \u2212 22) rounds to the double just above 0.45, and subtracting the double nearest 0.15 lands one ulp above 0.30. The boundary is not representable in binary \u2014 see Deviations \u2014 so this pins where the rounding actually falls rather than pretending the decimal edge is testable",
      "inputs": {
        "mat": 13.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "low_deviation_exactly_at_tolerance",
      "description": "Strict-comparison pin, inclusive side: a 25 \u00b0C mixture against 22 \u00b0C return and 2 \u00b0C outdoor puts %OA at \u22120.15, a deviation of exactly \u22120.30. On the low side the arithmetic is exact \u2014 the double nearest 0.15 doubles exactly into the double nearest 0.30 \u2014 so this is the one place the decimal boundary is genuinely testable. `devBig` is a strict `>`, so it reads healthy",
      "inputs": {
        "mat": 25.0,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "low_deviation_outside_tolerance",
      "description": "The low branch, which AHU-0021 structurally cannot produce: 25.2 \u00b0C mixed air against 22 \u00b0C return and 2 \u00b0C outdoor gives %OA \u22120.16, a deviation of \u22120.31. A mixture warmer than both streams is not mixing, so the ventilation the unit reports cannot be trusted \u2014 G36's diagnosis list leads with the three sensor errors for exactly this reason. Alarms after alarm_delay",
      "inputs": {
        "mat": 25.2,
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "delta_exactly_at_dtmin_holds_fault_down",
      "description": "NO_EVAL pin: 16 \u00b0C outdoor against 22 \u00b0C return is a spread of exactly dTMIN = 6.0 \u00b0C, and the 16.6 \u00b0C mixture reads 0.90 outdoor air \u2014 a damper stuck wide open in a minimum-OA state, a deviation of 0.75. `deltaOk` is a strict `>`, so yTempDeltaOk is false and the gate holds yFault down for the whole horizon. False here means unknown, not healthy",
      "inputs": {
        "mat": 16.6,
        "rat": 22.0,
        "oat": 16.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "delta_just_over_dtmin_alarms",
      "description": "The other side of the same boundary, one tenth of a degree colder outdoors: 15.9 \u00b0C against 22 \u00b0C is a 6.1 \u00b0C spread, enough to evaluate. The 16.5 \u00b0C mixture reads 0.90 outdoor air and the same stuck damper now alarms after alarm_delay",
      "inputs": {
        "mat": 16.5,
        "rat": 22.0,
        "oat": 15.9
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1500,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 2100,
          "to_s": 5400,
          "equals": true
        }
      ]
    },
    {
      "name": "small_delta_wild_fraction_silent",
      "description": "A 2 \u00b0C outdoor-to-return spring spread with a 12 \u00b0C mixture: the quotient reads 5.0, five hundred percent outdoor air, a deviation of 4.85. Nothing is wrong with the unit \u2014 a small denominator has amplified ordinary sensor error into an arbitrary fraction. yTempDeltaOk is false and yFault stays down, which is the whole purpose of the dTMIN conjunct",
      "inputs": {
        "mat": 12.0,
        "rat": 22.0,
        "oat": 20.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "zero_denominator_infinite_fraction_silent",
      "description": "The degenerate case: outdoor and return air at the same 22 \u00b0C, so the denominator is exactly zero. CDL `Divide` follows IEEE-754 and returns \u2212\u221e rather than raising, |\u2212\u221e \u2212 0.15| clears any finite tolerance, and `devBig` goes true. The gate is what stops it reaching the operator \u2014 garbage arithmetic can make the rule report itself unevaluable but can never assert a fault",
      "inputs": {
        "mat": 18.0,
        "rat": 22.0,
        "oat": 22.0
      },
      "expect": [
        {
          "output": "yTempDeltaOk",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "transient_excess_clears_before_alarm_delay",
      "description": "Transient: the fraction sits at 0.50 for 1200 s while the outdoor damper strokes back down from a purge, then returns to 0.15. Shorter than alarm_delay, so the deviation never reaches the operator",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 1200,
            "value": 19.0
          }
        ],
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    },
    {
      "name": "fault_clears_on_recovery",
      "description": "A sustained 0.50 fraction alarms at 1800 s; the minimum-position setpoint is corrected at t = 3000 and the fraction drops to 0.15. `TrueDelay` has no off-delay, so the alarm releases on that tick",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 3000,
            "value": 19.0
          }
        ],
        "rat": 22.0,
        "oat": 2.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
        }
      ]
    },
    {
      "name": "oscillating_deviation_never_alarms",
      "description": "A hunting damper swings the mixture between 12.0 \u00b0C and 13.2 \u00b0C on a 10-minute period, so the deviation alternates 0.35 (outside the tolerance) and 0.29 (inside). Its mean, 0.32, is outside \u2014 an averaged form of the test would report the fault. Persistence restarts on every compliant tick and this rule stays silent, which is the cost of substituting TrueDelay for G36's rolling averages (see Deviations)",
      "inputs": {
        "mat": [
          {
            "t": 0,
            "value": 12.0
          },
          {
            "t": 600,
            "value": 13.2
          },
          {
            "t": 1200,
            "value": 12.0
          },
          {
            "t": 1800,
            "value": 13.2
          },
          {
            "t": 2400,
            "value": 12.0
          },
          {
            "t": 3000,
            "value": 13.2
          },
          {
            "t": 3600,
            "value": 12.0
          },
          {
            "t": 4200,
            "value": 13.2
          },
          {
            "t": 4800,
            "value": 12.0
          }
        ],
        "rat": 22.0,
        "oat": 2.0
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 5400,
          "equals": false
        }
      ]
    }
  ]
}