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

ERV-0002 — Frost protection not engaging

Statusverified — engine e2ff2f8, cxf:fnv1a128:c975d4eb5acff4ebf97b661be18c5552, 2026-08-17
Severity2
Methodrule
Phase2
CategoryPROTECTIVE
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0001
Playbookserv-effectiveness
SourceHVAC FDD Reference v1.0 §15, ERV-0002; Engineering best practice
Operating statesERV enabled, heating season — the rule cannot fire above frost_threshold

Preconditions (host-enforced): oat must be a live, sane outdoor-air reading. This rule trusts it completely, which makes diagnosis 2 (a sensor reading warmer than actual) invisible from inside the rule: a sun-struck or failed-high sensor produces silence, not an alarm. erv_frost_prot must be bound to the frost sequence’s active state — the preheat stage, wheel speed reduction, or bypass modulation the unit actually uses — and not to an enable or permissive flag, which reads true all winter and silences the rule permanently. Both supply and exhaust fans must be running: the enable half of the operating state is in the graph as erv_enabled, the fan half is not, and a unit enabled with stopped fans is not moving the air that would frost the core. Units with no frost-protection sequence at all must be excluded host-side — they hold this fault true for the entire heating season, which is a design finding rather than an operating one.

Points: oat, erv_frost_prot, erv_enabled

Outputs:

  • yFault — True while the ERV is enabled and outdoor air is below frost_threshold with the frost sequence reporting inactive, continuously for at least alarm_delay

Parameters:

NameDefaultUnitCXF pathDescription
frost_threshold-10.0°CcoldOat.tOutdoor-air temperature below which the frost-protection sequence is expected to be engaged. Signed by construction — this is a point on the Celsius scale, not a magnitude
alarm_delay300.0spersist.delayTimeContinuous unprotected operation required before the alarm asserts (5 min)

Description

Below roughly -10 °C the moisture in the exhaust airstream starts freezing onto the recovery core as it gives up its heat, so every ERV that runs in a cold climate carries a frost-protection sequence — preheat the incoming air, slow the wheel, or bypass part of the outdoor air around the core. This rule watches for that sequence failing to appear when the weather calls for it. It is a watchdog on a control sequence, not on a physical measurement: nothing here observes ice, only that the conditions for ice are present and the thing meant to prevent it reports itself inactive. The failure is silent — frost accumulates over hours and the first symptom is usually a collapsed plate core or a seized wheel found in spring — which is what severity 2 and PROTECTIVE reflect: the cost is a core.

Detection Logic

cold    = oat < frost_threshold                (-10.0 °C)
noFrost = NOT erv_frost_prot

yFault  = (cold AND noFrost AND erv_enabled) sustained continuously for alarm_delay

Block graph (rule.cxf.jsonld):

ERV-0002 block graph

coldOat carries a negative parameter, which the library normally avoids — see Deviations for why a sub-zero temperature threshold is the exception. noFrost inverts the frost status so the conjunction reads as a single sentence: cold outside, protection off, unit running. unprotected combines the first two and armed adds the enable, which is the reference’s third conjunct rather than a host-side gate — so with the ERV disabled yFault reads false, and that false means not applicable, not protected.

The comparison is strict and so is the reference’s, so nothing is lost at the boundary: outdoor air at precisely -10.0 °C is not a fault and -10.1 °C is. persist requires 5 continuous minutes, and the delay does real work despite the short window — frost sequences stage in and out around their own setpoint, and an outdoor reading crossing -10 °C on a windy afternoon can toggle the cold term several times before the sequence latches. Any engagement of the sequence, however brief, drops the timer and discards the accumulated time.

Possible Diagnoses

  1. Frost protection control sequence disabled — switched off during troubleshooting, or never enabled at commissioning
  2. OAT sensor error, reading warmer than actual — the sequence is working and has simply not been told it is cold; the rule cannot distinguish that from a dead sequence, since its own cold term reads the same sensor
  3. Frost protection damper or valve actuator failure — the sequence commands, nothing moves, and the status point may or may not admit it
  4. Preheat coil not functioning: no hot water, a closed isolation valve, or a failed electric element behind a status point that still reports “on”

Energy Impact

