From 0a14e3315084065b19163104c3604d13a7c2dbc8 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Thu, 6 Jun 2024 17:09:02 -0400 Subject: [PATCH 01/14] probe_eddy_current: Add support for "rapid_scan" mode Add a scanning mode that does not require pausing the toolhead at each probe point. Signed-off-by: Kevin O'Connor --- klippy/extras/probe_eddy_current.py | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index de1f84476f05..345096e60b44 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -373,15 +373,27 @@ def __init__(self, printer, sensor_helper, calibration, z_offset, gcmd): self._gather = EddyGatherSamples(printer, sensor_helper, calibration, z_offset) self._sample_time_delay = 0.050 - self._sample_time = 0.100 + self._sample_time = gcmd.get_float("SAMPLE_TIME", 0.100, above=0.0) + self._is_rapid = gcmd.get("METHOD", "scan") == 'rapid_scan' + def _rapid_lookahead_cb(self, printtime): + start_time = printtime - self._sample_time / 2 + self._gather.note_probe_and_position( + start_time, start_time + self._sample_time, printtime) def run_probe(self, gcmd): toolhead = self._printer.lookup_object("toolhead") + if self._is_rapid: + toolhead.register_lookahead_callback(self._rapid_lookahead_cb) + return printtime = toolhead.get_last_move_time() toolhead.dwell(self._sample_time_delay + self._sample_time) start_time = printtime + self._sample_time_delay self._gather.note_probe_and_position( start_time, start_time + self._sample_time, start_time) def pull_probed_results(self): + if self._is_rapid: + # Flush lookahead (so all lookahead callbacks are invoked) + toolhead = self._printer.lookup_object("toolhead") + toolhead.get_last_move_time() results = self._gather.pull_probed() # Allow axis_twist_compensation to update results for epos in results: @@ -418,7 +430,7 @@ def get_status(self, eventtime): return self.cmd_helper.get_status(eventtime) def start_probe_session(self, gcmd): method = gcmd.get('METHOD', 'automatic').lower() - if method == 'scan': + if method in ('scan', 'rapid_scan'): z_offset = self.get_offsets()[2] return EddyScanningProbe(self.printer, self.sensor_helper, self.calibration, z_offset, gcmd) From f2df011c68aeff1c9240613837f91f04baa8da83 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Tue, 13 Feb 2024 14:55:14 -0500 Subject: [PATCH 02/14] bed_mesh: optimize rapid travel paths This adds supplemental path generation that implements "overshoot" when a change of direction is performed during a rapid scan. This overshoot reduces measurement error at the extremes of the mesh along the X axis. Signed-off-by: Eric Callahan --- klippy/extras/bed_mesh.py | 689 +++++++++++++++++++++++++++----------- 1 file changed, 495 insertions(+), 194 deletions(-) diff --git a/klippy/extras/bed_mesh.py b/klippy/extras/bed_mesh.py index 095ccf1fdc8c..d9ee7dfba8a6 100644 --- a/klippy/extras/bed_mesh.py +++ b/klippy/extras/bed_mesh.py @@ -298,130 +298,24 @@ def __init__(self, config, bedmesh): self.radius = self.origin = None self.mesh_min = self.mesh_max = (0., 0.) self.adaptive_margin = config.getfloat('adaptive_margin', 0.0) - self.zero_ref_pos = config.getfloatlist( - "zero_reference_position", None, count=2 - ) - self.zero_reference_mode = ZrefMode.DISABLED - self.faulty_regions = [] - self.substituted_indices = collections.OrderedDict() self.bedmesh = bedmesh self.mesh_config = collections.OrderedDict() self._init_mesh_config(config) - self._generate_points(config.error) + self.probe_mgr = ProbeManager( + config, self.orig_config, self.probe_finalize + ) + try: + self.probe_mgr.generate_points( + self.mesh_config, self.mesh_min, self.mesh_max, + self.radius, self.origin + ) + except BedMeshError as e: + raise config.error(str(e)) self._profile_name = "default" - self.probe_helper = probe.ProbePointsHelper( - config, self.probe_finalize, self._get_adjusted_points()) - self.probe_helper.minimum_points(3) - self.probe_helper.use_xy_offsets(True) self.gcode = self.printer.lookup_object('gcode') self.gcode.register_command( 'BED_MESH_CALIBRATE', self.cmd_BED_MESH_CALIBRATE, desc=self.cmd_BED_MESH_CALIBRATE_help) - def _generate_points(self, error, probe_method="automatic"): - x_cnt = self.mesh_config['x_count'] - y_cnt = self.mesh_config['y_count'] - min_x, min_y = self.mesh_min - max_x, max_y = self.mesh_max - x_dist = (max_x - min_x) / (x_cnt - 1) - y_dist = (max_y - min_y) / (y_cnt - 1) - # floor distances down to next hundredth - x_dist = math.floor(x_dist * 100) / 100 - y_dist = math.floor(y_dist * 100) / 100 - if x_dist < 1. or y_dist < 1.: - raise error("bed_mesh: min/max points too close together") - - if self.radius is not None: - # round bed, min/max needs to be recalculated - y_dist = x_dist - new_r = (x_cnt // 2) * x_dist - min_x = min_y = -new_r - max_x = max_y = new_r - else: - # rectangular bed, only re-calc max_x - max_x = min_x + x_dist * (x_cnt - 1) - pos_y = min_y - points = [] - for i in range(y_cnt): - for j in range(x_cnt): - if not i % 2: - # move in positive directon - pos_x = min_x + j * x_dist - else: - # move in negative direction - pos_x = max_x - j * x_dist - if self.radius is None: - # rectangular bed, append - points.append((pos_x, pos_y)) - else: - # round bed, check distance from origin - dist_from_origin = math.sqrt(pos_x*pos_x + pos_y*pos_y) - if dist_from_origin <= self.radius: - points.append( - (self.origin[0] + pos_x, self.origin[1] + pos_y)) - pos_y += y_dist - self.points = points - if self.zero_ref_pos is None or probe_method == "manual": - # Zero Reference Disabled - self.zero_reference_mode = ZrefMode.DISABLED - elif within(self.zero_ref_pos, self.mesh_min, self.mesh_max): - # Zero Reference position within mesh - self.zero_reference_mode = ZrefMode.IN_MESH - else: - # Zero Reference position outside of mesh - self.zero_reference_mode = ZrefMode.PROBE - if not self.faulty_regions: - return - self.substituted_indices.clear() - if self.zero_reference_mode == ZrefMode.PROBE: - # Cannot probe a reference within a faulty region - for min_c, max_c in self.faulty_regions: - if within(self.zero_ref_pos, min_c, max_c): - opt = "zero_reference_position" - raise error( - "bed_mesh: Cannot probe zero reference position at " - "(%.2f, %.2f) as it is located within a faulty region." - " Check the value for option '%s'" - % (self.zero_ref_pos[0], self.zero_ref_pos[1], opt,) - ) - # Check to see if any points fall within faulty regions - if probe_method == "manual": - return - last_y = self.points[0][1] - is_reversed = False - for i, coord in enumerate(self.points): - if not isclose(coord[1], last_y): - is_reversed = not is_reversed - last_y = coord[1] - adj_coords = [] - for min_c, max_c in self.faulty_regions: - if within(coord, min_c, max_c, tol=.00001): - # Point lies within a faulty region - adj_coords = [ - (min_c[0], coord[1]), (coord[0], min_c[1]), - (coord[0], max_c[1]), (max_c[0], coord[1])] - if is_reversed: - # Swap first and last points for zig-zag pattern - first = adj_coords[0] - adj_coords[0] = adj_coords[-1] - adj_coords[-1] = first - break - if not adj_coords: - # coord is not located within a faulty region - continue - valid_coords = [] - for ac in adj_coords: - # make sure that coordinates are within the mesh boundary - if self.radius is None: - if within(ac, (min_x, min_y), (max_x, max_y), .000001): - valid_coords.append(ac) - else: - dist_from_origin = math.sqrt(ac[0]*ac[0] + ac[1]*ac[1]) - if dist_from_origin <= self.radius: - valid_coords.append(ac) - if not valid_coords: - raise error("bed_mesh: Unable to generate coordinates" - " for faulty region at index: %d" % (i)) - self.substituted_indices[i] = valid_coords def print_generated_points(self, print_func): x_offset = y_offset = 0. probe = self.printer.lookup_object('probe', None) @@ -429,20 +323,23 @@ def print_generated_points(self, print_func): x_offset, y_offset = probe.get_offsets()[:2] print_func("bed_mesh: generated points\nIndex" " | Tool Adjusted | Probe") - for i, (x, y) in enumerate(self.points): + points = self.probe_mgr.get_base_points() + for i, (x, y) in enumerate(points): adj_pt = "(%.1f, %.1f)" % (x - x_offset, y - y_offset) mesh_pt = "(%.1f, %.1f)" % (x, y) print_func( " %-4d| %-16s| %s" % (i, adj_pt, mesh_pt)) - if self.zero_ref_pos is not None: + zero_ref_pos = self.probe_mgr.get_zero_ref_pos() + if zero_ref_pos is not None: print_func( "bed_mesh: zero_reference_position is (%.2f, %.2f)" - % (self.zero_ref_pos[0], self.zero_ref_pos[1]) + % (zero_ref_pos[0], zero_ref_pos[1]) ) - if self.substituted_indices: + substitutes = self.probe_mgr.get_substitutes() + if substitutes: print_func("bed_mesh: faulty region points") - for i, v in self.substituted_indices.items(): - pt = self.points[i] + for i, v in substitutes.items(): + pt = points[i] print_func("%d (%.2f, %.2f), substituted points: %s" % (i, pt[0], pt[1], repr(v))) def _init_mesh_config(self, config): @@ -481,42 +378,6 @@ def _init_mesh_config(self, config): config.get('algorithm', 'lagrange').strip().lower() orig_cfg['tension'] = mesh_cfg['tension'] = config.getfloat( 'bicubic_tension', .2, minval=0., maxval=2.) - for i in list(range(1, 100, 1)): - start = config.getfloatlist("faulty_region_%d_min" % (i,), None, - count=2) - if start is None: - break - end = config.getfloatlist("faulty_region_%d_max" % (i,), count=2) - # Validate the corners. If necessary reorganize them. - # c1 = min point, c3 = max point - # c4 ---- c3 - # | | - # c1 ---- c2 - c1 = [min([s, e]) for s, e in zip(start, end)] - c3 = [max([s, e]) for s, e in zip(start, end)] - c2 = [c1[0], c3[1]] - c4 = [c3[0], c1[1]] - # Check for overlapping regions - for j, (prev_c1, prev_c3) in enumerate(self.faulty_regions): - prev_c2 = [prev_c1[0], prev_c3[1]] - prev_c4 = [prev_c3[0], prev_c1[1]] - # Validate that no existing corner is within the new region - for coord in [prev_c1, prev_c2, prev_c3, prev_c4]: - if within(coord, c1, c3): - raise config.error( - "bed_mesh: Existing faulty_region_%d %s overlaps " - "added faulty_region_%d %s" - % (j+1, repr([prev_c1, prev_c3]), - i, repr([c1, c3]))) - # Validate that no new corner is within an existing region - for coord in [c1, c2, c3, c4]: - if within(coord, prev_c1, prev_c3): - raise config.error( - "bed_mesh: Added faulty_region_%d %s overlaps " - "existing faulty_region_%d %s" - % (i, repr([c1, c3]), - j+1, repr([prev_c1, prev_c3]))) - self.faulty_regions.append((c1, c3)) self._verify_algorithm(config.error) def _verify_algorithm(self, error): params = self.mesh_config @@ -712,47 +573,36 @@ def update_config(self, gcmd): if need_cfg_update: self._verify_algorithm(gcmd.error) - self._generate_points(gcmd.error, probe_method) + self.probe_mgr.generate_points( + self.mesh_config, self.mesh_min, self.mesh_max, + self.radius, self.origin, probe_method + ) gcmd.respond_info("Generating new points...") self.print_generated_points(gcmd.respond_info) - pts = self._get_adjusted_points() - self.probe_helper.update_probe_points(pts, 3) msg = "\n".join(["%s: %s" % (k, v) for k, v in self.mesh_config.items()]) logging.info("Updated Mesh Configuration:\n" + msg) else: - self._generate_points(gcmd.error, probe_method) - pts = self._get_adjusted_points() - self.probe_helper.update_probe_points(pts, 3) - def _get_adjusted_points(self): - adj_pts = [] - if self.substituted_indices: - last_index = 0 - for i, pts in self.substituted_indices.items(): - adj_pts.extend(self.points[last_index:i]) - adj_pts.extend(pts) - # Add one to the last index to skip the point - # we are replacing - last_index = i + 1 - adj_pts.extend(self.points[last_index:]) - else: - adj_pts = list(self.points) - if self.zero_reference_mode == ZrefMode.PROBE: - adj_pts.append(self.zero_ref_pos) - return adj_pts + self.probe_mgr.generate_points( + self.mesh_config, self.mesh_min, self.mesh_max, + self.radius, self.origin, probe_method + ) cmd_BED_MESH_CALIBRATE_help = "Perform Mesh Bed Leveling" def cmd_BED_MESH_CALIBRATE(self, gcmd): self._profile_name = gcmd.get('PROFILE', "default") if not self._profile_name.strip(): raise gcmd.error("Value for parameter 'PROFILE' must be specified") self.bedmesh.set_mesh(None) - self.update_config(gcmd) - self.probe_helper.start_probe(gcmd) + try: + self.update_config(gcmd) + except BedMeshError as e: + raise gcmd.error(str(e)) + self.probe_mgr.start_probe(gcmd) def probe_finalize(self, offsets, positions): x_offset, y_offset, z_offset = offsets positions = [[round(p[0], 2), round(p[1], 2), p[2]] for p in positions] - if self.zero_reference_mode == ZrefMode.PROBE: + if self.probe_mgr.get_zero_ref_mode() == ZrefMode.PROBE: ref_pos = positions.pop() logging.info( "bed_mesh: z-offset replaced with probed z value at " @@ -768,15 +618,17 @@ def probe_finalize(self, offsets, positions): x_cnt = params['x_count'] y_cnt = params['y_count'] - if self.substituted_indices: + substitutes = self.probe_mgr.get_substitutes() + base_points = self.probe_mgr.get_base_points() + if substitutes: # Replace substituted points with the original generated # point. Its Z Value is the average probed Z of the # substituted points. corrected_pts = [] idx_offset = 0 start_idx = 0 - for i, pts in self.substituted_indices.items(): - fpt = [p - o for p, o in zip(self.points[i], offsets[:2])] + for i, pts in substitutes.items(): + fpt = [p - o for p, o in zip(base_points[i], offsets[:2])] # offset the index to account for additional samples idx = i + idx_offset # Add "normal" points @@ -793,13 +645,13 @@ def probe_finalize(self, offsets, positions): corrected_pts.append(fpt) corrected_pts.extend(positions[start_idx:]) # validate corrected positions - if len(self.points) != len(corrected_pts): + if len(base_points) != len(corrected_pts): self._dump_points(positions, corrected_pts, offsets) raise self.gcode.error( "bed_mesh: invalid position list size, " "generated count: %d, probed count: %d" - % (len(self.points), len(corrected_pts))) - for gen_pt, probed in zip(self.points, corrected_pts): + % (len(base_points), len(corrected_pts))) + for gen_pt, probed in zip(base_points, corrected_pts): off_pt = [p - o for p, o in zip(gen_pt, offsets[:2])] if not isclose(off_pt[0], probed[0], abs_tol=.1) or \ not isclose(off_pt[1], probed[1], abs_tol=.1): @@ -866,11 +718,12 @@ def probe_finalize(self, offsets, positions): z_mesh.build_mesh(probed_matrix) except BedMeshError as e: raise self.gcode.error(str(e)) - if self.zero_reference_mode == ZrefMode.IN_MESH: + if self.probe_mgr.get_zero_ref_mode() == ZrefMode.IN_MESH: # The reference can be anywhere in the mesh, therefore # it is necessary to set the reference after the initial mesh # is generated to lookup the correct z value. - z_mesh.set_zero_reference(*self.zero_ref_pos) + zero_ref_pos = self.probe_mgr.get_zero_ref_pos() + z_mesh.set_zero_reference(*zero_ref_pos) self.bedmesh.set_mesh(z_mesh) self.gcode.respond_info("Mesh Bed Leveling Complete") if self._profile_name is not None: @@ -878,14 +731,15 @@ def probe_finalize(self, offsets, positions): def _dump_points(self, probed_pts, corrected_pts, offsets): # logs generated points with offset applied, points received # from the finalize callback, and the list of corrected points - max_len = max([len(self.points), len(probed_pts), len(corrected_pts)]) + points = self.probe_mgr.get_base_points() + max_len = max([len(points), len(probed_pts), len(corrected_pts)]) logging.info( "bed_mesh: calibration point dump\nIndex | %-17s| %-25s|" " Corrected Point" % ("Generated Point", "Probed Point")) for i in list(range(max_len)): gen_pt = probed_pt = corr_pt = "" - if i < len(self.points): - off_pt = [p - o for p, o in zip(self.points[i], offsets[:2])] + if i < len(points): + off_pt = [p - o for p, o in zip(points[i], offsets[:2])] gen_pt = "(%.2f, %.2f)" % tuple(off_pt) if i < len(probed_pts): probed_pt = "(%.2f, %.2f, %.4f)" % tuple(probed_pts[i]) @@ -894,6 +748,453 @@ def _dump_points(self, probed_pts, corrected_pts, offsets): logging.info( " %-4d| %-17s| %-25s| %s" % (i, gen_pt, probed_pt, corr_pt)) +class ProbeManager: + def __init__(self, config, orig_config, finalize_cb): + self.printer = config.get_printer() + self.cfg_overshoot = config.getfloat("scan_overshoot", 0, minval=1.) + self.orig_config = orig_config + self.faulty_regions = [] + self.overshoot = self.cfg_overshoot + self.zero_ref_pos = config.getfloatlist( + "zero_reference_position", None, count=2 + ) + self.zref_mode = ZrefMode.DISABLED + self.base_points = [] + self.substitutes = collections.OrderedDict() + self.is_round = orig_config["radius"] is not None + self.probe_helper = probe.ProbePointsHelper(config, finalize_cb, []) + self.probe_helper.use_xy_offsets(True) + self.rapid_scan_helper = RapidScanHelper(config, self, finalize_cb) + self._init_faulty_regions(config) + + def _init_faulty_regions(self, config): + for i in list(range(1, 100, 1)): + start = config.getfloatlist("faulty_region_%d_min" % (i,), None, + count=2) + if start is None: + break + end = config.getfloatlist("faulty_region_%d_max" % (i,), count=2) + # Validate the corners. If necessary reorganize them. + # c1 = min point, c3 = max point + # c4 ---- c3 + # | | + # c1 ---- c2 + c1 = [min([s, e]) for s, e in zip(start, end)] + c3 = [max([s, e]) for s, e in zip(start, end)] + c2 = [c1[0], c3[1]] + c4 = [c3[0], c1[1]] + # Check for overlapping regions + for j, (prev_c1, prev_c3) in enumerate(self.faulty_regions): + prev_c2 = [prev_c1[0], prev_c3[1]] + prev_c4 = [prev_c3[0], prev_c1[1]] + # Validate that no existing corner is within the new region + for coord in [prev_c1, prev_c2, prev_c3, prev_c4]: + if within(coord, c1, c3): + raise config.error( + "bed_mesh: Existing faulty_region_%d %s overlaps " + "added faulty_region_%d %s" + % (j+1, repr([prev_c1, prev_c3]), + i, repr([c1, c3]))) + # Validate that no new corner is within an existing region + for coord in [c1, c2, c3, c4]: + if within(coord, prev_c1, prev_c3): + raise config.error( + "bed_mesh: Added faulty_region_%d %s overlaps " + "existing faulty_region_%d %s" + % (i, repr([c1, c3]), + j+1, repr([prev_c1, prev_c3]))) + self.faulty_regions.append((c1, c3)) + + def start_probe(self, gcmd): + method = gcmd.get("METHOD", "automatic").lower() + can_scan = False + pprobe = self.printer.lookup_object("probe", None) + if pprobe is not None: + probe_name = pprobe.get_status(None).get("name", "") + can_scan = probe_name.startswith("probe_eddy_current") + if method == "rapid_scan" and can_scan: + self.rapid_scan_helper.perform_rapid_scan(gcmd) + else: + self.probe_helper.start_probe(gcmd) + + def get_zero_ref_pos(self): + return self.zero_ref_pos + + def get_zero_ref_mode(self): + return self.zref_mode + + def get_substitutes(self): + return self.substitutes + + def generate_points( + self, mesh_config, mesh_min, mesh_max, radius, origin, + probe_method="automatic" + ): + x_cnt = mesh_config['x_count'] + y_cnt = mesh_config['y_count'] + min_x, min_y = mesh_min + max_x, max_y = mesh_max + x_dist = (max_x - min_x) / (x_cnt - 1) + y_dist = (max_y - min_y) / (y_cnt - 1) + # floor distances down to next hundredth + x_dist = math.floor(x_dist * 100) / 100 + y_dist = math.floor(y_dist * 100) / 100 + if x_dist < 1. or y_dist < 1.: + raise BedMeshError("bed_mesh: min/max points too close together") + + if radius is not None: + # round bed, min/max needs to be recalculated + y_dist = x_dist + new_r = (x_cnt // 2) * x_dist + min_x = min_y = -new_r + max_x = max_y = new_r + else: + # rectangular bed, only re-calc max_x + max_x = min_x + x_dist * (x_cnt - 1) + pos_y = min_y + points = [] + for i in range(y_cnt): + for j in range(x_cnt): + if not i % 2: + # move in positive directon + pos_x = min_x + j * x_dist + else: + # move in negative direction + pos_x = max_x - j * x_dist + if radius is None: + # rectangular bed, append + points.append((pos_x, pos_y)) + else: + # round bed, check distance from origin + dist_from_origin = math.sqrt(pos_x*pos_x + pos_y*pos_y) + if dist_from_origin <= radius: + points.append( + (origin[0] + pos_x, origin[1] + pos_y)) + pos_y += y_dist + if self.zero_ref_pos is None or probe_method == "manual": + # Zero Reference Disabled + self.zref_mode = ZrefMode.DISABLED + elif within(self.zero_ref_pos, mesh_min, mesh_max): + # Zero Reference position within mesh + self.zref_mode = ZrefMode.IN_MESH + else: + # Zero Reference position outside of mesh + self.zref_mode = ZrefMode.PROBE + self.base_points = points + self.substitutes.clear() + # adjust overshoot + og_min_x = self.orig_config["mesh_min"][0] + og_max_x = self.orig_config["mesh_max"][0] + add_ovs = min(max(0, min_x - og_min_x), max(0, og_max_x - max_x)) + self.overshoot = self.cfg_overshoot + math.floor(add_ovs) + min_pt, max_pt = (min_x, min_y), (max_x, max_y) + self._process_faulty_regions(min_pt, max_pt, radius) + self.probe_helper.update_probe_points(self.get_std_path(), 3) + + def _process_faulty_regions(self, min_pt, max_pt, radius): + if not self.faulty_regions: + return + # Cannot probe a reference within a faulty region + if self.zref_mode == ZrefMode.PROBE: + for min_c, max_c in self.faulty_regions: + if within(self.zero_ref_pos, min_c, max_c): + opt = "zero_reference_position" + raise BedMeshError( + "bed_mesh: Cannot probe zero reference position at " + "(%.2f, %.2f) as it is located within a faulty region." + " Check the value for option '%s'" + % (self.zero_ref_pos[0], self.zero_ref_pos[1], opt,) + ) + # Check to see if any points fall within faulty regions + last_y = self.base_points[0][1] + is_reversed = False + for i, coord in enumerate(self.base_points): + if not isclose(coord[1], last_y): + is_reversed = not is_reversed + last_y = coord[1] + adj_coords = [] + for min_c, max_c in self.faulty_regions: + if within(coord, min_c, max_c, tol=.00001): + # Point lies within a faulty region + adj_coords = [ + (min_c[0], coord[1]), (coord[0], min_c[1]), + (coord[0], max_c[1]), (max_c[0], coord[1])] + if is_reversed: + # Swap first and last points for zig-zag pattern + first = adj_coords[0] + adj_coords[0] = adj_coords[-1] + adj_coords[-1] = first + break + if not adj_coords: + # coord is not located within a faulty region + continue + valid_coords = [] + for ac in adj_coords: + # make sure that coordinates are within the mesh boundary + if radius is None: + if within(ac, min_pt, max_pt, .000001): + valid_coords.append(ac) + else: + dist_from_origin = math.sqrt(ac[0]*ac[0] + ac[1]*ac[1]) + if dist_from_origin <= radius: + valid_coords.append(ac) + if not valid_coords: + raise BedMeshError( + "bed_mesh: Unable to generate coordinates" + " for faulty region at index: %d" % (i) + ) + self.substitutes[i] = valid_coords + + def get_base_points(self): + return self.base_points + + def get_std_path(self): + path = [] + for idx, pt in enumerate(self.base_points): + if idx in self.substitutes: + for sub_pt in self.substitutes[idx]: + path.append(sub_pt) + else: + path.append(pt) + if self.zref_mode == ZrefMode.PROBE: + path.append(self.zero_ref_pos) + return path + + def iter_rapid_path(self): + ascnd_x = True + last_base_pt = last_mv_pt = self.base_points[0] + # Generate initial move point + if self.overshoot: + overshoot = min(8, self.overshoot) + last_mv_pt = (last_base_pt[0] - overshoot, last_base_pt[1]) + yield last_mv_pt, False + for idx, pt in enumerate(self.base_points): + # increasing Y indicates direction change + dir_change = not isclose(pt[1], last_base_pt[1], abs_tol=1e-6) + if idx in self.substitutes: + fp_gen = self._gen_faulty_path( + last_mv_pt, idx, ascnd_x, dir_change + ) + for sub_pt, is_smp in fp_gen: + yield sub_pt, is_smp + last_mv_pt = sub_pt + else: + if dir_change: + for dpt in self._gen_dir_change(last_mv_pt, pt, ascnd_x): + yield dpt, False + yield pt, True + last_mv_pt = pt + last_base_pt = pt + ascnd_x ^= dir_change + if self.zref_mode == ZrefMode.PROBE: + if self.overshoot: + ovs = min(4, self.overshoot) + ovs = ovs if ascnd_x else -ovs + yield (last_mv_pt[0] + ovs, last_mv_pt[1]), False + yield self.zero_ref_pos, True + + def _gen_faulty_path(self, last_pt, idx, ascnd_x, dir_change): + subs = self.substitutes[idx] + sub_cnt = len(subs) + if dir_change: + for dpt in self._gen_dir_change(last_pt, subs[0], ascnd_x): + yield dpt, False + if self.is_round: + # No faulty region path handling for round beds + for pt in subs: + yield pt, True + return + # Check to see if this is the first corner + first_corner = False + sorted_sub_idx = sorted(self.substitutes.keys()) + if sub_cnt == 2 and idx < len(sorted_sub_idx): + first_corner = sorted_sub_idx[idx] == idx + yield subs[0], True + if sub_cnt == 1: + return + last_pt, next_pt = subs[:2] + if sub_cnt == 2: + if first_corner or dir_change: + # horizontal move first + yield (next_pt[0], last_pt[1]), False + else: + yield (last_pt[0], next_pt[1]), False + yield next_pt, True + elif sub_cnt >= 3: + if dir_change: + # first move should be a vertical switch up. If overshoot + # is available, simulate another direction change. Otherwise + # move inward 2 mm, then up through the faulty region. + if self.overshoot: + for dpt in self._gen_dir_change(last_pt, next_pt, ascnd_x): + yield dpt, False + else: + shift = -2 if ascnd_x else 2 + yield (last_pt[0] + shift, last_pt[1]), False + yield (last_pt[0] + shift, next_pt[1]), False + yield next_pt, True + last_pt, next_pt = subs[1:3] + else: + # vertical move + yield (last_pt[0], next_pt[1]), False + yield next_pt, True + last_pt, next_pt = subs[1:3] + if sub_cnt == 4: + # Vertical switch up within faulty region + shift = 2 if ascnd_x else -2 + yield (last_pt[0] + shift, last_pt[1]), False + yield (next_pt[0] - shift, next_pt[1]), False + yield next_pt, True + last_pt, next_pt = subs[2:4] + # horizontal move before final point + yield (next_pt[0], last_pt[1]), False + yield next_pt, True + + def _gen_dir_change(self, last_pt, next_pt, ascnd_x): + if not self.overshoot: + return + # overshoot X beyond the outer point + xdir = 1 if ascnd_x else -1 + overshoot = 2. if self.overshoot >= 3. else self.overshoot + ovr_pt = (last_pt[0] + overshoot * xdir, last_pt[1]) + yield ovr_pt + if self.overshoot < 3.: + # No room to generate an arc, move up to next y + yield (next_pt[0] + overshoot * xdir, next_pt[1]) + else: + # generate arc + STEP_ANGLE = 3 + START_ANGLE = 270 + ydiff = abs(next_pt[1] - last_pt[1]) + xdiff = abs(next_pt[0] - last_pt[0]) + max_radius = min(self.overshoot - 2, 8) + radius = min(ydiff / 2, max_radius) + origin = [ovr_pt[0], last_pt[1] + radius] + next_origin_y = next_pt[1] - radius + # determine angle + if xdiff < .01: + # Move is aligned on the x-axis + angle = 90 + if next_origin_y - origin[1] < .05: + # The move can be completed in a single arc + angle = 180 + else: + angle = int(math.degrees(math.atan(ydiff / xdiff))) + if ( + (ascnd_x and next_pt[0] < last_pt[0]) or + (not ascnd_x and next_pt[0] > last_pt[0]) + ): + angle = 180 - angle + count = int(angle // STEP_ANGLE) + # Gen first arc + step = STEP_ANGLE * xdir + start = START_ANGLE + step + for arc_pt in self._gen_arc(origin, radius, start, step, count): + yield arc_pt + if angle == 180: + # arc complete + return + # generate next arc + origin = [next_pt[0] + overshoot * xdir, next_origin_y] + # start at the angle where the last arc finished + start = START_ANGLE + count * step + # recalculate the count to make sure we generate a full 180 + # degrees. Add a step for the repeated connecting angle + count = 61 - count + for arc_pt in self._gen_arc(origin, radius, start, step, count): + yield arc_pt + + def _gen_arc(self, origin, radius, start, step, count): + end = start + step * count + # create a segent for every 3 degress of travel + for angle in range(start, end, step): + rad = math.radians(angle % 360) + opp = math.sin(rad) * radius + adj = math.cos(rad) * radius + yield (origin[0] + adj, origin[1] + opp) + + +MAX_HIT_DIST = 2. +MM_WIN_SPEED = 125 + +class RapidScanHelper: + def __init__(self, config, probe_mgr, finalize_cb): + self.printer = config.get_printer() + self.probe_manager = probe_mgr + self.speed = config.getfloat("speed", 50., above=0.) + self.scan_height = config.getfloat("horizontal_move_z", 5.) + self.finalize_callback = finalize_cb + + def perform_rapid_scan(self, gcmd): + speed = gcmd.get_float("SCAN_SPEED", self.speed) + scan_height = gcmd.get_float("HORIZONTAL_MOVE_Z", self.scan_height) + gcmd.respond_info( + "Beginning rapid surface scan at height %.2f..." % (scan_height) + ) + pprobe = self.printer.lookup_object("probe") + toolhead = self.printer.lookup_object("toolhead") + # Calculate time window around which a sample is valid. Current + # assumption is anything within 2mm is usable, so: + # window = 2 / max_speed + # + # TODO: validate maximum speed allowed based on sample rate of probe + # Scale the hit distance window for speeds lower than 125mm/s. The + # lower the speed the less the window shrinks. + scale = max(0, 1 - speed / MM_WIN_SPEED) + 1 + hit_dist = min(MAX_HIT_DIST, scale * speed / MM_WIN_SPEED) + half_window = hit_dist / speed + gcmd.respond_info( + "Sample hit distance +/- %.4fmm, time window +/- ms %.4f" + % (hit_dist, half_window * 1000) + ) + gcmd_params = gcmd.get_command_parameters() + gcmd_params["SAMPLE_TIME"] = half_window * 2 + self._raise_tool(gcmd, scan_height) + probe_session = pprobe.start_probe_session(gcmd) + offsets = pprobe.get_offsets() + initial_move = True + for pos, is_probe_pt in self.probe_manager.iter_rapid_path(): + pos = self._apply_offsets(pos[:2], offsets) + toolhead.manual_move(pos, speed) + if initial_move: + initial_move = False + self._move_to_scan_height(gcmd, scan_height) + if is_probe_pt: + probe_session.run_probe(gcmd) + results = probe_session.pull_probed_results() + toolhead.get_last_move_time() + self.finalize_callback(offsets, results) + probe_session.end_probe_session() + + def _raise_tool(self, gcmd, scan_height): + # If the nozzle is below scan height raise the tool + toolhead = self.printer.lookup_object("toolhead") + pprobe = self.printer.lookup_object("probe") + cur_pos = toolhead.get_position() + if cur_pos[2] >= scan_height: + return + pparams = pprobe.get_probe_params(gcmd) + lift_speed = pparams["lift_speed"] + cur_pos[2] = self.scan_height + .5 + toolhead.manual_move(cur_pos, lift_speed) + + def _move_to_scan_height(self, gcmd, scan_height): + time_window = gcmd.get_float("SAMPLE_TIME") + toolhead = self.printer.lookup_object("toolhead") + pprobe = self.printer.lookup_object("probe") + cur_pos = toolhead.get_position() + pparams = pprobe.get_probe_params(gcmd) + lift_speed = pparams["lift_speed"] + probe_speed = pparams["probe_speed"] + cur_pos[2] = scan_height + .5 + toolhead.manual_move(cur_pos, lift_speed) + cur_pos[2] = scan_height + toolhead.manual_move(cur_pos, probe_speed) + toolhead.dwell(time_window / 2 + .01) + + def _apply_offsets(self, point, offsets): + return [(pos - ofs) for pos, ofs in zip(point, offsets)] + class MoveSplitter: def __init__(self, config, gcode): From c7b7c11cc323e7e28521259e615f674990a1701e Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Wed, 14 Feb 2024 08:16:49 -0500 Subject: [PATCH 03/14] bed_mesh: add dump_mesh webhooks API Returns current mesh configuration and state. Includes probed and mesh matrices, saved profiles, current points, and travel paths. Signed-off-by: Eric Callahan --- klippy/extras/bed_mesh.py | 44 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/klippy/extras/bed_mesh.py b/klippy/extras/bed_mesh.py index d9ee7dfba8a6..8aa4958d51ae 100644 --- a/klippy/extras/bed_mesh.py +++ b/klippy/extras/bed_mesh.py @@ -121,6 +121,11 @@ def __init__(self, config): self.gcode.register_command( 'BED_MESH_OFFSET', self.cmd_BED_MESH_OFFSET, desc=self.cmd_BED_MESH_OFFSET_help) + # Register dump webhooks + webhooks = self.printer.lookup_object('webhooks') + webhooks.register_endpoint( + "bed_mesh/dump_mesh", self._handle_dump_request + ) # Register transform gcode_move = self.printer.load_object(config, 'gcode_move') gcode_move.set_move_transform(self) @@ -282,6 +287,31 @@ def cmd_BED_MESH_OFFSET(self, gcmd): gcode_move.reset_last_position() else: gcmd.respond_info("No mesh loaded to offset") + def _handle_dump_request(self, web_request): + eventtime = self.printer.get_reactor().monotonic() + prb = self.printer.lookup_object("probe", None) + th_sts = self.printer.lookup_object("toolhead").get_status(eventtime) + result = {"current_mesh": {}, "profiles": self.pmgr.get_profiles()} + if self.z_mesh is not None: + result["current_mesh"] = { + "name": self.z_mesh.get_profile_name(), + "probed_matrix": self.z_mesh.get_probed_matrix(), + "mesh_matrix": self.z_mesh.get_mesh_matrix(), + "mesh_params": self.z_mesh.get_mesh_params() + } + mesh_args = web_request.get_dict("mesh_args", {}) + gcmd = None + if mesh_args: + gcmd = self.gcode.create_gcode_command("", "", mesh_args) + with self.gcode.get_mutex(): + result["calibration"] = self.bmc.dump_calibration(gcmd) + else: + result["calibration"] = self.bmc.dump_calibration() + offsets = [0, 0, 0] if prb is None else prb.get_offsets() + result["probe_offsets"] = offsets + result["axis_minimum"] = th_sts["axis_minimum"] + result["axis_maximum"] = th_sts["axis_maximum"] + web_request.send(result) class ZrefMode: @@ -587,6 +617,20 @@ def update_config(self, gcmd): self.mesh_config, self.mesh_min, self.mesh_max, self.radius, self.origin, probe_method ) + def dump_calibration(self, gcmd=None): + if gcmd is not None and gcmd.get_command_parameters(): + self.update_config(gcmd) + cfg = dict(self.mesh_config) + cfg["mesh_min"] = self.mesh_min + cfg["mesh_max"] = self.mesh_max + cfg["origin"] = self.origin + cfg["radius"] = self.radius + return { + "points": self.probe_mgr.get_base_points(), + "config": cfg, + "probe_path": self.probe_mgr.get_std_path(), + "rapid_path": list(self.probe_mgr.iter_rapid_path()) + } cmd_BED_MESH_CALIBRATE_help = "Perform Mesh Bed Leveling" def cmd_BED_MESH_CALIBRATE(self, gcmd): self._profile_name = gcmd.get('PROFILE', "default") From fc0f17b920ce0fd2dca90dce0bd7df4573aee568 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Mon, 19 Feb 2024 15:10:56 -0500 Subject: [PATCH 04/14] graph_mesh: script for mesh visualization and analysis Signed-off-by: Eric Callahan --- scripts/graph_mesh.py | 533 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 533 insertions(+) create mode 100755 scripts/graph_mesh.py diff --git a/scripts/graph_mesh.py b/scripts/graph_mesh.py new file mode 100755 index 000000000000..3a331e5d5dca --- /dev/null +++ b/scripts/graph_mesh.py @@ -0,0 +1,533 @@ +#!/usr/bin/env python3 +# Bed Mesh data plotting and analysis +# +# Copyright (C) 2024 Eric Callahan +# +# This file may be distributed under the terms of the GNU GPLv3 license. +import argparse +import sys +import os +import stat +import errno +import time +import socket +import re +import json +import collections +import numpy as np +import matplotlib +import matplotlib.cm as cm +import matplotlib.pyplot as plt +import matplotlib.animation as ani + +MESH_DUMP_REQUEST = json.dumps( + {"id": 1, "method": "bed_mesh/dump_mesh"} +) + +def sock_error_exit(msg): + sys.stderr.write(msg + "\n") + sys.exit(-1) + +def webhook_socket_create(uds_filename): + sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + while 1: + try: + sock.connect(uds_filename) + except socket.error as e: + if e.errno == errno.ECONNREFUSED: + time.sleep(0.1) + continue + sock_error_exit( + "Unable to connect socket %s [%d,%s]" + % (uds_filename, e.errno, errno.errorcode[e.errno]) + ) + break + print("Connected") + return sock + +def process_message(msg): + try: + resp = json.loads(msg) + except json.JSONDecodeError: + return None + if resp.get("id", -1) != 1: + return None + if "error" in resp: + err = resp["error"].get("message", "Unknown") + sock_error_exit( + "Error requesting mesh dump: %s" % (err,) + ) + return resp["result"] + + +def request_from_unixsocket(unix_sock_name): + print("Connecting to Unix Socket File '%s'" % (unix_sock_name,)) + whsock = webhook_socket_create(unix_sock_name) + whsock.settimeout(1.) + # send mesh query + whsock.send(MESH_DUMP_REQUEST.encode() + b"\x03") + sock_data = b"" + end_time = time.monotonic() + 10.0 + try: + while time.monotonic() < end_time: + try: + data = whsock.recv(4096) + except TimeoutError: + pass + else: + if not data: + sock_error_exit("Socket closed before mesh received") + parts = data.split(b"\x03") + parts[0] = sock_data + parts[0] + sock_data = parts.pop() + for msg in parts: + result = process_message(msg) + if result is not None: + return result + time.sleep(.1) + finally: + whsock.close() + sock_error_exit("Mesh dump request timed out") + +def request_from_websocket(url): + print("Connecting to websocket url '%s'" % (url,)) + try: + from websockets.sync.client import connect + except ModuleNotFoundError: + sock_error_exit("Python module 'websockets' not installed.") + raise + with connect(url) as websocket: + websocket.send(MESH_DUMP_REQUEST) + end_time = time.monotonic() + 20.0 + while time.monotonic() < end_time: + try: + msg = websocket.recv(10.) + except TimeoutError: + continue + result = process_message(msg) + if result is not None: + return result + time.sleep(.1) + sock_error_exit("Mesh dump request timed out") + +def request_mesh_data(input_name): + url_match = re.match(r"((?:https?)|(?:wss?))://(.+)", input_name.lower()) + if url_match is None: + file_path = os.path.abspath(os.path.expanduser(input_name)) + if not os.path.exists(file_path): + sock_error_exit("Path '%s' does not exist" % (file_path,)) + st_res = os.stat(file_path) + if stat.S_ISSOCK(st_res.st_mode): + return request_from_unixsocket(file_path) + else: + print("Reading mesh data from json file '%s'" % (file_path,)) + with open(file_path, "r") as f: + return json.load(f) + scheme = url_match.group(1) + host = url_match.group(2).rstrip("/") + scheme = scheme.replace("http", "ws") + url = "%s://%s/klippysocket" % (scheme, host) + return request_from_websocket(url) + +class PathAnimation: + instance = None + def __init__(self, artist, x_travel, y_travel): + self.travel_artist = artist + self.x_travel = x_travel + self.y_travel = y_travel + fig = plt.gcf() + self.animation = ani.FuncAnimation( + fig=fig, func=self.update, frames=self.gen_path_position(), + cache_frame_data=False, interval=60 + ) + PathAnimation.instance = self + + def gen_path_position(self): + count = 1 + x_travel, y_travel = self.x_travel, self.y_travel + last_x, last_y = x_travel[0], y_travel[0] + yield count + for xpos, ypos in zip(x_travel[1:], y_travel[1:]): + count += 1 + if xpos == last_x or ypos == last_y: + yield count + last_x, last_y = xpos, ypos + + def update(self, frame): + x_travel, y_travel = self.x_travel, self.y_travel + self.travel_artist.set_xdata(x_travel[:frame]) + self.travel_artist.set_ydata(y_travel[:frame]) + return (self.travel_artist,) + + +def _gen_mesh_coords(min_c, max_c, count): + dist = (max_c - min_c) / (count - 1) + return [min_c + i * dist for i in range(count)] + +def _plot_path(travel_path, probed, diff, cmd_args): + x_travel, y_travel = np.array(travel_path).transpose() + x_probed, y_probed = np.array(probed).transpose() + plt.xlabel("X") + plt.ylabel("Y") + # plot travel + travel_line = plt.plot(x_travel, y_travel, "b-")[0] + # plot intermediate points + plt.plot(x_probed, y_probed, "k.") + # plot start point + plt.plot([x_travel[0]], [y_travel[0]], "g>") + # plot stop point + plt.plot([x_travel[-1]], [y_travel[-1]], "r*") + if diff: + diff_x, diff_y = np.array(diff).transpose() + plt.plot(diff_x, diff_y, "m.") + if cmd_args.animate and cmd_args.output is None: + PathAnimation(travel_line, x_travel, y_travel) + +def _format_mesh_data(matrix, params): + min_pt = (params["min_x"], params["min_y"]) + max_pt = (params["max_x"], params["max_y"]) + xvals = _gen_mesh_coords(min_pt[0], max_pt[0], len(matrix[0])) + yvals = _gen_mesh_coords(min_pt[1], max_pt[0], len(matrix)) + x, y = np.meshgrid(xvals, yvals) + z = np.array(matrix) + return x, y, z + +def _set_xy_limits(mesh_data, cmd_args): + if not cmd_args.scale_plot: + return + ax = plt.gca() + axis_min = mesh_data["axis_minimum"] + axis_max = mesh_data["axis_maximum"] + ax.set_xlim((axis_min[0], axis_max[0])) + ax.set_ylim((axis_min[1], axis_max[1])) + +def _plot_mesh(ax, matrix, params, cmap=cm.viridis, label=None): + x, y, z = _format_mesh_data(matrix, params) + surface = ax.plot_surface(x, y, z, cmap=cmap, label=label) + scale = max(abs(z.min()), abs(z.max())) * 3 + return surface, scale + +def plot_probe_points(mesh_data, cmd_args): + """Plot original generated points""" + calibration = mesh_data["calibration"] + x, y = np.array(calibration["points"]).transpose() + plt.title("Generated Probe Points") + plt.xlabel("X") + plt.ylabel("Y") + plt.plot(x, y, "b.") + _set_xy_limits(mesh_data, cmd_args) + +def plot_probe_path(mesh_data, cmd_args): + """Plot probe travel path""" + calibration = mesh_data["calibration"] + orig_pts = calibration["points"] + path_pts = calibration["probe_path"] + diff = [pt for pt in orig_pts if pt not in path_pts] + plt.title("Probe Travel Path") + _plot_path(path_pts, path_pts[1:-1], diff, cmd_args) + _set_xy_limits(mesh_data, cmd_args) + +def plot_rapid_path(mesh_data, cmd_args): + """Plot rapid scan travel path""" + calibration = mesh_data["calibration"] + orig_pts = calibration["points"] + rapid_pts = calibration["rapid_path"] + rapid_path = [pt[0] for pt in rapid_pts] + probed = [pt for pt, is_ppt in rapid_pts if is_ppt] + diff = [pt for pt in orig_pts if pt not in probed] + plt.title("Rapid Scan Travel Path") + _plot_path(rapid_path, probed, diff, cmd_args) + _set_xy_limits(mesh_data, cmd_args) + +def plot_probed_matrix(mesh_data, cmd_args): + """Plot probed Z values""" + ax = plt.subplot(projection="3d") + profile = cmd_args.profile_name + if profile is not None: + req_mesh = mesh_data["profiles"].get(profile) + if req_mesh is None: + raise Exception("Profile %s not found" % (profile,)) + matrix = req_mesh["points"] + name = profile + else: + req_mesh = mesh_data["current_mesh"] + if not req_mesh: + raise Exception("No current mesh data in dump") + matrix = req_mesh["probed_matrix"] + name = req_mesh["name"] + params = req_mesh["mesh_params"] + surface, scale = _plot_mesh(ax, matrix, params) + ax.set_title("Probed Mesh (%s)" % (name,)) + ax.set(zlim=(-scale, scale)) + plt.gcf().colorbar(surface, shrink=.75) + _set_xy_limits(mesh_data, cmd_args) + +def plot_mesh_matrix(mesh_data, cmd_args): + """Plot mesh Z values""" + ax = plt.subplot(projection="3d") + req_mesh = mesh_data["current_mesh"] + if not req_mesh: + raise Exception("No current mesh data in dump") + matrix = req_mesh["mesh_matrix"] + params = req_mesh["mesh_params"] + surface, scale = _plot_mesh(ax, matrix, params) + name = req_mesh["name"] + ax.set_title("Interpolated Mesh (%s)" % (name,)) + ax.set(zlim=(-scale, scale)) + plt.gcf().colorbar(surface, shrink=.75) + _set_xy_limits(mesh_data, cmd_args) + +def plot_overlay(mesh_data, cmd_args): + """Plots the current probed mesh overlaid with a profile""" + ax = plt.subplot(projection="3d") + # Plot Profile + profile = cmd_args.profile_name + if profile is None: + raise Exception("A profile must be specified to plot an overlay") + req_mesh = mesh_data["profiles"].get(profile) + if req_mesh is None: + raise Exception("Profile %s not found" % (profile,)) + matrix = req_mesh["points"] + params = req_mesh["mesh_params"] + prof_surf, prof_scale = _plot_mesh(ax, matrix, params, label=profile) + # Plot Current + req_mesh = mesh_data["current_mesh"] + if not req_mesh: + raise Exception("No current mesh data in dump") + matrix = req_mesh["probed_matrix"] + params = req_mesh["mesh_params"] + cur_name = req_mesh["name"] + cur_surf, cur_scale = _plot_mesh(ax, matrix, params, cm.inferno, cur_name) + ax.set_title("Probed Mesh Overlay") + scale = max(cur_scale, prof_scale) + ax.set(zlim=(-scale, scale)) + ax.legend(loc='best') + plt.gcf().colorbar(prof_surf, shrink=.75) + _set_xy_limits(mesh_data, cmd_args) + +def plot_delta(mesh_data, cmd_args): + """Plots the delta between current probed mesh and a profile""" + ax = plt.subplot(projection="3d") + # Plot Profile + profile = cmd_args.profile_name + if profile is None: + raise Exception("A profile must be specified to plot an overlay") + req_mesh = mesh_data["profiles"].get(profile) + if req_mesh is None: + raise Exception("Profile %s not found" % (profile,)) + prof_matix = req_mesh["points"] + prof_params = req_mesh["mesh_params"] + req_mesh = mesh_data["current_mesh"] + if not req_mesh: + raise Exception("No current mesh data in dump") + cur_matrix = req_mesh["probed_matrix"] + cur_params = req_mesh["mesh_params"] + cur_name = req_mesh["name"] + # validate that the params match + pfields = ("x_count", "y_count", "min_x", "max_x", "min_y", "max_y") + for field in pfields: + if abs(prof_params[field] - cur_params[field]) >= 1e-6: + raise Exception( + "Values for field %s do not match, cant plot deviation" + ) + delta = np.array(cur_matrix) - np.array(prof_matix) + surface, scale = _plot_mesh(ax, delta, cur_params) + ax.set(zlim=(-scale, scale)) + ax.set_title("Probed Mesh Delta (%s, %s)" % (cur_name, profile)) + _set_xy_limits(mesh_data, cmd_args) + + +PLOT_TYPES = { + "points": plot_probe_points, + "path": plot_probe_path, + "rapid": plot_rapid_path, + "probedz": plot_probed_matrix, + "meshz": plot_mesh_matrix, + "overlay": plot_overlay, + "delta": plot_delta, +} + +def print_types(cmd_args): + typelist = [ + "%-10s%s" % (name, func.__doc__) for name, func in PLOT_TYPES.items() + ] + print("\n".join(typelist)) + +def plot_mesh_data(cmd_args): + mesh_data = request_mesh_data(cmd_args.input) + if cmd_args.output is not None: + matplotlib.use("svg") + + fig = plt.figure() + plot_func = PLOT_TYPES[cmd_args.type] + plot_func(mesh_data, cmd_args) + fig.set_size_inches(10, 8) + fig.tight_layout() + if cmd_args.output is None: + plt.show() + else: + fig.savefig(cmd_args.output) + +def _check_path_unique(name, path): + path = np.array(path) + unique_pts, counts = np.unique(path, return_counts=True, axis=0) + for idx, count in enumerate(counts): + if count != 1: + coord = unique_pts[idx] + print( + " WARNING: Backtracking or duplicate found in %s path at %s, " + "this may be due to multiple samples in a faulty region." + % (name, coord) + ) + +def _analyze_mesh(name, mesh_axes): + print("\nAnalyzing Probed Mesh %s..." % (name,)) + x, y, z = mesh_axes + min_idx, max_idx = z.argmin(), z.argmax() + min_x, min_y = x.flatten()[min_idx], y.flatten()[min_idx] + max_x, max_y = x.flatten()[max_idx], y.flatten()[max_idx] + + print( + " Min Coord (%.2f, %.2f), Max Coord (%.2f, %.2f), " + "Probe Count: (%d, %d)" % + (x.min(), y.min(), x.max(), y.max(), len(z), len(z[0])) + ) + print( + " Mesh range: min %.4f (%.2f, %.2f), max %.4f (%.2f, %.2f)" + % (z.min(), min_x, min_y, z.max(), max_x, max_y) + ) + print(" Mean: %.4f, Standard Deviation: %.4f" % (z.mean(), z.std())) + +def _compare_mesh(name_a, name_b, mesh_a, mesh_b): + ax, ay, az = mesh_a + bx, by, bz = mesh_b + if not np.array_equal(ax, bx) or not np.array_equal(ay, by): + return + delta = az - bz + abs_max = max(abs(delta.max()), abs(delta.min())) + abs_mean = sum([abs(z) for z in delta.flatten()]) / len(delta.flatten()) + min_idx, max_idx = delta.argmin(), delta.argmax() + min_x, min_y = ax.flatten()[min_idx], ay.flatten()[min_idx] + max_x, max_y = ax.flatten()[max_idx], ay.flatten()[max_idx] + print(" Delta from %s to %s..." % (name_a, name_b)) + print( + " Range: min %.4f (%.2f, %.2f), max %.4f (%.2f, %.2f)\n" + " Mean: %.6f, Standard Deviation: %.6f\n" + " Absolute Max: %.6f, Absolute Mean: %.6f" + % (delta.min(), min_x, min_y, delta.max(), max_x, max_y, + delta.mean(), delta.std(), abs_max, abs_mean) + ) + +def analyze(cmd_args): + mesh_data = request_mesh_data(cmd_args.input) + print("Analyzing Travel Path...") + calibration = mesh_data["calibration"] + org_pts = calibration["points"] + probe_path = calibration["probe_path"] + rapid_path = calibration["rapid_path"] + rapid_points = [pt for pt, is_pt in rapid_path if is_pt] + rapid_moves = [pt[0] for pt in rapid_path] + print(" Original point count: %d" % (len(org_pts))) + print(" Probe path count: %d" % (len(probe_path))) + print(" Rapid scan sample count: %d" % (len(probe_path))) + print(" Rapid scan move count: %d" % (len(rapid_moves))) + if np.array_equal(rapid_points, probe_path): + print(" Rapid scan points match probe path points") + else: + diff = [pt for pt in rapid_points if pt not in probe_path] + print( + " ERROR: Rapid scan points do not match probe points\n" + "difference: %s" % (diff,) + ) + _check_path_unique("probe", probe_path) + _check_path_unique("rapid scan", rapid_moves) + req_mesh = mesh_data["current_mesh"] + formatted_data = collections.OrderedDict() + if req_mesh: + matrix = req_mesh["probed_matrix"] + params = req_mesh["mesh_params"] + name = req_mesh["name"] + formatted_data[name] = _format_mesh_data(matrix, params) + profiles = mesh_data["profiles"] + for prof_name, prof_data in profiles.items(): + if prof_name in formatted_data: + continue + matrix = prof_data["points"] + params = prof_data["mesh_params"] + formatted_data[prof_name] = _format_mesh_data(matrix, params) + while formatted_data: + name, current_axes = formatted_data.popitem() + _analyze_mesh(name, current_axes) + for prof_name, prof_axes in formatted_data.items(): + _compare_mesh(name, prof_name, current_axes, prof_axes) + +def dump_request(cmd_args): + mesh_data = request_mesh_data(cmd_args.input) + outfile = cmd_args.output + if outfile is None: + postfix = time.strftime("%Y%m%d_%H%M%S") + outfile = "klipper-bedmesh-%s.json" % (postfix,) + outfile = os.path.abspath(os.path.expanduser(outfile)) + print("Saving Mesh Output to '%s'" % (outfile)) + with open(outfile, "w") as f: + f.write(json.dumps(mesh_data)) + +def main(): + parser = argparse.ArgumentParser(description="Graph Bed Mesh Data") + sub_parsers = parser.add_subparsers() + list_parser = sub_parsers.add_parser( + "list", help="List available plot types" + ) + list_parser.set_defaults(func=print_types) + plot_parser = sub_parsers.add_parser("plot", help="Plot a specified type") + analyze_parser = sub_parsers.add_parser( + "analyze", help="Perform analysis on mesh data" + ) + dump_parser = sub_parsers.add_parser( + "dump", help="Dump API response to json file" + ) + plot_parser.add_argument( + "-a", "--animate", action="store_true", + help="Animate paths in live preview" + ) + plot_parser.add_argument( + "-s", "--scale-plot", action="store_true", + help="Use axis limits reported by Klipper to scale plot X/Y" + ) + plot_parser.add_argument( + "-p", "--profile-name", type=str, default=None, + help="Optional name of a profile to plot for 'probedz'" + ) + plot_parser.add_argument( + "-o", "--output", type=str, default=None, + help="Output file path" + ) + plot_parser.add_argument( + "type", metavar="", type=str, choices=PLOT_TYPES.keys(), + help="Type of data to graph" + ) + plot_parser.add_argument( + "input", metavar="", + help="Path/url to Klipper Socket or path to json file" + ) + plot_parser.set_defaults(func=plot_mesh_data) + analyze_parser.add_argument( + "input", metavar="", + help="Path/url to Klipper Socket or path to json file" + ) + analyze_parser.set_defaults(func=analyze) + dump_parser.add_argument( + "-o", "--output", type=str, default=None, + help="Json output file path" + ) + dump_parser.add_argument( + "input", metavar="", + help="Path or url to Klipper Socket" + ) + dump_parser.set_defaults(func=dump_request) + cmd_args = parser.parse_args() + cmd_args.func(cmd_args) + + +if __name__ == "__main__": + main() From 2c7e09cfa6379e94fcc78774f186bce0bf0a2078 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Sun, 9 Jun 2024 19:21:01 -0400 Subject: [PATCH 05/14] bed_mesh: use generated XY positions in probe_finalize() The scan modes provide kinematic XYZ coordinates in the probe results. These positions may deviate from the requested positions, which can introduce errors in mesh generation when transposing the result into the Z matrix. Rely on the coordinates generated by bed mesh to transpose the matrix, presuming that points at the same index in the list match. Signed-off-by: Eric Callahan --- klippy/extras/bed_mesh.py | 57 +++++++++++++++++++++------------------ 1 file changed, 31 insertions(+), 26 deletions(-) diff --git a/klippy/extras/bed_mesh.py b/klippy/extras/bed_mesh.py index 8aa4958d51ae..9c44692c35f0 100644 --- a/klippy/extras/bed_mesh.py +++ b/klippy/extras/bed_mesh.py @@ -643,7 +643,7 @@ def cmd_BED_MESH_CALIBRATE(self, gcmd): raise gcmd.error(str(e)) self.probe_mgr.start_probe(gcmd) def probe_finalize(self, offsets, positions): - x_offset, y_offset, z_offset = offsets + z_offset = offsets[2] positions = [[round(p[0], 2), round(p[1], 2), p[2]] for p in positions] if self.probe_mgr.get_zero_ref_mode() == ZrefMode.PROBE: @@ -654,16 +654,17 @@ def probe_finalize(self, offsets, positions): % (ref_pos[0], ref_pos[1], ref_pos[2]) ) z_offset = ref_pos[2] + base_points = self.probe_mgr.get_base_points() params = dict(self.mesh_config) - params['min_x'] = min(positions, key=lambda p: p[0])[0] + x_offset - params['max_x'] = max(positions, key=lambda p: p[0])[0] + x_offset - params['min_y'] = min(positions, key=lambda p: p[1])[1] + y_offset - params['max_y'] = max(positions, key=lambda p: p[1])[1] + y_offset + params['min_x'] = min(base_points, key=lambda p: p[0])[0] + params['max_x'] = max(base_points, key=lambda p: p[0])[0] + params['min_y'] = min(base_points, key=lambda p: p[1])[1] + params['max_y'] = max(base_points, key=lambda p: p[1])[1] x_cnt = params['x_count'] y_cnt = params['y_count'] substitutes = self.probe_mgr.get_substitutes() - base_points = self.probe_mgr.get_base_points() + probed_pts = positions if substitutes: # Replace substituted points with the original generated # point. Its Z Value is the average probed Z of the @@ -688,38 +689,42 @@ def probe_finalize(self, offsets, positions): % (i, fpt[0], fpt[1], avg_z, avg_z - z_offset)) corrected_pts.append(fpt) corrected_pts.extend(positions[start_idx:]) - # validate corrected positions - if len(base_points) != len(corrected_pts): - self._dump_points(positions, corrected_pts, offsets) - raise self.gcode.error( - "bed_mesh: invalid position list size, " - "generated count: %d, probed count: %d" - % (len(base_points), len(corrected_pts))) - for gen_pt, probed in zip(base_points, corrected_pts): - off_pt = [p - o for p, o in zip(gen_pt, offsets[:2])] - if not isclose(off_pt[0], probed[0], abs_tol=.1) or \ - not isclose(off_pt[1], probed[1], abs_tol=.1): - self._dump_points(positions, corrected_pts, offsets) - raise self.gcode.error( - "bed_mesh: point mismatch, orig = (%.2f, %.2f)" - ", probed = (%.2f, %.2f)" - % (off_pt[0], off_pt[1], probed[0], probed[1])) positions = corrected_pts + # validate length of result + if len(base_points) != len(positions): + self._dump_points(probed_pts, positions, offsets) + raise self.gcode.error( + "bed_mesh: invalid position list size, " + "generated count: %d, probed count: %d" + % (len(base_points), len(positions)) + ) + probed_matrix = [] row = [] - prev_pos = positions[0] - for pos in positions: + prev_pos = base_points[0] + for pos, result in zip(base_points, positions): + offset_pos = [p - o for p, o in zip(pos, offsets[:2])] + if ( + not isclose(offset_pos[0], result[0], abs_tol=.5) or + not isclose(offset_pos[1], result[1], abs_tol=.5) + ): + logging.info( + "bed_mesh: point deviation > .5mm: orig pt = (%.2f, %.2f)" + ", probed pt = (%.2f, %.2f)" + % (offset_pos[0], offset_pos[1], result[0], result[1]) + ) + z_pos = result[2] - z_offset if not isclose(pos[1], prev_pos[1], abs_tol=.1): # y has changed, append row and start new probed_matrix.append(row) row = [] if pos[0] > prev_pos[0]: # probed in the positive direction - row.append(pos[2] - z_offset) + row.append(z_pos) else: # probed in the negative direction - row.insert(0, pos[2] - z_offset) + row.insert(0, z_pos) prev_pos = pos # append last row probed_matrix.append(row) From a19af088945fdd33e49477413b90ee061c716e71 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Fri, 16 Feb 2024 06:13:05 -0500 Subject: [PATCH 06/14] bed_mesh: add support for MESH_PPS param in BMC In addition, do not respond with generated points. Signed-off-by: Eric Callahan --- klippy/extras/bed_mesh.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/klippy/extras/bed_mesh.py b/klippy/extras/bed_mesh.py index 9c44692c35f0..bcfd7c74ded1 100644 --- a/klippy/extras/bed_mesh.py +++ b/klippy/extras/bed_mesh.py @@ -594,6 +594,12 @@ def update_config(self, gcmd): self.mesh_config['y_count'] = y_cnt need_cfg_update = True + if "MESH_PPS" in params: + xpps, ypps = parse_gcmd_pair(gcmd, 'MESH_PPS', minval=0) + self.mesh_config['mesh_x_pps'] = xpps + self.mesh_config['mesh_y_pps'] = ypps + need_cfg_update = True + if "ALGORITHM" in params: self.mesh_config['algo'] = gcmd.get('ALGORITHM').strip().lower() need_cfg_update = True From a19d64febdbaa7d5ec0a0912bb65c6547c370ec3 Mon Sep 17 00:00:00 2001 From: Eric Callahan Date: Tue, 11 Jun 2024 14:31:39 -0400 Subject: [PATCH 07/14] docs: add rapid probing documentation Signed-off-by: Eric Callahan --- docs/API_Server.md | 127 ++++++++++++++++++ docs/Bed_Mesh.md | 270 ++++++++++++++++++++++++++++++++++++++- docs/Config_Reference.md | 7 + 3 files changed, 399 insertions(+), 5 deletions(-) diff --git a/docs/API_Server.md b/docs/API_Server.md index 4af1812a3a1d..cc0922e3ca81 100644 --- a/docs/API_Server.md +++ b/docs/API_Server.md @@ -401,3 +401,130 @@ might return: As with the "gcode/script" endpoint, this endpoint only completes after any pending G-Code commands complete. + +### bed_mesh/dump_mesh + +Dumps the configuration and state for the current mesh and all +saved profiles. + +For example: +`{"id": 123, "method": "bed_mesh/dump_mesh"}` + +might return: + +``` +{ + "current_mesh": { + "name": "eddy-scan-test", + "probed_matrix": [...], + "mesh_matrix": [...], + "mesh_params": { + "x_count": 9, + "y_count": 9, + "mesh_x_pps": 2, + "mesh_y_pps": 2, + "algo": "bicubic", + "tension": 0.5, + "min_x": 20, + "max_x": 330, + "min_y": 30, + "max_y": 320 + } + }, + "profiles": { + "default": { + "points": [...], + "mesh_params": { + "min_x": 20, + "max_x": 330, + "min_y": 30, + "max_y": 320, + "x_count": 9, + "y_count": 9, + "mesh_x_pps": 2, + "mesh_y_pps": 2, + "algo": "bicubic", + "tension": 0.5 + } + }, + "eddy-scan-test": { + "points": [...], + "mesh_params": { + "x_count": 9, + "y_count": 9, + "mesh_x_pps": 2, + "mesh_y_pps": 2, + "algo": "bicubic", + "tension": 0.5, + "min_x": 20, + "max_x": 330, + "min_y": 30, + "max_y": 320 + } + }, + "eddy-rapid-test": { + "points": [...], + "mesh_params": { + "x_count": 9, + "y_count": 9, + "mesh_x_pps": 2, + "mesh_y_pps": 2, + "algo": "bicubic", + "tension": 0.5, + "min_x": 20, + "max_x": 330, + "min_y": 30, + "max_y": 320 + } + } + }, + "calibration": { + "points": [...], + "config": { + "x_count": 9, + "y_count": 9, + "mesh_x_pps": 2, + "mesh_y_pps": 2, + "algo": "bicubic", + "tension": 0.5, + "mesh_min": [ + 20, + 30 + ], + "mesh_max": [ + 330, + 320 + ], + "origin": null, + "radius": null + }, + "probe_path": [...], + "rapid_path": [...] + }, + "probe_offsets": [ + 0, + 25, + 0.5 + ], + "axis_minimum": [ + 0, + 0, + -5, + 0 + ], + "axis_maximum": [ + 351, + 358, + 330, + 0 + ] +} +``` + +The `dump_mesh` endpoint takes one optional parameter, `mesh_args`. +This parameter must be an object, where the keys and values are +parameters available to [BED_MESH_CALIBRATE](#bed_mesh_calibrate). +This will update the mesh configuration and probe points using the +supplied parameters prior to returning the result. It is recommended +to omit mesh parameters unless it is desired to visualize the probe points +and/or travel path before performing `BED_MESH_CALIBRATE`. diff --git a/docs/Bed_Mesh.md b/docs/Bed_Mesh.md index 1538f6257210..62f1dee84ed7 100644 --- a/docs/Bed_Mesh.md +++ b/docs/Bed_Mesh.md @@ -421,12 +421,75 @@ have undesirable results when attempting print moves **outside** of the probed a full bed mesh has a variance greater than 1 layer height, caution must be taken when using adaptive bed meshes and attempting print moves outside of the meshed area. +## Surface Scans + +Some probes, such as the [Eddy Current Probe](./Eddy_Probe.md), are capable of +"scanning" the surface of the bed. That is, these probes can sample a mesh +without lifting the tool between samples. To activate scanning mode, the +`METHOD=scan` or `METHOD=rapid_scan` probe parameter should be passed in the +`BED_MESH_CALIBRATE` gcode command. + +### Scan Height + +The scan height is set by the `horizontal_move_z` option in `[bed_mesh]`. In +addition it can be supplied with the `BED_MESH_CALIBRATE` gcode command via the +`HORIZONTAL_MOVE_Z` parameter. + +The scan height must be sufficiently low to avoid scanning errors. Typically +a height of 2mm (ie: `HORIZONTAL_MOVE_Z=2`) should work well, presuming that the +probe is mounted correctly. + +It should be noted that if the probe is more than 4mm above the surface then the +results will be invalid. Thus, scanning is not possible on beds with severe +surface deviation or beds with extreme tilt that hasn't been corrected. + +### Rapid (Continuous) Scanning + +When performing a `rapid_scan` one should keep in mind that the results will +have some amount of error. This error should be low enough to be useful on +large print areas with reasonably thick layer heights. Some probes may be +more prone to error than others. + +It is not recommended that rapid mode be used to scan a "dense" mesh. Some of +the error introduced during a rapid scan may be gaussian noise from the sensor, +and a dense mesh will reflect this noise (ie: there will be peaks and valleys). + +Bed Mesh will attempt to optimize the travel path to provide the best possible +result based on the configuration. This includes avoiding faulty regions +when collecting samples and "overshooting" the mesh when changing direction. +This overshoot improves sampling at the edges of a mesh, however it requires +that the mesh be configured in a way that allows the tool to travel outside +of the mesh. + +``` +[bed_mesh] +speed: 120 +horizontal_move_z: 5 +mesh_min: 35, 6 +mesh_max: 240, 198 +probe_count: 5 +scan_overshoot: 8 +``` + +- `scan_overshoot` + _Default Value: 0 (disabled)_\ + The maximum amount of travel (in mm) available outside of the mesh. + For rectangular beds this applies to travel on the X axis, and for round beds + it applies to the entire radius. The tool must be able to travel the amount + specified outside of the mesh. This value is used to optimize the travel + path when performing a "rapid scan". The minimum value that may be specified + is 1. The default is no overshoot. + +If no scan overshoot is configured then travel path optimization will not +be applied to changes in direction. + ## Bed Mesh Gcodes ### Calibration -`BED_MESH_CALIBRATE PROFILE= METHOD=[manual | automatic] [=] - [=] [ADAPTIVE=[0|1] [ADAPTIVE_MARGIN=]`\ +`BED_MESH_CALIBRATE PROFILE= METHOD=[manual | automatic | scan | rapid_scan] \ +[=] [=] [ADAPTIVE=[0|1] \ +[ADAPTIVE_MARGIN=]`\ _Default Profile: default_\ _Default Method: automatic if a probe is detected, otherwise manual_ \ _Default Adaptive: 0_ \ @@ -435,9 +498,17 @@ _Default Adaptive Margin: 0_ Initiates the probing procedure for Bed Mesh Calibration. The mesh will be saved into a profile specified by the `PROFILE` parameter, -or `default` if unspecified. If `METHOD=manual` is selected then manual probing -will occur. When switching between automatic and manual probing the generated -mesh points will automatically be adjusted. +or `default` if unspecified. The `METHOD` parameter takes one of the following +values: + +- `METHOD=manual`: enables manual probing using the nozzle and the paper test +- `METHOD=automatic`: Automatic (standard) probing. This is the default. +- `METHOD=scan`: Enables surface scanning. The tool will pause over each position + to collect a sample. +- `METHOD=rapid_scan`: Enables continuous surface scanning. + +XY positions are automatically adjusted to include the X and/or Y offsets +when a probing method other than `manual` is selected. It is possible to specify mesh parameters to modify the probed area. The following parameters are available: @@ -451,6 +522,7 @@ following parameters are available: - `MESH_ORIGIN` - `ROUND_PROBE_COUNT` - All beds: + - `MESH_PPS` - `ALGORITHM` - `ADAPTIVE` - `ADAPTIVE_MARGIN` @@ -557,3 +629,191 @@ is intended to compensate for a `gcode offset` when [mesh fade](#mesh-fade) is enabled. For example, if a secondary extruder is higher than the primary and needs a negative gcode offset, ie: `SET_GCODE_OFFSET Z=-.2`, it can be accounted for in `bed_mesh` with `BED_MESH_OFFSET ZFADE=.2`. + +## Bed Mesh Webhooks APIs + +### Dumping mesh data + +`{"id": 123, "method": "bed_mesh/dump_mesh"}` + +Dumps the configuration and state for the current mesh and all +saved profiles. + +The `dump_mesh` endpoint takes one optional parameter, `mesh_args`. +This parameter must be an object, where the keys and values are +parameters available to [BED_MESH_CALIBRATE](#bed_mesh_calibrate). +This will update the mesh configuration and probe points using the +supplied parameters prior to returning the result. It is recommended +to omit mesh parameters unless it is desired to visualize the probe points +and/or travel path before performing `BED_MESH_CALIBRATE`. + +## Visualization and analysis + +Most users will likely find that the visualizers included with +applications such as Mainsail, Fluidd, and Octoprint are sufficient +for basic analysis. However, Klipper's `scripts` folder contains the +`graph_mesh.py` script that may be used to perform additional +visualizations and more detailed analysis, particularly useful +for debugging hardware or the results produced by `bed_mesh`: + +``` +usage: graph_mesh.py [-h] {list,plot,analyze,dump} ... + +Graph Bed Mesh Data + +positional arguments: + {list,plot,analyze,dump} + list List available plot types + plot Plot a specified type + analyze Perform analysis on mesh data + dump Dump API response to json file + +options: + -h, --help show this help message and exit +``` + +### Pre-requisites + +Like most graphing tools provided by Klipper, `graph_mesh.py` requires +the `matplotlib` and `numpy` python dependencies. In addition, connecting +to Klipper via Moonraker's websocket requires the `websockets` python +dependency. While all visualizations can be output to an `svg` file, most of +the visualizations offered by `graph_mesh.py` are better viewed in live +preview mode on a desktop class PC. For example, the 3D visualizations may be +rotated and zoomed in preview mode, and the path visualizations can optionally +be animated in preview mode. + +### Plotting Mesh data + +The `graph_mesh.py` tool can plot several types of visualizations. +Available types can be shown by running `graph_mesh.py list`: + +``` +graph_mesh.py list +points Plot original generated points +path Plot probe travel path +rapid Plot rapid scan travel path +probedz Plot probed Z values +meshz Plot mesh Z values +overlay Plots the current probed mesh overlaid with a profile +delta Plots the delta between current probed mesh and a profile +``` + +Several options are available when plotting visualizations: + +``` +usage: graph_mesh.py plot [-h] [-a] [-s] [-p PROFILE_NAME] [-o OUTPUT] + +positional arguments: + Type of data to graph + Path/url to Klipper Socket or path to json file + +options: + -h, --help show this help message and exit + -a, --animate Animate paths in live preview + -s, --scale-plot Use axis limits reported by Klipper to scale plot X/Y + -p PROFILE_NAME, --profile-name PROFILE_NAME + Optional name of a profile to plot for 'probedz' + -o OUTPUT, --output OUTPUT + Output file path +``` + +Below is a description of each argument: + +- `plot type`: A required positional argument designating the type of + visualization to generate. Must be one of the types output by the + `graph_mesh.py list` command. +- `input`: A required positional argument containing a path or url + to the input source. This must be one of the following: + - A path to Klipper's Unix Domain Socket + - A url to an instance of Moonraker + - A path to a json file produced by `graph_mesh.py dump ` +- `-a`: Optional animation for the `path` and `rapid` visualization types. + Animations only apply to a live preview. +- `-s`: Optionally scales a plot using the `axis_minimum` and `axis_maximum` + values reported by Klipper's `toolhead` object when the dump file was + generated. +- `-p`: A profile name that may be specified when generating the + `probedz` 3D mesh visualization. When generating an `overlay` or + `delta` visualization this argument must be provided. +- `-o`: An optional file path indicating that the script should save the + visualization to this location rather than run in preview mode. Images + are saved in `svg` format. + +For example, to plot an animated rapid path, connecting via Klipper's unix +socket: + +``` +graph_mesh.py plot -a rapid ~/printer_data/comms/klippy.sock +``` + +Or to plot a 3d visualization of the mesh, connecting via Moonraker: + +``` +graph_mesh.py plot meshz http://my-printer.local +``` + +### Bed Mesh Analysis + +The `graph_mesh.py` tool may also be used to perform an analysis on the +data provided by the [bed_mesh/dump_mesh](#dumping-mesh-data) API: + +``` +graph_mesh.py analyze +``` + +As with the `plot` command, the `` must be a path to Klipper's +unix socket, a URL to an instance of Moonraker, or a path to a json file +generated by the dump command. + +To begin, the analysis will perform various checks on the points and +probe paths generated by `bed_mesh` at the time of the dump. This +includes the following: + +- The number of probe points generated, without any additions +- The number of probe points generated including any points generated + as the result faulty regions and/or a configured zero reference position. +- The number of probe points generated when performing a rapid scan. +- The total number of moves generated for a rapid scan. +- A validation that the probe points generated for a rapid scan are + identical to the probe points generated for a standard probing procedure. +- A "backtracking" check for both the standard probe path and a rapid scan + path. Backtracking can be defined as moving to the same position more than + once during the probing procedure. Backtracking should never occur during a + standard probe. Faulty regions *can* result in backtracking during a rapid + scan in an attempt to avoid entering a faulty region when approaching or + leaving a probe location, however should never occur otherwise. + +Next each probed mesh present in the dump will by analyzed, beginning with +the mesh loaded at the time of the dump (if present) and followed by any +saved profiles. The following data is extracted: + +- Mesh shape (Min X,Y, Max X,Y Probe Count) +- Mesh Z range, (Minimum Z, Maximum Z) +- Mean Z value in the mesh +- Standard Deviation of the Z values in the Mesh + +In addition to the above, a delta analysis is performed between meshes +with the same shape, reporting the following: +- The range of the delta between to meshes (Minimum and Maximum) +- The mean delta +- Standard Deviation of the delta +- The absolute maximum difference +- The absolute mean + +### Save mesh data to a file + +The `dump` command may be used to save the response to a file which +can be shared for analysis when troubleshooting: + +``` +graph_mesh.py dump -o +``` + +The `` should be a path to Klipper's unix socket or +a URL to an instance of Moonraker. The `-o` option may be used to +specify the path to the output file. If omitted, the file will be +saved in the working directory, with a file name in the following +format: + +`klipper-bedmesh-{year}{month}{day}{hour}{minute}{second}.json` diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 6b42fe48da91..b192e7362c76 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -998,6 +998,13 @@ Visual Examples: #adaptive_margin: # An optional margin (in mm) to be added around the bed area used by # the defined print objects when generating an adaptive mesh. +#scan_overshoot: +# The maximum amount of travel (in mm) available outside of the mesh. +# For rectangular beds this applies to travel on the X axis, and for round beds +# it applies to the entire radius. The tool must be able to travel the amount +# specified outside of the mesh. This value is used to optimize the travel +# path when performing a "rapid scan". The minimum value that may be specified +# is 1. The default is no overshoot. ``` ### [bed_tilt] From 4ac283cc0e198ad64aad8f321e4f158065828397 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Sat, 15 Jun 2024 12:13:35 -0400 Subject: [PATCH 08/14] error_mcu: Move shutdown error message formatting to new error_mcu.py module Create a new module to help format verbose mcu error messages. Move the shutdown message formatting to this module. This moves the error formatting out of the background thread and out of the critical shutdown code path. Signed-off-by: Kevin O'Connor --- klippy/extras/error_mcu.py | 67 ++++++++++++++++++++++++++++++++++++++ klippy/klippy.py | 25 +++++++------- klippy/mcu.py | 41 ++++------------------- 3 files changed, 87 insertions(+), 46 deletions(-) create mode 100644 klippy/extras/error_mcu.py diff --git a/klippy/extras/error_mcu.py b/klippy/extras/error_mcu.py new file mode 100644 index 000000000000..ad737f9a78cf --- /dev/null +++ b/klippy/extras/error_mcu.py @@ -0,0 +1,67 @@ +# More verbose information on micro-controller errors +# +# Copyright (C) 2024 Kevin O'Connor +# +# This file may be distributed under the terms of the GNU GPLv3 license. +import logging + +message_shutdown = """ +Once the underlying issue is corrected, use the +"FIRMWARE_RESTART" command to reset the firmware, reload the +config, and restart the host software. +Printer is shutdown +""" + +Common_MCU_errors = { + ("Timer too close",): """ +This often indicates the host computer is overloaded. Check +for other processes consuming excessive CPU time, high swap +usage, disk errors, overheating, unstable voltage, or +similar system problems on the host computer.""", + ("Missed scheduling of next ",): """ +This is generally indicative of an intermittent +communication failure between micro-controller and host.""", + ("ADC out of range",): """ +This generally occurs when a heater temperature exceeds +its configured min_temp or max_temp.""", + ("Rescheduled timer in the past", "Stepper too far in past"): """ +This generally occurs when the micro-controller has been +requested to step at a rate higher than it is capable of +obtaining.""", + ("Command request",): """ +This generally occurs in response to an M112 G-Code command +or in response to an internal error in the host software.""", +} + +def error_hint(msg): + for prefixes, help_msg in Common_MCU_errors.items(): + for prefix in prefixes: + if msg.startswith(prefix): + return help_msg + return "" + +class PrinterMCUError: + def __init__(self, config): + self.printer = config.get_printer() + self.printer.register_event_handler("klippy:notify_mcu_shutdown", + self._handle_notify_mcu_shutdown) + def _check_mcu_shutdown(self, msg, details): + mcu_name = details['mcu'] + mcu_msg = details['reason'] + event_type = details['event_type'] + prefix = "MCU '%s' shutdown: " % (mcu_name,) + if event_type == 'is_shutdown': + prefix = "Previous MCU '%s' shutdown: " % (mcu_name,) + # Lookup generic hint + hint = error_hint(msg) + # Update error message + newmsg = "%s%s%s%s" % (prefix, mcu_msg, hint, message_shutdown) + self.printer.update_error_msg(msg, newmsg) + def _handle_notify_mcu_shutdown(self, msg, details): + if msg == "MCU shutdown": + self._check_mcu_shutdown(msg, details) + else: + self.printer.update_error_msg(msg, "%s%s" % (msg, message_shutdown)) + +def load_config(config): + return PrinterMCUError(config) diff --git a/klippy/klippy.py b/klippy/klippy.py index 097cff998c22..5574063dd34f 100644 --- a/klippy/klippy.py +++ b/klippy/klippy.py @@ -1,7 +1,7 @@ #!/usr/bin/env python2 # Main code for host side printer firmware # -# Copyright (C) 2016-2020 Kevin O'Connor +# Copyright (C) 2016-2024 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. import sys, os, gc, optparse, logging, time, collections, importlib @@ -40,13 +40,6 @@ Error configuring printer """ -message_shutdown = """ -Once the underlying issue is corrected, use the -"FIRMWARE_RESTART" command to reset the firmware, reload the -config, and restart the host software. -Printer is shutdown -""" - class Printer: config_error = configfile.error command_error = gcode.CommandError @@ -85,6 +78,13 @@ def _set_state(self, msg): if (msg != message_ready and self.start_args.get('debuginput') is not None): self.request_exit('error_exit') + def update_error_msg(self, oldmsg, newmsg): + if (self.state_message != oldmsg + or self.state_message in (message_ready, message_startup) + or newmsg in (message_ready, message_startup)): + return + self.state_message = newmsg + logging.error(newmsg) def add_object(self, name, obj): if name in self.objects: raise self.config_error( @@ -241,12 +241,12 @@ def set_rollover_info(self, name, info, log=True): logging.info(info) if self.bglogger is not None: self.bglogger.set_rollover_info(name, info) - def invoke_shutdown(self, msg): + def invoke_shutdown(self, msg, details={}): if self.in_shutdown_state: return logging.error("Transition to shutdown state: %s", msg) self.in_shutdown_state = True - self._set_state("%s%s" % (msg, message_shutdown)) + self._set_state(msg) for cb in self.event_handlers.get("klippy:shutdown", []): try: cb() @@ -254,9 +254,10 @@ def invoke_shutdown(self, msg): logging.exception("Exception during shutdown handler") logging.info("Reactor garbage collection: %s", self.reactor.get_gc_stats()) - def invoke_async_shutdown(self, msg): + self.send_event("klippy:notify_mcu_shutdown", msg, details) + def invoke_async_shutdown(self, msg, details): self.reactor.register_async_callback( - (lambda e: self.invoke_shutdown(msg))) + (lambda e: self.invoke_shutdown(msg, details))) def register_event_handler(self, event, callback): self.event_handlers.setdefault(event, []).append(callback) def send_event(self, event, *params): diff --git a/klippy/mcu.py b/klippy/mcu.py index 23ba07173257..feb4856a1d22 100644 --- a/klippy/mcu.py +++ b/klippy/mcu.py @@ -1,6 +1,6 @@ # Interface to Klipper micro-controller code # -# Copyright (C) 2016-2023 Kevin O'Connor +# Copyright (C) 2016-2024 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. import sys, os, zlib, logging, math @@ -605,6 +605,7 @@ def __init__(self, config, clocksync): self._mcu_tick_stddev = 0. self._mcu_tick_awake = 0. # Register handlers + printer.load_object(config, "error_mcu") printer.register_event_handler("klippy:firmware_restart", self._firmware_restart) printer.register_event_handler("klippy:mcu_identify", @@ -631,13 +632,13 @@ def _handle_shutdown(self, params): if clock is not None: self._shutdown_clock = self.clock32_to_clock64(clock) self._shutdown_msg = msg = params['static_string_id'] - logging.info("MCU '%s' %s: %s\n%s\n%s", self._name, params['#name'], + event_type = params['#name'] + self._printer.invoke_async_shutdown( + "MCU shutdown", {"reason": msg, "mcu": self._name, + "event_type": event_type}) + logging.info("MCU '%s' %s: %s\n%s\n%s", self._name, event_type, self._shutdown_msg, self._clocksync.dump_debug(), self._serial.dump_debug()) - prefix = "MCU '%s' shutdown: " % (self._name,) - if params['#name'] == 'is_shutdown': - prefix = "Previous MCU '%s' shutdown: " % (self._name,) - self._printer.invoke_async_shutdown(prefix + msg + error_help(msg)) def _handle_starting(self, params): if not self._is_shutdown: self._printer.invoke_async_shutdown("MCU '%s' spontaneous restart" @@ -1008,34 +1009,6 @@ def stats(self, eventtime): self._get_status_info['last_stats'] = last_stats return False, '%s: %s' % (self._name, stats) -Common_MCU_errors = { - ("Timer too close",): """ -This often indicates the host computer is overloaded. Check -for other processes consuming excessive CPU time, high swap -usage, disk errors, overheating, unstable voltage, or -similar system problems on the host computer.""", - ("Missed scheduling of next ",): """ -This is generally indicative of an intermittent -communication failure between micro-controller and host.""", - ("ADC out of range",): """ -This generally occurs when a heater temperature exceeds -its configured min_temp or max_temp.""", - ("Rescheduled timer in the past", "Stepper too far in past"): """ -This generally occurs when the micro-controller has been -requested to step at a rate higher than it is capable of -obtaining.""", - ("Command request",): """ -This generally occurs in response to an M112 G-Code command -or in response to an internal error in the host software.""", -} - -def error_help(msg): - for prefixes, help_msg in Common_MCU_errors.items(): - for prefix in prefixes: - if msg.startswith(prefix): - return help_msg - return "" - def add_printer_objects(config): printer = config.get_printer() reactor = printer.get_reactor() From ba529996ea18edf87de0bae41d8c458ffc07b889 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Sat, 15 Jun 2024 12:27:36 -0400 Subject: [PATCH 09/14] error_mcu: Move mcu protocol error reporting to error_mcu module Signed-off-by: Kevin O'Connor --- klippy/extras/error_mcu.py | 43 +++++++++++++++++++++++++++++++++++++ klippy/klippy.py | 44 ++++---------------------------------- 2 files changed, 47 insertions(+), 40 deletions(-) diff --git a/klippy/extras/error_mcu.py b/klippy/extras/error_mcu.py index ad737f9a78cf..339eb73133d5 100644 --- a/klippy/extras/error_mcu.py +++ b/klippy/extras/error_mcu.py @@ -12,6 +12,17 @@ Printer is shutdown """ +message_protocol_error1 = """ +This is frequently caused by running an older version of the +firmware on the MCU(s). Fix by recompiling and flashing the +firmware. +""" + +message_protocol_error2 = """ +Once the underlying issue is corrected, use the "RESTART" +command to reload the config and restart the host software. +""" + Common_MCU_errors = { ("Timer too close",): """ This often indicates the host computer is overloaded. Check @@ -45,6 +56,8 @@ def __init__(self, config): self.printer = config.get_printer() self.printer.register_event_handler("klippy:notify_mcu_shutdown", self._handle_notify_mcu_shutdown) + self.printer.register_event_handler("klippy:notify_mcu_error", + self._handle_notify_mcu_error) def _check_mcu_shutdown(self, msg, details): mcu_name = details['mcu'] mcu_msg = details['reason'] @@ -62,6 +75,36 @@ def _handle_notify_mcu_shutdown(self, msg, details): self._check_mcu_shutdown(msg, details) else: self.printer.update_error_msg(msg, "%s%s" % (msg, message_shutdown)) + def _check_protocol_error(self, msg, details): + host_version = self.printer.start_args['software_version'] + msg_update = [] + msg_updated = [] + for mcu_name, mcu in self.printer.lookup_objects('mcu'): + try: + mcu_version = mcu.get_status()['mcu_version'] + except: + logging.exception("Unable to retrieve mcu_version from mcu") + continue + if mcu_version != host_version: + msg_update.append("%s: Current version %s" + % (mcu_name.split()[-1], mcu_version)) + else: + msg_updated.append("%s: Current version %s" + % (mcu_name.split()[-1], mcu_version)) + if not msg_update: + msg_update.append("") + if not msg_updated: + msg_updated.append("") + newmsg = ["MCU Protocol error", + message_protocol_error1, + "Your Klipper version is: %s" % (host_version,), + "MCU(s) which should be updated:"] + newmsg += msg_update + ["Up-to-date MCU(s):"] + msg_updated + newmsg += [message_protocol_error2, details['error']] + self.printer.update_error_msg(msg, "\n".join(newmsg)) + def _handle_notify_mcu_error(self, msg, details): + if msg == "Protocol error": + self._check_protocol_error(msg, details) def load_config(config): return PrinterMCUError(config) diff --git a/klippy/klippy.py b/klippy/klippy.py index 5574063dd34f..17609b37e522 100644 --- a/klippy/klippy.py +++ b/klippy/klippy.py @@ -22,17 +22,6 @@ Printer is halted """ -message_protocol_error1 = """ -This is frequently caused by running an older version of the -firmware on the MCU(s). Fix by recompiling and flashing the -firmware. -""" - -message_protocol_error2 = """ -Once the underlying issue is corrected, use the "RESTART" -command to reload the config and restart the host software. -""" - message_mcu_connect_error = """ Once the underlying issue is corrected, use the "FIRMWARE_RESTART" command to reset the firmware, reload the @@ -143,33 +132,6 @@ def _read_config(self): m.add_printer_objects(config) # Validate that there are no undefined parameters in the config file pconfig.check_unused_options(config) - def _build_protocol_error_message(self, e): - host_version = self.start_args['software_version'] - msg_update = [] - msg_updated = [] - for mcu_name, mcu in self.lookup_objects('mcu'): - try: - mcu_version = mcu.get_status()['mcu_version'] - except: - logging.exception("Unable to retrieve mcu_version from mcu") - continue - if mcu_version != host_version: - msg_update.append("%s: Current version %s" - % (mcu_name.split()[-1], mcu_version)) - else: - msg_updated.append("%s: Current version %s" - % (mcu_name.split()[-1], mcu_version)) - if not msg_update: - msg_update.append("") - if not msg_updated: - msg_updated.append("") - msg = ["MCU Protocol error", - message_protocol_error1, - "Your Klipper version is: %s" % (host_version,), - "MCU(s) which should be updated:"] - msg += msg_update + ["Up-to-date MCU(s):"] + msg_updated - msg += [message_protocol_error2, str(e)] - return "\n".join(msg) def _connect(self, eventtime): try: self._read_config() @@ -183,8 +145,10 @@ def _connect(self, eventtime): self._set_state("%s\n%s" % (str(e), message_restart)) return except msgproto.error as e: - logging.exception("Protocol error") - self._set_state(self._build_protocol_error_message(e)) + msg = "Protocol error" + logging.exception(msg) + self._set_state(msg) + self.send_event("klippy:notify_mcu_error", msg, {"error": str(e)}) util.dump_mcu_build() return except mcu.error as e: From 7149bb1b6de2616f48b40f060ad8c887cb6a84d2 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Sat, 15 Jun 2024 12:34:29 -0400 Subject: [PATCH 10/14] error_mcu: Move formatting of mcu connect errors to error_mcu module Signed-off-by: Kevin O'Connor --- klippy/extras/error_mcu.py | 12 ++++++++++++ klippy/klippy.py | 13 ++++--------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/klippy/extras/error_mcu.py b/klippy/extras/error_mcu.py index 339eb73133d5..536debbb06e9 100644 --- a/klippy/extras/error_mcu.py +++ b/klippy/extras/error_mcu.py @@ -23,6 +23,13 @@ command to reload the config and restart the host software. """ +message_mcu_connect_error = """ +Once the underlying issue is corrected, use the +"FIRMWARE_RESTART" command to reset the firmware, reload the +config, and restart the host software. +Error configuring printer +""" + Common_MCU_errors = { ("Timer too close",): """ This often indicates the host computer is overloaded. Check @@ -102,9 +109,14 @@ def _check_protocol_error(self, msg, details): newmsg += msg_update + ["Up-to-date MCU(s):"] + msg_updated newmsg += [message_protocol_error2, details['error']] self.printer.update_error_msg(msg, "\n".join(newmsg)) + def _check_mcu_connect_error(self, msg, details): + newmsg = "%s%s" % (details['error'], message_mcu_connect_error) + self.printer.update_error_msg(msg, newmsg) def _handle_notify_mcu_error(self, msg, details): if msg == "Protocol error": self._check_protocol_error(msg, details) + elif msg == "MCU error during connect": + self._check_mcu_connect_error(msg, details) def load_config(config): return PrinterMCUError(config) diff --git a/klippy/klippy.py b/klippy/klippy.py index 17609b37e522..75ee6887ad71 100644 --- a/klippy/klippy.py +++ b/klippy/klippy.py @@ -22,13 +22,6 @@ Printer is halted """ -message_mcu_connect_error = """ -Once the underlying issue is corrected, use the -"FIRMWARE_RESTART" command to reset the firmware, reload the -config, and restart the host software. -Error configuring printer -""" - class Printer: config_error = configfile.error command_error = gcode.CommandError @@ -152,8 +145,10 @@ def _connect(self, eventtime): util.dump_mcu_build() return except mcu.error as e: - logging.exception("MCU error during connect") - self._set_state("%s%s" % (str(e), message_mcu_connect_error)) + msg = "MCU error during connect" + logging.exception(msg) + self._set_state(msg) + self.send_event("klippy:notify_mcu_error", msg, {"error": str(e)}) util.dump_mcu_build() return except Exception as e: From 9fa0fb1a0ebca3ed4be887417b255b26fc99bbfd Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 18 Jun 2024 12:51:32 -0400 Subject: [PATCH 11/14] error_mcu: Support mechanism to add per-instance context to a shutdown Signed-off-by: Kevin O'Connor --- klippy/extras/error_mcu.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/klippy/extras/error_mcu.py b/klippy/extras/error_mcu.py index 536debbb06e9..dc91c33a92c7 100644 --- a/klippy/extras/error_mcu.py +++ b/klippy/extras/error_mcu.py @@ -61,10 +61,13 @@ def error_hint(msg): class PrinterMCUError: def __init__(self, config): self.printer = config.get_printer() + self.clarify_callbacks = {} self.printer.register_event_handler("klippy:notify_mcu_shutdown", self._handle_notify_mcu_shutdown) self.printer.register_event_handler("klippy:notify_mcu_error", self._handle_notify_mcu_error) + def add_clarify(self, msg, callback): + self.clarify_callbacks.setdefault(msg, []).append(callback) def _check_mcu_shutdown(self, msg, details): mcu_name = details['mcu'] mcu_msg = details['reason'] @@ -73,9 +76,17 @@ def _check_mcu_shutdown(self, msg, details): if event_type == 'is_shutdown': prefix = "Previous MCU '%s' shutdown: " % (mcu_name,) # Lookup generic hint - hint = error_hint(msg) + hint = error_hint(mcu_msg) + # Add per instance help + clarify = [cb(msg, details) + for cb in self.clarify_callbacks.get(mcu_msg, [])] + clarify = [cm for cm in clarify if cm is not None] + clarify_msg = "" + if clarify: + clarify_msg = "\n".join(["", ""] + clarify + [""]) # Update error message - newmsg = "%s%s%s%s" % (prefix, mcu_msg, hint, message_shutdown) + newmsg = "%s%s%s%s%s" % (prefix, mcu_msg, clarify_msg, + hint, message_shutdown) self.printer.update_error_msg(msg, newmsg) def _handle_notify_mcu_shutdown(self, msg, details): if msg == "MCU shutdown": From d89722056bf58103dc7fc06bc310ac39afa6aaa0 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 18 Jun 2024 13:01:34 -0400 Subject: [PATCH 12/14] mcu: Rename setup_minmax() to setup_adc_sample() Rename this method so that it is more distinct from the the common temperature setup_minmax() method. Signed-off-by: Kevin O'Connor --- klippy/extras/adc_scaled.py | 6 ++--- klippy/extras/adc_temperature.py | 8 +++---- klippy/extras/buttons.py | 2 +- klippy/extras/hall_filament_width_sensor.py | 4 ++-- klippy/extras/temperature_mcu.py | 23 ++++++++++--------- .../extras/tsl1401cl_filament_width_sensor.py | 2 +- klippy/mcu.py | 4 ++-- 7 files changed, 24 insertions(+), 25 deletions(-) diff --git a/klippy/extras/adc_scaled.py b/klippy/extras/adc_scaled.py index c2d2cb877f78..80ea452f3109 100644 --- a/klippy/extras/adc_scaled.py +++ b/klippy/extras/adc_scaled.py @@ -7,7 +7,6 @@ SAMPLE_TIME = 0.001 SAMPLE_COUNT = 8 REPORT_TIME = 0.300 -RANGE_CHECK_COUNT = 4 class MCU_scaled_adc: def __init__(self, main, pin_params): @@ -18,7 +17,7 @@ def __init__(self, main, pin_params): qname = main.name + ":" + pin_params['pin'] query_adc.register_adc(qname, self._mcu_adc) self._callback = None - self.setup_minmax = self._mcu_adc.setup_minmax + self.setup_adc_sample = self._mcu_adc.setup_adc_sample self.get_mcu = self._mcu_adc.get_mcu def _handle_callback(self, read_time, read_value): max_adc = self._main.last_vref[1] @@ -54,8 +53,7 @@ def _config_pin(self, config, name, callback): ppins = self.printer.lookup_object('pins') mcu_adc = ppins.setup_pin('adc', pin_name) mcu_adc.setup_adc_callback(REPORT_TIME, callback) - mcu_adc.setup_minmax(SAMPLE_TIME, SAMPLE_COUNT, minval=0., maxval=1., - range_check_count=RANGE_CHECK_COUNT) + mcu_adc.setup_adc_sample(SAMPLE_TIME, SAMPLE_COUNT) query_adc = config.get_printer().load_object(config, 'query_adc') query_adc.register_adc(self.name + ":" + name, mcu_adc) return mcu_adc diff --git a/klippy/extras/adc_temperature.py b/klippy/extras/adc_temperature.py index b76e8c66fa85..260fe2817529 100644 --- a/klippy/extras/adc_temperature.py +++ b/klippy/extras/adc_temperature.py @@ -32,10 +32,10 @@ def adc_callback(self, read_time, read_value): temp = self.adc_convert.calc_temp(read_value) self.temperature_callback(read_time + SAMPLE_COUNT * SAMPLE_TIME, temp) def setup_minmax(self, min_temp, max_temp): - adc_range = [self.adc_convert.calc_adc(t) for t in [min_temp, max_temp]] - self.mcu_adc.setup_minmax(SAMPLE_TIME, SAMPLE_COUNT, - minval=min(adc_range), maxval=max(adc_range), - range_check_count=RANGE_CHECK_COUNT) + arange = [self.adc_convert.calc_adc(t) for t in [min_temp, max_temp]] + self.mcu_adc.setup_adc_sample(SAMPLE_TIME, SAMPLE_COUNT, + minval=min(arange), maxval=max(arange), + range_check_count=RANGE_CHECK_COUNT) ###################################################################### diff --git a/klippy/extras/buttons.py b/klippy/extras/buttons.py index 70d76a60e175..daa998a93dcd 100644 --- a/klippy/extras/buttons.py +++ b/klippy/extras/buttons.py @@ -104,7 +104,7 @@ def __init__(self, printer, pin, pullup): self.max_value = 0. ppins = printer.lookup_object('pins') self.mcu_adc = ppins.setup_pin('adc', self.pin) - self.mcu_adc.setup_minmax(ADC_SAMPLE_TIME, ADC_SAMPLE_COUNT) + self.mcu_adc.setup_adc_sample(ADC_SAMPLE_TIME, ADC_SAMPLE_COUNT) self.mcu_adc.setup_adc_callback(ADC_REPORT_TIME, self.adc_callback) query_adc = printer.lookup_object('query_adc') query_adc.register_adc('adc_button:' + pin.strip(), self.mcu_adc) diff --git a/klippy/extras/hall_filament_width_sensor.py b/klippy/extras/hall_filament_width_sensor.py index e080288741d7..8dab35226666 100644 --- a/klippy/extras/hall_filament_width_sensor.py +++ b/klippy/extras/hall_filament_width_sensor.py @@ -49,10 +49,10 @@ def __init__(self, config): # Start adc self.ppins = self.printer.lookup_object('pins') self.mcu_adc = self.ppins.setup_pin('adc', self.pin1) - self.mcu_adc.setup_minmax(ADC_SAMPLE_TIME, ADC_SAMPLE_COUNT) + self.mcu_adc.setup_adc_sample(ADC_SAMPLE_TIME, ADC_SAMPLE_COUNT) self.mcu_adc.setup_adc_callback(ADC_REPORT_TIME, self.adc_callback) self.mcu_adc2 = self.ppins.setup_pin('adc', self.pin2) - self.mcu_adc2.setup_minmax(ADC_SAMPLE_TIME, ADC_SAMPLE_COUNT) + self.mcu_adc2.setup_adc_sample(ADC_SAMPLE_TIME, ADC_SAMPLE_COUNT) self.mcu_adc2.setup_adc_callback(ADC_REPORT_TIME, self.adc2_callback) # extrude factor updating self.extrude_factor_update_timer = self.reactor.register_timer( diff --git a/klippy/extras/temperature_mcu.py b/klippy/extras/temperature_mcu.py index 585ec4c1d20d..02d91a3a459b 100644 --- a/klippy/extras/temperature_mcu.py +++ b/klippy/extras/temperature_mcu.py @@ -35,26 +35,27 @@ def __init__(self, config): query_adc.register_adc(config.get_name(), self.mcu_adc) # Register callbacks if self.printer.get_start_args().get('debugoutput') is not None: - self.mcu_adc.setup_minmax(SAMPLE_TIME, SAMPLE_COUNT, - range_check_count=RANGE_CHECK_COUNT) + self.mcu_adc.setup_adc_sample(SAMPLE_TIME, SAMPLE_COUNT) return self.printer.register_event_handler("klippy:mcu_identify", - self._mcu_identify) + self.handle_mcu_identify) + # Temperature interface def setup_callback(self, temperature_callback): self.temperature_callback = temperature_callback def get_report_time_delta(self): return REPORT_TIME - def adc_callback(self, read_time, read_value): - temp = self.base_temperature + read_value * self.slope - self.temperature_callback(read_time + SAMPLE_COUNT * SAMPLE_TIME, temp) def setup_minmax(self, min_temp, max_temp): self.min_temp = min_temp self.max_temp = max_temp + # Internal code + def adc_callback(self, read_time, read_value): + temp = self.base_temperature + read_value * self.slope + self.temperature_callback(read_time + SAMPLE_COUNT * SAMPLE_TIME, temp) def calc_adc(self, temp): return (temp - self.base_temperature) / self.slope def calc_base(self, temp, adc): return temp - adc * self.slope - def _mcu_identify(self): + def handle_mcu_identify(self): # Obtain mcu information mcu = self.mcu_adc.get_mcu() self.debug_read_cmd = mcu.lookup_query_command( @@ -89,10 +90,10 @@ def _mcu_identify(self): self.slope = (self.temp2 - self.temp1) / (self.adc2 - self.adc1) self.base_temperature = self.calc_base(self.temp1, self.adc1) # Setup min/max checks - adc_range = [self.calc_adc(t) for t in [self.min_temp, self.max_temp]] - self.mcu_adc.setup_minmax(SAMPLE_TIME, SAMPLE_COUNT, - minval=min(adc_range), maxval=max(adc_range), - range_check_count=RANGE_CHECK_COUNT) + arange = [self.calc_adc(t) for t in [self.min_temp, self.max_temp]] + self.mcu_adc.setup_adc_sample(SAMPLE_TIME, SAMPLE_COUNT, + minval=min(arange), maxval=max(arange), + range_check_count=RANGE_CHECK_COUNT) def config_unknown(self): raise self.printer.config_error("MCU temperature not supported on %s" % (self.mcu_type,)) diff --git a/klippy/extras/tsl1401cl_filament_width_sensor.py b/klippy/extras/tsl1401cl_filament_width_sensor.py index fb2d97131130..83480f46714a 100644 --- a/klippy/extras/tsl1401cl_filament_width_sensor.py +++ b/klippy/extras/tsl1401cl_filament_width_sensor.py @@ -33,7 +33,7 @@ def __init__(self, config): # Start adc self.ppins = self.printer.lookup_object('pins') self.mcu_adc = self.ppins.setup_pin('adc', self.pin) - self.mcu_adc.setup_minmax(ADC_SAMPLE_TIME, ADC_SAMPLE_COUNT) + self.mcu_adc.setup_adc_sample(ADC_SAMPLE_TIME, ADC_SAMPLE_COUNT) self.mcu_adc.setup_adc_callback(ADC_REPORT_TIME, self.adc_callback) # extrude factor updating self.extrude_factor_update_timer = self.reactor.register_timer( diff --git a/klippy/mcu.py b/klippy/mcu.py index feb4856a1d22..1122ff865845 100644 --- a/klippy/mcu.py +++ b/klippy/mcu.py @@ -496,8 +496,8 @@ def __init__(self, mcu, pin_params): self._inv_max_adc = 0. def get_mcu(self): return self._mcu - def setup_minmax(self, sample_time, sample_count, - minval=0., maxval=1., range_check_count=0): + def setup_adc_sample(self, sample_time, sample_count, + minval=0., maxval=1., range_check_count=0): self._sample_time = sample_time self._sample_count = sample_count self._min_sample = minval From 2d73211190e90f4dac0b8585fa931e8d46d4970e Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 18 Jun 2024 13:18:06 -0400 Subject: [PATCH 13/14] adc_temperature: Enhance "ADC out of range" error reports Try to report which ADC is reporting out of range. Signed-off-by: Kevin O'Connor --- klippy/extras/adc_temperature.py | 42 +++++++++++++++++++++++++++++--- 1 file changed, 38 insertions(+), 4 deletions(-) diff --git a/klippy/extras/adc_temperature.py b/klippy/extras/adc_temperature.py index 260fe2817529..c53ae7056adf 100644 --- a/klippy/extras/adc_temperature.py +++ b/klippy/extras/adc_temperature.py @@ -1,6 +1,6 @@ # Obtain temperature using linear interpolation of ADC values # -# Copyright (C) 2016-2018 Kevin O'Connor +# Copyright (C) 2016-2024 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. import logging, bisect @@ -22,8 +22,8 @@ def __init__(self, config, adc_convert): ppins = config.get_printer().lookup_object('pins') self.mcu_adc = ppins.setup_pin('adc', config.get('sensor_pin')) self.mcu_adc.setup_adc_callback(REPORT_TIME, self.adc_callback) - query_adc = config.get_printer().load_object(config, 'query_adc') - query_adc.register_adc(config.get_name(), self.mcu_adc) + self.diag_helper = HelperTemperatureDiagnostics( + config, self.mcu_adc, adc_convert.calc_temp) def setup_callback(self, temperature_callback): self.temperature_callback = temperature_callback def get_report_time_delta(self): @@ -33,9 +33,43 @@ def adc_callback(self, read_time, read_value): self.temperature_callback(read_time + SAMPLE_COUNT * SAMPLE_TIME, temp) def setup_minmax(self, min_temp, max_temp): arange = [self.adc_convert.calc_adc(t) for t in [min_temp, max_temp]] + min_adc, max_adc = sorted(arange) self.mcu_adc.setup_adc_sample(SAMPLE_TIME, SAMPLE_COUNT, - minval=min(arange), maxval=max(arange), + minval=min_adc, maxval=max_adc, range_check_count=RANGE_CHECK_COUNT) + self.diag_helper.setup_diag_minmax(min_temp, max_temp, min_adc, max_adc) + +# Tool to register with query_adc and report extra info on ADC range errors +class HelperTemperatureDiagnostics: + def __init__(self, config, mcu_adc, calc_temp_cb): + self.printer = config.get_printer() + self.name = config.get_name() + self.mcu_adc = mcu_adc + self.calc_temp_cb = calc_temp_cb + self.min_temp = self.max_temp = self.min_adc = self.max_adc = None + query_adc = self.printer.load_object(config, 'query_adc') + query_adc.register_adc(self.name, self.mcu_adc) + error_mcu = self.printer.load_object(config, 'error_mcu') + error_mcu.add_clarify("ADC out of range", self._clarify_adc_range) + def setup_diag_minmax(self, min_temp, max_temp, min_adc, max_adc): + self.min_temp, self.max_temp = min_temp, max_temp + self.min_adc, self.max_adc = min_adc, max_adc + def _clarify_adc_range(self, msg, details): + if self.min_temp is None: + return None + last_value, last_read_time = self.mcu_adc.get_last_value() + if not last_read_time: + return None + if last_value >= self.min_adc and last_value <= self.max_adc: + return None + tempstr = "?" + try: + last_temp = self.calc_temp_cb(last_value) + tempstr = "%.3f" % (last_temp,) + except e: + logging.exception("Error in calc_temp callback") + return ("Sensor '%s' temperature %s not in range %.3f:%.3f" + % (self.name, tempstr, self.min_temp, self.max_temp)) ###################################################################### From 6d70050261ec3290f3c2e4015438e4910fd430d0 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 18 Jun 2024 13:22:48 -0400 Subject: [PATCH 14/14] temperature_mcu: Enhance "ADC out of range" error reports Try to report which ADC is reporting out of range. Signed-off-by: Kevin O'Connor --- klippy/extras/temperature_mcu.py | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/klippy/extras/temperature_mcu.py b/klippy/extras/temperature_mcu.py index 02d91a3a459b..be2cd145c455 100644 --- a/klippy/extras/temperature_mcu.py +++ b/klippy/extras/temperature_mcu.py @@ -1,10 +1,11 @@ # Support for micro-controller chip based temperature sensors # -# Copyright (C) 2020 Kevin O'Connor +# Copyright (C) 2020-2024 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. import logging import mcu +from . import adc_temperature SAMPLE_TIME = 0.001 SAMPLE_COUNT = 8 @@ -31,8 +32,8 @@ def __init__(self, config): self.mcu_adc = ppins.setup_pin('adc', '%s:ADC_TEMPERATURE' % (mcu_name,)) self.mcu_adc.setup_adc_callback(REPORT_TIME, self.adc_callback) - query_adc = config.get_printer().load_object(config, 'query_adc') - query_adc.register_adc(config.get_name(), self.mcu_adc) + self.diag_helper = adc_temperature.HelperTemperatureDiagnostics( + config, self.mcu_adc, self.calc_temp) # Register callbacks if self.printer.get_start_args().get('debugoutput') is not None: self.mcu_adc.setup_adc_sample(SAMPLE_TIME, SAMPLE_COUNT) @@ -51,6 +52,8 @@ def setup_minmax(self, min_temp, max_temp): def adc_callback(self, read_time, read_value): temp = self.base_temperature + read_value * self.slope self.temperature_callback(read_time + SAMPLE_COUNT * SAMPLE_TIME, temp) + def calc_temp(self, adc): + return self.base_temperature + adc * self.slope def calc_adc(self, temp): return (temp - self.base_temperature) / self.slope def calc_base(self, temp, adc): @@ -91,9 +94,12 @@ def handle_mcu_identify(self): self.base_temperature = self.calc_base(self.temp1, self.adc1) # Setup min/max checks arange = [self.calc_adc(t) for t in [self.min_temp, self.max_temp]] + min_adc, max_adc = sorted(arange) self.mcu_adc.setup_adc_sample(SAMPLE_TIME, SAMPLE_COUNT, - minval=min(arange), maxval=max(arange), + minval=min_adc, maxval=max_adc, range_check_count=RANGE_CHECK_COUNT) + self.diag_helper.setup_diag_minmax(self.min_temp, self.max_temp, + min_adc, max_adc) def config_unknown(self): raise self.printer.config_error("MCU temperature not supported on %s" % (self.mcu_type,))