14 Commits

Author SHA1 Message Date
af9fee36ed Update README.md
Some checks failed
Smoke Tests / Klippy Tests (DangerKlippers/danger-klipper) (push) Has been cancelled
Smoke Tests / Klippy Tests (klipper3d/klipper) (push) Has been cancelled
Smoke Tests / lint (push) Has been cancelled
2024-08-01 16:52:24 +03:00
6b2cedfa28 Обновить README.md
Some checks are pending
Smoke Tests / Klippy Tests (DangerKlippers/danger-klipper) (push) Waiting to run
Smoke Tests / Klippy Tests (klipper3d/klipper) (push) Waiting to run
Smoke Tests / lint (push) Waiting to run
2024-08-01 01:04:04 +03:00
04ff95921e Обновить install.sh
Some checks are pending
Smoke Tests / Klippy Tests (DangerKlippers/danger-klipper) (push) Waiting to run
Smoke Tests / Klippy Tests (klipper3d/klipper) (push) Waiting to run
Smoke Tests / lint (push) Waiting to run
2024-08-01 00:58:47 +03:00
871dd72a88 Обновить moonraker.conf
Some checks are pending
Smoke Tests / Klippy Tests (DangerKlippers/danger-klipper) (push) Waiting to run
Smoke Tests / Klippy Tests (klipper3d/klipper) (push) Waiting to run
Smoke Tests / lint (push) Waiting to run
2024-08-01 00:34:32 +03:00
Félix Boisselier
66f5e32e4c typo in doc 2024-07-01 12:42:59 +02:00
Félix Boisselier
c12653e1f7 Merge pull request #138 from Frix-x/develop
v4.1.0
2024-06-30 22:41:30 +02:00
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
6e49c2c607 inverted belts colors to revert the behavior as pre-v4 2024-06-30 11:14:14 +02:00
Félix Boisselier
4a99e95882 Merge pull request #136 from Frix-x/develop
v4.0.2
2024-06-27 22:33:20 +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
13 changed files with 337 additions and 439 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