PROTECTIVE, MEDIUM confidence, QUALITATIVE_ONLY. There is no energy model here and the reference does not attempt one — it points to the Energy Impact Reference §4.4 and stops. The value is avoided equipment damage: frozen plate cores crack, iced wheels stall their drive motors, and both end in replacement rather than repair (the erv-effectiveness playbook prices a core at $2,000-$5,000). Heating-dominant by construction — the rule is unreachable above -10 °C. The second-order energy cost is unmodelled but ordered: a partially iced core is a degraded core, so a unit that runs unprotected through a cold snap shows up in ERV-0001’s effectiveness test afterwards, as a consequence rather than a coincidence.

Emissions Impact

Scope 2, DIRECT_EMISSIONS, HIGH confidence (the reference rates emissions confidence above energy confidence here, and the frontmatter’s single confidence field carries the MEDIUM from the energy profile). The reference’s typical range is “protective; pump energy minor; equipment damage primary” with no avoided-emissions basis — the consequence is the embodied carbon of a replacement core plus the conditioning energy spent while the recovery device is out of service, neither of which this rule can meter.

Deviations

  • The threshold parameter is negative, deliberately. The library normally expresses negatives as a Sources.Constant plus a Subtract so every literal reads as a magnitude, but a sub-zero temperature threshold is the documented exception: -10 °C is a point on a scale with a fixed zero, not a negated quantity, and 0 − 10 would invent arithmetic the physics does not have. coldOat.t = -10.0 is what an operator types into the BAS, and a host retuning for a milder climate sets -7.0, not 7.0.
  • erv_enabled is a conjunct in the graph, not a host-side operating-state gate. The library keeps operating-state gating in frontmatter, but the reference writes the enable into the equation itself, so the graph implements what the reference states. ERV-0001 makes the same choice with the same point, so the pair behaves consistently on a unit that is off.
  • No evaluability output. SCHEMA.md asks for one when the reference’s semantics include an in-rule evaluability condition; the only gating term here is erv_enabled, which the host binds as a boundary input and can read directly. Contrast ERV-0001’s yTempDeltaOk, which is computed from three temperatures and cannot be seen from outside the rule.
  • The frost test reads oat, the site sensor, not erv_oa_entering_temp, following the reference’s required-points list and the point dictionary, whose oat entry names this frost test as a consumer. On most units the two are the same sensor; where they are not, a well-sited inlet sensor is arguably the better measure of what the core sees, but a rooftop oat in direct sun reads warm, which is diagnosis 2 and silences the rule.
  • Fan status stays a host precondition. The reference’s ERV operating state is “ERV enabled, both supply and exhaust fans running”. The enable is in the graph because the equation names it; fan status is not in this rule’s required point list and is not invented here.
  • A unit with no frost sequence looks identical to a broken one. The rule reads a status point and cannot ask whether the sequence exists, so deploying it on a unit that never had frost protection produces a season-long standing alarm. That is a real finding, but it belongs in a design review, so the exclusion is host-side.
  • AlarmDelay = 5 min becomes persist.delayTime = 300 s with delayOnInit = true (CDL default is false), the library’s standing choice: a unit already running unprotected at controller start waits out the full five minutes rather than alarming on the first tick.
  • Frontmatter clusters is empty: the reference defines no cluster containing an ERV rule, and this card does not edit the cluster set. The relationship to ERV-0001 is carried by related and the shared playbook.
  • Frontmatter g36 is null. This is a research-backed 050-range rule sourced to engineering best practice; G36 has no ERV frost sequence to cite.
  • The reference publishes no test vectors for this card; every scenario in vectors.json is library-authored.

Notes

The erv-effectiveness playbook now starts with a dedicated mode/frost-state check: verify the OAT against a second local sensor, confirm the point means active protection rather than a permissive, and exercise one safe transition through the installed sequence. Continue by checking the damper, valve, wheel, or preheat source that implements protection. A unit that ran unprotected through a cold snap should have its core inspected for damage before it is trusted again.

Test Vectors

9 scenarios, clock step 60 s over 1800 s.

