diff --git a/README.md b/README.md index 1bb6546..e4156ac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Klipper Shake&Tune Module +# Klipper Shake&Tune plugin This "Shake&Tune" repository is a standalone module from the [Klippain](https://github.com/Frix-x/klippain) ecosystem, designed to automate and calibrate the input shaper system on your Klipper 3D printer with a streamlined workflow and insightful vizualisations. This can be installed on any Klipper machine. It is not limited to those using Klippain. diff --git a/docs/README.md b/docs/README.md index 714db9d..3e45ec6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# Klippain Shake&Tune module documentation +# Klipper Shake&Tune plugin documentation ![](./banner_long.png) @@ -89,7 +89,7 @@ Here are the parameters available when calling this macro: |SPEED|80|speed of the toolhead in mm/s for the movements| |ACCEL|1500 (or max printer accel)|accel in mm/s^2 used for all the moves| |TRAVEL_SPEED|120|speed in mm/s used for all the travels moves| -|ACCEL_CHIP|"adxl345"|accelerometer chip name in the config| +|ACCEL_CHIP|None|accelerometer to use for the test. If unset, it will automatically select the proper accelerometer based on what is configured in your `[resonance_tester]` config section| The machine will move slightly in +X, +Y, and +Z, and output in the console: `Detected axes_map: -z,y,x`. @@ -108,8 +108,11 @@ Here are the parameters available when calling this macro: | parameters | default value | description | |-----------:|---------------|-------------| |FREQUENCY|25|excitation frequency (in Hz) that you want to maintain. Usually, it's the frequency of a peak on one of the graphs| -|TIME|10|time in second to maintain this excitation| +|DURATION|10|duration in second to maintain this excitation| +|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| |AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"| +|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)| +|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section| ## Complementary ressources diff --git a/docs/macros/axis_tuning.md b/docs/macros/axis_tuning.md index a37168b..3a4c1e1 100644 --- a/docs/macros/axis_tuning.md +++ b/docs/macros/axis_tuning.md @@ -11,14 +11,15 @@ Then, call the `AXES_SHAPER_CALIBRATION` macro and look for the graphs in the re | parameters | default value | description | |-----------:|---------------|-------------| -|FREQ_START|5|Starting excitation frequency| -|FREQ_END|133|Maximum excitation frequency| -|HZ_PER_SEC|1|Number of Hz per seconds for the test| -|AXIS|"all"|Axis you want to test in the list of "all", "X" or "Y"| -|SCV|printer square corner velocity|Square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations| -|MAX_SMOOTHING|None|Max smoothing allowed when calculating shaper recommendations| -|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| -|KEEP_CSV|0|Weither or not to keep the CSV data file alonside the PNG graphs| +|FREQ_START|5|starting excitation frequency| +|FREQ_END|133|maximum excitation frequency| +|HZ_PER_SEC|1|number of Hz per seconds for the test| +|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| +|AXIS|"all"|axis you want to test in the list of "all", "X" or "Y"| +|SCV|printer square corner velocity|square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations| +|MAX_SMOOTHING|None|max smoothing allowed when calculating shaper recommendations| +|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)| +|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section| ## Graphs description diff --git a/docs/macros/belts_tuning.md b/docs/macros/belts_tuning.md index 0ce42c0..d1b3fd2 100644 --- a/docs/macros/belts_tuning.md +++ b/docs/macros/belts_tuning.md @@ -11,11 +11,12 @@ Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the re | parameters | default value | description | |-----------:|---------------|-------------| -|FREQ_START|5|Starting excitation frequency| -|FREQ_END|133|Maximum excitation frequency| -|HZ_PER_SEC|1|Number of Hz per seconds for the test| -|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| -|KEEP_CSV|0|Weither or not to keep the CSV data files alonside the PNG graphs| +|FREQ_START|5|starting excitation frequency| +|FREQ_END|133|maximum excitation frequency| +|HZ_PER_SEC|1|number of Hz per seconds for the test| +|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| +|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)| +|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section| ## Graphs description diff --git a/shaketune/graph_creators/graph_creator.py b/shaketune/graph_creators/graph_creator.py index 75fc3da..4902d37 100644 --- a/shaketune/graph_creators/graph_creator.py +++ b/shaketune/graph_creators/graph_creator.py @@ -57,7 +57,6 @@ class GraphCreator(abc.ABC): new_file = self._folder / f'{self._type}_{self._graph_date}_{custom_name}.csv' # shutil.move() is needed to move the file across filesystems (mainly for BTT CB1 Pi default OS image) shutil.move(filename, new_file) - fm.wait_file_ready(new_file) lognames.append(new_file) return lognames @@ -98,9 +97,9 @@ class BeltsGraphCreator(GraphCreator): def create_graph(self) -> None: lognames = self._move_and_prepare_files( - glob_pattern='raw_data_axis*.csv', + glob_pattern='shaketune-belt_*.csv', min_files_required=2, - custom_name_func=lambda f: f.stem.split('_')[3].upper(), + custom_name_func=lambda f: f.stem.split('_')[1].upper(), ) fig = belts_calibration( lognames=[str(path) for path in lognames], @@ -245,15 +244,13 @@ class AxesMapFinder(GraphCreator): def find_axesmap(self) -> None: tmp_folder = Path('/tmp') - globbed_files = list(tmp_folder.glob(f'{self._chip_name}-*.csv')) + globbed_files = list(tmp_folder.glob('shaketune-axemap_*.csv')) if not globbed_files: raise FileNotFoundError('no CSV files found in the /tmp folder to find the axes map!') - # Find the CSV files with the latest timestamp and wait for it to be released by Klipper + # Find the CSV files with the latest timestamp and process it logname = sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[0] - fm.wait_file_ready(logname) - results = axesmap_calibration( lognames=[str(logname)], accel=self._accel, @@ -271,6 +268,6 @@ class AxesMapFinder(GraphCreator): def clean_old_files(self, keep_results: int) -> None: tmp_folder = Path('/tmp') - globbed_files = list(tmp_folder.glob(f'{self._chip_name}-*.csv')) + globbed_files = list(tmp_folder.glob('shaketune-axemap_*.csv')) for csv_file in globbed_files: csv_file.unlink() diff --git a/shaketune/macros/__init__.py b/shaketune/macros/__init__.py index 5486211..e6338e4 100644 --- a/shaketune/macros/__init__.py +++ b/shaketune/macros/__init__.py @@ -5,6 +5,13 @@ from .axes_map import axes_map_calibration as axes_map_calibration from .belts_comparison import compare_belts_responses as compare_belts_responses from .static_freq import excitate_axis_at_freq as excitate_axis_at_freq +AXIS_CONFIG = [ + {'axis': 'x', 'direction': (1, 0, 0), 'label': 'axis_X'}, + {'axis': 'y', 'direction': (0, 1, 0), 'label': 'axis_Y'}, + {'axis': 'a', 'direction': (1, -1, 0), 'label': 'belt_A'}, + {'axis': 'b', 'direction': (1, 1, 0), 'label': 'belt_B'}, +] + # graph_creators = { # 'axesmap': (AxesMapFinder, lambda gc: gc.configure(options.accel_used, options.chip_name)), # 'belts': (BeltsGraphCreator, None), diff --git a/shaketune/macros/accelerometer.py b/shaketune/macros/accelerometer.py index 6b3b182..1a2e3c3 100644 --- a/shaketune/macros/accelerometer.py +++ b/shaketune/macros/accelerometer.py @@ -1,37 +1,57 @@ #!/usr/bin/env python3 +# This file provides a custom and internal Shake&Tune Accelerometer helper that is +# an interface to Klipper's own accelerometer classes. It is used to start and +# stop accelerometer measurements and write the data to a file in a blocking manner. + import time -from ..helpers.console_output import ConsoleOutput +# from ..helpers.console_output import ConsoleOutput class Accelerometer: def __init__(self, klipper_accelerometer): self._k_accelerometer = klipper_accelerometer + self._bg_client = None + + @staticmethod + def find_axis_accelerometer(printer, axis: str = 'xy'): + accel_chip_names = printer.lookup_object('resonance_tester').accel_chip_names + for chip_axis, chip_name in accel_chip_names: + if axis in ['x', 'y'] and chip_axis == 'xy': + return chip_name + elif chip_axis == axis: + return chip_name + return None def start_measurement(self): - if self._k_accelerometer.bg_client is None: - self._k_accelerometer.bg_client = self._k_accelerometer.chip.start_internal_client() - ConsoleOutput.print('accelerometer measurements started') + if self._bg_client is None: + self._bg_client = self._k_accelerometer.start_internal_client() + # ConsoleOutput.print('Accelerometer measurements started') else: raise ValueError('measurements already started!') - def stop_measurement(self, name: str = None): - if self._k_accelerometer.bg_client is not None: - name = name or time.strftime('%Y%m%d_%H%M%S') - if not name.replace('-', '').replace('_', '').isalnum(): - raise ValueError('invalid file name!') - - bg_client = self._k_accelerometer.bg_client - self._k_accelerometer.bg_client = None - bg_client.finish_measurements() - - filename = f'/tmp/shaketune-{name}.csv' - self._write_to_file(bg_client, filename) - ConsoleOutput.print(f'Measurements stopped. Data written to {filename}') - else: + def stop_measurement(self, name: str = None, append_time: bool = True): + if self._bg_client is None: raise ValueError('measurements need to be started first!') + timestamp = time.strftime('%Y%m%d_%H%M%S') + if name is None: + name = timestamp + elif append_time: + name += f'_{timestamp}' + + if not name.replace('-', '').replace('_', '').isalnum(): + raise ValueError('invalid file name!') + + bg_client = self._bg_client + self._bg_client = None + bg_client.finish_measurements() + + filename = f'/tmp/shaketune-{name}.csv' + self._write_to_file(bg_client, filename) + # ConsoleOutput.print(f'Accelerometer measurements stopped. Data written to {filename}') + def _write_to_file(self, bg_client, filename): with open(filename, 'w') as f: f.write('#time,accel_x,accel_y,accel_z\n') diff --git a/shaketune/macros/axes_input_shaper.py b/shaketune/macros/axes_input_shaper.py index 47b72d7..d25209d 100644 --- a/shaketune/macros/axes_input_shaper.py +++ b/shaketune/macros/axes_input_shaper.py @@ -3,33 +3,102 @@ from ..helpers.console_output import ConsoleOutput from ..shaketune_thread import ShakeTuneThread +from . import AXIS_CONFIG +from .accelerometer import Accelerometer +from .resonance_test import vibrate_axis def axes_shaper_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: min_freq = gcmd.get_float('FREQ_START', default=5, minval=1) max_freq = gcmd.get_float('FREQ_END', default=133.33, minval=1) hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1, minval=1) - axis = gcmd.get('AXIS', default='all') - if axis not in ['x', 'y', 'all']: + accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) + axis_input = gcmd.get('AXIS', default='all').lower() + if axis_input not in ['x', 'y', 'all']: gcmd.error('AXIS selection invalid. Should be either x, y, or all!') scv = gcmd.get_float('SCV', default=None, minval=0) max_sm = gcmd.get_float('MAX_SMOOTHING', default=None, minval=0) + feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) + z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) + + systime = printer.get_reactor().monotonic() + toolhead = printer.lookup_object('toolhead') + res_tester = printer.lookup_object('resonance_tester') if scv is None: - systime = printer.get_reactor().monotonic() - toolhead = printer.lookup_object('toolhead') toolhead_info = toolhead.get_status(systime) scv = toolhead_info['square_corner_velocity'] + if accel_per_hz is None: + accel_per_hz = res_tester.test.accel_per_hz + max_accel = max_freq * accel_per_hz + + # Move to the starting point + test_points = res_tester.test.get_start_test_points() + if len(test_points) > 1: + gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.') + if test_points[0] == (-1, -1, -1): + if z_height is None: + gcmd.error( + 'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1' + ) + # Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1 + # This is usefull to get something automatic and is also used in the Klippain modular config + kin_info = toolhead.kin.get_status(systime) + mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2 + mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2 + point = (mid_x, mid_y, z_height) + else: + x, y, z = test_points[0] + if z_height is not None: + z = z_height + point = (x, y, z) + + toolhead.manual_move(point, feedrate_travel) + + # Configure the graph creator creator = st_thread.get_graph_creator() creator.configure(scv, max_sm) - axis_flags = {'x': axis in ('x', 'all'), 'y': axis in ('y', 'all')} - for axis in ['x', 'y']: - if axis_flags[axis]: - gcode.run_script_from_command( - f'TEST_RESONANCES AXIS={axis.upper()} OUTPUT=raw_data NAME={axis} FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}' + # set the needed acceleration values for the test + toolhead_info = toolhead.get_status(systime) + old_accel = toolhead_info['max_accel'] + old_mcr = toolhead_info['minimum_cruise_ratio'] + gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=0') + + # Deactivate input shaper if it is active to get raw movements + input_shaper = printer.lookup_object('input_shaper', None) + if input_shaper is not None: + input_shaper.disable_shaping() + else: + input_shaper = None + + # Filter axis configurations based on user input, assuming 'axis_input' can be 'x', 'y', 'all' (that means 'x' and 'y') + filtered_config = [ + a for a in AXIS_CONFIG if a['axis'] == axis_input or (axis_input == 'all' and a['axis'] in ('x', 'y')) + ] + for config in filtered_config: + # First we need to find the accelerometer chip suited for the axis + accel_chip = Accelerometer.find_axis_accelerometer(printer, config['axis']) + if accel_chip is None: + gcmd.error( + 'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.' ) - ConsoleOutput.print(f'{axis.upper()} axis frequency profile generation...') - ConsoleOutput.print('This may take some time (1-3min)') - st_thread.run() + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) + + # Then do the actual measurements + accelerometer.start_measurement() + vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz) + accelerometer.stop_measurement(config['label'], append_time=True) + + # And finally generate the graph for each measured axis + ConsoleOutput.print(f'{config['axis'].upper()} axis frequency profile generation...') + ConsoleOutput.print('This may take some time (1-3min)') + st_thread.run() + + # Re-enable the input shaper if it was active + if input_shaper is not None: + input_shaper.enable_shaping() + + # Restore the previous acceleration values + gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr}') diff --git a/shaketune/macros/axes_map.py b/shaketune/macros/axes_map.py index 877b856..38d1e0e 100644 --- a/shaketune/macros/axes_map.py +++ b/shaketune/macros/axes_map.py @@ -6,16 +6,6 @@ from ..shaketune_thread import ShakeTuneThread from .accelerometer import Accelerometer -def find_axis_accelerometer(printer, axis: str = 'xy'): - accel_chip_names = printer.lookup_object('resonance_tester').accel_chip_names - for chip_axis, chip_name in accel_chip_names: - if axis in ['x', 'y'] and chip_axis == 'xy': - return chip_name - elif chip_axis == axis: - return chip_name - return None - - def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: z_height = gcmd.get_float('Z_HEIGHT', default=20.0) speed = gcmd.get_float('SPEED', default=80.0, minval=20.0) @@ -24,11 +14,12 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No accel_chip = gcmd.get('ACCEL_CHIP', default=None) if accel_chip is None: - accel_chip = find_axis_accelerometer(printer, 'xy') + accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy') if accel_chip is None: gcmd.error( 'No accelerometer specified for measurement! Multi-accelerometer configurations are not supported for this macro.' ) + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) systime = printer.get_reactor().monotonic() toolhead = printer.lookup_object('toolhead') @@ -57,10 +48,7 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No toolhead.dwell(0.5) # Start the measurements and do the movements (+X, +Y and then +Z) - accelerometer = Accelerometer(printer.lookup_object(accel_chip)) accelerometer.start_measurement() - # gcode.run_script_from_command(f'ACCELEROMETER_MEASURE CHIP={accel_chip}') - toolhead.dwell(1) toolhead.move([mid_x + 15, mid_y - 15, z_height, E], speed) toolhead.dwell(1) @@ -68,9 +56,7 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No toolhead.dwell(1) toolhead.move([mid_x + 15, mid_y + 15, z_height + 15, E], speed) toolhead.dwell(1) - accelerometer.stop_measurement('axemap') - # gcode.run_script_from_command(f'ACCELEROMETER_MEASURE CHIP={accel_chip} NAME=axemap') # Re-enable the input shaper if it was active if input_shaper is not None: diff --git a/shaketune/macros/belts_comparison.py b/shaketune/macros/belts_comparison.py index 7a4abf6..3e3a546 100644 --- a/shaketune/macros/belts_comparison.py +++ b/shaketune/macros/belts_comparison.py @@ -3,24 +3,83 @@ from ..helpers.console_output import ConsoleOutput from ..shaketune_thread import ShakeTuneThread +from . import AXIS_CONFIG +from .accelerometer import Accelerometer +from .resonance_test import vibrate_axis def compare_belts_responses(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: - min_freq = gcmd.get_float('FREQ_START', default=5, minval=1) + min_freq = gcmd.get_float('FREQ_START', default=5.0, minval=1) max_freq = gcmd.get_float('FREQ_END', default=133.33, minval=1) - hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1, minval=1) + hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1.0, minval=1) + accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) + feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) + z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) + systime = printer.get_reactor().monotonic() toolhead = printer.lookup_object('toolhead') + res_tester = printer.lookup_object('resonance_tester') - gcode.run_script_from_command( - f'TEST_RESONANCES AXIS=1,1 OUTPUT=raw_data NAME=b FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}' - ) - toolhead.wait_moves() + accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy') + if accel_chip is None: + gcmd.error( + 'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.' + ) + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) - gcode.run_script_from_command( - f'TEST_RESONANCES AXIS=1,-1 OUTPUT=raw_data NAME=a FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}' - ) - toolhead.wait_moves() + if accel_per_hz is None: + accel_per_hz = res_tester.test.accel_per_hz + max_accel = max_freq * accel_per_hz + + # Move to the starting point + test_points = res_tester.test.get_start_test_points() + if len(test_points) > 1: + gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.') + if test_points[0] == (-1, -1, -1): + if z_height is None: + gcmd.error( + 'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1' + ) + # Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1 + # This is usefull to get something automatic and is also used in the Klippain modular config + kin_info = toolhead.kin.get_status(systime) + mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2 + mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2 + point = (mid_x, mid_y, z_height) + else: + x, y, z = test_points[0] + if z_height is not None: + z = z_height + point = (x, y, z) + + toolhead.manual_move(point, feedrate_travel) + + # set the needed acceleration values for the test + toolhead_info = toolhead.get_status(systime) + old_accel = toolhead_info['max_accel'] + old_mcr = toolhead_info['minimum_cruise_ratio'] + gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=0') + + # Deactivate input shaper if it is active to get raw movements + input_shaper = printer.lookup_object('input_shaper', None) + if input_shaper is not None: + input_shaper.disable_shaping() + else: + input_shaper = None + + # Filter axis configurations to get the A and B axis only + filtered_config = [a for a in AXIS_CONFIG if a['axis'] in ('x', 'y')] + for config in filtered_config: + accelerometer.start_measurement() + vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz) + accelerometer.stop_measurement(config['label'], append_time=True) + + # Re-enable the input shaper if it was active + if input_shaper is not None: + input_shaper.enable_shaping() + + # Restore the previous acceleration values + gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr}') # Run post-processing ConsoleOutput.print('Belts comparative frequency profile generation...') diff --git a/shaketune/macros/resonance_test.py b/shaketune/macros/resonance_test.py new file mode 100644 index 0000000..6913626 --- /dev/null +++ b/shaketune/macros/resonance_test.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +# The logic in this file was "extracted" from Klipper's orignal resonance_tester.py file +# Courtesy of Dmitry Butyugin for the original implementation + +# This derive a bit from Klipper's implementation as there are two main changes: +# 1. Original code doesn't use euclidean distance for the moves calculation with projection. The new approach implemented here +# ensures that the vector's total length remains constant (= L), regardless of the direction components. It's especially +# important when the direction vector involves combinations of movements along multiple axes like for the diagonal belt tests. +# 2. Original code doesn't allow Z axis movement that was added here for later use + +import math + +from ..helpers.console_output import ConsoleOutput + + +# This function is used to vibrate the toolhead in a specific axis direction +# to test the resonance frequency of the printer and its components +def vibrate_axis(toolhead, gcode, axis_direction, min_freq, max_freq, hz_per_sec, accel_per_hz): + freq = min_freq + X, Y, Z, E = toolhead.get_position() # Get current position + sign = 1.0 + + while freq <= max_freq + 0.000001: + t_seg = 0.25 / freq # Time segment for one vibration cycle + accel = accel_per_hz * freq # Acceleration for each half-cycle + max_v = accel * t_seg # Max velocity for each half-cycle + toolhead.cmd_M204(gcode.create_gcode_command('M204', 'M204', {'S': accel})) + L = 0.5 * accel * t_seg**2 # Distance for each half-cycle + + # Calculate move points based on axis direction (X, Y and Z) + magnitude = math.sqrt(sum([component**2 for component in axis_direction])) + normalized_direction = tuple(component / magnitude for component in axis_direction) + dX, dY, dZ = normalized_direction[0] * L, normalized_direction[1] * L, normalized_direction[2] * L + nX = X + sign * dX + nY = Y + sign * dY + nZ = Z + sign * dZ + + # Execute movement + toolhead.move([nX, nY, nZ, E], max_v) + toolhead.move([X, Y, Z, E], max_v) + sign *= -1 + + # Increase frequency for next cycle + old_freq = freq + freq += 2 * t_seg * hz_per_sec + if int(freq) > int(old_freq): + ConsoleOutput.print(f'Testing frequency: {freq:.0f} Hz') + + toolhead.wait_moves() diff --git a/shaketune/macros/static_freq.py b/shaketune/macros/static_freq.py index 596a32c..b6bbf12 100644 --- a/shaketune/macros/static_freq.py +++ b/shaketune/macros/static_freq.py @@ -1,22 +1,55 @@ #!/usr/bin/env python3 from ..helpers.console_output import ConsoleOutput +from . import AXIS_CONFIG +from .resonance_test import vibrate_axis -def excitate_axis_at_freq(gcmd, gcode) -> None: +def excitate_axis_at_freq(gcmd, printer, gcode) -> None: freq = gcmd.get_int('FREQUENCY', default=25, minval=1) duration = gcmd.get_int('DURATION', default=10, minval=1) - axis = gcmd.get('AXIS', default='x') - if axis not in ['x', 'y', 'a', 'b']: + accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) + axis = gcmd.get('AXIS', default='x').lower() + feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) + z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) + + axis_config = next((item for item in AXIS_CONFIG if item['axis'] == axis), None) + if axis_config is None: gcmd.error('AXIS selection invalid. Should be either x, y, a or b!') ConsoleOutput.print(f'Excitating {axis.upper()} axis at {freq}Hz for {duration} seconds') - if axis == 'a': - axis = '1,-1' - elif axis == 'b': - axis = '1,1' + systime = printer.get_reactor().monotonic() + toolhead = printer.lookup_object('toolhead') + res_tester = printer.lookup_object('resonance_tester') - gcode.run_script_from_command( - f'TEST_RESONANCES OUTPUT=raw_data AXIS={axis} FREQ_START={freq-1} FREQ_END={freq+1} HZ_PER_SEC={1/(duration/3)}' - ) + if accel_per_hz is None: + accel_per_hz = res_tester.test.accel_per_hz + + # Move to the starting point + test_points = res_tester.test.get_start_test_points() + if len(test_points) > 1: + gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.') + if test_points[0] == (-1, -1, -1): + if z_height is None: + gcmd.error( + 'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1' + ) + # Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1 + # This is usefull to get something automatic and is also used in the Klippain modular config + kin_info = toolhead.kin.get_status(systime) + mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2 + mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2 + point = (mid_x, mid_y, z_height) + else: + x, y, z = test_points[0] + if z_height is not None: + z = z_height + point = (x, y, z) + + toolhead.manual_move(point, feedrate_travel) + + min_freq = freq - 1 + max_freq = freq + 1 + hz_per_sec = 1 / (duration / 3) + vibrate_axis(toolhead, gcode, axis_config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz)