motor resonances filters added

This commit is contained in:
Félix Boisselier
2024-06-23 00:25:15 +02:00
parent 37d0e39d84
commit a49a571911
3 changed files with 293 additions and 83 deletions

View File

@@ -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. # printer.cfg file. If you want to see the macros in the webui, set this to True.
# timeout: 300 # timeout: 300
# The maximum time in seconds to let Shake&Tune process the CSV files and generate the graphs. # 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)**. Don't forget to check out **[Shake&Tune documentation here](./docs/README.md)**.

View File

@@ -0,0 +1,123 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (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()

View File

@@ -8,6 +8,7 @@
# loading of the plugin, and the registration of the tuning commands # loading of the plugin, and the registration of the tuning commands
import importlib
import os import os
from pathlib import Path from pathlib import Path
@@ -26,166 +27,231 @@ from .graph_creators import (
VibrationsGraphCreator, VibrationsGraphCreator,
) )
from .helpers.console_output import ConsoleOutput from .helpers.console_output import ConsoleOutput
from .motor_res_filter import MotorResonanceFilter
from .shaketune_config import ShakeTuneConfig from .shaketune_config import ShakeTuneConfig
from .shaketune_process import ShakeTuneProcess from .shaketune_process import ShakeTuneProcess
IN_DANGER = False 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: class ShakeTune:
def __init__(self, config) -> None: def __init__(self, config) -> None:
try: self._config = config
from extras.danger_options import get_danger_options
IN_DANGER = True # check if Shake&Tune is running in DangerKlipper
except ImportError:
continue
self._pconfig = config
self._printer = config.get_printer() self._printer = config.get_printer()
gcode = self._printer.lookup_object('gcode')
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
# 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) res_tester = self._printer.lookup_object('resonance_tester', None)
if res_tester is 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 = config.get('result_folder', default='~/printer_data/config/ShakeTune_results')
result_folder_path = Path(result_folder).expanduser() if result_folder else None 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_n_results = config.getint('number_of_results_to_keep', default=3, minval=0)
keep_csv = config.getboolean('keep_raw_csv', default=False) 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) 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) self.timeout = config.getfloat('timeout', 300, above=0.0)
ConsoleOutput.register_output_callback(gcode.respond_info) 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 = [ measurement_commands = [
( ('EXCITATE_AXIS_AT_FREQ', self.cmd_EXCITATE_AXIS_AT_FREQ, ST_COMMANDS['EXCITATE_AXIS_AT_FREQ']),
'EXCITATE_AXIS_AT_FREQ', ('AXES_MAP_CALIBRATION', self.cmd_AXES_MAP_CALIBRATION, ST_COMMANDS['AXES_MAP_CALIBRATION']),
self.cmd_EXCITATE_AXIS_AT_FREQ, ('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']),
'Maintain a specified excitation frequency for a period ' ('CREATE_VIBRATIONS_PROFILE', self.cmd_CREATE_VIBRATIONS_PROFILE, ST_COMMANDS['CREATE_VIBRATIONS_PROFILE']),
'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'
),
),
] ]
command_descriptions = {name: desc for name, _, desc in measurement_commands}
for name, command, description in measurement_commands:
gcode.register_command(f'_{name}' if show_macros else name, command, desc=description)
# Load the dummy macros with their description in order to show them in the web interfaces # Register Shake&Tune's measurement commands using the official Klipper API (gcode.register_command)
if show_macros: # Doing this makes the commands available in Klipper but they are not shown in the web interfaces
pconfig = self._printer.lookup_object('configfile') # 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 self._show_macros else name, command, desc=description)
# 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__)) dirname = os.path.dirname(os.path.realpath(__file__))
filename = os.path.join(dirname, 'dummy_macros.cfg') filename = os.path.join(dirname, 'dummy_macros.cfg')
try: try:
dummy_macros_cfg = pconfig.read_config(filename) dummy_macros_cfg = configfile.read_config(filename)
except Exception as err: 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 '): for gcode_macro in dummy_macros_cfg.get_prefix_sections('gcode_macro '):
gcode_macro_name = gcode_macro.get_name() 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] 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) gcode_macro.fileconfig.set(gcode_macro_name, 'description', description)
# Add the section to the Klipper configuration object with all its options # Add the section to the Klipper configuration object with all its options
if not config.fileconfig.has_section(gcode_macro_name.lower()): if not self._config.fileconfig.has_section(gcode_macro_name.lower()):
config.fileconfig.add_section(gcode_macro_name.lower()) self._config.fileconfig.add_section(gcode_macro_name.lower())
for option in gcode_macro.fileconfig.options(gcode_macro_name): for option in gcode_macro.fileconfig.options(gcode_macro_name):
value = gcode_macro.fileconfig.get(gcode_macro_name, option) 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 # 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 # 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: def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') 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( st_process = ShakeTuneProcess(
self._config, self._st_config,
self._printer.get_reactor(), self._printer.get_reactor(),
static_freq_graph_creator, static_freq_graph_creator,
self.timeout, 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: def cmd_AXES_MAP_CALIBRATION(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') 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( st_process = ShakeTuneProcess(
self._config, self._st_config,
self._printer.get_reactor(), self._printer.get_reactor(),
axes_map_graph_creator, axes_map_graph_creator,
self.timeout, 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: def cmd_COMPARE_BELTS_RESPONSES(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') 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( st_process = ShakeTuneProcess(
self._config, self._st_config,
self._printer.get_reactor(), self._printer.get_reactor(),
belt_graph_creator, belt_graph_creator,
self.timeout, 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: def cmd_AXES_SHAPER_CALIBRATION(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') 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( st_process = ShakeTuneProcess(
self._config, self._st_config,
self._printer.get_reactor(), self._printer.get_reactor(),
shaper_graph_creator, shaper_graph_creator,
self.timeout, 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: def cmd_CREATE_VIBRATIONS_PROFILE(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') 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( st_process = ShakeTuneProcess(
self._config, self._st_config,
self._printer.get_reactor(), self._printer.get_reactor(),
vibration_profile_creator, vibration_profile_creator,
self.timeout, 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"}.')