ERV-0005 — Supply/exhaust airflow imbalance
| Status | verified — engine e2ff2f8, cxf:fnv1a128:5e63c030131568b1c91e53f28f1df15c, 2026-08-20 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | COMFORT_ENERGY |
| Confidence | MEDIUM |
| Estimation | QUALITATIVE_ONLY |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | ERV-0001, ERV-0004, SYS-0008 |
| Playbooks | erv-effectiveness |
| Source | Library-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 states | ERV 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_durationyFlowOk— Evaluability flag — true only when mean absolute flow exceeds minimum_evaluable_flow; false means NO_EVAL and the host must ignore yFaultySupplyHigh— Immediate diagnostic flag — evaluable supply flow exceeds exhaust by more than max_imbalance_fraction of mean flowyExhaustHigh— Immediate diagnostic flag — evaluable exhaust flow exceeds supply by more than max_imbalance_fraction of mean flow
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
max_imbalance_fraction | 0.15 | 1 | allowedDiff.k | ADOPTED_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_flow | 100.0 | L/s | flowOk.t | NO_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_duration | 900.0 | s | supplyHeld.delayTime, exhaustHeld.delayTime | ADOPTED_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):
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
- Supply or exhaust fan failed, overridden, or running at the wrong speed
- Loaded filter/core, blocked intake/discharge, or closed/stuck damper
- Belt, wheel, or runaround-device problem changing system resistance
- Unit never balanced, or balancing changed after filter/fan modifications
- Airflow sensor bias, reversed polarity, mismatched averaging, or wrong unit
- 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 withfraction × 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/yExhaustHighare immediate flow-gated diagnostics. They are not persistence outputs and are not gated byerv_enabled;yFaultis. False direction flags never mean NO_EVAL — that meaning belongs only toyFlowOk.- 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.
| Scenario | Description |
|---|---|
balanced_positive_flows_are_healthy | Equal 1000 L/s streams are evaluable, balanced, and quiet. |
supply_high_asserts_after_its_own_window | Supply 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_window | The mirror direction is independently detected and timed. |
imbalance_exactly_at_fraction_is_healthy | 1075 and 925 L/s produce mean=1000 and difference=150, exactly the 0.15 allowance. Reals.Greater is strict. |
imbalance_just_beyond_fraction_alarms | Keeping 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_eval | 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. |
mean_exactly_at_minimum_blocks_a_large_ratio | One 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_evaluable | 200.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_eval | The multiply-and-compare formulation contains no division: zero flows produce allowed difference zero, false directions, and yFlowOk false. |
negative_flow_binding_produces_a_misleading_direction | 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. |
transient_imbalance_one_sample_short_stays_clear | Supply is high for 870 s, one sample short of the window, then the streams rebalance. |
direction_reversal_restarts_persistence | 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. |
disabled_unit_exposes_direction_but_not_fault | Direction and evaluability outputs describe the measurements even when disabled; the alarm’s armed branches remain false. |
rebalance_clears_an_active_alarm | A 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_alarm | After 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_accumulate | Three short supply-high bursts separated by balanced intervals never mature the supply timer. |
imbalance_just_inside_fraction_is_healthy | Keeping 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_eval | 199.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_maturity | Both streams rebalance at t=870, one 30 s step before the 900 s timer edge, so yFault never matures. |
imbalance_clears_two_steps_after_maturity | 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. |
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
}
]
}
]
}