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}