ScenarioDescription
cold_with_frost_protection_offThe motivating case: -15 C outdoor air, the ERV running, and the frost sequence reporting inactive. All three conjuncts hold from t=0, so delayOnInit makes the alarm land one alarm_delay (300 s) in.
cold_with_frost_protection_engagedSame -15 C outdoor air with the frost sequence engaged. This is the unit working as designed and the rule stays silent no matter how cold it gets.
mild_outdoor_air_no_frost_protection-2 C with frost protection off. Frost protection is not supposed to be running here — the threshold is what separates a missing sequence from a sequence that has no reason to act.
erv_disabled_in_deep_cold-20 C, frost protection off, but the ERV is not enabled: no air is moving through the core, so there is nothing to frost. The enable conjunct holds yFault down — and false here means ‘not applicable’, not ‘protected’.
oat_exactly_at_frost_thresholdBoundary: outdoor air sitting exactly on frost_threshold (-10.0 C) with frost protection off. CDL Reals has no LessEqual, so the comparison is strict < and exactly -10.0 clears.
oat_just_below_frost_thresholdBoundary from the other side: -10.1 C, one tenth of a degree colder than the threshold. The cold term now passes and the alarm lands after alarm_delay.
brief_cold_excursionA three-minute dip to -12 C with frost protection off, ending 120 s short of the 300 s alarm_delay. The timer resets when the air warms and no alarm is ever raised.
frost_protection_engages_after_alarmRecovery: the alarm asserts at 300 s, then the frost sequence starts at t=900 s. yFault drops on that same tick — TrueDelay only delays the rising edge — even though the outdoor air is still at -15 C.
erv_shuts_down_after_alarmThe other release path: the alarm asserts at 300 s and the ERV is disabled at t=900 s. yFault drops with the enable, which is the reference’s own semantics — the fault describes an enabled ERV running unprotected.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 60,
    "horizon_s": 1800
  },
  "scenarios": [
    {
      "name": "cold_with_frost_protection_off",
      "description": "The motivating case: -15 C outdoor air, the ERV running, and the frost sequence reporting inactive. All three conjuncts hold from t=0, so delayOnInit makes the alarm land one alarm_delay (300 s) in.",
      "inputs": {
        "oat": -15.0,
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "cold_with_frost_protection_engaged",
      "description": "Same -15 C outdoor air with the frost sequence engaged. This is the unit working as designed and the rule stays silent no matter how cold it gets.",
      "inputs": {
        "oat": -15.0,
        "erv_frost_prot": true,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "mild_outdoor_air_no_frost_protection",
      "description": "-2 C with frost protection off. Frost protection is not supposed to be running here \u2014 the threshold is what separates a missing sequence from a sequence that has no reason to act.",
      "inputs": {
        "oat": -2.0,
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "erv_disabled_in_deep_cold",
      "description": "-20 C, frost protection off, but the ERV is not enabled: no air is moving through the core, so there is nothing to frost. The enable conjunct holds yFault down \u2014 and false here means 'not applicable', not 'protected'.",
      "inputs": {
        "oat": -20.0,
        "erv_frost_prot": false,
        "erv_enabled": false
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_exactly_at_frost_threshold",
      "description": "Boundary: outdoor air sitting exactly on frost_threshold (-10.0 C) with frost protection off. CDL Reals has no LessEqual, so the comparison is strict `<` and exactly -10.0 clears.",
      "inputs": {
        "oat": -10.0,
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "oat_just_below_frost_threshold",
      "description": "Boundary from the other side: -10.1 C, one tenth of a degree colder than the threshold. The cold term now passes and the alarm lands after alarm_delay.",
      "inputs": {
        "oat": -10.1,
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 1800,
          "equals": true
        }
      ]
    },
    {
      "name": "brief_cold_excursion",
      "description": "A three-minute dip to -12 C with frost protection off, ending 120 s short of the 300 s alarm_delay. The timer resets when the air warms and no alarm is ever raised.",
      "inputs": {
        "oat": [
          {
            "t": 0,
            "value": -12.0
          },
          {
            "t": 180,
            "value": -5.0
          }
        ],
        "erv_frost_prot": false,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "frost_protection_engages_after_alarm",
      "description": "Recovery: the alarm asserts at 300 s, then the frost sequence starts at t=900 s. yFault drops on that same tick \u2014 TrueDelay only delays the rising edge \u2014 even though the outdoor air is still at -15 C.",
      "inputs": {
        "oat": -15.0,
        "erv_frost_prot": [
          {
            "t": 0,
            "value": false
          },
          {
            "t": 900,
            "value": true
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        }
      ]
    },
    {
      "name": "erv_shuts_down_after_alarm",
      "description": "The other release path: the alarm asserts at 300 s and the ERV is disabled at t=900 s. yFault drops with the enable, which is the reference's own semantics \u2014 the fault describes an enabled ERV running unprotected.",
      "inputs": {
        "oat": -15.0,
        "erv_frost_prot": false,
        "erv_enabled": [
          {
            "t": 0,
            "value": true
          },
          {
            "t": 900,
            "value": false
          }
        ]
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 240,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 360,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 1800,
          "equals": false
        }
      ]
    }
  ]
}