code wiki / (root) / nx_print_runtime_monitor.nx

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}