diff --git a/README.md b/README.md index 015f02374..09cfeb1e7 100644 --- a/README.md +++ b/README.md @@ -144,6 +144,8 @@ See the [Kalico Additions document](https://docs.kalico.gg/Kalico_Additions.html - [extruder: cold_extrude](https://github.com/KalicoCrew/kalico/pull/750) +- [core: automatic mesh bounds and home position](https://github.com/KalicoCrew/kalico/pull/879) + If you're feeling adventurous, take a peek at the extra features in the bleeding-edge-v2 branch [feature documentation](docs/Bleeding_Edge.md) and [feature configuration reference](docs/Config_Reference_Bleeding_Edge.md): diff --git a/docs/Axis_Twist_Compensation.md b/docs/Axis_Twist_Compensation.md index bef7542f5..b13a14e8a 100644 --- a/docs/Axis_Twist_Compensation.md +++ b/docs/Axis_Twist_Compensation.md @@ -40,6 +40,10 @@ This command will calibrate the X-axis by default. SAMPLE_COUNT= `` +If `bed_size` and `bed_corner_position` are defined in the `[printer]` +section, the calibration points will be automatically calculated to cover +the largest reachable area of the bed. + 2. **Adjust Your Z Offset:** After completing the calibration, be sure to [adjust your Z offset](Probe_Calibrate.md#calibrating-probe-z-offset). diff --git a/docs/Bed_Mesh.md b/docs/Bed_Mesh.md index 4bd297fd0..72a720777 100644 --- a/docs/Bed_Mesh.md +++ b/docs/Bed_Mesh.md @@ -57,6 +57,24 @@ probe_count: 5, 3 as a single integer value that is used for both axes, ie `probe_count: 3`. Note that a mesh requires a minimum probe_count of 3 along each axis. +### Automatic mesh bounds + +If `bed_size` and `bed_corner_position` are defined in the `[printer]` +section, then `mesh_min` and `mesh_max` can be automatically calculated. +The calculated mesh will be the largest reachable rectangular area on the +bed, respecting the probe's offsets and any configured edge distance. +The `bed_corner_position` (as a nozzle coordinate) can be set to the true +physical location of the bed, even if that point is unreachable by the +nozzle or probe (i.e. outside the axis limits). Kalico will automatically +adjust the mesh to stay within the printer's reachable area. + +Note that if `mesh_min` or `mesh_max` are explicitly defined in the +`[bed_mesh]` section, those values will take precedence over the +automatically calculated ones. + +Automatic calculation is currently only supported for rectangular +kinematics systems. + The illustration below demonstrates how the `mesh_min`, `mesh_max`, and `probe_count` options are used to generate probe points. The arrows indicate the direction of the probing procedure, beginning at `mesh_min`. For reference, diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 96840a015..ed10bb39c 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -202,6 +202,19 @@ kinematics: # The type of printer in use. This option may be one of: cartesian, # corexy, corexz, hybrid_corexy, hybrid_corexz, rotary_delta, delta, # deltesian, polar, winch, or none. This parameter must be specified. +#bed_size: +# The physical size of the bed (width, height) in mm. This is used +# for automatic mesh bounds and home position calculation. This +# parameter is currently only supported for rectangular kinematics +# (cartesian, corexy, corexz, limited_*, and deltesian). +#bed_corner_position: +# The XY coordinate (as a nozzle coordinate) of the front-left corner +# of the bed. This is used for automatic mesh bounds and home +# position calculation. This parameter is currently only supported +# for rectangular kinematics (cartesian, corexy, corexz, limited_*, +# and deltesian). Note that this value can be outside of the axis +# limits defined for the steppers; Kalico will automatically find +# the nearest reachable point within the bed's boundaries. max_velocity: # Maximum velocity (in mm/s) of the toolhead (relative to the # print). This value may be changed at runtime using the @@ -1669,8 +1682,11 @@ Where x is the 0, 0 point on the bed # A newline separated list of four X, Y points that should be probed # during a QUAD_GANTRY_LEVEL command. Order of the locations is # important, and should correspond to Z, Z1, Z2, and Z3 location in -# order. This parameter must be provided. For maximum accuracy, -# ensure your probe offsets are configured. +# order. If `bed_size` and `bed_corner_position` are defined in the +# `[printer]` section, this parameter is optional and defaults to +# the largest reachable area of the bed, while maintaining the +# aspect ratio of the gantry corners. For maximum accuracy, ensure +# your probe offsets are configured. #speed: 50 # The speed (in mm/s) of non-probing moves during the calibration. # The default is 50. @@ -1775,7 +1791,10 @@ has to move to the center of the bed before Z can be homed. [safe_z_home] home_xy_position: # A X, Y coordinate (e.g. 100, 100) where the Z homing should be -# performed. This parameter must be provided. +# performed. If `bed_size` and `bed_corner_position` are defined +# in the `[printer]` section, this parameter is optional and +# defaults to the center of the bed (adjusted by the probe's +# offsets if a probe is present). #speed: 50.0 # Speed at which the toolhead is moved to the safe Z home # coordinate. The default is 50 mm/s diff --git a/docs/Features.md b/docs/Features.md index 134e20b51..099663121 100644 --- a/docs/Features.md +++ b/docs/Features.md @@ -106,7 +106,9 @@ Kalico supports many standard 3d printer features: multiple Z steppers then Kalico can also level by independently manipulating the Z steppers. Most Z height probes are supported, including BL-Touch probes and servo activated probes. Probes may be - calibrated for axis twist compensation. + calibrated for axis twist compensation. Kalico can also + automatically calculate the mesh boundaries and safe Z home + position based on the physical bed size and corner position. * Automatic delta calibration support. The calibration tool can perform basic height calibration as well as an enhanced X and Y diff --git a/docs/Kalico_Additions.md b/docs/Kalico_Additions.md index f1e60ebb5..de96d8f56 100644 --- a/docs/Kalico_Additions.md +++ b/docs/Kalico_Additions.md @@ -22,6 +22,7 @@ - Input shaper calibration now warns about active fans that may affect measurement accuracy. - [`BED_MESH_CHECK`](./G-Codes.md#bed_mesh_check) validates the current bed mesh against specified criteria, allowing you to check maximum deviation and slope between adjacent points before printing. - [`[resonance_tester]`](./Config_Reference.md#resonance_tester) now supports multiple accelerometer chips via the new `accel_chips` parameter, allowing data from multiple accelerometers to be combined for more accurate input shaper calibration. +- [`[printer] bed_size` and `bed_corner_position`](./Config_Reference.md#printer) can be used to automatically calculate mesh bounds for `[bed_mesh]`, `[quad_gantry_level]`, `[axis_twist_compensation]`, and the home position for `[safe_z_home]`. ## New Kalico Modules diff --git a/klippy/extras/axis_twist_compensation.py b/klippy/extras/axis_twist_compensation.py index 54139cf93..ede3dc150 100644 --- a/klippy/extras/axis_twist_compensation.py +++ b/klippy/extras/axis_twist_compensation.py @@ -3,9 +3,12 @@ # Copyright (C) 2022 Jeremy Tan # # This file may be distributed under the terms of the GNU GPLv3 license. +from __future__ import annotations import math +from ..mathutil import Point +from ..printer_info import PrinterInfo from . import bed_mesh, manual_probe DEFAULT_SAMPLE_COUNT = 3 @@ -24,9 +27,11 @@ def __init__(self, config): "horizontal_move_z", DEFAULT_HORIZONTAL_MOVE_Z ) self.speed = config.getfloat("speed", DEFAULT_SPEED) - self.calibrate_start_x = config.getfloat("calibrate_start_x") - self.calibrate_end_x = config.getfloat("calibrate_end_x") - self.calibrate_y = config.getfloat("calibrate_y") + self.calibrate_start_x = config.getfloat( + "calibrate_start_x", default=None + ) + self.calibrate_end_x = config.getfloat("calibrate_end_x", default=None) + self.calibrate_y = config.getfloat("calibrate_y", default=None) self.z_compensations = config.getlists( "z_compensations", default=[], parser=float ) @@ -145,6 +150,16 @@ def __init__(self, compensation, config): ) self.speed = compensation.speed self.horizontal_move_z = compensation.horizontal_move_z + self._update_points_with(compensation) + self.results = None + self.current_point_index = None + self.gcmd = None + self.configname = config.get_name() + + # register gcode handlers + self._register_gcode_handlers() + + def _update_points_with(self, compensation: AxisTwistCompensation) -> None: self.x_start_point = ( compensation.calibrate_start_x, compensation.calibrate_y, @@ -161,13 +176,6 @@ def __init__(self, compensation, config): compensation.calibrate_x, compensation.calibrate_end_y, ) - self.results = None - self.current_point_index = None - self.gcmd = None - self.configname = config.get_name() - - # register gcode handlers - self._register_gcode_handlers() def _handle_connect(self): self.probe = self.printer.lookup_object("probe", None) @@ -178,6 +186,90 @@ def _handle_connect(self): self.lift_speed = self.probe.get_lift_speed() self.probe_x_offset, self.probe_y_offset, _ = self.probe.get_offsets() + # If all are defined, then no need to update the points: + if None not in [ + *self.x_start_point, + *self.x_end_point, + *self.y_start_point, + *self.y_end_point, + ]: + return + + printer_info: PrinterInfo = self.printer.lookup_object("printer_info") + + required_fields = [ + "calibrate_start_x", + "calibrate_end_x", + "calibrate_y", + ] + if not printer_info.is_rectangular or None in [ + printer_info.bed_size, + printer_info.bed_corner_position, + printer_info.min_position, + printer_info.max_position, + ]: + missing_fields = [ + field + for field in required_fields + if getattr(self.compensation, field) is None + ] + if len(missing_fields) > 0: + if not printer_info.is_rectangular: + raise self.printer.config_error( + f"AXIS_TWIST_COMPENSATION automatic field calculation" + f" is not supported for {printer_info.kinematics_name}" + f" kinematics, please specify {missing_fields} manually" + ) + raise self.printer.config_error( + f"AXIS_TWIST_COMPENSATION requires the fields" + f" {missing_fields} to be set, or printer properties" + f" bed_size, and bed_corner_position to be defined for" + f" automatic field calculation" + ) + # If the required fields are set, and the optional ones can not be calculated, then return + return + + mesh_min, mesh_max = printer_info.get_mesh_bounds( + mesh_min=None, + mesh_max=None, + use_offsets=True, + error=self.printer.config_error, + probe_offset=(self.probe_x_offset, self.probe_y_offset), + ) + + center = Point(*mesh_min) + (Point(*mesh_max) - Point(*mesh_min)) / 2.0 + + # First update the points in self.compensation, ensuring that other tools which access + # these points have the updated values instead of None: + ( + ( + calibrate_start_x, + calibrate_start_y, + ), + (calibrate_x, calibrate_y), + ( + calibrate_end_x, + calibrate_end_y, + ), + ) = [mesh_min, (center[0], center[1]), mesh_max] + + for name, value in { + "calibrate_start_x": calibrate_start_x, + "calibrate_start_y": calibrate_start_y, + "calibrate_x": calibrate_x, + "calibrate_y": calibrate_y, + "calibrate_end_x": calibrate_end_x, + "calibrate_end_y": calibrate_end_y, + }.items(): + if getattr(self.compensation, name) is not None: + continue + + setattr(self.compensation, name, value) + + # The same points are stored in a different representation in self, + # which have to be updated as well: + self._update_points_with(self.compensation) + def _register_gcode_handlers(self): # register gcode handlers self.gcode = self.printer.lookup_object("gcode") @@ -209,18 +301,6 @@ def cmd_AXIS_TWIST_COMPENSATION_CALIBRATE(self, gcmd): if axis == "X": self.compensation.clear_compensations("X") - if ( - self.x_start_point[0] is None - or self.x_end_point[0] is None - or self.x_start_point[1] is None - ): - raise gcmd.error( - """AXIS_TWIST_COMPENSATION for X axis requires - calibrate_start_x, calibrate_end_x and calibrate_y - to be defined - """ - ) - start_point = self.x_start_point end_point = self.x_end_point @@ -235,18 +315,6 @@ def cmd_AXIS_TWIST_COMPENSATION_CALIBRATE(self, gcmd): elif axis == "Y": self.compensation.clear_compensations("Y") - if ( - self.y_start_point[0] is None - or self.y_end_point[0] is None - or self.y_start_point[1] is None - ): - raise gcmd.error( - """AXIS_TWIST_COMPENSATION for Y axis requires - calibrate_start_y, calibrate_end_y and calibrate_x - to be defined - """ - ) - start_point = self.y_start_point end_point = self.y_end_point diff --git a/klippy/extras/bed_mesh.py b/klippy/extras/bed_mesh.py index 952b438c1..2b2c754af 100644 --- a/klippy/extras/bed_mesh.py +++ b/klippy/extras/bed_mesh.py @@ -3,11 +3,14 @@ # Copyright (C) 2018-2019 Eric Callahan # # This file may be distributed under the terms of the GNU GPLv3 license. +from __future__ import annotations + import collections import json import logging import math +from ..printer_info import PrinterInfo from . import probe from .danger_options import get_danger_options @@ -172,6 +175,8 @@ def __init__(self, config): def handle_connect(self): self.toolhead = self.printer.lookup_object("toolhead") + self.bmc._update_mesh_bounds(self.printer.config_error) + if self.default_mesh_name: if self.default_mesh_name in self.pmgr.get_profiles(): self.pmgr.load_profile(self.default_mesh_name) @@ -506,8 +511,24 @@ def __init__(self, config, bedmesh): 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 + + mesh_min = self.mesh_min + mesh_max = self.mesh_max + + # The mesh_min and mesh_max points might not be calculated yet. + if None in [*mesh_min, *mesh_max]: + # These are explicitly set to None, None to make code fail that for + # some reason tries to use these placeholder values: + self.points = [] + for i in range(y_cnt): + for j in range(x_cnt): + self.points.append((None, None)) + self.zero_reference_mode = ZrefMode.DISABLED + return + + 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 @@ -546,11 +567,12 @@ def _generate_points(self, error, probe_method="automatic"): (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): + elif within(self.zero_ref_pos, mesh_min, mesh_max): # Zero Reference position within mesh self.zero_reference_mode = ZrefMode.IN_MESH else: @@ -672,10 +694,28 @@ def _init_mesh_config(self, config): else: # rectangular x_cnt, y_cnt = parse_config_pair(config, "probe_count", 3, minval=3) - min_x, min_y = config.getfloatlist("mesh_min", count=2) - max_x, max_y = config.getfloatlist("mesh_max", count=2) - if max_x <= min_x or max_y <= min_y: - raise config.error("bed_mesh: invalid min/max points") + min_x, min_y = config.getfloatlist( + "mesh_min", count=2, default=(None, None) + ) + max_x, max_y = config.getfloatlist( + "mesh_max", count=2, default=(None, None) + ) + + def check_min_max(axis, min_value, max_value, config): + if min_value is None or max_value is None: + return + + if max_value <= min_value: + raise config.error( + f"bed_mesh: invalid min/max points (min={min_value}, max={max_value}) for {axis}" + ) + + check_min_max("X", min_x, max_x, config) + check_min_max("Y", min_y, max_y, config) + + orig_cfg["aspect_ratio"] = config.getfloat( + "aspect_ratio", None, minval=0.1 + ) orig_cfg["x_count"] = mesh_cfg["x_count"] = x_cnt orig_cfg["y_count"] = mesh_cfg["y_count"] = y_cnt orig_cfg["mesh_min"] = self.mesh_min = (min_x, min_y) @@ -739,6 +779,75 @@ def _init_mesh_config(self, config): self.faulty_regions.append((c1, c3)) self._verify_algorithm(config.error) + def _update_mesh_bounds(self, error): + # For correctly calculating the mesh_min and mesh_max, it is necessary to + # know how where the toolhead can move to. The calculated values should not + # cause invalid moves. + # + # The toolhead and printer_info (the latter contains physical information about the printer) + # object are initialized after all other sections are loaded (including bed_mesh). + # Therefore this information is not available on init, but it is necessary. + # + # The first idea was to initialize the self.bmc (calling this class constructor) on connect, + # but then klipper would complain about unused config options. These config options are parsed + # in the constructor. Doing this by hand, would involve many changes to the code, which should + # be avoided, given that it makes it harder to merge changes from upstream klipper. + # + # Instead the following is done: + # - The mesh bounds are initialized with dummy values or the old ones on init + # - On connect, the toolhead is available, and it will then call this function + # here the mesh bounds are updated with the correct values, or an error is raised + # if they conflict with the toolhead kinematics. + # + # The important part is to update everything that depends on mesh_min/mesh_max, so they know + # the new values. The following use the ZMesh data: + # - BedMesh.update_status() + # - BED_MESH_MAP command + # - BED_MESH_CHECK command + # + # and the values from this class are used in + # - _init_mesh_config (reads config values and sets fields + in orig_cfg) + # - update_config + # - _generate_points (this one is important) + # + # The z_mesh seems to be initialized from the stored mesh data and after calibration it should be updated. + # That should not be a problem, given that after connect, the mesh_min and mesh_max will be set correctly. + # The update_config is only called through the BED_MESH_CALIBRATE command, so this is fine too. + # + # Now the only functions that remain are the _generate_points (which is called on init) and _init_mesh_config + # + # Both have been adjusted to handle the case where the mesh_min and mesh_max are not set as follows: + # - If the mesh_min or mesh_max is not set in the config, they default to (None, None), indicating that they are not set. + # The None value is used instead of for example 0.0, to make code fail that might use the mesh_min and mesh_max + # values before they are updated on connect. + # - _generate_points is updated to handle the case where the mesh_min and mesh_max are None + + min_x, min_y = self.mesh_min + max_x, max_y = self.mesh_max + + # If the mesh bounds are all explicitly set in the config, + # then there is nothing to calculate + if None not in [min_x, min_y, max_x, max_y]: + return + + printer_info: PrinterInfo = self.printer.lookup_object("printer_info") + + self.mesh_min, self.mesh_max = printer_info.get_mesh_bounds( + (min_x, min_y) if None not in [min_x, min_y] else None, + (max_x, max_y) if None not in [max_x, max_y] else None, + self.probe_helper.use_offsets, + error, + # NOTE: probe xy offset might still be null here in probe_helper, because it is only set while probing + # This is left empty, so printer_info automatically loads the probe offsets from the probe object + target_aspect_ratio=self.orig_config["aspect_ratio"], + ) + + self.orig_config["mesh_min"] = self.mesh_min + self.orig_config["mesh_max"] = self.mesh_max + + # Now that the mesh bounds are updated, the points have to be generated again: + self._internal_update_config(error) + def _verify_algorithm(self, error): params = self.mesh_config x_pps = params["mesh_x_pps"] @@ -920,6 +1029,12 @@ def set_adaptive_mesh(self, gcmd): self._profile_name = None return True + def _internal_update_config(self, error, probe_method="automatic"): + self._verify_algorithm(error) + self._generate_points(error, probe_method) + pts = self._get_adjusted_points() + self.probe_helper.update_probe_points(pts, 3) + def update_config(self, gcmd): # reset default configuration self.radius = self.orig_config["radius"] @@ -967,10 +1082,7 @@ def update_config(self, gcmd): probe_method = gcmd.get("METHOD", "automatic") if need_cfg_update: - self._verify_algorithm(gcmd.error) - self._generate_points(gcmd.error, probe_method) - pts = self._get_adjusted_points() - self.probe_helper.update_probe_points(pts, 3) + self._internal_update_config(gcmd.error, probe_method) msg = "\n".join( ["%s: %s" % (k, v) for k, v in self.mesh_config.items()] ) diff --git a/klippy/extras/probe.py b/klippy/extras/probe.py index 426d478ff..08a803056 100644 --- a/klippy/extras/probe.py +++ b/klippy/extras/probe.py @@ -313,6 +313,26 @@ def __init__(self, config, mcu_probe): self.was_last_result_good = False self.gcode_move = self.printer.load_object(config, "gcode_move") self.retry_session = RetrySession(config) + # TODO: Name is open for suggestions + # + # For most probes they need to be a certain distance away from the edge of the bed. + # For example with a coil based probe, the entire coil should be on the bed. + # + # The distance is from the center of the probe (= nozzle coordinate + probe offset) to the + # edge of the bed. + # + # If the distance is negative, it will allow probing outside the bed by that distance. + # In case this parameter is not specified, it will not restrict the probing area at all. + # + # TODO: In the future this could be extended to a list of floats similar to how margins are + # specified in css: + # - top, left, bottom, right + # - top and bottom, left and right + # - or all sides the same (the current implementation) + # TODO: This could be enforced while probing + self.min_edge_distance = config.getfloat( + "min_edge_distance", default=None + ) # Infer Z position to move to during a probe if config.has_section("stepper_z"): zconfig = config.getsection("stepper_z") @@ -432,6 +452,9 @@ def get_lift_speed(self, gcmd=None): def get_offsets(self): return self.x_offset, self.y_offset, self.z_offset + def get_min_edge_distance(self) -> float | None: + return self.min_edge_distance + def probing_move( self, speed, gcmd: GCodeCommand ) -> tuple[list[float], bool]: diff --git a/klippy/extras/quad_gantry_level.py b/klippy/extras/quad_gantry_level.py index 9becab70b..c93aa96f6 100644 --- a/klippy/extras/quad_gantry_level.py +++ b/klippy/extras/quad_gantry_level.py @@ -3,8 +3,11 @@ # Copyright (C) 2018 Maks Zolin # # This file may be distributed under the terms of the GNU GPLv3 license. +from __future__ import annotations + import logging +from ..printer_info import PrinterInfo from . import probe, z_tilt # Leveling code for XY rails that are controlled by Z steppers as in: @@ -25,6 +28,8 @@ class QuadGantryLevel: + points: list[tuple[float, float] | None] + def __init__(self, config): self.printer = config.get_printer() self.retry_helper = z_tilt.RetryHelper( @@ -32,7 +37,17 @@ def __init__(self, config): ) self.max_adjust = config.getfloat("max_adjust", 4, above=0) self.horizontal_move_z = config.getfloat("horizontal_move_z", 5.0) - self.probe_helper = probe.ProbePointsHelper(config, self.probe_finalize) + + default_points = None + if config.get("points", None) is None: + default_points = [None] * 4 + self.printer.register_event_handler( + "klippy:connect", self._update_gantry_level_points + ) + + self.probe_helper = probe.ProbePointsHelper( + config, self.probe_finalize, default_points=default_points + ) if len(self.probe_helper.probe_points) != 4: raise config.error( "Need exactly 4 probe points for quad_gantry_level" @@ -54,6 +69,48 @@ def __init__(self, config): desc=self.cmd_QUAD_GANTRY_LEVEL_help, ) + def _update_gantry_level_points(self) -> list[tuple[float, float]]: + if None not in self.probe_helper.probe_points: + return self.probe_helper.probe_points + + # This is only called if the user did not specify probe points in the config, + # these are then inferred from the mesh: + printer_info: PrinterInfo = self.printer.lookup_object("printer_info") + + # TODO: Should the aspect ratio calculation be optional? + # Calculate the target aspect ratio based on the gantry corners: + x_width = abs(self.gantry_corners[0][0] - self.gantry_corners[1][0]) + y_width = abs(self.gantry_corners[0][1] - self.gantry_corners[1][1]) + + # The get_mesh_bounds handles a missing probe by using 0, 0 as the offset. + # This would be a problem with quad_gantry_level with the points so close to the edge. + # + # To prevent a silent failure, an explicit error is raised here: + if self.printer.lookup_object("probe", None) is None: + raise self.printer.config_error( + "quad_gantry_level requires a probe to be defined" + ) + + mesh_min, mesh_max = printer_info.get_mesh_bounds( + mesh_min=None, + mesh_max=None, + use_offsets=self.probe_helper.use_offsets, + error=self.printer.config_error, + target_aspect_ratio=x_width / y_width if y_width != 0 else None, + ) + + self.probe_helper.update_probe_points( + [ + (mesh_min[0], mesh_min[1]), # Front-left + (mesh_min[0], mesh_max[1]), # Back-left + (mesh_max[0], mesh_max[1]), # Back-right + (mesh_max[0], mesh_min[1]), # Front-right + ], + 4, + ) + + return self.probe_helper.probe_points + cmd_QUAD_GANTRY_LEVEL_help = ( "Conform a moving, twistable gantry to the shape of a stationary bed" ) diff --git a/klippy/extras/safe_z_home.py b/klippy/extras/safe_z_home.py index c0b55f55d..7902e9157 100644 --- a/klippy/extras/safe_z_home.py +++ b/klippy/extras/safe_z_home.py @@ -4,12 +4,21 @@ # # This file may be distributed under the terms of the GNU GPLv3 license. +from __future__ import annotations + +from typing import Callable + +from ..mathutil import Point +from ..printer_info import PrinterInfo +from .probe import PrinterProbe + class SafeZHoming: def __init__(self, config): self.printer = config.get_printer() - x_pos, y_pos = config.getfloatlist("home_xy_position", count=2) - self.home_x_pos, self.home_y_pos = x_pos, y_pos + self.home_x_pos, self.home_y_pos = config.getfloatlist( + "home_xy_position", count=2, default=(None, None) + ) self.z_hop = config.getfloat("z_hop", default=0.0) self.z_hop_speed = config.getfloat("z_hop_speed", 15.0, above=0.0) @@ -32,9 +41,86 @@ def __init__(self, config): + " be used simultaneously" ) + # Ensure the home position is set when the printer connects, making it available to other modules + self.printer.register_event_handler( + "klippy:connect", self._update_home_position + ) + + def _update_home_position( + self, + default_error: Callable[[str], Exception] | None = None, + use_offsets: bool = False, + ) -> tuple[float, float]: + printer_info: PrinterInfo | None = self.printer.lookup_object( + "printer_info", None + ) + + error = ( + self.printer.config_error + if default_error is None + else default_error + ) + + if printer_info is None and ( + self.home_x_pos is None or self.home_y_pos is None + ): + raise error( + "printer properties not defined, therefore home_xy_position must be set" + ) + + # If the home position is set, use that. + if self.home_x_pos is not None and self.home_y_pos is not None: + return self.home_x_pos, self.home_y_pos + + # The probe might not be present, in that case, it should be okay to home + # with the nozzle in the center of the bed. + probe: PrinterProbe | None = self.printer.lookup_object("probe", None) + probe_offsets = Point.origin() + if probe is not None: + offsets = probe.get_offsets() + probe_offsets = Point(offsets[0], offsets[1]) + + if not printer_info.is_rectangular: + raise error( + f"automatic home position calculation is not supported for" + f" {printer_info.kinematics_name} kinematics, please specify" + f" home_xy_position manually" + ) + + printer_info.require_properties( + ["bed_corner_position", "bed_size"], error + ) + + bed_center = ( + printer_info.bed_corner_position + + Point(*printer_info.bed_size) / 2.0 + ) + + result = printer_info.nearest_point(bed_center - probe_offsets) + + if use_offsets: + result += probe_offsets + + # In case the probe is currently not defined, it will not cache the calculated home position, + # then on the next call, it will calculate it again with the probe defined: + if probe is None: + return result + + # Cache the calculated home position for future calls: + self.home_x_pos, self.home_y_pos = ( + self.home_x_pos or result.x, + self.home_y_pos or result.y, + ) + + return self.home_x_pos, self.home_y_pos + def cmd_G28(self, gcmd): toolhead = self.printer.lookup_object("toolhead") + # First get the home position for z, if it is not set, this will throw an error, which should + # happen before the printer starts moving + home_position = self._update_home_position() + # Perform Z Hop if necessary if self.z_hop != 0.0: # Check if Z axis is homed and its last known position @@ -94,7 +180,7 @@ def cmd_G28(self, gcmd): # Move to safe XY homing position prevpos = toolhead.get_position() - toolhead.manual_move([self.home_x_pos, self.home_y_pos], self.speed) + toolhead.manual_move([*home_position], self.speed) # Home Z g28_gcmd = self.gcode.create_gcode_command("G28", "G28", {"Z": "0"}) diff --git a/klippy/extras/z_calibration.py b/klippy/extras/z_calibration.py index 62b51bb87..9eb571fd9 100644 --- a/klippy/extras/z_calibration.py +++ b/klippy/extras/z_calibration.py @@ -107,6 +107,14 @@ def handle_connect(self): "No nozzle position" " configured for %s" % (self.config.get_name()) ) + # TODO: Is it allowed to use the calculated home position? I have never used this feature + if safe_z_home.home_x_pos is None or safe_z_home.home_y_pos is None: + raise self.printer.config_error( + "No nozzle position" + f" configured for {self.config.get_name()} and unable to get it from" + " safe_z_home, because home_xy_position is not set" + ) + self.nozzle_site = [ safe_z_home.home_x_pos, safe_z_home.home_y_pos, diff --git a/klippy/mathutil.py b/klippy/mathutil.py index 43b84fe10..d27e2ca59 100644 --- a/klippy/mathutil.py +++ b/klippy/mathutil.py @@ -3,10 +3,13 @@ # Copyright (C) 2018-2019 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. +import dataclasses +import functools import logging import math import multiprocessing import traceback +from typing import Any, Generator from . import queuelogger @@ -160,3 +163,82 @@ def matrix_sub(m1, m2): def matrix_mul(m1, s): return [m1[0] * s, m1[1] * s, m1[2] * s] + + +@functools.total_ordering +@dataclasses.dataclass +class Point: + """A 2D point supporting basic vector arithmetic. + + Attributes: + x: The horizontal coordinate. + y: The vertical coordinate. + """ + + x: float + y: float + + @staticmethod + def origin() -> "Point": + """Return the point at the origin (0, 0).""" + return Point(0.0, 0.0) + + def __getitem__(self, key) -> float: + if not isinstance(key, int): + key = int(key) + + return [self.x, self.y][key] + + def __add__(self, other: "Point") -> "Point": + """Return the element-wise sum of two points.""" + if not isinstance(other, Point): + return NotImplemented + + return Point(self.x + other.x, self.y + other.y) + + def __sub__(self, other: "Point") -> "Point": + """Return the element-wise difference of two points.""" + if not isinstance(other, Point): + return NotImplemented + + return Point(self.x - other.x, self.y - other.y) + + def __mul__(self, scalar: float) -> "Point": + """Return the point scaled by a scalar factor.""" + return Point(self.x * scalar, self.y * scalar) + + def __truediv__(self, other: float) -> "Point": + """Return the point divided by a scalar factor.""" + if not isinstance(other, (int, float)): + return NotImplemented + + return Point(self.x / other, self.y / other) + + def __pow__(self, other: float) -> "Point": + """Raise each coordinate to the given power.""" + if not isinstance(other, (int, float)): + return NotImplemented + + return Point(self.x**other, self.y**other) + + def __eq__(self, other: object) -> bool: + """Check if two points are equal.""" + if not isinstance(other, Point): + return NotImplemented + + return self.x == other.x and self.y == other.y + + def __lt__(self, other: "Point") -> bool: + if not isinstance(other, Point): + return NotImplemented + + return (self.x, self.y) < (other.x, other.y) + + def __hash__(self) -> int: + """Return a hash value for the point.""" + return hash((self.x, self.y)) + + def __iter__(self) -> Generator[float, Any, None]: + """Allow unpacking the point into (x, y).""" + yield self.x + yield self.y diff --git a/klippy/printer.py b/klippy/printer.py index b9724d251..63564e942 100644 --- a/klippy/printer.py +++ b/klippy/printer.py @@ -30,6 +30,7 @@ mcu, msgproto, pins, + printer_info, queuelogger, reactor, toolhead, @@ -324,7 +325,7 @@ def _read_config(self): self.load_object(config, section_config, None) if self.get_start_args().get("debuginput") is not None: self.load_object(config, "testing", None) - for m in [toolhead]: + for m in [toolhead, printer_info]: m.add_printer_objects(config) # Validate that there are no undefined parameters in the config file error_on_unused = get_danger_options().error_on_unused_config_options diff --git a/klippy/printer_info.py b/klippy/printer_info.py new file mode 100644 index 000000000..7f34b981a --- /dev/null +++ b/klippy/printer_info.py @@ -0,0 +1,337 @@ +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING, Callable + +from .mathutil import Point + +if TYPE_CHECKING: + # To avoid circular imports/import issues, because . and .extras haven't been initialized yet, + # these are only imported for type checking: + from . import ConfigWrapper, Printer + from .extras.probe import PrinterProbe + +logger = logging.getLogger(__name__) + + +class PrinterInfo: + bed_size: tuple[float, float] | None + bed_corner_position: Point | None + min_position: Point | None + max_position: Point | None + printer: Printer + + def __init__(self, config: ConfigWrapper) -> None: + self.printer = config.get_printer() + self.kinematics_name = config.get("kinematics") + self.is_rectangular = self.kinematics_name in [ + "cartesian", + "corexy", + "corexz", + "limited_cartesian", + "limited_corexy", + "limited_corexz", + "deltesian", + ] + + # This is hardcoded in bed_mesh.py in the BedMeshCalibrate._generate_points method, where + # + # 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.0 or y_dist < 1.0: + # raise error("bed_mesh: min/max points too close together") + # + # The minimum probe count (x_cnt/y_cnt) is hardcoded to 3. With this + # the minimum will be: + # + # dist / (*_cnt - 1) < 1.0 + # <=> dist < 1.0 * (*_cnt - 1) + # <=> dist < 1.0 * (3 - 1) + # <=> dist < 2.0 + # + # TODO: Maybe refactor the code that enforces this into a shared function, so it is not hardcoded in multiple locations? + self.min_mesh_size = Point(2.0, 2.0) + self.bed_size = config.getfloatlist("bed_size", count=2, default=None) + corner = config.getfloatlist( + "bed_corner_position", count=2, default=None + ) + self.bed_corner_position = ( + Point(*corner) if corner is not None else None + ) + + if not self.is_rectangular and ( + self.bed_size is not None or self.bed_corner_position is not None + ): + raise config.error( + f"bed_size and bed_corner_position are not supported for" + f" {self.kinematics_name} kinematics" + ) + + if self.bed_size is not None and ( + self.bed_size[0] <= 0 or self.bed_size[1] <= 0 + ): + raise config.error( + f"Invalid bed size {self.bed_size}, should be a positive value" + ) + + toolhead = self.printer.lookup_object("toolhead") + curtime = self.printer.get_reactor().monotonic() + + kin_status = toolhead.get_kinematics().get_status(curtime) + if "axis_minimum" in kin_status: + self.min_position = Point( + kin_status["axis_minimum"][0], kin_status["axis_minimum"][1] + ) + else: + self.min_position = None + + if "axis_maximum" in kin_status: + self.max_position = Point( + kin_status["axis_maximum"][0], kin_status["axis_maximum"][1] + ) + else: + self.max_position = None + + def nearest_point(self, point: Point) -> Point: + """ + Ensures the given point is within the printers reachable area, + returning the point itself or adjusting it to the nearest reachable point. + + This assumes both self.min_position and self.max_position are not None. + + """ + x, y = point + + x = max(min(x, self.max_position.x), self.min_position.x) + y = max(min(y, self.max_position.y), self.min_position.y) + + return Point(x, y) + + def _probe_margin(self) -> Point: + probe: PrinterProbe = self.printer.lookup_object("probe", None) + if probe is None: + return Point.origin() + + if probe.get_min_edge_distance() is None: + return Point.origin() + + edge_distance = max(probe.get_min_edge_distance(), 0.0) + return Point(edge_distance, edge_distance) + + def _mesh_min(self, probe_offset: Point) -> Point: + # The bed corner position defines where the bed begins. Given that the probe + # has to be a certain distance from the edge, the margin is added. + # This results in the outermost point where the probe could be. + # + # For a better understanding, this is the bed, and point A is the bed corner position: + # + # +----------------+ + # | | + # | +----------+ | + # | | | | + # | | | | + # | | | | + # | | | | + # | 1----------+ | + # | | + # A----------------+ + # + # The inner square is the area where we can safely probe, and we are calculating the point 1. + desired_probe_min = self.bed_corner_position + self._probe_margin() + # This is the point where the probe could reach based on the physical limits of the printer: + reachable_probe_min = self.min_position + probe_offset + # The above points are small values like (5, 2) and (-5, -5). And the more the values increase, + # the closer it gets to the center of the bed which could for example be at (150, 150). + # + # So by taking the max of these two points, we ensure that we can move there and if both points + # are reachable, we take the one that is further from the edge to ensure we are within the margins. + probe_min = Point( + max(desired_probe_min.x, reachable_probe_min.x), + max(desired_probe_min.y, reachable_probe_min.y), + ) + + # The above calculations were for the probe, but there is an offset between the probe and the nozzle. + # The point should be reachable by the nozzle as well, which this ensures: + return self.nearest_point(probe_min - probe_offset) + + def _mesh_max(self, probe_offset: Point) -> Point: + # This is the same as _mesh_min, but for the other corner of the bed. + bed_max = self.bed_corner_position + Point(*self.bed_size) + + desired_probe_max = bed_max - self._probe_margin() + reachable_probe_max = self.max_position + probe_offset + probe_max = Point( + min(desired_probe_max.x, reachable_probe_max.x), + min(desired_probe_max.y, reachable_probe_max.y), + ) + + return self.nearest_point(probe_max - probe_offset) + + def require_properties( + self, properties: list[str], error: Callable[[str], Exception] + ) -> None: + missing_properties = [ + prop for prop in properties if getattr(self, prop) is None + ] + + if len(missing_properties) > 0: + raise error( + f"the following options are required, but are not defined in the [printer] section: {', '.join(missing_properties)}" + ) + + def get_mesh_bounds( + self, + mesh_min: tuple[float, float] | None, + mesh_max: tuple[float, float] | None, + use_offsets: bool, + error: Callable[[str], Exception], + probe_offset: tuple[float, float] | None = None, + target_aspect_ratio: float | None = None, + ) -> tuple[tuple[float, float], tuple[float, float]]: + # If both are set, there is nothing to adjust: + if mesh_min is not None and mesh_max is not None: + return mesh_min, mesh_max + + if not self.is_rectangular: + raise error( + f"automatic mesh bounds calculation is not supported for" + f" {self.kinematics_name} kinematics, please specify" + f" mesh_min and mesh_max manually" + ) + + self.require_properties( + ["bed_size", "bed_corner_position", "min_position", "max_position"], + error, + ) + + can_move_min = mesh_min is None + can_move_max = mesh_max is None + + if probe_offset is None: + probe: PrinterProbe = self.printer.lookup_object("probe") + offsets = probe.get_offsets() + probe_offset = Point(offsets[0], offsets[1]) + else: + probe_offset = Point(probe_offset[0], probe_offset[1]) + + logger.debug( + f"printer_info: mesh_min={mesh_min}, mesh_max={mesh_max}," + f" probe_offset={probe_offset}, use_offsets={use_offsets}," + f" target_aspect_ratio={target_aspect_ratio}," + f" bed_corner_position={self.bed_corner_position}," + f" bed_size={self.bed_size}, min_position={self.min_position}," + f" max_position={self.max_position}, can_move_min={can_move_min}," + f" can_move_max={can_move_max}" + ) + + # It is allowed to explicitly set one of the corners, and having it calculate the other + # corner. + # + # This can be useful when you want to force how far the mesh should be from one of the edges. + if mesh_min is None: + mesh_min: Point = self._mesh_min(probe_offset) + else: + mesh_min: Point = Point(*mesh_min) + if use_offsets: + mesh_min -= probe_offset + + if mesh_max is None: + mesh_max: Point = self._mesh_max(probe_offset) + else: + mesh_max: Point = Point(*mesh_max) + if use_offsets: + mesh_max -= probe_offset + + logger.debug( + f"printer_info: calculated (nozzle coordinate) mesh_min={mesh_min}, mesh_max={mesh_max}" + ) + + mesh_delta = mesh_max - mesh_min + if ( + mesh_delta.x < self.min_mesh_size.x + or mesh_delta.y < self.min_mesh_size.y + ): + raise error( + f"failed to calculate mesh_min and mesh_max, because the resulting mesh" + f" (mesh_min={mesh_min}, mesh_max={mesh_max}) is too small, minimum size" + f" {self.min_mesh_size}, but got {mesh_delta}.\n" + "Please ensure the physical properties are correctly defined and the probe" + " edge distance is not too large." + ) + + # In many cases the probe offset is only on one axis, e.g. only in the X direction, and 0 in the Y direction. + # This can result in a mesh that has a different aspect ratio than the bed. + # + # This can be a problem with for example quad gantry level, where it expects the aspect ratio to match + # the gantry geometry. + # + # Therefore it is allowed to specify a target aspect ratio, defined as x width divided by y width. + # The mesh will be shrunk to fit the target aspect ratio. Given that the mesh is shrunk, it should not + # be possible to reach outside of the printer's reachable area. + if target_aspect_ratio is not None and (can_move_min or can_move_max): + if target_aspect_ratio <= 0: + raise error( + f"failed to adjust mesh to {target_aspect_ratio}, because it is not a positive value" + ) + + current_aspect_ratio = mesh_delta.x / mesh_delta.y + + # First we calculate how the mesh has to be adjusted to fit the target aspect ratio. + # + # In general there are two options to adjust the aspect ratio, by shrinking one dimension + # or by increasing the other dimension. + # + # But we want to stay within the bounds, therefore only shinking is allowed: + if current_aspect_ratio > target_aspect_ratio: + # The mesh is wider than the target ratio, so we need to reduce the width or increase the height + # but given that we want to be inside the margins, only reducing the width is an option: + new_mesh_width = mesh_delta.y * target_aspect_ratio + delta = Point(mesh_delta.x - new_mesh_width, 0) + elif current_aspect_ratio < target_aspect_ratio: + # The mesh is taller than the target ratio, so we need to reduce the height or increase the width + # but given that we want to be inside the margins, only reducing the height is an option: + new_mesh_height = mesh_delta.x / target_aspect_ratio + delta = Point(0, mesh_delta.y - new_mesh_height) + else: + delta = Point(0, 0) + + # For the min we would have to add the delta, for the max we would have to subtract the delta, + # ideally we would want to do half and half, but this is only possible if both mesh_min and mesh_max + # are movable: + if can_move_min and can_move_max: + mesh_min += delta / 2 + mesh_max -= delta / 2 + elif can_move_min: + mesh_min += delta + elif can_move_max: + mesh_max -= delta + + # After adjusting the mesh corners, it might have become too small, so this should be checked again: + mesh_delta = mesh_max - mesh_min + if ( + mesh_delta.x < self.min_mesh_size.x + or mesh_delta.y < self.min_mesh_size.y + ): + raise error( + f"failed to adjust mesh to match target aspect ratio {target_aspect_ratio}, because the resulting mesh" + f" (mesh_min={mesh_min}, mesh_max={mesh_max}) is too small, minimum size is {self.min_mesh_size}," + f" but got {mesh_delta}" + ) + + if use_offsets: + mesh_min += probe_offset + mesh_max += probe_offset + + logger.debug( + f"printer_info: returning mesh_min={mesh_min}, mesh_max={mesh_max}" + f" with use_offsets={use_offsets} and probe_offset={probe_offset}" + ) + + return ((mesh_min.x, mesh_min.y), (mesh_max.x, mesh_max.y)) + + +def add_printer_objects(config: ConfigWrapper) -> None: + config.get_printer().add_object("printer_info", PrinterInfo(config)) diff --git a/test/klippy/auto_mesh_offset.cfg b/test/klippy/auto_mesh_offset.cfg new file mode 100644 index 000000000..26d5f2d78 --- /dev/null +++ b/test/klippy/auto_mesh_offset.cfg @@ -0,0 +1,167 @@ +# Test config for auto mesh bounds calculation +[mcu] +serial: /dev/serial/by-id/usb-Klipper_Klipper_firmware_12345-if00 + +[printer] +kinematics: corexy +max_velocity: 300 +max_accel: 3000 +max_z_velocity: 5 +max_z_accel: 100 + +bed_size: 300.0, 300.0 +bed_corner_position: -4.3, -10.8 +#margin: 30.0, 30.0 +#preserve_aspect_ratio: True + +[stepper_x] +# B Stepper - Back Left +step_pin: PF13 +dir_pin: !PF12 +enable_pin: !PF14 +microsteps: 32 +rotation_distance: 40 +endstop_pin: PG6 +position_endstop: -5 +position_min: -5 +position_max: 300 +homing_speed: 60 +homing_positive_dir: False + +[stepper_y] +# A Stepper - Back Right +step_pin: PF9 +dir_pin: !PF10 +enable_pin: !PG2 +microsteps: 32 +rotation_distance: 40 +endstop_pin: PG9 +position_endstop: -5 +position_min: -5 +position_max: 305 +homing_speed: 60 +homing_positive_dir: False + +[stepper_z] +# Z0 Stepper - Front Left - M1 +step_pin: PG0 +dir_pin: !PG1 +enable_pin: !PF15 +rotation_distance: 40 +gear_ratio: 80:16 +endstop_pin: probe:z_virtual_endstop +position_max: 280 +position_min: -20 +microsteps: 16 + +[stepper_z1] +# Z1 Stepper - Rear Left - M5 +step_pin: PC13 +dir_pin: PF0 +enable_pin: !PF1 +rotation_distance: 40 +gear_ratio: 80:16 +microsteps: 16 + +[stepper_z2] +# Z2 Stepper - Rear Right - M7 +step_pin: PE6 +dir_pin: !PA14 +enable_pin: !PE0 +rotation_distance: 40 +gear_ratio: 80:16 +microsteps: 16 + +[stepper_z3] +# Z2 Stepper - Front Right - M3 +step_pin: PG4 +dir_pin: PC1 +enable_pin: !PA2 +rotation_distance: 40 +gear_ratio: 80:16 +microsteps: 16 + +[extruder] +step_pin: PA4 +dir_pin: PA6 +enable_pin: !PA2 +microsteps: 16 +rotation_distance: 33.5 +nozzle_diameter: 0.400 +filament_diameter: 1.750 +heater_pin: PB4 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PF4 +control: pid +pid_Kp: 22.2 +pid_Ki: 1.08 +pid_Kd: 114 +min_temp: 0 +max_temp: 250 + +[heater_bed] +heater_pin: PA1 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PF3 +control: watermark +min_temp: 0 +max_temp: 130 + +[bltouch] +sensor_pin: PC7 +control_pin: PC5 +z_offset: 1.15 +y_offset: 22.0 +x_offset: 0.0 +min_edge_distance: 30 + +[bed_mesh] +zero_reference_position: 150, 150 +horizontal_move_z: 3 +probe_count: 10, 10 +algorithm: bicubic + +[safe_z_home] + +# TODO: This is what the values should be: +# +# [axis_twist_compensation] +# z_compensations = 0.029214, 0.004422, -0.011374, -0.012044, -0.010217 +# compensation_start_x = 25.7 +# compensation_end_x = 265.7 +# calibrate_start_x = 25.7 +# calibrate_x = 145.7 +# calibrate_end_x = 265.7 +# calibrate_start_y = 19.2 +# calibrate_y = 139.2 +# calibrate_end_y = 259.2 +# +# [safe_z_home] +# home_xy_position = 145.7, 117.2 +# +# [bed_mesh] +# mesh_min = 25.7, 19.2 +# mesh_max = 265.7, 259.2 +# +# [quad_gantry_level] +# points = +# 25.7, 19.2 +# 25.7, 259.2 +# 265.7, 259.2 +# 265.7, 19.2 + +[quad_gantry_level] +gantry_corners: + -60, -10 + 360, 370 +speed: 250 +horizontal_move_z: 15 +min_horizontal_move_z: 1.0 +retries: 10 +retry_tolerance: 0.0075 +max_adjust: 10 +adaptive_horizontal_move_z: True +use_probe_xy_offsets: True + +[axis_twist_compensation] + diff --git a/test/klippy/auto_mesh_offset.test b/test/klippy/auto_mesh_offset.test new file mode 100644 index 000000000..52e39c795 --- /dev/null +++ b/test/klippy/auto_mesh_offset.test @@ -0,0 +1,17 @@ +# Test case for automatic offset based on printer properties +CONFIG auto_mesh_offset.cfg +DICTIONARY stm32h723.dict + +G28 + +BED_MESH_CALIBRATE + +QUAD_GANTRY_LEVEL + +AXIS_TWIST_COMPENSATION_CALIBRATE AXIS=X + +ACCEPT + +AXIS_TWIST_COMPENSATION_CALIBRATE AXIS=Y + +ACCEPT