HP-0002 — Defrost cycle anomaly
| Status | verified — engine e2ff2f8, cxf:fnv1a128:1cb2669baf4a0c1ac7e2d345e6854370, 2026-08-17 |
| Severity | 3 |
| Method | rule |
| Phase | 2 |
| Category | EFFICIENCY_LOSS |
| Confidence | MEDIUM |
| Estimation | PROXY_ESTIMATION |
| G36 | — |
| Clusters | — |
| Suppresses | — |
| Suppressed by | — |
| Related | HP-0001, HP-0003, HP-0007, HP-0008 |
| Playbooks | heat-pump-faults |
| Source | HVAC FDD Reference v1.0 §11, HP-0002; HVAC FDD Reference v1.0, Remediation Playbooks pp. 169-170; Barandier 2023 |
| Operating states | heating mode |
Preconditions (host-enforced): The unit must be in heating mode with the compressor running — the reference’s operating state, and outside it defrost_status carries no information this rule can read. The host must report NO_EVAL for the first count_window (1 h) after engine start: while the moving average’s window fills, its divisor is elapsed time rather than the window, so the frequency branch reads an extrapolated rate instead of a completed-hour count, and alarm_delay (30 min) is too short to cover that hour on its own. The host tick interval must sit inside the band the count arithmetic and the edge counter jointly allow, and count_scale must be retuned with it (see Deviations). defrost_status must be the unit’s own defrost-active flag sampled faster than its shortest cycle; a defrost shorter than one tick is invisible to the counter.
Points: defrost_status, oat
Outputs:
yFault— True while at least one of the three defrost defects — too many cycles in the trailing hour, one cycle running past max_defrost_duration, or a cycle running at a mild outdoor temperature — has held continuously for alarm_delay
Parameters:
| Name | Default | Unit | CXF path | Description |
|---|---|---|---|---|
max_defrost_frequency | 4.0 | 1/h | freqHigh.t | Defrost cycles per hour above which the cadence counts as excessive. Must stay strictly below the edge counter’s Nyquist ceiling of count_window / (2 × host tick) — 6/h at the default 3600 s window and 300 s tick |
count_window | 3600.0 | s | rate.delta | Trailing window the defrost starts are counted over (1 h). It also fixes the units of max_defrost_frequency; a host that shortens it must retune count_scale with it and read the limit as cycles per window |
count_scale | 12.0 | 1 | count.k | Rescales the moving average of the one-tick pulse train back into a cycle count: k = count_window / host tick interval in seconds. The default 12.0 is 3600/300, correct only at a 300 s tick; a host on a different tick MUST retune this or every count is wrong by the ratio of the two intervals |
max_defrost_duration | 900.0 | s | tooLong.delayTime | Continuous defrost time above which one cycle counts as overlong (15 min) |
defrost_unnecessary_temp | 7.0 | °C | mildOat.t | Outdoor temperature above which a coil should not need defrosting at all, so a cycle running here indicts the defrost sensor or board. Adopted from the reference’s remediation playbook, not from its tunables table |
alarm_delay | 1800.0 | s | persist.delayTime | Continuous fault persistence required before the alarm asserts (30 min) |
Description
Defrost is a heat pump running backwards on purpose: the reversing valve swaps, the outdoor coil becomes a condenser, and for five to fifteen minutes the unit cools the outdoors with the heat it owed the building — usually with resistance heat propping up the supply air so nobody notices. The question is whether the unit is buying more of those minutes than the coil needs. There are three ways to buy too many and the reference tests all three: cycling too often, a cycle that will not terminate, and a cycle initiated where there is no ice to clear. They share a fault code because they share a service call — somebody opens the outdoor unit and looks at the coil, the defrost sensor, and the board. None of it is visible from the space, which is what makes this a metered-energy fault.
Detection Logic
starts = rising edges of defrost_status
count = MovingAverage(starts, count_window) × count_scale cycles in the trailing hour
too_often = count > max_defrost_frequency
too_long = defrost_status held continuously for max_defrost_duration
needless = defrost_status AND oat > defrost_unnecessary_temp
yFault = (too_often OR too_long OR needless) sustained continuously for alarm_delay
Block graph (rule.cxf.jsonld):
The frequency branch is AHU-0004’s counter idiom applied to a boolean point:
dfStart reduces each cycle to a one-tick pulse and rate, a continuous-time
mean of u·dt over the window, returns n · dt / count_window, which
count_scale = count_window / dt = 3600/300 = 12 turns back into n. The count
is therefore tied to the host’s tick interval in two ways a deployer must
honour: count_scale retunes with the tick, and because the pulses are edges
the fastest observable cadence is count_window / (2 × dt) — 6/h at the
defaults, inside a legal tick band of 57.1–450 s (see Deviations).
tooLong needs no counting: a TrueDelay on defrost_status matures when one
cycle has run continuously for max_defrost_duration and falls the instant the
cycle ends — inclusive at its boundary, where the two threshold comparisons are
strict. mildOat and needless are the third branch, and the conjunction
matters: a mild outdoor temperature is not a fault and a defrost cycle is not a
fault; a defrost cycle at a mild outdoor temperature is. persist applies the
reference’s single 30-minute delay once, after the Or tree, so the branches
accumulate rather than each having to persist alone.
Possible Diagnoses
- Outdoor coil heavily fouled or iced — the coil genuinely needs the cycles it is taking, and the fix is cleaning rather than controls
- Defrost sensor failure — the one cause that can produce all three branches, since it both initiates cycles that are not needed and fails to terminate the ones that are
- Defrost control board malfunction, including a time-initiated defrost timer left at a setting the manufacturer no longer recommends
- Refrigerant charge issue — low charge lowers coil temperature and brings on frost that would not otherwise form, showing here as cadence rather than as capacity (HP-0001 is the rule that sees it as capacity)
Energy Impact
EFFICIENCY_LOSS, MEDIUM confidence, PROXY_ESTIMATION. Every defrost cycle costs
twice — the heat pulled back out of the building, plus the supplementary heat
brought on to cover the gap — and the reference puts the excess at 3–10% of
heating energy. waste_kw ≈ defrost_fraction × hp_heating_kw scales the unit’s
heating draw by the share of hours spent in unnecessary defrost. PROXY, and
MEDIUM, for the same reason: the cycle counts are solid but the share of them
that was unnecessary is an inference, and a coil that is genuinely icing is
being correctly served by cycles this rule flags. Heating-dominant, worst in the
humid part of the season around 0–5 °C where frost forms fastest.
Emissions Impact
Scope 2, PROXY_EMISSIONS, MEDIUM confidence; typically 200–1,500 kg CO₂e/yr from excess defrost energy. All electric — compressor plus whatever supplementary heat covers the cycle — so the avoided-emissions basis is the marginal operating emissions rate (MOER). Cold mornings are both when the fault costs most and when the grid is dirtiest, which pushes the marginal figure above the average one.
Deviations
comp_statusis dropped from the points list. The reference’s Required Points row lists it but its equation never uses it — all three branches readdefrost_statusandoat. Compressor state is what makes the operating state (heating mode) true, and operating states are declared in frontmatter here rather than binding a point the graph ignores. Precedent: VAV-0001 dropszone_airflow, AHU-0029 dropsoat.defrost_unnecessary_temphas no default in the reference’s tunables table — the card names it in the equation and then lists only the other three. The 7.0 °C shipped here comes from the same document’s remediation playbooks (step 1.b: defrost should not be needed above 7 °C / 45 °F), the nearest in-document authority and the number a technician would verify by hand.- The rolling count is built from a moving average, because the block set has
no windowed counter.
Integers.OnCountercounts monotonically from a reset, so “cycles in the trailing hour” would need a host-driven hourly reset — a tumbling window whose verdict depends on where the boundary fell. AHU-0004 established the idiom;Logical.EdgereplacesIntegers.Changebecause the counted signal is boolean. count_scaleis coupled to the host’s tick interval:k = count_window / dt, and the default 12.0 is correct only at the 300 s tick the vectors use. A host ticking every 60 s must set 60.0; leaving it at 12.0 reports a fifth of the true cadence and the branch never fires. The failure is silent — a mis-set scale still produces a plausible number — so it belongs on any deployment checklist.- The tick interval has a legal band, and its ceiling is half what a level
counter would give. A rising edge needs one false sample between two true
ones, so the most cycles observable in a window is
count_window / (2 × dt)— 6/h at the defaults, andmax_defrost_frequencymust sit strictly below it (the shipped 4/h needsdt < 450 s). The floor isReals.MovingAverage’s fixed 64-checkpoint ring:dt ≥ count_window / 63 ≈ 57.1 s. - Sampling floor on the point itself. A defrost cycle shorter than one tick
is invisible to the edge counter — the point may never be sampled true. The
point dictionary flags this on
defrost_status; at a 300 s tick against a 5–10 minute cycle the margin is thin, and a host that can afford a 60 s tick should take it (and setcount_scale = 60.0). - The first hour reads as a rate, not a count, and
alarm_delaydoes not cover it. Whilet < count_windowthe moving average divides by elapsed time rather than by the window, so the first defrost start of a run reads as 12/h — the pace, extrapolated. AHU-0004’salarm_delayequals itscount_window, sodelayOnInitblocks any verdict until the window fills; here it is 1800 s against 3600 s and does not, which is why the frontmatter requires the host to report NO_EVAL for the first hour. - The duration branch is inclusive at its boundary, where the reference is
strict. The reference writes
defrost_duration > max_defrost_duration, butLogical.TrueDelayasserts when accumulated true-time reachesdelayTime, so the realized test is≥. Observable resolution is one tick either way, and the timer restarts from the next rising edge rather than resuming. - Strict
>on the other two thresholds. CDL Reals has noGreaterEqual, so exactly 4 cycles an hour reads clear and 5 alarms, and a cycle at exactly 7.0 °C does not count as needless. The frequency boundary is unambiguous rather than measure-zero — the recovered count takes integer values in steady state — while the temperature boundary is measure-zero on a real-valued signal. - The third branch cannot reach a verdict on its own at the reference’s
defaults, and that is arithmetic rather than a wiring choice.
needlessis true only while a cycle runs, the singleAlarmDelayis 30 minutes, and a normal defrost is 5–15, so a cycle long enough to fill the persistence window has already tripped the duration branch. What the branch contributes is timing — it starts the clock at the beginning of a mild-weather cycle, landing the alarm 900 s sooner — which is why repeated mild-weather defrosting is reported through the frequency branch instead. Calling unnecessary defrost on its own evidence needsalarm_delaybelow a typical cycle length, at the cost of twitchier other branches. - One
AlarmDelayon the disjunction, not three. The reference states a single 30-minute delay for the card and this rule applies it once, after theOrtree, so a cycle that is both overlong and needless does not need each branch to persist separately — only their union. Logical.Edge’spre_u_startis left at the CDL default (false), since the CXF contract sets only non-default parameters. The consequence is a spurious rising edge if the unit is already in defrost at controller start, and it costs nothing: the moving average integratesu·dtanddtis zero on the first tick, so that pulse encloses no area and never reaches the count.delayOnInit = trueon bothTrueDelayinstances (CDL default isfalse), the library’s standing choice. Onpersista unit already faulted at load waits out the full 30 minutes; ontooLonga defrost already running is timed from the controller start rather than assumed to have run forever.- Operating state (heating mode) is declared in frontmatter for host enforcement
rather than encoded in the block graph. Severity 3 and
method: ruleare the reference’s chapter 11 card; its §5.8.4 index carries no severity column. - The reference publishes no test vectors. Every scenario in
vectors.jsonis authored from the equation, and every assertion edge was derived by replaying the graph at the pinned engine rev rather than by closed-form arithmetic — the moving average’s warm-up trajectory does not match hand-computed statistics.
Notes
The three branches point at different service work even though they share a playbook. Cadence with normal-length cycles is a coil or a charge problem; a cycle that will not terminate is the defrost sensor or the board, and the branch most likely to be a single failed part; cycles at mild outdoor temperatures are almost always the sensor reading low. Step 2.b of the heat-pump-faults playbook works that order, and its resolution test is deliberately stricter than this alarm — under 4 cycles/h and under 10 minutes each over 48 h, so a unit at 4 cycles an hour of 14 minutes apiece clears every branch here while spending a fifth of its heating hours in defrost. Check HP-0001 on the same unit before ordering a coil cleaning: low charge frosts a coil sooner, and if both rules fire the charge is the more likely root cause and the cheaper one to verify.
Test Vectors
13 scenarios, clock step 300 s over 12000 s.
| Scenario | Description |
|---|---|
no_defrost_activity | A cold day with the defrost sequence idle: no cycles, no duration, no mild-weather trigger. The rolling count sits at zero and all three branches stay down. |
normal_defrost_cadence | Healthy winter operation: one 5-minute defrost every 30 minutes, so 2 cycles per hour against a limit of 4. The count reads 12/hr on the first pulse — the moving average divides by elapsed time until its window fills — but that artifact collapses within two ticks and never survives the 30-minute persistence. |
four_cycles_per_hour_is_exactly_the_threshold | Threshold edge: the unit settles into a defrost every 15 minutes — exactly max_defrost_frequency — starting after the counting window has filled, so the reading is a completed-hour count rather than an extrapolated rate. count holds at precisely 4.0 and the strict > keeps it clear. |
five_cycles_per_hour_is_the_first_faulted_cadence | Threshold edge from the other side, and the finest step a 300 s tick can resolve: a repeating 600/600/600/900/900 s spacing puts exactly 5 rising edges in every trailing hour. The count clears 4 at t = 6600 s and the alarm follows one alarm_delay later. |
excessive_defrost_frequency | A coil icing up faster than it can be cleared: defrost every 10 minutes, 6 cycles per hour, beginning after the counting window has filled. The count crosses 4 at t = 6300 s — the fifth cycle of the run — and the alarm lands 1800 s after that. |
defrost_overlong | One defrost cycle that never terminates. tooLong matures at 900 s and the alarm 1800 s after that, at t = 2700 s. The frequency branch contributes nothing: the single rising edge lands on tick 0, where the moving average’s integrand encloses no area. |
defrost_released_on_the_maturity_tick | Duration edge: the first cycle is observed true at t = 0, 300 and 600 s and false at 900 s — the tick the duration timer would have matured on — so it does not trip the branch. Defrost restarts at 1200 s and runs on, which matures tooLong at 2100 s and the alarm at 3900 s: 1200 s later than the uninterrupted case. |
defrost_released_on_the_alarm_tick | Persistence edge: a single defrost runs from t = 0 and ends at t = 2700 s, the exact tick the alarm would assert on. The input is already false when persist is evaluated, so nothing is ever reported — a 45-minute defrost that this rule declines to call. |
defrost_released_one_tick_later | Persistence edge from the other side: the same defrost held 300 s longer asserts at t = 2700 s and drops again at 3000 s when the cycle ends. One tick of difference in the input is one tick of alarm. |
oat_exactly_at_the_mild_threshold | Mild-weather edge: a continuous defrost at exactly defrost_unnecessary_temp (7.0 °C). The comparison is a strict >, so the third branch stays down and the alarm arrives on the duration branch’s schedule at t = 2700 s. |
mild_oat_advances_the_alarm | Mild-weather edge from the other side: one tenth of a degree warmer and the third branch is live from t = 0, so the alarm matures at 1800 s instead of 2700 s. Defrosting at 7.1 °C means the defrost sensor or board is calling for a cycle the coil cannot need. |
mild_oat_short_cycles_never_persist | The third branch’s practical limit: 5-minute defrosts at 10 °C, twice an hour. Every cycle is needless and the branch says so, but it is true for one tick at a time and the 30-minute persistence never fills. On its own, branch 3 only reports a defrost that outlasts alarm_delay — see the card’s Deviations. |
alarm_clears_when_defrost_ends | Recovery: a stuck defrost alarms at 2700 s and the cycle finally terminates at 5400 s (the defrost sensor was replaced, or the board was reset). Both timers fall immediately and the alarm clears on that tick. |
vectors.json
{
"schema": "cxf-library/vectors/v1",
"clock": {
"step_s": 300,
"horizon_s": 12000
},
"scenarios": [
{
"name": "no_defrost_activity",
"description": "A cold day with the defrost sequence idle: no cycles, no duration, no mild-weather trigger. The rolling count sits at zero and all three branches stay down.",
"inputs": {
"oat": -5.0,
"defrost_status": false
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 12000,
"equals": false
}
]
},
{
"name": "normal_defrost_cadence",
"description": "Healthy winter operation: one 5-minute defrost every 30 minutes, so 2 cycles per hour against a limit of 4. The count reads 12/hr on the first pulse \u2014 the moving average divides by elapsed time until its window fills \u2014 but that artifact collapses within two ticks and never survives the 30-minute persistence.",
"inputs": {
"oat": -5.0,
"defrost_status": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 600,
"value": false
},
{
"t": 2100,
"value": true
},
{
"t": 2400,
"value": false
},
{
"t": 3900,
"value": true
},
{
"t": 4200,
"value": false
},
{
"t": 5700,
"value": true
},
{
"t": 6000,
"value": false
},
{
"t": 7500,
"value": true
},
{
"t": 7800,
"value": false
},
{
"t": 9300,
"value": true
},
{
"t": 9600,
"value": false
},
{
"t": 11100,
"value": true
},
{
"t": 11400,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 12000,
"equals": false
}
]
},
{
"name": "four_cycles_per_hour_is_exactly_the_threshold",
"description": "Threshold edge: the unit settles into a defrost every 15 minutes \u2014 exactly max_defrost_frequency \u2014 starting after the counting window has filled, so the reading is a completed-hour count rather than an extrapolated rate. `count` holds at precisely 4.0 and the strict `>` keeps it clear.",
"inputs": {
"oat": -5.0,
"defrost_status": [
{
"t": 0,
"value": false
},
{
"t": 3900,
"value": true
},
{
"t": 4200,
"value": false
},
{
"t": 4800,
"value": true
},
{
"t": 5100,
"value": false
},
{
"t": 5700,
"value": true
},
{
"t": 6000,
"value": false
},
{
"t": 6600,
"value": true
},
{
"t": 6900,
"value": false
},
{
"t": 7500,
"value": true
},
{
"t": 7800,
"value": false
},
{
"t": 8400,
"value": true
},
{
"t": 8700,
"value": false
},
{
"t": 9300,
"value": true
},
{
"t": 9600,
"value": false
},
{
"t": 10200,
"value": true
},
{
"t": 10500,
"value": false
},
{
"t": 11100,
"value": true
},
{
"t": 11400,
"value": false
},
{
"t": 12000,
"value": true
},
{
"t": 12300,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 12000,
"equals": false
}
]
},
{
"name": "five_cycles_per_hour_is_the_first_faulted_cadence",
"description": "Threshold edge from the other side, and the finest step a 300 s tick can resolve: a repeating 600/600/600/900/900 s spacing puts exactly 5 rising edges in every trailing hour. The count clears 4 at t = 6600 s and the alarm follows one alarm_delay later.",
"inputs": {
"oat": -5.0,
"defrost_status": [
{
"t": 0,
"value": false
},
{
"t": 3900,
"value": true
},
{
"t": 4200,
"value": false
},
{
"t": 4500,
"value": true
},
{
"t": 4800,
"value": false
},
{
"t": 5100,
"value": true
},
{
"t": 5400,
"value": false
},
{
"t": 5700,
"value": true
},
{
"t": 6000,
"value": false
},
{
"t": 6600,
"value": true
},
{
"t": 6900,
"value": false
},
{
"t": 7500,
"value": true
},
{
"t": 7800,
"value": false
},
{
"t": 8100,
"value": true
},
{
"t": 8400,
"value": false
},
{
"t": 8700,
"value": true
},
{
"t": 9000,
"value": false
},
{
"t": 9300,
"value": true
},
{
"t": 9600,
"value": false
},
{
"t": 10200,
"value": true
},
{
"t": 10500,
"value": false
},
{
"t": 11100,
"value": true
},
{
"t": 11400,
"value": false
},
{
"t": 11700,
"value": true
},
{
"t": 12000,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 8100,
"equals": false
},
{
"output": "yFault",
"from_s": 8700,
"to_s": 12000,
"equals": true
}
]
},
{
"name": "excessive_defrost_frequency",
"description": "A coil icing up faster than it can be cleared: defrost every 10 minutes, 6 cycles per hour, beginning after the counting window has filled. The count crosses 4 at t = 6300 s \u2014 the fifth cycle of the run \u2014 and the alarm lands 1800 s after that.",
"inputs": {
"oat": -5.0,
"defrost_status": [
{
"t": 0,
"value": false
},
{
"t": 3900,
"value": true
},
{
"t": 4200,
"value": false
},
{
"t": 4500,
"value": true
},
{
"t": 4800,
"value": false
},
{
"t": 5100,
"value": true
},
{
"t": 5400,
"value": false
},
{
"t": 5700,
"value": true
},
{
"t": 6000,
"value": false
},
{
"t": 6300,
"value": true
},
{
"t": 6600,
"value": false
},
{
"t": 6900,
"value": true
},
{
"t": 7200,
"value": false
},
{
"t": 7500,
"value": true
},
{
"t": 7800,
"value": false
},
{
"t": 8100,
"value": true
},
{
"t": 8400,
"value": false
},
{
"t": 8700,
"value": true
},
{
"t": 9000,
"value": false
},
{
"t": 9300,
"value": true
},
{
"t": 9600,
"value": false
},
{
"t": 9900,
"value": true
},
{
"t": 10200,
"value": false
},
{
"t": 10500,
"value": true
},
{
"t": 10800,
"value": false
},
{
"t": 11100,
"value": true
},
{
"t": 11400,
"value": false
},
{
"t": 11700,
"value": true
},
{
"t": 12000,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 7800,
"equals": false
},
{
"output": "yFault",
"from_s": 8400,
"to_s": 12000,
"equals": true
}
]
},
{
"name": "defrost_overlong",
"description": "One defrost cycle that never terminates. `tooLong` matures at 900 s and the alarm 1800 s after that, at t = 2700 s. The frequency branch contributes nothing: the single rising edge lands on tick 0, where the moving average's integrand encloses no area.",
"inputs": {
"oat": -5.0,
"defrost_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 3000,
"to_s": 12000,
"equals": true
}
]
},
{
"name": "defrost_released_on_the_maturity_tick",
"description": "Duration edge: the first cycle is observed true at t = 0, 300 and 600 s and false at 900 s \u2014 the tick the duration timer would have matured on \u2014 so it does not trip the branch. Defrost restarts at 1200 s and runs on, which matures `tooLong` at 2100 s and the alarm at 3900 s: 1200 s later than the uninterrupted case.",
"inputs": {
"oat": -5.0,
"defrost_status": [
{
"t": 0,
"value": true
},
{
"t": 900,
"value": false
},
{
"t": 1200,
"value": true
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 3600,
"equals": false
},
{
"output": "yFault",
"from_s": 4200,
"to_s": 12000,
"equals": true
}
]
},
{
"name": "defrost_released_on_the_alarm_tick",
"description": "Persistence edge: a single defrost runs from t = 0 and ends at t = 2700 s, the exact tick the alarm would assert on. The input is already false when persist is evaluated, so nothing is ever reported \u2014 a 45-minute defrost that this rule declines to call.",
"inputs": {
"oat": -5.0,
"defrost_status": [
{
"t": 0,
"value": true
},
{
"t": 2700,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 12000,
"equals": false
}
]
},
{
"name": "defrost_released_one_tick_later",
"description": "Persistence edge from the other side: the same defrost held 300 s longer asserts at t = 2700 s and drops again at 3000 s when the cycle ends. One tick of difference in the input is one tick of alarm.",
"inputs": {
"oat": -5.0,
"defrost_status": [
{
"t": 0,
"value": true
},
{
"t": 3000,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 2700,
"to_s": 2700,
"equals": true
},
{
"output": "yFault",
"from_s": 3000,
"to_s": 12000,
"equals": false
}
]
},
{
"name": "oat_exactly_at_the_mild_threshold",
"description": "Mild-weather edge: a continuous defrost at exactly defrost_unnecessary_temp (7.0 \u00b0C). The comparison is a strict `>`, so the third branch stays down and the alarm arrives on the duration branch's schedule at t = 2700 s.",
"inputs": {
"oat": 7.0,
"defrost_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 3000,
"to_s": 12000,
"equals": true
}
]
},
{
"name": "mild_oat_advances_the_alarm",
"description": "Mild-weather edge from the other side: one tenth of a degree warmer and the third branch is live from t = 0, so the alarm matures at 1800 s instead of 2700 s. Defrosting at 7.1 \u00b0C means the defrost sensor or board is calling for a cycle the coil cannot need.",
"inputs": {
"oat": 7.1,
"defrost_status": true
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 1500,
"equals": false
},
{
"output": "yFault",
"from_s": 2100,
"to_s": 12000,
"equals": true
}
]
},
{
"name": "mild_oat_short_cycles_never_persist",
"description": "The third branch's practical limit: 5-minute defrosts at 10 \u00b0C, twice an hour. Every cycle is needless and the branch says so, but it is true for one tick at a time and the 30-minute persistence never fills. On its own, branch 3 only reports a defrost that outlasts alarm_delay \u2014 see the card's Deviations.",
"inputs": {
"oat": 10.0,
"defrost_status": [
{
"t": 0,
"value": false
},
{
"t": 300,
"value": true
},
{
"t": 600,
"value": false
},
{
"t": 2100,
"value": true
},
{
"t": 2400,
"value": false
},
{
"t": 3900,
"value": true
},
{
"t": 4200,
"value": false
},
{
"t": 5700,
"value": true
},
{
"t": 6000,
"value": false
},
{
"t": 7500,
"value": true
},
{
"t": 7800,
"value": false
},
{
"t": 9300,
"value": true
},
{
"t": 9600,
"value": false
},
{
"t": 11100,
"value": true
},
{
"t": 11400,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 12000,
"equals": false
}
]
},
{
"name": "alarm_clears_when_defrost_ends",
"description": "Recovery: a stuck defrost alarms at 2700 s and the cycle finally terminates at 5400 s (the defrost sensor was replaced, or the board was reset). Both timers fall immediately and the alarm clears on that tick.",
"inputs": {
"oat": -5.0,
"defrost_status": [
{
"t": 0,
"value": true
},
{
"t": 5400,
"value": false
}
]
},
"expect": [
{
"output": "yFault",
"from_s": 0,
"to_s": 2400,
"equals": false
},
{
"output": "yFault",
"from_s": 3000,
"to_s": 5100,
"equals": true
},
{
"output": "yFault",
"from_s": 5400,
"to_s": 12000,
"equals": false
}
]
}
]
}