6 Commits

Author SHA1 Message Date
Félix Boisselier
8cf81bcb44 better sync of the peaks pair for close frequencies 2024-06-30 22:41:06 +02:00
Félix Boisselier
92a651b6a6 switched to pearson coefficient for belts similarity 2024-06-30 22:27:46 +02:00
Félix Boisselier
6712506862 fixed potential out of bounds error in belt graphs 2024-06-30 20:30:05 +02:00
Félix Boisselier
f5a74c29e1 fixed pyproject.toml project name 2024-06-27 22:25:04 +02:00
Aaron Haun
f87713eacd feat: automated testing GitHub action (#134) 2024-06-27 18:35:07 +02:00
Félix Boisselier
f045b8a49e fixed a mistake about some code that shouldn't be here... 2024-06-27 18:31:41 +02:00
10 changed files with 330 additions and 431 deletions

69
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,69 @@
name: Smoke Tests
on:
workflow_dispatch:
push:
jobs:
klippy_testing:
name: Klippy Tests
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
klipper_repo:
- klipper3d/klipper
- DangerKlippers/danger-klipper
steps:
- name: Checkout shaketune
uses: actions/checkout@v4
with:
path: shaketune
- name: Checkout Klipper
uses: actions/checkout@v4
with:
path: klipper
repository: ${{ matrix.klipper_repo }}
ref: master
- name: Install build dependencies
run: |
sudo apt-get update
sudo apt-get install -y build-essential
- name: Build klipper dict
run: |
pushd klipper
cp ../shaketune/ci/smoke-test/klipper-smoketest.kconfig .config
make olddefconfig
make out/compile_time_request.o
popd
- name: Setup klippy env
run: |
python3 -m venv --prompt klippy klippy-env
./klippy-env/bin/python -m pip install -r klipper/scripts/klippy-requirements.txt
./klippy-env/bin/python -m pip install -r shaketune/requirements.txt
- name: Install shaketune
run: |
ln -s $PWD/shaketune/shaketune $PWD/klipper/klippy/extras/shaketune
- name: Klipper import test
run: |
./klippy-env/bin/python klipper/klippy/klippy.py --import-test
- name: Klipper integrated test
run: |
pushd klipper
mkdir ../dicts
cp ../klipper/out/klipper.dict ../dicts/linux_basic.dict
../klippy-env/bin/python scripts/test_klippy.py -d ../dicts ../shaketune/ci/smoke-test/klippy-tests/simple.test
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
cache: 'pip'
- name: install ruff
run: |
pip install ruff
- name: run ruff tests
run: |
ruff check

View File

@@ -31,27 +31,6 @@ 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 has limitations in stock Klipper and is best used with DangerKlipper /!\
# Frequencies of X and Y motor resonances to filter them by using
# composite shapers. This requires the `[input_shaper]` config
# section to be defined in your printer.cfg file to work.
# motor_freq_x:
# motor_freq_y:
# /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\
# If motor_freq is not set, these two parameters can be used
# to configure different filters for X and Y motors. The same
# values are supported as for motor_freq parameter.
# motor_damping_ratio: 0.05
# /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\
# Damping ratios for X and Y motor resonances.
# motor_damping_ratio_x:
# motor_damping_ratio_y:
# /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\
# If motor_damping_ratio is not set, these two parameters can be used
# to configure different filters for X and Y motors. The same values
# are supported as for motor_damping_ratio parameter.
``` ```
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,34 @@
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

View File

@@ -0,0 +1,9 @@
[mcu]
serial: /tmp/klipper_host_mcu
[printer]
kinematics: none
max_velocity: 300
max_accel: 300
[shaketune]

View File

@@ -0,0 +1,4 @@
DICTIONARY linux_basic.dict
CONFIG simple.cfg
G4 P1000

View File

@@ -1,5 +1,5 @@
[project] [project]
name = "Shake&Tune" name = "shake_n_tune"
description = "Klipper streamlined input shaper workflow and calibration tools" description = "Klipper streamlined input shaper workflow and calibration tools"
readme = "README.md" readme = "README.md"
requires-python = ">= 3.9" requires-python = ">= 3.9"

View File

@@ -19,6 +19,7 @@ import matplotlib.font_manager
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.ticker import matplotlib.ticker
import numpy as np import numpy as np
from scipy.stats import pearsonr
matplotlib.use('Agg') matplotlib.use('Agg')
@@ -343,14 +344,12 @@ def plot_versus_belts(
common_freqs: np.ndarray, common_freqs: np.ndarray,
signal1: SignalData, signal1: SignalData,
signal2: SignalData, signal2: SignalData,
interp_psd1: np.ndarray,
interp_psd2: np.ndarray,
signal1_belt: str, signal1_belt: str,
signal2_belt: str, signal2_belt: str,
) -> None: ) -> None:
ax.set_title('Cross-belts comparison plot', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Cross-belts comparison plot', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
max_psd = max(np.max(interp_psd1), np.max(interp_psd2)) max_psd = max(np.max(signal1.psd), np.max(signal2.psd))
ideal_line = np.linspace(0, max_psd * 1.1, 500) ideal_line = np.linspace(0, max_psd * 1.1, 500)
green_boundary = ideal_line + (0.35 * max_psd * np.exp(-ideal_line / (0.6 * max_psd))) green_boundary = ideal_line + (0.35 * max_psd * np.exp(-ideal_line / (0.6 * max_psd)))
ax.fill_betweenx(ideal_line, ideal_line, green_boundary, color='green', alpha=0.15) ax.fill_betweenx(ideal_line, ideal_line, green_boundary, color='green', alpha=0.15)
@@ -364,8 +363,8 @@ def plot_versus_belts(
linewidth=2, linewidth=2,
) )
ax.plot(interp_psd1, interp_psd2, color='dimgrey', marker='o', markersize=1.5) ax.plot(signal1.psd, signal2.psd, color='dimgrey', marker='o', markersize=1.5)
ax.fill_betweenx(interp_psd2, interp_psd1, color=KLIPPAIN_COLORS['red_pink'], alpha=0.1) ax.fill_betweenx(signal2.psd, signal1.psd, color=KLIPPAIN_COLORS['red_pink'], alpha=0.1)
paired_peak_count = 0 paired_peak_count = 0
unpaired_peak_count = 0 unpaired_peak_count = 0
@@ -374,31 +373,27 @@ def plot_versus_belts(
label = ALPHABET[paired_peak_count] label = ALPHABET[paired_peak_count]
freq1 = signal1.freqs[peak1[0]] freq1 = signal1.freqs[peak1[0]]
freq2 = signal2.freqs[peak2[0]] freq2 = signal2.freqs[peak2[0]]
nearest_idx1 = np.argmin(np.abs(common_freqs - freq1))
nearest_idx2 = np.argmin(np.abs(common_freqs - freq2))
if nearest_idx1 == nearest_idx2: if abs(freq1 - freq2) < 1:
psd1_peak_value = interp_psd1[nearest_idx1] ax.plot(signal1.psd[peak1[0]], signal2.psd[peak2[0]], marker='o', color='black', markersize=7)
psd2_peak_value = interp_psd2[nearest_idx1]
ax.plot(psd1_peak_value, psd2_peak_value, marker='o', color='black', markersize=7)
ax.annotate( ax.annotate(
f'{label}1/{label}2', f'{label}1/{label}2',
(psd1_peak_value, psd2_peak_value), (signal1.psd[peak1[0]], signal2.psd[peak2[0]]),
textcoords='offset points', textcoords='offset points',
xytext=(-7, 7), xytext=(-7, 7),
fontsize=13, fontsize=13,
color='black', color='black',
) )
else: else:
psd1_peak_value = interp_psd1[nearest_idx1] ax.plot(
psd1_on_peak = interp_psd1[nearest_idx2] signal1.psd[peak2[0]], signal2.psd[peak2[0]], marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7
psd2_peak_value = interp_psd2[nearest_idx2] )
psd2_on_peak = interp_psd2[nearest_idx1] ax.plot(
ax.plot(psd1_on_peak, psd2_peak_value, marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7) signal1.psd[peak1[0]], signal2.psd[peak1[0]], marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7
ax.plot(psd1_peak_value, psd2_on_peak, marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7) )
ax.annotate( ax.annotate(
f'{label}1', f'{label}1',
(psd1_peak_value, psd2_on_peak), (signal1.psd[peak1[0]], signal2.psd[peak1[0]]),
textcoords='offset points', textcoords='offset points',
xytext=(0, 7), xytext=(0, 7),
fontsize=13, fontsize=13,
@@ -406,7 +401,7 @@ def plot_versus_belts(
) )
ax.annotate( ax.annotate(
f'{label}2', f'{label}2',
(psd1_on_peak, psd2_peak_value), (signal1.psd[peak2[0]], signal2.psd[peak2[0]]),
textcoords='offset points', textcoords='offset points',
xytext=(0, 7), xytext=(0, 7),
fontsize=13, fontsize=13,
@@ -415,16 +410,12 @@ def plot_versus_belts(
paired_peak_count += 1 paired_peak_count += 1
for _, peak_index in enumerate(signal1.unpaired_peaks): for _, peak_index in enumerate(signal1.unpaired_peaks):
freq1 = signal1.freqs[peak_index] ax.plot(
freq2 = signal2.freqs[peak_index] signal1.psd[peak_index], signal2.psd[peak_index], marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7
nearest_idx1 = np.argmin(np.abs(common_freqs - freq1)) )
nearest_idx2 = np.argmin(np.abs(common_freqs - freq2))
psd1_peak_value = interp_psd1[nearest_idx1]
psd2_peak_value = interp_psd2[nearest_idx1]
ax.plot(psd1_peak_value, psd2_peak_value, marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7)
ax.annotate( ax.annotate(
str(unpaired_peak_count + 1), str(unpaired_peak_count + 1),
(psd1_peak_value, psd2_peak_value), (signal1.psd[peak_index], signal2.psd[peak_index]),
textcoords='offset points', textcoords='offset points',
fontsize=13, fontsize=13,
weight='bold', weight='bold',
@@ -434,16 +425,12 @@ def plot_versus_belts(
unpaired_peak_count += 1 unpaired_peak_count += 1
for _, peak_index in enumerate(signal2.unpaired_peaks): for _, peak_index in enumerate(signal2.unpaired_peaks):
freq1 = signal1.freqs[peak_index] ax.plot(
freq2 = signal2.freqs[peak_index] signal1.psd[peak_index], signal2.psd[peak_index], marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7
nearest_idx1 = np.argmin(np.abs(common_freqs - freq1)) )
nearest_idx2 = np.argmin(np.abs(common_freqs - freq2))
psd1_peak_value = interp_psd1[nearest_idx1]
psd2_peak_value = interp_psd2[nearest_idx1]
ax.plot(psd1_peak_value, psd2_peak_value, marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7)
ax.annotate( ax.annotate(
str(unpaired_peak_count + 1), str(unpaired_peak_count + 1),
(psd1_peak_value, psd2_peak_value), (signal1.psd[peak_index], signal2.psd[peak_index]),
textcoords='offset points', textcoords='offset points',
fontsize=13, fontsize=13,
weight='bold', weight='bold',
@@ -476,16 +463,21 @@ def plot_versus_belts(
# Original Klipper function to get the PSD data of a raw accelerometer signal # Original Klipper function to get the PSD data of a raw accelerometer signal
def compute_signal_data(data: np.ndarray, max_freq: float) -> SignalData: def compute_signal_data(data: np.ndarray, common_freqs: np.ndarray, max_freq: float) -> SignalData:
helper = shaper_calibrate.ShaperCalibrate(printer=None) helper = shaper_calibrate.ShaperCalibrate(printer=None)
calibration_data = helper.process_accelerometer_data(data) calibration_data = helper.process_accelerometer_data(data)
freqs = calibration_data.freq_bins[calibration_data.freq_bins <= max_freq] freqs = calibration_data.freq_bins[calibration_data.freq_bins <= max_freq]
psd = calibration_data.get_psd('all')[calibration_data.freq_bins <= max_freq] psd = calibration_data.get_psd('all')[calibration_data.freq_bins <= max_freq]
_, peaks, _ = detect_peaks(psd, freqs, PEAKS_DETECTION_THRESHOLD * psd.max()) # Re-interpolate the PSD signal to a common frequency range to be able to plot them one against the other
interp_psd = np.interp(common_freqs, freqs, psd)
return SignalData(freqs=freqs, psd=psd, peaks=peaks) _, peaks, _ = detect_peaks(
interp_psd, common_freqs, PEAKS_DETECTION_THRESHOLD * interp_psd.max(), window_size=20, vicinity=15
)
return SignalData(freqs=common_freqs, psd=interp_psd, peaks=peaks)
###################################################################### ######################################################################
@@ -517,8 +509,9 @@ def belts_calibration(
signal2_belt += belt_info.get(signal2_belt, '') signal2_belt += belt_info.get(signal2_belt, '')
# Compute calibration data for the two datasets with automatic peaks detection # Compute calibration data for the two datasets with automatic peaks detection
signal1 = compute_signal_data(datas[0], max_freq) common_freqs = np.linspace(0, max_freq, 500)
signal2 = compute_signal_data(datas[1], max_freq) signal1 = compute_signal_data(datas[0], common_freqs, max_freq)
signal2 = compute_signal_data(datas[1], common_freqs, max_freq)
del datas del datas
# Pair the peaks across the two datasets # Pair the peaks across the two datasets
@@ -526,18 +519,13 @@ def belts_calibration(
signal1 = signal1._replace(paired_peaks=pairing_result.paired_peaks, unpaired_peaks=pairing_result.unpaired_peaks1) signal1 = signal1._replace(paired_peaks=pairing_result.paired_peaks, unpaired_peaks=pairing_result.unpaired_peaks1)
signal2 = signal2._replace(paired_peaks=pairing_result.paired_peaks, unpaired_peaks=pairing_result.unpaired_peaks2) signal2 = signal2._replace(paired_peaks=pairing_result.paired_peaks, unpaired_peaks=pairing_result.unpaired_peaks2)
# Re-interpolate the PSD signals to a common frequency range to be able to plot them one against the other point by point # R² proved to be pretty instable to compute the similarity between the two belts
common_freqs = np.linspace(0, max_freq, 500) # So now, we use the Pearson correlation coefficient to compute the similarity
interp_psd1 = np.interp(common_freqs, signal1.freqs, signal1.psd) correlation, _ = pearsonr(signal1.psd, signal2.psd)
interp_psd2 = np.interp(common_freqs, signal2.freqs, signal2.psd) similarity_factor = correlation * 100
similarity_factor = np.clip(similarity_factor, 0, 100)
# Calculating R^2 to y=x line to compute the similarity between the two belts
ss_res = np.sum((interp_psd2 - interp_psd1) ** 2)
ss_tot = np.sum((interp_psd2 - np.mean(interp_psd2)) ** 2)
similarity_factor = (1 - (ss_res / ss_tot)) * 100
ConsoleOutput.print(f'Belts estimated similarity: {similarity_factor:.1f}%') ConsoleOutput.print(f'Belts estimated similarity: {similarity_factor:.1f}%')
# mhi = compute_mhi(similarity_factor, num_peaks, num_unpaired_peaks)
mhi = compute_mhi(similarity_factor, signal1, signal2) mhi = compute_mhi(similarity_factor, signal1, signal2)
ConsoleOutput.print(f'[experimental] Mechanical health: {mhi}') ConsoleOutput.print(f'[experimental] Mechanical health: {mhi}')
@@ -582,11 +570,11 @@ def belts_calibration(
# Add the accel_per_hz value to the title # Add the accel_per_hz value to the title
title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz'
fig.text(0.55, 0.915, title_line5, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.551, 0.915, title_line5, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'])
# Plot the graphs # Plot the graphs
plot_compare_frequency(ax1, signal1, signal2, signal1_belt, signal2_belt, max_freq) plot_compare_frequency(ax1, signal1, signal2, signal1_belt, signal2_belt, max_freq)
plot_versus_belts(ax3, common_freqs, signal1, signal2, interp_psd1, interp_psd2, signal1_belt, signal2_belt) plot_versus_belts(ax3, common_freqs, signal1, signal2, signal1_belt, signal2_belt)
# Adding a small Klippain logo to the top left corner of the figure # Adding a small Klippain logo to the top left corner of the figure
ax_logo = fig.add_axes([0.001, 0.894, 0.105, 0.105], anchor='NW') ax_logo = fig.add_axes([0.001, 0.894, 0.105, 0.105], anchor='NW')

View File

@@ -39,7 +39,6 @@ from ..helpers.motors_config_parser import Motor, MotorsConfigParser
from ..shaketune_config import ShakeTuneConfig from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator from .graph_creator import GraphCreator
DEFAULT_LOW_FREQ_MAX = 30
PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04 PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04
CURVE_SIMILARITY_SIGMOID_K = 0.5 CURVE_SIMILARITY_SIGMOID_K = 0.5
@@ -115,61 +114,58 @@ def calc_freq_response(data) -> Tuple[np.ndarray, np.ndarray]:
return helper.process_accelerometer_data(data) return helper.process_accelerometer_data(data)
def find_motor_characteristics(motor: str, freqs: np.ndarray, psd: np.ndarray) -> Tuple[float, float, int]: # Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements for the machine kinematics
motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(psd, freqs, DEFAULT_LOW_FREQ_MAX) # main angles and then create a global motor profile as a weighted average (from their own vibrations) of all calculated profiles
def compute_motor_profiles(
freqs: np.ndarray,
psds: dict,
all_angles_energy: dict,
measured_angles: Optional[List[int]] = None,
energy_amplification_factor: int = 2,
) -> Tuple[dict, np.ndarray]:
if measured_angles is None:
measured_angles = [0, 90]
if lowfreq_max:
ConsoleOutput.print(
(
f'[WARNING] {motor} motor has a lot of low frequency vibrations. This is '
'probably due to the test being performed at too high an acceleration!\n'
'Try lowering ACCEL and/or increasing SIZE before restarting the macro '
'to ensure that only constant speeds are being recorded and that the '
'dynamic behavior of the machine is not affecting the measurements.'
)
)
if motor_zeta is not None:
ConsoleOutput.print(
(
f'Motor {motor} have a main resonant frequency at {motor_fr:.1f}Hz '
f'with an estimated damping ratio of {motor_zeta:.3f}'
)
)
else:
ConsoleOutput.print(
(
f'Motor {motor} have a main resonant frequency at {motor_fr:.1f}Hz '
'but it was impossible to estimate its damping ratio.'
)
)
return motor_fr, motor_zeta, motor_res_idx
# Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements
# for the machine kinematics main angles
def compute_motor_profiles(freqs: np.ndarray, psds: dict, measured_angles: Optional[List[int]] = (0, 90)) -> dict:
motor_profiles = {} motor_profiles = {}
weighted_sum_profiles = np.zeros_like(freqs)
total_weight = 0
conv_filter = np.ones(20) / 20 conv_filter = np.ones(20) / 20
# Creating the PSD motor profiles for each angle by summing the PSDs for each speed # Creating the PSD motor profiles for each angles
for angle in measured_angles: for angle in measured_angles:
# Calculate the sum of PSDs for the current angle and then convolve
sum_curve = np.sum(np.array([psds[angle][speed] for speed in psds[angle]]), axis=0) sum_curve = np.sum(np.array([psds[angle][speed] for speed in psds[angle]]), axis=0)
motor_profiles[angle] = np.convolve(sum_curve / len(psds[angle]), conv_filter, mode='same') motor_profiles[angle] = np.convolve(sum_curve / len(psds[angle]), conv_filter, mode='same')
return motor_profiles # Calculate weights
angle_energy = (
all_angles_energy[angle] ** energy_amplification_factor
) # First weighting factor is based on the total vibrations of the machine at the specified angle
curve_area = (
np.trapz(motor_profiles[angle], freqs) ** energy_amplification_factor
) # Additional weighting factor is based on the area under the current motor profile at this specified angle
total_angle_weight = angle_energy * curve_area
# Update weighted sum profiles to get the global motor profile
weighted_sum_profiles += motor_profiles[angle] * total_angle_weight
total_weight += total_angle_weight
# Creating a global average motor profile that is the weighted average of all the PSD motor profiles
global_motor_profile = weighted_sum_profiles / total_weight if total_weight != 0 else weighted_sum_profiles
return motor_profiles, global_motor_profile
# Since it was discovered that there is no non-linear mixing in the stepper "steps" vibrations, instead of measuring # Since it was discovered that there is no non-linear mixing in the stepper "steps" vibrations, instead of measuring
# the effects of each speeds at each angles, this function simplify it by using only the main motors axes (X/Y for Cartesian # the effects of each speeds at each angles, this function simplify it by using only the main motors axes (X/Y for Cartesian
# printers and A/B for CoreXY) measurements and project each points on the [0, 360] degrees range using trigonometry # printers and A/B for CoreXY) measurements and project each points on the [0,360] degrees range using trigonometry
# to "sum" the vibration impact of each axis at every points of the generated spectrogram. The result is very similar at the end. # to "sum" the vibration impact of each axis at every points of the generated spectrogram. The result is very similar at the end.
def compute_dir_speed_spectrogram( def compute_dir_speed_spectrogram(
measured_speeds: List[float], measured_speeds: List[float], data: dict, kinematics: str = 'cartesian', measured_angles: Optional[List[int]] = None
data: dict,
kinematics: str = 'cartesian',
measured_angles: Optional[List[int]] = (0, 90),
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: ) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
if measured_angles is None:
measured_angles = [0, 90]
# We want to project the motor vibrations measured on their own axes on the [0, 360] range # We want to project the motor vibrations measured on their own axes on the [0, 360] range
spectrum_angles = np.linspace(0, 360, 720) # One point every 0.5 degrees spectrum_angles = np.linspace(0, 360, 720) # One point every 0.5 degrees
spectrum_speeds = np.linspace(min(measured_speeds), max(measured_speeds), len(measured_speeds) * 6) spectrum_speeds = np.linspace(min(measured_speeds), max(measured_speeds), len(measured_speeds) * 6)
@@ -297,8 +293,11 @@ def filter_and_split_ranges(
# This function allow the computation of a symmetry score that reflect the spectrogram apparent symmetry between # This function allow the computation of a symmetry score that reflect the spectrogram apparent symmetry between
# measured axes on both the shape of the signal and the energy level consistency across both side of the signal # measured axes on both the shape of the signal and the energy level consistency across both side of the signal
def compute_symmetry_analysis( def compute_symmetry_analysis(
all_angles: np.ndarray, spectrogram_data: np.ndarray, measured_angles: Optional[List[int]] = (0, 90) all_angles: np.ndarray, spectrogram_data: np.ndarray, measured_angles: Optional[List[int]] = None
) -> float: ) -> float:
if measured_angles is None:
measured_angles = [0, 90]
total_spectrogram_angles = len(all_angles) total_spectrogram_angles = len(all_angles)
half_spectrogram_angles = total_spectrogram_angles // 2 half_spectrogram_angles = total_spectrogram_angles // 2
@@ -502,40 +501,75 @@ def plot_angular_speed_profiles(
def plot_motor_profiles( def plot_motor_profiles(
ax: plt.Axes, freqs: np.ndarray, main_angles: List[int], motor_profiles: dict, max_freq: float ax: plt.Axes,
freqs: np.ndarray,
main_angles: List[int],
motor_profiles: dict,
global_motor_profile: np.ndarray,
max_freq: float,
) -> None: ) -> None:
ax.set_title('Motors frequency profiles', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Motor frequency profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_ylabel('Energy') ax.set_ylabel('Energy')
ax.set_xlabel('Frequency (Hz)') ax.set_xlabel('Frequency (Hz)')
ax2 = ax.twinx() ax2 = ax.twinx()
ax2.yaxis.set_visible(False) ax2.yaxis.set_visible(False)
# Global weighted average motor profile
ax.plot(freqs, global_motor_profile, label='Combined', color=KLIPPAIN_COLORS['purple'], zorder=5)
max_value = global_motor_profile.max()
# Mapping of angles to axis names # Mapping of angles to axis names
angle_settings = {0: 'X', 90: 'Y', 45: 'A', 135: 'B'} angle_settings = {0: 'X', 90: 'Y', 45: 'A', 135: 'B'}
# And then plot the motor profiles at each measured angles with their characteristics # And then plot the motor profiles at each measured angles
max_value = 0
for angle in main_angles: for angle in main_angles:
profile_max = motor_profiles[angle].max() profile_max = motor_profiles[angle].max()
if profile_max > max_value: if profile_max > max_value:
max_value = profile_max max_value = profile_max
label = f'{angle_settings[angle]} ({angle} deg)' if angle in angle_settings else f'{angle} deg' label = f'{angle_settings[angle]} ({angle} deg)' if angle in angle_settings else f'{angle} deg'
ax.plot(freqs, motor_profiles[angle], label=label, zorder=2) ax.plot(freqs, motor_profiles[angle], linestyle='--', label=label, zorder=2)
motor_fr, motor_zeta, motor_res_idx = find_motor_characteristics(
angle_settings[angle], freqs, motor_profiles[angle]
)
ax2.plot([], [], ' ', label=f'{angle_settings[angle]} resonant frequency (ω0): {motor_fr:.1f}Hz')
if motor_zeta is not None:
ax2.plot([], [], ' ', label=f'{angle_settings[angle]} damping ratio (ζ): {motor_zeta:.3f}')
else:
ax2.plot([], [], ' ', label=f'{angle_settings[angle]} damping ratio (ζ): unknown')
ax.set_xlim([0, max_freq]) ax.set_xlim([0, max_freq])
ax.set_ylim([0, max_value * 1.1]) ax.set_ylim([0, max_value * 1.1])
ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0)) ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0))
# Then add the motor resonance peak to the graph and print some infos about it
motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(global_motor_profile, freqs, 30)
if lowfreq_max:
ConsoleOutput.print(
'[WARNING] There are a lot of low frequency vibrations that can alter the readings. This is probably due to the test being performed at too high an acceleration!'
)
ConsoleOutput.print(
'Try lowering the ACCEL value and/or increasing the SIZE value before restarting the macro to ensure that only constant speeds are being recorded and that the dynamic behavior of the machine is not affecting the measurements'
)
if motor_zeta is not None:
ConsoleOutput.print(
f'Motors have a main resonant frequency at {motor_fr:.1f}Hz with an estimated damping ratio of {motor_zeta:.3f}'
)
else:
ConsoleOutput.print(
f'Motors have a main resonant frequency at {motor_fr:.1f}Hz but it was impossible to estimate a damping ratio.'
)
ax.plot(freqs[motor_res_idx], global_motor_profile[motor_res_idx], 'x', color='black', markersize=10)
ax.annotate(
'R',
(freqs[motor_res_idx], global_motor_profile[motor_res_idx]),
textcoords='offset points',
xytext=(15, 5),
ha='right',
fontsize=14,
color=KLIPPAIN_COLORS['red_pink'],
weight='bold',
)
ax2.plot([], [], ' ', label=f'Motor resonant frequency (ω0): {motor_fr:.1f}Hz')
if motor_zeta is not None:
ax2.plot([], [], ' ', label=f'Motor damping ratio (ζ): {motor_zeta:.3f}')
else:
ax2.plot([], [], ' ', label='No damping ratio computed')
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.grid(which='major', color='grey') ax.grid(which='major', color='grey')
@@ -615,7 +649,7 @@ def plot_vibration_spectrogram(
def plot_motor_config_txt(fig: plt.Figure, motors: List[MotorsConfigParser], differences: Optional[str]) -> None: def plot_motor_config_txt(fig: plt.Figure, motors: List[MotorsConfigParser], differences: Optional[str]) -> None:
motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')] motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')]
distance = 0.15 distance = 0.12
if motors[0].get_config('autotune_enabled'): if motors[0].get_config('autotune_enabled'):
distance = 0.27 distance = 0.27
config_blocks = [ config_blocks = [
@@ -698,9 +732,9 @@ def vibrations_profile(
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
if kinematics == 'cartesian' or kinematics == 'corexz': if kinematics == 'cartesian' or kinematics == 'corexz':
main_angles = (0, 90) main_angles = [0, 90]
elif kinematics == 'corexy': elif kinematics == 'corexy':
main_angles = (45, 135) main_angles = [45, 135]
else: else:
raise ValueError('Only Cartesian, CoreXY and CoreXZ kinematics are supported by this tool at the moment!') raise ValueError('Only Cartesian, CoreXY and CoreXZ kinematics are supported by this tool at the moment!')
@@ -741,7 +775,7 @@ def vibrations_profile(
) )
all_angles_energy = compute_angle_powers(spectrogram_data) all_angles_energy = compute_angle_powers(spectrogram_data)
sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric = compute_speed_powers(spectrogram_data) sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric = compute_speed_powers(spectrogram_data)
motor_profiles = compute_motor_profiles(target_freqs, psds, main_angles) motor_profiles, global_motor_profile = compute_motor_profiles(target_freqs, psds, all_angles_energy, main_angles)
# symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy) # symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy)
symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles) symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles)
@@ -850,7 +884,7 @@ def vibrations_profile(
plot_angular_speed_profiles(ax3, all_speeds, all_angles, spectrogram_data, kinematics) plot_angular_speed_profiles(ax3, all_speeds, all_angles, spectrogram_data, kinematics)
plot_vibration_spectrogram(ax5, all_angles, all_speeds, spectrogram_data, vibration_peaks) plot_vibration_spectrogram(ax5, all_angles, all_speeds, spectrogram_data, vibration_peaks)
plot_motor_profiles(ax6, target_freqs, main_angles, motor_profiles, max_freq) plot_motor_profiles(ax6, target_freqs, main_angles, motor_profiles, global_motor_profile, max_freq)
# Adding a small Klippain logo to the top left corner of the figure # Adding a small Klippain logo to the top left corner of the figure
ax_logo = fig.add_axes([0.001, 0.924, 0.075, 0.075], anchor='NW') ax_logo = fig.add_axes([0.001, 0.924, 0.075, 0.075], anchor='NW')

View File

@@ -1,142 +0,0 @@
# 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.
import math
from .helpers.console_output import ConsoleOutput
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 custom composite input shaping filter
@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)
if input_shaper is None:
raise ValueError(
'Unable to apply Shake&Tune motor resonance filters: no [input_shaper] config section found!'
)
shapers = input_shaper.get_shapers()
for shaper in shapers:
axis = shaper.axis
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... This is due to the size of the pulse train being too '
'small and not allowing the convolved shapers to be applied... unless this PR is '
'merged: https://github.com/Klipper3d/klipper/pull/6460'
)
)
continue
# Get the current shaper parameters and store them for later restoration
_, axis_shaper_A, axis_shaper_T = shaper.get_shaper()
self._original_shapers[axis] = (axis_shaper_A, axis_shaper_T)
# Creating the new combined shapers that contains the motor resonance filters
if axis in {'x', 'y'}:
if self._in_danger:
# In DangerKlipper, the pulse train is large enough to allow the
# convolution of any shapers in order to craft the new combined shapers
# so we can use the MZV shaper (that looks to be the best for this purpose)
df = math.sqrt(1.0 - self.damping_x**2)
K = math.exp(-0.75 * self.damping_x * math.pi / df)
t_d = 1.0 / (self.freq_x * df)
a1 = 1.0 - 1.0 / math.sqrt(2.0)
a2 = (math.sqrt(2.0) - 1.0) * K
a3 = a1 * K * K
motor_filter_A = [a1, a2, a3]
motor_filter_T = [0.0, 0.375 * t_d, 0.75 * t_d]
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
df = math.sqrt(1.0 - self.damping_x**2)
K = math.exp(-self.damping_x * math.pi / df)
t_d = 1.0 / (self.freq_x * df)
motor_filter_A = [1.0, K]
motor_filter_T = [0.0, 0.5 * t_d]
combined_filter_A, combined_filter_T = MotorResonanceFilter.convolve_shapers(
(axis_shaper_A, axis_shaper_T),
(motor_filter_A, motor_filter_T),
)
shaper.A = combined_filter_A
shaper.T = combined_filter_T
shaper.n = len(combined_filter_A)
# Update the running input shaper filter with the new parameters
input_shaper._update_input_shaping()
def remove_filters(self) -> None:
input_shaper = self._printer.lookup_object('input_shaper', None)
if input_shaper is None:
raise ValueError(
'Unable to deactivate Shake&Tune motor resonance filters: no [input_shaper] config section found!'
)
shapers = input_shaper.get_shapers()
for shaper in shapers:
axis = shaper.axis
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,7 +8,6 @@
# 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
@@ -27,234 +26,159 @@ 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
DEFAULT_MOTOR_DAMPING_RATIO = 0.05 IN_DANGER = False
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:
self._config = config self._pconfig = config
self._printer = config.get_printer() 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') gcode = self._printer.lookup_object('gcode')
ConsoleOutput.register_output_callback(gcode.respond_info)
self._initialize_config(config) res_tester = self._printer.lookup_object('resonance_tester', None)
self._register_commands() if res_tester is None:
self._initialize_motor_resonance_filter() config.error('No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune.')
# Initialize the ShakeTune object and its configuration self.timeout = config.getfloat('timeout', 300, above=0.0)
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.timeout = config.getfloat('timeout', 300, above=0.0) self._config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi)
self._show_macros = config.getboolean('show_macros_in_webui', default=True) ConsoleOutput.register_output_callback(gcode.respond_info)
motor_freq = config.getfloat('motor_freq', None, minval=0.0) # Register Shake&Tune's measurement commands
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']), (
('AXES_MAP_CALIBRATION', self.cmd_AXES_MAP_CALIBRATION, ST_COMMANDS['AXES_MAP_CALIBRATION']), 'EXCITATE_AXIS_AT_FREQ',
('COMPARE_BELTS_RESPONSES', self.cmd_COMPARE_BELTS_RESPONSES, ST_COMMANDS['COMPARE_BELTS_RESPONSES']), self.cmd_EXCITATE_AXIS_AT_FREQ,
('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']), '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'
),
),
] ]
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: for name, command, description in measurement_commands:
gcode.register_command(f'_{name}' if self._show_macros else name, command, desc=description) gcode.register_command(f'_{name}' if 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 # Load the dummy macros with their description in order to show them in the web interfaces
# interfaces. This is not a good way to do it, but it's the only way to do it for now to get if show_macros:
# a good user experience while using Shake&Tune (it's indeed easier to just click a macro button) pconfig = self._printer.lookup_object('configfile')
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 = configfile.read_config(filename) dummy_macros_cfg = pconfig.read_config(filename)
except Exception as err: except Exception as err:
raise self._config.error(f'Cannot load Shake&Tune dummy macro {filename}') from err raise 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 from ST_COMMANDS (to avoid code duplication and define it in only one place) # Replace the dummy description by the one here (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 = ST_COMMANDS.get(command, 'Shake&Tune macro') description = command_descriptions.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 self._config.fileconfig.has_section(gcode_macro_name.lower()): if not config.fileconfig.has_section(gcode_macro_name.lower()):
self._config.fileconfig.add_section(gcode_macro_name.lower()) 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)
self._config.fileconfig.set(gcode_macro_name.lower(), option, value) 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
self._config.access_tracking[(gcode_macro_name.lower(), option.lower())] = 1 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(self._config, gcode_macro_name.lower()) self._printer.load_object(config, gcode_macro_name.lower())
# Register the motor resonance filters if they are defined in the config
# DangerKlipper is required for the full feature but a degraded system forcing the ZV filter for
# both input shaping and motor resonance filter will be used instead in stock Klipper. But this might
# be improved in the future if https://github.com/Klipper3d/klipper/pull/6460 get merged
# TODO: To mitigate this issue, add an automated patch to klippy/chelper/kin_shaper.c
# (using a .diff file) to enable the motor filters in stock Klipper as well.
# But this will make the Klipper repo dirty to moonraker update manager, so I'm not
# sure how to handle this. Maybe with also a command to revert the patch? Or a
# manual command to apply the patch with a required user action?
def _initialize_motor_resonance_filter(self) -> None:
if self._motor_freq_x is not None and self._motor_freq_y is not None:
self._printer.register_event_handler('klippy:ready', self._on_klippy_ready)
gcode = self._printer.lookup_object('gcode')
gcode.register_command(
'MOTOR_RESONANCE_FILTER',
self.cmd_MOTOR_RESONANCE_FILTER,
desc='Enable/disable the motor resonance filters',
)
self.motor_resonance_filter = MotorResonanceFilter(
self._printer,
self._motor_freq_x,
self._motor_freq_y,
self._motor_damping_x,
self._motor_damping_y,
self.IN_DANGER,
)
def _on_klippy_connect(self) -> None:
# Check if the resonance_tester object is available in the printer
# configuration as it is required for Shake&Tune to work properly
res_tester = self._printer.lookup_object('resonance_tester', None)
if res_tester is None:
raise self._config.error(
'No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune!'
)
# In case the user has configured a motor resonance filter, we need to make sure
# that the input shaper is configured as well in order to use them. This is because
# the input shaper object is the one used to actually applies the additional filters
if self._motor_freq_x is not None and self._motor_freq_y is not None:
input_shaper = self._printer.lookup_object('input_shaper', None)
if input_shaper is None:
raise self._config.error(
(
'No [input_shaper] config section found in printer.cfg! Please add one to use Shake&Tune '
'motor resonance filters!'
)
)
def _on_klippy_ready(self) -> None:
self.motor_resonance_filter.apply_filters()
# ------------------------------------------------------------------------------------------
# ------------------------------------------------------------------------------------------
# Following are all the Shake&Tune commands that are registered to the Klipper console
# ------------------------------------------------------------------------------------------
# ------------------------------------------------------------------------------------------
def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None: 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._st_config) static_freq_graph_creator = StaticGraphCreator(self._config)
st_process = ShakeTuneProcess( st_process = ShakeTuneProcess(
self._st_config, self._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._config, st_process) excitate_axis_at_freq(gcmd, self._pconfig, 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._st_config) axes_map_graph_creator = AxesMapGraphCreator(self._config)
st_process = ShakeTuneProcess( st_process = ShakeTuneProcess(
self._st_config, self._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._config, st_process) axes_map_calibration(gcmd, self._pconfig, 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._st_config) belt_graph_creator = BeltsGraphCreator(self._config)
st_process = ShakeTuneProcess( st_process = ShakeTuneProcess(
self._st_config, self._config,
self._printer.get_reactor(), self._printer.get_reactor(),
belt_graph_creator, belt_graph_creator,
self.timeout, self.timeout,
) )
compare_belts_responses(gcmd, self._config, st_process) compare_belts_responses(gcmd, self._pconfig, 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._st_config) shaper_graph_creator = ShaperGraphCreator(self._config)
st_process = ShakeTuneProcess( st_process = ShakeTuneProcess(
self._st_config, self._config,
self._printer.get_reactor(), self._printer.get_reactor(),
shaper_graph_creator, shaper_graph_creator,
self.timeout, self.timeout,
) )
axes_shaper_calibration(gcmd, self._config, st_process) axes_shaper_calibration(gcmd, self._pconfig, 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._st_config) vibration_profile_creator = VibrationsGraphCreator(self._config)
st_process = ShakeTuneProcess( st_process = ShakeTuneProcess(
self._st_config, self._config,
self._printer.get_reactor(), self._printer.get_reactor(),
vibration_profile_creator, vibration_profile_creator,
self.timeout, self.timeout,
) )
create_vibrations_profile(gcmd, self._config, st_process) create_vibrations_profile(gcmd, self._pconfig, 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"}.')