From 0c2416ea7fd5ae15af079f11947902c4e7b51364 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=A1s=20A=2E=20M=C3=A9ndez?= Date: Tue, 24 Mar 2026 13:51:44 -0300 Subject: [PATCH 1/9] avr: Fix missing SP definition in newer avr-libc and missing include in adc.c (#7233) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compatibility fix for newer avr-libc - define stack pointer add missing avr/io.h include to adc.c add missing avr/io.h include to spi.c Signed-off-by: Nicolás Méndez <9phhsyrbm@relay.firefox.com> --- src/avr/adc.c | 1 + src/avr/main.c | 6 ++++++ src/avr/spi.c | 1 + 3 files changed, 8 insertions(+) diff --git a/src/avr/adc.c b/src/avr/adc.c index 99fd063f6963..e94dfbc57006 100644 --- a/src/avr/adc.c +++ b/src/avr/adc.c @@ -4,6 +4,7 @@ // // This file may be distributed under the terms of the GNU GPLv3 license. +#include // ADCSRA #include "autoconf.h" // CONFIG_MACH_atmega644p #include "command.h" // shutdown #include "gpio.h" // gpio_adc_read diff --git a/src/avr/main.c b/src/avr/main.c index 0523af411156..b0d299dac879 100644 --- a/src/avr/main.c +++ b/src/avr/main.c @@ -12,6 +12,12 @@ #include "irq.h" // irq_enable #include "sched.h" // sched_main +// Newer avr-libc headers expose the stack pointer as SP instead of the +// older AVR_STACK_POINTER_REG alias used by Klipper. +#ifndef AVR_STACK_POINTER_REG +#define AVR_STACK_POINTER_REG SP +#endif + DECL_CONSTANT_STR("MCU", CONFIG_MCU); diff --git a/src/avr/spi.c b/src/avr/spi.c index 55eb1f58c991..d8acd45daf18 100644 --- a/src/avr/spi.c +++ b/src/avr/spi.c @@ -4,6 +4,7 @@ // // This file may be distributed under the terms of the GNU GPLv3 license. +#include // SPCR #include "autoconf.h" // CONFIG_MACH_atmega644p #include "command.h" // shutdown #include "gpio.h" // spi_setup From 9ebff0cc84dfd1885d171a34198943cc36999176 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Sun, 22 Mar 2026 13:16:09 -0400 Subject: [PATCH 2/9] rp2040: Avoid run-time divide in i2c.c Rework the code slightly to avoid an expensive software divide when calculating the rate in i2c.c . Signed-off-by: Kevin O'Connor --- src/rp2040/Kconfig | 2 -- src/rp2040/i2c.c | 12 ++++++++---- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/src/rp2040/Kconfig b/src/rp2040/Kconfig index 481761b3d49b..97c31b859c06 100644 --- a/src/rp2040/Kconfig +++ b/src/rp2040/Kconfig @@ -14,8 +14,6 @@ config RPXXXX_SELECT select HAVE_GPIO_HARD_PWM select HAVE_STEPPER_OPTIMIZED_BOTH_EDGE select HAVE_BOOTLOADER_REQUEST - # Software divide needed on rp2040 for rate calculation in i2c.c - select HAVE_SOFTWARE_DIVIDE_REQUIRED if MACH_RP2040 config BOARD_DIRECTORY string diff --git a/src/rp2040/i2c.c b/src/rp2040/i2c.c index 8ec2bd7a6684..0bec61c29eef 100644 --- a/src/rp2040/i2c.c +++ b/src/rp2040/i2c.c @@ -101,10 +101,14 @@ i2c_setup(uint32_t bus, uint32_t rate, uint8_t addr) // See `i2c_set_baudrate` in the Pico SDK `hardware_i2c/i2c.c` file // for details on the calculations here. - if (rate > 1000000) - rate = 1000000; // Clamp the rate to 1Mbps - uint32_t period = (pclk + rate / 2) / rate; - uint32_t lcnt = period * 3 / 5; + uint32_t period; + if (rate >= 1000000) + period = DIV_ROUND_CLOSEST(pclk, 1000000); + else if (rate >= 400000) + period = DIV_ROUND_CLOSEST(pclk, 400000); + else + period = DIV_ROUND_CLOSEST(pclk, 100000); + uint32_t lcnt = DIV_ROUND_CLOSEST(period * ((3<<16) / 5), 1<<16); // 60% uint32_t hcnt = period - lcnt; uint32_t sda_tx_hold_count = ((pclk * 3) / 10000000) + 1; From 4046b34a7baf5612486cff7eba46a06b950e80d3 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 3 Mar 2026 19:21:52 -0500 Subject: [PATCH 3/9] docs: Note probe options that can change via G-Code in Config_Reference.md Signed-off-by: Kevin O'Connor --- docs/Config_Reference.md | 30 +++++++++++++++++++++--------- 1 file changed, 21 insertions(+), 9 deletions(-) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 50b1b30ac81f..55638c55d4c4 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -2137,32 +2137,44 @@ z_offset: # The distance (in mm) between the bed and the nozzle when the probe # triggers. This parameter must be provided. #speed: 5.0 -# Speed (in mm/s) of the Z axis when probing. The default is 5mm/s. +# Speed (in mm/s) of the Z axis when probing. It may be possible to +# change this value at runtime via a "PROBE_SPEED" command +# parameter. The default is 5mm/s. #samples: 1 # The number of times to probe each point. The probed z-values will -# be averaged. The default is to probe 1 time. +# be averaged. It may be possible to change this value at runtime +# via a "SAMPLES" command parameter. The default is to probe 1 time. #sample_retract_dist: 2.0 # The distance (in mm) to lift the toolhead between each sample (if -# sampling more than once). The default is 2mm. +# sampling more than once). It may be possible to change this value +# at runtime via a "SAMPLE_RETRACT_DIST" command parameter. The +# default is 2mm. #lift_speed: # Speed (in mm/s) of the Z axis when lifting the probe between -# samples. The default is to use the same value as the 'speed' -# parameter. +# samples. It may be possible to change this value at runtime via a +# "LIFT_SPEED" command parameter. The default is to use the same +# value as the 'speed' parameter. #samples_result: average # The calculation method when sampling more than once - either -# "median" or "average". The default is average. +# "median" or "average". It may be possible to change this value at +# runtime via a "SAMPLES_RESULT" command parameter. The default is +# average. #samples_tolerance: 0.100 # The maximum Z distance (in mm) that a sample may differ from other # samples. If this tolerance is exceeded then either an error is # reported or the attempt is restarted (see -# samples_tolerance_retries). The default is 0.100mm. +# samples_tolerance_retries). It may be possible to change this +# value at runtime via a "SAMPLES_TOLERANCE" command parameter. The +# default is 0.100mm. #samples_tolerance_retries: 0 # The number of times to retry if a sample is found that exceeds # samples_tolerance. On a retry, all current samples are discarded # and the probe attempt is restarted. If a valid set of samples are # not obtained in the given number of retries then an error is -# reported. The default is zero which causes an error to be reported -# on the first sample that exceeds samples_tolerance. +# reported. It may be possible to change this value at runtime via a +# "SAMPLES_TOLERANCE_RETRIES" command parameter. The default is zero +# which causes an error to be reported on the first sample that +# exceeds samples_tolerance. #activate_gcode: # A list of G-Code commands to execute prior to each probe attempt. # See docs/Command_Templates.md for G-Code format. This may be From 7f5e918331dc1695899433915659337dc50176e0 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 3 Mar 2026 14:29:48 -0500 Subject: [PATCH 4/9] manual_probe: Introduce new create_probe_result() helper function Add a new create_probe_result() helper function that can generate a ProbeResult using a toolhead position and a set of probe offsets. Use this helper in other modules. Signed-off-by: Kevin O'Connor --- klippy/extras/load_cell_probe.py | 5 +++-- klippy/extras/manual_probe.py | 11 +++++++++-- klippy/extras/probe.py | 7 ++----- klippy/extras/probe_eddy_current.py | 11 ++++------- 4 files changed, 18 insertions(+), 16 deletions(-) diff --git a/klippy/extras/load_cell_probe.py b/klippy/extras/load_cell_probe.py index 1e361d5d17ba..913b91a5b2ca 100644 --- a/klippy/extras/load_cell_probe.py +++ b/klippy/extras/load_cell_probe.py @@ -5,7 +5,7 @@ # This file may be distributed under the terms of the GNU GPLv3 license. import logging, math import mcu -from . import probe, trigger_analog, load_cell, hx71x, ads1220 +from . import probe, manual_probe, trigger_analog, load_cell, hx71x, ads1220 np = None # delay NumPy import until configuration time @@ -426,7 +426,8 @@ def end_probe_session(self): # probe until a single good sample is returned or retries are exhausted def run_probe(self, gcmd): epos, is_good = self._tapping_move.run_tap(gcmd) - res = self._probe_offsets.create_probe_result(epos) + offsets = self._probe_offsets.get_offsets() + res = manual_probe.create_probe_result(epos, offsets) self._results.append(res) def pull_probed_results(self): diff --git a/klippy/extras/manual_probe.py b/klippy/extras/manual_probe.py index 727526916c07..f3d4e6a36405 100644 --- a/klippy/extras/manual_probe.py +++ b/klippy/extras/manual_probe.py @@ -1,6 +1,6 @@ # Helper script for manual z height probing # -# Copyright (C) 2019-2025 Kevin O'Connor +# Copyright (C) 2019-2026 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. import logging, bisect, collections @@ -14,6 +14,13 @@ ProbeResult = collections.namedtuple('probe_result', [ 'bed_x', 'bed_y', 'bed_z', 'test_x', 'test_y', 'test_z']) +# Helper to create a ProbeResult from a test position and probe offsets +def create_probe_result(test_pos, offsets=(0., 0., 0.)): + x_offset, y_offset, z_offset = offsets + return ProbeResult( + test_pos[0]+x_offset, test_pos[1]+y_offset, test_pos[2]-z_offset, + test_pos[0], test_pos[1], test_pos[2]) + # Helper to lookup the Z stepper config section def lookup_z_endstop_config(config): if config.has_section('stepper_z'): @@ -283,7 +290,7 @@ def finalize(self, success): mpresult = None if success: kin_pos = self.get_kinematics_pos() - mpresult = ProbeResult(*(kin_pos[:3] + kin_pos[:3])) + mpresult = create_probe_result(kin_pos) self.finalize_callback(mpresult) def load_config(config): diff --git a/klippy/extras/probe.py b/klippy/extras/probe.py index 044875318e1c..28187bc52762 100644 --- a/klippy/extras/probe.py +++ b/klippy/extras/probe.py @@ -270,7 +270,8 @@ def run_probe(self, gcmd): speed = self.param_helper.get_probe_params(gcmd)['probe_speed'] phoming = self.printer.lookup_object('homing') ppos = phoming.probing_move(self.mcu_probe, pos, speed) - res = self.probe_offsets.create_probe_result(ppos) + offsets = self.probe_offsets.get_offsets() + res = manual_probe.create_probe_result(ppos, offsets) self.results.append(res) def pull_probed_results(self): res = self.results @@ -430,10 +431,6 @@ def __init__(self, config): self.z_offset = config.getfloat('z_offset') def get_offsets(self, gcmd=None): return self.x_offset, self.y_offset, self.z_offset - def create_probe_result(self, test_pos): - return manual_probe.ProbeResult( - test_pos[0]+self.x_offset, test_pos[1]+self.y_offset, - test_pos[2]-self.z_offset, test_pos[0], test_pos[1], test_pos[2]) ###################################################################### diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index a208c154e16d..1e31f4c735ca 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -357,7 +357,7 @@ def _await_sensor_messages(self): "probe_eddy_current sensor outage") if mcu.is_fileoutput(): # In debugging mode - just create dummy response - dummy_pr = manual_probe.ProbeResult(0., 0., 0., 0., 0., 0.) + dummy_pr = manual_probe.create_probe_result((0., 0., 0.,)) self._analysis_results.append((dummy_pr, None)) self._probe_requests.pop(0) continue @@ -384,10 +384,8 @@ def probe_results_from_avg(measures, toolhead_pos, calibration, offsets): sensor_z = calibration.freq_to_height(freq_avg) if sensor_z <= -OUT_OF_RANGE or sensor_z >= OUT_OF_RANGE: raise cmderr("probe_eddy_current sensor not in valid range") - return manual_probe.ProbeResult( - toolhead_pos[0] + offsets[0], toolhead_pos[1] + offsets[1], - toolhead_pos[2] - sensor_z, - toolhead_pos[0], toolhead_pos[1], toolhead_pos[2]) + return manual_probe.create_probe_result(toolhead_pos, + (offsets[0], offsets[1], sensor_z)) MAX_VALID_RAW_VALUE=0x03ffffff @@ -570,8 +568,7 @@ def _analyze_tap(self, measures, start_time, end_time): self._validate_samples_time(measures, start_time, end_time) pos_time = self._pull_tap_time(measures) trig_pos = self._lookup_toolhead_pos(pos_time) - return manual_probe.ProbeResult(trig_pos[0], trig_pos[1], trig_pos[2], - trig_pos[0], trig_pos[1], trig_pos[2]) + return manual_probe.create_probe_result(trig_pos) # Probe session interface def start_probe_session(self, gcmd): self._prep_trigger_analog_tap(gcmd) From 20df766e21c2899794e9781af801409af6dc6e6e Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 3 Mar 2026 14:46:11 -0500 Subject: [PATCH 5/9] probe_eddy_current: Rename config option z_offset to descend_z The config option 'z_offset' name is confusing as its behavior is notably different from how other probe hardware uses 'z_offset'. Rename to 'descend_z' to make its behavior more clear. Signed-off-by: Kevin O'Connor --- config/sample-cartographer-v3.cfg | 1 + docs/Config_Changes.md | 4 ++++ docs/Config_Reference.md | 2 +- docs/Eddy_Probe.md | 14 +++++++------- klippy/extras/probe_eddy_current.py | 24 ++++++++++++++++++------ test/klippy/eddy.cfg | 2 +- 6 files changed, 32 insertions(+), 15 deletions(-) diff --git a/config/sample-cartographer-v3.cfg b/config/sample-cartographer-v3.cfg index ee1a6fd41794..a8b9508a0e4f 100644 --- a/config/sample-cartographer-v3.cfg +++ b/config/sample-cartographer-v3.cfg @@ -50,3 +50,4 @@ frequency: 24000000 i2c_address: 42 i2c_mcu: carto i2c_bus: i2c1_PB6_PB7 +descend_z: 0.5 diff --git a/docs/Config_Changes.md b/docs/Config_Changes.md index d50a0f8e6e51..cf2e5d759ebb 100644 --- a/docs/Config_Changes.md +++ b/docs/Config_Changes.md @@ -8,6 +8,10 @@ All dates in this document are approximate. ## Changes +20260318: The `[probe_eddy_current]` config option `z_offset` has been +renamed to `descend_z`. Using the old name is deprecated and it will +be removed in the near future. + 20260214: The `MANUAL_STEPPER` G-Code command `STOP_ON_ENDSTOP` parameter has changed. See the [MANUAL_STEPPER](G-Codes.md#manual_stepper) documentation for diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 55638c55d4c4..94b332296215 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -2317,7 +2317,7 @@ sensor_type: ldc1612 #intb_pin: # MCU gpio pin connected to the ldc1612 sensor's INTB pin (if # available). The default is to not use the INTB pin. -#z_offset: +#descend_z: # The nominal distance (in mm) between the nozzle and bed that a # probing attempt should stop at. This parameter must be provided. #i2c_address: diff --git a/docs/Eddy_Probe.md b/docs/Eddy_Probe.md index aaf50b7ffc00..e3de7da0219d 100644 --- a/docs/Eddy_Probe.md +++ b/docs/Eddy_Probe.md @@ -11,7 +11,7 @@ for further details. Start by declaring a [probe_eddy_current config section](Config_Reference.md#probe_eddy_current) -in the printer.cfg file. It is recommended to set the `z_offset` to +in the printer.cfg file. It is recommended to set `descend_z` to 0.5mm. It is typical for the sensor to require an `x_offset` and `y_offset`. If these values are not known, one should estimate the values during initial calibration. @@ -42,11 +42,11 @@ tool completes it will output the sensor performance data: ``` probe_eddy_current: noise 0.000642mm, MAD_Hz=11.314 in 2525 queries Total frequency range: 45000.012 Hz -z_offset: 0.250 # noise 0.000200mm, MAD_Hz=11.000 -z_offset: 0.530 # noise 0.000300mm, MAD_Hz=12.000 -z_offset: 1.010 # noise 0.000400mm, MAD_Hz=14.000 -z_offset: 2.010 # noise 0.000600mm, MAD_Hz=12.000 -z_offset: 3.010 # noise 0.000700mm, MAD_Hz=9.000 +z: 0.250 # noise 0.000200mm, MAD_Hz=11.000 +z: 0.530 # noise 0.000300mm, MAD_Hz=12.000 +z: 1.010 # noise 0.000400mm, MAD_Hz=14.000 +z: 2.010 # noise 0.000600mm, MAD_Hz=12.000 +z: 3.010 # noise 0.000700mm, MAD_Hz=9.000 ``` issue a `SAVE_CONFIG` command to save the results to the printer.cfg and restart. @@ -167,7 +167,7 @@ calibration routine output: ``` probe_eddy_current: noise 0.000642mm, MAD_Hz=11.314 ... -z_offset: 1.010 # noise 0.000400mm, MAD_Hz=14.000 +z: 1.010 # noise 0.000400mm, MAD_Hz=14.000 ``` The estimation will be: ``` diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index 1e31f4c735ca..11e05698b509 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -206,7 +206,7 @@ def validate_calibration_data(self, positions): for pos, _, mad_hz, mad_mm in filtered: if len(points) and points[0] <= pos: points.pop(0) - msg = "z_offset: %.3f # noise %.6fmm, MAD_Hz=%.3f\n" % ( + msg = "z: %.3f # noise %.6fmm, MAD_Hz=%.3f\n" % ( pos, mad_mm, mad_hz) gcode.respond_info(msg) return filtered @@ -399,6 +399,12 @@ def __init__(self, config, sensor_helper, calibration, self._probe_offsets = probe_offsets self._param_helper = param_helper self._trigger_analog = trigger_analog + if (config.get('z_offset', None, note_valid=False) is not None + and config.get('descend_z', None, note_valid=False) is None): + config.deprecate('z_offset') + self._descend_z = config.getfloat('z_offset', above=0.) + else: + self._descend_z = config.getfloat('descend_z', above=0.) self._z_min_position = probe.lookup_minimum_z(config) self._gather = None def _prep_trigger_analog(self): @@ -406,8 +412,7 @@ def _prep_trigger_analog(self): sos_filter.set_filter_design(None) sos_filter.set_offset_scale(0, 1.) self._trigger_analog.set_raw_range(0, MAX_VALID_RAW_VALUE) - z_offset = self._probe_offsets.get_offsets()[2] - trigger_freq = self._calibration.height_to_freq(z_offset) + trigger_freq = self._calibration.height_to_freq(self._descend_z) conv_freq = self._sensor_helper.convert_frequency(trigger_freq) self._trigger_analog.set_trigger('gt', conv_freq) # Probe session interface @@ -471,8 +476,7 @@ def probe_prepare(self, hmove): def probe_finish(self, hmove): pass def get_position_endstop(self): - z_offset = self._eddy_descend._probe_offsets.get_offsets()[2] - return z_offset + return self._eddy_descend._descend_z # Probing helper for "tap" requests class EddyTap: @@ -661,6 +665,14 @@ def end_probe_session(self): self._gather.finish() self._gather = None +# Eddy specific ProbeOffsets class (does not store z_offset) +class EddyProbeOffsets: + def __init__(self, config): + self.x_offset = config.getfloat('x_offset', 0.) + self.y_offset = config.getfloat('y_offset', 0.) + def get_offsets(self, gcmd=None): + return self.x_offset, self.y_offset, 0. + # Main "printer object" class PrinterEddyProbe: def __init__(self, config): @@ -674,7 +686,7 @@ def __init__(self, config): trig_analog = trigger_analog.MCU_trigger_analog(self.sensor_helper) probe.LookupZSteppers(config, trig_analog.get_dispatch().add_stepper) # Basic probe requests - self.probe_offsets = probe.ProbeOffsetsHelper(config) + self.probe_offsets = EddyProbeOffsets(config) self.param_helper = probe.ProbeParameterHelper(config) self.eddy_descend = EddyDescend( config, self.sensor_helper, self.calibration, self.probe_offsets, diff --git a/test/klippy/eddy.cfg b/test/klippy/eddy.cfg index 6f26899ef9e5..fd6a2a005ced 100644 --- a/test/klippy/eddy.cfg +++ b/test/klippy/eddy.cfg @@ -57,7 +57,7 @@ min_temp: 0 max_temp: 130 [probe_eddy_current eddy] -z_offset: 0.4 +descend_z: 0.4 x_offset: -5 y_offset: -4 sensor_type: ldc1612 From 6c8c8d24d7c07864857f80c3ecb232832cf33e62 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 3 Mar 2026 15:33:39 -0500 Subject: [PATCH 6/9] probe_eddy_current: Support new tap_z_offset config parameter Theoretically a "tap" probe should detect the exact point that the nozzle contacts the bed. In practice, however, there can be a systemic bias that one may wish to account for. This bias may be due to backlash, thermal expansion, a detection bias, or similar issues. Add a new tap_z_offset config parameter to allow users to specify an offset. Also, update the Z_OFFSET_APPLY_PROBE command to support modifying this value. Signed-off-by: Kevin O'Connor --- docs/Config_Reference.md | 6 ++++++ docs/G-Codes.md | 8 ++++++++ klippy/extras/probe_eddy_current.py | 20 +++++++++++++++++++- 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 94b332296215..db0ca25f2cc2 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -2348,6 +2348,12 @@ sensor_type: ldc1612 # the bed. If this value is specified then one may override its # value at run-time using the "TAP_THRESHOLD" parameter on probe # commands. The default is to not enable support for "tap" probing. +#tap_z_offset: 0.0 +# The Z height (in mm) of the nozzle relative to the bed at the +# contact point detected during "tap" probing. Nominally this would +# be 0.0 to indicate the contact point has zero distance, but one +# may set this to account for backlash, thermal expansion, a +# systemic probing bias, or similar. The default is zero. ``` ### [axis_twist_compensation] diff --git a/docs/G-Codes.md b/docs/G-Codes.md index 9b876a059f67..1afaa2bb8c9e 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -1251,6 +1251,14 @@ additional parameters if a `[probe_eddy_current]` section is defined: specified in the `[probe_eddy_current]` config section when probing using `METHOD=tap`. +The `Z_OFFSET_APPLY_PROBE` command is also extended to support a +`METHOD=tap` parameter. When no METHOD parameter is provided, the +`Z_OFFSET_APPLY_PROBE` command alters the probe calibration to apply +the current Z G-Code offset to future `scan`, `rapid_scan`, and +default probes. If `METHOD=tap` is specified then the command instead +applies the change to `tap_z_offset` so that future `tap` probes are +updated to use the current Z G-Code offset. + #### PROBE_EDDY_CURRENT_CALIBRATE `PROBE_EDDY_CURRENT_CALIBRATE CHIP=`: This starts a tool that calibrates the sensor resonance frequencies to corresponding Z diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index 11e05698b509..f3567e8b66fc 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -260,6 +260,19 @@ def _save_calibration(self, z_freq_pairs): cal_contents.pop() configfile = self.printer.lookup_object('configfile') configfile.set(self.name, 'calibrate', ''.join(cal_contents)) + def _save_tap_z_offset(self, gcmd, homing_z): + eventtime = self.printer.get_reactor().monotonic() + configfile = self.printer.lookup_object('configfile') + cstatus = configfile.get_status(eventtime) + csettings = cstatus.get('settings', {}).get(self.name, {}) + tap_z_offset = csettings.get('tap_z_offset', 0.) + new_calibrate = tap_z_offset - homing_z + gcmd.respond_info( + "%s: tap_z_offset: %.3f\n" + "The SAVE_CONFIG command will update the printer config file\n" + "with the above and restart the printer." + % (self.name, new_calibrate)) + configfile.set(self.name, 'tap_z_offset', "%.3f" % (new_calibrate,)) cmd_EDDY_CALIBRATE_help = "Calibrate eddy current probe" def cmd_EDDY_CALIBRATE(self, gcmd): self.probe_speed = gcmd.get_float("PROBE_SPEED", 5., above=0.) @@ -273,6 +286,9 @@ def cmd_Z_OFFSET_APPLY_PROBE(self, gcmd): if offset == 0: gcmd.respond_info("Nothing to do: Z Offset is 0") return + if gcmd.get("METHOD", "").lower() == "tap": + self._save_tap_z_offset(gcmd, offset) + return cal_zpos = [z - offset for z in self.cal_zpos] z_freq_pairs = zip(cal_zpos, self.cal_freqs) z_freq_pairs = sorted(z_freq_pairs) @@ -488,6 +504,7 @@ def __init__(self, config, sensor_helper, param_helper, trigger_analog): self._z_min_position = probe.lookup_minimum_z(config) self._gather = None self._filter_design = None + self._tap_z_offset = config.getfloat('tap_z_offset', 0.) self._tap_threshold = config.getfloat('tap_threshold', 0., above=0.) if self._tap_threshold: self._setup_tap() @@ -572,7 +589,8 @@ def _analyze_tap(self, measures, start_time, end_time): self._validate_samples_time(measures, start_time, end_time) pos_time = self._pull_tap_time(measures) trig_pos = self._lookup_toolhead_pos(pos_time) - return manual_probe.create_probe_result(trig_pos) + return manual_probe.create_probe_result(trig_pos, + (0., 0., self._tap_z_offset)) # Probe session interface def start_probe_session(self, gcmd): self._prep_trigger_analog_tap(gcmd) From e5c3dfe7a89538b597f955896cb03a9a11a7c7f7 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 3 Mar 2026 18:53:37 -0500 Subject: [PATCH 7/9] probe_eddy_current: Use different defaults for "scan", "rapid_scan" and "tap" Add a new EddyParameterHelper to override ProbeParameterHelper. Only use the printer.cfg lift_speed, samples, sample_retract_dist, samples_result, samples_tolerance, and samples_tolerance_retries settings for normal probe operations. Don't use these defaults when using a "METHOD" set to "scan", "rapid_scan", or "tap". Each of these probing mechanisms is distinct and it's unlikely a user could meaningfully set a default for all of them. Don't set sample_retract_dist when using "scan" and "rapid_scan" modes. Signed-off-by: Kevin O'Connor --- docs/Config_Changes.md | 9 +++++++++ docs/Config_Reference.md | 7 ++++++- docs/Eddy_Probe.md | 9 --------- klippy/extras/probe_eddy_current.py | 31 +++++++++++++++++++++++++++-- 4 files changed, 44 insertions(+), 12 deletions(-) diff --git a/docs/Config_Changes.md b/docs/Config_Changes.md index cf2e5d759ebb..7fe758d7a74d 100644 --- a/docs/Config_Changes.md +++ b/docs/Config_Changes.md @@ -8,6 +8,15 @@ All dates in this document are approximate. ## Changes +20260318: The `[probe_eddy_current]` config options `speed`, +`lift_speed`, `samples`, `sample_retract_dist`, `samples_result`, +`samples_tolerance`, and `samples_tolerance_retries` no longer apply +to probe commands using `METHOD=scan`, `METHOD=rapid_scan`, nor +`METHOD=tap`. To use different settings, supply the equivalent +`PROBE_SPEED`, `LIFT_SPEED`, `SAMPLES`, `SAMPLE_RETRACT_DIST`, +`SAMPLES_RESULT`, `SAMPLES_TOLERANCE`, or `SAMPLES_TOLERANCE_RETRIES` +parameter with the probe command. + 20260318: The `[probe_eddy_current]` config option `z_offset` has been renamed to `descend_z`. Using the old name is deprecated and it will be removed in the near future. diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index db0ca25f2cc2..d247a9eccc8c 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -2330,6 +2330,8 @@ sensor_type: ldc1612 # settings" section for a description of the above parameters. #x_offset: #y_offset: +# The distance (in mm) between the probe and the nozzle along the +# x and y axes. The default is 0. #speed: #lift_speed: #samples: @@ -2337,7 +2339,10 @@ sensor_type: ldc1612 #samples_result: #samples_tolerance: #samples_tolerance_retries: -# See the "probe" section for information on these parameters. +# See the "probe" section for information on these parameters. Note +# that the settings here apply only to regular probe commands. These +# settings do not have an effect if using a probe "METHOD" of +# "scan", "rapid_scan", or "tap". #tap_threshold: # Noise cutoff/stop trigger threshold (in Hz). Specify this value to # enable support for "METHOD=tap" probe commands. See Eddy_Probe.md diff --git a/docs/Eddy_Probe.md b/docs/Eddy_Probe.md index e3de7da0219d..14bc1e7e87c3 100644 --- a/docs/Eddy_Probe.md +++ b/docs/Eddy_Probe.md @@ -116,15 +116,6 @@ Practically, it ensures that the Eddy's output data absolute value change per second (velocity) is high enough - higher than the noise level, and that upon collision it always decreases by at least this value. -``` -[probe_eddy_current my_probe] -# eddy probe configuration... -# Recommended starting values for the tap -#samples: 3 -#samples_tolerance: 0.025 -#samples_tolerance_retries: 3 -``` - Before setting it to any other value, it is necessary to install `scipy`: ```bash diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index f3567e8b66fc..abefcf8d73a4 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -657,7 +657,7 @@ def start_probe_session(self, gcmd): self._calibration.verify_calibrated() self._gather = EddyGatherSamples(self._printer, self._sensor_helper) self._sample_time = gcmd.get_float("SAMPLE_TIME", 0.100, above=0.0) - self._is_rapid = gcmd.get("METHOD", "scan") == 'rapid_scan' + self._is_rapid = gcmd.get("METHOD", "scan").lower() == 'rapid_scan' return self def run_probe(self, gcmd): toolhead = self._printer.lookup_object("toolhead") @@ -691,6 +691,33 @@ def __init__(self, config): def get_offsets(self, gcmd=None): return self.x_offset, self.y_offset, 0. +# Wrapper around ProbeParameterHelper +class EddyParameterHelper: + def __init__(self, config): + self._param_helper = probe.ProbeParameterHelper(config) + def get_probe_params(self, gcmd=None): + method = None + if gcmd is not None: + method = gcmd.get('METHOD', '').lower() + if method not in ['scan', 'rapid_scan', 'tap']: + return self._param_helper.get_probe_params(gcmd) + probe_speed = gcmd.get_float("PROBE_SPEED", 5.0, above=0.) + lift_speed = gcmd.get_float("LIFT_SPEED", 5.0, above=0.) + samples = gcmd.get_int("SAMPLES", 1, minval=1) + samp_retract_dist = gcmd.get_float("SAMPLE_RETRACT_DIST", 2.0, above=0.) + if method in ['scan', 'rapid_scan']: + samp_retract_dist = 0. + samp_tolerance = gcmd.get_float("SAMPLES_TOLERANCE", 0.100, minval=0.) + samp_retries = gcmd.get_int("SAMPLES_TOLERANCE_RETRIES", 0, minval=0) + samples_result = gcmd.get("SAMPLES_RESULT", 'average') + return {'probe_speed': probe_speed, + 'lift_speed': lift_speed, + 'samples': samples, + 'sample_retract_dist': samp_retract_dist, + 'samples_tolerance': samp_tolerance, + 'samples_tolerance_retries': samp_retries, + 'samples_result': samples_result} + # Main "printer object" class PrinterEddyProbe: def __init__(self, config): @@ -705,7 +732,7 @@ def __init__(self, config): probe.LookupZSteppers(config, trig_analog.get_dispatch().add_stepper) # Basic probe requests self.probe_offsets = EddyProbeOffsets(config) - self.param_helper = probe.ProbeParameterHelper(config) + self.param_helper = EddyParameterHelper(config) self.eddy_descend = EddyDescend( config, self.sensor_helper, self.calibration, self.probe_offsets, self.param_helper, trig_analog) From de280e237b8b962d09f7645f5b00ad0d24c5e6e6 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 24 Feb 2026 13:26:01 -0500 Subject: [PATCH 8/9] probe_eddy_current: Retract at end of each "tap" probe Perform lifting at the end of EddyTap.run_probe(). This is in preparation for performing tap analysis during retraction. Signed-off-by: Kevin O'Connor --- klippy/extras/probe_eddy_current.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index abefcf8d73a4..586b3a7b511d 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -600,7 +600,10 @@ def run_probe(self, gcmd): toolhead = self._printer.lookup_object('toolhead') pos = toolhead.get_position() pos[2] = self._z_min_position - speed = self._param_helper.get_probe_params(gcmd)['probe_speed'] + params = self._param_helper.get_probe_params(gcmd) + speed = params['probe_speed'] + lift_speed = params['lift_speed'] + lift_dist = gcmd.get_float('SAMPLE_RETRACT_DIST', 4., above=0.) move_start_time = toolhead.get_last_move_time() # Perform probing move phoming = self._printer.lookup_object('homing') @@ -614,6 +617,10 @@ def run_probe(self, gcmd): end_time = trigger_time self._gather.add_probe_request(self._analyze_tap, start_time, end_time, start_time, end_time) + # Perform lifting move + haltpos = toolhead.get_position() + haltpos[2] += lift_dist + toolhead.manual_move(haltpos, lift_speed) def pull_probed_results(self): return self._gather.pull_probed() def end_probe_session(self): @@ -704,9 +711,7 @@ def get_probe_params(self, gcmd=None): probe_speed = gcmd.get_float("PROBE_SPEED", 5.0, above=0.) lift_speed = gcmd.get_float("LIFT_SPEED", 5.0, above=0.) samples = gcmd.get_int("SAMPLES", 1, minval=1) - samp_retract_dist = gcmd.get_float("SAMPLE_RETRACT_DIST", 2.0, above=0.) - if method in ['scan', 'rapid_scan']: - samp_retract_dist = 0. + samp_retract_dist = 0. samp_tolerance = gcmd.get_float("SAMPLES_TOLERANCE", 0.100, minval=0.) samp_retries = gcmd.get_int("SAMPLES_TOLERANCE_RETRIES", 0, minval=0) samples_result = gcmd.get("SAMPLES_RESULT", 'average') From 4db1bf8ac689191a02c2f2a37e57c2315cf761a1 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 3 Mar 2026 20:02:15 -0500 Subject: [PATCH 9/9] probe_eddy_current: Calculate "tap" position from lift movement Don't calculate the nozzle/bed contact position from the descent movement. Instead, obtain the data during the lifting movement and determine the contact point by analyzing where the nozzle separates from the bed. This improves the precision and repeatability of the "tap" results. Signed-off-by: Kevin O'Connor --- klippy/extras/probe_eddy_current.py | 132 +++++++++++++++++++--------- 1 file changed, 90 insertions(+), 42 deletions(-) diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index 586b3a7b511d..94bf9a7f8771 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -1,9 +1,9 @@ # Support for eddy current based Z probes # -# Copyright (C) 2021-2024 Kevin O'Connor +# Copyright (C) 2021-2026 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. -import logging, math, bisect +import sys, logging, math, bisect import mcu from . import ldc1612, trigger_analog, probe, manual_probe @@ -553,31 +553,6 @@ def _validate_samples_time(self, measures, start_time, end_time): if tmin < cycle_time * 0.75: raise cmderr("Eddy: CLKIN frequency too low: %.3f < %.3f" % (tmin, cycle_time * 0.75)) - def _central_diff(self, times, values): - velocity = [0.0] * len(values) - for i in range(1, len(values) - 1): - delta_v = (values[i+1] - values[i-1]) - delta_t = (times[i+1] - times[i-1]) - velocity[i] = delta_v / delta_t - velocity[0] = (values[1] - values[0]) / (times[1] - times[0]) - velocity[-1] = (values[-1] - values[-2]) / (times[-1] - times[-2]) - return velocity - def _pull_tap_time(self, measures): - tap_time = [] - tap_value = [] - for time, freq, z in measures: - tap_time.append(time) - tap_value.append(freq) - # Do the same filtering as on the MCU but without induced lag - main_design = self._filter_design.get_main_filter() - try: - fvals = main_design.filtfilt(tap_value) - except ValueError as e: - raise self._printer.command_error(str(e)) - velocity = self._central_diff(tap_time, fvals) - peak_velocity = max(velocity) - i = velocity.index(peak_velocity) - return tap_time[i] def _lookup_toolhead_pos(self, pos_time): toolhead = self._printer.lookup_object('toolhead') kin = toolhead.get_kinematics() @@ -585,12 +560,89 @@ def _lookup_toolhead_pos(self, pos_time): s.get_past_mcu_position(pos_time)) for s in kin.get_steppers()} return kin.calc_position(kin_spos) - def _analyze_tap(self, measures, start_time, end_time): + def _calc_least_squares(self, eqs, ans, est_z_contact): + # XXX - this implementation is not efficient + len_data = len(eqs) + import numpy + for i in range(len_data): + eq = eqs[i] + step_z = eq[1] + if step_z < est_z_contact: + eq[2] = eq[3] = 0. + continue + eq[2] = (step_z - est_z_contact) + eq[3] = (step_z - est_z_contact)**2 + res = numpy.linalg.lstsq(eqs, ans, rcond=None) + coeffs = list(res[0]) + if coeffs[3] < 0.: + # z**2 factor can't be negative - retry using only linear + res = numpy.linalg.lstsq(eqs[:][:,:3], ans, rcond=None) + coeffs = list(res[0]) + [0.] + if not res[1]: + err = sys.float_info.max + else: + err = res[1][0] + #logging.info("z=%.6f err=%.3f coeffs=%s", est_z_contact, err, coeffs) + return err, coeffs + def _find_least_squares(self, data): + len_data = len(data) + import numpy + # Populate initial numpy linear least squares arrays + eqs = numpy.zeros((len_data, 4)) + ans = numpy.zeros((len_data,)) + for i, (sensor_freq, tool_pos) in enumerate(data): + ans[i] = sensor_freq + eq = eqs[i] + eq[0] = 1. + eq[1] = tool_pos[2] + #logging.info("sample: freq=%.3f z=%.6f", sensor_freq, tool_pos[2]) + # Run least squares with various z values to reduce residual error + min_z = best_z = eqs[0][1] + max_z = eqs[-1][1] + best_err = sys.float_info.max + best_coeffs = [0., 0., 0., 0.] + while max_z - min_z > 0.000250: + # Select z value to check + mid_z = (min_z + max_z) * .5 + if best_z < mid_z: + guess_z = (best_z + max_z) * .5 + else: + guess_z = (min_z + best_z) * .5 + # Calculate least squares error for given z + guess_err, coeffs = self._calc_least_squares(eqs, ans, guess_z) + # Update search bounds + if guess_err < best_err: + if guess_z > best_z: + min_z = best_z + else: + max_z = best_z + best_z = guess_z + best_err = guess_err + best_coeffs = coeffs + else: + if guess_z > best_z: + max_z = guess_z + else: + min_z = guess_z + best_coeffs = [float(v) for v in best_coeffs] + #logging.info("best: z=%.6f err=%.6f coeffs=%s", + # best_z, best_err, best_coeffs) + return float(best_z), best_coeffs + def _analyze_pullback(self, measures, start_time, end_time): self._validate_samples_time(measures, start_time, end_time) - pos_time = self._pull_tap_time(measures) - trig_pos = self._lookup_toolhead_pos(pos_time) - return manual_probe.create_probe_result(trig_pos, - (0., 0., self._tap_z_offset)) + # Correlate measurements to toolhead position at time of measurement + data = [(sensor_freq, self._lookup_toolhead_pos(samp_time)) + for samp_time, sensor_freq, sensor_z in measures] + # Find best fit for extracted measurements + z_contact, coeffs = self._find_least_squares(data) + # Report probe position + trig_idx = len(data)-1 + while trig_idx > 0 and data[trig_idx-1][1][2] > z_contact: + trig_idx -= 1 + trig_pos = data[trig_idx][1] + adj_z_contact = z_contact - self._tap_z_offset + return manual_probe.ProbeResult(trig_pos[0], trig_pos[1], adj_z_contact, + trig_pos[0], trig_pos[1], trig_pos[2]) # Probe session interface def start_probe_session(self, gcmd): self._prep_trigger_analog_tap(gcmd) @@ -604,23 +656,19 @@ def run_probe(self, gcmd): speed = params['probe_speed'] lift_speed = params['lift_speed'] lift_dist = gcmd.get_float('SAMPLE_RETRACT_DIST', 4., above=0.) - move_start_time = toolhead.get_last_move_time() # Perform probing move phoming = self._printer.lookup_object('homing') trig_pos = phoming.probing_move(self._trigger_analog, pos, speed) - # Extract samples - trigger_time = self._trigger_analog.get_last_trigger_time() - start_time = trigger_time - 0.250 - if start_time < move_start_time: - # Filter short move - start_time = move_start_time - end_time = trigger_time - self._gather.add_probe_request(self._analyze_tap, start_time, end_time, - start_time, end_time) # Perform lifting move haltpos = toolhead.get_position() haltpos[2] += lift_dist + retract_start_time = toolhead.get_last_move_time() toolhead.manual_move(haltpos, lift_speed) + # Extract retract samples + start_time = retract_start_time - 0.010 + end_time = retract_start_time + 0.150 + self._gather.add_probe_request(self._analyze_pullback, start_time, + end_time, start_time, end_time) def pull_probed_results(self): return self._gather.pull_probed() def end_probe_session(self):