code wiki / (root) / nx_hvac_efficiency.nx

nx_hvac_efficiency.nx source

↩ module page · 230 lines · 9966 B

1// nx_hvac_efficiency.nx -- INDEPENDENT HVAC efficiency verdict from 2// out-of-band sensor readings (the thermostat cannot fake this). 3// 4// Operator ask (2026-06-23): "get our hvac efficiency s class measured in 5// our iot so that when we use our therm we can measure independently how 6// effective the systems are working." Architecture (operator-chosen): broad 7// / multi-system; the independent runtime + power signal comes from a 8// whole-panel CT monitor (Emporia Vue / Sense class), so on/off is the 9// HVAC circuit drawing current -- NOT the thermostat's self-report. 10// 11// THE EXCEED: a vendor thermostat reports comfort + schedule ("at setpoint, 12// all good"). A CT monitor reports energy. NEITHER emits a physics-grounded 13// verdict on whether the equipment is actually working well. This organ does, 14// from signals the thermostat does not control: 15// - SHORT_CYCLING -> oversizing (cycles/hr too high; high humidity, 16// wear, "feels hot at 72") -- the vendor hides this 17// - DEGRADED_CAPACITY -> it runs but barely moves temp (fouled coil, low 18// charge, dirty filter) -- the vendor says "heating..." 19// - POOR_TRACKING -> overshoots the setpoint (bad control loop) 20// = a verdict a happy thermostat would never give you. 21// 22// This is the R1 CORE: it needs only ONE independent indoor temp sensor + 23// the runtime flag, so it works for ANY system type (forced-air, heat pump, 24// mini-split, window) before any per-vendor power math (R3/R4 COP) lands. 25// 26// NO-FLOAT: everything is integer (milli-degC, seconds, permille, x10 rates) 27// per the ecosystem integer-only doctrine. No syscalls -> deterministic + 28// hard-gateable with a canned trace. 29// 30// NEVER-BRICK (CLAUDE.md #26): efficiency MEASUREMENT is read-only by 31// construction -- this organ reads samples and emits a verdict; it writes 32// nothing to any device, thermostat, or firmware. There is no firmware path 33// here to prove safe because none exists. (Any future active-recovery TEST 34// that commands a setpoint is a SEPARATE organ that must restore the prior 35// setpoint + fail-safe; it is deliberately NOT in this read-only core.) 36// 37// genealogy_id: project-iot-discovery-pairing-revival-2026-06-22 (R-HVAC.1) 38// + project-ac-footprint-and-hvac-refurb (short-cycle/oversize) 39// license_tier: ORIGINAL 40// 41// nx_capability_claims: 42// needs: [pointer_arithmetic] 43// provides: [independent_hvac_efficiency_verdict, duty_cycle_measure, 44// short_cycle_detect, effective_response_rate, overshoot_track, 45// vendor_independent_assessment] 46// safety: [no_unchecked_deref, no_floating_point, no_syscall, 47// bounded_iteration, read_only_no_device_write, 48// no_firmware_write_by_construction, sealed_enum_verdict] 49// verdict: [sealed_enum_hvac, no_silent_failure, insufficient_data_is_loud] 50// license: ORIGINAL 51// kind: iot_runtime_primitive 52// sss: [S6 (no cloud), S7 (sealed-enum verdict)] 53// 54// nx_safety_envelope: 55// intended_use: "Independent HVAC efficiency verdict from out-of-band 56// temperature + runtime samples; read-only measurement." 57// sil_target: SIL1 (advisory measurement; emits no control action) 58// evidence: [no_syscall, no_floating_point, bounded_iteration, 59// read_only_no_device_write, sealed_enum_complete, 60// liar_kill_KAT, no_firmware_write_by_construction] 61// verdict: NOT_YET_EVALUATED 62 63// ---- Mode: expected direction of indoor change while running ------- 64// Used as a +1/-1 multiplier so heating "up" and cooling "down" both 65// score positive when the equipment is moving temperature the right way. 66const NX_HVAC_MODE_HEAT: i64 = 1 67const NX_HVAC_MODE_COOL: i64 = -1 68 69// ---- Sealed enum: efficiency verdict ------------------------------- 70const NX_HVAC_INSUFFICIENT_DATA: i64 = 0 // < 2 samples or zero-length window 71const NX_HVAC_EFFICIENT: i64 = 1 // ran, moved temp well, low cycling, tracked 72const NX_HVAC_SHORT_CYCLING: i64 = 2 // cycles/hr over ceiling -> oversizing 73const NX_HVAC_DEGRADED_CAPACITY: i64 = 3 // ran but response rate under floor 74const NX_HVAC_POOR_TRACKING: i64 = 4 // overshot setpoint past tolerance 75const NX_HVAC_IDLE: i64 = 5 // never ran in window (valid, not a fault) 76const NX_HVAC_BAD_ARG: i64 = 6 // invalid mode / args 77const NX_HVAC_N: i64 = 7 78 79func nx_hvac_verdict_is_valid(v: i64) -> i64 { 80 if v < 0 { return 0 } 81 if v >= NX_HVAC_N { return 0 } 82 return 1 83} 84 85// ---- Config thresholds (DATA-DRIVEN, CLAUDE.md #11) ---------------- 86// In production these come from svc-config, not code. Defaults documented 87// from HVAC field practice: forced-air short-cycles above ~3-4 cycles/hr; 88// a working system moves indoor air faster than ~0.15 degC/min when running. 89struct HvacThresh { 90 max_cph_x10: i64, // cycles-per-hour ceiling x10 (e.g. 35 = 3.5/hr) 91 min_resp_mC_per_min: i64, // min effective rate while running (mC/min) 92 max_overshoot_mC: i64, // setpoint overshoot tolerance (milli-degC) 93} 94 95// ---- Output telemetry + verdict ------------------------------------ 96struct HvacEff { 97 total_sec: i64, // window length 98 on_sec: i64, // seconds the HVAC circuit drew current 99 duty_permille: i64, // on_sec / total_sec * 1000 100 cycles: i64, // distinct on-bursts 101 cph_x10: i64, // cycles per hour x10 102 resp_mC_per_min: i64, // effective heat/cool rate while running (mode-oriented) 103 peak_overshoot_mC: i64, // furthest past setpoint in the active direction 104 mean_abs_err_mC: i64, // mean |indoor - setpoint| over window (telemetry) 105 mean_outdoor_mC: i64, // load context: mean outdoor temp over window 106 verdict: i64, // NX_HVAC_* 107} 108 109func nx_hvac_abs(x: i64) -> i64 { 110 if x < 0 { return 0 - x } 111 return x 112} 113 114// ---- The analysis (the keystone) ----------------------------------- 115// Parallel sample arrays, one entry per reading; t_sec ascending. on[i] is 116// the independent runtime flag for the interval starting at sample i (1 = 117// the HVAC circuit drew current per the CT monitor). Returns the verdict and 118// fills *out. Pure: reads samples, writes only *out -- no device, no syscall. 119func nx_hvac_analyze_core(t_sec: *i64, indoor_mC: *i64, outdoor_mC: *i64, on: *i64, 120 n: i64, setpoint_mC: i64, mode: i64, 121 max_cph_x10: i64, min_resp_mC_per_min: i64, 122 max_overshoot_mC: i64, out: *HvacEff) -> i64 { 123 out.total_sec = 0 124 out.on_sec = 0 125 out.duty_permille = 0 126 out.cycles = 0 127 out.cph_x10 = 0 128 out.resp_mC_per_min = 0 129 out.peak_overshoot_mC = 0 130 out.mean_abs_err_mC = 0 131 out.mean_outdoor_mC = 0 132 out.verdict = NX_HVAC_INSUFFICIENT_DATA 133 134 if n < 2 { 135 out.verdict = NX_HVAC_INSUFFICIENT_DATA 136 return out.verdict 137 } 138 if mode != NX_HVAC_MODE_HEAT { 139 if mode != NX_HVAC_MODE_COOL { 140 out.verdict = NX_HVAC_BAD_ARG 141 return out.verdict 142 } 143 } 144 145 let last: i64 = n - 1 146 let total: i64 = t_sec[last] - t_sec[0] 147 if total <= 0 { 148 out.verdict = NX_HVAC_INSUFFICIENT_DATA 149 return out.verdict 150 } 151 out.total_sec = total 152 153 // ---- single pass over intervals: on-time, on-delta, cycles ---- 154 var on_sec: i64 = 0 155 var delta_on: i64 = 0 156 var cycles: i64 = 0 157 if on[0] == 1 { cycles = 1 } 158 var i: i64 = 0 159 while i < last { 160 let ip1: i64 = i + 1 161 let dt: i64 = t_sec[ip1] - t_sec[i] 162 if dt > 0 { 163 if on[i] == 1 { 164 on_sec = on_sec + dt 165 delta_on = delta_on + (indoor_mC[ip1] - indoor_mC[i]) 166 } 167 } 168 if on[i] == 0 { 169 if on[ip1] == 1 { 170 cycles = cycles + 1 171 } 172 } 173 i = i + 1 174 } 175 out.on_sec = on_sec 176 out.cycles = cycles 177 out.duty_permille = (on_sec * 1000) / total 178 out.cph_x10 = (cycles * 36000) / total 179 if on_sec > 0 { 180 out.resp_mC_per_min = ((delta_on * mode) * 60) / on_sec 181 } 182 183 // ---- setpoint tracking: peak overshoot + mean abs error ------- 184 var peak_over: i64 = 0 185 var err_sum: i64 = 0 186 var out_sum: i64 = 0 187 var j: i64 = 0 188 while j < n { 189 let dev: i64 = indoor_mC[j] - setpoint_mC 190 let over: i64 = dev * mode 191 if over > peak_over { peak_over = over } 192 err_sum = err_sum + nx_hvac_abs(dev) 193 out_sum = out_sum + outdoor_mC[j] 194 j = j + 1 195 } 196 out.peak_overshoot_mC = peak_over 197 out.mean_abs_err_mC = err_sum / n 198 out.mean_outdoor_mC = out_sum / n 199 200 // ---- verdict: highest-severity defect wins -------------------- 201 if on_sec == 0 { 202 out.verdict = NX_HVAC_IDLE 203 return out.verdict 204 } 205 if out.cph_x10 > max_cph_x10 { 206 out.verdict = NX_HVAC_SHORT_CYCLING 207 return out.verdict 208 } 209 if out.resp_mC_per_min < min_resp_mC_per_min { 210 out.verdict = NX_HVAC_DEGRADED_CAPACITY 211 return out.verdict 212 } 213 if out.peak_overshoot_mC > max_overshoot_mC { 214 out.verdict = NX_HVAC_POOR_TRACKING 215 return out.verdict 216 } 217 out.verdict = NX_HVAC_EFFICIENT 218 return out.verdict 219} 220 221// Back-compat struct wrapper (#19): unpack the spec block and call the core. 222// The maintenance spine calls nx_hvac_analyze_core directly with AssetSpec data. 223func nx_hvac_analyze(t_sec: *i64, indoor_mC: *i64, outdoor_mC: *i64, on: *i64, 224 n: i64, setpoint_mC: i64, mode: i64, 225 th: *HvacThresh, out: *HvacEff) -> i64 { 226 return nx_hvac_analyze_core(t_sec, indoor_mC, outdoor_mC, on, n, 227 setpoint_mC, mode, 228 th.max_cph_x10, th.min_resp_mC_per_min, 229 th.max_overshoot_mC, out) 230}