From 6349d4fb082a2ce36f0ff67d4e378cd9901f11c7 Mon Sep 17 00:00:00 2001 From: Timofey Titovets Date: Thu, 23 Apr 2026 18:21:29 +0200 Subject: [PATCH 01/32] github: bump checkout and cache actions to node v24 Signed-off-by: Timofey Titovets --- .github/workflows/build-test.yaml | 4 ++-- .github/workflows/klipper3d-deploy.yaml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build-test.yaml b/.github/workflows/build-test.yaml index 460e89f6cb2a..0cb237671234 100644 --- a/.github/workflows/build-test.yaml +++ b/.github/workflows/build-test.yaml @@ -6,10 +6,10 @@ jobs: build: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Setup cache - uses: actions/cache@v3 + uses: actions/cache@v5 with: path: ci_cache key: ${{ runner.os }}-build-${{ hashFiles('scripts/ci-install.sh') }} diff --git a/.github/workflows/klipper3d-deploy.yaml b/.github/workflows/klipper3d-deploy.yaml index 076be8dac90e..489ca0ab2f2a 100644 --- a/.github/workflows/klipper3d-deploy.yaml +++ b/.github/workflows/klipper3d-deploy.yaml @@ -12,12 +12,12 @@ jobs: deploy: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v5 - name: Setup python uses: actions/setup-python@v4 with: python-version: '3.8' - - uses: actions/cache@v3 + - uses: actions/cache@v5 with: path: ~/.cache/pip key: ${{ runner.os }}-pip-${{ hashFiles('docs/_klipper3d/mkdocs-requirements.txt') }} From e4c4a5b949f48f7bf1a77506ab3c3582f08aa9c8 Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Thu, 19 Mar 2026 23:31:03 +0100 Subject: [PATCH 02/32] shaper_defs: Generalized MZV input shaper Signed-off-by: Dmitry Butyugin --- klippy/extras/shaper_defs.py | 54 ++++++++++++++++++++++++++++-------- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/klippy/extras/shaper_defs.py b/klippy/extras/shaper_defs.py index 70e573ecce69..6e4489ff4d84 100644 --- a/klippy/extras/shaper_defs.py +++ b/klippy/extras/shaper_defs.py @@ -1,9 +1,10 @@ # Definitions of the supported input shapers # -# Copyright (C) 2020-2021 Dmitry Butyugin +# Copyright (C) 2020-2026 Dmitry Butyugin # # This file may be distributed under the terms of the GNU GPLv3 license. -import collections, math +import collections, copy, math +import mathutil SHAPER_VIBRATION_REDUCTION=20. DEFAULT_DAMPING_RATIO = 0.1 @@ -12,6 +13,9 @@ 'InputShaperCfg', ('name', 'init_func', 'min_freq', 'max_damping_ratio')) +class ShaperError(Exception): + pass + def get_none_shaper(): return ([], []) @@ -31,17 +35,45 @@ def get_zvd_shaper(shaper_freq, damping_ratio): T = [0., .5*t_d, t_d] return (A, T) -def get_mzv_shaper(shaper_freq, damping_ratio): +def get_mzv_coeffs(n, t): + if n < 3: + raise ShaperError("Too small n=%d, must be at least 3" % n) + if n <= 2 * t + 1 + 1e-7: + raise ShaperError("Too large t=%.6f for n=%d, must be less than %.6f" % + (t, n, 0.5 * (n - 1))) + # Projected shaper duration with n -> \infinity for computing shaper zeros + tau = t * (n - 2) / (n - 2 * t - 1) + T = [i * t / (n-1) for i in range(n)] + # Build a system of equations for A. The first equation is sum(A) = 1 + M = [[1.] * n] + F = [1.] + # Ensure correct placement of shaper zeros. Note that the system is not + # over-contrained, as the extra equations are linearly-dependent. + for i in range(n-1): + W = [2. * math.pi * (1. + i / tau) * tj for tj in T] + M.append([math.cos(w) for w in W]) + M.append([math.sin(w) for w in W]) + F.append(0.) + F.append(0.) + M_inv = mathutil.pseudo_inverse(M) + if M_inv is None: + raise ShaperError("Ill-formed shaper with n=%d, t=%.6f" % (n, t)) + A = mathutil.mat_mat_mul([F], mathutil.mat_transp(M_inv))[0] + if any(a < -0.00001 for a in A): + raise ShaperError("Negative-valued shaper with n=%d, t=%.6f" % (n, t)) + return (A, T) + +def get_mzv_shaper(shaper_freq, damping_ratio, n=3, t=0.75): + A, T = get_mzv_coeffs(n, t) + # Apply damping df = math.sqrt(1. - damping_ratio**2) - K = math.exp(-.75 * damping_ratio * math.pi / df) + K = math.exp(-2. * t * damping_ratio * math.pi / ((n - 1.) * df)) t_d = 1. / (shaper_freq * df) - - a1 = 1. - 1. / math.sqrt(2.) - a2 = (math.sqrt(2.) - 1.) * K - a3 = a1 * K * K - - A = [a1, a2, a3] - T = [0., .375*t_d, .75*t_d] + Kp = K + for i in range(1, n): + T[i] *= t_d + A[i] *= Kp + Kp *= K return (A, T) def get_ei_shaper(shaper_freq, damping_ratio): From db10e92e9b3a4112e7f8fdafffbdb0969e94bcfd Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Fri, 20 Mar 2026 00:37:52 +0100 Subject: [PATCH 03/32] shaper_defs: Support parameterized shaper initialization Signed-off-by: Dmitry Butyugin --- klippy/extras/input_shaper.py | 29 +++++++++++++++--------- klippy/extras/shaper_calibrate.py | 27 ++++++++++++++++------ klippy/extras/shaper_defs.py | 37 ++++++++++++++++++++++++++++++- scripts/calibrate_shaper.py | 4 ++-- scripts/graph_shaper.py | 24 +++++++------------- 5 files changed, 85 insertions(+), 36 deletions(-) diff --git a/klippy/extras/input_shaper.py b/klippy/extras/input_shaper.py index 39b3f5a8573f..e407fd84d2ea 100644 --- a/klippy/extras/input_shaper.py +++ b/klippy/extras/input_shaper.py @@ -11,40 +11,49 @@ class InputShaperParams: def __init__(self, axis, config): self.axis = axis - self.shapers = {s.name : s for s in shaper_defs.INPUT_SHAPERS} shaper_type = config.get('shaper_type', 'mzv') self.shaper_type = config.get('shaper_type_' + axis, shaper_type) - if self.shaper_type not in self.shapers: + sconfig = shaper_defs.get_shaper_cfg(self.shaper_type) + if sconfig is None: raise config.error( 'Unsupported shaper type: %s' % (self.shaper_type,)) self.damping_ratio = config.getfloat( 'damping_ratio_' + axis, shaper_defs.DEFAULT_DAMPING_RATIO, minval=0., - maxval=self.shapers[self.shaper_type].max_damping_ratio) + maxval=sconfig.max_damping_ratio) self.shaper_freq = config.getfloat('shaper_freq_' + axis, 0., minval=0.) + # Validate input shaper + self.get_shaper(error=config.error) def update(self, gcmd): axis = self.axis.upper() shaper_type = gcmd.get('SHAPER_TYPE', None) if shaper_type is None: shaper_type = gcmd.get('SHAPER_TYPE_' + axis, self.shaper_type) - if shaper_type.lower() not in self.shapers: + sconfig = shaper_defs.get_shaper_cfg(shaper_type.lower()) + if sconfig is None: raise gcmd.error('Unsupported shaper type: %s' % (shaper_type,)) damping_ratio = gcmd.get_float('DAMPING_RATIO_' + axis, self.damping_ratio, minval=0.) - if damping_ratio > self.shapers[shaper_type.lower()].max_damping_ratio: + if damping_ratio > sconfig.max_damping_ratio: raise gcmd.error( 'Too high value of damping_ratio=%.3f for shaper %s' ' on axis %c' % (damping_ratio, shaper_type, axis)) - self.shaper_freq = gcmd.get_float('SHAPER_FREQ_' + axis, - self.shaper_freq, minval=0.) + shaper_freq = gcmd.get_float('SHAPER_FREQ_' + axis, + self.shaper_freq, minval=0.) + # Validate input shaper + self.get_shaper(shaper_type.lower(), shaper_freq, damping_ratio, + gcmd.error) self.damping_ratio = damping_ratio self.shaper_type = shaper_type.lower() - def get_shaper(self): + def get_shaper(self, shaper_type=None, shaper_freq=None, + damping_ratio=None, error=None): if not self.shaper_freq: A, T = shaper_defs.get_none_shaper() else: - A, T = self.shapers[self.shaper_type].init_func( - self.shaper_freq, self.damping_ratio) + A, T = shaper_defs.init_shaper(shaper_type or self.shaper_type, + shaper_freq or self.shaper_freq, + damping_ratio or self.damping_ratio, + error=error) return len(A), A, T def get_status(self): return collections.OrderedDict([ diff --git a/klippy/extras/shaper_calibrate.py b/klippy/extras/shaper_calibrate.py index db5c8e1cadc7..f8f7faf813ea 100644 --- a/klippy/extras/shaper_calibrate.py +++ b/klippy/extras/shaper_calibrate.py @@ -247,7 +247,7 @@ def _get_shaper_smoothing(self, shaper, accel=5000, scv=5.): offset_180 *= inv_D return max(offset_90, offset_180) - def fit_shaper(self, shaper_cfg, calibration_data, shaper_freqs, + def fit_shaper(self, shaper_name, calibration_data, shaper_freqs, damping_ratio, scv, max_smoothing, test_damping_ratios, max_freq): np = self.numpy @@ -259,7 +259,8 @@ def fit_shaper(self, shaper_cfg, calibration_data, shaper_freqs, shaper_freqs = (None, None, None) if isinstance(shaper_freqs, tuple): freq_end = shaper_freqs[1] or MAX_SHAPER_FREQ - freq_start = min(shaper_freqs[0] or shaper_cfg.min_freq, + freq_start = min(shaper_freqs[0] or + shaper_defs.get_shaper_cfg(shaper_name).min_freq, freq_end - 1e-7) freq_step = shaper_freqs[2] or .2 test_freqs = np.arange(freq_start, freq_end, freq_step) @@ -275,7 +276,8 @@ def fit_shaper(self, shaper_cfg, calibration_data, shaper_freqs, min_freq = min(min_freq, data.freq_bins.min()) for test_freq in test_freqs[::-1]: shaper_vibrations = 0. - shaper = shaper_cfg.init_func(test_freq, damping_ratio) + shaper = shaper_defs.init_shaper(shaper_name, test_freq, + damping_ratio) shaper_smoothing = self._get_shaper_smoothing(shaper, scv=scv) if max_smoothing and shaper_smoothing > max_smoothing and best_res: return [best_res] + results @@ -310,7 +312,7 @@ def fit_shaper(self, shaper_cfg, calibration_data, shaper_freqs, freq_bins, vals)) results.append( CalibrationResult( - name=shaper_cfg.name, freq=test_freq, + name=shaper_name, freq=test_freq, freq_bins=shaper_freq_bins, vals=shaper_vals, vibrs=shaper_vibrations, smoothing=shaper_smoothing, score=shaper_score, max_accel=max_accel)) @@ -359,11 +361,22 @@ def find_best_shaper(self, calibration_data, shapers=None, best_shaper = None all_shapers = [] shapers = shapers or AUTOTUNE_SHAPERS - for shaper_cfg in shaper_defs.INPUT_SHAPERS: - if shaper_cfg.name not in shapers: + for shaper_name in shapers: + shaper_cfg = shaper_defs.get_shaper_cfg(shaper_name) + if shaper_defs.get_shaper_cfg(shaper_name) is None: + if logger is not None: + logger("Unknown shaper %s" % shaper_name) continue + try: + shaper_defs.init_shaper(shaper_name, shaper_cfg.min_freq, + shaper_defs.DEFAULT_DAMPING_RATIO) + except Exception as e: + if logger is not None: + logger("Cannot initialize shaper %s: %s" % (shaper_name, + str(e))) + continue fit_results = self.background_process_exec(self.fit_shaper, ( - shaper_cfg, calibration_data, shaper_freqs, damping_ratio, + shaper_name, calibration_data, shaper_freqs, damping_ratio, scv, max_smoothing, test_damping_ratios, max_freq)) shaper = fit_results[0] results = fit_results[1:] diff --git a/klippy/extras/shaper_defs.py b/klippy/extras/shaper_defs.py index 6e4489ff4d84..383df5799a27 100644 --- a/klippy/extras/shaper_defs.py +++ b/klippy/extras/shaper_defs.py @@ -3,7 +3,7 @@ # Copyright (C) 2020-2026 Dmitry Butyugin # # This file may be distributed under the terms of the GNU GPLv3 license. -import collections, copy, math +import collections, copy, math, re import mathutil SHAPER_VIBRATION_REDUCTION=20. @@ -151,3 +151,38 @@ def get_3hump_ei_shaper(shaper_freq, damping_ratio): InputShaperCfg(name='3hump_ei', init_func=get_3hump_ei_shaper, min_freq=48., max_damping_ratio=0.2), ] + +def get_shaper_cfg(shaper_name): + m = re.match(r"(\w+)\s*\((.*)\)$", shaper_name) + if m: + shaper_name = m.group(1) + for s in INPUT_SHAPERS: + if shaper_name == s.name: + return s + return None + +def init_shaper(shaper_name, shaper_freq, damping_ratio, error=None): + try: + m = re.match(r"(\w+)\s*\((.*)\)$", shaper_name) + args_l = [] + args_kv = {} + if m: + shaper_name = m.group(1) + args = m.group(2) + if args: + parsed_args = re.findall(r"(?:(\w+)\s*=\s*)?\s*([\d.]+)", args) + def parse_val(s): + if '.' in s: + return float(s) + return int(s) + args_l = [parse_val(v) for k, v in parsed_args if not k] + args_kv = {k: parse_val(v) for k, v in parsed_args if k} + for s in INPUT_SHAPERS: + if shaper_name == s.name: + return s.init_func(shaper_freq, damping_ratio, + *args_l, **args_kv) + except ShaperError as e: + if error is None: + raise + raise error("Failed to initialize shaper: %s" % str(e)) + return None diff --git a/scripts/calibrate_shaper.py b/scripts/calibrate_shaper.py index a8bf11197241..7e233b4c10c1 100755 --- a/scripts/calibrate_shaper.py +++ b/scripts/calibrate_shaper.py @@ -6,7 +6,7 @@ # # This file may be distributed under the terms of the GNU GPLv3 license. from __future__ import print_function -import csv, importlib, optparse, os, sys +import csv, importlib, optparse, os, re, sys from textwrap import wrap import numpy as np, matplotlib sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)), @@ -255,7 +255,7 @@ def main(): if options.shapers is None: shapers = None else: - shapers = options.shapers.lower().split(',') + shapers = re.split(r",(?![^(]*\))", options.shapers.lower()) # Parse data datas = [parse_log(fn) for fn in args] diff --git a/scripts/graph_shaper.py b/scripts/graph_shaper.py index e97361693f12..d1628af5b17e 100755 --- a/scripts/graph_shaper.py +++ b/scripts/graph_shaper.py @@ -46,14 +46,6 @@ def shift_pulses(shaper): for i in range(n): T[i] -= ts -# Shaper selection -def get_shaper(shaper_name, shaper_freq, damping_ratio): - for s in shaper_defs.INPUT_SHAPERS: - if shaper_name.lower() == s.name: - return s.init_func(shaper_freq, damping_ratio) - return shaper_defs.get_none_shaper() - - ###################################################################### # Plotting and startup ###################################################################### @@ -142,9 +134,13 @@ def step_response(t): return time, result, legend -def plot_shaper(shaper_name, shaper_freq, damping_ratio, test_damping_ratios, - system_freq, system_damping_ratio): - shaper = get_shaper(shaper_name, shaper_freq, damping_ratio) +def plot_shaper(opts, shaper_name, shaper_freq, damping_ratio, + test_damping_ratios, system_freq, system_damping_ratio): + try: + shaper = shaper_defs.init_shaper(shaper_name.lower(), + shaper_freq, damping_ratio) + except Exception as e: + opts.error("Invalid --shaper=%s specified. %s" % (shaper_name, str(e))) shift_pulses(shaper) freqs, response, response_legend = gen_shaper_response( shaper, shaper_freq, test_damping_ratios) @@ -213,10 +209,6 @@ def main(): if len(args) != 0: opts.error("Incorrect number of arguments") - if options.shaper.lower() not in [ - s.name for s in shaper_defs.INPUT_SHAPERS]: - opts.error("Invalid --shaper=%s specified" % options.shaper) - if options.test_damping_ratios: try: test_damping_ratios = [float(s) for s in @@ -229,7 +221,7 @@ def main(): # Draw graph setup_matplotlib(options.output is not None) - fig = plot_shaper(options.shaper, options.shaper_freq, + fig = plot_shaper(opts, options.shaper, options.shaper_freq, options.damping_ratio, test_damping_ratios, options.system_freq, options.system_damping_ratio) From 7c0ee842c48c027aa68c641f5a6cc5c75ca087db Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Fri, 20 Mar 2026 01:03:15 +0100 Subject: [PATCH 04/32] input_shaper: IS calculation through a single pass Also increased the maximum number of input shaper pulses Signed-off-by: Dmitry Butyugin --- klippy/chelper/kin_shaper.c | 49 +++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 19 deletions(-) diff --git a/klippy/chelper/kin_shaper.c b/klippy/chelper/kin_shaper.c index 682d7fa49244..0a4dfe4b5cc8 100644 --- a/klippy/chelper/kin_shaper.c +++ b/klippy/chelper/kin_shaper.c @@ -21,10 +21,10 @@ static const int KIN_FLAGS[3] = { AF_X, AF_Y, AF_Z }; struct shaper_pulses { - int num_pulses; + int num_pulses, p_ind; struct { double t, a; - } pulses[5]; + } pulses[10]; }; // Shift pulses around 'mid-point' t=0 so that the input shaper is an identity @@ -38,6 +38,10 @@ shift_pulses(struct shaper_pulses *sp) ts += sp->pulses[i].a * sp->pulses[i].t; for (i = 0; i < sp->num_pulses; ++i) sp->pulses[i].t -= ts; + // Find the first pulse with positive time + for (i = 0; i < sp->num_pulses; ++i) + if (sp->pulses[i].t > 0) break; + sp->p_ind = i; } static int @@ -48,6 +52,10 @@ init_shaper(int n, double a[], double t[], struct shaper_pulses *sp) return -1; } int i; + // Check that pulses go in pulse time increasing order + for (i = 1; i < n; ++i) + if (t[i-1] >= t[i]) + return -1; double sum_a = 0.; for (i = 0; i < n; ++i) sum_a += a[i]; @@ -76,20 +84,6 @@ get_axis_position(struct move *m, int axis, double move_time) return start_pos + axis_r * move_dist; } -static inline double -get_axis_position_across_moves(struct move *m, int axis, double time) -{ - while (likely(time < 0.)) { - m = list_prev_entry(m, node); - time += m->move_t; - } - while (likely(time > m->move_t)) { - time -= m->move_t; - m = list_next_entry(m, node); - } - return get_axis_position(m, axis, time); -} - // Calculate the position from the convolution of the shaper with input signal static inline double calc_position(struct move *m, int axis, double move_time @@ -97,9 +91,26 @@ calc_position(struct move *m, int axis, double move_time { double res = 0.; int num_pulses = sp->num_pulses, i; - for (i = 0; i < num_pulses; ++i) { - double t = sp->pulses[i].t, a = sp->pulses[i].a; - res += a * get_axis_position_across_moves(m, axis, move_time + t); + struct move *prev = m; + double t = move_time; + for (i = sp->p_ind - 1; i >= 0; --i) { + t += sp->pulses[i].t; + while (likely(t < 0.)) { + prev = list_prev_entry(prev, node); + t += prev->move_t; + } + res += sp->pulses[i].a * get_axis_position(prev, axis, t); + t -= sp->pulses[i].t; + } + t = move_time; + for (i = sp->p_ind; i < num_pulses; ++i) { + t += sp->pulses[i].t; + while (likely(t > m->move_t)) { + t -= m->move_t; + m = list_next_entry(m, node); + } + res += sp->pulses[i].a * get_axis_position(m, axis, t); + t -= sp->pulses[i].t; } return res; } From 19da13507a1258963ce8ffb93e6765f727cdc286 Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Sun, 22 Mar 2026 16:41:49 +0100 Subject: [PATCH 05/32] shaper_defs: Support more parameters for customizable input shapers Signed-off-by: Dmitry Butyugin --- klippy/extras/shaper_defs.py | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/klippy/extras/shaper_defs.py b/klippy/extras/shaper_defs.py index 383df5799a27..e7b62b3cb450 100644 --- a/klippy/extras/shaper_defs.py +++ b/klippy/extras/shaper_defs.py @@ -42,8 +42,8 @@ def get_mzv_coeffs(n, t): raise ShaperError("Too large t=%.6f for n=%d, must be less than %.6f" % (t, n, 0.5 * (n - 1))) # Projected shaper duration with n -> \infinity for computing shaper zeros - tau = t * (n - 2) / (n - 2 * t - 1) - T = [i * t / (n-1) for i in range(n)] + tau = t * (n - 2.) / (n - 2. * t - 1.) + T = [i * t / (n - 1.) for i in range(n)] # Build a system of equations for A. The first equation is sum(A) = 1 M = [[1.] * n] F = [1.] @@ -63,7 +63,13 @@ def get_mzv_coeffs(n, t): raise ShaperError("Negative-valued shaper with n=%d, t=%.6f" % (n, t)) return (A, T) -def get_mzv_shaper(shaper_freq, damping_ratio, n=3, t=0.75): +def get_mzv_shaper(shaper_freq, damping_ratio, n=3, t=0.0, tau=0.0): + if not tau and not t: + t = 0.75 + elif tau: + # Infer total shaper duration from a projected shaper duration with + # n -> \infinity + t = tau * (n - 1.) / (n + 2. * tau - 2.) A, T = get_mzv_coeffs(n, t) # Apply damping df = math.sqrt(1. - damping_ratio**2) @@ -76,8 +82,8 @@ def get_mzv_shaper(shaper_freq, damping_ratio, n=3, t=0.75): Kp *= K return (A, T) -def get_ei_shaper(shaper_freq, damping_ratio): - v_tol = 1. / SHAPER_VIBRATION_REDUCTION # vibration tolerance +def get_ei_shaper(shaper_freq, damping_ratio, + v_tol=1./SHAPER_VIBRATION_REDUCTION): df = math.sqrt(1. - damping_ratio**2) t_d = 1. / (shaper_freq * df) dr = damping_ratio @@ -177,6 +183,9 @@ def parse_val(s): return int(s) args_l = [parse_val(v) for k, v in parsed_args if not k] args_kv = {k: parse_val(v) for k, v in parsed_args if k} + if args_l and args_kv: + raise ShaperError("Mixing named and non-named shaper" + " parameters is not supported") for s in INPUT_SHAPERS: if shaper_name == s.name: return s.init_func(shaper_freq, damping_ratio, From 530cd051221056bdd93121da52da759908571ef8 Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Sun, 22 Mar 2026 16:43:53 +0100 Subject: [PATCH 06/32] input_shaper: Added documentation and tests for customizable parameters Signed-off-by: Dmitry Butyugin --- docs/Config_Reference.md | 5 +++-- docs/Resonance_Compensation.md | 39 +++++++++++++++++++++++----------- test/klippy/input_shaper.cfg | 2 +- test/klippy/input_shaper.test | 2 +- 4 files changed, 32 insertions(+), 16 deletions(-) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 5a7e76c02959..64fe648ec91c 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -1794,8 +1794,9 @@ the [command reference](G-Codes.md#input_shaper). # value is 0, which disables input shaping for Z axis. #shaper_type: mzv # A type of the input shaper to use for all axes. Supported -# shapers are zv, mzv, zvd, ei, 2hump_ei, and 3hump_ei. The default -# is mzv input shaper. +# shapers are zv, mzv, zvd, ei, 2hump_ei, and 3hump_ei. Some shapers +# support optional additional parameters, e.g. mzv(n=4,t=0.9) or +# ei(v_tol=0.1). The default is mzv input shaper (without parameters). #shaper_type_x: #shaper_type_y: #shaper_type_z: diff --git a/docs/Resonance_Compensation.md b/docs/Resonance_Compensation.md index b5a5e5284693..430ecd210e73 100644 --- a/docs/Resonance_Compensation.md +++ b/docs/Resonance_Compensation.md @@ -480,18 +480,30 @@ with the exception of MZV, and one can find more in-depth overview in the articles describing the corresponding shapers. MZV stands for a Modified-ZV input shaper. The classic definition of ZV shaper -assumes the duration Ts equal to 1/2 of the damped period of oscillations Td and -has two pulses. However, ZV input shaper has a generalized form for an arbitrary -duration in the range (0, Td] with three pulses (Specified-Duration ZV, see also -SNA-ZV), with a negative middle pulse if Ts < Td and a positive one if Ts > Td. -The MZV shaper was designed as an intermediate shaper between ZV and ZVD, +assumes two pulses and the total duration `t` equal to 1/2 of the damped period +of oscillations `Td`. However, it is possible to construct a generalized form +of ZV input shaper with `n >= 3` pulses and an arbitrary total duration +`t >= 0.5 * Td` (with the maximum of `t` depending on `n` value), see for +instance SNA-ZV and MIS-ZV input shapers, which can be seen as special cases +of a more generalized implementation of MZV input shaper in Klipper. +The default MZV parameters in Klipper are `n=3`, `t=0.75` (of `Td`), and this +shaper was designed to serve as an intermediate shaper between ZV and ZVD, offering better vibrations suppression than ZV when the determined (measured) shaper parameters deviate from the ones actually required by the printer, -and smaller smoothing than ZVD. Effectively, it is a SD-ZV shaper with the -specific duration Ts = 3/4 Td, exactly between ZV (Ts = 1/2 Td) and -ZVD (Ts = Td), and it happens to work well for many real-life 3D printers. - -The table below shows some (usually approximate) parameters of each shaper. +and smaller smoothing than ZVD. Effectively, its specific duration `t=0.75`, +exactly between ZV (with `t=0.5` of `Td`) and ZVD (`t=1` of `Td`), and it +happens to work well for many real-life 3D printers. However, experienced +users can modify the default parameters of the MZV input shaper and try other +variations that may work better for their specific printers (with these +non-default variations specified as, e.g. `mzv(n=3,t=0.8)` or `mzv(n=5,t=1.1)` +in the `[input_shaper]` section or as a parameter to `SET_INPUT_SHAPER` command, +as well as in a parameter to `~/klipper/scripts/calibrate_shaper.py` script, +e.g. as `--shapers='2hump_ei,3hump_ei,mzv(n=6,t=1.0)'`. These custom parameters +of the shapers are supported by `~/klipper/scripts/graph_shaper.py` scripts via +e.g. `--shaper='mzv(n=3,t=0.6666666666)'` parameter. + +The table below shows some (usually approximate) parameters of each shaper with +their default parameters. | Input
shaper | Shaper
duration | Vibration reduction 20x
(5% vibration tolerance) | Vibration reduction 10x
(10% vibration tolerance) | |:--:|:--:|:--:|:--:| @@ -508,11 +520,14 @@ configured more precisely and it will then reduce the resonances in a bit wider range of frequencies. However, the damping ratio is usually unknown and is hard to estimate without a special equipment, so Klipper uses 0.1 value by default, which is a good all-round value. The frequency ranges in the table cover a -number of different possible damping ratios around that value (approx. from 0.05 -to 0.2). +number of different possible damping ratios around that value (approx. from 0.075 +to 0.15). Also note that EI, 2HUMP_EI, and 3HUMP_EI are tuned to reduce vibrations to 5%, so the values for 10% vibration tolerance are provided only for the reference. +However, a user can force a desired vibration tolerance for EI input shaper in +a manner similar to MZV input shaper as, e.g. `ei(v_tol=0.02)` or +`ei(v_tol=0.1)`, in which case the vibration reduction range will be different. **How to use this table:** diff --git a/test/klippy/input_shaper.cfg b/test/klippy/input_shaper.cfg index 33ec4e2130fa..7258875bda25 100644 --- a/test/klippy/input_shaper.cfg +++ b/test/klippy/input_shaper.cfg @@ -70,7 +70,7 @@ max_z_accel: 100 [input_shaper] shaper_type_x: mzv shaper_freq_x: 33.2 -shaper_type_y: ei +shaper_type_y: ei(v_tol=0.02) shaper_freq_y: 39.3 damping_ratio_y: 0.4 shaper_freq_z: 42 diff --git a/test/klippy/input_shaper.test b/test/klippy/input_shaper.test index bc993ce278e1..819f95c324ce 100644 --- a/test/klippy/input_shaper.test +++ b/test/klippy/input_shaper.test @@ -3,5 +3,5 @@ CONFIG input_shaper.cfg DICTIONARY atmega2560.dict # Simple command test -SET_INPUT_SHAPER SHAPER_FREQ_X=22.2 DAMPING_RATIO_X=.1 SHAPER_TYPE_X=zv +SET_INPUT_SHAPER SHAPER_FREQ_X=22.2 DAMPING_RATIO_X=.1 SHAPER_TYPE_X='mzv(5,0.6)' SET_INPUT_SHAPER SHAPER_FREQ_Y=33.3 DAMPING_RATIO_Y=.11 SHAPER_TYPE_Y=2hump_ei From ef8edf3aef7fe8c4e886f20eef3d68dd15e61465 Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Sun, 22 Mar 2026 17:15:11 +0100 Subject: [PATCH 07/32] shaper_calibrate: Added an option to limit remaining vibrations score Signed-off-by: Dmitry Butyugin --- klippy/extras/shaper_calibrate.py | 26 +++++++++++++++++--------- scripts/calibrate_shaper.py | 11 +++++++++-- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/klippy/extras/shaper_calibrate.py b/klippy/extras/shaper_calibrate.py index f8f7faf813ea..75db6515aa06 100644 --- a/klippy/extras/shaper_calibrate.py +++ b/klippy/extras/shaper_calibrate.py @@ -248,8 +248,8 @@ def _get_shaper_smoothing(self, shaper, accel=5000, scv=5.): return max(offset_90, offset_180) def fit_shaper(self, shaper_name, calibration_data, shaper_freqs, - damping_ratio, scv, max_smoothing, test_damping_ratios, - max_freq): + damping_ratio, scv, max_smoothing, max_vibrations, + test_damping_ratios, max_freq): np = self.numpy damping_ratio = damping_ratio or shaper_defs.DEFAULT_DAMPING_RATIO @@ -323,8 +323,10 @@ def fit_shaper(self, shaper_name, calibration_data, shaper_freqs, # much worse than the 'best' one, but gives much less smoothing selected = best_res for res in results[::-1]: - if res.vibrs < best_res.vibrs * 1.1 + .0005 \ - and res.score < selected.score: + if res.vibrs < best_res.vibrs * 1.1 + .0005 and ( + res.score < selected.score + or (max_vibrations is not None and + best_res.vibrs > max_vibrations)): selected = res return [selected] + results @@ -356,8 +358,8 @@ def find_shaper_max_accel(self, shaper, scv): def find_best_shaper(self, calibration_data, shapers=None, damping_ratio=None, scv=None, shaper_freqs=None, - max_smoothing=None, test_damping_ratios=None, - max_freq=None, logger=None): + max_smoothing=None, max_vibrations=None, + test_damping_ratios=None, max_freq=None, logger=None): best_shaper = None all_shapers = [] shapers = shapers or AUTOTUNE_SHAPERS @@ -377,14 +379,20 @@ def find_best_shaper(self, calibration_data, shapers=None, continue fit_results = self.background_process_exec(self.fit_shaper, ( shaper_name, calibration_data, shaper_freqs, damping_ratio, - scv, max_smoothing, test_damping_ratios, max_freq)) + scv, max_smoothing, max_vibrations, test_damping_ratios, + max_freq)) shaper = fit_results[0] results = fit_results[1:] if (best_shaper is None or shaper.score * 1.2 < best_shaper.score or (shaper.score * 1.05 < best_shaper.score and - shaper.smoothing * 1.1 < best_shaper.smoothing)): + shaper.smoothing * 1.1 < best_shaper.smoothing) or + (max_vibrations is not None and + best_shaper.vibrs > max_vibrations and + shaper.vibrs < best_shaper.vibrs)): # Either the shaper significantly improves the score (by 20%), - # or it improves the score and smoothing (by 5% and 10% resp.) + # or it improves the score and smoothing (by 5% and 10% resp.), + # or the previous shaper was giving more remaining vibrations + # and the current one improves them. best_shaper = shaper for s in results[::-1]: if s.vibrs < best_shaper.vibrs and \ diff --git a/scripts/calibrate_shaper.py b/scripts/calibrate_shaper.py index 7e233b4c10c1..6b3ed57f5c40 100755 --- a/scripts/calibrate_shaper.py +++ b/scripts/calibrate_shaper.py @@ -62,8 +62,8 @@ def parse_log(logname): # Find the best shaper parameters def calibrate_shaper(datas, csv_output, *, shapers, damping_ratio, scv, - shaper_freqs, max_smoothing, test_damping_ratios, - max_freq): + shaper_freqs, max_smoothing, max_vibrations, + test_damping_ratios, max_freq): # Combine accelerometer data calibration_data = datas[0] for data in datas[1:]: @@ -75,6 +75,7 @@ def calibrate_shaper(datas, csv_output, *, shapers, damping_ratio, scv, shaper, all_shapers = helper.find_best_shaper( calibration_data, shapers=shapers, damping_ratio=damping_ratio, scv=scv, shaper_freqs=shaper_freqs, max_smoothing=max_smoothing, + max_vibrations=max_vibrations, test_damping_ratios=test_damping_ratios, max_freq=max_freq, logger=print) if not shaper: @@ -190,6 +191,9 @@ def main(): help="maximum frequency to plot") opts.add_option("-s", "--max_smoothing", type="float", dest="max_smoothing", default=None, help="maximum shaper smoothing to allow") + opts.add_option("-v", "--max_vibrs_pcnt", type="float", + dest="max_vibrs_pcnt", default=None, help="maximum " + + "remaining shaper vibrations score to allow (in percents)") opts.add_option("--scv", "--square_corner_velocity", type="float", dest="scv", default=5., help="square corner velocity") opts.add_option("--shaper_freq", type="string", dest="shaper_freq", @@ -209,6 +213,8 @@ def main(): opts.error("Incorrect number of arguments") if options.max_smoothing is not None and options.max_smoothing < 0.05: opts.error("Too small max_smoothing specified (must be at least 0.05)") + if options.max_vibrs_pcnt is not None and options.max_vibrs_pcnt < 0.1: + opts.error("Too small max_smoothing specified (must be at least 0.1)") max_freq = options.max_freq if options.shaper_freq is None: @@ -266,6 +272,7 @@ def main(): damping_ratio=options.damping_ratio, scv=options.scv, shaper_freqs=shaper_freqs, max_smoothing=options.max_smoothing, + max_vibrations=options.max_vibrs_pcnt * 0.01, test_damping_ratios=test_damping_ratios, max_freq=max_freq) if selected_shaper is None: From 293e1e9d58821a94ef346a9f0656b7e62fd77b30 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 28 Apr 2026 21:28:18 -0400 Subject: [PATCH 08/32] scripts: Fix logextract "MCU shutdown" line detection Due to recent changes, a shutdown log line may not have a description. Update the logextract.py script accordingly. Signed-off-by: Kevin O'Connor --- scripts/logextract.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/logextract.py b/scripts/logextract.py index 592f6f8a764c..a169830afbd8 100755 --- a/scripts/logextract.py +++ b/scripts/logextract.py @@ -406,7 +406,7 @@ def get_lines(self): return self.api_stream stats_r = re.compile(r"^Stats " + time_s + ": ") -mcu_r = re.compile(r"MCU '(?P[^']+)' (is_)?shutdown: (?P.*)$") +mcu_r = re.compile(r"MCU '(?P[^']+)' (is_)?shutdown:(?P.*)$") stepper_r = re.compile(r"^Dumping stepper '(?P[^']*)' \((?P[^)]+)\) " + count_s + r" queue_step:$") trapq_r = re.compile(r"^Dumping trapq '(?P[^']*)' " + count_s From 352bc8b2e384fef6ae986ef38a8bbc42a1e5e4cb Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Fri, 17 Apr 2026 17:37:04 -0400 Subject: [PATCH 09/32] probe_eddy_current: Support "tap" even if tap_threshold not configured Now that "tap" support does not require the scipy package to be installed it is reasonable to activate "tap" for all configurations. This can make the initial calibration easier. Signed-off-by: Kevin O'Connor --- klippy/extras/probe_eddy_current.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index 21b061857ba5..513e0e874a4a 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -508,8 +508,7 @@ def __init__(self, config, sensor_helper, param_helper, trigger_analog): self._tap_threshold = config.getfloat('tap_threshold', 0., above=0.) self._least_squares_cache = {} self._current_tap_threshold = 0. - if self._tap_threshold: - self._setup_tap() + self._setup_tap() # Setup for "tap" probe request def _setup_tap(self): # Create sos filter "design" @@ -526,7 +525,9 @@ def _setup_tap(self): sos_filter = trigger_analog.MCU_SosFilter(mcu, cmd_queue, filter_size) self._trigger_analog.setup_sos_filter(sos_filter) def _prep_trigger_analog_tap(self, gcmd): - if not self._tap_threshold: + tap_threshold = gcmd.get_float("TAP_THRESHOLD", + self._tap_threshold, above=0.) + if not tap_threshold: raise self._printer.command_error("Tap not configured") # Setup mcu filter (scale internal values to milli-hz) sos_filter = self._trigger_analog.get_sos_filter() @@ -536,8 +537,6 @@ def _prep_trigger_analog_tap(self, gcmd): sos_filter.set_offset_scale(0, s, auto_offset=True) self._trigger_analog.set_raw_range(0, MAX_VALID_RAW_VALUE) # Set mcu trigger to tap_threshold - tap_threshold = gcmd.get_float("TAP_THRESHOLD", - self._tap_threshold, above=0.) samp_thresh = int(FRAC_HZ * tap_threshold + 0.5) self._trigger_analog.set_trigger('diff_peak_gt', samp_thresh) self._current_tap_threshold = tap_threshold From 460ee78340a57f51493bf4568ced0a89e26eb063 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 24 Mar 2026 13:52:03 -0400 Subject: [PATCH 10/32] probe_eddy_current: Rework tap_threshold to use hz/mm Rework the tap_threshold to use units of hz/mm (instead of hz). This will make it easier to implement tap calibration based on results of PROBE_EDDY_CURRENT_CALIBRATE and previous tap attempts. It can also make the tap_threshold more stable with different probe speeds and sensor rates. As a result of this change, existing user configurations will need to be updated. It is hoped that existing configurations will have a value that is sufficiently low that they will reliably get an error until they can recalibrate. Signed-off-by: Kevin O'Connor --- docs/Config_Changes.md | 5 +++++ docs/Config_Reference.md | 16 +++++++--------- docs/Eddy_Probe.md | 11 +++++------ klippy/extras/probe_eddy_current.py | 15 +++++++++------ test/klippy/eddy.cfg | 2 +- 5 files changed, 27 insertions(+), 22 deletions(-) diff --git a/docs/Config_Changes.md b/docs/Config_Changes.md index a4d9d04e871a..f97e79e5b0bf 100644 --- a/docs/Config_Changes.md +++ b/docs/Config_Changes.md @@ -8,6 +8,11 @@ All dates in this document are approximate. ## Changes +20260501: The handling of the `[probe_eddy_current]` `tap_threshold` +config option and associated `TAP_THRESHOLD` G-Code parameter has +changed. It will be necessary to recalibrate the value. See the +[eddy probe documentation](Eddy_Probe.md) for calibration directions. + 20260408: The script `lib/canboot/flash_can.py` has been updated to the most current version from [Katapult](https://github.com/Arksine/katapult) and as such renamed to diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 64fe648ec91c..71cbf3ef7f54 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -2350,15 +2350,13 @@ sensor_type: ldc1612 # 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 -# for more information. Larger values make the tap detection less -# sensitive. That is, larger values make it less likely the toolhead -# will incorrectly stop early due to noise, while increasing the -# risk of the toolhead not correctly stopping when it first contacts -# 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. +# Descent stop threshold (in Hz/mm) for "tap" probing. Larger values +# reduce the chance of the toolhead incorrectly stopping early due +# to noise, while increasing the risk of the toolhead not correctly +# stopping when it first contacts the bed. See Eddy_Probe.md for +# more information. This value may be overridden at run-time using +# the "TAP_THRESHOLD" parameter on probe commands. The default is +# to not enable "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 diff --git a/docs/Eddy_Probe.md b/docs/Eddy_Probe.md index d7753c5b0955..cfa74dabcdf0 100644 --- a/docs/Eddy_Probe.md +++ b/docs/Eddy_Probe.md @@ -137,8 +137,8 @@ PROBE METHOD=tap Here is an example sequence of threshold values to test: ``` -1 -> 5 -> 10 -> 20 -> 40 -> 80 -> 160 -160 -> 120 -> 100 +50 -> 250 -> 500 -> 1000 -> 2000 -> 4000 -> 8000 +8000 -> 6000 -> 5000 ``` Your value will normally be between those. - Too high a value leaves a less safe margin for early collision - @@ -156,8 +156,8 @@ z: 1.010 # noise 0.000400mm, MAD_Hz=14.000 ``` The estimation will be: ``` -MAD_Hz * 2 -11.314 * 2 = 22.628 +MAD_Hz * 100 +11.314 * 100 = 113.14 ``` To further fine tune threshold, one can use `PROBE_ACCURACY METHOD=tap`. @@ -173,8 +173,7 @@ the speed of the descent. If you take 24 photos per second of the moving train, you can only estimate where the train was between photos. -It is possible to reduce the descending speed. It may require decrease of -absolute `tap_threshold` value. +It is possible to reduce the descending speed. It is possible to tap over non-conductive surfaces as long as there is metal behind it within the sensor's sensitivity range. diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index 513e0e874a4a..fea0834282d0 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -529,6 +529,7 @@ def _prep_trigger_analog_tap(self, gcmd): self._tap_threshold, above=0.) if not tap_threshold: raise self._printer.command_error("Tap not configured") + params = self._param_helper.get_probe_params(gcmd) # Setup mcu filter (scale internal values to milli-hz) sos_filter = self._trigger_analog.get_sos_filter() sos_filter.set_filter_design(self._filter_design) @@ -537,7 +538,9 @@ def _prep_trigger_analog_tap(self, gcmd): sos_filter.set_offset_scale(0, s, auto_offset=True) self._trigger_analog.set_raw_range(0, MAX_VALID_RAW_VALUE) # Set mcu trigger to tap_threshold - samp_thresh = int(FRAC_HZ * tap_threshold + 0.5) + sps = self._sensor_helper.get_samples_per_second() + adj_thresh = tap_threshold * params['probe_speed'] / sps + samp_thresh = int(FRAC_HZ * adj_thresh + 0.5) self._trigger_analog.set_trigger('diff_peak_gt', samp_thresh) self._current_tap_threshold = tap_threshold # Measurement analysis to determine "tap" position @@ -703,7 +706,7 @@ def _find_least_squares(self, data): return final_coeffs def _error_detect(self, msg): raise self._printer.command_error("Unable to detect tap: %s" % (msg,)) - def _analyze_pullback(self, measures, start_time, end_time, speed): + def _analyze_pullback(self, measures, start_time, end_time): reactor = self._printer.get_reactor() self._validate_samples_time(measures, start_time, end_time) # Correlate measurements to toolhead position at time of measurement @@ -720,10 +723,10 @@ def _analyze_pullback(self, measures, start_time, end_time, speed): z_contact, freq_contact, depress_slope, slope, slope2 = coeffs reactor.pause(0.) sps = self._sensor_helper.get_samples_per_second() - contact_slope_delta_per_sample = (depress_slope - slope) * speed / sps - if contact_slope_delta_per_sample < self._current_tap_threshold: + contact_slope_delta = depress_slope - slope + if contact_slope_delta < self._current_tap_threshold: self._error_detect("insufficient slope delta (%.6f vs %.6f)" - % (contact_slope_delta_per_sample, + % (contact_slope_delta, self._current_tap_threshold)) if slope >= 0. or slope2 < 0.: self._error_detect("invalid free air slope (s=%.6f s2=%.6f)" @@ -764,7 +767,7 @@ def run_probe(self, gcmd): 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, speed) + end_time, start_time, end_time) def pull_probed_results(self): return self._gather.pull_probed() def end_probe_session(self): diff --git a/test/klippy/eddy.cfg b/test/klippy/eddy.cfg index fd6a2a005ced..c2827fada7f7 100644 --- a/test/klippy/eddy.cfg +++ b/test/klippy/eddy.cfg @@ -63,7 +63,7 @@ y_offset: -4 sensor_type: ldc1612 speed: 10.0 intb_pin: PK7 -tap_threshold: 30 +tap_threshold: 1500 [bed_mesh] mesh_min: 10,10 From ef7b13b1f2d45dc82a9d3ba37500756145cb5278 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Fri, 17 Apr 2026 18:41:33 -0400 Subject: [PATCH 11/32] probe_eddy_current: Add new PROBE_EDDY_CURRENT_TAP_CALIBRATE command Add a command to help configure the tap_threshold parameter. Signed-off-by: Kevin O'Connor --- docs/Eddy_Probe.md | 167 +++++++++++++++------------- docs/G-Codes.md | 6 + klippy/extras/probe_eddy_current.py | 163 +++++++++++++++++++++++++-- test/klippy/eddy.cfg | 3 +- test/klippy/eddy.test | 5 + 5 files changed, 251 insertions(+), 93 deletions(-) diff --git a/docs/Eddy_Probe.md b/docs/Eddy_Probe.md index cfa74dabcdf0..3b03fcc46334 100644 --- a/docs/Eddy_Probe.md +++ b/docs/Eddy_Probe.md @@ -98,86 +98,93 @@ gcode: ## Tap calibration -The Eddy probe measures the resonance frequency of the coil. -By the absolute value of the frequency and the calibration curve from -`PROBE_EDDY_CURRENT_CALIBRATE`, it is therefore possible to detect -where the bed is without physical contact. - -By use of the same knowledge, we know that frequency changes with -the distance. It is possible to track that change in real time and -detect the time/position where contact happens - a change of frequency -starts to change in a different way. -For example, stopped to change because of the collision. - -Because eddy output is not perfect: there is sensor noise, -mechanical oscillation, thermal expansion and other discrepancies, -it is required to calibrate the stop threshold for your machine. -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. - -The suggested calibration routine works as follows: -1. Home Z -2. Place the toolhead at the center of the bed. -3. Move the Z axis far away (30 mm, for example). -4. Run `PROBE METHOD=tap` -5. If it stops before colliding, increase the `tap_threshold`. - -Repeat until the nozzle softly touches the bed. -This is easier to do with a clean nozzle and -by visually inspecting the process. - -You can streamline the process by placing the toolhead in the center once. -Then, upon config restart, trick the machine into thinking that Z is homed. -``` -SET_KINEMATIC_POSITION X= Y= Z=0 -G0 Z5 # Optional retract -PROBE METHOD=tap -``` - -Here is an example sequence of threshold values to test: -``` -50 -> 250 -> 500 -> 1000 -> 2000 -> 4000 -> 8000 -8000 -> 6000 -> 5000 -``` -Your value will normally be between those. -- Too high a value leaves a less safe margin for early collision - -if something is between the nozzle and the bed, or if the nozzle -is too close to the bed before the tap. -- Too low - can make the toolhead stop in mid-air -because of the noise. - -You can estimate the initial threshold value by analyzing your own -calibration routine output: -``` -probe_eddy_current: noise 0.000642mm, MAD_Hz=11.314 -... -z: 1.010 # noise 0.000400mm, MAD_Hz=14.000 -``` -The estimation will be: -``` -MAD_Hz * 100 -11.314 * 100 = 113.14 -``` - -To further fine tune threshold, one can use `PROBE_ACCURACY METHOD=tap`. -The range is expected to be about 0.02 mm, -with the default probe speed of 5 mm/s. -Elevated coil temperature may increase noise and may require additional tuning. - -You can validate the tap precision by measuring the paper thickness -from the initial calibration guide. It is expected to be ~0.1mm. - -Tap precision is limited by the sampling frequency and -the speed of the descent. -If you take 24 photos per second of the moving train, you can only estimate -where the train was between photos. - -It is possible to reduce the descending speed. - -It is possible to tap over non-conductive surfaces as long as there is metal -behind it within the sensor's sensitivity range. -Max distance can be approximated to be about 1.5x of the coil's narrowest part. +Eddy current probes support a special kind of probing referred to as +"tap" probing. This mechanism directs the toolhead to descend until +the nozzle makes contact with the bed. That bed contact may cause a +change in sensor measurements which can be detected and used to halt +further downward movement. The nozzle is then lifted away from the bed +and the sensor measurements during the lifting movement are analyzed +to determine the location where the nozzle breaks contact with the +bed. + +In order to utilize "tap" probing it is necessary to configure a +`tap_threshold` parameter. This parameter determines when downward +toolhead movement during a "tap" probe should be halted. A value too +large could result in a nozzle/bed contact not detected, which could +result in the nozzle crashing uncontrollably into the bed. A value too +small could result in a "tap" probe attempt halting before making +contact with the bed, which could result in wildly inaccurate probe +results. + +The `PROBE_EDDY_CURRENT_TAP_CALIBRATE` command can be used to +configure an appropriate `tap_threshold` value. This tool may be run +after completing the main `PROBE_EDDY_CURRENT_CALIBRATE` calibration. +Follow these steps to calibrate `tap_threshold`: + +1. Verify that both the nozzle and bed are clean. Enable the printer, + home the printer, move the toolhead to a position near the center + of the bed, and make sure the nozzle is between 3 - 10 millimeters + from the bed. + +2. The next step involves commanding the nozzle to make contact with + the bed. This process always has some risks, so be prepared to + issue an emergency halt (`M112`) if the probing descent does not + stop after contacting the bed. When ready issue the following + command: + `PROBE_EDDY_CURRENT_TAP_CALIBRATE TAP=guess` + This command analyzes the data found during the main probe + calibration to make an initial coarse guess for the tap_threshold + value and it then performs the corresponding "tap" probe. Ideally + the above command will cause the probe to descend until it hits the + bed, lift away from the bed, and then report a valid probe result. + If not, see the paragraphs at the end of this section to + troubleshoot. If the attempt was successful then continue to the + next step. + +3. The next step is to run another tap probe with a "refined" + threshold setting. The tool utilizes information gathered during a + previous successful tap probe to determine this improved threshold. + Make sure that the nozzle is near the center of the bed, that it is + between 3 - 10mm above the bed, be ready to issue an emergency + halt, and then run the following command: + `PROBE_EDDY_CURRENT_TAP_CALIBRATE TAP=refine` + Ideally this command will also succeed; if not, see the paragraphs + at the end of this section to troubleshoot. If the attempt was + successful then continue to the next step. + +4. If probing with the refined threshold is successful then the next + test is to verify that it is stable over multiple probe attempts. + Make sure that the nozzle is near the center of the bed, that it is + between 3 - 10mm above the bed, be ready to issue an emergency + halt, and then run the following command: + `PROBE_EDDY_CURRENT_TAP_CALIBRATE TAP=verify` + This command will probe the bed five times in a row. Ideally the + above command will also succeed; if not, see the paragraphs at the + end of this section to troubleshoot. If the attempt was successful + then continue to the next step. + +5. If all of the above steps are successful then one can issue a + `SAVE_CONFIG` command to save the "tap_threshold" parameter to the + printer.cfg file. Calibration should now be complete. + +If any of the steps above did not succeed then it may be necessary to +troubleshoot and manually determine an appropriate `tap_threshold`. +This is done by running commands of the form: +`PROBE METHOD=tap TAP_THRESHOLD=` +Where `` is a threshold to test. + +In general, if a probe attempt halts before making contact with the +bed, then this indicates that the provided `TAP_THRESHOLD` parameter +is too low. Try increasing it by about 10% and retry. Similarly, if a +probe attempt does not halt after making contact with the bed then it +indicates that `TAP_THRESHOLD` is too high. Consider decreasing the +attempted value in half. + +If the automated calibration tool failed during the initial "guess" +stage, then one can use the tap_threshold value reported by the tool +as a starting point for manual attempts. Once a successful probe +attempt is completed then one can return to the main steps described +above starting at the "refine" stage. ## Thermal Drift Calibration diff --git a/docs/G-Codes.md b/docs/G-Codes.md index 19185ca9e54c..ab2e7f15a3d1 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -1269,6 +1269,12 @@ heights. The tool will take a couple of minutes to complete. After completion, use the SAVE_CONFIG command to store the results in the printer.cfg file. +#### PROBE_EDDY_CURRENT_TAP_CALIBRATE +`PROBE_EDDY_CURRENT_TAP_CALIBRATE [TAP=guess|refine|verify]`: This +starts a tool that can calibrate the probe's "tap_threshold" +parameter. See the +[eddy probe documentation](Eddy_Probe.md#tap-calibration) for details. + #### LDC_CALIBRATE_DRIVE_CURRENT `LDC_CALIBRATE_DRIVE_CURRENT CHIP=` This tool will calibrate the ldc1612 DRIVE_CURRENT0 register. Prior to using this diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index fea0834282d0..81d3585913f5 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -7,6 +7,11 @@ import mcu, mathutil from . import ldc1612, trigger_analog, probe, manual_probe + +###################################################################### +# Calibration +###################################################################### + OUT_OF_RANGE = 99.9 # Tool for calibrating the sensor Z detection and applying that calibration @@ -63,6 +68,8 @@ def apply_calibration(self, samples): offset = prev_zpos - prev_freq * gain zpos = adj_freq * gain + offset samples[i] = (samp_time, freq, round(zpos, 6)) + def get_calibration(self): + return list(self.cal_freqs), list(self.cal_zpos) def freq_to_height(self, freq): dummy_sample = [(0., freq, 0.)] self.apply_calibration(dummy_sample) @@ -296,6 +303,133 @@ def cmd_Z_OFFSET_APPLY_PROBE(self, gcmd): def register_drift_compensation(self, comp): self.drift_comp = comp +# Tool for calibrating tap_threshold +class EddyTapCalibration: + def __init__(self, config, calibration, eddy_tap): + self._printer = config.get_printer() + self._name = config.get_name() + self._calibration = calibration + self._eddy_tap = eddy_tap + self._refine_tap_threshold = None + gcode = self._printer.lookup_object("gcode") + gcode.register_command("PROBE_EDDY_CURRENT_TAP_CALIBRATE", + self.cmd_TAP_CALIBRATE, + desc=self.cmd_TAP_CALIBRATE_help) + def _analyze_main_calibration(self): + freqs, zpos = self._calibration.get_calibration() + if len(freqs) < 2: + return None + # Find best fit for: freq = c0 + c1*z + c2*z*z + eqs = [] + ans = [] + for freq, z in zip(freqs, zpos): + if z <= 0.750: + ans.append([freq]) + eqs.append([1., z, z*z]) + eqst = mathutil.mat_transp(eqs) + eqst_eqs = mathutil.mat_mat_mul(eqst, eqs) + eqst_ans = mathutil.mat_mat_mul(eqst, ans) + return mathutil.gaussian_solve(eqst_eqs, eqst_ans) + def _describe_main_calibration(self, coeffs): + if coeffs is None: + return ["Main calibration data not available.", ""] + msg = ("Calibration: f=%.3f s=%.3f q=%.3f" + % (coeffs[0][0], coeffs[1][0], coeffs[2][0])) + return [msg, ""] + def _describe_last_tap(self, last_tap): + if last_tap is None: + return ["Run tap probe for last tap analysis."] + status, depress_dist, coeffs = last_tap + z_contact, freq_contact, depress_slope, slope, slope2 = coeffs + contact_slope_delta = depress_slope - slope + m1 = ("Last tap: z=%.6f f=%.3f s=%.3f q=%.3f" + % (z_contact, freq_contact, slope, slope2)) + m2 = (" depress_dist=%.6f depress_slope=%.3f" + % (depress_dist, depress_slope)) + m3 = (" contact_slope_delta=%.3f" % (contact_slope_delta,)) + msgs = [m1, m2, m3] + if status != "success": + msgs.extend(["", "Warning! Last tap did not succeed."]) + return msgs + def _try_tap(self, gcmd, tap_threshold, samples=1): + # Create dummy gcmd with SAMPLES=1 + fo_params = dict(gcmd.get_command_parameters()) + fo_params['METHOD'] = "tap" + fo_params['TAP_THRESHOLD'] = "%.3f" % (tap_threshold,) + fo_params['SAMPLES'] = str(samples) + gcode = self._printer.lookup_object('gcode') + fo_gcmd = gcode.create_gcode_command("", "", fo_params) + gcmd.respond_info("Tap probing with TAP_THRESHOLD=%s SAMPLES=%s" + % (fo_params['TAP_THRESHOLD'], fo_params['SAMPLES'])) + # Run "tap" probe + probe = self._printer.lookup_object('probe') + probe_session = probe.start_probe_session(fo_gcmd) + probe_session.run_probe(fo_gcmd) + positions = probe_session.pull_probed_results() + probe_session.end_probe_session() + gcmd.respond_info("Tap probing reports z=%.6f" % (positions[0][2],)) + def _save_tap_threshold(self, gcmd, tap_threshold): + configfile = self._printer.lookup_object('configfile') + gcmd.respond_info( + "%s: tap_threshold: %.3f\n" + "The SAVE_CONFIG command will update the printer config file\n" + "with the above and restart the printer." + % (self._name, tap_threshold)) + configfile.set(self._name, 'tap_threshold', "%.3f" % (tap_threshold,)) + cmd_TAP_CALIBRATE_help = "Calibrate tap_threshold for 'tap' probing" + def cmd_TAP_CALIBRATE(self, gcmd): + mc_coeffs = self._analyze_main_calibration() + last_tap = self._eddy_tap.get_last_tap_info() + tap_test = gcmd.get("TAP", None) + if tap_test is None: + # Provide technical information + mc_msgs = self._describe_main_calibration(mc_coeffs) + lt_msgs = self._describe_last_tap(last_tap) + gcmd.respond_info('\n'.join(mc_msgs + lt_msgs)) + elif tap_test == 'guess': + # Attempt tap based on main calibration + self._refine_tap_threshold = None + if mc_coeffs is None: + raise gcmd.error( + "Must complete PROBE_EDDY_CURRENT_CALIBRATE first") + self._try_tap(gcmd, mc_coeffs[1][0] * -0.10) + elif tap_test == 'refine': + # Attempt tap based on change in slope observed during last tap + self._refine_tap_threshold = None + if last_tap is None or last_tap[0] != "success": + raise gcmd.error("Must complete valid 'tap' probe first") + status, depress_dist, coeffs = last_tap + z_contact, freq_contact, depress_slope, slope, slope2 = coeffs + contact_slope_delta = depress_slope - slope + try_tap_threshold = contact_slope_delta * 0.20 + self._try_tap(gcmd, try_tap_threshold) + self._refine_tap_threshold = try_tap_threshold + elif tap_test == 'verify': + # Retry tap several times to verify it is stable + if self._refine_tap_threshold is None: + raise gcmd.error("Must complete valid 'refine' step first") + self._try_tap(gcmd, self._refine_tap_threshold, 5) + self._save_tap_threshold(gcmd, self._refine_tap_threshold) + else: + raise gcmd.error("Please provide a valid TAP parameter") + +class DummyDriftCompensation: + def get_temperature(self): + return 0. + def note_z_calibration_start(self): + pass + def note_z_calibration_finish(self): + pass + def adjust_freq(self, freq, temp=None): + return freq + def unadjust_freq(self, freq, temp=None): + return freq + + +###################################################################### +# Measurement collection +###################################################################### + # Tool to gather samples and convert them to probe positions class EddyGatherSamples: def __init__(self, printer, sensor_helper): @@ -403,6 +537,11 @@ def probe_results_from_avg(measures, toolhead_pos, calibration, offsets): return manual_probe.create_probe_result(toolhead_pos, (offsets[0], offsets[1], sensor_z)) + +###################################################################### +# Probe sessions +###################################################################### + MAX_VALID_RAW_VALUE=0x03ffffff # Helper for implementing PROBE style commands (descend until trigger) @@ -509,6 +648,7 @@ def __init__(self, config, sensor_helper, param_helper, trigger_analog): self._least_squares_cache = {} self._current_tap_threshold = 0. self._setup_tap() + self._last_tap = None # Setup for "tap" probe request def _setup_tap(self): # Create sos filter "design" @@ -707,6 +847,7 @@ def _find_least_squares(self, data): def _error_detect(self, msg): raise self._printer.command_error("Unable to detect tap: %s" % (msg,)) def _analyze_pullback(self, measures, start_time, end_time): + self._last_tap = None reactor = self._printer.get_reactor() self._validate_samples_time(measures, start_time, end_time) # Correlate measurements to toolhead position at time of measurement @@ -721,6 +862,7 @@ def _analyze_pullback(self, measures, start_time, end_time): # Find best fit for extracted measurements coeffs = self._find_least_squares(data) z_contact, freq_contact, depress_slope, slope, slope2 = coeffs + self._last_tap = ("fail", z_contact - min_z, coeffs) reactor.pause(0.) sps = self._sensor_helper.get_samples_per_second() contact_slope_delta = depress_slope - slope @@ -734,6 +876,7 @@ def _analyze_pullback(self, measures, start_time, end_time): if z_contact - min_z < 0.030 or z_contact - min_z > 0.250: self._error_detect("invalid depress distance (%.6f vs %.6f:%.6f)" % (z_contact - min_z, 0.030, 0.250)) + self._last_tap = ("success", z_contact - min_z, coeffs) # Report probe position trig_idx = len(data)-1 while trig_idx > 0 and data[trig_idx-1][1][2] > z_contact: @@ -742,6 +885,8 @@ def _analyze_pullback(self, measures, start_time, end_time): 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]) + def get_last_tap_info(self): + return self._last_tap # Probe session interface def start_probe_session(self, gcmd): self._prep_trigger_analog_tap(gcmd) @@ -837,6 +982,11 @@ def end_probe_session(self): self._gather.finish() self._gather = None + +###################################################################### +# Main probe interface +###################################################################### + # Eddy specific ProbeOffsets class (does not store z_offset) class EddyProbeOffsets: def __init__(self, config): @@ -895,6 +1045,7 @@ def __init__(self, config): # Probing via "tap" interface self.eddy_tap = EddyTap(config, self.sensor_helper, self.param_helper, trig_analog) + EddyTapCalibration(config, self.calibration, self.eddy_tap) # Probing via "scan" and "rapid_scan" requests self.eddy_scan = EddyScanningProbe(config, self.sensor_helper, self.calibration, self.probe_offsets) @@ -928,17 +1079,5 @@ def start_probe_session(self, gcmd): def register_drift_compensation(self, comp): self.calibration.register_drift_compensation(comp) -class DummyDriftCompensation: - def get_temperature(self): - return 0. - def note_z_calibration_start(self): - pass - def note_z_calibration_finish(self): - pass - def adjust_freq(self, freq, temp=None): - return freq - def unadjust_freq(self, freq, temp=None): - return freq - def load_config_prefix(config): return PrinterEddyProbe(config) diff --git a/test/klippy/eddy.cfg b/test/klippy/eddy.cfg index c2827fada7f7..b500bcf57163 100644 --- a/test/klippy/eddy.cfg +++ b/test/klippy/eddy.cfg @@ -82,4 +82,5 @@ max_z_accel: 100 # Dummy calibration data [probe_eddy_current eddy] calibrate = - 0.050000:3300000.000,1.000000:3200000.000,5.000000:3000000.000 + 0.050000:3300000.000,0.100000:3200000.000,0.150000:3000000.000, + 0.200000:2900000.000,1.000000:2800000.000,5.000000:2700000.000 diff --git a/test/klippy/eddy.test b/test/klippy/eddy.test index beeacf949b27..522f529fa082 100644 --- a/test/klippy/eddy.test +++ b/test/klippy/eddy.test @@ -23,9 +23,14 @@ BED_MESH_CALIBRATE METHOD=rapid_scan # Move again G1 Z5 X0 Y0 +# Test PROBE_EDDY_CURRENT_TAP_CALIBRATE +PROBE_EDDY_CURRENT_TAP_CALIBRATE + # Do "tap" probe PROBE METHOD=tap +PROBE_EDDY_CURRENT_TAP_CALIBRATE + # Do regular probe G1 Z5 PROBE From d2aa4bd7e371412591e9b163d1aadb4a11ade56d Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Tue, 24 Mar 2026 13:56:34 -0400 Subject: [PATCH 12/32] ldc1612: Increase sample rate to 400 samples per second (up from 250) Increase the sample rate so that more samples are available during "tap" analysis. Further increases to the sample rate will likely provide diminishing returns. The ldc1612 sensor seems to exhibit internal quantization (that is, repeat identical values are frequently observed). So, it seems increasing above 250sps can provide some additional information, but going much higher is unlikely to provide significant benefit. Signed-off-by: Kevin O'Connor --- klippy/extras/ldc1612.py | 2 +- klippy/extras/trigger_analog.py | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/klippy/extras/ldc1612.py b/klippy/extras/ldc1612.py index fecab4e9d964..36319a02cbab 100644 --- a/klippy/extras/ldc1612.py +++ b/klippy/extras/ldc1612.py @@ -78,7 +78,7 @@ def __init__(self, config, calibration=None): self.printer = config.get_printer() self.calibration = calibration self.dccal = DriveCurrentCalibrate(config, self) - self.data_rate = 250 + self.data_rate = 400 # Setup mcu sensor_ldc1612 bulk query code self.i2c = bus.MCU_I2C_from_config(config, default_addr=LDC1612_ADDR, diff --git a/klippy/extras/trigger_analog.py b/klippy/extras/trigger_analog.py index f9ff11c03d60..3a07f19e281c 100644 --- a/klippy/extras/trigger_analog.py +++ b/klippy/extras/trigger_analog.py @@ -42,15 +42,15 @@ def calc_frac_bits(values): # Pre-generated SOS filters (avoid Scipy package for common installs) GeneratedSOS = { - ('lowpass', 10.0, 4): [ - [0.004824343357716228, 0.009648686715432456, 0.004824343357716228, - 1.0, -1.0485995763626117, 0.2961403575616696], - [1.0, 2.0, 1.0, 1.0, -1.3209134308194264, 0.6327387928852766], + ('lowpass', 400.0/25.0, 4): [ + [0.0009334986129548442, 0.0018669972259096883, 0.0009334986129548442, + 1.0, -1.3651172372392975, 0.4775922500725171], + [1.0, 2.0, 1.0, 1.0, -1.6117270964574348, 0.7445208382054344], ], } # Helper tool to pre-generate SOS filters. Run with something like: -# python -c 'import trigger_analog as m; m.pre_gen_filt("lowpass", 250, 25, 4)' +# python -c 'import trigger_analog as m; m.pre_gen_filt("lowpass", 400, 25, 4)' def pre_gen_filt(btype, sps, freq, order): global GeneratedSOS GeneratedSOS = {} @@ -59,7 +59,8 @@ def pre_gen_filt(btype, sps, freq, order): fs = df._butter(freq, btype, order) # Write filter info to stdout msgs = [] - msgs.append(" ('%s', %s, %d): [" % (btype, repr(float(sps)/freq), order)) + msgs.append(" ('%s', %s/%s, %d): [" + % (btype, repr(float(sps)), repr(float(freq)), order)) for data in fs: coeffs = ", ".join([repr(float(c)) for c in data]) msgs.append(" [%s]," % coeffs,) From 2239041df6a289acce54c4e88eddfdf76bcba7f0 Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Sat, 2 May 2026 01:12:37 +0200 Subject: [PATCH 13/32] calibrate_shaper: Fixed processing of max_vibrs_pcnt parameter Signed-off-by: Dmitry Butyugin --- scripts/calibrate_shaper.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/scripts/calibrate_shaper.py b/scripts/calibrate_shaper.py index 6b3ed57f5c40..7334b2f490da 100755 --- a/scripts/calibrate_shaper.py +++ b/scripts/calibrate_shaper.py @@ -62,12 +62,13 @@ def parse_log(logname): # Find the best shaper parameters def calibrate_shaper(datas, csv_output, *, shapers, damping_ratio, scv, - shaper_freqs, max_smoothing, max_vibrations, + shaper_freqs, max_smoothing, max_vibrs_pcnt, test_damping_ratios, max_freq): # Combine accelerometer data calibration_data = datas[0] for data in datas[1:]: calibration_data.add_data(data) + max_vibrations = None if max_vibrs_pcnt is None else max_vibrs_pcnt * 0.01 print("Processing resonances from %s" % ",".join(d.name for d in calibration_data.get_datasets())) @@ -272,7 +273,7 @@ def main(): damping_ratio=options.damping_ratio, scv=options.scv, shaper_freqs=shaper_freqs, max_smoothing=options.max_smoothing, - max_vibrations=options.max_vibrs_pcnt * 0.01, + max_vibrs_pcnt=options.max_vibrs_pcnt, test_damping_ratios=test_damping_ratios, max_freq=max_freq) if selected_shaper is None: From 77d5d942ed2fb6d3f688114f68d236ccd391bda7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Christian=20R=C3=B6del?= Date: Mon, 4 May 2026 20:22:19 +0200 Subject: [PATCH 14/32] input_shaper: Fix shaper_freq not updated by SET_INPUT_SHAPER MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Commit db10e92e9 ("shaper_defs: Support parameterized shaper initialization") refactored InputShaperParams.update() so that the new shaper frequency is read from the gcode command into a local variable 'shaper_freq' for validation via get_shaper(). However, unlike 'damping_ratio' and 'shaper_type', the validated 'shaper_freq' is never written back to 'self.shaper_freq'. As a result, runtime SET_INPUT_SHAPER commands that include SHAPER_FREQ_X / SHAPER_FREQ_Y do not change the actual shaping frequency -- it silently keeps using the value loaded from [input_shaper] at startup. This breaks slicer-driven input shaper tuning towers and toolchanger setups that switch shaper frequencies per tool. Signed-off-by: Christian Rödel --- klippy/extras/input_shaper.py | 1 + 1 file changed, 1 insertion(+) diff --git a/klippy/extras/input_shaper.py b/klippy/extras/input_shaper.py index e407fd84d2ea..40b7fcf3f076 100644 --- a/klippy/extras/input_shaper.py +++ b/klippy/extras/input_shaper.py @@ -45,6 +45,7 @@ def update(self, gcmd): gcmd.error) self.damping_ratio = damping_ratio self.shaper_type = shaper_type.lower() + self.shaper_freq = shaper_freq def get_shaper(self, shaper_type=None, shaper_freq=None, damping_ratio=None, error=None): if not self.shaper_freq: From 735644a062891b2603dfaf2f19ac9da154675de0 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Fri, 8 May 2026 09:59:25 -0400 Subject: [PATCH 15/32] probe_eddy_current: Separate PROBE_EDDY_CURRENT_CALIBRATE to its own class Move the handling of the PROBE_EDDY_CURRENT_CALIBRATE command to a new EddyCalibrationTool class. Signed-off-by: Kevin O'Connor --- klippy/extras/probe_eddy_current.py | 134 +++++++++++++++------------- 1 file changed, 74 insertions(+), 60 deletions(-) diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index 81d3585913f5..4736a44eaf85 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -14,11 +14,23 @@ OUT_OF_RANGE = 99.9 -# Tool for calibrating the sensor Z detection and applying that calibration +# Dummy temperature adjustments when "[temperature_probe]" not utilized +class DummyDriftCompensation: + def get_temperature(self): + return 0. + def note_z_calibration_start(self): + pass + def note_z_calibration_finish(self): + pass + def adjust_freq(self, freq, temp=None): + return freq + def unadjust_freq(self, freq, temp=None): + return freq + +# Storage for frequency to height calibration class EddyCalibration: def __init__(self, config): self.printer = config.get_printer() - self.name = config.get_name() self.drift_comp = DummyDriftCompensation() # Current calibration data self.cal_freqs = [] @@ -27,28 +39,25 @@ def __init__(self, config): if cal is not None: cal = [list(map(float, d.strip().split(':', 1))) for d in cal.split(',')] - self.load_calibration(cal) - # Probe calibrate state - self.probe_speed = 0. - # Register commands - cname = self.name.split()[-1] - gcode = self.printer.lookup_object('gcode') - gcode.register_mux_command("PROBE_EDDY_CURRENT_CALIBRATE", "CHIP", - cname, self.cmd_EDDY_CALIBRATE, - desc=self.cmd_EDDY_CALIBRATE_help) - gcode.register_command('Z_OFFSET_APPLY_PROBE', - self.cmd_Z_OFFSET_APPLY_PROBE, - desc=self.cmd_Z_OFFSET_APPLY_PROBE_help) + self._load_calibration(cal) + def _load_calibration(self, cal): + cal = sorted([(c[1], c[0]) for c in cal]) + self.cal_freqs = [c[0] for c in cal] + self.cal_zpos = [c[1] for c in cal] def get_printer(self): return self.printer + def note_z_calibration_start(self): + self.drift_comp.note_z_calibration_start() + def note_z_calibration_finish(self): + self.drift_comp.note_z_calibration_finish() + def register_drift_compensation(self, comp): + self.drift_comp = comp def verify_calibrated(self): if len(self.cal_freqs) <= 2: raise self.printer.command_error( "Must calibrate probe_eddy_current first") - def load_calibration(self, cal): - cal = sorted([(c[1], c[0]) for c in cal]) - self.cal_freqs = [c[0] for c in cal] - self.cal_zpos = [c[1] for c in cal] + def get_calibration(self): + return list(self.cal_freqs), list(self.cal_zpos) def apply_calibration(self, samples): cur_temp = self.drift_comp.get_temperature() for i, (samp_time, freq, dummy_z) in enumerate(samples): @@ -68,8 +77,6 @@ def apply_calibration(self, samples): offset = prev_zpos - prev_freq * gain zpos = adj_freq * gain + offset samples[i] = (samp_time, freq, round(zpos, 6)) - def get_calibration(self): - return list(self.cal_freqs), list(self.cal_zpos) def freq_to_height(self, freq): dummy_sample = [(0., freq, 0.)] self.apply_calibration(dummy_sample) @@ -90,6 +97,40 @@ def height_to_freq(self, height): offset = prev_freq - prev_zpos * gain freq = height * gain + offset return self.drift_comp.unadjust_freq(freq) + +# Implement PROBE_EDDY_CURRENT_CALIBRATE (and similar) +class EddyCalibrationTool: + def __init__(self, config, calibration): + self.printer = config.get_printer() + self.name = config.get_name() + self.calibration = calibration + # Probe calibrate state + self.probe_speed = 0. + # Register commands + cname = self.name.split()[-1] + gcode = self.printer.lookup_object('gcode') + gcode.register_mux_command("PROBE_EDDY_CURRENT_CALIBRATE", "CHIP", + cname, self.cmd_EDDY_CALIBRATE, + desc=self.cmd_EDDY_CALIBRATE_help) + gcode.register_command('Z_OFFSET_APPLY_PROBE', + self.cmd_Z_OFFSET_APPLY_PROBE, + desc=self.cmd_Z_OFFSET_APPLY_PROBE_help) + def _save_calibration(self, z_freq_pairs): + gcode = self.printer.lookup_object("gcode") + gcode.respond_info( + "The SAVE_CONFIG command will update the printer config file\n" + "and restart the printer.") + # Save results + cal_contents = [] + for i, (pos, freq) in enumerate(z_freq_pairs): + if not i % 3: + cal_contents.append('\n') + cal_contents.append("%.6f:%.3f" % (pos, freq)) + cal_contents.append(',') + cal_contents.pop() + configfile = self.printer.lookup_object('configfile') + configfile.set(self.name, 'calibrate', ''.join(cal_contents)) + # PROBE_EDDY_CURRENT_CALIBRATE def do_calibration_moves(self, move_speed): toolhead = self.printer.lookup_object('toolhead') kin = toolhead.get_kinematics() @@ -104,7 +145,7 @@ def handle_batch(msg): return True self.printer.lookup_object(self.name).add_client(handle_batch) toolhead.dwell(1.) - self.drift_comp.note_z_calibration_start() + self.calibration.note_z_calibration_start() # Move to each 40um position max_z = 4.0 samp_dist = 0.040 @@ -131,7 +172,7 @@ def handle_batch(msg): times.append((start_query_time, end_query_time, kin_pos[2])) toolhead.dwell(1.0) toolhead.wait_moves() - self.drift_comp.note_z_calibration_finish() + self.calibration.note_z_calibration_finish() # Finish data collection is_finished = True # Correlate query responses @@ -148,7 +189,6 @@ def handle_batch(msg): raise self.printer.command_error( "Failed calibration - incomplete sensor data") return cal - def _median(self, values): values = sorted(values) n = len(values) @@ -252,21 +292,13 @@ def post_manual_probe(self, mpresult): "Failed calibration - No usable data") z_freq_pairs = [(pos, freq) for pos, freq, _, _ in filtered] self._save_calibration(z_freq_pairs) - def _save_calibration(self, z_freq_pairs): - gcode = self.printer.lookup_object("gcode") - gcode.respond_info( - "The SAVE_CONFIG command will update the printer config file\n" - "and restart the printer.") - # Save results - cal_contents = [] - for i, (pos, freq) in enumerate(z_freq_pairs): - if not i % 3: - cal_contents.append('\n') - cal_contents.append("%.6f:%.3f" % (pos, freq)) - cal_contents.append(',') - cal_contents.pop() - configfile = self.printer.lookup_object('configfile') - configfile.set(self.name, 'calibrate', ''.join(cal_contents)) + 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.) + # Start manual probe + manual_probe.ManualProbeHelper(self.printer, gcmd, + self.post_manual_probe) + # Z_OFFSET_APPLY_PROBE def _save_tap_z_offset(self, gcmd, homing_z): eventtime = self.printer.get_reactor().monotonic() configfile = self.printer.lookup_object('configfile') @@ -280,12 +312,6 @@ def _save_tap_z_offset(self, gcmd, homing_z): "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.) - # Start manual probe - manual_probe.ManualProbeHelper(self.printer, gcmd, - self.post_manual_probe) cmd_Z_OFFSET_APPLY_PROBE_help = "Adjust the probe's z_offset" def cmd_Z_OFFSET_APPLY_PROBE(self, gcmd): gcode_move = self.printer.lookup_object("gcode_move") @@ -296,12 +322,11 @@ def cmd_Z_OFFSET_APPLY_PROBE(self, gcmd): 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) + cal_freqs, cal_zpos = self.calibration.get_calibration() + cal_zpos = [z - offset for z in cal_zpos] + z_freq_pairs = zip(cal_zpos, cal_freqs) z_freq_pairs = sorted(z_freq_pairs) self._save_calibration(z_freq_pairs) - def register_drift_compensation(self, comp): - self.drift_comp = comp # Tool for calibrating tap_threshold class EddyTapCalibration: @@ -413,18 +438,6 @@ def cmd_TAP_CALIBRATE(self, gcmd): else: raise gcmd.error("Please provide a valid TAP parameter") -class DummyDriftCompensation: - def get_temperature(self): - return 0. - def note_z_calibration_start(self): - pass - def note_z_calibration_finish(self): - pass - def adjust_freq(self, freq, temp=None): - return freq - def unadjust_freq(self, freq, temp=None): - return freq - ###################################################################### # Measurement collection @@ -1025,6 +1038,7 @@ class PrinterEddyProbe: def __init__(self, config): self.printer = config.get_printer() self.calibration = EddyCalibration(config) + EddyCalibrationTool(config, self.calibration) # Sensor type sensors = { "ldc1612": ldc1612.LDC1612 } sensor_type = config.getchoice('sensor_type', {s: s for s in sensors}) From 72c054f93844a52a5a5bbd107566e082c7f5a1a8 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Sat, 2 May 2026 12:13:09 -0400 Subject: [PATCH 16/32] docs: Updates to Eddy_probe.md document Provide more information on the different probe types available when using an eddy current probe. Signed-off-by: Kevin O'Connor --- docs/Eddy_Probe.md | 309 ++++++++++++++++++++++++++++++++++++--------- 1 file changed, 252 insertions(+), 57 deletions(-) diff --git a/docs/Eddy_Probe.md b/docs/Eddy_Probe.md index 3b03fcc46334..c3998779a61a 100644 --- a/docs/Eddy_Probe.md +++ b/docs/Eddy_Probe.md @@ -1,21 +1,227 @@ # Eddy Current Inductive probe -This document describes how to use an +This document describes the support for [eddy current](https://en.wikipedia.org/wiki/Eddy_current) inductive -probe in Klipper. +probes in Klipper. + +These probes detect the bed by measuring the +[resonant frequency](https://en.wikipedia.org/wiki/Resonance) of a +coil within the sensor. The closer that coil is to a metal bed the +higher the coil's resonant frequency. The frequency measurements can +thus be used to estimate the distance between sensor and bed. + +The Klipper eddy current sensor support is primarily intended for bed +probing. It is possible to "home" the Z axis with an eddy current +probe, but see the [homing correction](#homing-correction-macros) +section for important information. + +## Probing mechanisms + +Unlike traditional bed probes an eddy current sensor supports four +different methods of probing: default, "scan", "rapid_scan", and +"tap". The different probing methods are activated by passing a +`METHOD=xxx` parameter to probe commands (for example, +`PROBE METHOD=tap`). Each probing method has advantages and +disadvantages as described below. + +### Default probing method + +The default probing method behaves most like a traditional bed probe. +The toolhead descends toward the bed until the sensor detects that it +is near the bed and then several sensor measurements are taken at the +halted position to estimate the distance between sensor and bed. This +probing mechanism is activated by not specifying a `METHOD` parameter +on probe type commands (eg, a bare `PROBE` command). -Currently, an eddy current probe can not precisely home Z (i.e., `G28 Z`). -The sensor can precisely do Z probing (i.e., `PROBE ...`). -Look at the [homing correction](Eddy_Probe.md#homing-correction-macros) -for further details. +Advantages: -Start by declaring a +* It is the most general purpose probing method. It provides good + precision with good flexibility. +* Can be used in many starting toolhead positions. It is necessary to + ensure that the toolhead XY position places the sensor over the + metal bed, but otherwise there is flexibility in the exact starting + height. + +Disadvantages: + +* The probe results are subject to thermal drift. Distances reported + by the probe correlate to distances measured during initial + calibration (via `PROBE_EDDY_CURRENT_CALIBRATE`) and the results may + be impacted if probing is run at a different temperature. Changes to + the temperature of the bed, sensor coil, sensor electronics, or any + metal near the sensor can all impact the results. The impact is + small (think microns), but the acceptable precision for a bed probe + is also small (again think microns). For best results, it is + recommended to run the calibration and subsequent probes at a + consistent temperature. + +When to use: + +This is the default probing method, and it is recommended for most +probing actions. In particular, it is the recommended probe type for +bed alignment tools such as `QUAD_GANTRY_LEVEL`, `Z_TILT_ADJUST`, +`SCREWS_TILT_CALCULATE`, `DELTA_CALIBRATE`, and similar. + +### "scan" probing method + +The "scan" probing method is similar to the default method, except the +probe does not descend towards the bed. Instead, the probe gathers +sensor measurements at the current Z position to estimate the distance +between sensor and bed. It is useful for `BED_MESH_CALIBRATE` as the +entire bed can be scanned with only horizontal movements. + +Advantages: + +* The Z position does not change during probing and there is less + chance for Z stepper backlash (and similar) to impact measurements. + This can be particularly useful when only relative Z height + measurements are desired (eg, when using `zero_reference_position` + with `BED_MESH_CALIBRATE`). + +* A full bed scan may take less time than the default method. + +Disadvantages: + +* The bed must be nearly parallel to the printer XY rails and there + must not be any large deviations in bed height. For acceptable + results the bed scanning must be run with a low `HORIZONTAL_MOVE_Z` + so that the sensor remains close to the bed during the entire bed + scan. (The smaller the distance the more accurate the results.) In + practice, this requires that the distance between nozzle and bed be + no more than about a millimeter, and at these distances any notable + bed deviations could result in a nozzle/bed collision during + horizontal movement. + +* The "scan" method has the same thermal drift disadvantages described + for the default method. For best results, it is recommended to run + the calibration and subsequent probes at a consistent temperature. + +When to use: + +The "scan" method is typically used during bed mesh calibration. It is +recommended to always verify the bed is parallel to the printer XY +rails prior to performing a bed scan. Depending on the printer +hardware, one may use an automated tool utilizing the default probing +method to verify the bed is parallel - for example: +`QUAD_GANTRY_LEVEL RETRY_TOLERANCE=0.250`, +`Z_TILT_ADJUST RETRY_TOLERANCE=0.250`, or +`SCREWS_TILT_CALCULATION MAX_TOLERANCE=0.250`. + +A bed mesh can then be run with something similar to +`BED_MESH_CALIBRATE METHOD=scan HORIZONTAL_MOVE_Z=1`. + +### "rapid_scan" probing method + +The "rapid_scan" probing method is very similar to the "scan" method, +except the probe does not pause at each point to be measured. Instead, +measurements taken during horizontal movement near each probing point +are used to estimate the distance between sensor and bed. + +Advantages: + +* A "rapid_scan" full bed scan may be slightly faster than the "scan" + method. + +* Otherwise, it has the same advantages as the "scan" method. + +Disadvantages: + +* The results of a "rapid_scan" may be less accurate than the "scan" + method. + +* Same disadvantages as "scan" probes (bed must be parallel and + thermal drift). + +When to use: + +A "rapid_scan" may be useful when performing a large detailed bed mesh +scan for diagnostic purposes. In this situation, the reduced scanning +time may outweigh the possible loss of accuracy. + +For normal printing, a bed mesh using the regular "scan" method is +generally preferred for best accuracy and minimal additional probing +time. + +Once the bed is verified to be parallel to the XY rails then one can +run a rapid bed mesh scan with something similar to +`BED_MESH_CALIBRATE METHOD=rapid_scan HORIZONTAL_MOVE_Z=1`. + +### "tap" probing method + +During "tap" probing, the toolhead descends until the nozzle makes +contact with the bed, the nozzle is then lifted away from the bed, and +sensor measurements during the lifting movement are analyzed to +determine the location where the nozzle breaks contact with the bed. + +Advantages: + +* The probe results are determined by the actual point of contact + between nozzle and bed instead of indirect measurements between + sensor and bed. This can be particularly useful if one changes + nozzles frequently, as the results will take into account the + geometry of the current nozzle. + +* A "tap" probe does not have the thermal drift issues associated with + the other probing methods. The main probe calibration is not + utilized during tap probes, and thus one does not need to track + temperatures between initial calibration and subsequent probing. + +* Axis "twist" inaccuracies are less of an issue during tap probes as + there is no XY probe offset to compensate for. However, one must + still ensure the toolhead XY position places both the nozzle and + sensor above the bed prior to tap probing. + +Disadvantages: + +* One must ensure both the nozzle and bed are clean prior to tap + probing. Any filament on the nozzle or debris on the bed may + significantly skew the probe results. + +* One must ensure that the nozzle is around 3-20mm away from the bed + prior to starting each "tap" probe attempt. If the nozzle starts too + close to the bed then contact may not be detected which could result + in an uncontrolled nozzle/bed crash. If the nozzle starts very far + from the bed then sensor measurements are not accurate and a tap + attempt may fail or provide inaccurate results. + +* The printer hardware must allow the nozzle to fully make contact + with the bed. There must not be any limit switches or carriage stops + that make contact prior to the nozzle contacting the bed. + +* One must ensure that the nozzle temperature is not too high for the + bed. A too high temperature could melt the PEI coatings on some + beds, for example. + +When to use: + +A "tap" probe is often used as one step during a multi-step +homing/leveling process to account for the current nozzle geometry and +to reduce errors associated with thermal drift. For example, one might +deploy a macro that homes, calls `Z_TILT_ADJUST` with default probe +method, heats the printer to an intermediate temperature, cleans the +nozzle by repeatedly wiping it over a brush, performs a "tap" probe, +uses `SET_KINEMATIC_POSITION` with the tap results, runs +`BED_MESH_CALIBRATE` while utilizing a `zero_reference_position`, and +then brings the printer to normal printing temperature. The actual +steps to utilize a "tap" probe depend heavily on the specific printer +hardware. + +A "tap" probe may be initiated with something like `PROBE METHOD=tap`. + +## Configuration + +To configure an eddy current probe, 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 `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. +Then restart the printer and proceed to the following calibration +steps. + +### Calibrating drive current + The first step in calibration is to determine the appropriate DRIVE_CURRENT for the sensor. Home the printer and navigate the toolhead so that the sensor is near the center of the bed and is about @@ -26,7 +232,8 @@ named `[probe_eddy_current my_eddy_probe]` then one would run complete in a few seconds. After it completes, issue a `SAVE_CONFIG` command to save the results to the printer.cfg and restart. -Eddy current is used as a proximity/distance sensor (similar to a laser ruler). +### Calibrating Z heights + The second step in calibration is to correlate the sensor readings to the corresponding Z heights. Home the printer and navigate the toolhead so that the nozzle is near the center of the bed. Then run a @@ -58,54 +265,14 @@ If either the `x_offset` or `y_offset` is modified then be sure to run the `PROBE_EDDY_CURRENT_CALIBRATE` command (as described above) after making the change. -Once calibration is complete, one may use all the standard Klipper -tools that use a Z probe. - -Note that eddy current sensors (and inductive probes in general) are -susceptible to "thermal drift". That is, changes in temperature can -result in changes in reported Z height. Changes in either the bed -surface temperature or sensor hardware temperature can skew the -results. It is important that calibration and probing is only done -when the printer is at a stable temperature. - -## Homing correction macros - -Because of current limitations, homing and probing -are implemented differently for the eddy sensors. -As a result, homing suffers from an offset error, -while probing handles this correctly. - -To correct the homing offset. -One can use the suggested macro inside the homing override or -inside the starting G-Code. +Note that eddy current sensors are susceptible to "thermal drift". +That is, changes in temperature can result in changes in reported Z +height. Changes in either the bed surface temperature or sensor +hardware temperature can alter the results. Therefore, for best +results the calibration done here and the subsequent probing that +utilizes that calibration should be done at the same temperature. -[Force move](Config_Reference.md#force_move) section -have to be defined in the config. - -``` -[gcode_macro _RELOAD_Z_OFFSET_FROM_PROBE] -gcode: - {% set Z = printer.toolhead.position.z %} - SET_KINEMATIC_POSITION Z={Z - printer.probe.last_probe_position.z} - -[gcode_macro SET_Z_FROM_PROBE] -gcode: - {% set METHOD = params.METHOD | default("automatic") %} - PROBE METHOD={METHOD} - _RELOAD_Z_OFFSET_FROM_PROBE - G0 Z5 -``` - -## Tap calibration - -Eddy current probes support a special kind of probing referred to as -"tap" probing. This mechanism directs the toolhead to descend until -the nozzle makes contact with the bed. That bed contact may cause a -change in sensor measurements which can be detected and used to halt -further downward movement. The nozzle is then lifted away from the bed -and the sensor measurements during the lifting movement are analyzed -to determine the location where the nozzle breaks contact with the -bed. +### Tap calibration In order to utilize "tap" probing it is necessary to configure a `tap_threshold` parameter. This parameter determines when downward @@ -113,8 +280,8 @@ toolhead movement during a "tap" probe should be halted. A value too large could result in a nozzle/bed contact not detected, which could result in the nozzle crashing uncontrollably into the bed. A value too small could result in a "tap" probe attempt halting before making -contact with the bed, which could result in wildly inaccurate probe -results. +contact with the bed, which could result in probing errors or +inaccurate probe results. The `PROBE_EDDY_CURRENT_TAP_CALIBRATE` command can be used to configure an appropriate `tap_threshold` value. This tool may be run @@ -186,6 +353,34 @@ as a starting point for manual attempts. Once a successful probe attempt is completed then one can return to the main steps described above starting at the "refine" stage. +## Homing correction macros + +Because of current limitations, homing and probing +are implemented differently for the eddy sensors. +As a result, homing suffers from an offset error, +while probing handles this correctly. + +To correct the homing offset. +One can use the suggested macro inside the homing override or +inside the starting G-Code. + +[Force move](Config_Reference.md#force_move) section +have to be defined in the config. + +``` +[gcode_macro _RELOAD_Z_OFFSET_FROM_PROBE] +gcode: + {% set Z = printer.toolhead.position.z %} + SET_KINEMATIC_POSITION Z={Z - printer.probe.last_probe_position.z} + +[gcode_macro SET_Z_FROM_PROBE] +gcode: + {% set METHOD = params.METHOD | default("automatic") %} + PROBE METHOD={METHOD} + _RELOAD_Z_OFFSET_FROM_PROBE + G0 Z5 +``` + ## Thermal Drift Calibration As with all inductive probes, eddy current probes are subject to From 54f482775684d43b2febcb49837f9ca1e138964c Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Wed, 6 May 2026 19:34:29 -0400 Subject: [PATCH 17/32] docs: Expand Eddy_Probe.md instructions for homing with an eddy probe Signed-off-by: Kevin O'Connor --- docs/Eddy_Probe.md | 83 ++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 70 insertions(+), 13 deletions(-) diff --git a/docs/Eddy_Probe.md b/docs/Eddy_Probe.md index c3998779a61a..4b3b9549df52 100644 --- a/docs/Eddy_Probe.md +++ b/docs/Eddy_Probe.md @@ -355,17 +355,16 @@ above starting at the "refine" stage. ## Homing correction macros -Because of current limitations, homing and probing -are implemented differently for the eddy sensors. -As a result, homing suffers from an offset error, -while probing handles this correctly. +It is possible to use an eddy current probe to home a Z axis. However, +due to software limitations, the homing operation does not provide an +accurate result and it is therefore necessary to perform a multi-step +process to achieve acceptable accuracy. -To correct the homing offset. -One can use the suggested macro inside the homing override or -inside the starting G-Code. - -[Force move](Config_Reference.md#force_move) section -have to be defined in the config. +To use this process, make sure the `[stepper_z]` config section has +the `endstop_pin` set to `probe:z_virtual_endstop`. Also make sure the +[force move](Config_Reference.md#force_move) section is defined and +ensure its `enable_force_move` option is present and set to true. +Finally, define the following macros: ``` [gcode_macro _RELOAD_Z_OFFSET_FROM_PROBE] @@ -375,12 +374,70 @@ gcode: [gcode_macro SET_Z_FROM_PROBE] gcode: - {% set METHOD = params.METHOD | default("automatic") %} - PROBE METHOD={METHOD} + PROBE _RELOAD_Z_OFFSET_FROM_PROBE - G0 Z5 ``` +To home the Z axis, perform an initial Z home using a `G28 Z0` command +and then run `SET_Z_FROM_PROBE` to obtain an accurate Z position. + +It is important that the `SET_Z_FROM_PROBE` macro is used after every +`G28 Z0` (and similar) homing command. If the Z axis is ever homed +without running the correction macro then the internal Z positions +will not be accurate which could lead to nozzle/bed collisions and +very poor print results. One may wish to consider using a +`[homing_override]` config section to ensure the correction macro is +always run. + +### Performing initial calibration when homing with probe + +In order to home with an eddy probe it is necessary to first calibrate +the probe via the `PROBE_EDDY_CURRENT_CALIBRATE` command. However, +that command requires that the printer be homed first. + +The following steps may be used to avoid this circular dependency for +the very first calibration: + +1. Define a `[probe_eddy_current]` config section in the printer.cfg + file as described in the [configuration section](#configuration). + +2. Setup the macros and config sections for Z homing with a probe as + described in the main + [homing correction macros](#homing-correction-macros) section. + +3. Manually adjust the carriages so that the toolhead is near the + center of the bed and roughly 20mm away from the bed. Issue + `LDC_CALIBRATE_DRIVE_CURRENT CHIP=` and `SAVE_CONFIG` + commands as described in the + [calibrating drive current section](#calibrating-drive-current). + +4. Manually move the toolhead so that it is roughly 20mm away from the + bed and home the printer's X and Y axes. This is typically done + with a `G28 X0 Y0` command. Command the toolhead X and Y position + so that the toolhead is roughly over the center of the bed. This is + typically done with a command like `G1 X50 Y50` (using appropriate + XY values for the printer). + +5. Manually adjust the bed so that it is mostly flat relative to the + toolhead XY carriages (if necessary). Manually adjust the Z + carriage so that the nozzle is roughly 20mm from the bed and issue + a `SET_STEPPER_ENABLE STEPPER=stepper_z` command. Issue a + `SET_KINEMATIC_POSITION Z=25` command followed by a + `PROBE_EDDY_CURRENT_CALIBRATE CHIP=my_eddy_probe` command. + Important - after issuing these commands the printer will be able + to move in the Z direction, but it does not know the actual Z + position. Care must be taken to avoid movement requests that may + cause the toolhead to descend into the bed. + +6. Complete the eddy probe calibration as described in the + [calibrating z heights section](#calibrating-z-heights). Issue a + `SAVE_CONFIG` command upon completion. + +These steps are only needed to obtain an initial configuration. If one +needs to rerun `PROBE_EDDY_CURRENT_CALIBRATE` in the future then the +normal mechanism should be possible once this initial configuration is +available. + ## Thermal Drift Calibration As with all inductive probes, eddy current probes are subject to From 02cfc65eb6f71f40312ea896dd959e46f9fd5069 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Thu, 7 May 2026 18:20:40 -0400 Subject: [PATCH 18/32] docs: Note that Z "position_min" needed for "tap" in Eddy_Probe.md Signed-off-by: Kevin O'Connor --- docs/Eddy_Probe.md | 26 ++++++++++++++++++-------- 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/docs/Eddy_Probe.md b/docs/Eddy_Probe.md index 4b3b9549df52..4721ad187750 100644 --- a/docs/Eddy_Probe.md +++ b/docs/Eddy_Probe.md @@ -274,14 +274,24 @@ utilizes that calibration should be done at the same temperature. ### Tap calibration -In order to utilize "tap" probing it is necessary to configure a -`tap_threshold` parameter. This parameter determines when downward -toolhead movement during a "tap" probe should be halted. A value too -large could result in a nozzle/bed contact not detected, which could -result in the nozzle crashing uncontrollably into the bed. A value too -small could result in a "tap" probe attempt halting before making -contact with the bed, which could result in probing errors or -inaccurate probe results. +In order to utilize "tap" probing it is necessary to configure some +parameters. + +It must be possible to command the toolhead below the nominal plane of +the bed. This is typically done by setting `position_min: -1` in the +`[stepper_z]` config section of the printer.cfg (or similar setting, +such as `minimum_z_position`, depending on the kinematics). This is +necessary to ensure the nozzle can be commanded to firmly contact the +bed. This is also to ensure the nozzle makes contact with the bed +before it would otherwise be commanded to start deceleration. + +It is also necessary to configure a `tap_threshold` parameter. This +parameter determines when downward toolhead movement during a "tap" +probe should be halted. A value too large could result in a nozzle/bed +contact not detected, which could result in the nozzle crashing +uncontrollably into the bed. A value too small could result in a "tap" +probe attempt halting before making contact with the bed, which could +result in probing errors or inaccurate probe results. The `PROBE_EDDY_CURRENT_TAP_CALIBRATE` command can be used to configure an appropriate `tap_threshold` value. This tool may be run From cf35368390d2d61466f5b0d499bb01ad4607850b Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Thu, 30 Apr 2026 23:27:24 +0200 Subject: [PATCH 19/32] stm32: Fixed PA2/PA3 pin PWM mapping for STM32F072 Signed-off-by: Dmitry Butyugin --- src/stm32/hard_pwm.c | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/stm32/hard_pwm.c b/src/stm32/hard_pwm.c index 201c214cca18..ef77f3fbca81 100644 --- a/src/stm32/hard_pwm.c +++ b/src/stm32/hard_pwm.c @@ -49,8 +49,8 @@ static const struct gpio_pwm_info pwm_regs[] = { #endif #if CONFIG_MACH_STM32F072 {TIM2, GPIO('A', 1), 2, GPIO_FUNCTION(2)}, - {TIM2, GPIO('A', 2), 3, GPIO_FUNCTION(2)}, - {TIM2, GPIO('A', 3), 4, GPIO_FUNCTION(2)}, + {TIM15, GPIO('A', 2), 1, GPIO_FUNCTION(0)}, + {TIM15, GPIO('A', 3), 2, GPIO_FUNCTION(0)}, {TIM14, GPIO('A', 4), 1, GPIO_FUNCTION(4)}, {TIM3, GPIO('A', 6), 1, GPIO_FUNCTION(1)}, {TIM3, GPIO('A', 7), 2, GPIO_FUNCTION(1)}, From 6fd5f822fbdc482dbdeda50cd0aafcad20b1cec1 Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Sun, 3 May 2026 22:50:53 +0200 Subject: [PATCH 20/32] motan: Added support for capturing and plotting load cell data Signed-off-by: Dmitry Butyugin --- scripts/motan/data_logger.py | 4 +++ scripts/motan/readlog.py | 51 ++++++++++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+) diff --git a/scripts/motan/data_logger.py b/scripts/motan/data_logger.py index db2e9d8ec16c..eeca2c533fe6 100755 --- a/scripts/motan/data_logger.py +++ b/scripts/motan/data_logger.py @@ -21,6 +21,10 @@ ('angle', '{ct}:{csn}', '{ct}/dump_{ct}', {'sensor': '{csn}'}), ('probe_eddy_current', 'ldc1612:{csn}', 'ldc1612/dump_ldc1612', {'sensor': '{csn}'}), + ('load_cell', 'loadcell:{csn}', 'load_cell/dump_force', + {'load_cell': '{csn}'}), + ('load_cell_probe', 'loadcell:{csn}', 'load_cell/dump_force', + {'load_cell': '{csn}'}), ('tmc2130', 'stallguard:{csn}', 'tmc/stallguard_dump', {'name': '{csn}'}), ('tmc2209', 'stallguard:{csn}', 'tmc/stallguard_dump', {'name': '{csn}'}), ('tmc2260', 'stallguard:{csn}', 'tmc/stallguard_dump', {'name': '{csn}'}), diff --git a/scripts/motan/readlog.py b/scripts/motan/readlog.py index 11b1a7e0dcde..5d33eb0019f9 100644 --- a/scripts/motan/readlog.py +++ b/scripts/motan/readlog.py @@ -564,6 +564,57 @@ def pull_data(self, req_time): self.data_pos += 1 LogHandlers["ldc1612"] = HandleEddyCurrent +# Extract load cell force data +class HandleLoadCell: + SubscriptionIdParts = 2 + ParametersMin = 1 + ParametersMax = 2 + DataSets = [ + ('loadcell()', 'Force reading from load cell'), + ('loadcell(,counts)', 'Raw ADC counts from load cell'), + ] + def __init__(self, lmanager, name, name_parts): + self.name = name + self.sensor_name = name_parts[1] + if len(name_parts) == 3 and name_parts[2] != "counts": + raise error("Unknown loadcell selection '%s'" % (name_parts[2],)) + self.report_counts = len(name_parts) == 3 + self.jdispatch = lmanager.get_jdispatch() + self.next_samp = self.prev_samp = [0., 0., 0., 0.] + self.cur_data = [] + self.data_pos = 0 + def get_label(self): + if self.report_counts: + label = '%s counts' % (self.sensor_name,) + return {'label': label, 'units': 'ADC Counts'} + label = '%s force' % (self.sensor_name,) + return {'label': label, 'units': 'Force\n(g)'} + def pull_data(self, req_time): + while 1: + next_time, next_force, next_counts, _ = self.next_samp + if req_time <= next_time: + prev_time, prev_force, prev_counts, _ = self.prev_samp + if self.report_counts: + next_val = next_counts + prev_val = prev_counts + else: + next_val = next_force + prev_val = prev_force + return interpolate(next_val, prev_val, next_time, prev_time, + req_time) + if self.data_pos >= len(self.cur_data): + # Read next data block + jmsg = self.jdispatch.pull_msg(req_time, self.name) + if jmsg is None: + return 0. + self.cur_data = jmsg['data'] + self.data_pos = 0 + continue + self.prev_samp = self.next_samp + self.next_samp = self.cur_data[self.data_pos] + self.data_pos += 1 +LogHandlers["loadcell"] = HandleLoadCell + ###################################################################### # Log reading From 4767a8ed97c57e4bb2ecf60fd72e345f58dfa3fc Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Mon, 4 May 2026 00:50:55 +0200 Subject: [PATCH 21/32] motan: Added a script to export motan data as a CSV file Signed-off-by: Dmitry Butyugin --- scripts/motan/data_export.py | 104 +++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) create mode 100644 scripts/motan/data_export.py diff --git a/scripts/motan/data_export.py b/scripts/motan/data_export.py new file mode 100644 index 000000000000..adc208bd81af --- /dev/null +++ b/scripts/motan/data_export.py @@ -0,0 +1,104 @@ +#!/usr/bin/env python +# Script to export motion analysis data to CSV +# +# Copyright (C) 2021 Kevin O'Connor +# Copyright (C) 2026 Dmitry Butyugin +# +# This file may be distributed under the terms of the GNU GPLv3 license. +import sys, csv, optparse, ast +import readlog, analyzers + + +###################################################################### +# CSV Export +###################################################################### + +def export_csv(amanager, columns): + for dataset in columns: + amanager.setup_dataset(dataset) + amanager.generate_datasets() + datasets = amanager.get_datasets() + times = amanager.get_dataset_times() + + header = ["Time (s)"] + for dataset in columns: + label = amanager.get_label(dataset) + unit = label['units'].split('\n')[-1] + if unit == "Unknown": + header.append(label['label']) + else: + header.append("%s %s" % (label['label'], unit)) + + rows = [] + for i, t in enumerate(times): + row = [t] + for dataset in columns: + row.append(datasets[dataset][i]) + rows.append(row) + + return header, rows + + +###################################################################### +# Startup +###################################################################### + +def list_datasets(): + datasets = readlog.list_datasets() + analyzers.list_datasets() + out = ["\nAvailable datasets:\n"] + for dataset, desc in datasets: + out.append("%-24s: %s\n" % (dataset, desc)) + out.append("\n") + sys.stdout.write("".join(out)) + sys.exit(0) + +def main(): + # Parse command-line arguments + usage = "%prog [options] " + opts = optparse.OptionParser(usage) + opts.add_option("-o", "--output", type="string", dest="output", + default=None, help="filename of output csv") + opts.add_option("-s", "--skip", type="float", default=0., + help="Set the start time to export") + opts.add_option("-d", "--duration", type="float", default=5., + help="Number of seconds to export") + opts.add_option("--segment-time", type="float", default=0.000100, + help="Analysis segment time (default 0.000100 seconds)") + opts.add_option("-c", "--columns", + help="Columns to export (python literal)") + opts.add_option("-l", "--list-datasets", action="store_true", + help="List available datasets") + options, args = opts.parse_args() + if options.list_datasets: + list_datasets() + if len(args) != 1: + opts.error("Incorrect number of arguments") + if options.columns is None: + opts.error("Option --columns is required") + log_prefix = args[0] + + columns = ast.literal_eval(options.columns) + + lmanager = readlog.LogManager(log_prefix) + lmanager.setup_index() + lmanager.seek_time(options.skip) + amanager = analyzers.AnalyzerManager(lmanager, options.segment_time) + amanager.set_duration(options.duration) + + header, rows = export_csv(amanager, columns) + + if options.output is None: + writer = csv.writer(sys.stdout) + else: + outfile = open(options.output, "w") + writer = csv.writer(outfile) + + writer.writerow(header) + for row in rows: + writer.writerow(row) + + if options.output is not None: + outfile.close() + +if __name__ == '__main__': + main() From ca8230d505b7ba7fd225bfa6ed9655bc4520e805 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Mon, 11 May 2026 18:45:19 -0400 Subject: [PATCH 22/32] ldc1612: Add support for a new 'max_sensor_hz' config parameter Add a new option so that setting of sensor_div is more robust. Signed-off-by: Kevin O'Connor --- docs/Config_Reference.md | 6 ++++++ klippy/extras/ldc1612.py | 20 ++++++++++++++------ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/docs/Config_Reference.md b/docs/Config_Reference.md index 71cbf3ef7f54..1efa1a057a0a 100644 --- a/docs/Config_Reference.md +++ b/docs/Config_Reference.md @@ -2323,6 +2323,12 @@ 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. +#max_sensor_hz: +# Maximum expected resonant frequency reported by the sensor (in +# Hz). This is used during internal clock rate configuration. This +# value is typically only configured if the software reports a +# warning suggesting the value should be increased. The default is +# 5000000. #descend_z: # The nominal distance (in mm) between the nozzle and bed that a # probing attempt should stop at. This parameter must be provided. diff --git a/klippy/extras/ldc1612.py b/klippy/extras/ldc1612.py index 36319a02cbab..eb3326a741b6 100644 --- a/klippy/extras/ldc1612.py +++ b/klippy/extras/ldc1612.py @@ -1,9 +1,9 @@ # Support for reading frequency samples from ldc1612 # -# Copyright (C) 2020-2024 Kevin O'Connor +# Copyright (C) 2020-2026 Kevin O'Connor # # This file may be distributed under the terms of the GNU GPLv3 license. -import logging +import logging, math from . import bus, bulk_sensor MIN_MSG_TIME = 0.100 @@ -76,6 +76,7 @@ def handle_batch(msg): class LDC1612: def __init__(self, config, calibration=None): self.printer = config.get_printer() + self.name = config.get_name().split()[-1] self.calibration = calibration self.dccal = DriveCurrentCalibrate(config, self) self.data_rate = 400 @@ -89,10 +90,18 @@ def __init__(self, config, calibration=None): self.query_ldc1612_cmd = None self.clock_freq = config.getint("frequency", DEFAULT_LDC1612_FREQ, 2000000, 40000000) - # Coil frequency divider, assume 12MHz is BTT Eddy - # BTT Eddy's coil frequency is > 1/4 of reference clock - self.sensor_div = 1 if self.clock_freq != DEFAULT_LDC1612_FREQ else 2 + # Determine sensor divider (want 4*max_hz < clock_ref) + max_hz = config.getfloat("max_sensor_hz", 5000000., 3000000., 20000000.) + self.sensor_div = int(math.ceil(4. * max_hz / self.clock_freq)) self.freq_conv = float(self.clock_freq * self.sensor_div) / (1<<28) + if self.calibration is not None: + cal_freqs, cal_zpos = self.calibration.get_calibration() + if cal_freqs and max(cal_freqs) > max_hz: + pconfig = self.printer.lookup_object("configfile") + pconfig.runtime_warning( + "ldc1612 %s: Should set 'max_sensor_hz' to at least %d" + % (self.name, math.ceil(max(cal_freqs)))) + # Configure intb and mcu object if config.get('intb_pin', None) is not None: ppins = config.get_printer().lookup_object("pins") pin_params = ppins.lookup_pin(config.get('intb_pin')) @@ -115,7 +124,6 @@ def __init__(self, config, calibration=None): self.batch_bulk = bulk_sensor.BatchBulkHelper( self.printer, self._process_batch, self._start_measurements, self._finish_measurements, BATCH_UPDATES) - self.name = config.get_name().split()[-1] hdr = ('time', 'frequency', 'z') self.batch_bulk.add_mux_endpoint("ldc1612/dump_ldc1612", "sensor", self.name, {'header': hdr}) From a994bb1f733af71840fa68764de78c084125be3e Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Mon, 11 May 2026 13:16:01 -0400 Subject: [PATCH 23/32] temperature_probe: Convert from custom fit() code to mathutil gaussian_solve() Signed-off-by: Kevin O'Connor --- klippy/extras/temperature_probe.py | 46 ++++++++---------------------- 1 file changed, 12 insertions(+), 34 deletions(-) diff --git a/klippy/extras/temperature_probe.py b/klippy/extras/temperature_probe.py index f47c4e8481df..23df0ddde755 100644 --- a/klippy/extras/temperature_probe.py +++ b/klippy/extras/temperature_probe.py @@ -4,6 +4,7 @@ # # This file may be distributed under the terms of the GNU GPLv3 license. import logging +import mathutil from . import manual_probe KELVIN_TO_CELSIUS = -273.15 @@ -12,16 +13,6 @@ # Polynomial Helper Classes and Functions ###################################################################### -def calc_determinant(matrix): - m = matrix - aei = m[0][0] * m[1][1] * m[2][2] - bfg = m[1][0] * m[2][1] * m[0][2] - cdh = m[2][0] * m[0][1] * m[1][2] - ceg = m[2][0] * m[1][1] * m[0][2] - bdi = m[1][0] * m[0][1] * m[2][2] - afh = m[0][0] * m[2][1] * m[1][2] - return aei + bfg + cdh - ceg - bdi - afh - class Polynomial2d: def __init__(self, a, b, c): self.a = a @@ -56,30 +47,17 @@ def __repr__(self): @classmethod def fit(cls, coords): - xlist = [c[0] for c in coords] - ylist = [c[1] for c in coords] - count = len(coords) - sum_x = sum(xlist) - sum_y = sum(ylist) - sum_x2 = sum([x**2 for x in xlist]) - sum_x3 = sum([x**3 for x in xlist]) - sum_x4 = sum([x**4 for x in xlist]) - sum_xy = sum([x * y for x, y in coords]) - sum_x2y = sum([y*x**2 for x, y in coords]) - vector_b = [sum_y, sum_xy, sum_x2y] - m = [ - [count, sum_x, sum_x2], - [sum_x, sum_x2, sum_x3], - [sum_x2, sum_x3, sum_x4] - ] - m0 = [vector_b, m[1], m[2]] - m1 = [m[0], vector_b, m[2]] - m2 = [m[0], m[1], vector_b] - det_m = calc_determinant(m) - a0 = calc_determinant(m0) / det_m - a1 = calc_determinant(m1) / det_m - a2 = calc_determinant(m2) / det_m - return cls(a0, a1, a2) + # Find best fit for: a + b*x + c*x*x = y + eqs = [] + ans = [] + for x, y in coords: + eqs.append([1., x, x*x]) + ans.append([y]) + eqst = mathutil.mat_transp(eqs) + eqst_eqs = mathutil.mat_mat_mul(eqst, eqs) + eqst_ans = mathutil.mat_mat_mul(eqst, ans) + res = mathutil.gaussian_solve(eqst_eqs, eqst_ans) + return cls(res[0][0], res[1][0], res[2][0]) class TemperatureProbe: def __init__(self, config): From 835c37aae0c892d816272a10158a262150a0af63 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Wed, 13 May 2026 10:19:10 -0400 Subject: [PATCH 24/32] mathutil: Add a solve_linear_equations() helper Signed-off-by: Kevin O'Connor --- klippy/extras/probe_eddy_current.py | 5 +---- klippy/extras/temperature_probe.py | 5 +---- klippy/mathutil.py | 7 +++++++ 3 files changed, 9 insertions(+), 8 deletions(-) diff --git a/klippy/extras/probe_eddy_current.py b/klippy/extras/probe_eddy_current.py index 4736a44eaf85..6fc3172e36dc 100644 --- a/klippy/extras/probe_eddy_current.py +++ b/klippy/extras/probe_eddy_current.py @@ -351,10 +351,7 @@ def _analyze_main_calibration(self): if z <= 0.750: ans.append([freq]) eqs.append([1., z, z*z]) - eqst = mathutil.mat_transp(eqs) - eqst_eqs = mathutil.mat_mat_mul(eqst, eqs) - eqst_ans = mathutil.mat_mat_mul(eqst, ans) - return mathutil.gaussian_solve(eqst_eqs, eqst_ans) + return mathutil.solve_linear_equations(eqs, ans) def _describe_main_calibration(self, coeffs): if coeffs is None: return ["Main calibration data not available.", ""] diff --git a/klippy/extras/temperature_probe.py b/klippy/extras/temperature_probe.py index 23df0ddde755..ef53f0ed744a 100644 --- a/klippy/extras/temperature_probe.py +++ b/klippy/extras/temperature_probe.py @@ -53,10 +53,7 @@ def fit(cls, coords): for x, y in coords: eqs.append([1., x, x*x]) ans.append([y]) - eqst = mathutil.mat_transp(eqs) - eqst_eqs = mathutil.mat_mat_mul(eqst, eqs) - eqst_ans = mathutil.mat_mat_mul(eqst, ans) - res = mathutil.gaussian_solve(eqst_eqs, eqst_ans) + res = mathutil.solve_linear_equations(eqs, ans) return cls(res[0][0], res[1][0], res[2][0]) class TemperatureProbe: diff --git a/klippy/mathutil.py b/klippy/mathutil.py index 1506bc01ac58..e5406982bfd6 100644 --- a/klippy/mathutil.py +++ b/klippy/mathutil.py @@ -199,3 +199,10 @@ def pseudo_inverse(m): mt = mat_transp(m) mtm = mat_mat_mul(mt, m) return gaussian_solve(mtm, mt) + +# Find least squares solution for a set of linear equations +def solve_linear_equations(eqs, ans): + eqst = mat_transp(eqs) + eqst_eqs = mat_mat_mul(eqst, eqs) + eqst_ans = mat_mat_mul(eqst, ans) + return gaussian_solve(eqst_eqs, eqst_ans) From ef7c9116f1a0d3503e4f05be209f324a647dc3d2 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Wed, 13 May 2026 13:05:57 -0400 Subject: [PATCH 25/32] mathutil: Minor matrix optimizations Python is faster with list comprehensions than generators (prefer `sum([x for x in c])` over `sum(x for x in c)` ). Python2 is faster if lists are built with list comprehensions instead of appending. Optimize the common `mat_mat_mul(mat_transp(a), a)` as only roughly half the results are unique. Signed-off-by: Kevin O'Connor --- klippy/mathutil.py | 49 +++++++++++++++++++++++++++++++--------------- 1 file changed, 33 insertions(+), 16 deletions(-) diff --git a/klippy/mathutil.py b/klippy/mathutil.py index e5406982bfd6..68b04149fd87 100644 --- a/klippy/mathutil.py +++ b/klippy/mathutil.py @@ -141,20 +141,38 @@ def matrix_mul(m1, s): # Matrix helper functions for NxM matrices ###################################################################### +# Transpose a matrix +def mat_transp(a): + return [[a[j][i] for j in range(len(a))] + for i in range(len(a[0]))] + +# Multiply two matrices def mat_mat_mul(a, b): - if len(a[0]) != len(b): + rows_a = len(a) + cols_a = len(a[0]) + rows_b = len(b) + cols_b = len(b[0]) + if cols_a != rows_b: return None - res = [] - for i in range(len(a)): - res.append([]) - for j in range(len(b[0])): - res[i].append(sum(a[i][k] * b[k][j] for k in range(len(b)))) - return res - -def mat_transp(a): - res = [] - for i in range(len(a[0])): - res.append([a[j][i] for j in range(len(a))]) + return [[sum([a[i][k] * b[k][j] for k in range(rows_b)]) + for j in range(cols_b)] + for i in range(rows_a)] + +# Optimized version of mat_mat_mul(mat_transp(a), b) +def mat_transp_mul(a, b): + rows_at = len(a[0]) + cols_at = len(a) + rows_b = len(b) + cols_b = len(b[0]) + if cols_at != rows_b: + return None + res = [[0.] * cols_b for i in range(rows_at)] + for i in range(rows_at): + for j in range(cols_b): + if a is b and j < i: + res[i][j] = res[j][i] + continue + res[i][j] = sum([a[k][i] * b[k][j] for k in range(rows_b)]) return res def gaussian_solve(a, rhs): @@ -196,13 +214,12 @@ def gaussian_solve(a, rhs): return res def pseudo_inverse(m): + mtm = mat_transp_mul(m, m) mt = mat_transp(m) - mtm = mat_mat_mul(mt, m) return gaussian_solve(mtm, mt) # Find least squares solution for a set of linear equations def solve_linear_equations(eqs, ans): - eqst = mat_transp(eqs) - eqst_eqs = mat_mat_mul(eqst, eqs) - eqst_ans = mat_mat_mul(eqst, ans) + eqst_eqs = mat_transp_mul(eqs, eqs) + eqst_ans = mat_transp_mul(eqs, ans) return gaussian_solve(eqst_eqs, eqst_ans) From 455ef070e8f87d70db9db0e10d7ceb30cd1f82b0 Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Wed, 13 May 2026 16:17:32 -0400 Subject: [PATCH 26/32] z_tilt: Use solve_linear_equations() instead of coordinate_descent() It's simpler and more accurate to use linear least squares. Signed-off-by: Kevin O'Connor --- klippy/extras/z_tilt.py | 31 ++++++++++++++----------------- klippy/mathutil.py | 15 +++++++++------ test/klippy/multi_z.test | 10 +++++++++- 3 files changed, 32 insertions(+), 24 deletions(-) diff --git a/klippy/extras/z_tilt.py b/klippy/extras/z_tilt.py index 28763f1f01ab..e1a979e27e76 100644 --- a/klippy/extras/z_tilt.py +++ b/klippy/extras/z_tilt.py @@ -144,26 +144,23 @@ def cmd_Z_TILT_ADJUST(self, gcmd): self.retry_helper.start(gcmd) self.probe_helper.start_probe(gcmd) def probe_finalize(self, positions): - # Setup for coordinate descent analysis logging.info("Calculating bed tilt with: %s", positions) - params = { 'x_adjust': 0., 'y_adjust': 0., 'z_adjust': 0. } - # Perform coordinate descent - def adjusted_height(pos, params): - return (pos.bed_z - pos.bed_x*params['x_adjust'] - - pos.bed_y*params['y_adjust'] - params['z_adjust']) - def errorfunc(params): - total_error = 0. - for pos in positions: - total_error += adjusted_height(pos, params)**2 - return total_error - new_params = mathutil.coordinate_descent( - params.keys(), params, errorfunc) + # Find best fit for: a + b*bed_x + c*bed_y = bed_z + eqs = [] + ans = [] + for pos in positions: + eqs.append([1., pos.bed_x, pos.bed_y]) + ans.append([pos.bed_z]) + res = mathutil.solve_linear_equations(eqs, ans, + allow_underdetermined=True) + z_adjust = res[0][0] + x_adjust = res[1][0] + y_adjust = res[2][0] # Apply results + logging.info("Calculated bed tilt parameters:" + " x_adjust=%.6f y_adjust=%.6f z_adjust=%.6f", + x_adjust, y_adjust, z_adjust) speed = self.probe_helper.get_lift_speed() - logging.info("Calculated bed tilt parameters: %s", new_params) - x_adjust = new_params['x_adjust'] - y_adjust = new_params['y_adjust'] - z_adjust = new_params['z_adjust'] adjustments = [x*x_adjust + y*y_adjust + z_adjust for x, y in self.z_positions] self.z_helper.adjust_steppers(adjustments, speed) diff --git a/klippy/mathutil.py b/klippy/mathutil.py index 68b04149fd87..5c6089849ac5 100644 --- a/klippy/mathutil.py +++ b/klippy/mathutil.py @@ -175,7 +175,7 @@ def mat_transp_mul(a, b): res[i][j] = sum([a[k][i] * b[k][j] for k in range(rows_b)]) return res -def gaussian_solve(a, rhs): +def gaussian_solve(a, rhs, allow_underdetermined=False): res = copy.deepcopy(rhs) m = copy.deepcopy(a) n = len(m) @@ -188,10 +188,13 @@ def gaussian_solve(a, rhs): m[i], m[j] = m[j], m[i] res[i], res[j] = res[j], res[i] - if abs(m[i][i]) < 1e-10: - return None # Scale the i-th row - recipr = 1. / m[i][i] + if abs(m[i][i]) < 1e-10: + if not allow_underdetermined: + return None + recipr = 0. + else: + recipr = 1. / m[i][i] for j in range(i+1, n): m[i][j] *= recipr for j in range(len(res[i])): @@ -219,7 +222,7 @@ def pseudo_inverse(m): return gaussian_solve(mtm, mt) # Find least squares solution for a set of linear equations -def solve_linear_equations(eqs, ans): +def solve_linear_equations(eqs, ans, allow_underdetermined=False): eqst_eqs = mat_transp_mul(eqs, eqs) eqst_ans = mat_transp_mul(eqs, ans) - return gaussian_solve(eqst_eqs, eqst_ans) + return gaussian_solve(eqst_eqs, eqst_ans, allow_underdetermined) diff --git a/test/klippy/multi_z.test b/test/klippy/multi_z.test index add117c51b33..8fe23ccc92c3 100644 --- a/test/klippy/multi_z.test +++ b/test/klippy/multi_z.test @@ -18,7 +18,15 @@ BED_TILT_CALIBRATE G1 Z5 X0 Y0 # Run Z_TILT_ADJUST -Z_TILT_ADJUST +Z_TILT_ADJUST method=manual +TESTZ Z=-.2 +accept +TESTZ Z=-.3 +accept +TESTZ Z=-.4 +accept +TESTZ Z=-.1 +accept # Move again G1 Z2 X2 Y3 From 76f3a97464ab391339f4a1ba930130fb4a2cbaee Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Wed, 13 May 2026 16:18:24 -0400 Subject: [PATCH 27/32] bed_tilt: Use solve_linear_equations() instead of coordinate_descent() It's simpler and more accurate to use linear least squares. Signed-off-by: Kevin O'Connor --- klippy/extras/bed_tilt.py | 35 ++++++++++++----------------------- 1 file changed, 12 insertions(+), 23 deletions(-) diff --git a/klippy/extras/bed_tilt.py b/klippy/extras/bed_tilt.py index feb92499768d..21bbbcb5c47b 100644 --- a/klippy/extras/bed_tilt.py +++ b/klippy/extras/bed_tilt.py @@ -59,33 +59,22 @@ def __init__(self, config, bedtilt): def cmd_BED_TILT_CALIBRATE(self, gcmd): self.probe_helper.start_probe(gcmd) def probe_finalize(self, positions): - # Setup for coordinate descent analysis logging.info("Calculating bed_tilt with: %s", positions) - params = { 'x_adjust': self.bedtilt.x_adjust, - 'y_adjust': self.bedtilt.y_adjust, - 'z_adjust': 0. } - logging.info("Initial bed_tilt parameters: %s", params) - # Perform coordinate descent - def adjusted_height(pos, params): - return (pos.bed_z - pos.bed_x*params['x_adjust'] - - pos.bed_y*params['y_adjust'] - params['z_adjust']) - def errorfunc(params): - total_error = 0. - for pos in positions: - total_error += adjusted_height(pos, params)**2 - return total_error - new_params = mathutil.coordinate_descent( - params.keys(), params, errorfunc) + # Find best fit for: a + b*bed_x + c*bed_y = bed_z + eqs = [] + ans = [] + for pos in positions: + eqs.append([1., pos.bed_x, pos.bed_y]) + ans.append([pos.bed_z]) + res = mathutil.solve_linear_equations(eqs, ans) + if res is None: + raise self.gcode.error("Unable to calculate tilt") + z_adjust = res[0][0] + x_adjust = res[1][0] + y_adjust = res[2][0] # Update current bed_tilt calculations - x_adjust = new_params['x_adjust'] - y_adjust = new_params['y_adjust'] - z_adjust = new_params['z_adjust'] self.bedtilt.update_adjust(x_adjust, y_adjust, z_adjust) # Log and report results - logging.info("Calculated bed_tilt parameters: %s", new_params) - for pos in positions: - logging.info("orig: %s new: %s", adjusted_height(pos, params), - adjusted_height(pos, new_params)) msg = "x_adjust: %.6f y_adjust: %.6f z_adjust: %.6f" % ( x_adjust, y_adjust, z_adjust) self.printer.set_rollover_info("bed_tilt", "bed_tilt: %s" % (msg,)) From 4cc47cf56542944fdaed633acd525f3b7b17c2bc Mon Sep 17 00:00:00 2001 From: Timofey Titovets Date: Mon, 2 Feb 2026 01:45:11 +0100 Subject: [PATCH 28/32] temperature_probe: use tap for calibration Signed-off-by: Timofey Titovets --- docs/G-Codes.md | 6 ++++-- klippy/extras/temperature_probe.py | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 2 deletions(-) diff --git a/docs/G-Codes.md b/docs/G-Codes.md index ab2e7f15a3d1..d061dd359ea1 100644 --- a/docs/G-Codes.md +++ b/docs/G-Codes.md @@ -1584,13 +1584,15 @@ The following commands are available when a is enabled. #### TEMPERATURE_PROBE_CALIBRATE -`TEMPERATURE_PROBE_CALIBRATE [PROBE=] [TARGET=] [STEP=]`: +`TEMPERATURE_PROBE_CALIBRATE [PROBE=] [TARGET=] [STEP=] +[METHOD=]`: Initiates probe drift calibration for eddy current based probes. The `TARGET` is a target temperature for the last sample. When the temperature recorded during a sample exceeds the `TARGET` calibration will complete. The `STEP` parameter sets temperature delta (in C) between samples. After a sample has been taken, this delta is used to schedule a call to `TEMPERATURE_PROBE_NEXT`. -The default `STEP` is 2. +The default `STEP` is 2. The `METHOD` only supports `tap` as an option, +if specified, probing will be automated. #### TEMPERATURE_PROBE_NEXT `TEMPERATURE_PROBE_NEXT`: After calibration has started this command is run to diff --git a/klippy/extras/temperature_probe.py b/klippy/extras/temperature_probe.py index ef53f0ed744a..d56f9067c17f 100644 --- a/klippy/extras/temperature_probe.py +++ b/klippy/extras/temperature_probe.py @@ -95,6 +95,7 @@ def __init__(self, config): self.last_temp_read_time = 0. self.last_measurement = (0., 99999999., 0.,) # Calibration State + self._method = "manual" self.cal_helper = None self.next_auto_temp = 99999999. self.target_temp = 0 @@ -323,7 +324,19 @@ def _get_speeds(self): cmd_TEMPERATURE_PROBE_CALIBRATE_help = ( "Calibrate probe temperature drift compensation" ) + def _auto_probe(self, gcmd): + fo_params = dict(gcmd.get_command_parameters()) + fo_params['METHOD'] = self._method + gcode = self.printer.lookup_object('gcode') + fo_gcmd = gcode.create_gcode_command("PROBE", "PROBE", fo_params) + pprobe = self.printer.lookup_object("probe") + probe_session = pprobe.start_probe_session(fo_gcmd) + probe_session.run_probe(fo_gcmd) + pos = probe_session.pull_probed_results()[0] + probe_session.end_probe_session() + self._manual_probe_finalize(pos) def cmd_TEMPERATURE_PROBE_CALIBRATE(self, gcmd): + self._method = gcmd.get('METHOD', 'manual').lower() if self.cal_helper is None: raise gcmd.error( "No calibration helper registered for [%s]" @@ -386,6 +399,9 @@ def cmd_TEMPERATURE_PROBE_CALIBRATE(self, gcmd): # Capture start position and begin initial probe toolhead = self.printer.lookup_object("toolhead") self.start_pos = toolhead.get_position()[:2] + if self._method == "tap": + self._auto_probe(gcmd) + return manual_probe.ManualProbeHelper( self.printer, gcmd, self._manual_probe_finalize ) @@ -408,6 +424,9 @@ def cmd_TEMPERATURE_PROBE_NEXT(self, gcmd): curpos[2] = start_z toolhead.manual_move(curpos, probe_speed) self.gcode.register_command("ABORT", None) + if self._method == "tap": + self._auto_probe(gcmd) + return manual_probe.ManualProbeHelper( self.printer, gcmd, self._manual_probe_finalize ) From 87f5f13536f6a0c65f9805bd39300c3c70e7942a Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Sat, 2 May 2026 20:33:39 +0200 Subject: [PATCH 29/32] load_cell_probe: Fixed floating point parameters to match the docs Signed-off-by: Dmitry Butyugin --- klippy/extras/load_cell_probe.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/klippy/extras/load_cell_probe.py b/klippy/extras/load_cell_probe.py index c2fef9d2b21a..f37c8f819dda 100644 --- a/klippy/extras/load_cell_probe.py +++ b/klippy/extras/load_cell_probe.py @@ -242,9 +242,9 @@ def __init__(self, config, load_cell_inst): self._tare_time_param = floatParamHelper(config, 'tare_time', default=4. / 60., minval=0.01, maxval=1.0) # triggering options - self._trigger_force_param = intParamHelper(config, 'trigger_force', + self._trigger_force_param = floatParamHelper(config, 'trigger_force', default=75, minval=10, maxval=250) - self._force_safety_limit_param = intParamHelper(config, + self._force_safety_limit_param = floatParamHelper(config, 'force_safety_limit', minval=100, maxval=5000, default=2000) def get_tare_samples(self, gcmd=None): From bd09e0170b6da6858486fb84c72ff1916d35290a Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Sat, 9 May 2026 15:30:41 +0200 Subject: [PATCH 30/32] load_cell: Fully support floating-point samples-per-second values Signed-off-by: Dmitry Butyugin --- klippy/extras/load_cell.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/klippy/extras/load_cell.py b/klippy/extras/load_cell.py index 361c937fc616..5448389c8219 100644 --- a/klippy/extras/load_cell.py +++ b/klippy/extras/load_cell.py @@ -369,7 +369,7 @@ def __init__(self, config, sensor): self.config_name = config.get_name() self.name = config.get_name().split()[-1] self.sensor = sensor # must implement BulkSensorAdc - buffer_size = sensor.get_samples_per_second() // 2 + buffer_size = int(sensor.get_samples_per_second() / 2) self._force_buffer = collections.deque(maxlen=buffer_size) self.reference_tare_counts = config.getint('reference_tare_counts', default=None) @@ -456,7 +456,7 @@ def counts_to_percent(self, counts): # performs safety checks for saturation def avg_counts(self, num_samples=None): if num_samples is None: - num_samples = self.sensor.get_samples_per_second() + num_samples = int(self.sensor.get_samples_per_second()) samples, errors = self.get_collector().collect_min(num_samples) if errors: raise self.printer.command_error( From db88a336892ed2ca71763110b3226e0a2a1b51ed Mon Sep 17 00:00:00 2001 From: Kevin O'Connor Date: Sun, 17 May 2026 23:21:31 -0400 Subject: [PATCH 31/32] mathutil: Optimizations for matrix multiplication Signed-off-by: Dmitry Butyugin Signed-off-by: Kevin O'Connor --- klippy/mathutil.py | 50 +++++++++++++++++++--------------------------- 1 file changed, 21 insertions(+), 29 deletions(-) diff --git a/klippy/mathutil.py b/klippy/mathutil.py index 5c6089849ac5..53b08bce92b3 100644 --- a/klippy/mathutil.py +++ b/klippy/mathutil.py @@ -4,7 +4,7 @@ # Copyright (C) 2025-2026 Dmitry Butyugin # # This file may be distributed under the terms of the GNU GPLv3 license. -import copy, math, logging, multiprocessing, traceback +import copy, operator, math, logging, multiprocessing, traceback import queuelogger @@ -143,36 +143,27 @@ def matrix_mul(m1, s): # Transpose a matrix def mat_transp(a): - return [[a[j][i] for j in range(len(a))] + return [[a_j[i] for a_j in a] for i in range(len(a[0]))] # Multiply two matrices def mat_mat_mul(a, b): - rows_a = len(a) - cols_a = len(a[0]) - rows_b = len(b) - cols_b = len(b[0]) - if cols_a != rows_b: + if len(a[0]) != len(b): return None - return [[sum([a[i][k] * b[k][j] for k in range(rows_b)]) - for j in range(cols_b)] - for i in range(rows_a)] - -# Optimized version of mat_mat_mul(mat_transp(a), b) -def mat_transp_mul(a, b): - rows_at = len(a[0]) - cols_at = len(a) - rows_b = len(b) - cols_b = len(b[0]) - if cols_at != rows_b: - return None - res = [[0.] * cols_b for i in range(rows_at)] - for i in range(rows_at): - for j in range(cols_b): - if a is b and j < i: - res[i][j] = res[j][i] - continue - res[i][j] = sum([a[k][i] * b[k][j] for k in range(rows_b)]) + bt = mat_transp(b) + return [[sum(map(operator.mul, a_i, bt_j)) + for bt_j in bt] + for a_i in a] + +# Optimized version of mat_mat_mul(a, mat_transp(a)) +def mat_mul_transp(a): + # Resulting matrix is symmetric - compute lower-left + res = [[sum(map(operator.mul, a_i, a_j)) + for a_j in a[:i+1]] + for i, a_i in enumerate(a)] + # Fill in upper right of matrix + for i, res_i in enumerate(res): + res_i.extend([res_j[i] for res_j in res[i+1:]]) return res def gaussian_solve(a, rhs, allow_underdetermined=False): @@ -217,12 +208,13 @@ def gaussian_solve(a, rhs, allow_underdetermined=False): return res def pseudo_inverse(m): - mtm = mat_transp_mul(m, m) mt = mat_transp(m) + mtm = mat_mul_transp(mt) return gaussian_solve(mtm, mt) # Find least squares solution for a set of linear equations def solve_linear_equations(eqs, ans, allow_underdetermined=False): - eqst_eqs = mat_transp_mul(eqs, eqs) - eqst_ans = mat_transp_mul(eqs, ans) + eqst = mat_transp(eqs) + eqst_eqs = mat_mul_transp(eqst) + eqst_ans = mat_mat_mul(eqst, ans) return gaussian_solve(eqst_eqs, eqst_ans, allow_underdetermined) From 616242d4678a9cca65a66e6c3e02337c66672bf0 Mon Sep 17 00:00:00 2001 From: Dmitry Butyugin Date: Sun, 17 May 2026 22:48:49 +0200 Subject: [PATCH 32/32] mathutil: Optimizations of gaussian solve Rework gaussian_solve() to use the lower left part of the matrix (instead of the upper right). This simplifies the indexing and avoids the need to store 1.0 and 0.0 entries back to the matrix. Prefer building new lists instead of updating lists in place. Use mat_transp(res) to improve list layout during the final solving phase. Signed-off-by: Dmitry Butyugin Signed-off-by: Kevin O'Connor --- klippy/mathutil.py | 60 +++++++++++++++++++++++----------------------- 1 file changed, 30 insertions(+), 30 deletions(-) diff --git a/klippy/mathutil.py b/klippy/mathutil.py index 53b08bce92b3..7adf9aa56fc9 100644 --- a/klippy/mathutil.py +++ b/klippy/mathutil.py @@ -4,7 +4,7 @@ # Copyright (C) 2025-2026 Dmitry Butyugin # # This file may be distributed under the terms of the GNU GPLv3 license. -import copy, operator, math, logging, multiprocessing, traceback +import operator, math, logging, multiprocessing, traceback import queuelogger @@ -167,45 +167,45 @@ def mat_mul_transp(a): return res def gaussian_solve(a, rhs, allow_underdetermined=False): - res = copy.deepcopy(rhs) - m = copy.deepcopy(a) - n = len(m) + res = list(rhs) + m = list(a) + rows_m = len(m) # Perform the LU-decomposition through Gaussian elimination - for i in range(n): + for i in range(rows_m-1, -1, -1): # Find a pivot and swap the corresponding rows - abs_col = [abs(m[j][i]) for j in range(i, n)] - j = abs_col.index(max(abs_col)) + i + abs_col = [abs(m_j[i]) for m_j in m[:i+1]] + j = abs_col.index(max(abs_col)) if i != j: m[i], m[j] = m[j], m[i] res[i], res[j] = res[j], res[i] - # Scale the i-th row - if abs(m[i][i]) < 1e-10: + # Scale the i-th row (and drop last column) + m_i = m[i] + if abs(m_i[i]) < 1e-10: if not allow_underdetermined: return None recipr = 0. else: - recipr = 1. / m[i][i] - for j in range(i+1, n): - m[i][j] *= recipr - for j in range(len(res[i])): - res[i][j] *= recipr - m[i][i] = 1. - - # Zero-out the i-th column after the row i - for j in range(i+1, n): - c = m[j][i] - for k in range(i, n): - m[j][k] -= c * m[i][k] - for k in range(len(res[j])): - res[j][k] -= c * res[i][k] - - # Solve the system with the upper-triangular matrix - for i in range(n-2, -1, -1): - for j in range(i+1, n): - for k in range(len(res[j])): - res[i][k] -= m[i][j] * res[j][k] - return res + recipr = 1. / m_i[i] + m[i] = m_i = [m_i_j * recipr for m_i_j in m_i[:i]] + res[i] = res_i = [res_i_k * recipr for res_i_k in res[i]] + + # Zero-out the last column in rows prior to i, and remove last column + for j in range(i): + m_j = m[j] + c = m_j[i] + m[j] = [m_j_k - c * m_i_k for m_j_k, m_i_k in zip(m_j, m_i)] + res[j] = [res_j_k - c * res_i_k + for res_j_k, res_i_k in zip(res[j], res_i)] + + # Solve the system with the lower-triangular matrix + rest = mat_transp(res) + if not rest: + return res + for rest_k in rest: + for i in range(1, rows_m): + rest_k[i] -= sum(map(operator.mul, m[i], rest_k[:i])) + return mat_transp(rest) def pseudo_inverse(m): mt = mat_transp(m)