@@ -13,7 +13,7 @@ Follow these steps to install Shake&Tune on your printer:
1. Be sure to have a working accelerometer on your machine and a `[resonance_tester]` section defined. You can follow the official [Measuring Resonances Klipper documentation](https://www.klipper3d.org/Measuring_Resonances.html) to configure it.
1. Install Shake&Tune by running over SSH on your printer:
```bash
wget -O - https://raw.githubusercontent.com/Frix-x/klippain-shaketune/main/install.sh | bash
wget -O - https://cloud.reijii.org/gitea/reijii/klippain-shaketune-telegramm/raw/branch/main/install.sh | bash
```
1. Then, append the following to your `printer.cfg` file and restart Klipper:
```
@@ -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.
# timeout: 300
# 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)**.

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

@@ -39,9 +39,9 @@ Aside from the actual belt tension, the resonant frequency/amplitude of the curv
The Cross-Belts plot is an innovative cool way to compare the frequency profiles of the belts at every frequency point. In this plot, each point marks the amplitude response of each belt at different frequencies, connected point by point to trace the frequency spectrum. Ideally, these points should align on the diagonal center line, indicating that both belts have matching energy response values at each frequency.
The good zone, wider at the bottom (low-amplitude regions where the deviation doesn't matter much) and narrower at the top right (high-energy region where the main peaks lie), represents acceptable deviations. So **you want all points to be close to the ideal center line and as many as possible within the green zone**, as this means that the bands are well tuned and behave similarly.
The good zone, wider at the bottom (low-amplitude regions where the deviation doesn't matter much) and narrower at the top right (high-energy region where the main peaks lie), represents acceptable deviations. So **you want all points to be close to the ideal center line and as many as possible within the green zone**, as this means that the belts are well tuned and behave similarly.
Paired peaks of exactly the same frequency will be on the same point (labeled α1/α2, β1/β2, ...) and the distance from the center line will show the difference in energy. For paired peaks that also have a frequency delta between them, they are displayed as two points (labeled α1 and α2, ...) and the additional distance between them along the plotted line represents their frequency delta.
Paired peaks at the same frequency will be on the same point (labeled α1/α2, β1/β2, ...) and the distance from the center line will show the difference in energy. For paired peaks that also have a frequency delta between them, they are displayed as two points (labeled α1 and α2, ...) and the additional distance between them along the plotted line represents their frequency delta.
### Estimated similarity and mechanical issues indicator

View File

@@ -64,7 +64,7 @@ function check_download {
if [ ! -d "${K_SHAKETUNE_PATH}" ]; then
echo "[DOWNLOAD] Downloading Klippain Shake&Tune module repository..."
if git -C $shaketunedirname clone https://github.com/Frix-x/klippain-shaketune.git $shaketunebasename; then
if git -C $shaketunedirname clone https://cloud.reijii.org/gitea/reijii/klippain-shaketune-telegramm.git $shaketunebasename; then
chmod +x ${K_SHAKETUNE_PATH}/install.sh
printf "[DOWNLOAD] Download complete!\n\n"
else

View File

@@ -1,8 +1,7 @@
## Klippain Shake&Tune automatic update management
[update_manager Klippain-ShakeTune]
type: git_repo
origin: https://github.com/Frix-x/klippain-shaketune.git
origin: https://cloud.reijii.org/gitea/reijii/klippain-shaketune-telegramm.git
path: ~/klippain_shaketune
virtualenv: ~/klippy-env
requirements: requirements.txt

View File

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

View File

@@ -19,6 +19,7 @@ import matplotlib.font_manager
import matplotlib.pyplot as plt
import matplotlib.ticker
import numpy as np
from scipy.stats import pearsonr
matplotlib.use('Agg')
@@ -210,8 +211,8 @@ def plot_compare_frequency(
ax: plt.Axes, signal1: SignalData, signal2: SignalData, signal1_belt: str, signal2_belt: str, max_freq: float
) -> None:
# Plot the two belts PSD signals
ax.plot(signal1.freqs, signal1.psd, label='Belt ' + signal1_belt, color=KLIPPAIN_COLORS['purple'])
ax.plot(signal2.freqs, signal2.psd, label='Belt ' + signal2_belt, color=KLIPPAIN_COLORS['orange'])
ax.plot(signal1.freqs, signal1.psd, label='Belt ' + signal1_belt, color=KLIPPAIN_COLORS['orange'])
ax.plot(signal2.freqs, signal2.psd, label='Belt ' + signal2_belt, color=KLIPPAIN_COLORS['purple'])
psd_highest_max = max(signal1.psd.max(), signal2.psd.max())
@@ -343,14 +344,12 @@ def plot_versus_belts(
common_freqs: np.ndarray,
signal1: SignalData,
signal2: SignalData,
interp_psd1: np.ndarray,
interp_psd2: np.ndarray,
signal1_belt: str,
signal2_belt: str,
) -> None:
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)
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)
@@ -364,8 +363,8 @@ def plot_versus_belts(
linewidth=2,
)
ax.plot(interp_psd1, interp_psd2, color='dimgrey', marker='o', markersize=1.5)
ax.fill_betweenx(interp_psd2, interp_psd1, color=KLIPPAIN_COLORS['red_pink'], alpha=0.1)
ax.plot(signal1.psd, signal2.psd, color='dimgrey', marker='o', markersize=1.5)
ax.fill_betweenx(signal2.psd, signal1.psd, color=KLIPPAIN_COLORS['red_pink'], alpha=0.1)
paired_peak_count = 0
unpaired_peak_count = 0
@@ -374,31 +373,27 @@ def plot_versus_belts(
label = ALPHABET[paired_peak_count]
freq1 = signal1.freqs[peak1[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:
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='black', markersize=7)
if abs(freq1 - freq2) < 1:
ax.plot(signal1.psd[peak1[0]], signal2.psd[peak2[0]], marker='o', color='black', markersize=7)
ax.annotate(
f'{label}1/{label}2',
(psd1_peak_value, psd2_peak_value),
(signal1.psd[peak1[0]], signal2.psd[peak2[0]]),
textcoords='offset points',
xytext=(-7, 7),
fontsize=13,
color='black',
)
else:
psd1_peak_value = interp_psd1[nearest_idx1]
psd1_on_peak = interp_psd1[nearest_idx2]
psd2_peak_value = interp_psd2[nearest_idx2]
psd2_on_peak = interp_psd2[nearest_idx1]
ax.plot(psd1_on_peak, psd2_peak_value, marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7)
ax.plot(psd1_peak_value, psd2_on_peak, marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7)
ax.plot(
signal1.psd[peak2[0]], signal2.psd[peak2[0]], marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7
)
ax.plot(
signal1.psd[peak1[0]], signal2.psd[peak1[0]], marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7
)
ax.annotate(
f'{label}1',
(psd1_peak_value, psd2_on_peak),
(signal1.psd[peak1[0]], signal2.psd[peak1[0]]),
textcoords='offset points',
xytext=(0, 7),
fontsize=13,
@@ -406,7 +401,7 @@ def plot_versus_belts(
)
ax.annotate(
f'{label}2',
(psd1_on_peak, psd2_peak_value),
(signal1.psd[peak2[0]], signal2.psd[peak2[0]]),
textcoords='offset points',
xytext=(0, 7),
fontsize=13,
@@ -415,16 +410,12 @@ def plot_versus_belts(
paired_peak_count += 1
for _, peak_index in enumerate(signal1.unpaired_peaks):
freq1 = signal1.freqs[peak_index]
freq2 = signal2.freqs[peak_index]
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.plot(
signal1.psd[peak_index], signal2.psd[peak_index], marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7
)
ax.annotate(
str(unpaired_peak_count + 1),
(psd1_peak_value, psd2_peak_value),
(signal1.psd[peak_index], signal2.psd[peak_index]),
textcoords='offset points',
fontsize=13,
weight='bold',
@@ -434,16 +425,12 @@ def plot_versus_belts(
unpaired_peak_count += 1
for _, peak_index in enumerate(signal2.unpaired_peaks):
freq1 = signal1.freqs[peak_index]
freq2 = signal2.freqs[peak_index]
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.plot(
signal1.psd[peak_index], signal2.psd[peak_index], marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7
)
ax.annotate(
str(unpaired_peak_count + 1),
(psd1_peak_value, psd2_peak_value),
(signal1.psd[peak_index], signal2.psd[peak_index]),
textcoords='offset points',
fontsize=13,
weight='bold',
@@ -476,16 +463,21 @@ def plot_versus_belts(
# 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)
calibration_data = helper.process_accelerometer_data(data)
freqs = calibration_data.freq_bins[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, '')
# Compute calibration data for the two datasets with automatic peaks detection
signal1 = compute_signal_data(datas[0], max_freq)
signal2 = compute_signal_data(datas[1], max_freq)
common_freqs = np.linspace(0, max_freq, 500)
signal1 = compute_signal_data(datas[0], common_freqs, max_freq)
signal2 = compute_signal_data(datas[1], common_freqs, max_freq)
del datas
# 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)
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
common_freqs = np.linspace(0, max_freq, 500)
interp_psd1 = np.interp(common_freqs, signal1.freqs, signal1.psd)
interp_psd2 = np.interp(common_freqs, signal2.freqs, signal2.psd)
# 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
# R² proved to be pretty instable to compute the similarity between the two belts
# So now, we use the Pearson correlation coefficient to compute the similarity
correlation, _ = pearsonr(signal1.psd, signal2.psd)
similarity_factor = correlation * 100
similarity_factor = np.clip(similarity_factor, 0, 100)
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)
ConsoleOutput.print(f'[experimental] Mechanical health: {mhi}')
@@ -582,11 +570,11 @@ def belts_calibration(
# Add the accel_per_hz value to the title
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_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
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 .graph_creator import GraphCreator
DEFAULT_LOW_FREQ_MAX = 30
PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04
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)
def find_motor_characteristics(motor: str, freqs: np.ndarray, psd: np.ndarray) -> Tuple[float, float, int]:
motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(psd, freqs, DEFAULT_LOW_FREQ_MAX)
# Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements for the machine kinematics
# 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 = {}
weighted_sum_profiles = np.zeros_like(freqs)
total_weight = 0
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:
# 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)
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
# 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.
def compute_dir_speed_spectrogram(
measured_speeds: List[float],
data: dict,
kinematics: str = 'cartesian',
measured_angles: Optional[List[int]] = (0, 90),
measured_speeds: List[float], data: dict, kinematics: str = 'cartesian', measured_angles: Optional[List[int]] = None
) -> 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
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)
@@ -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
# measured axes on both the shape of the signal and the energy level consistency across both side of the signal
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:
if measured_angles is None:
measured_angles = [0, 90]
total_spectrogram_angles = len(all_angles)
half_spectrogram_angles = total_spectrogram_angles // 2
@@ -502,40 +501,75 @@ def plot_angular_speed_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:
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_xlabel('Frequency (Hz)')
ax2 = ax.twinx()
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
angle_settings = {0: 'X', 90: 'Y', 45: 'A', 135: 'B'}
# And then plot the motor profiles at each measured angles with their characteristics
max_value = 0
# And then plot the motor profiles at each measured angles
for angle in main_angles:
profile_max = motor_profiles[angle].max()
if profile_max > max_value:
max_value = profile_max
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)
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.plot(freqs, motor_profiles[angle], linestyle='--', label=label, zorder=2)
ax.set_xlim([0, max_freq])
ax.set_ylim([0, max_value * 1.1])
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.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
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:
motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')]
distance = 0.15
distance = 0.12
if motors[0].get_config('autotune_enabled'):
distance = 0.27
config_blocks = [
@@ -698,9 +732,9 @@ def vibrations_profile(
shaper_calibrate = setup_klipper_import(klipperdir)
if kinematics == 'cartesian' or kinematics == 'corexz':
main_angles = (0, 90)
main_angles = [0, 90]
elif kinematics == 'corexy':
main_angles = (45, 135)
main_angles = [45, 135]
else:
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)
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, spectrogram_data, main_angles)
@@ -850,7 +884,7 @@ def vibrations_profile(
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_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
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
import importlib
import os
from pathlib import Path
@@ -27,234 +26,159 @@ from .graph_creators import (
VibrationsGraphCreator,
)
from .helpers.console_output import ConsoleOutput
from .motor_res_filter import MotorResonanceFilter
from .shaketune_config import ShakeTuneConfig
from .shaketune_process import ShakeTuneProcess
DEFAULT_MOTOR_DAMPING_RATIO = 0.05
ST_COMMANDS = {
'EXCITATE_AXIS_AT_FREQ': (
'Maintain a specified excitation frequency for a period '
'of time to diagnose and locate a source of vibrations'
),
'AXES_MAP_CALIBRATION': (
'Perform a set of movements to measure the orientation of the accelerometer '
'and help you set the best axes_map configuration for your printer'
),
'COMPARE_BELTS_RESPONSES': (
'Perform a custom half-axis test to analyze and compare the '
'frequency profiles of individual belts on CoreXY or CoreXZ printers'
),
'AXES_SHAPER_CALIBRATION': 'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter',
'CREATE_VIBRATIONS_PROFILE': (
'Run a series of motions to find speed/angle ranges where the printer could be '
'exposed to VFAs to optimize your slicer speed profiles and TMC driver parameters'
),
}
IN_DANGER = False
class ShakeTune:
def __init__(self, config) -> None:
self._config = config
self._pconfig = 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)
self._initialize_config(config)
self._register_commands()
self._initialize_motor_resonance_filter()
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.')
# Initialize the ShakeTune object and its configuration
def _initialize_config(self, config) -> None:
self.timeout = config.getfloat('timeout', 300, above=0.0)
result_folder = config.get('result_folder', default='~/printer_data/config/ShakeTune_results')
result_folder_path = Path(result_folder).expanduser() if result_folder else None
keep_n_results = config.getint('number_of_results_to_keep', default=3, minval=0)
keep_csv = config.getboolean('keep_raw_csv', default=False)
show_macros = config.getboolean('show_macros_in_webui', default=True)
dpi = config.getint('dpi', default=150, minval=100, maxval=500)
self._st_config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi)
self.timeout = config.getfloat('timeout', 300, above=0.0)
self._show_macros = config.getboolean('show_macros_in_webui', default=True)
self._config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi)
ConsoleOutput.register_output_callback(gcode.respond_info)
motor_freq = config.getfloat('motor_freq', None, minval=0.0)
self._motor_freq_x = config.getfloat('motor_freq_x', motor_freq, minval=0.0)
self._motor_freq_y = config.getfloat('motor_freq_y', motor_freq, minval=0.0)
motor_damping = config.getfloat('motor_damping_ratio', DEFAULT_MOTOR_DAMPING_RATIO, minval=0.0)
self._motor_damping_x = config.getfloat('motor_damping_ratio_x', motor_damping, minval=0.0)
self._motor_damping_y = config.getfloat('motor_damping_ratio_y', motor_damping, minval=0.0)
# Create the Klipper commands to allow the user to run Shake&Tune's tools
def _register_commands(self) -> None:
gcode = self._printer.lookup_object('gcode')
# Register Shake&Tune's 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']),
('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']),
(
'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'
),
),
]
# 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)
command_descriptions = {name: desc for name, _, desc 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
# 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')
# 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')
dirname = os.path.dirname(os.path.realpath(__file__))
filename = os.path.join(dirname, 'dummy_macros.cfg')
try:
dummy_macros_cfg = configfile.read_config(filename)
dummy_macros_cfg = pconfig.read_config(filename)
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 '):
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]
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)
# Add the section to the Klipper configuration object with all its options
if not self._config.fileconfig.has_section(gcode_macro_name.lower()):
self._config.fileconfig.add_section(gcode_macro_name.lower())
if not config.fileconfig.has_section(gcode_macro_name.lower()):
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)
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
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
self._printer.load_object(self._config, gcode_macro_name.lower())
# Register the motor resonance filters if they are defined in the config
# DangerKlipper is required for 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
# ------------------------------------------------------------------------------------------
# ------------------------------------------------------------------------------------------
self._printer.load_object(config, gcode_macro_name.lower())
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._st_config)
static_freq_graph_creator = StaticGraphCreator(self._config)
st_process = ShakeTuneProcess(
self._st_config,
self._config,
self._printer.get_reactor(),
static_freq_graph_creator,
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:
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(
self._st_config,
self._config,
self._printer.get_reactor(),
axes_map_graph_creator,
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:
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(
self._st_config,
self._config,
self._printer.get_reactor(),
belt_graph_creator,
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:
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(
self._st_config,
self._config,
self._printer.get_reactor(),
shaper_graph_creator,
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:
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(
self._st_config,
self._config,
self._printer.get_reactor(),
vibration_profile_creator,
self.timeout,
)
create_vibrations_profile(gcmd, self._config, st_process)
def cmd_MOTOR_RESONANCE_FILTER(self, gcmd) -> None:
enable = gcmd.get_int('ENABLE', default=1, minval=0, maxval=1)
if enable:
self.motor_resonance_filter.apply_filters()
else:
self.motor_resonance_filter.remove_filters()
ConsoleOutput.print(f'Motor resonance filter {"enabled" if enable else "disabled"}.')
create_vibrations_profile(gcmd, self._pconfig, st_process)