Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8d59e33775 | ||
|
|
3d919898a6 | ||
|
|
c19af1c457 | ||
|
|
e3e24184be | ||
|
|
a49a571911 |
69
.github/workflows/test.yml
vendored
69
.github/workflows/test.yml
vendored
@@ -1,69 +0,0 @@
|
||||
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
|
||||
|
||||
|
||||
23
README.md
23
README.md
@@ -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://cloud.reijii.org/gitea/reijii/klippain-shaketune-telegramm/raw/branch/main/install.sh | bash
|
||||
wget -O - https://raw.githubusercontent.com/Frix-x/klippain-shaketune/main/install.sh | bash
|
||||
```
|
||||
1. Then, append the following to your `printer.cfg` file and restart Klipper:
|
||||
```
|
||||
@@ -31,6 +31,27 @@ Follow these steps to install Shake&Tune on your printer:
|
||||
# printer.cfg file. If you want to see the macros in the webui, set this to True.
|
||||
# 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)**.
|
||||
|
||||
@@ -1,34 +0,0 @@
|
||||
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
|
||||
@@ -1,9 +0,0 @@
|
||||
[mcu]
|
||||
serial: /tmp/klipper_host_mcu
|
||||
|
||||
[printer]
|
||||
kinematics: none
|
||||
max_velocity: 300
|
||||
max_accel: 300
|
||||
|
||||
[shaketune]
|
||||
@@ -1,4 +0,0 @@
|
||||
DICTIONARY linux_basic.dict
|
||||
CONFIG simple.cfg
|
||||
|
||||
G4 P1000
|
||||
@@ -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 belts 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 bands are well tuned and behave similarly.
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
### Estimated similarity and mechanical issues indicator
|
||||
|
||||
|
||||
@@ -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://cloud.reijii.org/gitea/reijii/klippain-shaketune-telegramm.git $shaketunebasename; then
|
||||
if git -C $shaketunedirname clone https://github.com/Frix-x/klippain-shaketune.git $shaketunebasename; then
|
||||
chmod +x ${K_SHAKETUNE_PATH}/install.sh
|
||||
printf "[DOWNLOAD] Download complete!\n\n"
|
||||
else
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
|
||||
## Klippain Shake&Tune automatic update management
|
||||
[update_manager Klippain-ShakeTune]
|
||||
type: git_repo
|
||||
origin: https://cloud.reijii.org/gitea/reijii/klippain-shaketune-telegramm.git
|
||||
origin: https://github.com/Frix-x/klippain-shaketune.git
|
||||
path: ~/klippain_shaketune
|
||||
virtualenv: ~/klippy-env
|
||||
requirements: requirements.txt
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[project]
|
||||
name = "shake_n_tune"
|
||||
name = "Shake&Tune"
|
||||
description = "Klipper streamlined input shaper workflow and calibration tools"
|
||||
readme = "README.md"
|
||||
requires-python = ">= 3.9"
|
||||
|
||||
@@ -19,7 +19,6 @@ import matplotlib.font_manager
|
||||
import matplotlib.pyplot as plt
|
||||
import matplotlib.ticker
|
||||
import numpy as np
|
||||
from scipy.stats import pearsonr
|
||||
|
||||
matplotlib.use('Agg')
|
||||
|
||||
@@ -211,8 +210,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['orange'])
|
||||
ax.plot(signal2.freqs, signal2.psd, label='Belt ' + signal2_belt, color=KLIPPAIN_COLORS['purple'])
|
||||
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'])
|
||||
|
||||
psd_highest_max = max(signal1.psd.max(), signal2.psd.max())
|
||||
|
||||
@@ -344,12 +343,14 @@ 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(signal1.psd), np.max(signal2.psd))
|
||||
max_psd = max(np.max(interp_psd1), np.max(interp_psd2))
|
||||
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)
|
||||
@@ -363,8 +364,8 @@ def plot_versus_belts(
|
||||
linewidth=2,
|
||||
)
|
||||
|
||||
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)
|
||||
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)
|
||||
|
||||
paired_peak_count = 0
|
||||
unpaired_peak_count = 0
|
||||
@@ -373,27 +374,31 @@ 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 abs(freq1 - freq2) < 1:
|
||||
ax.plot(signal1.psd[peak1[0]], signal2.psd[peak2[0]], marker='o', color='black', markersize=7)
|
||||
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)
|
||||
ax.annotate(
|
||||
f'{label}1/{label}2',
|
||||
(signal1.psd[peak1[0]], signal2.psd[peak2[0]]),
|
||||
(psd1_peak_value, psd2_peak_value),
|
||||
textcoords='offset points',
|
||||
xytext=(-7, 7),
|
||||
fontsize=13,
|
||||
color='black',
|
||||
)
|
||||
else:
|
||||
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
|
||||
)
|
||||
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.annotate(
|
||||
f'{label}1',
|
||||
(signal1.psd[peak1[0]], signal2.psd[peak1[0]]),
|
||||
(psd1_peak_value, psd2_on_peak),
|
||||
textcoords='offset points',
|
||||
xytext=(0, 7),
|
||||
fontsize=13,
|
||||
@@ -401,7 +406,7 @@ def plot_versus_belts(
|
||||
)
|
||||
ax.annotate(
|
||||
f'{label}2',
|
||||
(signal1.psd[peak2[0]], signal2.psd[peak2[0]]),
|
||||
(psd1_on_peak, psd2_peak_value),
|
||||
textcoords='offset points',
|
||||
xytext=(0, 7),
|
||||
fontsize=13,
|
||||
@@ -410,12 +415,16 @@ def plot_versus_belts(
|
||||
paired_peak_count += 1
|
||||
|
||||
for _, peak_index in enumerate(signal1.unpaired_peaks):
|
||||
ax.plot(
|
||||
signal1.psd[peak_index], signal2.psd[peak_index], marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7
|
||||
)
|
||||
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.annotate(
|
||||
str(unpaired_peak_count + 1),
|
||||
(signal1.psd[peak_index], signal2.psd[peak_index]),
|
||||
(psd1_peak_value, psd2_peak_value),
|
||||
textcoords='offset points',
|
||||
fontsize=13,
|
||||
weight='bold',
|
||||
@@ -425,12 +434,16 @@ def plot_versus_belts(
|
||||
unpaired_peak_count += 1
|
||||
|
||||
for _, peak_index in enumerate(signal2.unpaired_peaks):
|
||||
ax.plot(
|
||||
signal1.psd[peak_index], signal2.psd[peak_index], marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7
|
||||
)
|
||||
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.annotate(
|
||||
str(unpaired_peak_count + 1),
|
||||
(signal1.psd[peak_index], signal2.psd[peak_index]),
|
||||
(psd1_peak_value, psd2_peak_value),
|
||||
textcoords='offset points',
|
||||
fontsize=13,
|
||||
weight='bold',
|
||||
@@ -463,21 +476,16 @@ def plot_versus_belts(
|
||||
|
||||
|
||||
# Original Klipper function to get the PSD data of a raw accelerometer signal
|
||||
def compute_signal_data(data: np.ndarray, common_freqs: np.ndarray, max_freq: float) -> SignalData:
|
||||
def compute_signal_data(data: 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]
|
||||
|
||||
# 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)
|
||||
_, peaks, _ = detect_peaks(psd, freqs, PEAKS_DETECTION_THRESHOLD * psd.max())
|
||||
|
||||
_, 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)
|
||||
return SignalData(freqs=freqs, psd=psd, peaks=peaks)
|
||||
|
||||
|
||||
######################################################################
|
||||
@@ -509,9 +517,8 @@ def belts_calibration(
|
||||
signal2_belt += belt_info.get(signal2_belt, '')
|
||||
|
||||
# Compute calibration data for the two datasets with automatic peaks detection
|
||||
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)
|
||||
signal1 = compute_signal_data(datas[0], max_freq)
|
||||
signal2 = compute_signal_data(datas[1], max_freq)
|
||||
del datas
|
||||
|
||||
# Pair the peaks across the two datasets
|
||||
@@ -519,13 +526,18 @@ 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)
|
||||
|
||||
# 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)
|
||||
# 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
|
||||
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}')
|
||||
|
||||
@@ -570,11 +582,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.551, 0.915, title_line5, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'])
|
||||
fig.text(0.55, 0.915, title_line5, ha='left', va='top', fontsize=14, 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, signal1_belt, signal2_belt)
|
||||
plot_versus_belts(ax3, common_freqs, signal1, signal2, interp_psd1, interp_psd2, 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')
|
||||
|
||||
@@ -39,6 +39,7 @@ 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
|
||||
@@ -114,46 +115,49 @@ def calc_freq_response(data) -> Tuple[np.ndarray, np.ndarray]:
|
||||
return helper.process_accelerometer_data(data)
|
||||
|
||||
|
||||
# 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]
|
||||
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)
|
||||
|
||||
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 angles
|
||||
# Creating the PSD motor profiles for each angle by summing the PSDs for each speed
|
||||
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')
|
||||
|
||||
# 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
|
||||
return motor_profiles
|
||||
|
||||
|
||||
# Since it was discovered that there is no non-linear mixing in the stepper "steps" vibrations, instead of measuring
|
||||
@@ -161,11 +165,11 @@ def compute_motor_profiles(
|
||||
# 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]] = None
|
||||
measured_speeds: List[float],
|
||||
data: dict,
|
||||
kinematics: str = 'cartesian',
|
||||
measured_angles: Optional[List[int]] = (0, 90),
|
||||
) -> 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)
|
||||
@@ -293,11 +297,8 @@ 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]] = None
|
||||
all_angles: np.ndarray, spectrogram_data: np.ndarray, measured_angles: Optional[List[int]] = (0, 90)
|
||||
) -> float:
|
||||
if measured_angles is None:
|
||||
measured_angles = [0, 90]
|
||||
|
||||
total_spectrogram_angles = len(all_angles)
|
||||
half_spectrogram_angles = total_spectrogram_angles // 2
|
||||
|
||||
@@ -501,75 +502,40 @@ def plot_angular_speed_profiles(
|
||||
|
||||
|
||||
def plot_motor_profiles(
|
||||
ax: plt.Axes,
|
||||
freqs: np.ndarray,
|
||||
main_angles: List[int],
|
||||
motor_profiles: dict,
|
||||
global_motor_profile: np.ndarray,
|
||||
max_freq: float,
|
||||
ax: plt.Axes, freqs: np.ndarray, main_angles: List[int], motor_profiles: dict, max_freq: float
|
||||
) -> None:
|
||||
ax.set_title('Motor frequency profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
|
||||
ax.set_title('Motors frequency profiles', 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
|
||||
# And then plot the motor profiles at each measured angles with their characteristics
|
||||
max_value = 0
|
||||
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], linestyle='--', label=label, zorder=2)
|
||||
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.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')
|
||||
@@ -649,7 +615,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.12
|
||||
distance = 0.15
|
||||
if motors[0].get_config('autotune_enabled'):
|
||||
distance = 0.27
|
||||
config_blocks = [
|
||||
@@ -732,9 +698,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!')
|
||||
|
||||
@@ -775,7 +741,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, global_motor_profile = compute_motor_profiles(target_freqs, psds, all_angles_energy, main_angles)
|
||||
motor_profiles = compute_motor_profiles(target_freqs, psds, main_angles)
|
||||
|
||||
# symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy)
|
||||
symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles)
|
||||
@@ -884,7 +850,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, global_motor_profile, max_freq)
|
||||
plot_motor_profiles(ax6, target_freqs, main_angles, motor_profiles, 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')
|
||||
|
||||
142
shaketune/motor_res_filter.py
Normal file
142
shaketune/motor_res_filter.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# 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()
|
||||
@@ -8,6 +8,7 @@
|
||||
# loading of the plugin, and the registration of the tuning commands
|
||||
|
||||
|
||||
import importlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
@@ -26,159 +27,234 @@ 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
|
||||
|
||||
IN_DANGER = False
|
||||
DEFAULT_MOTOR_DAMPING_RATIO = 0.05
|
||||
ST_COMMANDS = {
|
||||
'EXCITATE_AXIS_AT_FREQ': (
|
||||
'Maintain a specified excitation frequency for a period '
|
||||
'of time to diagnose and locate a source of vibrations'
|
||||
),
|
||||
'AXES_MAP_CALIBRATION': (
|
||||
'Perform a set of movements to measure the orientation of the accelerometer '
|
||||
'and help you set the best axes_map configuration for your printer'
|
||||
),
|
||||
'COMPARE_BELTS_RESPONSES': (
|
||||
'Perform a custom half-axis test to analyze and compare the '
|
||||
'frequency profiles of individual belts on CoreXY or CoreXZ printers'
|
||||
),
|
||||
'AXES_SHAPER_CALIBRATION': 'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter',
|
||||
'CREATE_VIBRATIONS_PROFILE': (
|
||||
'Run a series of motions to find speed/angle ranges where the printer could be '
|
||||
'exposed to VFAs to optimize your slicer speed profiles and TMC driver parameters'
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
class ShakeTune:
|
||||
def __init__(self, config) -> None:
|
||||
self._pconfig = config
|
||||
self._config = config
|
||||
self._printer = config.get_printer()
|
||||
self._printer.register_event_handler('klippy:connect', self._on_klippy_connect)
|
||||
|
||||
# Check if Shake&Tune is running in DangerKlipper
|
||||
self.IN_DANGER = importlib.util.find_spec('extras.danger_options') is not None
|
||||
|
||||
# Register the console print output callback to the corresponding Klipper function
|
||||
gcode = self._printer.lookup_object('gcode')
|
||||
ConsoleOutput.register_output_callback(gcode.respond_info)
|
||||
|
||||
res_tester = self._printer.lookup_object('resonance_tester', None)
|
||||
if res_tester is None:
|
||||
config.error('No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune.')
|
||||
self._initialize_config(config)
|
||||
self._register_commands()
|
||||
self._initialize_motor_resonance_filter()
|
||||
|
||||
self.timeout = config.getfloat('timeout', 300, above=0.0)
|
||||
# Initialize the ShakeTune object and its configuration
|
||||
def _initialize_config(self, config) -> None:
|
||||
result_folder = config.get('result_folder', default='~/printer_data/config/ShakeTune_results')
|
||||
result_folder_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._config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi)
|
||||
ConsoleOutput.register_output_callback(gcode.respond_info)
|
||||
self.timeout = config.getfloat('timeout', 300, above=0.0)
|
||||
self._show_macros = config.getboolean('show_macros_in_webui', default=True)
|
||||
|
||||
# Register Shake&Tune's measurement commands
|
||||
motor_freq = config.getfloat('motor_freq', None, minval=0.0)
|
||||
self._motor_freq_x = config.getfloat('motor_freq_x', motor_freq, minval=0.0)
|
||||
self._motor_freq_y = config.getfloat('motor_freq_y', motor_freq, minval=0.0)
|
||||
motor_damping = config.getfloat('motor_damping_ratio', DEFAULT_MOTOR_DAMPING_RATIO, minval=0.0)
|
||||
self._motor_damping_x = config.getfloat('motor_damping_ratio_x', motor_damping, minval=0.0)
|
||||
self._motor_damping_y = config.getfloat('motor_damping_ratio_y', motor_damping, minval=0.0)
|
||||
|
||||
# Create the Klipper commands to allow the user to run Shake&Tune's tools
|
||||
def _register_commands(self) -> None:
|
||||
gcode = self._printer.lookup_object('gcode')
|
||||
measurement_commands = [
|
||||
(
|
||||
'EXCITATE_AXIS_AT_FREQ',
|
||||
self.cmd_EXCITATE_AXIS_AT_FREQ,
|
||||
(
|
||||
'Maintain a specified excitation frequency for a period '
|
||||
'of time to diagnose and locate a source of vibrations'
|
||||
),
|
||||
),
|
||||
(
|
||||
'AXES_MAP_CALIBRATION',
|
||||
self.cmd_AXES_MAP_CALIBRATION,
|
||||
(
|
||||
'Perform a set of movements to measure the orientation of the accelerometer '
|
||||
'and help you set the best axes_map configuration for your printer'
|
||||
),
|
||||
),
|
||||
(
|
||||
'COMPARE_BELTS_RESPONSES',
|
||||
self.cmd_COMPARE_BELTS_RESPONSES,
|
||||
(
|
||||
'Perform a custom half-axis test to analyze and compare the '
|
||||
'frequency profiles of individual belts on CoreXY or CoreXZ printers'
|
||||
),
|
||||
),
|
||||
(
|
||||
'AXES_SHAPER_CALIBRATION',
|
||||
self.cmd_AXES_SHAPER_CALIBRATION,
|
||||
'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter',
|
||||
),
|
||||
(
|
||||
'CREATE_VIBRATIONS_PROFILE',
|
||||
self.cmd_CREATE_VIBRATIONS_PROFILE,
|
||||
(
|
||||
'Run a series of motions to find speed/angle ranges where the printer could be '
|
||||
'exposed to VFAs to optimize your slicer speed profiles and TMC driver parameters'
|
||||
),
|
||||
),
|
||||
('EXCITATE_AXIS_AT_FREQ', self.cmd_EXCITATE_AXIS_AT_FREQ, ST_COMMANDS['EXCITATE_AXIS_AT_FREQ']),
|
||||
('AXES_MAP_CALIBRATION', self.cmd_AXES_MAP_CALIBRATION, ST_COMMANDS['AXES_MAP_CALIBRATION']),
|
||||
('COMPARE_BELTS_RESPONSES', self.cmd_COMPARE_BELTS_RESPONSES, ST_COMMANDS['COMPARE_BELTS_RESPONSES']),
|
||||
('AXES_SHAPER_CALIBRATION', self.cmd_AXES_SHAPER_CALIBRATION, ST_COMMANDS['AXES_SHAPER_CALIBRATION']),
|
||||
('CREATE_VIBRATIONS_PROFILE', self.cmd_CREATE_VIBRATIONS_PROFILE, ST_COMMANDS['CREATE_VIBRATIONS_PROFILE']),
|
||||
]
|
||||
command_descriptions = {name: desc for name, _, desc in measurement_commands}
|
||||
for name, command, description in measurement_commands:
|
||||
gcode.register_command(f'_{name}' if show_macros else name, command, desc=description)
|
||||
|
||||
# Load the dummy macros with their description in order to show them in the web interfaces
|
||||
if show_macros:
|
||||
pconfig = self._printer.lookup_object('configfile')
|
||||
# Register Shake&Tune's measurement commands using the official Klipper API (gcode.register_command)
|
||||
# Doing this makes the commands available in Klipper but they are not shown in the web interfaces
|
||||
# and are only available by typing the full name in the console (like all the other Klipper commands)
|
||||
for name, command, description in measurement_commands:
|
||||
gcode.register_command(f'_{name}' if self._show_macros else name, command, desc=description)
|
||||
|
||||
# Then, a hack to inject the macros into Klipper's config system in order to show them in the web
|
||||
# interfaces. This is not a good way to do it, but it's the only way to do it for now to get
|
||||
# a good user experience while using Shake&Tune (it's indeed easier to just click a macro button)
|
||||
if self._show_macros:
|
||||
configfile = self._printer.lookup_object('configfile')
|
||||
dirname = os.path.dirname(os.path.realpath(__file__))
|
||||
filename = os.path.join(dirname, 'dummy_macros.cfg')
|
||||
try:
|
||||
dummy_macros_cfg = pconfig.read_config(filename)
|
||||
dummy_macros_cfg = configfile.read_config(filename)
|
||||
except Exception as err:
|
||||
raise config.error(f'Cannot load Shake&Tune dummy macro {filename}') from err
|
||||
raise self._config.error(f'Cannot load Shake&Tune dummy macro {filename}') from err
|
||||
|
||||
for gcode_macro in dummy_macros_cfg.get_prefix_sections('gcode_macro '):
|
||||
gcode_macro_name = gcode_macro.get_name()
|
||||
|
||||
# Replace the dummy description by the one here (to avoid code duplication and define it in only one place)
|
||||
# Replace the dummy description by the one from ST_COMMANDS (to avoid code duplication and define it in only one place)
|
||||
command = gcode_macro_name.split(' ', 1)[1]
|
||||
description = command_descriptions.get(command, 'Shake&Tune macro')
|
||||
description = ST_COMMANDS.get(command, 'Shake&Tune macro')
|
||||
gcode_macro.fileconfig.set(gcode_macro_name, 'description', description)
|
||||
|
||||
# Add the section to the Klipper configuration object with all its options
|
||||
if not config.fileconfig.has_section(gcode_macro_name.lower()):
|
||||
config.fileconfig.add_section(gcode_macro_name.lower())
|
||||
if not self._config.fileconfig.has_section(gcode_macro_name.lower()):
|
||||
self._config.fileconfig.add_section(gcode_macro_name.lower())
|
||||
for option in gcode_macro.fileconfig.options(gcode_macro_name):
|
||||
value = gcode_macro.fileconfig.get(gcode_macro_name, option)
|
||||
config.fileconfig.set(gcode_macro_name.lower(), option, value)
|
||||
|
||||
self._config.fileconfig.set(gcode_macro_name.lower(), option, value)
|
||||
# Small trick to ensure the new injected sections are considered valid by Klipper config system
|
||||
config.access_tracking[(gcode_macro_name.lower(), option.lower())] = 1
|
||||
self._config.access_tracking[(gcode_macro_name.lower(), option.lower())] = 1
|
||||
|
||||
# Finally, load the section within the printer objects
|
||||
self._printer.load_object(config, gcode_macro_name.lower())
|
||||
self._printer.load_object(self._config, gcode_macro_name.lower())
|
||||
|
||||
# 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:
|
||||
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
|
||||
static_freq_graph_creator = StaticGraphCreator(self._config)
|
||||
static_freq_graph_creator = StaticGraphCreator(self._st_config)
|
||||
st_process = ShakeTuneProcess(
|
||||
self._config,
|
||||
self._st_config,
|
||||
self._printer.get_reactor(),
|
||||
static_freq_graph_creator,
|
||||
self.timeout,
|
||||
)
|
||||
excitate_axis_at_freq(gcmd, self._pconfig, st_process)
|
||||
excitate_axis_at_freq(gcmd, self._config, st_process)
|
||||
|
||||
def cmd_AXES_MAP_CALIBRATION(self, gcmd) -> None:
|
||||
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
|
||||
axes_map_graph_creator = AxesMapGraphCreator(self._config)
|
||||
axes_map_graph_creator = AxesMapGraphCreator(self._st_config)
|
||||
st_process = ShakeTuneProcess(
|
||||
self._config,
|
||||
self._st_config,
|
||||
self._printer.get_reactor(),
|
||||
axes_map_graph_creator,
|
||||
self.timeout,
|
||||
)
|
||||
axes_map_calibration(gcmd, self._pconfig, st_process)
|
||||
axes_map_calibration(gcmd, self._config, st_process)
|
||||
|
||||
def cmd_COMPARE_BELTS_RESPONSES(self, gcmd) -> None:
|
||||
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
|
||||
belt_graph_creator = BeltsGraphCreator(self._config)
|
||||
belt_graph_creator = BeltsGraphCreator(self._st_config)
|
||||
st_process = ShakeTuneProcess(
|
||||
self._config,
|
||||
self._st_config,
|
||||
self._printer.get_reactor(),
|
||||
belt_graph_creator,
|
||||
self.timeout,
|
||||
)
|
||||
compare_belts_responses(gcmd, self._pconfig, st_process)
|
||||
compare_belts_responses(gcmd, self._config, st_process)
|
||||
|
||||
def cmd_AXES_SHAPER_CALIBRATION(self, gcmd) -> None:
|
||||
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
|
||||
shaper_graph_creator = ShaperGraphCreator(self._config)
|
||||
shaper_graph_creator = ShaperGraphCreator(self._st_config)
|
||||
st_process = ShakeTuneProcess(
|
||||
self._config,
|
||||
self._st_config,
|
||||
self._printer.get_reactor(),
|
||||
shaper_graph_creator,
|
||||
self.timeout,
|
||||
)
|
||||
axes_shaper_calibration(gcmd, self._pconfig, st_process)
|
||||
axes_shaper_calibration(gcmd, self._config, st_process)
|
||||
|
||||
def cmd_CREATE_VIBRATIONS_PROFILE(self, gcmd) -> None:
|
||||
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
|
||||
vibration_profile_creator = VibrationsGraphCreator(self._config)
|
||||
vibration_profile_creator = VibrationsGraphCreator(self._st_config)
|
||||
st_process = ShakeTuneProcess(
|
||||
self._config,
|
||||
self._st_config,
|
||||
self._printer.get_reactor(),
|
||||
vibration_profile_creator,
|
||||
self.timeout,
|
||||
)
|
||||
create_vibrations_profile(gcmd, self._pconfig, st_process)
|
||||
create_vibrations_profile(gcmd, self._config, st_process)
|
||||
|
||||
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"}.')
|
||||
|
||||
Reference in New Issue
Block a user