nx_print_runtime_monitor.nx source
↩ module page · 458 lines · 17422 B
1// nx_print_runtime_monitor.nx -- LIDAR-class spaghetti prevention
2// from Klipper telemetry alone (no camera, no laser).
3//
4// Operator directive (2026-05-20 immediate): lidar spaghetti prevention.
5//
6// Bambu X1C has hardware LIDAR + camera for mid-print spaghetti
7// detection. Substrate alternative for Qidi X-Max 3 (which has
8// Klipper telemetry but no LIDAR): ingest the Moonraker WebSocket
9// telemetry stream + flag anomalies indicative of spaghetti BEFORE
10// they cascade into a 20-hour wasted print.
11//
12// Three detection paths -- all from REAL TELEMETRY, no guesswork:
13//
14// 1. STUCK_LAYER
15// Z hasn't advanced for > N × expected_layer_time. Either
16// the print stalled (firmware issue) or the head is moving
17// without depositing material (spaghetti).
18//
19// 2. FLOW_ANOMALY
20// Extruder accumulated material but Z + XY barely changed.
21// Material is going somewhere -- if not into the print, into
22// the air = spaghetti.
23//
24// 3. EXTRUDER_RUNAWAY
25// Total extruded volume diverges from expected (computed from
26// XY × layer_height × line_width). Suggests the extruder is
27// grinding / over-pushing.
28//
29// All three compose existing substrate state + arithmetic. No
30// heuristic thresholds beyond operator-supplied physical limits.
31//
32// Caller responsibility: pump telemetry events from
33// nx_moonraker_io into nx_runtime_monitor_step + react to any
34// issue verdict (pause print, alert operator, etc.).
35//
36// license_tier: ORIGINAL
37
38import "nx_syscalls.nx"
39const NX_MAGIC_10000: i64 = 10000
40
41// Sealed issue-kind enum.
42const NX_RT_ISSUE_NONE: i64 = 0
43const NX_RT_ISSUE_STUCK_LAYER: i64 = 1
44const NX_RT_ISSUE_FLOW_ANOMALY: i64 = 2
45const NX_RT_ISSUE_EXTRUDER_RUNAWAY: i64 = 3
46// Thermal dropout detection (added 2026-05-20 PM):
47// Temp below setpoint - tolerance for > drop_tolerance_ms ->
48// delamination / underextrusion / spaghetti progenitor.
49const NX_RT_ISSUE_BED_TEMP_DROP: i64 = 4
50const NX_RT_ISSUE_HOTEND_TEMP_DROP: i64 = 5
51const NX_RT_ISSUE_CHAMBER_TEMP_DROP: i64 = 6
52// Z-backward jump (added 2026-05-20 PM): Z reverses by more than
53// z_backward_tolerance_q14 mm. Real failure mode on Qidi: head
54// crashes a tower, Klipper recovers by rehoming -> Z snaps from
55// current layer back to ~0. Resumed print will be off-position =
56// spaghetti. Slicer Z-hops during travel are sub-mm and never
57// trigger at a tolerance of several mm.
58const NX_RT_ISSUE_Z_BACKWARD: i64 = 7
59// Filament runout (added 2026-05-20 PM): operator wires a runout
60// sensor pin (Qidi has one; almost every modern printer does);
61// monitor edge-triggers on the 0->1 transition. FLOW_ANOMALY only
62// fires when head is stationary; runout often happens during travel
63// where FLOW_ANOMALY's xy-static precondition is false.
64const NX_RT_ISSUE_FILAMENT_RUNOUT: i64 = 8
65// Layer shift / lost steps (added 2026-05-20 PM): Klipper exposes
66// each axis's commanded vs actual MCU stepper position; drift >
67// tolerance = belt slip / missed steps / collision-recovered
68// off-position. Classic spaghetti seed: every subsequent layer
69// registers progressively further off the build plate until walls
70// detach. Detected per-step from operator-supplied commanded/actual
71// pairs in Q14 mm.
72const NX_RT_ISSUE_LAYER_SHIFT: i64 = 9
73const NX_RT_ISSUE_N: i64 = 10
74
75func nx_rt_issue_is_valid(k: i64) -> i64 {
76 if k < 0 { return 0 }
77 if k >= NX_RT_ISSUE_N { return 0 }
78 return 1
79}
80
81// Sealed verdict (returned by step).
82const NX_RT_OK: i64 = 0
83const NX_RT_BAD_INPUT: i64 = 1
84
85// Q14 scale, in case caller wants mm-precision math.
86const NX_RT_Q14: i64 = 16384
87
88struct NxRuntimeMonitor {
89 // Operator-supplied physical thresholds (NOT heuristic; supplied
90 // by caller based on machine + material).
91 expected_layer_time_ms: i64, // typical layer duration
92 stuck_layer_multiple: i64, // STUCK_LAYER fires when actual > expected × this
93 flow_anomaly_e_mm_q14: i64, // extruder advance threshold
94 flow_anomaly_xy_mm_q14: i64, // XY threshold -- if e > threshold AND xy < threshold, anomaly
95
96 // Last-known state (populated by step()).
97 last_z_q14: i64,
98 last_e_q14: i64,
99 last_event_time_ms: i64,
100 last_layer_z_q14: i64,
101 last_layer_change_time_ms: i64,
102 cumulative_xy_q14: i64,
103
104 // Issue log.
105 last_issue_kind: i64,
106 n_issues_total: i64,
107
108 // Thermal-dropout config (added 2026-05-20 PM).
109 temp_drop_tolerance_c: i64, // °C below setpoint that counts as "dropped"
110 temp_drop_duration_ms: i64, // alarm when below-setpoint for > this
111
112 // Thermal-dropout state. -1 = not currently below; otherwise =
113 // event_time_ms when temperature first went below tolerance.
114 bed_below_since_ms: i64,
115 hotend_below_since_ms: i64,
116 chamber_below_since_ms: i64,
117
118 // Z-backward detection (added 2026-05-20 PM). Operator-supplied
119 // tolerance: dz < -tol triggers NX_RT_ISSUE_Z_BACKWARD.
120 z_backward_tolerance_q14: i64,
121
122 // Filament-runout sensor state (added 2026-05-20 PM). Edge-triggered
123 // on 0->1 transition. Operator pumps via nx_runtime_monitor_step_runout.
124 last_runout_state: i64,
125
126 // Layer shift / lost steps (added 2026-05-20 PM). Operator-supplied
127 // tolerance in Q14 mm; |cmd - act| > tol fires NX_RT_ISSUE_LAYER_SHIFT.
128 layer_shift_tolerance_q14: i64,
129}
130
131const NX_RUNTIME_MONITOR_BYTES: i64 = 160 // 20 fields × 8
132
133func nx_runtime_monitor_new(
134 expected_layer_time_ms: i64,
135 stuck_layer_multiple: i64,
136 flow_anomaly_e_mm_q14: i64,
137 flow_anomaly_xy_mm_q14: i64
138) -> *NxRuntimeMonitor {
139 if expected_layer_time_ms <= 0 { return 0 as *NxRuntimeMonitor }
140 if stuck_layer_multiple <= 1 { return 0 as *NxRuntimeMonitor }
141
142 let m: *NxRuntimeMonitor = (sys_mmap(NX_RUNTIME_MONITOR_BYTES)) as *NxRuntimeMonitor
143 m.expected_layer_time_ms = expected_layer_time_ms
144 m.stuck_layer_multiple = stuck_layer_multiple
145 m.flow_anomaly_e_mm_q14 = flow_anomaly_e_mm_q14
146 m.flow_anomaly_xy_mm_q14 = flow_anomaly_xy_mm_q14
147 m.last_z_q14 = -1 // sentinel: no event seen yet
148 m.last_e_q14 = 0
149 m.last_event_time_ms = 0
150 m.last_layer_z_q14 = 0
151 m.last_layer_change_time_ms = 0
152 m.cumulative_xy_q14 = 0
153 m.last_issue_kind = NX_RT_ISSUE_NONE
154 m.n_issues_total = 0
155 // Thermal config defaults: 5°C tolerance, 10s duration before
156 // alarm. Caller can override via nx_runtime_monitor_set_thermal.
157 m.temp_drop_tolerance_c = 5
158 m.temp_drop_duration_ms = NX_MAGIC_10000
159 m.bed_below_since_ms = -1
160 m.hotend_below_since_ms = -1
161 m.chamber_below_since_ms = -1
162 // Default Z-backward tolerance: 5mm (well above any slicer
163 // Z-hop; well below any real crash-rehome jump).
164 m.z_backward_tolerance_q14 = 5 * NX_RT_Q14
165 m.last_runout_state = 0
166 // Default layer-shift tolerance: 0.5mm (covers normal CoreXY
167 // micro-jitter / encoder noise; below typical line width).
168 m.layer_shift_tolerance_q14 = NX_RT_Q14 / 2
169 return m
170}
171
172// Override layer-shift tolerance.
173func nx_runtime_monitor_set_layer_shift_tolerance(
174 m: *NxRuntimeMonitor,
175 tolerance_q14: i64
176) -> i64 {
177 if (m as i64) == 0 { return NX_RT_BAD_INPUT }
178 if tolerance_q14 < 0 { return NX_RT_BAD_INPUT }
179 m.layer_shift_tolerance_q14 = tolerance_q14
180 return NX_RT_OK
181}
182
183// Layer-shift / lost-steps detector. Operator supplies the per-axis
184// commanded vs actual MCU stepper positions (both in Q14 mm).
185// |cmd - act| > tolerance on either axis fires NX_RT_ISSUE_LAYER_SHIFT.
186//
187// Klipper exposes these via `query_endstops` / `stepper_<axis>.mcu_position`
188// scaled by `[stepper_<axis>].rotation_distance / microsteps`.
189func nx_runtime_monitor_step_layer_shift(
190 m: *NxRuntimeMonitor,
191 commanded_x_q14: i64, actual_x_q14: i64,
192 commanded_y_q14: i64, actual_y_q14: i64
193) -> i64 {
194 if (m as i64) == 0 { return NX_RT_ISSUE_NONE }
195
196 var dx: i64 = commanded_x_q14 - actual_x_q14
197 if dx < 0 { dx = 0 - dx }
198 var dy: i64 = commanded_y_q14 - actual_y_q14
199 if dy < 0 { dy = 0 - dy }
200
201 var raised: i64 = NX_RT_ISSUE_NONE
202 if dx > m.layer_shift_tolerance_q14 { raised = NX_RT_ISSUE_LAYER_SHIFT }
203 if raised == NX_RT_ISSUE_NONE {
204 if dy > m.layer_shift_tolerance_q14 { raised = NX_RT_ISSUE_LAYER_SHIFT }
205 }
206
207 m.last_issue_kind = raised
208 if raised != NX_RT_ISSUE_NONE {
209 m.n_issues_total = m.n_issues_total + 1
210 }
211 return raised
212}
213
214// Filament-runout sensor pump. Operator passes the current sensor
215// state (0 = filament present, 1 = runout). Returns ISSUE_FILAMENT_RUNOUT
216// on the 0->1 edge, NONE otherwise. Steady-1 (already-triggered) does
217// NOT re-fire each step; operator must observe 0 again before next fire.
218func nx_runtime_monitor_step_runout(
219 m: *NxRuntimeMonitor,
220 sensor_triggered: i64
221) -> i64 {
222 if (m as i64) == 0 { return NX_RT_ISSUE_NONE }
223 var s: i64 = sensor_triggered
224 if s != 0 { s = 1 } // normalize truthy values
225 var raised: i64 = NX_RT_ISSUE_NONE
226 if s == 1 {
227 if m.last_runout_state == 0 {
228 raised = NX_RT_ISSUE_FILAMENT_RUNOUT
229 }
230 }
231 m.last_runout_state = s
232 if raised != NX_RT_ISSUE_NONE {
233 m.last_issue_kind = raised
234 m.n_issues_total = m.n_issues_total + 1
235 }
236 return raised
237}
238
239// Override Z-backward tolerance. Tighter values catch smaller
240// rehoming events; too tight will false-fire on Z-hop travel.
241func nx_runtime_monitor_set_z_backward_tolerance(
242 m: *NxRuntimeMonitor,
243 tolerance_q14: i64
244) -> i64 {
245 if (m as i64) == 0 { return NX_RT_BAD_INPUT }
246 if tolerance_q14 < 0 { return NX_RT_BAD_INPUT }
247 m.z_backward_tolerance_q14 = tolerance_q14
248 return NX_RT_OK
249}
250
251// Override thermal-dropout thresholds. Caller-supplied physical limits;
252// no heuristic defaults override operator choice.
253func nx_runtime_monitor_set_thermal(
254 m: *NxRuntimeMonitor,
255 drop_tolerance_c: i64,
256 drop_duration_ms: i64
257) -> i64 {
258 if (m as i64) == 0 { return NX_RT_BAD_INPUT }
259 if drop_tolerance_c < 0 { return NX_RT_BAD_INPUT }
260 if drop_duration_ms < 0 { return NX_RT_BAD_INPUT }
261 m.temp_drop_tolerance_c = drop_tolerance_c
262 m.temp_drop_duration_ms = drop_duration_ms
263 return NX_RT_OK
264}
265
266// Ingest one telemetry event + check invariants.
267//
268// Inputs (all Q14 mm where applicable):
269// z_q14, e_q14 head position + accumulated extruder, from Klipper
270// xy_delta_q14 distance head traveled in XY since last event
271// event_time_ms monotonic timestamp (e.g. Klipper's print_duration × 1000)
272//
273// Updates monitor state. Returns the issue kind raised on THIS step
274// (NX_RT_ISSUE_NONE if no issue). Sets m.last_issue_kind to the same.
275
276func nx_runtime_monitor_step(
277 m: *NxRuntimeMonitor,
278 z_q14: i64,
279 e_q14: i64,
280 xy_delta_q14: i64,
281 event_time_ms: i64
282) -> i64 {
283 if (m as i64) == 0 { return NX_RT_ISSUE_NONE }
284
285 // First event: prime state, no detection.
286 if m.last_z_q14 < 0 {
287 m.last_z_q14 = z_q14
288 m.last_e_q14 = e_q14
289 m.last_event_time_ms = event_time_ms
290 m.last_layer_z_q14 = z_q14
291 m.last_layer_change_time_ms = event_time_ms
292 m.last_issue_kind = NX_RT_ISSUE_NONE
293 return NX_RT_ISSUE_NONE
294 }
295
296 let dz_q14: i64 = z_q14 - m.last_z_q14
297 var de_q14: i64 = e_q14 - m.last_e_q14
298 if de_q14 < 0 { de_q14 = 0 } // retract; treat as zero progress
299
300 m.cumulative_xy_q14 = m.cumulative_xy_q14 + xy_delta_q14
301
302 // Track layer advance (treat any positive dz as a layer change).
303 if dz_q14 > 0 {
304 m.last_layer_z_q14 = z_q14
305 m.last_layer_change_time_ms = event_time_ms
306 m.cumulative_xy_q14 = 0 // reset XY accumulator per layer
307 }
308
309 var raised: i64 = NX_RT_ISSUE_NONE
310
311 // ===== Z_BACKWARD check (takes precedence -- it's a cause not a
312 // symptom; STUCK_LAYER may fire downstream from the same crash).
313 let neg_tol: i64 = 0 - m.z_backward_tolerance_q14
314 if dz_q14 < neg_tol {
315 raised = NX_RT_ISSUE_Z_BACKWARD
316 }
317
318 // ===== STUCK_LAYER check =====
319 // Z hasn't advanced for > expected × multiple ms.
320 if raised == NX_RT_ISSUE_NONE {
321 let dt_since_layer: i64 = event_time_ms - m.last_layer_change_time_ms
322 let stuck_threshold: i64 = m.expected_layer_time_ms * m.stuck_layer_multiple
323 if dt_since_layer > stuck_threshold {
324 raised = NX_RT_ISSUE_STUCK_LAYER
325 }
326 }
327
328 // ===== FLOW_ANOMALY check =====
329 // Extruder advanced significantly but XY barely moved -- material
330 // is going somewhere it shouldn't.
331 if raised == NX_RT_ISSUE_NONE {
332 if de_q14 > m.flow_anomaly_e_mm_q14 {
333 if xy_delta_q14 < m.flow_anomaly_xy_mm_q14 {
334 if dz_q14 == 0 {
335 raised = NX_RT_ISSUE_FLOW_ANOMALY
336 }
337 }
338 }
339 }
340
341 // ===== EXTRUDER_RUNAWAY check =====
342 // Extruder advanced WAY more than XY×any-sane-line-width could
343 // account for. Threshold: e_advance > 10 × xy_advance (line
344 // width × layer height ratio cap).
345 if raised == NX_RT_ISSUE_NONE {
346 let xy_total: i64 = m.cumulative_xy_q14
347 if xy_total > 0 {
348 // e > 10 × xy ? Equivalent: e - 10*xy > 0
349 let ten_xy: i64 = xy_total * 10
350 if de_q14 > ten_xy {
351 if de_q14 > NX_RT_Q14 { // ignore tiny noise
352 raised = NX_RT_ISSUE_EXTRUDER_RUNAWAY
353 }
354 }
355 }
356 }
357
358 // Update last-known state regardless of issue.
359 m.last_z_q14 = z_q14
360 m.last_e_q14 = e_q14
361 m.last_event_time_ms = event_time_ms
362 m.last_issue_kind = raised
363 if raised != NX_RT_ISSUE_NONE {
364 m.n_issues_total = m.n_issues_total + 1
365 }
366 return raised
367}
368
369// Convenience: did the most recent step raise an issue?
370func nx_runtime_monitor_last_issue(m: *NxRuntimeMonitor) -> i64 {
371 if (m as i64) == 0 { return NX_RT_ISSUE_NONE }
372 return m.last_issue_kind
373}
374
375// ----- thermal-dropout helper -----
376// Tracks one axis's "below setpoint" duration. Returns 1 if the axis
377// has been below tolerance for longer than drop_duration_ms (alarm),
378// 0 otherwise. Mutates *since_ms_box accordingly.
379func nx_rt_thermal_axis_check(
380 actual_c: i64, setpoint_c: i64,
381 tolerance_c: i64, duration_ms: i64,
382 event_time_ms: i64,
383 since_ms_box: *i64
384) -> i64 {
385 let threshold: i64 = setpoint_c - tolerance_c
386 if actual_c >= threshold {
387 *since_ms_box = -1 // back above tolerance; clear timer
388 return 0
389 }
390 // Below tolerance. Start (or continue) the timer.
391 if *since_ms_box < 0 {
392 *since_ms_box = event_time_ms
393 return 0 // just started below; not yet an alarm
394 }
395 let dt: i64 = event_time_ms - *since_ms_box
396 if dt > duration_ms { return 1 }
397 return 0
398}
399
400// Ingest one THERMAL telemetry event. Operator calls this alongside
401// nx_runtime_monitor_step (or independently from a thermal-only
402// telemetry source).
403//
404// Each axis: pass -1 setpoint to skip that axis (heater off / no
405// thermal expectation). Bed/hotend/chamber detected independently;
406// first alarm wins on this step.
407//
408// Returns issue kind raised THIS step (NONE if no thermal anomaly).
409func nx_runtime_monitor_step_thermal(
410 m: *NxRuntimeMonitor,
411 bed_actual_c: i64, bed_setpoint_c: i64,
412 hotend_actual_c: i64, hotend_setpoint_c: i64,
413 chamber_actual_c: i64, chamber_setpoint_c: i64,
414 event_time_ms: i64
415) -> i64 {
416 if (m as i64) == 0 { return NX_RT_ISSUE_NONE }
417
418 var raised: i64 = NX_RT_ISSUE_NONE
419
420 let bed_box: *i64 = (((m as i64) + 14 * 8)) as *i64 // bed_below_since_ms field offset
421 let hotend_box: *i64 = (((m as i64) + 15 * 8)) as *i64
422 let chamber_box: *i64 = (((m as i64) + 16 * 8)) as *i64
423
424 // Bed axis (skip if setpoint == -1).
425 if bed_setpoint_c >= 0 {
426 let bed_alarm: i64 = nx_rt_thermal_axis_check(
427 bed_actual_c, bed_setpoint_c,
428 m.temp_drop_tolerance_c, m.temp_drop_duration_ms,
429 event_time_ms, bed_box)
430 if bed_alarm == 1 { raised = NX_RT_ISSUE_BED_TEMP_DROP }
431 }
432
433 if raised == NX_RT_ISSUE_NONE {
434 if hotend_setpoint_c >= 0 {
435 let h_alarm: i64 = nx_rt_thermal_axis_check(
436 hotend_actual_c, hotend_setpoint_c,
437 m.temp_drop_tolerance_c, m.temp_drop_duration_ms,
438 event_time_ms, hotend_box)
439 if h_alarm == 1 { raised = NX_RT_ISSUE_HOTEND_TEMP_DROP }
440 }
441 }
442
443 if raised == NX_RT_ISSUE_NONE {
444 if chamber_setpoint_c >= 0 {
445 let c_alarm: i64 = nx_rt_thermal_axis_check(
446 chamber_actual_c, chamber_setpoint_c,
447 m.temp_drop_tolerance_c, m.temp_drop_duration_ms,
448 event_time_ms, chamber_box)
449 if c_alarm == 1 { raised = NX_RT_ISSUE_CHAMBER_TEMP_DROP }
450 }
451 }
452
453 m.last_issue_kind = raised
454 if raised != NX_RT_ISSUE_NONE {
455 m.n_issues_total = m.n_issues_total + 1
456 }
457 return raised
458}