AHU-0037 — Economizing when it should not (damper position)
| Status | verified — engine e2ff2f8, cxf:fnv1a128:2188a703036ffd6c5698d42b30bea1e3, 2026-08-18 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EXCESS_CONSUMPTION |
| Confidence | HIGH |
| Estimation | DIRECT_MEASUREMENT |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | AHU-0021, AHU-0030, AHU-0006, AHU-0034 |
| Playbooks | economizer-failure |
| Source | PNNL-27338 §3.4 (pp. 3.15-3.18) — the ‘economizing when it should not’ AIRCx process: gated on econ_condition == False, then a damper-position test with no temperature ratio in it, avg_damper_signal > excess_damper_threshold. Published parameters min_damper_sp = 20%, excess_damper_threshold = min_damper_sp × 1.5 = 30%, data_window = 30 minutes; PNNL-27338 §3.1 (p. 3.2) — econ_condition itself, the differential-dry-bulb / fixed-high-limit comparison this card encodes, with temp_deadband = 1 °F; PNNL-27338 §3.5 (pp. 3.20-3.23) — the excess-OA process, which runs this same raw-damper test alongside its OAF ratio against the same 30% line and reports both causes when both trip; Sibling precedent: AHU-0017 and AHU-0034 (the changeover-type switch and its three parameters, transcribed unchanged); AHU-0021 (feeding a published knob as a Constant so it stays retunable alone); Library extension: the HVAC FDD Reference v1.0 ch.9 specifies AHU-0001..065 and stops — see faults/ahu/README.md; PNNL-27338 is not redistributed with this library |
| Operating states | occupied, supply fan running — host-gated. Whether outdoor conditions favor economizing is not a host mode here: it is tested in-rule by unfavSel from oat and rat. Unoccupied hours belong to AHU-0026, where the damper expectation is closed rather than minimum. |
Preconditions (host-enforced): Supply fan running, and the unit past its start-up transient — a morning warm-up purge or a scheduled pre-occupancy flush opens the damper by design and needs a host NO_EVAL, not a fault. min_damper_sp must be set to the unit’s own commissioned minimum damper position before deployment: 20% is PNNL’s default, and a unit whose design minimum is 30% alarms continuously against it. OAT sensor quality is a host gate, since a sensor reading high manufactures this fault out of a working economizer. Do not bind the rule to a unit with no return-air path (100%-outdoor-air or makeup-air): it has no minimum position to hold. The rule reads the damper command, so it is silent on a damper commanded to minimum and stuck open — that is AHU-0020 and AHU-0028. When any gate is unmet the verdict is NO_EVAL, not healthy.
Points: oat, rat, oa_dmpr_cmd
Outputs:
yFault— True while outdoor conditions have not favored economizing and the OA damper command has stayed above min_damper_sp × excess_damper_multiplier, both continuously for at least alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
min_damper_sp | 20.0 | % | minDmpr.k | Minimum outdoor-air damper position the unit should hold when economizing is not favorable. PNNL-27338 §3.4’s own site value; retune it to the unit’s commissioned minimum, and the excess threshold follows |
excess_damper_multiplier | 1.5 | 1 | excessThr.k | Multiple of min_damper_sp above which the damper counts as economizing. PNNL-27338 §3.4 publishes excess_damper_threshold as min_damper_sp × 1.5 = 30%; the multiplier stays a separate knob so the threshold tracks a retuned minimum instead of being recomputed by hand |
econ_type_is_ddb | True | bool | isDDB.k | Changeover type — true = differential dry-bulb (compare oat to rat), false = fixed changeover temperature (compare oat to econ_hl_temp). Same parameter, same meaning, same default as AHU-0017 and AHU-0034; a unit running any two of the three must carry the same value in all of them |
econ_hl_temp | 21.0 | °C | hlConst.k | Fixed changeover temperature, used only when econ_type_is_ddb is false — the outdoor temperature above which the unit should be back at minimum outdoor air |
temp_deadband | 1.0 | °C | ddbUnfav.t, hlUnfav.t | Margin by which outdoor air must beat the changeover point before economizing counts as favorable; inside it the outdoor condition is unfavorable and this rule evaluates. Binds both changeover branches. PNNL-27338 §3.1’s own value is 1 °F — see Deviations |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min, the length of PNNL-27338 §3.4’s data_window) |
Description
Nothing about the weather favors free cooling and the outdoor-air damper is open well past its minimum position anyway. Whatever the coils are doing, they are doing it to more outdoor air than the ventilation calculation asked for, and outdoor air no cooler than the air it displaces is a load, not a resource. This is the raw-damper half of the economizer question: it never divides one temperature difference by another, so it keeps working in the conditions where the outdoor-air fraction cannot be computed at all — which is most of the band it fires in. AHU-0021 and AHU-0030 measure how much extra air arrives; this rule asks only whether the damper had any business being open, and answers from the command the controller wrote.
Detection Logic
econ_unfavorable = (rat − oat) < temp_deadband when econ_type_is_ddb
= (econ_hl_temp − oat) < temp_deadband otherwise
yFault = econ_unfavorable
AND oa_dmpr_cmd > min_damper_sp × excess_damper_multiplier
sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
Both changeover branches are computed every tick and unfavSel
(Logical.Switch, y = u2 ? u1 : u3) picks one, exactly as AHU-0017 does — same
subtractions, same operand order, same temp_deadband, with LessThreshold
where that card has GreaterThreshold. econ_unfavorable is therefore the
strict complement of AHU-0017’s econ_favorable, in both branches, which is what
lets a site read the pair as one policy. minDmpr and excessThr keep PNNL’s
two published numbers as two live knobs: the 30% line is computed on the wire, so
retuning a site’s minimum position to 30% moves the threshold to 45% without
anyone redoing the arithmetic. Both comparisons are strict, so a damper commanded
to exactly 30% and a changeover gap of exactly 1.0 °C both read healthy — the
second is one boundary point more conservative than PNNL’s
NOT (rat − oat > temp_deadband).
persist requires 30 continuous minutes, the length of PNNL’s averaging window,
which rides out the damper stroke and the minutes either side of changeover;
delayOnInit = true holds that window across a controller restart.
Possible Diagnoses
PNNL-27338 §3.4 emits a diagnostic message rather than a fault set, so this list is authored from the mechanisms that hold an OA damper command above minimum when nothing favors economizing:
- Minimum position parameter set well above the ventilation design minimum — dialled up during a complaint or a commissioning shortcut, and a $0 desk fix. Check it before anything else.
- Economizer enable with no disable path: the sequence opens on a favorable comparison and never re-tests it
- Changeover setpoint or high limit left at a factory default that does not fit the climate
- A changeover device — dry-bulb or enthalpy switch — failed in its “economize” state, a single point of failure with no other symptom
- OAT sensor reading low, so outdoor air still looks worth importing
- A mixed-air low-limit or freeze-protection loop holding the damper open, its setpoint never re-tuned for cooling weather
- An override left in place after service (AHU-0027 finds the flag itself)
Energy Impact
EXCESS_CONSUMPTION, HIGH confidence, DIRECT_MEASUREMENT. Every point of damper
command above the minimum imports outdoor air with no ventilation benefit, and
the rule fires only when that air is within temp_deadband of return temperature
or hotter, so the waste is a cooling penalty: (oa_dmpr_cmd − min_damper_sp)/100 × supply_airflow × ρ·cp × (oat − rat), every term but the airflow already on this
rule’s wires. Read it as a floor — sensible-only, and in the near-changeover band
where this rule does its distinctive work the latent load of humid outdoor air is
the larger half. Cooling-dominant and sharply seasonal.
Emissions Impact
Scope 2, DIRECT_EMISSIONS, HIGH confidence; typical 1,000-6,000 kg CO₂e/yr, matching AHU-0034’s range for the same equipment and the same mechanical cooling. The whole impact is electric compressor or chiller work, and the hours are the grid’s worst — hot afternoons coincident with cooling peaks — so value it at the marginal operating emissions rate (MOER) rather than an average grid factor.
Deviations
- This card is a library extension, not a transcription: chapter 9 specifies
AHU-0001..065 and stops. Name, severity 3, phase 2 and
method: ruleare assigned here — severity 3 to match every other economizer card, phase 2 because the rule presupposes a site that has already configured a changeover type for AHU-0017. The algorithm and every threshold are PNNL-27338 §3.4’s. - The economizing-unfavorable gate is in the graph, where AHU-0021 and
AHU-0030 leave it to the host. Those cards gate on the unit’s economizer
mode, an operating state no measurement establishes. PNNL-27338 §3.1 computes
econ_conditionfrom two temperatures at every time step, which is a measurement — the library’s standing line (AHU-0030: measured points in, modes out) — and AHU-0017 and AHU-0034 already carry that comparison in-graph. Here it is load-bearing: host-side, this rule would be a bare damper threshold. - The changeover-type switch is carried even though §3.4’s prose is written
around the dry-bulb case. §3.1 defines both branches, and AHU-0017 and
AHU-0034 ship them; a site on fixed high-limit changeover would otherwise have
this rule contradicting its two siblings on the same unit.
econ_type_is_ddb,econ_hl_tempandtemp_deadbandare their parameters unchanged. min_damper_sp×excess_damper_multiplierships as two parameters, not one 30% threshold. PNNL publishes the minimum as the site value and the excess threshold as its derivative; collapsing them makes a site with a 30% minimum remember to type 45. Feeding the minimum asReals.Sources.Constant.kand the factor asMultiplyByParameter.kis algebraically identical and keeps both of PNNL’s published names retunable alone (AHU-0021’s precedent fordesired_oaf).- Persistence replaces PNNL’s window average. §3.4 averages
damper_signalover a 30-minutedata_window(withno_required_data = 10) and tests the mean; this card tests the instantaneous comparison through a 30-minuteTrueDelay. The latency matches, the semantics do not: a damper spending half the window wide open and half closed would trip the average and does not trip the delay. Persistence is the house form and the stricter of the two. temp_deadbandships at 1.0 °C, not PNNL’s 1 °F (0.56 °C). The value is AHU-0017’s and AHU-0034’s, and one deadband across all three keeps their changeover geometry coherent: that pair brackets a symmetric ±1.0 °C dead zone and this card’s gate is the exact complement of AHU-0017’s. Coherence is worth more than half a degree of fidelity to a threshold PNNL itself calls adjustable. A site with untrimmed sensors must raise it on all three together.- No evaluability output. AHU-0021 and AHU-0030 must publish
yTempDeltaOkbecause their quotient becomes meaningless as|oat − rat|shrinks. This rule has no quotient, so there is no in-rule condition under which its own answer is unknown, and per SCHEMA.md there is nothing to expose as ay…Ok. - Both comparisons are strict (
>on the damper,<on the changeover gap). Atrat − oatexactly equal totemp_deadbandthis rule stays silent where PNNL’sNOT (rat − oat > temp_deadband)would evaluate — one boundary point, in the direction the library’s strict convention always takes. fan_spis not bound. §3.4.1 lists it among the process’s required points, but it appears only in the energy estimate of Step 7, never in the detection test; fan speed belongs to the host’s energy accounting, and binding a point a rule does not read would misstate the rule’s data requirement.- The unoccupied case in §3.4’s prose is left to AHU-0026. PNNL extends the
check to unoccupied periods, where the damper should be closed rather than at
minimum; AHU-0026 already tests exactly that against
occ_schedule, so this card’soperating_statesrestricts it to occupied hours instead of carrying a second expectation for the same actuator. - The rule reads the damper command, not a measured position (PNNL’s own
damper_signal, from the controller). A damper commanded to minimum and mechanically stuck open produces the entire physical fault and none of this signature; run AHU-0020 on the OA damper alongside, as AHU-0034 does. suppressesandsuppressed_byare empty although this rule nests AHU-0034. At shipped defaults its changeover gate implies this one, its damper threshold is higher, and it adds a cooling conjunct — so every AHU-0034 assertion is also one of these, in both branches. Both findings are true and separately actionable — AHU-0034’s is the sharper diagnosis, this one the wider net — and any suppression edge would be an index-level decision declared on both cards, which single-writer authoring cannot do from here.savings_rangeis inherited, not transcribed. PNNL-27338 publishes no annual percentage for any of its five economizer diagnostics; the band quoted is AHU-0034’s, carried so the family reads consistently, and the card’s own claim is the runtime integral.persist.delayOnInit = true(Modelica/CDL default isfalse), the library’s standing choice: a damper already open past changeover when the controller restarts waits out the full 30 minutes instead of alarming on the first tick.- Every vector is authored — PNNL-27338 publishes an algorithm and a flow
chart, not test cases. With default parameters they reach only the dry-bulb
branch, since
vectors/v1stages inputs and not parameters, sohlConst,hlGapandhlUnfavare structurally verified but never reachyFaultthroughu3(as on AHU-0034);econ_type_is_ddb = falsecommissions itself.
Notes
The temperature spread decides which of the three excess-outdoor-air cards can
speak. AHU-0021 and AHU-0030 infer the fraction from (mat − rat) / (oat − rat)
and report NO_EVAL whenever |oat − rat| is under 6 °C. This rule’s gate points
the other way — it needs rat − oat under 1 °C — so it owns a roughly 7 °C-wide
band, from a degree below return temperature up to where the ratio becomes
reliable, in which it is the only one of the three that can answer at all. Above
that band both forms work and both should fire: PNNL-27338 §3.5 does exactly
that, running the raw damper test beside its OAF check against the same 30% line
and reporting both causes when both trip. Below it, in genuine free-cooling
weather, this rule is silent by construction. The 1-to-6 °C gap where none of
them speaks is honest — mild outdoor air with an unreliable ratio is when an
open damper is defensible.
Check the minimum-position parameter before sending anyone to the roof, then
command the damper to minimum and watch it move. If the command changes and the
fault clears, the sequence never asked for minimum and the fix is at a desk
(economizer-failure, remote steps).
If oa_dmpr_cmd already sits at minimum while the space says otherwise, this
rule was never going to see it, and AHU-0020 is the one to run.
Test Vectors
11 scenarios, clock step 60 s over 3600 s.
| Scenario | Description |
|---|---|
damper_at_minimum_unfavorable | Healthy: OAT 28 °C against RAT 22 °C leaves nothing to economize with, and the damper sits at its 20% minimum — the correct response |
damper_above_minimum_unfavorable | The fault: same unfavorable outdoor condition, damper commanded to 60% — twice the 30% excess threshold; alarms after alarm_delay (1800 s) |
favorable_for_economizing | Econ gate off on its own: OAT 12 °C is 10 °C below return air, so a wide-open damper is free cooling doing its job and no damper position is excessive |
damper_exactly_at_threshold | Edge case: damper parked exactly on min_damper_sp × excess_damper_multiplier (20 × 1.5 = 30%) — the strict > keeps it out of the alarm |
damper_just_above_threshold | Edge case, other side: 30.1% clears the threshold by a tenth of a point and the alarm follows at 1800 s |
deadband_exactly_at_threshold | Edge case: rat − oat sits exactly on temp_deadband (22.0 − 21.0 = 1.0 °C) with the damper wide open — the strict < leaves the outdoor condition counted as still favorable, so no alarm |
deadband_just_inside_threshold | Edge case, other side: rat − oat = 0.9 °C closes the deadband, the outdoor condition counts as unfavorable, and the wide-open damper alarms at 1800 s |
close_temperatures_where_the_ratio_is_blind | The complementarity case: OAT 23.0 °C against RAT 22.5 °C is a 0.5 °C spread, far inside the 6 °C evaluability gate that puts AHU-0021 and AHU-0030 into NO_EVAL — this rule still reads a 70% damper as excess and alarms at 1800 s |
changeover_arrives_midrun | Gate edge: the damper is open at 95% throughout, but outdoor air only rises from 12 °C to 28 °C at t=600 — persistence starts from the changeover, not from t=0, so the alarm lands at 2400 s |
damper_closes_before_delay | Transient: the damper drives from 60% back to its 20% minimum at t=1200, clearing the condition short of alarm_delay, so no alarm |
damper_cycle_resets_persistence | Transient: the damper closes at t=900 and reopens at t=1200 under an unbroken unfavorable outdoor condition — persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 60,
"horizon_s": 3600
},
"scenarios": [
{
"name": "damper_at_minimum_unfavorable",
"description": "Healthy: OAT 28 \u00b0C against RAT 22 \u00b0C leaves nothing to economize with, and the damper sits at its 20% minimum \u2014 the correct response",
"inputs": {
"oat": 28.0,
"rat": 22.0,
"oa_dmpr_cmd": 20.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_above_minimum_unfavorable",
"description": "The fault: same unfavorable outdoor condition, damper commanded to 60% \u2014 twice the 30% excess threshold; alarms after alarm_delay (1800 s)",
"inputs": {
"oat": 28.0,
"rat": 22.0,
"oa_dmpr_cmd": 60.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "favorable_for_economizing",
"description": "Econ gate off on its own: OAT 12 \u00b0C is 10 \u00b0C below return air, so a wide-open damper is free cooling doing its job and no damper position is excessive",
"inputs": {
"oat": 12.0,
"rat": 22.0,
"oa_dmpr_cmd": 95.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_exactly_at_threshold",
"description": "Edge case: damper parked exactly on min_damper_sp \u00d7 excess_damper_multiplier (20 \u00d7 1.5 = 30%) \u2014 the strict `>` keeps it out of the alarm",
"inputs": {
"oat": 28.0,
"rat": 22.0,
"oa_dmpr_cmd": 30.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_just_above_threshold",
"description": "Edge case, other side: 30.1% clears the threshold by a tenth of a point and the alarm follows at 1800 s",
"inputs": {
"oat": 28.0,
"rat": 22.0,
"oa_dmpr_cmd": 30.1
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "deadband_exactly_at_threshold",
"description": "Edge case: rat \u2212 oat sits exactly on temp_deadband (22.0 \u2212 21.0 = 1.0 \u00b0C) with the damper wide open \u2014 the strict `<` leaves the outdoor condition counted as still favorable, so no alarm",
"inputs": {
"oat": 21.0,
"rat": 22.0,
"oa_dmpr_cmd": 95.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "deadband_just_inside_threshold",
"description": "Edge case, other side: rat \u2212 oat = 0.9 \u00b0C closes the deadband, the outdoor condition counts as unfavorable, and the wide-open damper alarms at 1800 s",
"inputs": {
"oat": 21.1,
"rat": 22.0,
"oa_dmpr_cmd": 95.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "close_temperatures_where_the_ratio_is_blind",
"description": "The complementarity case: OAT 23.0 \u00b0C against RAT 22.5 \u00b0C is a 0.5 \u00b0C spread, far inside the 6 \u00b0C evaluability gate that puts AHU-0021 and AHU-0030 into NO_EVAL \u2014 this rule still reads a 70% damper as excess and alarms at 1800 s",
"inputs": {
"oat": 23.0,
"rat": 22.5,
"oa_dmpr_cmd": 70.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1740,
"equals": false
},
{
"output": "yFault",
"from_s": 1860,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "changeover_arrives_midrun",
"description": "Gate edge: the damper is open at 95% throughout, but outdoor air only rises from 12 \u00b0C to 28 \u00b0C at t=600 \u2014 persistence starts from the changeover, not from t=0, so the alarm lands at 2400 s",
"inputs": {
"oat": [
{
"t": 0,
"value": 12.0
},
{
"t": 600,
"value": 28.0
}
],
"rat": 22.0,
"oa_dmpr_cmd": 95.0
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2340,
"equals": false
},
{
"output": "yFault",
"from_s": 2460,
"to_s": 3600,
"equals": true
}
]
},
{
"name": "damper_closes_before_delay",
"description": "Transient: the damper drives from 60% back to its 20% minimum at t=1200, clearing the condition short of alarm_delay, so no alarm",
"inputs": {
"oat": 28.0,
"rat": 22.0,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 60.0
},
{
"t": 1200,
"value": 20.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
}
]
},
{
"name": "damper_cycle_resets_persistence",
"description": "Transient: the damper closes at t=900 and reopens at t=1200 under an unbroken unfavorable outdoor condition \u2014 persistence restarts from the reopen, so the alarm lands at 3000 s, not 1800 s",
"inputs": {
"oat": 28.0,
"rat": 22.0,
"oa_dmpr_cmd": [
{
"t": 0,
"value": 60.0
},
{
"t": 900,
"value": 20.0
},
{
"t": 1200,
"value": 60.0
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2940,
"equals": false
},
{
"output": "yFault",
"from_s": 3060,
"to_s": 3600,
"equals": true
}
]
}
]
}