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-0005 — Supply/exhaust airflow imbalance

Statusverified — engine e2ff2f8, cxf:fnv1a128:5e63c030131568b1c91e53f28f1df15c, 2026-08-20
Severity3
Methodrule
Phase2
CategoryCOMFORT_ENERGY
ConfidenceMEDIUM
EstimationQUALITATIVE_ONLY
G36
Clusters
Suppresses
Suppressed by
RelatedERV-0001, ERV-0004, SYS-0008
Playbookserv-effectiveness
SourceLibrary-authored from conservation of flow and ERV commissioning practice: compare the two device-local air streams on one averaging basis, while retaining any intentional design offset as a site configuration obligation; Library precedents: ERV-0001 (device-local recovery boundaries and evaluability), SYS-0008 (two directional air-balance findings with per-condition persistence), and CHW-0005 (MultiplyByParameter plus Greater for a dynamic threshold); points/erv.points.json erv_supply_airflow and erv_exhaust_airflow — Brick 1.4.4 / ASHRAE 223 / QUDT-grounded stream and unit contract; NREL Standard Work Specification 6.0303.1p and PNNL Building America balanced-ventilation guidance — public support for balancing incoming/outgoing ERV flow for recovery and pressure control; DOE/NREL Ventilation Integrated Comfort System report pp.28-29 — public example of frost prevention intentionally unbalancing core-path airflow, requiring the host frost-mode exclusion
Operating statesERV enabled and intended to operate in a balanced-flow mode, after both fans/dampers have completed their normal start or transition

Preconditions (host-enforced): Both flow measurements must represent the same ERV, the streams that actually traverse the recovery device, and the same time-averaging basis in finite nonnegative L/s. Reversed polarity or signed bidirectional bindings are invalid; the graph intentionally does not repair them. minimum_evaluable_flow must be configured from this unit’s size and sensor accuracy before deployment. The unit must have a balanced-flow intent: where design calls for building pressurization or another nonzero offset, the host must normalize/bias the measurements or configure a separate approved limit. Exclude smoke control, kitchen/lab exhaust offsets, purge, commissioning/balancing, demand-control ramps, frost strategies that intentionally unbalance flow, and sensor calibration failures. erv_enabled is in-graph; all other mode and quality gates remain host-side.

Points: erv_supply_airflow, erv_exhaust_airflow, erv_enabled

Outputs:

  • yFault — True while one evaluable directional imbalance remains active and enabled continuously for sustained_duration
  • yFlowOk — Evaluability flag — true only when mean absolute flow exceeds minimum_evaluable_flow; false means NO_EVAL and the host must ignore yFault
  • ySupplyHigh — Immediate diagnostic flag — evaluable supply flow exceeds exhaust by more than max_imbalance_fraction of mean flow
  • yExhaustHigh — Immediate diagnostic flag — evaluable exhaust flow exceeds supply by more than max_imbalance_fraction of mean flow

Parameters:

NameDefaultUnitCXF pathDescription
max_imbalance_fraction0.151allowedDiff.kADOPTED_TUNABLE: directional difference allowed as a fraction of mean absolute flow. Must remain positive and be commissioned against design pressure/offset intent; 0.15 is an executable starting point, not a universal ventilation requirement.
minimum_evaluable_flow100.0L/sflowOk.tNO_PORTABLE_DEFAULT: runnable 100 L/s placeholder below which sensor noise and ratios are not trusted. Set a positive instance value from design flow, turndown, and both sensors’ usable range before deployment.
sustained_duration900.0ssupplyHeld.delayTime, exhaustHeld.delayTimeADOPTED_TUNABLE: continuous imbalance in one direction required before alarm (15 min). One card parameter drives both direction timers; hosts must set both paths together.

Description

Energy recovery depends on two comparable air streams. A failed fan, loaded filter, closed damper, poor balance, or bad sensor can leave one stream carrying substantially more air than the other, reducing useful recovery and pushing the building away from its intended pressure. This rule normalizes their difference by mean flow, rejects low-flow operation, and reports which stream is high.

Detection Logic

mean_flow    = (|erv_supply_airflow| + |erv_exhaust_airflow|) / 2
allowed_diff = max_imbalance_fraction × mean_flow

yFlowOk      = mean_flow > minimum_evaluable_flow
ySupplyHigh  = yFlowOk AND (erv_supply_airflow − erv_exhaust_airflow > allowed_diff)
yExhaustHigh = yFlowOk AND (erv_exhaust_airflow − erv_supply_airflow > allowed_diff)

yFault = (erv_enabled AND ySupplyHigh) sustained for sustained_duration
      OR (erv_enabled AND yExhaustHigh) sustained for sustained_duration

