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"}.')