From a49a571911d3ee9d47bb30bfe20e2e0f60141861 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sun, 23 Jun 2024 00:25:15 +0200 Subject: [PATCH 1/5] motor resonances filters added --- README.md | 21 ++++ shaketune/motor_res_filter.py | 123 ++++++++++++++++++ shaketune/shaketune.py | 226 ++++++++++++++++++++++------------ 3 files changed, 290 insertions(+), 80 deletions(-) create mode 100644 shaketune/motor_res_filter.py diff --git a/README.md b/README.md index b69f6b1..1a73bf6 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,27 @@ Follow these steps to install Shake&Tune on your printer: # printer.cfg file. If you want to see the macros in the webui, set this to True. # timeout: 300 # The maximum time in seconds to let Shake&Tune process the CSV files and generate the graphs. + + # motor_freq: + # /!\ This option is only available in DangerKlipper /!\ + # Frequencies of X and Y motor resonances to filter them using + # composite shapers. This require the `[input_shaper]` config + # section to be defined in your printer.cfg file to work. + # motor_freq_x: + # motor_freq_y: + # /!\ This option is only available in DangerKlipper /!\ + # If motor_freq is not set, these two parameters can be used + # to configure different filters for X and Y motors. The same + # values are supported as for motor_freq parameter. + # motor_damping_ratio: 0.05 + # /!\ This option is only available in DangerKlipper /!\ + # Damping ratios of X and Y motor resonances. + # motor_damping_ratio_x: + # motor_damping_ratio_y: + # /!\ This option is only available in DangerKlipper /!\ + # If motor_damping_ratio is not set, these two parameters can be used + # to configure different filters for X and Y motors. The same values + # are supported as for motor_damping_ratio parameter. ``` Don't forget to check out **[Shake&Tune documentation here](./docs/README.md)**. diff --git a/shaketune/motor_res_filter.py b/shaketune/motor_res_filter.py new file mode 100644 index 0000000..5bf6240 --- /dev/null +++ b/shaketune/motor_res_filter.py @@ -0,0 +1,123 @@ +# Shake&Tune: 3D printer analysis tools +# +# Copyright (C) 2024 Félix Boisselier (Frix_x on Discord) +# Licensed under the GNU General Public License v3.0 (GPL-3.0) +# +# File: motor_res_filter.py +# Description: This script defines the MotorResonanceFilter class that applies and removes motor resonance filters +# into the input shaper initial Klipper object. This is done by convolving a motor resonance targeted +# input shaper filter with the current configured axis input shapers. + +from importlib import import_module + +from .helpers.console_output import ConsoleOutput + +shaper_defs = import_module('.shaper_defs', 'extras') + + +class MotorResonanceFilter: + def __init__(self, printer, freq_x: float, freq_y: float, damping_x: float, damping_y: float, in_danger: bool): + self._printer = printer + self.freq_x = freq_x + self.freq_y = freq_y + self.damping_x = damping_x + self.damping_y = damping_y + self._in_danger = in_danger + + self._original_shapers = {} + + # Convolve two Klipper shapers into a new composite shaper + @staticmethod + def convolve_shapers(L, R): + As = [a * b for a in L[0] for b in R[0]] + Ts = [a + b for a in L[1] for b in R[1]] + C = sorted(list(zip(Ts, As))) + return ([a for _, a in C], [t for t, _ in C]) + + def apply_filters(self) -> None: + input_shaper = self._printer.lookup_object('input_shaper', None) + shapers = input_shaper.get_shapers() + for shaper in shapers: + axis = shaper.axis + shaper_type = shaper.params.get_status()['shaper_type'] + + # Ignore the motor resonance filters for smoothers from DangerKlipper + if shaper_type.startswith('smooth_'): + ConsoleOutput.print( + ( + f'Warning: {shaper_type} type shaper on {axis} axis is a smoother from DangerKlipper ' + 'Bleeding-Edge that already filters the motor resonance frequency range. Shake&Tune ' + 'motor resonance filters will be ignored for this axis...' + ) + ) + continue + + # Ignore the motor resonance filters for custom shapers as users can set their own A&T values + if shaper_type == 'custom': + ConsoleOutput.print( + ( + f'Warning: custom type shaper on {axis} axis is a manually crafted filter. So you have ' + 'already set custom A&T values for this axis and you should be able to convolve the motor ' + 'resonance frequency range to this custom shaper. Shake&Tune motor resonance filters will ' + 'be ignored for this axis...' + ) + ) + continue + + # At the moment, when running stock Klipper, only ZV type shapers are supported to get combined with + # the motor resonance filters. This is due to the size of the pulse train that is too small and is not + # allowing the convolved shapers to be applied. This unless this PR is merged: https://github.com/Klipper3d/klipper/pull/6460 + if not self._in_danger and shaper_type != 'zv': + ConsoleOutput.print( + ( + f'Error: the {axis} axis is not a ZV type shaper. Shake&Tune motor resonance filters ' + 'will be ignored for this axis... Thi is due to the size of the pulse train being too ' + 'small and not allowing the convolved shapers to be applied... unless this PR is ' + 'merged: https://github.com/Klipper3d/klipper/pull/6460' + ) + ) + continue + + # Get the current shaper parameters and store them for later restoration + _, A, T = shaper.get_shaper() + self._original_shapers[axis] = (A, T) + + # Creating the new combined shapers that contains the motor resonance filters + if axis in {'x', 'y'}: + if self._in_danger: + # In DangerKlipper, the pulse train is large enough to allow the + # convolution of any shapers in order to craft the new combined shapers + new_A, new_T = MotorResonanceFilter.convolve_shapers( + (A, T), + shaper_defs.get_mzv_shaper(self.freq_x, self.damping_x), + ) + else: + # In stock Klipper, the pulse train is too small for most shapers + # to be convolved. So we need to use the ZV shaper instead for the + # motor resonance filters... even if it's not the best for this purpose + new_A, new_T = MotorResonanceFilter.convolve_shapers( + (A, T), + shaper_defs.get_zv_shaper(self.freq_x, self.damping_x), + ) + + shaper.A = new_A + shaper.T = new_T + shaper.n = len(new_A) + + # Update the running input shaper filter with the new parameters + input_shaper._update_input_shaping() + + def remove_filters(self) -> None: + input_shaper = self._printer.lookup_object('input_shaper', None) + shapers = input_shaper.get_shapers() + for shaper in shapers: + axis = shaper.axis + if axis in self._original_shapers: + A, T = self._original_shapers[axis] + shaper.A = A + shaper.T = T + shaper.n = len(A) + + # Update the running input shaper filter with the restored initial parameters + # to keep only standard axis input shapers activated + input_shaper._update_input_shaping() diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py index 5b6b5cb..6965a66 100644 --- a/shaketune/shaketune.py +++ b/shaketune/shaketune.py @@ -8,6 +8,7 @@ # loading of the plugin, and the registration of the tuning commands +import importlib import os from pathlib import Path @@ -26,166 +27,231 @@ VibrationsGraphCreator, ) from .helpers.console_output import ConsoleOutput +from .motor_res_filter import MotorResonanceFilter from .shaketune_config import ShakeTuneConfig from .shaketune_process import ShakeTuneProcess IN_DANGER = False +DEFAULT_MOTOR_DAMPING_RATIO = 0.05 +ST_COMMANDS = { + 'EXCITATE_AXIS_AT_FREQ': ( + 'Maintain a specified excitation frequency for a period ' + 'of time to diagnose and locate a source of vibrations' + ), + 'AXES_MAP_CALIBRATION': ( + 'Perform a set of movements to measure the orientation of the accelerometer ' + 'and help you set the best axes_map configuration for your printer' + ), + 'COMPARE_BELTS_RESPONSES': ( + 'Perform a custom half-axis test to analyze and compare the ' + 'frequency profiles of individual belts on CoreXY or CoreXZ printers' + ), + 'AXES_SHAPER_CALIBRATION': 'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter', + 'CREATE_VIBRATIONS_PROFILE': ( + 'Run a series of motions to find speed/angle ranges where the printer could be ' + 'exposed to VFAs to optimize your slicer speed profiles and TMC driver parameters' + ), +} class ShakeTune: def __init__(self, config) -> None: - try: - from extras.danger_options import get_danger_options + self._config = config + self._printer = config.get_printer() - IN_DANGER = True # check if Shake&Tune is running in DangerKlipper - except ImportError: - continue + self._initialize_danger_klipper() + self._initialize_console_output() + self._validate_resonance_tester() + self._initialize_config(config) + self._register_commands() + self._initialize_motor_resonance_filter() - self._pconfig = config - self._printer = config.get_printer() + # Check if Shake&Tune is running in DangerKlipper + def _initialize_danger_klipper(self) -> None: + global IN_DANGER + if importlib.util.find_spec('extras.danger_options') is not None: + IN_DANGER = True + + # Register the console print output callback to the corresponding Klipper function + def _initialize_console_output(self) -> None: gcode = self._printer.lookup_object('gcode') + ConsoleOutput.register_output_callback(gcode.respond_info) + # Check if the resonance_tester object is available in the printer + # configuration as it is required for Shake&Tune to work properly + def _validate_resonance_tester(self) -> None: res_tester = self._printer.lookup_object('resonance_tester', None) if res_tester is None: - config.error('No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune.') + raise self._config.error( + 'No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune.' + ) - self.timeout = config.getfloat('timeout', 300, above=0.0) + # Initialize the ShakeTune object and its configuration + def _initialize_config(self, config) -> None: result_folder = config.get('result_folder', default='~/printer_data/config/ShakeTune_results') result_folder_path = Path(result_folder).expanduser() if result_folder else None keep_n_results = config.getint('number_of_results_to_keep', default=3, minval=0) keep_csv = config.getboolean('keep_raw_csv', default=False) - show_macros = config.getboolean('show_macros_in_webui', default=True) dpi = config.getint('dpi', default=150, minval=100, maxval=500) + self._st_config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi) - self._config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi) - ConsoleOutput.register_output_callback(gcode.respond_info) + self.timeout = config.getfloat('timeout', 300, above=0.0) + self._show_macros = config.getboolean('show_macros_in_webui', default=True) - # Register Shake&Tune's measurement commands + motor_freq = config.getfloat('motor_freq', None, minval=0.0) + self._motor_freq_x = config.getfloat('motor_freq_x', motor_freq, minval=0.0) + self._motor_freq_y = config.getfloat('motor_freq_y', motor_freq, minval=0.0) + motor_damping = config.getfloat('motor_damping_ratio', DEFAULT_MOTOR_DAMPING_RATIO, minval=0.0) + self._motor_damping_x = config.getfloat('motor_damping_ratio_x', motor_damping, minval=0.0) + self._motor_damping_y = config.getfloat('motor_damping_ratio_y', motor_damping, minval=0.0) + + # Create the Klipper commands to allow the user to run Shake&Tune's tools + def _register_commands(self) -> None: + gcode = self._printer.lookup_object('gcode') measurement_commands = [ - ( - 'EXCITATE_AXIS_AT_FREQ', - self.cmd_EXCITATE_AXIS_AT_FREQ, - ( - 'Maintain a specified excitation frequency for a period ' - 'of time to diagnose and locate a source of vibrations' - ), - ), - ( - 'AXES_MAP_CALIBRATION', - self.cmd_AXES_MAP_CALIBRATION, - ( - 'Perform a set of movements to measure the orientation of the accelerometer ' - 'and help you set the best axes_map configuration for your printer' - ), - ), - ( - 'COMPARE_BELTS_RESPONSES', - self.cmd_COMPARE_BELTS_RESPONSES, - ( - 'Perform a custom half-axis test to analyze and compare the ' - 'frequency profiles of individual belts on CoreXY or CoreXZ printers' - ), - ), - ( - 'AXES_SHAPER_CALIBRATION', - self.cmd_AXES_SHAPER_CALIBRATION, - 'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter', - ), - ( - 'CREATE_VIBRATIONS_PROFILE', - self.cmd_CREATE_VIBRATIONS_PROFILE, - ( - 'Run a series of motions to find speed/angle ranges where the printer could be ' - 'exposed to VFAs to optimize your slicer speed profiles and TMC driver parameters' - ), - ), + ('EXCITATE_AXIS_AT_FREQ', self.cmd_EXCITATE_AXIS_AT_FREQ, ST_COMMANDS['EXCITATE_AXIS_AT_FREQ']), + ('AXES_MAP_CALIBRATION', self.cmd_AXES_MAP_CALIBRATION, ST_COMMANDS['AXES_MAP_CALIBRATION']), + ('COMPARE_BELTS_RESPONSES', self.cmd_COMPARE_BELTS_RESPONSES, ST_COMMANDS['COMPARE_BELTS_RESPONSES']), + ('AXES_SHAPER_CALIBRATION', self.cmd_AXES_SHAPER_CALIBRATION, ST_COMMANDS['AXES_SHAPER_CALIBRATION']), + ('CREATE_VIBRATIONS_PROFILE', self.cmd_CREATE_VIBRATIONS_PROFILE, ST_COMMANDS['CREATE_VIBRATIONS_PROFILE']), ] - command_descriptions = {name: desc for name, _, desc in measurement_commands} + + # Register Shake&Tune's measurement commands using the official Klipper API (gcode.register_command) + # Doing this makes the commands available in Klipper but they are not shown in the web interfaces + # and are only available by typing the full name in the console (like all the other Klipper commands) for name, command, description in measurement_commands: - gcode.register_command(f'_{name}' if show_macros else name, command, desc=description) + gcode.register_command(f'_{name}' if self._show_macros else name, command, desc=description) - # Load the dummy macros with their description in order to show them in the web interfaces - if show_macros: - pconfig = self._printer.lookup_object('configfile') + # Then, a hack to inject the macros into Klipper's config system in order to show them in the web + # interfaces. This is not a good way to do it, but it's the only way to do it for now to get + # a good user experience while using Shake&Tune (it's indeed easier to just click a macro button) + if self._show_macros: + configfile = self._printer.lookup_object('configfile') dirname = os.path.dirname(os.path.realpath(__file__)) filename = os.path.join(dirname, 'dummy_macros.cfg') try: - dummy_macros_cfg = pconfig.read_config(filename) + dummy_macros_cfg = configfile.read_config(filename) except Exception as err: - raise config.error(f'Cannot load Shake&Tune dummy macro {filename}') from err + raise self._config.error(f'Cannot load Shake&Tune dummy macro {filename}') from err for gcode_macro in dummy_macros_cfg.get_prefix_sections('gcode_macro '): gcode_macro_name = gcode_macro.get_name() - # Replace the dummy description by the one here (to avoid code duplication and define it in only one place) + # Replace the dummy description by the one from ST_COMMANDS (to avoid code duplication and define it in only one place) command = gcode_macro_name.split(' ', 1)[1] - description = command_descriptions.get(command, 'Shake&Tune macro') + description = ST_COMMANDS.get(command, 'Shake&Tune macro') gcode_macro.fileconfig.set(gcode_macro_name, 'description', description) # Add the section to the Klipper configuration object with all its options - if not config.fileconfig.has_section(gcode_macro_name.lower()): - config.fileconfig.add_section(gcode_macro_name.lower()) + if not self._config.fileconfig.has_section(gcode_macro_name.lower()): + self._config.fileconfig.add_section(gcode_macro_name.lower()) for option in gcode_macro.fileconfig.options(gcode_macro_name): value = gcode_macro.fileconfig.get(gcode_macro_name, option) - config.fileconfig.set(gcode_macro_name.lower(), option, value) - + self._config.fileconfig.set(gcode_macro_name.lower(), option, value) # Small trick to ensure the new injected sections are considered valid by Klipper config system - config.access_tracking[(gcode_macro_name.lower(), option.lower())] = 1 + self._config.access_tracking[(gcode_macro_name.lower(), option.lower())] = 1 # Finally, load the section within the printer objects - self._printer.load_object(config, gcode_macro_name.lower()) + self._printer.load_object(self._config, gcode_macro_name.lower()) + + # Register the motor resonance filters if they are defined in the config + # DangerKlipper is required for now or a degraded system forcing the ZV filter for + # both input shaping and motor resonance filter will be used instead. But this will + # be improved in the future if https://github.com/Klipper3d/klipper/pull/6460 get merged + # TODO: To mitigate this issue, add a automated patch to klippy/chelper/kin_shaper.c + # (using a .diff file) to enable the motor filters in stock Klipper as well. + # But this will make the Klipper repo dirty to moonraker update manager, so I'm not + # sure how to handle this. Maybe with also a command to revert the patch? Or a + # manual command to apply the patch with a required user action? + def _initialize_motor_resonance_filter(self) -> None: + if self._motor_freq_x is not None and self._motor_freq_y is not None: + input_shaper = self._printer.lookup_object('input_shaper', None) + if input_shaper is None: + raise self._config.error( + ( + 'Error: motor resonance filters cannot be enabled because the standard ' + '[input_shaper] Klipper config section is not configured!' + ) + ) + + gcode = self._printer.lookup_object('gcode') + gcode.register_command( + 'MOTOR_RESONANCE_FILTER', self.cmd_MOTOR_RESONANCE_FILTER, desc='Enable/disable motor resonance filters' + ) + + self.motor_resonance_filter = MotorResonanceFilter( + self._printer, + self._motor_freq_x, + self._motor_freq_y, + self._motor_damping_x, + self._motor_damping_y, + IN_DANGER, + ) + + self._printer.register_event_handler('klippy:ready', self.handle_ready) + + def handle_ready(self) -> None: + self.motor_resonance_filter.apply_filters() def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') - static_freq_graph_creator = StaticGraphCreator(self._config) + static_freq_graph_creator = StaticGraphCreator(self._st_config) st_process = ShakeTuneProcess( - self._config, + self._st_config, self._printer.get_reactor(), static_freq_graph_creator, self.timeout, ) - excitate_axis_at_freq(gcmd, self._pconfig, st_process) + excitate_axis_at_freq(gcmd, self._config, st_process) def cmd_AXES_MAP_CALIBRATION(self, gcmd) -> None: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') - axes_map_graph_creator = AxesMapGraphCreator(self._config) + axes_map_graph_creator = AxesMapGraphCreator(self._st_config) st_process = ShakeTuneProcess( - self._config, + self._st_config, self._printer.get_reactor(), axes_map_graph_creator, self.timeout, ) - axes_map_calibration(gcmd, self._pconfig, st_process) + axes_map_calibration(gcmd, self._config, st_process) def cmd_COMPARE_BELTS_RESPONSES(self, gcmd) -> None: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') - belt_graph_creator = BeltsGraphCreator(self._config) + belt_graph_creator = BeltsGraphCreator(self._st_config) st_process = ShakeTuneProcess( - self._config, + self._st_config, self._printer.get_reactor(), belt_graph_creator, self.timeout, ) - compare_belts_responses(gcmd, self._pconfig, st_process) + compare_belts_responses(gcmd, self._config, st_process) def cmd_AXES_SHAPER_CALIBRATION(self, gcmd) -> None: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') - shaper_graph_creator = ShaperGraphCreator(self._config) + shaper_graph_creator = ShaperGraphCreator(self._st_config) st_process = ShakeTuneProcess( - self._config, + self._st_config, self._printer.get_reactor(), shaper_graph_creator, self.timeout, ) - axes_shaper_calibration(gcmd, self._pconfig, st_process) + axes_shaper_calibration(gcmd, self._config, st_process) def cmd_CREATE_VIBRATIONS_PROFILE(self, gcmd) -> None: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') - vibration_profile_creator = VibrationsGraphCreator(self._config) + vibration_profile_creator = VibrationsGraphCreator(self._st_config) st_process = ShakeTuneProcess( - self._config, + self._st_config, self._printer.get_reactor(), vibration_profile_creator, self.timeout, ) - create_vibrations_profile(gcmd, self._pconfig, st_process) + create_vibrations_profile(gcmd, self._config, st_process) + + def cmd_MOTOR_RESONANCE_FILTER(self, gcmd) -> None: + enable = gcmd.get_int('ENABLE', default=1, minval=0, maxval=1) + if enable: + self.motor_resonance_filter.apply_filters() + else: + self.motor_resonance_filter.remove_filters() + ConsoleOutput.print(f'Motor resonance filter {"enabled" if enable else "disabled"}.') From e3e24184be9803a85dd8e7f0cc922fa0ae434a6a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Mon, 24 Jun 2024 18:47:25 +0200 Subject: [PATCH 2/5] small code cleaning and fixes --- README.md | 12 ++--- shaketune/motor_res_filter.py | 14 +++++- shaketune/shaketune.py | 83 ++++++++++++++++++----------------- 3 files changed, 61 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index 1a73bf6..df367c7 100644 --- a/README.md +++ b/README.md @@ -33,22 +33,22 @@ Follow these steps to install Shake&Tune on your printer: # The maximum time in seconds to let Shake&Tune process the CSV files and generate the graphs. # motor_freq: - # /!\ This option is only available in DangerKlipper /!\ + # /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\ # Frequencies of X and Y motor resonances to filter them using - # composite shapers. This require the `[input_shaper]` config + # composite shapers. This requires the `[input_shaper]` config # section to be defined in your printer.cfg file to work. # motor_freq_x: # motor_freq_y: - # /!\ This option is only available in DangerKlipper /!\ + # /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\ # If motor_freq is not set, these two parameters can be used # to configure different filters for X and Y motors. The same # values are supported as for motor_freq parameter. # motor_damping_ratio: 0.05 - # /!\ This option is only available in DangerKlipper /!\ - # Damping ratios of X and Y motor resonances. + # /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\ + # Damping ratios for X and Y motor resonances. # motor_damping_ratio_x: # motor_damping_ratio_y: - # /!\ This option is only available in DangerKlipper /!\ + # /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\ # If motor_damping_ratio is not set, these two parameters can be used # to configure different filters for X and Y motors. The same values # are supported as for motor_damping_ratio parameter. diff --git a/shaketune/motor_res_filter.py b/shaketune/motor_res_filter.py index 5bf6240..a1d4b81 100644 --- a/shaketune/motor_res_filter.py +++ b/shaketune/motor_res_filter.py @@ -26,7 +26,7 @@ def __init__(self, printer, freq_x: float, freq_y: float, damping_x: float, damp self._original_shapers = {} - # Convolve two Klipper shapers into a new composite shaper + # Convolve two Klipper shapers into a new custom composite input shaping filter @staticmethod def convolve_shapers(L, R): As = [a * b for a in L[0] for b in R[0]] @@ -36,6 +36,11 @@ def convolve_shapers(L, R): def apply_filters(self) -> None: input_shaper = self._printer.lookup_object('input_shaper', None) + if input_shaper is None: + raise ValueError( + 'Unable to apply Shake&Tune motor resonance filters: no [input_shaper] config section found!' + ) + shapers = input_shaper.get_shapers() for shaper in shapers: axis = shaper.axis @@ -71,7 +76,7 @@ def apply_filters(self) -> None: ConsoleOutput.print( ( f'Error: the {axis} axis is not a ZV type shaper. Shake&Tune motor resonance filters ' - 'will be ignored for this axis... Thi is due to the size of the pulse train being too ' + 'will be ignored for this axis... This is due to the size of the pulse train being too ' 'small and not allowing the convolved shapers to be applied... unless this PR is ' 'merged: https://github.com/Klipper3d/klipper/pull/6460' ) @@ -109,6 +114,11 @@ def apply_filters(self) -> None: def remove_filters(self) -> None: input_shaper = self._printer.lookup_object('input_shaper', None) + if input_shaper is None: + raise ValueError( + 'Unable to deactivate Shake&Tune motor resonance filters: no [input_shaper] config section found!' + ) + shapers = input_shaper.get_shapers() for shaper in shapers: axis = shaper.axis diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py index 6965a66..f9f1fd4 100644 --- a/shaketune/shaketune.py +++ b/shaketune/shaketune.py @@ -31,7 +31,6 @@ from .shaketune_config import ShakeTuneConfig from .shaketune_process import ShakeTuneProcess -IN_DANGER = False DEFAULT_MOTOR_DAMPING_RATIO = 0.05 ST_COMMANDS = { 'EXCITATE_AXIS_AT_FREQ': ( @@ -58,33 +57,18 @@ class ShakeTune: def __init__(self, config) -> None: self._config = config self._printer = config.get_printer() + self._printer.register_event_handler('klippy:connect', self._on_klippy_connect) - self._initialize_danger_klipper() - self._initialize_console_output() - self._validate_resonance_tester() - self._initialize_config(config) - self._register_commands() - self._initialize_motor_resonance_filter() - - # Check if Shake&Tune is running in DangerKlipper - def _initialize_danger_klipper(self) -> None: - global IN_DANGER - if importlib.util.find_spec('extras.danger_options') is not None: - IN_DANGER = True + # Check if Shake&Tune is running in DangerKlipper + self.IN_DANGER = importlib.util.find_spec('extras.danger_options') is not None - # Register the console print output callback to the corresponding Klipper function - def _initialize_console_output(self) -> None: + # Register the console print output callback to the corresponding Klipper function gcode = self._printer.lookup_object('gcode') ConsoleOutput.register_output_callback(gcode.respond_info) - # Check if the resonance_tester object is available in the printer - # configuration as it is required for Shake&Tune to work properly - def _validate_resonance_tester(self) -> None: - res_tester = self._printer.lookup_object('resonance_tester', None) - if res_tester is None: - raise self._config.error( - 'No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune.' - ) + self._initialize_config(config) + self._register_commands() + self._initialize_motor_resonance_filter() # Initialize the ShakeTune object and its configuration def _initialize_config(self, config) -> None: @@ -155,44 +139,63 @@ def _register_commands(self) -> None: self._printer.load_object(self._config, gcode_macro_name.lower()) # Register the motor resonance filters if they are defined in the config - # DangerKlipper is required for now or a degraded system forcing the ZV filter for - # both input shaping and motor resonance filter will be used instead. But this will + # DangerKlipper is required for the full feature but a degraded system forcing the ZV filter for + # both input shaping and motor resonance filter will be used instead in stock Klipper. But this might # be improved in the future if https://github.com/Klipper3d/klipper/pull/6460 get merged - # TODO: To mitigate this issue, add a automated patch to klippy/chelper/kin_shaper.c + # TODO: To mitigate this issue, add an automated patch to klippy/chelper/kin_shaper.c # (using a .diff file) to enable the motor filters in stock Klipper as well. # But this will make the Klipper repo dirty to moonraker update manager, so I'm not # sure how to handle this. Maybe with also a command to revert the patch? Or a # manual command to apply the patch with a required user action? def _initialize_motor_resonance_filter(self) -> None: if self._motor_freq_x is not None and self._motor_freq_y is not None: - input_shaper = self._printer.lookup_object('input_shaper', None) - if input_shaper is None: - raise self._config.error( - ( - 'Error: motor resonance filters cannot be enabled because the standard ' - '[input_shaper] Klipper config section is not configured!' - ) - ) - + self._printer.register_event_handler('klippy:ready', self._on_klippy_ready) gcode = self._printer.lookup_object('gcode') gcode.register_command( - 'MOTOR_RESONANCE_FILTER', self.cmd_MOTOR_RESONANCE_FILTER, desc='Enable/disable motor resonance filters' + 'MOTOR_RESONANCE_FILTER', + self.cmd_MOTOR_RESONANCE_FILTER, + desc='Enable/disable the motor resonance filters', ) - self.motor_resonance_filter = MotorResonanceFilter( self._printer, self._motor_freq_x, self._motor_freq_y, self._motor_damping_x, self._motor_damping_y, - IN_DANGER, + self.IN_DANGER, + ) + + def _on_klippy_connect(self) -> None: + # Check if the resonance_tester object is available in the printer + # configuration as it is required for Shake&Tune to work properly + res_tester = self._printer.lookup_object('resonance_tester', None) + if res_tester is None: + raise self._config.error( + 'No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune!' ) - self._printer.register_event_handler('klippy:ready', self.handle_ready) + # In case the user has configured a motor resonance filter, we need to make sure + # that the input shaper is configured as well in order to use them. This is because + # the input shaper object is the one used to actually applies the additional filters + if self._motor_freq_x is not None and self._motor_freq_y is not None: + input_shaper = self._printer.lookup_object('input_shaper', None) + if input_shaper is None: + raise self._config.error( + ( + 'No [input_shaper] config section found in printer.cfg! Please add one to use Shake&Tune ' + 'motor resonance filters!' + ) + ) - def handle_ready(self) -> None: + def _on_klippy_ready(self) -> None: self.motor_resonance_filter.apply_filters() + # ------------------------------------------------------------------------------------------ + # ------------------------------------------------------------------------------------------ + # Following are all the Shake&Tune commands that are registered to the Klipper console + # ------------------------------------------------------------------------------------------ + # ------------------------------------------------------------------------------------------ + def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') static_freq_graph_creator = StaticGraphCreator(self._st_config) From c19af1c457f74f68fbc6aa30ce9a5573ea1468fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sat, 29 Jun 2024 23:20:00 +0200 Subject: [PATCH 3/5] adapted motor profile to be independant --- .../vibrations_graph_creator.py | 153 +++++++----------- shaketune/motor_res_filter.py | 42 +++-- 2 files changed, 87 insertions(+), 108 deletions(-) diff --git a/shaketune/graph_creators/vibrations_graph_creator.py b/shaketune/graph_creators/vibrations_graph_creator.py index 3fc0034..c501586 100644 --- a/shaketune/graph_creators/vibrations_graph_creator.py +++ b/shaketune/graph_creators/vibrations_graph_creator.py @@ -114,58 +114,61 @@ def calc_freq_response(data) -> Tuple[np.ndarray, np.ndarray]: return helper.process_accelerometer_data(data) -# Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements for the machine kinematics -# main angles and then create a global motor profile as a weighted average (from their own vibrations) of all calculated profiles -def compute_motor_profiles( - freqs: np.ndarray, - psds: dict, - all_angles_energy: dict, - measured_angles: Optional[List[int]] = None, - energy_amplification_factor: int = 2, -) -> Tuple[dict, np.ndarray]: - if measured_angles is None: - measured_angles = [0, 90] +def find_motor_characteristics(motor: str, freqs: np.ndarray, psd: np.ndarray) -> Tuple[float, float, int]: + motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(psd, freqs, 30) + if lowfreq_max: + ConsoleOutput.print( + ( + f'[WARNING] {motor} motor has a lot of low frequency vibrations. This is ' + 'probably due to the test being performed at too high an acceleration!\n' + 'Try lowering ACCEL and/or increasing SIZE before restarting the macro ' + 'to ensure that only constant speeds are being recorded and that the ' + 'dynamic behavior of the machine is not affecting the measurements.' + ) + ) + if motor_zeta is not None: + ConsoleOutput.print( + ( + f'Motor {motor} have a main resonant frequency at {motor_fr:.1f}Hz ' + f'with an estimated damping ratio of {motor_zeta:.3f}' + ) + ) + else: + ConsoleOutput.print( + ( + f'Motor {motor} have a main resonant frequency at {motor_fr:.1f}Hz ' + 'but it was impossible to estimate its damping ratio.' + ) + ) + + return motor_fr, motor_zeta, motor_res_idx + + +# Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements +# for the machine kinematics main angles +def compute_motor_profiles(freqs: np.ndarray, psds: dict, measured_angles: Optional[List[int]] = (0, 90)) -> dict: motor_profiles = {} - weighted_sum_profiles = np.zeros_like(freqs) - total_weight = 0 conv_filter = np.ones(20) / 20 - # Creating the PSD motor profiles for each angles + # Creating the PSD motor profiles for each angles by summing the PSDs for each speeds for angle in measured_angles: - # Calculate the sum of PSDs for the current angle and then convolve sum_curve = np.sum(np.array([psds[angle][speed] for speed in psds[angle]]), axis=0) motor_profiles[angle] = np.convolve(sum_curve / len(psds[angle]), conv_filter, mode='same') - # Calculate weights - angle_energy = ( - all_angles_energy[angle] ** energy_amplification_factor - ) # First weighting factor is based on the total vibrations of the machine at the specified angle - curve_area = ( - np.trapz(motor_profiles[angle], freqs) ** energy_amplification_factor - ) # Additional weighting factor is based on the area under the current motor profile at this specified angle - total_angle_weight = angle_energy * curve_area - - # Update weighted sum profiles to get the global motor profile - weighted_sum_profiles += motor_profiles[angle] * total_angle_weight - total_weight += total_angle_weight - - # Creating a global average motor profile that is the weighted average of all the PSD motor profiles - global_motor_profile = weighted_sum_profiles / total_weight if total_weight != 0 else weighted_sum_profiles - - return motor_profiles, global_motor_profile + return motor_profiles # Since it was discovered that there is no non-linear mixing in the stepper "steps" vibrations, instead of measuring # the effects of each speeds at each angles, this function simplify it by using only the main motors axes (X/Y for Cartesian -# printers and A/B for CoreXY) measurements and project each points on the [0,360] degrees range using trigonometry +# printers and A/B for CoreXY) measurements and project each points on the [0, 360] degrees range using trigonometry # to "sum" the vibration impact of each axis at every points of the generated spectrogram. The result is very similar at the end. def compute_dir_speed_spectrogram( - measured_speeds: List[float], data: dict, kinematics: str = 'cartesian', measured_angles: Optional[List[int]] = None + measured_speeds: List[float], + data: dict, + kinematics: str = 'cartesian', + measured_angles: Optional[List[int]] = (0, 90), ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: - if measured_angles is None: - measured_angles = [0, 90] - # We want to project the motor vibrations measured on their own axes on the [0, 360] range spectrum_angles = np.linspace(0, 360, 720) # One point every 0.5 degrees spectrum_speeds = np.linspace(min(measured_speeds), max(measured_speeds), len(measured_speeds) * 6) @@ -293,11 +296,8 @@ def filter_and_split_ranges( # This function allow the computation of a symmetry score that reflect the spectrogram apparent symmetry between # measured axes on both the shape of the signal and the energy level consistency across both side of the signal def compute_symmetry_analysis( - all_angles: np.ndarray, spectrogram_data: np.ndarray, measured_angles: Optional[List[int]] = None + all_angles: np.ndarray, spectrogram_data: np.ndarray, measured_angles: Optional[List[int]] = (0, 90) ) -> float: - if measured_angles is None: - measured_angles = [0, 90] - total_spectrogram_angles = len(all_angles) half_spectrogram_angles = total_spectrogram_angles // 2 @@ -501,75 +501,40 @@ def plot_angular_speed_profiles( def plot_motor_profiles( - ax: plt.Axes, - freqs: np.ndarray, - main_angles: List[int], - motor_profiles: dict, - global_motor_profile: np.ndarray, - max_freq: float, + ax: plt.Axes, freqs: np.ndarray, main_angles: List[int], motor_profiles: dict, max_freq: float ) -> None: - ax.set_title('Motor frequency profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') + ax.set_title('Motors frequency profiles', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_ylabel('Energy') ax.set_xlabel('Frequency (Hz)') ax2 = ax.twinx() ax2.yaxis.set_visible(False) - # Global weighted average motor profile - ax.plot(freqs, global_motor_profile, label='Combined', color=KLIPPAIN_COLORS['purple'], zorder=5) - max_value = global_motor_profile.max() - # Mapping of angles to axis names angle_settings = {0: 'X', 90: 'Y', 45: 'A', 135: 'B'} - # And then plot the motor profiles at each measured angles + # And then plot the motor profiles at each measured angles with their characteristics + max_value = 0 for angle in main_angles: profile_max = motor_profiles[angle].max() if profile_max > max_value: max_value = profile_max label = f'{angle_settings[angle]} ({angle} deg)' if angle in angle_settings else f'{angle} deg' - ax.plot(freqs, motor_profiles[angle], linestyle='--', label=label, zorder=2) + ax.plot(freqs, motor_profiles[angle], label=label, zorder=2) + + motor_fr, motor_zeta, motor_res_idx = find_motor_characteristics( + angle_settings[angle], freqs, motor_profiles[angle] + ) + ax2.plot([], [], ' ', label=f'{angle_settings[angle]} resonant frequency (ω0): {motor_fr:.1f}Hz') + if motor_zeta is not None: + ax2.plot([], [], ' ', label=f'{angle_settings[angle]} damping ratio (ζ): {motor_zeta:.3f}') + else: + ax2.plot([], [], ' ', label=f'{angle_settings[angle]} damping ratio (ζ): unknown') ax.set_xlim([0, max_freq]) ax.set_ylim([0, max_value * 1.1]) ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0)) - # Then add the motor resonance peak to the graph and print some infos about it - motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(global_motor_profile, freqs, 30) - if lowfreq_max: - ConsoleOutput.print( - '[WARNING] There are a lot of low frequency vibrations that can alter the readings. This is probably due to the test being performed at too high an acceleration!' - ) - ConsoleOutput.print( - 'Try lowering the ACCEL value and/or increasing the SIZE value before restarting the macro to ensure that only constant speeds are being recorded and that the dynamic behavior of the machine is not affecting the measurements' - ) - if motor_zeta is not None: - ConsoleOutput.print( - f'Motors have a main resonant frequency at {motor_fr:.1f}Hz with an estimated damping ratio of {motor_zeta:.3f}' - ) - else: - ConsoleOutput.print( - f'Motors have a main resonant frequency at {motor_fr:.1f}Hz but it was impossible to estimate a damping ratio.' - ) - - ax.plot(freqs[motor_res_idx], global_motor_profile[motor_res_idx], 'x', color='black', markersize=10) - ax.annotate( - 'R', - (freqs[motor_res_idx], global_motor_profile[motor_res_idx]), - textcoords='offset points', - xytext=(15, 5), - ha='right', - fontsize=14, - color=KLIPPAIN_COLORS['red_pink'], - weight='bold', - ) - - ax2.plot([], [], ' ', label=f'Motor resonant frequency (ω0): {motor_fr:.1f}Hz') - if motor_zeta is not None: - ax2.plot([], [], ' ', label=f'Motor damping ratio (ζ): {motor_zeta:.3f}') - else: - ax2.plot([], [], ' ', label='No damping ratio computed') - ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.grid(which='major', color='grey') @@ -732,9 +697,9 @@ def vibrations_profile( shaper_calibrate = setup_klipper_import(klipperdir) if kinematics == 'cartesian' or kinematics == 'corexz': - main_angles = [0, 90] + main_angles = (0, 90) elif kinematics == 'corexy': - main_angles = [45, 135] + main_angles = (45, 135) else: raise ValueError('Only Cartesian, CoreXY and CoreXZ kinematics are supported by this tool at the moment!') @@ -775,7 +740,7 @@ def vibrations_profile( ) all_angles_energy = compute_angle_powers(spectrogram_data) sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric = compute_speed_powers(spectrogram_data) - motor_profiles, global_motor_profile = compute_motor_profiles(target_freqs, psds, all_angles_energy, main_angles) + motor_profiles = compute_motor_profiles(target_freqs, psds, main_angles) # symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy) symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles) @@ -884,7 +849,7 @@ def vibrations_profile( plot_angular_speed_profiles(ax3, all_speeds, all_angles, spectrogram_data, kinematics) plot_vibration_spectrogram(ax5, all_angles, all_speeds, spectrogram_data, vibration_peaks) - plot_motor_profiles(ax6, target_freqs, main_angles, motor_profiles, global_motor_profile, max_freq) + plot_motor_profiles(ax6, target_freqs, main_angles, motor_profiles, max_freq) # Adding a small Klippain logo to the top left corner of the figure ax_logo = fig.add_axes([0.001, 0.924, 0.075, 0.075], anchor='NW') diff --git a/shaketune/motor_res_filter.py b/shaketune/motor_res_filter.py index a1d4b81..01f283e 100644 --- a/shaketune/motor_res_filter.py +++ b/shaketune/motor_res_filter.py @@ -8,12 +8,10 @@ # into the input shaper initial Klipper object. This is done by convolving a motor resonance targeted # input shaper filter with the current configured axis input shapers. -from importlib import import_module +import math from .helpers.console_output import ConsoleOutput -shaper_defs = import_module('.shaper_defs', 'extras') - class MotorResonanceFilter: def __init__(self, printer, freq_x: float, freq_y: float, damping_x: float, damping_y: float, in_danger: bool): @@ -84,30 +82,46 @@ def apply_filters(self) -> None: continue # Get the current shaper parameters and store them for later restoration - _, A, T = shaper.get_shaper() - self._original_shapers[axis] = (A, T) + _, axis_shaper_A, axis_shaper_T = shaper.get_shaper() + self._original_shapers[axis] = (axis_shaper_A, axis_shaper_T) # Creating the new combined shapers that contains the motor resonance filters if axis in {'x', 'y'}: if self._in_danger: # In DangerKlipper, the pulse train is large enough to allow the # convolution of any shapers in order to craft the new combined shapers - new_A, new_T = MotorResonanceFilter.convolve_shapers( - (A, T), - shaper_defs.get_mzv_shaper(self.freq_x, self.damping_x), + # so we can use the MZV shaper (that looks to be the best for this purpose) + df = math.sqrt(1.0 - self.damping_x**2) + K = math.exp(-0.75 * self.damping_x * math.pi / df) + t_d = 1.0 / (self.freq_x * df) + a1 = 1.0 - 1.0 / math.sqrt(2.0) + a2 = (math.sqrt(2.0) - 1.0) * K + a3 = a1 * K * K + motor_filter_A = [a1, a2, a3] + motor_filter_T = [0.0, 0.375 * t_d, 0.75 * t_d] + + combined_filter_A, combined_filter_T = MotorResonanceFilter.convolve_shapers( + (axis_shaper_A, axis_shaper_T), + (motor_filter_A, motor_filter_T), ) else: # In stock Klipper, the pulse train is too small for most shapers # to be convolved. So we need to use the ZV shaper instead for the # motor resonance filters... even if it's not the best for this purpose - new_A, new_T = MotorResonanceFilter.convolve_shapers( - (A, T), - shaper_defs.get_zv_shaper(self.freq_x, self.damping_x), + df = math.sqrt(1.0 - self.damping_x**2) + K = math.exp(-self.damping_x * math.pi / df) + t_d = 1.0 / (self.freq_x * df) + motor_filter_A = [1.0, K] + motor_filter_T = [0.0, 0.5 * t_d] + + combined_filter_A, combined_filter_T = MotorResonanceFilter.convolve_shapers( + (axis_shaper_A, axis_shaper_T), + (motor_filter_A, motor_filter_T), ) - shaper.A = new_A - shaper.T = new_T - shaper.n = len(new_A) + shaper.A = combined_filter_A + shaper.T = combined_filter_T + shaper.n = len(combined_filter_A) # Update the running input shaper filter with the new parameters input_shaper._update_input_shaping() From 3d919898a684c51d47c0b811c037fc927fc97245 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sat, 29 Jun 2024 23:26:19 +0200 Subject: [PATCH 4/5] added a bit of distance for TMC parameters in vib header --- shaketune/graph_creators/vibrations_graph_creator.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shaketune/graph_creators/vibrations_graph_creator.py b/shaketune/graph_creators/vibrations_graph_creator.py index c501586..2765b06 100644 --- a/shaketune/graph_creators/vibrations_graph_creator.py +++ b/shaketune/graph_creators/vibrations_graph_creator.py @@ -614,7 +614,7 @@ def plot_vibration_spectrogram( def plot_motor_config_txt(fig: plt.Figure, motors: List[MotorsConfigParser], differences: Optional[str]) -> None: motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')] - distance = 0.12 + distance = 0.15 if motors[0].get_config('autotune_enabled'): distance = 0.27 config_blocks = [ From 8d59e3377513dd58ab3ddea0ffd83a0e78ed845e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sat, 29 Jun 2024 23:56:16 +0200 Subject: [PATCH 5/5] code cleanup --- README.md | 2 +- .../graph_creators/vibrations_graph_creator.py | 5 +++-- shaketune/motor_res_filter.py | 13 ++++--------- 3 files changed, 8 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index df367c7..fb85c22 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ Follow these steps to install Shake&Tune on your printer: # motor_freq: # /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\ - # Frequencies of X and Y motor resonances to filter them using + # Frequencies of X and Y motor resonances to filter them by using # composite shapers. This requires the `[input_shaper]` config # section to be defined in your printer.cfg file to work. # motor_freq_x: diff --git a/shaketune/graph_creators/vibrations_graph_creator.py b/shaketune/graph_creators/vibrations_graph_creator.py index 2765b06..599f7d8 100644 --- a/shaketune/graph_creators/vibrations_graph_creator.py +++ b/shaketune/graph_creators/vibrations_graph_creator.py @@ -39,6 +39,7 @@ from ..shaketune_config import ShakeTuneConfig from .graph_creator import GraphCreator +DEFAULT_LOW_FREQ_MAX = 30 PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04 CURVE_SIMILARITY_SIGMOID_K = 0.5 @@ -115,7 +116,7 @@ def calc_freq_response(data) -> Tuple[np.ndarray, np.ndarray]: def find_motor_characteristics(motor: str, freqs: np.ndarray, psd: np.ndarray) -> Tuple[float, float, int]: - motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(psd, freqs, 30) + motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(psd, freqs, DEFAULT_LOW_FREQ_MAX) if lowfreq_max: ConsoleOutput.print( @@ -151,7 +152,7 @@ def compute_motor_profiles(freqs: np.ndarray, psds: dict, measured_angles: Optio motor_profiles = {} conv_filter = np.ones(20) / 20 - # Creating the PSD motor profiles for each angles by summing the PSDs for each speeds + # Creating the PSD motor profiles for each angle by summing the PSDs for each speed for angle in measured_angles: sum_curve = np.sum(np.array([psds[angle][speed] for speed in psds[angle]]), axis=0) motor_profiles[angle] = np.convolve(sum_curve / len(psds[angle]), conv_filter, mode='same') diff --git a/shaketune/motor_res_filter.py b/shaketune/motor_res_filter.py index 01f283e..7b804fb 100644 --- a/shaketune/motor_res_filter.py +++ b/shaketune/motor_res_filter.py @@ -99,11 +99,6 @@ def apply_filters(self) -> None: a3 = a1 * K * K motor_filter_A = [a1, a2, a3] motor_filter_T = [0.0, 0.375 * t_d, 0.75 * t_d] - - combined_filter_A, combined_filter_T = MotorResonanceFilter.convolve_shapers( - (axis_shaper_A, axis_shaper_T), - (motor_filter_A, motor_filter_T), - ) else: # In stock Klipper, the pulse train is too small for most shapers # to be convolved. So we need to use the ZV shaper instead for the @@ -114,10 +109,10 @@ def apply_filters(self) -> None: motor_filter_A = [1.0, K] motor_filter_T = [0.0, 0.5 * t_d] - combined_filter_A, combined_filter_T = MotorResonanceFilter.convolve_shapers( - (axis_shaper_A, axis_shaper_T), - (motor_filter_A, motor_filter_T), - ) + combined_filter_A, combined_filter_T = MotorResonanceFilter.convolve_shapers( + (axis_shaper_A, axis_shaper_T), + (motor_filter_A, motor_filter_T), + ) shaper.A = combined_filter_A shaper.T = combined_filter_T