Block graph (rule.cxf.jsonld):

ERV-0005 block graph

The graph never divides: multiplying the positive evaluable mean by the allowed fraction is algebraically equivalent and remains defined at zero flow. Both comparisons and the flow floor are strict. Each direction owns a startup- conservative timer, so a direct reversal resets persistence rather than allowing two opposite short intervals to combine.

Possible Diagnoses

  1. Supply or exhaust fan failed, overridden, or running at the wrong speed
  2. Loaded filter/core, blocked intake/discharge, or closed/stuck damper
  3. Belt, wheel, or runaround-device problem changing system resistance
  4. Unit never balanced, or balancing changed after filter/fan modifications
  5. Airflow sensor bias, reversed polarity, mismatched averaging, or wrong unit
  6. Intentional pressure offset not represented in the binding/configuration

Energy Impact

COMFORT_ENERGY, MEDIUM confidence, QUALITATIVE_ONLY. The observed flow difference is direct, but its energy consequence is not: it depends on fan curves, envelope leakage, weather, pressure intent, and recovery effectiveness. The most valuable output is often operational — which air path to inspect first.

Emissions Impact

Scope 1 + 2, QUALITATIVE_EMISSIONS. Fan waste is scope 2; extra conditioning from lost recovery or pressure-driven outdoor air follows the building’s electric cooling and electric/fuel heating systems. No emissions value is inferred here.

Deviations

  • Division-free dynamic comparison. The roadmap writes difference / mean; this graph compares each difference with fraction × mean, exactly equivalent when evaluable and defined even when both flows are zero.
  • One timer per direction. A single delay after the directional OR would not reset on an instantaneous reversal, contradicting the required vector. The SYS-0008 per-condition idiom preserves the stated reset behavior.
  • ySupplyHigh/yExhaustHigh are immediate flow-gated diagnostics. They are not persistence outputs and are not gated by erv_enabled; yFault is. False direction flags never mean NO_EVAL — that meaning belongs only to yFlowOk.
  • Absolute values serve only mean-flow evaluability. Signed differences still choose direction, so a negative binding can look evaluable and alarm backwards; vectors pin this raw behavior and the host must reject such data.
  • 0.15 is ADOPTED_TUNABLE; 100 L/s is NO_PORTABLE_DEFAULT. Neither is called a standard requirement. Both must be reconciled with unit size, sensor range, and intentional pressure offset.
  • Frost-mode unbalance is explicitly excluded. DOE’s VICS testing shows a legitimate tempering strategy that changes the two core paths differently; evaluating that interval would diagnose the frost sequence as a flow fault.
  • No suppression or new cluster. ERV-0004 can coexist with balanced airflow, and degraded effectiveness can remain a real separate finding. The shared investigation order is documented in the playbook.
  • No empirical validation claim. The current EnergyPlus harness lacks two defensible device-local airflow measurements and operating-state mappings; synthetic vectors cover thresholds, evaluability, timing, and bad bindings.

Notes

Do not “fix” a design pressure offset by widening the threshold until every unit passes. Normalize to the intended offset first; the residual is the imbalance this rule is meant to detect.

Test Vectors

20 scenarios, clock step 30 s over 2700 s.

