diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 8f726d7..dd6244e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -27,7 +27,7 @@ jobs: - name: Install build dependencies run: | sudo apt-get update - sudo apt-get install -y build-essential + sudo apt-get install -y build-essential gcc-avr avr-libc - name: Build klipper dict run: | pushd klipper @@ -50,7 +50,7 @@ jobs: run: | pushd klipper mkdir ../dicts - cp ../klipper/out/klipper.dict ../dicts/linux_basic.dict + cp ../klipper/out/klipper.dict ../dicts/atmega2560.dict ../klippy-env/bin/python scripts/test_klippy.py -d ../dicts ../shaketune/ci/smoke-test/klippy-tests/simple.test lint: runs-on: ubuntu-latest diff --git a/ci/smoke-test/klipper-smoketest.kconfig b/ci/smoke-test/klipper-smoketest.kconfig index b37fbb0..42376d4 100644 --- a/ci/smoke-test/klipper-smoketest.kconfig +++ b/ci/smoke-test/klipper-smoketest.kconfig @@ -1,34 +1,4 @@ -CONFIG_LOW_LEVEL_OPTIONS=y -# CONFIG_MACH_AVR is not set -# CONFIG_MACH_ATSAM is not set -# CONFIG_MACH_ATSAMD is not set -# CONFIG_MACH_LPC176X is not set -# CONFIG_MACH_STM32 is not set -# CONFIG_MACH_HC32F460 is not set -# CONFIG_MACH_RP2040 is not set -# CONFIG_MACH_PRU is not set -# CONFIG_MACH_AR100 is not set -CONFIG_MACH_LINUX=y -# CONFIG_MACH_SIMU is not set -CONFIG_BOARD_DIRECTORY="linux" -CONFIG_CLOCK_FREQ=50000000 -CONFIG_LINUX_SELECT=y -CONFIG_USB_VENDOR_ID=0x1d50 -CONFIG_USB_DEVICE_ID=0x614e -CONFIG_USB_SERIAL_NUMBER="12345" -CONFIG_WANT_GPIO_BITBANGING=y -CONFIG_WANT_DISPLAYS=y -CONFIG_WANT_SENSORS=y -CONFIG_WANT_LIS2DW=y -CONFIG_WANT_LDC1612=y -CONFIG_WANT_SOFTWARE_I2C=y -CONFIG_WANT_SOFTWARE_SPI=y -CONFIG_NEED_SENSOR_BULK=y -CONFIG_CANBUS_FREQUENCY=1000000 -CONFIG_INITIAL_PINS="" -CONFIG_HAVE_GPIO=y -CONFIG_HAVE_GPIO_ADC=y -CONFIG_HAVE_GPIO_SPI=y -CONFIG_HAVE_GPIO_I2C=y -CONFIG_HAVE_GPIO_HARD_PWM=y -CONFIG_INLINE_STEPPER_HACK=y +# Base Kconfig file for atmega2560 +CONFIG_MACH_AVR=y +CONFIG_MACH_atmega2560=y +CONFIG_CLOCK_FREQ=16000000 diff --git a/ci/smoke-test/klippy-tests/simple.cfg b/ci/smoke-test/klippy-tests/simple.cfg index 8604aa1..80a194a 100644 --- a/ci/smoke-test/klippy-tests/simple.cfg +++ b/ci/smoke-test/klippy-tests/simple.cfg @@ -1,9 +1,85 @@ +# Test config with a minimal setup to have kind +# of a machine ready with an ADXL345 and an MPU9250 +# to have the required the resonance_tester section +# and allow loading and initializing Shake&Tune into Klipper + +[stepper_x] +step_pin: PF0 +dir_pin: PF1 +enable_pin: !PD7 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PE5 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_y] +step_pin: PF6 +dir_pin: !PF7 +enable_pin: !PF2 +microsteps: 16 +rotation_distance: 40 +endstop_pin: ^PJ1 +position_endstop: 0 +position_max: 200 +homing_speed: 50 + +[stepper_z] +step_pin: PL3 +dir_pin: PL1 +enable_pin: !PK0 +microsteps: 16 +rotation_distance: 8 +endstop_pin: ^PD3 +position_endstop: 0.5 +position_max: 200 + +[extruder] +step_pin: PA4 +dir_pin: PA6 +enable_pin: !PA2 +microsteps: 16 +rotation_distance: 33.5 +nozzle_diameter: 0.500 +filament_diameter: 3.500 +heater_pin: PB4 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK5 +control: pid +pid_Kp: 22.2 +pid_Ki: 1.08 +pid_Kd: 114 +min_temp: 0 +max_temp: 210 + +[heater_bed] +heater_pin: PH5 +sensor_type: EPCOS 100K B57560G104F +sensor_pin: PK6 +control: watermark +min_temp: 0 +max_temp: 110 + [mcu] -serial: /tmp/klipper_host_mcu +serial: /dev/ttyACM0 [printer] -kinematics: none +kinematics: cartesian max_velocity: 300 -max_accel: 300 +max_accel: 3000 +max_z_velocity: 5 +max_z_accel: 100 + +[adxl345] +cs_pin: PK7 +axes_map: -x,-y,z + +[mpu9250 my_mpu] + +[resonance_tester] +probe_points: 20,20,20 +accel_chip_x: adxl345 +accel_chip_y: mpu9250 my_mpu [shaketune] diff --git a/ci/smoke-test/klippy-tests/simple.test b/ci/smoke-test/klippy-tests/simple.test index 3de2790..1cc7e27 100644 --- a/ci/smoke-test/klippy-tests/simple.test +++ b/ci/smoke-test/klippy-tests/simple.test @@ -1,4 +1,4 @@ -DICTIONARY linux_basic.dict CONFIG simple.cfg +DICTIONARY atmega2560.dict G4 P1000 diff --git a/shaketune/graph_creators/shaper_graph_creator.py b/shaketune/graph_creators/shaper_graph_creator.py index 10475c0..fecf6eb 100644 --- a/shaketune/graph_creators/shaper_graph_creator.py +++ b/shaketune/graph_creators/shaper_graph_creator.py @@ -22,13 +22,14 @@ import optparse import os from datetime import datetime -from typing import List, Optional +from typing import Dict, List, Optional import matplotlib import matplotlib.font_manager import matplotlib.pyplot as plt import matplotlib.ticker import numpy as np +from scipy.interpolate import interp1d matplotlib.use('Agg') @@ -47,7 +48,9 @@ PEAKS_EFFECT_THRESHOLD = 0.12 SPECTROGRAM_LOW_PERCENTILE_FILTER = 5 MAX_VIBRATIONS = 5.0 - +MAX_VIBRATIONS_PLOTTED = 80.0 +MAX_VIBRATIONS_PLOTTED_ZOOM = 1.25 # 1.25x max vibs values from the standard filters selection +SMOOTHING_TESTS = 10 # Number of smoothing values to test (it will significantly increase the computation time) KLIPPAIN_COLORS = { 'purple': '#70088C', 'orange': '#FF8D32', @@ -112,15 +115,13 @@ def calibrate_shaper(datas: List[np.ndarray], max_smoothing: Optional[float], sc calibration_data = helper.process_accelerometer_data(datas) calibration_data.normalize_to_frequencies() + # We compute the damping ratio using the Klipper's default value if it fails fr, zeta, _, _ = compute_mechanical_parameters(calibration_data.psd_sum, calibration_data.freq_bins) - - # If the damping ratio computation fail, we use Klipper default value instead - if zeta is None: - zeta = 0.1 + zeta = zeta if zeta is not None else 0.1 compat = False try: - shaper, all_shapers = helper.find_best_shaper( + k_shaper_choice, all_shapers = helper.find_best_shaper( calibration_data, shapers=None, damping_ratio=zeta, @@ -129,23 +130,79 @@ def calibrate_shaper(datas: List[np.ndarray], max_smoothing: Optional[float], sc max_smoothing=max_smoothing, test_damping_ratios=None, max_freq=max_freq, - logger=ConsoleOutput.print, + logger=None, ) - except TypeError: ConsoleOutput.print( - '[WARNING] You seem to be using an older version of Klipper that is not compatible with all the latest Shake&Tune features!' + ( + f'Detected a square corner velocity of {scv:.1f} and a damping ratio of {zeta:.3f}. ' + 'These values will be used to compute the input shaper filter recommendations' + ) ) + except TypeError: ConsoleOutput.print( - 'Shake&Tune now runs in compatibility mode: be aware that the results may be slightly off, since the real damping ratio cannot be used to create the filter recommendations' + ( + '[WARNING] You seem to be using an older version of Klipper that is not compatible with all the latest ' + 'Shake&Tune features!\nShake&Tune now runs in compatibility mode: be aware that the results may be ' + 'slightly off, since the real damping ratio cannot be used to craft accurate filter recommendations' + ) ) compat = True - shaper, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, ConsoleOutput.print) + k_shaper_choice, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, None) - ConsoleOutput.print( - f'\n-> Recommended shaper is {shaper.name.upper()} @ {shaper.freq:.1f} Hz (when using a square corner velocity of {scv:.1f} and a damping ratio of {zeta:.3f})' + # If max_smoothing is not None, we run the same computation but without a smoothing value + # to get the max smoothing values from the filters and create the testing list + all_shapers_nosmoothing = None + if max_smoothing is not None: + if compat: + _, all_shapers_nosmoothing = helper.find_best_shaper(calibration_data, None, None) + else: + _, all_shapers_nosmoothing = helper.find_best_shaper( + calibration_data, + shapers=None, + damping_ratio=zeta, + scv=scv, + shaper_freqs=None, + max_smoothing=None, + test_damping_ratios=None, + max_freq=max_freq, + logger=None, + ) + + # Then we iterate over the all_shaperts_nosmoothing list to get the max of the smoothing values + max_smoothing = 0.0 + if all_shapers_nosmoothing is not None: + for shaper in all_shapers_nosmoothing: + if shaper.smoothing > max_smoothing: + max_smoothing = shaper.smoothing + else: + for shaper in all_shapers: + if shaper.smoothing > max_smoothing: + max_smoothing = shaper.smoothing + + # Then we create a list of smoothing values to test (no need to test the max smoothing value as it was already tested) + smoothing_test_list = np.linspace(0.001, max_smoothing, SMOOTHING_TESTS)[:-1] + additional_all_shapers = {} + for smoothing in smoothing_test_list: + if compat: + _, all_shapers_bis = helper.find_best_shaper(calibration_data, smoothing, None) + else: + _, all_shapers_bis = helper.find_best_shaper( + calibration_data, + shapers=None, + damping_ratio=zeta, + scv=scv, + shaper_freqs=None, + max_smoothing=smoothing, + test_damping_ratios=None, + max_freq=max_freq, + logger=None, + ) + additional_all_shapers[f'sm_{smoothing}'] = all_shapers_bis + additional_all_shapers['max_smoothing'] = ( + all_shapers_nosmoothing if all_shapers_nosmoothing is not None else all_shapers ) - return shaper.name, all_shapers, calibration_data, fr, zeta, compat + return k_shaper_choice.name, all_shapers, additional_all_shapers, calibration_data, fr, zeta, max_smoothing, compat ###################################################################### @@ -164,7 +221,7 @@ def plot_freq_response( fr: float, zeta: float, max_freq: float, -) -> None: +) -> Dict[str, List[Dict[str, str]]]: freqs = calibration_data.freqs psd = calibration_data.psd_sum px = calibration_data.psd_x @@ -193,27 +250,40 @@ def plot_freq_response( ax2 = ax.twinx() ax2.yaxis.set_visible(False) + shaper_table_data = { + 'shapers': [], + 'recommendations': [], + 'damping_ratio': zeta, + } + # Draw the shappers curves and add their specific parameters in the legend perf_shaper_choice = None perf_shaper_vals = None perf_shaper_freq = None perf_shaper_accel = 0 for shaper in shapers: - shaper_max_accel = round(shaper.max_accel / 100.0) * 100.0 - label = f'{shaper.name.upper()} ({shaper.freq:.1f} Hz, vibr={shaper.vibrs * 100.0:.1f}%, sm~={shaper.smoothing:.2f}, accel<={shaper_max_accel:.0f})' - ax2.plot(freqs, shaper.vals, label=label, linestyle='dotted') + ax2.plot(freqs, shaper.vals, label=shaper.name.upper(), linestyle='dotted') + + shaper_info = { + 'type': shaper.name.upper(), + 'frequency': shaper.freq, + 'vibrations': shaper.vibrs, + 'smoothing': shaper.smoothing, + 'max_accel': shaper.max_accel, + } + shaper_table_data['shapers'].append(shaper_info) # Get the Klipper recommended shaper (usually it's a good low vibration compromise) if shaper.name == klipper_shaper_choice: klipper_shaper_freq = shaper.freq klipper_shaper_vals = shaper.vals - klipper_shaper_accel = shaper_max_accel + klipper_shaper_accel = shaper.max_accel # Find the shaper with the highest accel but with vibrs under MAX_VIBRATIONS as it's # a good performance compromise when injecting the SCV and damping ratio in the computation - if perf_shaper_accel < shaper_max_accel and shaper.vibrs * 100 < MAX_VIBRATIONS: + if perf_shaper_accel < shaper.max_accel and shaper.vibrs * 100 < MAX_VIBRATIONS: perf_shaper_choice = shaper.name - perf_shaper_accel = shaper_max_accel + perf_shaper_accel = shaper.max_accel perf_shaper_freq = shaper.freq perf_shaper_vals = shaper.vals @@ -226,32 +296,30 @@ def plot_freq_response( and perf_shaper_choice != klipper_shaper_choice and perf_shaper_accel >= klipper_shaper_accel ): - ax2.plot( - [], - [], - ' ', - label=f'Recommended performance shaper: {perf_shaper_choice.upper()} @ {perf_shaper_freq:.1f} Hz', + perf_shaper_string = f'Recommended for performance: {perf_shaper_choice.upper()} @ {perf_shaper_freq:.1f} Hz' + lowvibr_shaper_string = ( + f'Recommended for low vibrations: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz' ) + shaper_table_data['recommendations'].append(perf_shaper_string) + shaper_table_data['recommendations'].append(lowvibr_shaper_string) + ConsoleOutput.print(f'{perf_shaper_string} (with a damping ratio of {zeta:.3f})') + ConsoleOutput.print(f'{lowvibr_shaper_string} (with a damping ratio of {zeta:.3f})') ax.plot( freqs, psd * perf_shaper_vals, label=f'With {perf_shaper_choice.upper()} applied', color='cyan', ) - ax2.plot( - [], - [], - ' ', - label=f'Recommended low vibrations shaper: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz', + ax.plot( + freqs, + psd * klipper_shaper_vals, + label=f'With {klipper_shaper_choice.upper()} applied', + color='lime', ) - ax.plot(freqs, psd * klipper_shaper_vals, label=f'With {klipper_shaper_choice.upper()} applied', color='lime') else: - ax2.plot( - [], - [], - ' ', - label=f'Recommended performance shaper: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz', - ) + shaper_string = f'Recommended best shaper: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz' + shaper_table_data['recommendations'].append(shaper_string) + ConsoleOutput.print(f'{shaper_string} (with a damping ratio of {zeta:.3f})') ax.plot( freqs, psd * klipper_shaper_vals, @@ -259,9 +327,6 @@ def plot_freq_response( color='cyan', ) - # And the estimated damping ratio is finally added at the end of the legend - ax2.plot([], [], ' ', label=f'Estimated damping ratio (ζ): {zeta:.3f}') - # Draw the detected peaks and name them # This also draw the detection threshold and warning threshold (aka "effect zone") ax.plot(peaks_freqs, psd[peaks], 'x', color='black', markersize=8) @@ -297,7 +362,7 @@ def plot_freq_response( ax.legend(loc='upper left', prop=fontP) ax2.legend(loc='upper right', prop=fontP) - return + return shaper_table_data # Plot a time-frequency spectrogram to see how the system respond over time during the @@ -350,6 +415,170 @@ def plot_spectrogram( return +def plot_smoothing_vs_accel( + ax: plt.Axes, + shaper_table_data: Dict[str, List[Dict[str, str]]], + additional_shapers: Dict[str, List[Dict[str, str]]], +) -> None: + fontP = matplotlib.font_manager.FontProperties() + fontP.set_size('x-small') + + ax.xaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(1000)) + ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) + ax.grid(which='major', color='grey') + ax.grid(which='minor', color='lightgrey') + + shaper_data = {} + + # Extract data from additional_shapers first + for _, shapers in additional_shapers.items(): + for shaper in shapers: + shaper_type = shaper.name.upper() + if shaper_type not in shaper_data: + shaper_data[shaper_type] = [] + shaper_data[shaper_type].append( + { + 'max_accel': shaper.max_accel, + 'vibrs': shaper.vibrs * 100.0, + } + ) + + # Extract data from shaper_table_data and insert into shaper_data + max_shaper_vibrations = 0 + for shaper in shaper_table_data['shapers']: + shaper_type = shaper['type'] + if shaper_type not in shaper_data: + shaper_data[shaper_type] = [] + max_shaper_vibrations = max(max_shaper_vibrations, float(shaper['vibrations']) * 100.0) + shaper_data[shaper_type].append( + { + 'max_accel': float(shaper['max_accel']), + 'vibrs': float(shaper['vibrations']) * 100.0, + } + ) + + # Calculate the maximum `max_accel` for points below the thresholds to get a good plot with + # continuous lines and a zoom on the graph to show details at low vibrations + min_accel_limit = 99999 + max_accel_limit = 0 + max_accel_limit_zoom = 0 + for data in shaper_data.values(): + min_accel_limit = min(min_accel_limit, min(d['max_accel'] for d in data)) + max_accel_limit = max( + max_accel_limit, max(d['max_accel'] for d in data if d['vibrs'] <= MAX_VIBRATIONS_PLOTTED) + ) + max_accel_limit_zoom = max( + max_accel_limit_zoom, + max(d['max_accel'] for d in data if d['vibrs'] <= max_shaper_vibrations * MAX_VIBRATIONS_PLOTTED_ZOOM), + ) + + # Add a zoom axes on the graph to show details at low vibrations + zoomed_window = np.clip(max_shaper_vibrations * MAX_VIBRATIONS_PLOTTED_ZOOM, 0, 20) + axins = ax.inset_axes( + [0.575, 0.125, 0.40, 0.45], + xlim=(min_accel_limit * 0.95, max_accel_limit_zoom * 1.1), + ylim=(-0.5, zoomed_window), + ) + ax.indicate_inset_zoom(axins, edgecolor=KLIPPAIN_COLORS['purple'], linewidth=3) + axins.xaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(500)) + axins.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) + axins.grid(which='major', color='grey') + axins.grid(which='minor', color='lightgrey') + + # Draw the green zone on both axes to highlight the low vibrations zone + number_of_interpolated_points = 100 + x_fill = np.linspace(min_accel_limit * 0.95, max_accel_limit * 1.1, number_of_interpolated_points) + y_fill = np.full_like(x_fill, 5.0) + ax.axhline(y=5.0, color='black', linestyle='--', linewidth=0.5) + ax.fill_between(x_fill, -0.5, y_fill, color='green', alpha=0.15) + if zoomed_window > 5.0: + axins.axhline(y=5.0, color='black', linestyle='--', linewidth=0.5) + axins.fill_between(x_fill, -0.5, y_fill, color='green', alpha=0.15) + + # Plot each shaper remaining vibrations response over acceleration + max_vibrations = 0 + for _, (shaper_type, data) in enumerate(shaper_data.items()): + max_accel_values = np.array([d['max_accel'] for d in data]) + vibrs_values = np.array([d['vibrs'] for d in data]) + + # remove duplicate values in max_accel_values and delete the corresponding vibrs_values + # and interpolate the curves to get them smoother with more datapoints + unique_max_accel_values, unique_indices = np.unique(max_accel_values, return_index=True) + max_accel_values = unique_max_accel_values + vibrs_values = vibrs_values[unique_indices] + interp_func = interp1d(max_accel_values, vibrs_values, kind='cubic') + max_accel_fine = np.linspace(max_accel_values.min(), max_accel_values.max(), number_of_interpolated_points) + vibrs_fine = interp_func(max_accel_fine) + + ax.plot(max_accel_fine, vibrs_fine, label=f'{shaper_type}', zorder=10) + axins.plot(max_accel_fine, vibrs_fine, label=f'{shaper_type}', zorder=15) + max_vibrations = max(max_vibrations, max(vibrs_fine)) + + ax.set_xlabel('Max Acceleration') + ax.set_ylabel('Remaining Vibrations (%)') + ax.set_xlim([min_accel_limit * 0.95, max_accel_limit * 1.1]) + ax.set_ylim([-0.5, np.clip(max_vibrations * 1.05, 50, MAX_VIBRATIONS_PLOTTED)]) + ax.set_title( + 'Filters performances over acceleration', + fontsize=14, + color=KLIPPAIN_COLORS['dark_orange'], + weight='bold', + ) + ax.legend(loc='best', prop=fontP) + + +def print_shaper_table(fig: plt.Figure, shaper_table_data: Dict[str, List[Dict[str, str]]]) -> None: + columns = ['Type', 'Frequency', 'Vibrations', 'Smoothing', 'Max Accel'] + table_data = [] + + for shaper_info in shaper_table_data['shapers']: + row = [ + f'{shaper_info["type"].upper()}', + f'{shaper_info["frequency"]:.1f} Hz', + f'{shaper_info["vibrations"] * 100:.1f} %', + f'{shaper_info["smoothing"]:.3f}', + f'{round(shaper_info["max_accel"] / 10) * 10:.0f}', + ] + table_data.append(row) + table = plt.table(cellText=table_data, colLabels=columns, bbox=[1.130, -0.4, 0.803, 0.25], cellLoc='center') + table.auto_set_font_size(False) + table.set_fontsize(10) + table.auto_set_column_width([0, 1, 2, 3, 4]) + table.set_zorder(100) + + # Add the recommendations and damping ratio using fig.text + fig.text( + 0.585, + 0.235, + f'Estimated damping ratio (ζ): {shaper_table_data["damping_ratio"]:.3f}', + fontsize=14, + color=KLIPPAIN_COLORS['purple'], + ) + if len(shaper_table_data['recommendations']) == 1: + fig.text( + 0.585, + 0.200, + shaper_table_data['recommendations'][0], + fontsize=14, + color=KLIPPAIN_COLORS['red_pink'], + ) + elif len(shaper_table_data['recommendations']) == 2: + fig.text( + 0.585, + 0.200, + shaper_table_data['recommendations'][0], + fontsize=14, + color=KLIPPAIN_COLORS['red_pink'], + ) + fig.text( + 0.585, + 0.175, + shaper_table_data['recommendations'][1], + fontsize=14, + color=KLIPPAIN_COLORS['red_pink'], + ) + + ###################################################################### # Startup and main routines ###################################################################### @@ -375,8 +604,8 @@ def shaper_calibration( ConsoleOutput.print('Warning: incorrect number of .csv files detected. Only the first one will be used!') # Compute shapers, PSD outputs and spectrogram - klipper_shaper_choice, shapers, calibration_data, fr, zeta, compat = calibrate_shaper( - datas[0], max_smoothing, scv, max_freq + klipper_shaper_choice, shapers, additional_shapers, calibration_data, fr, zeta, max_smoothing_computed, compat = ( + calibrate_shaper(datas[0], max_smoothing, scv, max_freq) ) pdata, bins, t = compute_spectrogram(datas[0]) del datas @@ -400,29 +629,31 @@ def shaper_calibration( peak_freqs_formated = ['{:.1f}'.format(f) for f in peaks_freqs] num_peaks_above_effect_threshold = np.sum(calibration_data.psd_sum[peaks] > peaks_threshold[1]) ConsoleOutput.print( - f"\nPeaks detected on the graph: {num_peaks} @ {', '.join(map(str, peak_freqs_formated))} Hz ({num_peaks_above_effect_threshold} above effect threshold)" + f"Peaks detected on the graph: {num_peaks} @ {', '.join(map(str, peak_freqs_formated))} Hz ({num_peaks_above_effect_threshold} above effect threshold)" ) # Create graph layout - fig, (ax1, ax2) = plt.subplots( + fig, ((ax1, ax3), (ax2, ax4)) = plt.subplots( + 2, 2, - 1, gridspec_kw={ 'height_ratios': [4, 3], + 'width_ratios': [5, 4], 'bottom': 0.050, 'top': 0.890, - 'left': 0.085, + 'left': 0.048, 'right': 0.966, 'hspace': 0.169, - 'wspace': 0.200, + 'wspace': 0.150, }, ) - fig.set_size_inches(8.3, 11.6) + ax4.remove() + fig.set_size_inches(15, 11.6) # Add a title with some test info title_line1 = 'INPUT SHAPER CALIBRATION TOOL' fig.text( - 0.12, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold' + 0.065, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold' ) try: filename_parts = (lognames[0].split('/')[-1]).split('_') @@ -433,8 +664,11 @@ def shaper_calibration( title_line4 = '| and SCV are not used for filter recommendations!' title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else '' else: + max_smoothing_string = ( + f'maximum ({max_smoothing_computed:0.3f})' if max_smoothing is None else f'{max_smoothing:0.3f}' + ) title_line3 = f'| Square corner velocity: {scv} mm/s' - title_line4 = f'| Max allowed smoothing: {max_smoothing}' + title_line4 = f'| Allowed smoothing: {max_smoothing_string}' title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else '' except Exception: ConsoleOutput.print(f'Warning: CSV filename look to be different than expected ({lognames[0]})') @@ -442,19 +676,22 @@ def shaper_calibration( title_line3 = '' title_line4 = '' title_line5 = '' - fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) - fig.text(0.58, 0.963, title_line3, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) - fig.text(0.58, 0.948, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) - fig.text(0.58, 0.933, title_line5, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.065, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.50, 0.990, title_line3, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.50, 0.968, title_line4, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.501, 0.945, title_line5, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) # Plot the graphs - plot_freq_response( + shaper_table_data = plot_freq_response( ax1, calibration_data, shapers, klipper_shaper_choice, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq ) plot_spectrogram(ax2, t, bins, pdata, peaks_freqs, max_freq) + plot_smoothing_vs_accel(ax3, shaper_table_data, additional_shapers) + + print_shaper_table(fig, shaper_table_data) # Adding a small Klippain logo to the top left corner of the figure - ax_logo = fig.add_axes([0.001, 0.8995, 0.1, 0.1], anchor='NW') + ax_logo = fig.add_axes([0.001, 0.924, 0.075, 0.075], anchor='NW') ax_logo.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png'))) ax_logo.axis('off') diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py index e30bacf..12cd396 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 @@ -29,156 +30,176 @@ from .shaketune_config import ShakeTuneConfig from .shaketune_process import ShakeTuneProcess -IN_DANGER = False +DEFAULT_FOLDER = '~/printer_data/config/ShakeTune_results' +DEFAULT_NUMBER_OF_RESULTS = 3 +DEFAULT_KEEP_RAW_CSV = False +DEFAULT_DPI = 150 +DEFAULT_TIMEOUT = 300 +DEFAULT_SHOW_MACROS = True +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: - self._pconfig = config + self._config = config self._printer = config.get_printer() + self._printer.register_event_handler('klippy:connect', self._on_klippy_connect) + + # 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 gcode = self._printer.lookup_object('gcode') + ConsoleOutput.register_output_callback(gcode.respond_info) - 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.') + self._initialize_config(config) + self._register_commands() - self.timeout = config.getfloat('timeout', 300, above=0.0) - result_folder = config.get('result_folder', default='~/printer_data/config/ShakeTune_results') + # Initialize the ShakeTune object and its configuration + def _initialize_config(self, config) -> None: + result_folder = config.get('result_folder', default=DEFAULT_FOLDER) 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) + keep_n_results = config.getint('number_of_results_to_keep', default=DEFAULT_NUMBER_OF_RESULTS, minval=0) + keep_csv = config.getboolean('keep_raw_csv', default=DEFAULT_KEEP_RAW_CSV) + dpi = config.getint('dpi', default=DEFAULT_DPI, 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 + # 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()) + + 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!' + ) + + # ------------------------------------------------------------------------------------------ + # ------------------------------------------------------------------------------------------ + # 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._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)