ScenarioDescription
balanced_positive_flows_are_healthyEqual 1000 L/s streams are evaluable, balanced, and quiet.
supply_high_asserts_after_its_own_windowSupply exceeds exhaust by 40% of mean flow. The direction flag is immediate; yFault waits the full startup-conservative 900 s.
exhaust_high_asserts_after_its_own_windowThe mirror direction is independently detected and timed.
imbalance_exactly_at_fraction_is_healthy1075 and 925 L/s produce mean=1000 and difference=150, exactly the 0.15 allowance. Reals.Greater is strict.
imbalance_just_beyond_fraction_alarmsKeeping mean at 1000 while moving the streams 0.1 L/s each side makes the difference 150.2, just beyond the allowance.
both_flows_below_minimum_are_no_evalEqual 80 L/s streams are balanced but below the instance’s evaluability floor. yFlowOk is false and the verdict is NO_EVAL, not healthy.
mean_exactly_at_minimum_blocks_a_large_ratioOne stream at 200 L/s and one at zero gives mean=100 exactly. The strict floor blocks both the direction output and alarm.
mean_just_above_minimum_is_evaluable200.2 L/s against zero gives mean=100.1, just above the floor; the supply direction now evaluates and matures.
both_flows_zero_are_safely_no_evalThe multiply-and-compare formulation contains no division: zero flows produce allowed difference zero, false directions, and yFlowOk false.
negative_flow_binding_produces_a_misleading_directionNegative flow violates the point contract. The raw graph uses magnitudes only for mean flow and reports exhaust high here, pinning why the host must reject signed/reversed bindings.
transient_imbalance_one_sample_short_stays_clearSupply is high for 870 s, one sample short of the window, then the streams rebalance.
direction_reversal_restarts_persistenceThe unit is continuously imbalanced but flips direction at t=600. Separate direction timers discard the first lane; the true assertion starts one step after the exhaust lane’s timer edge.
disabled_unit_exposes_direction_but_not_faultDirection and evaluability outputs describe the measurements even when disabled; the alarm’s armed branches remain false.
rebalance_clears_an_active_alarmA startup violation matures at t=900 and clears immediately when both streams return to 1000 L/s at t=1200.
flow_drop_forces_no_eval_and_clears_alarmAfter a mature imbalance, both streams fall below the minimum. yFlowOk and both direction outputs fall on the same tick as yFault.
intermittent_imbalance_does_not_accumulateThree short supply-high bursts separated by balanced intervals never mature the supply timer.
imbalance_just_inside_fraction_is_healthyKeeping mean at 1000 while using a 149.8 L/s difference places the signal just inside the strict 0.15 allowance.
mean_just_below_minimum_is_no_eval199.8 L/s against zero gives mean=99.9, just below the 100 L/s floor; the large raw ratio is blocked.
imbalance_clears_one_step_before_maturityBoth streams rebalance at t=870, one 30 s step before the 900 s timer edge, so yFault never matures.
imbalance_clears_two_steps_after_maturityThe direction remains active two steps beyond the 900 s edge. Assertions skip the boundary tick, prove yFault at t=930, and prove recovery at t=960.
vectors.json
{
  "schema": "cxf-library/vectors/v1",
  "clock": {
    "step_s": 30,
    "horizon_s": 2700
  },
  "scenarios": [
    {
      "name": "balanced_positive_flows_are_healthy",
      "description": "Equal 1000 L/s streams are evaluable, balanced, and quiet.",
      "inputs": {
        "erv_supply_airflow": 1000.0,
        "erv_exhaust_airflow": 1000.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "supply_high_asserts_after_its_own_window",
      "description": "Supply exceeds exhaust by 40% of mean flow. The direction flag is immediate; yFault waits the full startup-conservative 900 s.",
      "inputs": {
        "erv_supply_airflow": 1200.0,
        "erv_exhaust_airflow": 800.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "exhaust_high_asserts_after_its_own_window",
      "description": "The mirror direction is independently detected and timed.",
      "inputs": {
        "erv_supply_airflow": 800.0,
        "erv_exhaust_airflow": 1200.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "imbalance_exactly_at_fraction_is_healthy",
      "description": "1075 and 925 L/s produce mean=1000 and difference=150, exactly the 0.15 allowance. Reals.Greater is strict.",
      "inputs": {
        "erv_supply_airflow": 1075.0,
        "erv_exhaust_airflow": 925.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "imbalance_just_beyond_fraction_alarms",
      "description": "Keeping mean at 1000 while moving the streams 0.1 L/s each side makes the difference 150.2, just beyond the allowance.",
      "inputs": {
        "erv_supply_airflow": 1075.1,
        "erv_exhaust_airflow": 924.9,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "both_flows_below_minimum_are_no_eval",
      "description": "Equal 80 L/s streams are balanced but below the instance's evaluability floor. yFlowOk is false and the verdict is NO_EVAL, not healthy.",
      "inputs": {
        "erv_supply_airflow": 80.0,
        "erv_exhaust_airflow": 80.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "mean_exactly_at_minimum_blocks_a_large_ratio",
      "description": "One stream at 200 L/s and one at zero gives mean=100 exactly. The strict floor blocks both the direction output and alarm.",
      "inputs": {
        "erv_supply_airflow": 200.0,
        "erv_exhaust_airflow": 0.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "mean_just_above_minimum_is_evaluable",
      "description": "200.2 L/s against zero gives mean=100.1, just above the floor; the supply direction now evaluates and matures.",
      "inputs": {
        "erv_supply_airflow": 200.2,
        "erv_exhaust_airflow": 0.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "both_flows_zero_are_safely_no_eval",
      "description": "The multiply-and-compare formulation contains no division: zero flows produce allowed difference zero, false directions, and yFlowOk false.",
      "inputs": {
        "erv_supply_airflow": 0.0,
        "erv_exhaust_airflow": 0.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "negative_flow_binding_produces_a_misleading_direction",
      "description": "Negative flow violates the point contract. The raw graph uses magnitudes only for mean flow and reports exhaust high here, pinning why the host must reject signed/reversed bindings.",
      "inputs": {
        "erv_supply_airflow": -600.0,
        "erv_exhaust_airflow": -400.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "transient_imbalance_one_sample_short_stays_clear",
      "description": "Supply is high for 870 s, one sample short of the window, then the streams rebalance.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 300,
            "value": 1200.0
          },
          {
            "t": 1170,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 300,
            "value": 800.0
          },
          {
            "t": 1170,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "direction_reversal_restarts_persistence",
      "description": "The unit is continuously imbalanced but flips direction at t=600. Separate direction timers discard the first lane; the true assertion starts one step after the exhaust lane's timer edge.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 600,
            "value": 800.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 600,
            "value": 1200.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 570,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 600,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 570,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 600,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 1470,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 1530,
          "to_s": 2700,
          "equals": true
        }
      ]
    },
    {
      "name": "disabled_unit_exposes_direction_but_not_fault",
      "description": "Direction and evaluability outputs describe the measurements even when disabled; the alarm's armed branches remain false.",
      "inputs": {
        "erv_supply_airflow": 1200.0,
        "erv_exhaust_airflow": 800.0,
        "erv_enabled": false
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "rebalance_clears_an_active_alarm",
      "description": "A startup violation matures at t=900 and clears immediately when both streams return to 1000 L/s at t=1200.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 1200,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 1200,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "flow_drop_forces_no_eval_and_clears_alarm",
      "description": "After a mature imbalance, both streams fall below the minimum. yFlowOk and both direction outputs fall on the same tick as yFault.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 1200,
            "value": 50.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 1200,
            "value": 0.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFlowOk",
          "from_s": 1200,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 1170,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 1200,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 1200,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "intermittent_imbalance_does_not_accumulate",
      "description": "Three short supply-high bursts separated by balanced intervals never mature the supply timer.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 300,
            "value": 1200.0
          },
          {
            "t": 600,
            "value": 1000.0
          },
          {
            "t": 900,
            "value": 1200.0
          },
          {
            "t": 1200,
            "value": 1000.0
          },
          {
            "t": 1500,
            "value": 1200.0
          },
          {
            "t": 1800,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 1000.0
          },
          {
            "t": 300,
            "value": 800.0
          },
          {
            "t": 600,
            "value": 1000.0
          },
          {
            "t": 900,
            "value": 800.0
          },
          {
            "t": 1200,
            "value": 1000.0
          },
          {
            "t": 1500,
            "value": 800.0
          },
          {
            "t": 1800,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "imbalance_just_inside_fraction_is_healthy",
      "description": "Keeping mean at 1000 while using a 149.8 L/s difference places the signal just inside the strict 0.15 allowance.",
      "inputs": {
        "erv_supply_airflow": 1074.9,
        "erv_exhaust_airflow": 925.1,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yExhaustHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "mean_just_below_minimum_is_no_eval",
      "description": "199.8 L/s against zero gives mean=99.9, just below the 100 L/s floor; the large raw ratio is blocked.",
      "inputs": {
        "erv_supply_airflow": 199.8,
        "erv_exhaust_airflow": 0.0,
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFlowOk",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "imbalance_clears_one_step_before_maturity",
      "description": "Both streams rebalance at t=870, one 30 s step before the 900 s timer edge, so yFault never matures.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 870,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 870,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 2700,
          "equals": false
        },
        {
          "output": "ySupplyHigh",
          "from_s": 0,
          "to_s": 840,
          "equals": true
        },
        {
          "output": "ySupplyHigh",
          "from_s": 870,
          "to_s": 2700,
          "equals": false
        }
      ]
    },
    {
      "name": "imbalance_clears_two_steps_after_maturity",
      "description": "The direction remains active two steps beyond the 900 s edge. Assertions skip the boundary tick, prove yFault at t=930, and prove recovery at t=960.",
      "inputs": {
        "erv_supply_airflow": [
          {
            "t": 0,
            "value": 1200.0
          },
          {
            "t": 960,
            "value": 1000.0
          }
        ],
        "erv_exhaust_airflow": [
          {
            "t": 0,
            "value": 800.0
          },
          {
            "t": 960,
            "value": 1000.0
          }
        ],
        "erv_enabled": true
      },
      "expect": [
        {
          "output": "yFault",
          "from_s": 0,
          "to_s": 870,
          "equals": false
        },
        {
          "output": "yFault",
          "from_s": 930,
          "to_s": 930,
          "equals": true
        },
        {
          "output": "yFault",
          "from_s": 960,
          "to_s": 2700,
          "equals": false
        }
      ]
    }
  ]
}