18 Commits

Author SHA1 Message Date
Félix Boisselier
0c951c57f4 updated CI smoke tests 2024-07-18 10:44:47 +02:00
Félix Boisselier
9798e5ae19 switched to accel vs vibrations with a zoomed plot for details 2024-07-17 00:35:51 +02:00
Félix Boisselier
e364b9079e smoothing vs accel plot added 2024-07-15 18:04:49 +02:00
Félix Boisselier
ccd95e27e1 refactor module init to better handle Klipper init errors 2024-07-13 11:04:10 +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
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
Félix Boisselier
37d0e39d84 updated commands descriptions 2024-06-20 21:36:14 +02:00
Félix Boisselier
50ed13ca59 using Klipper reactor for file write process handling 2024-06-20 11:59:19 +02:00
delisjr
90ed7aca3c Compatibility with Klipper < v0.12.0-239 (#129) 2024-06-19 09:59:54 +02:00
Félix Boisselier
69ad228356 small documentation update about default parameters 2024-06-17 19:53:13 +02:00
Zeanon
b98d103a26 Make frequencies default on [resonance_tester] settings (#119) 2024-06-17 19:49:37 +02:00
Félix Boisselier
a9c7a8491b fix random Timer too close or Move queue overflow errors (#123) 2024-06-17 19:45:20 +02:00
Félix Boisselier
fb8e1ce98f avoid returning wrong axes_map if it wasn't determined correctly 2024-06-16 18:43:16 +02:00
Félix Boisselier
8b0862a96a fixed axis frequency scale on belt graph 2024-06-13 14:21:04 +02:00
22 changed files with 918 additions and 342 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 gcc-avr avr-libc
- 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/atmega2560.dict
../klippy-env/bin/python scripts/test_klippy.py -d ../dicts ../shaketune/ci/smoke-test/klippy-tests/simple.test
lint:
runs-on: ubuntu-latest
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

@@ -0,0 +1,4 @@
# Base Kconfig file for atmega2560
CONFIG_MACH_AVR=y
CONFIG_MACH_atmega2560=y
CONFIG_CLOCK_FREQ=16000000

View File

@@ -0,0 +1,85 @@
# Test config with a minimal setup to have kind
# of a machine ready with an ADXL345 and an MPU9250
# to have the required the resonance_tester section
# and allow loading and initializing Shake&Tune into Klipper
[stepper_x]
step_pin: PF0
dir_pin: PF1
enable_pin: !PD7
microsteps: 16
rotation_distance: 40
endstop_pin: ^PE5
position_endstop: 0
position_max: 200
homing_speed: 50
[stepper_y]
step_pin: PF6
dir_pin: !PF7
enable_pin: !PF2
microsteps: 16
rotation_distance: 40
endstop_pin: ^PJ1
position_endstop: 0
position_max: 200
homing_speed: 50
[stepper_z]
step_pin: PL3
dir_pin: PL1
enable_pin: !PK0
microsteps: 16
rotation_distance: 8
endstop_pin: ^PD3
position_endstop: 0.5
position_max: 200
[extruder]
step_pin: PA4
dir_pin: PA6
enable_pin: !PA2
microsteps: 16
rotation_distance: 33.5
nozzle_diameter: 0.500
filament_diameter: 3.500
heater_pin: PB4
sensor_type: EPCOS 100K B57560G104F
sensor_pin: PK5
control: pid
pid_Kp: 22.2
pid_Ki: 1.08
pid_Kd: 114
min_temp: 0
max_temp: 210
[heater_bed]
heater_pin: PH5
sensor_type: EPCOS 100K B57560G104F
sensor_pin: PK6
control: watermark
min_temp: 0
max_temp: 110
[mcu]
serial: /dev/ttyACM0
[printer]
kinematics: cartesian
max_velocity: 300
max_accel: 3000
max_z_velocity: 5
max_z_accel: 100
[adxl345]
cs_pin: PK7
axes_map: -x,-y,z
[mpu9250 my_mpu]
[resonance_tester]
probe_points: 20,20,20
accel_chip_x: adxl345
accel_chip_y: mpu9250 my_mpu
[shaketune]

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 501 KiB

After

Width:  |  Height:  |  Size: 490 KiB

View File

@@ -36,9 +36,9 @@ axes_map: -z,y,x
This plot shows the acceleration data over time for the X, Y, and Z axes after removing the gravity offset. Look for patterns in the acceleration data for each axis: you should have exactly 2 spikes for each subplot (for the start and stop of the motion) that break away from the global noise. This can help identify any anomalies or inconsistencies in your accelerometer behavior. This plot shows the acceleration data over time for the X, Y, and Z axes after removing the gravity offset. Look for patterns in the acceleration data for each axis: you should have exactly 2 spikes for each subplot (for the start and stop of the motion) that break away from the global noise. This can help identify any anomalies or inconsistencies in your accelerometer behavior.
The detected gravity offset is printed in the legend to give some context to the readings and their scale: if it's too far from the standard 9.8-10 m/s², this means that your accelerometer is not working properly and should be fixed or calibrated. The dynamic noise and background vibrations measured by the accelerometer are extracted from the signal (using wavelet transform decomposition) and printed in the legend. **Usually values below about 500mm/s² are ok**, but Shake&Tune will automatically add a note if too much noise is recorded. **Be careful because this value is very different from Klipper's `MEASURE_AXES_NOISE` command, as Shake&Tune measures everything during the motion**, such as accelerometer noise, but also vibrations and motor noise, axis and toolhead oscillations, etc. If you want to record your axes_map correctly, you may need to use about 10 times this value in the `ACCEL` parameter to get a good signal-to-noise ratio and allow Shake&Tune to correctly detect the toolhead acceleration and deceleration phases.
The average noise in the accelerometer measurement is calculated (using wavelet transform decomposition) and displayed at the top of the image. Usually values <500mm/s² are ok, but a note is automatically added by Shake&Tune in case your accelerometer has too much noise. The detected gravity offset is printed in the legend to give some context to the readings and their scale: if it's too far from the standard 9.8-10 m/s², this means that your accelerometer is not working properly and should be fixed or calibrated.
### Estimated 3D movement path ### Estimated 3D movement path

View File

@@ -11,10 +11,10 @@ Then, call the `AXES_SHAPER_CALIBRATION` macro and look for the graphs in the re
| parameters | default value | description | | parameters | default value | description |
|-----------:|---------------|-------------| |-----------:|---------------|-------------|
|FREQ_START|5|starting excitation frequency| |FREQ_START|None (default to `[resonance_tester]` value)|starting excitation frequency|
|FREQ_END|133|maximum excitation frequency| |FREQ_END|None (default to `[resonance_tester]` value)|maximum excitation frequency|
|HZ_PER_SEC|1|number of Hz per seconds for the test| |HZ_PER_SEC|1|number of Hz per seconds for the test|
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| |ACCEL_PER_HZ|None (default to `[resonance_tester]` value)|accel per Hz value used for the test|
|AXIS|"all"|axis you want to test in the list of "all", "X" or "Y"| |AXIS|"all"|axis you want to test in the list of "all", "X" or "Y"|
|SCV|printer square corner velocity|square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations| |SCV|printer square corner velocity|square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations|
|MAX_SMOOTHING|None|max smoothing allowed when calculating shaper recommendations| |MAX_SMOOTHING|None|max smoothing allowed when calculating shaper recommendations|

View File

@@ -15,10 +15,10 @@ Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the re
| parameters | default value | description | | parameters | default value | description |
|-----------:|---------------|-------------| |-----------:|---------------|-------------|
|FREQ_START|5|starting excitation frequency| |FREQ_START|None (default to `[resonance_tester]` value)|starting excitation frequency|
|FREQ_END|133|maximum excitation frequency| |FREQ_END|None (default to `[resonance_tester]` value)|maximum excitation frequency|
|HZ_PER_SEC|1|number of Hz per seconds for the test| |HZ_PER_SEC|1|number of Hz per seconds for the test|
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| |ACCEL_PER_HZ|None (default to `[resonance_tester]` value)|accel per Hz value used for the test|
|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)| |TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)|
|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section| |Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section|

View File

@@ -12,7 +12,7 @@ Here are the parameters available:
|CREATE_GRAPH|0|whether or not to record the accelerometer data and create an associated graph during the excitation| |CREATE_GRAPH|0|whether or not to record the accelerometer data and create an associated graph during the excitation|
|FREQUENCY|25|excitation frequency (in Hz) that you want to maintain. Usually, it's the frequency of a peak on one of the graphs| |FREQUENCY|25|excitation frequency (in Hz) that you want to maintain. Usually, it's the frequency of a peak on one of the graphs|
|DURATION|30|duration in second to maintain this excitation| |DURATION|30|duration in second to maintain this excitation|
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| |ACCEL_PER_HZ|None (default to `[resonance_tester]` value)|accel per Hz value used for the test|
|AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"| |AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"|
|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)| |TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)|
|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section| |Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section|

View File

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

View File

@@ -9,15 +9,21 @@
# accelerometer measurements and write the data to a file in a blocking manner. # accelerometer measurements and write the data to a file in a blocking manner.
import os
import time import time
from multiprocessing import Process, Queue
# from ..helpers.console_output import ConsoleOutput FILE_WRITE_TIMEOUT = 10 # seconds
class Accelerometer: class Accelerometer:
def __init__(self, klipper_accelerometer): def __init__(self, reactor, klipper_accelerometer):
self._k_accelerometer = klipper_accelerometer self._k_accelerometer = klipper_accelerometer
self._reactor = reactor
self._bg_client = None self._bg_client = None
self._write_queue = Queue()
self._write_processes = []
@staticmethod @staticmethod
def find_axis_accelerometer(printer, axis: str = 'xy'): def find_axis_accelerometer(printer, axis: str = 'xy'):
@@ -32,7 +38,6 @@ class Accelerometer:
def start_measurement(self): def start_measurement(self):
if self._bg_client is None: if self._bg_client is None:
self._bg_client = self._k_accelerometer.start_internal_client() self._bg_client = self._k_accelerometer.start_internal_client()
# ConsoleOutput.print('Accelerometer measurements started')
else: else:
raise ValueError('measurements already started!') raise ValueError('measurements already started!')
@@ -54,12 +59,49 @@ class Accelerometer:
bg_client.finish_measurements() bg_client.finish_measurements()
filename = f'/tmp/shaketune-{name}.csv' filename = f'/tmp/shaketune-{name}.csv'
self._write_to_file(bg_client, filename) self._queue_file_write(bg_client, filename)
# ConsoleOutput.print(f'Accelerometer measurements stopped. Data written to {filename}')
def _queue_file_write(self, bg_client, filename):
self._write_queue.put(filename)
write_proc = Process(target=self._write_to_file, args=(bg_client, filename))
write_proc.daemon = True
write_proc.start()
self._write_processes.append(write_proc)
def _write_to_file(self, bg_client, filename): def _write_to_file(self, bg_client, filename):
try:
os.nice(20)
except Exception:
pass
with open(filename, 'w') as f: with open(filename, 'w') as f:
f.write('#time,accel_x,accel_y,accel_z\n') f.write('#time,accel_x,accel_y,accel_z\n')
samples = bg_client.samples or bg_client.get_samples() samples = bg_client.samples or bg_client.get_samples()
for t, accel_x, accel_y, accel_z in samples: for t, accel_x, accel_y, accel_z in samples:
f.write(f'{t:.6f},{accel_x:.6f},{accel_y:.6f},{accel_z:.6f}\n') f.write(f'{t:.6f},{accel_x:.6f},{accel_y:.6f},{accel_z:.6f}\n')
self._write_queue.get()
def wait_for_file_writes(self):
while not self._write_queue.empty():
eventtime = self._reactor.monotonic()
self._reactor.pause(eventtime + 0.1)
for proc in self._write_processes:
if proc is None:
continue
eventtime = self._reactor.monotonic()
endtime = eventtime + FILE_WRITE_TIMEOUT
complete = False
while eventtime < endtime:
eventtime = self._reactor.pause(eventtime + 0.05)
if not proc.is_alive():
complete = True
break
if not complete:
raise TimeoutError(
'Shake&Tune was not able to write the accelerometer data into the CSV file. '
'This might be due to a slow SD card or a busy or full filesystem.'
)
self._write_processes = []

View File

@@ -37,15 +37,21 @@ def axes_map_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
raise gcmd.error( raise gcmd.error(
f'The parameter axes_map is already set in your {accel_chip} configuration! Please remove it (or set it to "x,y,z")!' f'The parameter axes_map is already set in your {accel_chip} configuration! Please remove it (or set it to "x,y,z")!'
) )
accelerometer = Accelerometer(k_accelerometer) accelerometer = Accelerometer(printer.get_reactor(), k_accelerometer)
toolhead_info = toolhead.get_status(systime) toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel'] old_accel = toolhead_info['max_accel']
old_mcr = toolhead_info['minimum_cruise_ratio']
old_sqv = toolhead_info['square_corner_velocity'] old_sqv = toolhead_info['square_corner_velocity']
# set the wanted acceleration values # set the wanted acceleration values
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY=5.0') if 'minimum_cruise_ratio' in toolhead_info:
old_mcr = toolhead_info['minimum_cruise_ratio'] # minimum_cruise_ratio found: Klipper >= v0.12.0-239
gcode.run_script_from_command(
f'SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY=5.0'
)
else: # minimum_cruise_ratio not found: Klipper < v0.12.0-239
old_mcr = None
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={accel} SQUARE_CORNER_VELOCITY=5.0')
# Deactivate input shaper if it is active to get raw movements # Deactivate input shaper if it is active to get raw movements
input_shaper = printer.lookup_object('input_shaper', None) input_shaper = printer.lookup_object('input_shaper', None)
@@ -82,14 +88,20 @@ def axes_map_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
toolhead.dwell(0.5) toolhead.dwell(0.5)
accelerometer.stop_measurement('axesmap_Z', append_time=True) accelerometer.stop_measurement('axesmap_Z', append_time=True)
accelerometer.wait_for_file_writes()
# Re-enable the input shaper if it was active # Re-enable the input shaper if it was active
if input_shaper is not None: if input_shaper is not None:
input_shaper.enable_shaping() input_shaper.enable_shaping()
# Restore the previous acceleration values # Restore the previous acceleration values
gcode.run_script_from_command( if old_mcr is not None: # minimum_cruise_ratio found: Klipper >= v0.12.0-239
f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr} SQUARE_CORNER_VELOCITY={old_sqv}' gcode.run_script_from_command(
) f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr} SQUARE_CORNER_VELOCITY={old_sqv}'
)
else: # minimum_cruise_ratio not found: Klipper < v0.12.0-239
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} SQUARE_CORNER_VELOCITY={old_sqv}')
toolhead.wait_moves() toolhead.wait_moves()
# Run post-processing # Run post-processing

View File

@@ -17,14 +17,20 @@ from .accelerometer import Accelerometer
def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None: def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
min_freq = gcmd.get_float('FREQ_START', default=5, minval=1) printer = config.get_printer()
max_freq = gcmd.get_float('FREQ_END', default=133.33, minval=1) toolhead = printer.lookup_object('toolhead')
res_tester = printer.lookup_object('resonance_tester')
systime = printer.get_reactor().monotonic()
toolhead_info = toolhead.get_status(systime)
min_freq = gcmd.get_float('FREQ_START', default=res_tester.test.min_freq, minval=1)
max_freq = gcmd.get_float('FREQ_END', default=res_tester.test.max_freq, minval=1)
hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1, minval=1) hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1, minval=1)
accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None)
axis_input = gcmd.get('AXIS', default='all').lower() axis_input = gcmd.get('AXIS', default='all').lower()
if axis_input not in {'x', 'y', 'all'}: if axis_input not in {'x', 'y', 'all'}:
raise gcmd.error('AXIS selection invalid. Should be either x, y, or all!') raise gcmd.error('AXIS selection invalid. Should be either x, y, or all!')
scv = gcmd.get_float('SCV', default=None, minval=0) scv = gcmd.get_float('SCV', default=toolhead_info['square_corner_velocity'], minval=0)
max_sm = gcmd.get_float('MAX_SMOOTHING', default=None, minval=0) max_sm = gcmd.get_float('MAX_SMOOTHING', default=None, minval=0)
feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0)
z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1)
@@ -32,18 +38,11 @@ def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
if accel_per_hz == '': if accel_per_hz == '':
accel_per_hz = None accel_per_hz = None
printer = config.get_printer()
gcode = printer.lookup_object('gcode')
toolhead = printer.lookup_object('toolhead')
res_tester = printer.lookup_object('resonance_tester')
systime = printer.get_reactor().monotonic()
if scv is None:
toolhead_info = toolhead.get_status(systime)
scv = toolhead_info['square_corner_velocity']
if accel_per_hz is None: if accel_per_hz is None:
accel_per_hz = res_tester.test.accel_per_hz accel_per_hz = res_tester.test.accel_per_hz
gcode = printer.lookup_object('gcode')
max_accel = max_freq * accel_per_hz max_accel = max_freq * accel_per_hz
# Move to the starting point # Move to the starting point
@@ -77,8 +76,12 @@ def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
# set the needed acceleration values for the test # set the needed acceleration values for the test
toolhead_info = toolhead.get_status(systime) toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel'] old_accel = toolhead_info['max_accel']
old_mcr = toolhead_info['minimum_cruise_ratio'] if 'minimum_cruise_ratio' in toolhead_info: # minimum_cruise_ratio found: Klipper >= v0.12.0-239
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=0') old_mcr = toolhead_info['minimum_cruise_ratio']
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=0')
else: # minimum_cruise_ratio not found: Klipper < v0.12.0-239
old_mcr = None
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel}')
# Deactivate input shaper if it is active to get raw movements # Deactivate input shaper if it is active to get raw movements
input_shaper = printer.lookup_object('input_shaper', None) input_shaper = printer.lookup_object('input_shaper', None)
@@ -96,13 +99,15 @@ def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
accel_chip = Accelerometer.find_axis_accelerometer(printer, config['axis']) accel_chip = Accelerometer.find_axis_accelerometer(printer, config['axis'])
if accel_chip is None: if accel_chip is None:
raise gcmd.error('No suitable accelerometer found for measurement!') raise gcmd.error('No suitable accelerometer found for measurement!')
accelerometer = Accelerometer(printer.lookup_object(accel_chip)) accelerometer = Accelerometer(printer.get_reactor(), printer.lookup_object(accel_chip))
# Then do the actual measurements # Then do the actual measurements
accelerometer.start_measurement() accelerometer.start_measurement()
vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz) vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz)
accelerometer.stop_measurement(config['label'], append_time=True) accelerometer.stop_measurement(config['label'], append_time=True)
accelerometer.wait_for_file_writes()
# And finally generate the graph for each measured axis # And finally generate the graph for each measured axis
ConsoleOutput.print(f'{config["axis"].upper()} axis frequency profile generation...') ConsoleOutput.print(f'{config["axis"].upper()} axis frequency profile generation...')
ConsoleOutput.print('This may take some time (1-3min)') ConsoleOutput.print('This may take some time (1-3min)')
@@ -116,4 +121,7 @@ def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
input_shaper.enable_shaping() input_shaper.enable_shaping()
# Restore the previous acceleration values # Restore the previous acceleration values
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr}') if old_mcr is not None: # minimum_cruise_ratio found: Klipper >= v0.12.0-239
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr}')
else: # minimum_cruise_ratio not found: Klipper < v0.12.0-239
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel}')

View File

@@ -18,9 +18,14 @@ from .accelerometer import Accelerometer
def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None: def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None:
min_freq = gcmd.get_float('FREQ_START', default=5.0, minval=1) printer = config.get_printer()
max_freq = gcmd.get_float('FREQ_END', default=133.33, minval=1) toolhead = printer.lookup_object('toolhead')
hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1.0, minval=1) res_tester = printer.lookup_object('resonance_tester')
systime = printer.get_reactor().monotonic()
min_freq = gcmd.get_float('FREQ_START', default=res_tester.test.min_freq, minval=1)
max_freq = gcmd.get_float('FREQ_END', default=res_tester.test.max_freq, minval=1)
hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1, minval=1)
accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None)
feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0)
z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1)
@@ -28,14 +33,11 @@ def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None:
if accel_per_hz == '': if accel_per_hz == '':
accel_per_hz = None accel_per_hz = None
printer = config.get_printer()
gcode = printer.lookup_object('gcode')
toolhead = printer.lookup_object('toolhead')
res_tester = printer.lookup_object('resonance_tester')
systime = printer.get_reactor().monotonic()
if accel_per_hz is None: if accel_per_hz is None:
accel_per_hz = res_tester.test.accel_per_hz accel_per_hz = res_tester.test.accel_per_hz
gcode = printer.lookup_object('gcode')
max_accel = max_freq * accel_per_hz max_accel = max_freq * accel_per_hz
# Configure the graph creator # Configure the graph creator
@@ -58,7 +60,7 @@ def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None:
raise gcmd.error( raise gcmd.error(
'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.' 'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.'
) )
accelerometer = Accelerometer(printer.lookup_object(accel_chip)) accelerometer = Accelerometer(printer.get_reactor(), printer.lookup_object(accel_chip))
# Move to the starting point # Move to the starting point
test_points = res_tester.test.get_start_test_points() test_points = res_tester.test.get_start_test_points()
@@ -87,8 +89,12 @@ def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None:
# set the needed acceleration values for the test # set the needed acceleration values for the test
toolhead_info = toolhead.get_status(systime) toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel'] old_accel = toolhead_info['max_accel']
old_mcr = toolhead_info['minimum_cruise_ratio'] if 'minimum_cruise_ratio' in toolhead_info: # minimum_cruise_ratio found: Klipper >= v0.12.0-239
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=0') old_mcr = toolhead_info['minimum_cruise_ratio']
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=0')
else: # minimum_cruise_ratio not found: Klipper < v0.12.0-239
old_mcr = None
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel}')
# Deactivate input shaper if it is active to get raw movements # Deactivate input shaper if it is active to get raw movements
input_shaper = printer.lookup_object('input_shaper', None) input_shaper = printer.lookup_object('input_shaper', None)
@@ -103,12 +109,17 @@ def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None:
vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz) vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz)
accelerometer.stop_measurement(config['label'], append_time=True) accelerometer.stop_measurement(config['label'], append_time=True)
accelerometer.wait_for_file_writes()
# Re-enable the input shaper if it was active # Re-enable the input shaper if it was active
if input_shaper is not None: if input_shaper is not None:
input_shaper.enable_shaping() input_shaper.enable_shaping()
# Restore the previous acceleration values # Restore the previous acceleration values
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr}') if old_mcr is not None: # minimum_cruise_ratio found: Klipper >= v0.12.0-239
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr}')
else: # minimum_cruise_ratio not found: Klipper < v0.12.0-239
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel}')
# Run post-processing # Run post-processing
ConsoleOutput.print('Belts comparative frequency profile generation...') ConsoleOutput.print('Belts comparative frequency profile generation...')

View File

@@ -59,11 +59,17 @@ def create_vibrations_profile(gcmd, config, st_process: ShakeTuneProcess) -> Non
toolhead_info = toolhead.get_status(systime) toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel'] old_accel = toolhead_info['max_accel']
old_mcr = toolhead_info['minimum_cruise_ratio']
old_sqv = toolhead_info['square_corner_velocity'] old_sqv = toolhead_info['square_corner_velocity']
# set the wanted acceleration values # set the wanted acceleration values
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY=5.0') if 'minimum_cruise_ratio' in toolhead_info: # minimum_cruise_ratio found: Klipper >= v0.12.0-239
old_mcr = toolhead_info['minimum_cruise_ratio']
gcode.run_script_from_command(
f'SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY=5.0'
)
else: # minimum_cruise_ratio not found: Klipper < v0.12.0-239
old_mcr = None
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={accel} SQUARE_CORNER_VELOCITY=5.0')
kin_info = toolhead.kin.get_status(systime) kin_info = toolhead.kin.get_status(systime)
mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2 mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2
@@ -90,8 +96,8 @@ def create_vibrations_profile(gcmd, config, st_process: ShakeTuneProcess) -> Non
k_accelerometer = printer.lookup_object(current_accel_chip, None) k_accelerometer = printer.lookup_object(current_accel_chip, None)
if k_accelerometer is None: if k_accelerometer is None:
raise gcmd.error(f'Accelerometer [{current_accel_chip}] not found!') raise gcmd.error(f'Accelerometer [{current_accel_chip}] not found!')
accelerometer = Accelerometer(k_accelerometer)
ConsoleOutput.print(f'Accelerometer chip used for this angle: [{current_accel_chip}]') ConsoleOutput.print(f'Accelerometer chip used for this angle: [{current_accel_chip}]')
accelerometer = Accelerometer(printer.get_reactor(), k_accelerometer)
# Sweep the speed range to record the vibrations at different speeds # Sweep the speed range to record the vibrations at different speeds
for curr_speed_sample in range(nb_speed_samples): for curr_speed_sample in range(nb_speed_samples):
@@ -131,10 +137,15 @@ def create_vibrations_profile(gcmd, config, st_process: ShakeTuneProcess) -> Non
toolhead.dwell(0.3) toolhead.dwell(0.3)
toolhead.wait_moves() toolhead.wait_moves()
# Restore the previous acceleration values accelerometer.wait_for_file_writes()
gcode.run_script_from_command(
f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr} SQUARE_CORNER_VELOCITY={old_sqv}' # Restore the previous acceleration values
) if old_mcr is not None: # minimum_cruise_ratio found: Klipper >= v0.12.0-239
gcode.run_script_from_command(
f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr} SQUARE_CORNER_VELOCITY={old_sqv}'
)
else: # minimum_cruise_ratio not found: Klipper < v0.12.0-239
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} SQUARE_CORNER_VELOCITY={old_sqv}')
toolhead.wait_moves() toolhead.wait_moves()
# Run post-processing # Run post-processing

View File

@@ -41,7 +41,7 @@ def excitate_axis_at_freq(gcmd, config, st_process: ShakeTuneProcess) -> None:
k_accelerometer = printer.lookup_object(accel_chip, None) k_accelerometer = printer.lookup_object(accel_chip, None)
if k_accelerometer is None: if k_accelerometer is None:
raise gcmd.error(f'Accelerometer chip [{accel_chip}] was not found!') raise gcmd.error(f'Accelerometer chip [{accel_chip}] was not found!')
accelerometer = Accelerometer(k_accelerometer) accelerometer = Accelerometer(printer.get_reactor(), k_accelerometer)
ConsoleOutput.print(f'Excitating {axis.upper()} axis at {freq}Hz for {duration} seconds') ConsoleOutput.print(f'Excitating {axis.upper()} axis at {freq}Hz for {duration} seconds')
@@ -100,6 +100,7 @@ def excitate_axis_at_freq(gcmd, config, st_process: ShakeTuneProcess) -> None:
# If the user wanted to create a graph, we stop the recording and generate it # If the user wanted to create a graph, we stop the recording and generate it
if create_graph: if create_graph:
accelerometer.stop_measurement(f'staticfreq_{axis.upper()}', append_time=True) accelerometer.stop_measurement(f'staticfreq_{axis.upper()}', append_time=True)
accelerometer.wait_for_file_writes()
creator = st_process.get_graph_creator() creator = st_process.get_graph_creator()
creator.configure(freq, duration, accel_per_hz) creator.configure(freq, duration, accel_per_hz)

View File

@@ -45,15 +45,15 @@ gcode:
[gcode_macro COMPARE_BELTS_RESPONSES] [gcode_macro COMPARE_BELTS_RESPONSES]
description: dummy description: dummy
gcode: gcode:
{% set freq_start = params.FREQ_START|default(5) %} {% set freq_start = params.FREQ_START %}
{% set freq_end = params.FREQ_END|default(133.33) %} {% set freq_end = params.FREQ_END %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1) %} {% set hz_per_sec = params.HZ_PER_SEC|default(1) %}
{% set accel_per_hz = params.ACCEL_PER_HZ %} {% set accel_per_hz = params.ACCEL_PER_HZ %}
{% set travel_speed = params.TRAVEL_SPEED|default(120) %} {% set travel_speed = params.TRAVEL_SPEED|default(120) %}
{% set z_height = params.Z_HEIGHT %} {% set z_height = params.Z_HEIGHT %}
{% set params_filtered = { {% set params_filtered = {
"FREQ_START": freq_start, "FREQ_START": freq_start if freq_start is not none else '',
"FREQ_END": freq_end, "FREQ_END": freq_end if freq_end is not none else '',
"HZ_PER_SEC": hz_per_sec, "HZ_PER_SEC": hz_per_sec,
"ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '', "ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '',
"TRAVEL_SPEED": travel_speed, "TRAVEL_SPEED": travel_speed,
@@ -65,8 +65,8 @@ gcode:
[gcode_macro AXES_SHAPER_CALIBRATION] [gcode_macro AXES_SHAPER_CALIBRATION]
description: dummy description: dummy
gcode: gcode:
{% set freq_start = params.FREQ_START|default(5) %} {% set freq_start = params.FREQ_START %}
{% set freq_end = params.FREQ_END|default(133.33) %} {% set freq_end = params.FREQ_END %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1) %} {% set hz_per_sec = params.HZ_PER_SEC|default(1) %}
{% set accel_per_hz = params.ACCEL_PER_HZ %} {% set accel_per_hz = params.ACCEL_PER_HZ %}
{% set axis = params.AXIS|default('all') %} {% set axis = params.AXIS|default('all') %}
@@ -75,8 +75,8 @@ gcode:
{% set travel_speed = params.TRAVEL_SPEED|default(120) %} {% set travel_speed = params.TRAVEL_SPEED|default(120) %}
{% set z_height = params.Z_HEIGHT %} {% set z_height = params.Z_HEIGHT %}
{% set params_filtered = { {% set params_filtered = {
"FREQ_START": freq_start, "FREQ_START": freq_start if freq_start is not none else '',
"FREQ_END": freq_end, "FREQ_END": freq_end if freq_end is not none else '',
"HZ_PER_SEC": hz_per_sec, "HZ_PER_SEC": hz_per_sec,
"ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '', "ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '',
"AXIS": axis, "AXIS": axis,

View File

@@ -7,7 +7,6 @@
# Description: Implements the axes map detection script for Shake&Tune, including # Description: Implements the axes map detection script for Shake&Tune, including
# calibration tools and graph creation for 3D printer vibration analysis. # calibration tools and graph creation for 3D printer vibration analysis.
import optparse import optparse
import os import os
from datetime import datetime from datetime import datetime
@@ -194,35 +193,39 @@ def linear_regression_direction(
def plot_compare_frequency( def plot_compare_frequency(
ax: plt.Axes, time: np.ndarray, accel_x: np.ndarray, accel_y: np.ndarray, accel_z: np.ndarray, offset: float, i: int ax: plt.Axes,
time_data: List[np.ndarray],
accel_data: List[Tuple[np.ndarray, np.ndarray, np.ndarray]],
offset: float,
noise_level: str,
) -> None: ) -> None:
# Plot acceleration data # Plot acceleration data
ax.plot( for i, (time, (accel_x, accel_y, accel_z)) in enumerate(zip(time_data, accel_data)):
time, ax.plot(
accel_x, time,
label='X' if i == 0 else '', accel_x,
color=KLIPPAIN_COLORS['purple'], label='X' if i == 0 else '',
linewidth=0.5, color=KLIPPAIN_COLORS['purple'],
zorder=50 if i == 0 else 10, linewidth=0.5,
) zorder=50 if i == 0 else 10,
ax.plot( )
time, ax.plot(
accel_y, time,
label='Y' if i == 0 else '', accel_y,
color=KLIPPAIN_COLORS['orange'], label='Y' if i == 0 else '',
linewidth=0.5, color=KLIPPAIN_COLORS['orange'],
zorder=50 if i == 1 else 10, linewidth=0.5,
) zorder=50 if i == 1 else 10,
ax.plot( )
time, ax.plot(
accel_z, time,
label='Z' if i == 0 else '', accel_z,
color=KLIPPAIN_COLORS['red_pink'], label='Z' if i == 0 else '',
linewidth=0.5, color=KLIPPAIN_COLORS['red_pink'],
zorder=50 if i == 2 else 10, linewidth=0.5,
) zorder=50 if i == 2 else 10,
)
# Setting axis parameters, grid and graph title
ax.set_xlabel('Time (s)') ax.set_xlabel('Time (s)')
ax.set_ylabel('Acceleration (mm/s²)') ax.set_ylabel('Acceleration (mm/s²)')
@@ -242,53 +245,52 @@ def plot_compare_frequency(
ax.legend(loc='upper left', prop=fontP) ax.legend(loc='upper left', prop=fontP)
# Add gravity offset to the graph # Add the gravity and noise level to the graph legend
if i == 0: ax2 = ax.twinx()
ax2 = ax.twinx() # To split the legends in two box ax2.yaxis.set_visible(False)
ax2.yaxis.set_visible(False) ax2.plot([], [], ' ', label=noise_level)
ax2.plot([], [], ' ', label=f'Measured gravity: {offset / 1000:0.3f} m/s²') ax2.plot([], [], ' ', label=f'Measured gravity: {offset / 1000:0.3f} m/s²')
ax2.legend(loc='upper right', prop=fontP) ax2.legend(loc='upper right', prop=fontP)
def plot_3d_path( def plot_3d_path(
ax: plt.Axes, ax: plt.Axes,
i: int, position_data: List[Tuple[np.ndarray, np.ndarray, np.ndarray]],
position_x: np.ndarray, direction_vectors: List[np.ndarray],
position_y: np.ndarray, angle_errors: List[float],
position_z: np.ndarray,
average_direction_vector: np.ndarray,
angle_error: float,
) -> None: ) -> None:
ax.plot(position_x, position_y, position_z, color=KLIPPAIN_COLORS['orange'], linestyle=':', linewidth=2) # Plot the 3D path of the movement
ax.scatter(position_x[0], position_y[0], position_z[0], color=KLIPPAIN_COLORS['red_pink'], zorder=10) for i, ((position_x, position_y, position_z), average_direction_vector, angle_error) in enumerate(
ax.text( zip(position_data, direction_vectors, angle_errors)
position_x[0] + 1, ):
position_y[0], ax.plot(position_x, position_y, position_z, color=KLIPPAIN_COLORS['orange'], linestyle=':', linewidth=2)
position_z[0], ax.scatter(position_x[0], position_y[0], position_z[0], color=KLIPPAIN_COLORS['red_pink'], zorder=10)
str(i + 1), ax.text(
color='black', position_x[0] + 1,
fontsize=16, position_y[0],
fontweight='bold', position_z[0],
zorder=20, str(i + 1),
) color='black',
fontsize=16,
fontweight='bold',
zorder=20,
)
# Plot the average direction vector # Plot the average direction vector
start_position = np.array([position_x[0], position_y[0], position_z[0]]) start_position = np.array([position_x[0], position_y[0], position_z[0]])
end_position = start_position + average_direction_vector * np.linalg.norm( end_position = start_position + average_direction_vector * np.linalg.norm(
[position_x[-1] - position_x[0], position_y[-1] - position_y[0], position_z[-1] - position_z[0]] [position_x[-1] - position_x[0], position_y[-1] - position_y[0], position_z[-1] - position_z[0]]
) )
axes = ['X', 'Y', 'Z'] ax.plot(
ax.plot( [start_position[0], end_position[0]],
[start_position[0], end_position[0]], [start_position[1], end_position[1]],
[start_position[1], end_position[1]], [start_position[2], end_position[2]],
[start_position[2], end_position[2]], label=f'{["X", "Y", "Z"][i]} angle: {angle_error:0.2f}°',
label=f'{axes[i]} angle: {angle_error:0.2f}°', color=KLIPPAIN_COLORS['purple'],
color=KLIPPAIN_COLORS['purple'], linestyle='-',
linestyle='-', linewidth=2,
linewidth=2, )
)
# Setting axis parameters, grid and graph title
ax.set_xlabel('X Position (mm)') ax.set_xlabel('X Position (mm)')
ax.set_ylabel('Y Position (mm)') ax.set_ylabel('Y Position (mm)')
ax.set_zlabel('Z Position (mm)') ax.set_zlabel('Z Position (mm)')
@@ -311,14 +313,24 @@ def plot_3d_path(
def format_direction_vector(vectors: List[np.ndarray]) -> str: def format_direction_vector(vectors: List[np.ndarray]) -> str:
formatted_vector = [] formatted_vector = []
axes_count = {'x': 0, 'y': 0, 'z': 0}
for vector in vectors: for vector in vectors:
for i in range(len(vector)): for i in range(len(vector)):
if vector[i] > 0: if vector[i] > 0:
formatted_vector.append(MACHINE_AXES[i]) formatted_vector.append(MACHINE_AXES[i])
axes_count[MACHINE_AXES[i]] += 1
break break
elif vector[i] < 0: elif vector[i] < 0:
formatted_vector.append(f'-{MACHINE_AXES[i]}') formatted_vector.append(f'-{MACHINE_AXES[i]}')
axes_count[MACHINE_AXES[i]] += 1
break break
# Check if all axes are present in the axes_map and return an error message if not
for _, count in axes_count.items():
if count != 1:
return 'unable to determine it correctly!'
return ', '.join(formatted_vector) return ', '.join(formatted_vector)
@@ -360,8 +372,12 @@ def axesmap_calibration(
cumulative_start_position = np.array([0, 0, 0]) cumulative_start_position = np.array([0, 0, 0])
direction_vectors = [] direction_vectors = []
angle_errors = []
total_noise_intensity = 0.0 total_noise_intensity = 0.0
for i, machine_axis in enumerate(MACHINE_AXES): acceleration_data = []
position_data = []
gravities = []
for _, machine_axis in enumerate(MACHINE_AXES):
if machine_axis not in raw_datas: if machine_axis not in raw_datas:
raise ValueError(f'Missing CSV file for axis {machine_axis}') raise ValueError(f'Missing CSV file for axis {machine_axis}')
@@ -388,15 +404,19 @@ def axesmap_calibration(
f'Machine axis {machine_axis.upper()} -> nearest accelerometer direction vector: {direction_vector} (angle error: {angle_error:.2f}°)' f'Machine axis {machine_axis.upper()} -> nearest accelerometer direction vector: {direction_vector} (angle error: {angle_error:.2f}°)'
) )
direction_vectors.append(direction_vector) direction_vectors.append(direction_vector)
angle_errors.append(angle_error)
total_noise_intensity += noise_intensity total_noise_intensity += noise_intensity
plot_compare_frequency(ax1, time, accel_x, accel_y, accel_z, gravity, i) acceleration_data.append((time, (accel_x, accel_y, accel_z)))
plot_3d_path(ax2, i, position_x, position_y, position_z, average_direction_vector, angle_error) position_data.append((position_x, position_y, position_z))
gravities.append(gravity)
# Update the cumulative start position for the next segment # Update the cumulative start position for the next segment
cumulative_start_position = np.array([position_x[-1], position_y[-1], position_z[-1]]) cumulative_start_position = np.array([position_x[-1], position_y[-1], position_z[-1]])
gravity = np.mean(gravities)
average_noise_intensity = total_noise_intensity / len(raw_datas) average_noise_intensity = total_noise_intensity / len(raw_datas)
if average_noise_intensity <= 350: if average_noise_intensity <= 350:
average_noise_intensity_text = '-> OK' average_noise_intensity_text = '-> OK'
@@ -405,11 +425,25 @@ def axesmap_calibration(
else: else:
average_noise_intensity_text = '-> ERROR: accelerometer noise is too high!' average_noise_intensity_text = '-> ERROR: accelerometer noise is too high!'
average_noise_intensity_label = (
f'Dynamic noise level: {average_noise_intensity:.2f} mm/s² {average_noise_intensity_text}'
)
ConsoleOutput.print(average_noise_intensity_label)
ConsoleOutput.print(f'--> Detected gravity: {gravity / 1000 :.2f} m/s²')
formatted_direction_vector = format_direction_vector(direction_vectors) formatted_direction_vector = format_direction_vector(direction_vectors)
ConsoleOutput.print(f'--> Detected axes_map: {formatted_direction_vector}') ConsoleOutput.print(f'--> Detected axes_map: {formatted_direction_vector}')
ConsoleOutput.print(
f'Average accelerometer noise level: {average_noise_intensity:.2f} mm/s² {average_noise_intensity_text}' # Plot the differents graphs
plot_compare_frequency(
ax1,
[d[0] for d in acceleration_data],
[d[1] for d in acceleration_data],
gravity,
average_noise_intensity_label,
) )
plot_3d_path(ax2, position_data, direction_vectors, angle_errors)
# Add title # Add title
title_line1 = 'AXES MAP CALIBRATION TOOL' title_line1 = 'AXES MAP CALIBRATION TOOL'
@@ -430,9 +464,7 @@ def axesmap_calibration(
fig.text(0.060, 0.939, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.060, 0.939, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
title_line3 = f'| Detected axes_map: {formatted_direction_vector}' title_line3 = f'| Detected axes_map: {formatted_direction_vector}'
title_line4 = f'| Accelerometer noise level: {average_noise_intensity:.2f} mm/s² {average_noise_intensity_text}' fig.text(0.50, 0.985, title_line3, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.50, 0.985, title_line3, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.50, 0.950, title_line4, ha='left', va='top', fontsize=11, color=KLIPPAIN_COLORS['dark_purple'])
# Adding a small Klippain logo to the top left corner of the figure # Adding a small Klippain logo to the top left corner of the figure
ax_logo = fig.add_axes([0.001, 0.894, 0.105, 0.105], anchor='NW') ax_logo = fig.add_axes([0.001, 0.894, 0.105, 0.105], anchor='NW')

View File

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

View File

@@ -22,13 +22,14 @@
import optparse import optparse
import os import os
from datetime import datetime from datetime import datetime
from typing import List, Optional from typing import Dict, List, Optional
import matplotlib import matplotlib
import matplotlib.font_manager import matplotlib.font_manager
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.ticker import matplotlib.ticker
import numpy as np import numpy as np
from scipy.interpolate import interp1d
matplotlib.use('Agg') matplotlib.use('Agg')
@@ -47,7 +48,9 @@ PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_EFFECT_THRESHOLD = 0.12 PEAKS_EFFECT_THRESHOLD = 0.12
SPECTROGRAM_LOW_PERCENTILE_FILTER = 5 SPECTROGRAM_LOW_PERCENTILE_FILTER = 5
MAX_VIBRATIONS = 5.0 MAX_VIBRATIONS = 5.0
MAX_VIBRATIONS_PLOTTED = 80.0
MAX_VIBRATIONS_PLOTTED_ZOOM = 1.25 # 1.25x max vibs values from the standard filters selection
SMOOTHING_TESTS = 10 # Number of smoothing values to test (it will significantly increase the computation time)
KLIPPAIN_COLORS = { KLIPPAIN_COLORS = {
'purple': '#70088C', 'purple': '#70088C',
'orange': '#FF8D32', 'orange': '#FF8D32',
@@ -112,15 +115,13 @@ def calibrate_shaper(datas: List[np.ndarray], max_smoothing: Optional[float], sc
calibration_data = helper.process_accelerometer_data(datas) calibration_data = helper.process_accelerometer_data(datas)
calibration_data.normalize_to_frequencies() calibration_data.normalize_to_frequencies()
# We compute the damping ratio using the Klipper's default value if it fails
fr, zeta, _, _ = compute_mechanical_parameters(calibration_data.psd_sum, calibration_data.freq_bins) fr, zeta, _, _ = compute_mechanical_parameters(calibration_data.psd_sum, calibration_data.freq_bins)
zeta = zeta if zeta is not None else 0.1
# If the damping ratio computation fail, we use Klipper default value instead
if zeta is None:
zeta = 0.1
compat = False compat = False
try: try:
shaper, all_shapers = helper.find_best_shaper( k_shaper_choice, all_shapers = helper.find_best_shaper(
calibration_data, calibration_data,
shapers=None, shapers=None,
damping_ratio=zeta, damping_ratio=zeta,
@@ -129,23 +130,79 @@ def calibrate_shaper(datas: List[np.ndarray], max_smoothing: Optional[float], sc
max_smoothing=max_smoothing, max_smoothing=max_smoothing,
test_damping_ratios=None, test_damping_ratios=None,
max_freq=max_freq, max_freq=max_freq,
logger=ConsoleOutput.print, logger=None,
)
ConsoleOutput.print(
(
f'Detected a square corner velocity of {scv:.1f} and a damping ratio of {zeta:.3f}. '
'These values will be used to compute the input shaper filter recommendations'
)
) )
except TypeError: except TypeError:
ConsoleOutput.print( ConsoleOutput.print(
'[WARNING] You seem to be using an older version of Klipper that is not compatible with all the latest Shake&Tune features!' (
) '[WARNING] You seem to be using an older version of Klipper that is not compatible with all the latest '
ConsoleOutput.print( 'Shake&Tune features!\nShake&Tune now runs in compatibility mode: be aware that the results may be '
'Shake&Tune now runs in compatibility mode: be aware that the results may be slightly off, since the real damping ratio cannot be used to create the filter recommendations' 'slightly off, since the real damping ratio cannot be used to craft accurate filter recommendations'
)
) )
compat = True compat = True
shaper, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, ConsoleOutput.print) k_shaper_choice, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, None)
ConsoleOutput.print( # If max_smoothing is not None, we run the same computation but without a smoothing value
f'\n-> Recommended shaper is {shaper.name.upper()} @ {shaper.freq:.1f} Hz (when using a square corner velocity of {scv:.1f} and a damping ratio of {zeta:.3f})' # to get the max smoothing values from the filters and create the testing list
all_shapers_nosmoothing = None
if max_smoothing is not None:
if compat:
_, all_shapers_nosmoothing = helper.find_best_shaper(calibration_data, None, None)
else:
_, all_shapers_nosmoothing = helper.find_best_shaper(
calibration_data,
shapers=None,
damping_ratio=zeta,
scv=scv,
shaper_freqs=None,
max_smoothing=None,
test_damping_ratios=None,
max_freq=max_freq,
logger=None,
)
# Then we iterate over the all_shaperts_nosmoothing list to get the max of the smoothing values
max_smoothing = 0.0
if all_shapers_nosmoothing is not None:
for shaper in all_shapers_nosmoothing:
if shaper.smoothing > max_smoothing:
max_smoothing = shaper.smoothing
else:
for shaper in all_shapers:
if shaper.smoothing > max_smoothing:
max_smoothing = shaper.smoothing
# Then we create a list of smoothing values to test (no need to test the max smoothing value as it was already tested)
smoothing_test_list = np.linspace(0.001, max_smoothing, SMOOTHING_TESTS)[:-1]
additional_all_shapers = {}
for smoothing in smoothing_test_list:
if compat:
_, all_shapers_bis = helper.find_best_shaper(calibration_data, smoothing, None)
else:
_, all_shapers_bis = helper.find_best_shaper(
calibration_data,
shapers=None,
damping_ratio=zeta,
scv=scv,
shaper_freqs=None,
max_smoothing=smoothing,
test_damping_ratios=None,
max_freq=max_freq,
logger=None,
)
additional_all_shapers[f'sm_{smoothing}'] = all_shapers_bis
additional_all_shapers['max_smoothing'] = (
all_shapers_nosmoothing if all_shapers_nosmoothing is not None else all_shapers
) )
return shaper.name, all_shapers, calibration_data, fr, zeta, compat return k_shaper_choice.name, all_shapers, additional_all_shapers, calibration_data, fr, zeta, max_smoothing, compat
###################################################################### ######################################################################
@@ -164,7 +221,7 @@ def plot_freq_response(
fr: float, fr: float,
zeta: float, zeta: float,
max_freq: float, max_freq: float,
) -> None: ) -> Dict[str, List[Dict[str, str]]]:
freqs = calibration_data.freqs freqs = calibration_data.freqs
psd = calibration_data.psd_sum psd = calibration_data.psd_sum
px = calibration_data.psd_x px = calibration_data.psd_x
@@ -193,27 +250,40 @@ def plot_freq_response(
ax2 = ax.twinx() ax2 = ax.twinx()
ax2.yaxis.set_visible(False) ax2.yaxis.set_visible(False)
shaper_table_data = {
'shapers': [],
'recommendations': [],
'damping_ratio': zeta,
}
# Draw the shappers curves and add their specific parameters in the legend # Draw the shappers curves and add their specific parameters in the legend
perf_shaper_choice = None perf_shaper_choice = None
perf_shaper_vals = None perf_shaper_vals = None
perf_shaper_freq = None perf_shaper_freq = None
perf_shaper_accel = 0 perf_shaper_accel = 0
for shaper in shapers: for shaper in shapers:
shaper_max_accel = round(shaper.max_accel / 100.0) * 100.0 ax2.plot(freqs, shaper.vals, label=shaper.name.upper(), linestyle='dotted')
label = f'{shaper.name.upper()} ({shaper.freq:.1f} Hz, vibr={shaper.vibrs * 100.0:.1f}%, sm~={shaper.smoothing:.2f}, accel<={shaper_max_accel:.0f})'
ax2.plot(freqs, shaper.vals, label=label, linestyle='dotted') shaper_info = {
'type': shaper.name.upper(),
'frequency': shaper.freq,
'vibrations': shaper.vibrs,
'smoothing': shaper.smoothing,
'max_accel': shaper.max_accel,
}
shaper_table_data['shapers'].append(shaper_info)
# Get the Klipper recommended shaper (usually it's a good low vibration compromise) # Get the Klipper recommended shaper (usually it's a good low vibration compromise)
if shaper.name == klipper_shaper_choice: if shaper.name == klipper_shaper_choice:
klipper_shaper_freq = shaper.freq klipper_shaper_freq = shaper.freq
klipper_shaper_vals = shaper.vals klipper_shaper_vals = shaper.vals
klipper_shaper_accel = shaper_max_accel klipper_shaper_accel = shaper.max_accel
# Find the shaper with the highest accel but with vibrs under MAX_VIBRATIONS as it's # Find the shaper with the highest accel but with vibrs under MAX_VIBRATIONS as it's
# a good performance compromise when injecting the SCV and damping ratio in the computation # a good performance compromise when injecting the SCV and damping ratio in the computation
if perf_shaper_accel < shaper_max_accel and shaper.vibrs * 100 < MAX_VIBRATIONS: if perf_shaper_accel < shaper.max_accel and shaper.vibrs * 100 < MAX_VIBRATIONS:
perf_shaper_choice = shaper.name perf_shaper_choice = shaper.name
perf_shaper_accel = shaper_max_accel perf_shaper_accel = shaper.max_accel
perf_shaper_freq = shaper.freq perf_shaper_freq = shaper.freq
perf_shaper_vals = shaper.vals perf_shaper_vals = shaper.vals
@@ -226,32 +296,30 @@ def plot_freq_response(
and perf_shaper_choice != klipper_shaper_choice and perf_shaper_choice != klipper_shaper_choice
and perf_shaper_accel >= klipper_shaper_accel and perf_shaper_accel >= klipper_shaper_accel
): ):
ax2.plot( perf_shaper_string = f'Recommended for performance: {perf_shaper_choice.upper()} @ {perf_shaper_freq:.1f} Hz'
[], lowvibr_shaper_string = (
[], f'Recommended for low vibrations: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz'
' ',
label=f'Recommended performance shaper: {perf_shaper_choice.upper()} @ {perf_shaper_freq:.1f} Hz',
) )
shaper_table_data['recommendations'].append(perf_shaper_string)
shaper_table_data['recommendations'].append(lowvibr_shaper_string)
ConsoleOutput.print(f'{perf_shaper_string} (with a damping ratio of {zeta:.3f})')
ConsoleOutput.print(f'{lowvibr_shaper_string} (with a damping ratio of {zeta:.3f})')
ax.plot( ax.plot(
freqs, freqs,
psd * perf_shaper_vals, psd * perf_shaper_vals,
label=f'With {perf_shaper_choice.upper()} applied', label=f'With {perf_shaper_choice.upper()} applied',
color='cyan', color='cyan',
) )
ax2.plot( ax.plot(
[], freqs,
[], psd * klipper_shaper_vals,
' ', label=f'With {klipper_shaper_choice.upper()} applied',
label=f'Recommended low vibrations shaper: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz', color='lime',
) )
ax.plot(freqs, psd * klipper_shaper_vals, label=f'With {klipper_shaper_choice.upper()} applied', color='lime')
else: else:
ax2.plot( shaper_string = f'Recommended best shaper: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz'
[], shaper_table_data['recommendations'].append(shaper_string)
[], ConsoleOutput.print(f'{shaper_string} (with a damping ratio of {zeta:.3f})')
' ',
label=f'Recommended performance shaper: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz',
)
ax.plot( ax.plot(
freqs, freqs,
psd * klipper_shaper_vals, psd * klipper_shaper_vals,
@@ -259,9 +327,6 @@ def plot_freq_response(
color='cyan', color='cyan',
) )
# And the estimated damping ratio is finally added at the end of the legend
ax2.plot([], [], ' ', label=f'Estimated damping ratio (ζ): {zeta:.3f}')
# Draw the detected peaks and name them # Draw the detected peaks and name them
# This also draw the detection threshold and warning threshold (aka "effect zone") # This also draw the detection threshold and warning threshold (aka "effect zone")
ax.plot(peaks_freqs, psd[peaks], 'x', color='black', markersize=8) ax.plot(peaks_freqs, psd[peaks], 'x', color='black', markersize=8)
@@ -297,7 +362,7 @@ def plot_freq_response(
ax.legend(loc='upper left', prop=fontP) ax.legend(loc='upper left', prop=fontP)
ax2.legend(loc='upper right', prop=fontP) ax2.legend(loc='upper right', prop=fontP)
return return shaper_table_data
# Plot a time-frequency spectrogram to see how the system respond over time during the # Plot a time-frequency spectrogram to see how the system respond over time during the
@@ -350,6 +415,170 @@ def plot_spectrogram(
return return
def plot_smoothing_vs_accel(
ax: plt.Axes,
shaper_table_data: Dict[str, List[Dict[str, str]]],
additional_shapers: Dict[str, List[Dict[str, str]]],
) -> None:
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('x-small')
ax.xaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(1000))
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
shaper_data = {}
# Extract data from additional_shapers first
for _, shapers in additional_shapers.items():
for shaper in shapers:
shaper_type = shaper.name.upper()
if shaper_type not in shaper_data:
shaper_data[shaper_type] = []
shaper_data[shaper_type].append(
{
'max_accel': shaper.max_accel,
'vibrs': shaper.vibrs * 100.0,
}
)
# Extract data from shaper_table_data and insert into shaper_data
max_shaper_vibrations = 0
for shaper in shaper_table_data['shapers']:
shaper_type = shaper['type']
if shaper_type not in shaper_data:
shaper_data[shaper_type] = []
max_shaper_vibrations = max(max_shaper_vibrations, float(shaper['vibrations']) * 100.0)
shaper_data[shaper_type].append(
{
'max_accel': float(shaper['max_accel']),
'vibrs': float(shaper['vibrations']) * 100.0,
}
)
# Calculate the maximum `max_accel` for points below the thresholds to get a good plot with
# continuous lines and a zoom on the graph to show details at low vibrations
min_accel_limit = 99999
max_accel_limit = 0
max_accel_limit_zoom = 0
for data in shaper_data.values():
min_accel_limit = min(min_accel_limit, min(d['max_accel'] for d in data))
max_accel_limit = max(
max_accel_limit, max(d['max_accel'] for d in data if d['vibrs'] <= MAX_VIBRATIONS_PLOTTED)
)
max_accel_limit_zoom = max(
max_accel_limit_zoom,
max(d['max_accel'] for d in data if d['vibrs'] <= max_shaper_vibrations * MAX_VIBRATIONS_PLOTTED_ZOOM),
)
# Add a zoom axes on the graph to show details at low vibrations
zoomed_window = np.clip(max_shaper_vibrations * MAX_VIBRATIONS_PLOTTED_ZOOM, 0, 20)
axins = ax.inset_axes(
[0.575, 0.125, 0.40, 0.45],
xlim=(min_accel_limit * 0.95, max_accel_limit_zoom * 1.1),
ylim=(-0.5, zoomed_window),
)
ax.indicate_inset_zoom(axins, edgecolor=KLIPPAIN_COLORS['purple'], linewidth=3)
axins.xaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(500))
axins.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
axins.grid(which='major', color='grey')
axins.grid(which='minor', color='lightgrey')
# Draw the green zone on both axes to highlight the low vibrations zone
number_of_interpolated_points = 100
x_fill = np.linspace(min_accel_limit * 0.95, max_accel_limit * 1.1, number_of_interpolated_points)
y_fill = np.full_like(x_fill, 5.0)
ax.axhline(y=5.0, color='black', linestyle='--', linewidth=0.5)
ax.fill_between(x_fill, -0.5, y_fill, color='green', alpha=0.15)
if zoomed_window > 5.0:
axins.axhline(y=5.0, color='black', linestyle='--', linewidth=0.5)
axins.fill_between(x_fill, -0.5, y_fill, color='green', alpha=0.15)
# Plot each shaper remaining vibrations response over acceleration
max_vibrations = 0
for _, (shaper_type, data) in enumerate(shaper_data.items()):
max_accel_values = np.array([d['max_accel'] for d in data])
vibrs_values = np.array([d['vibrs'] for d in data])
# remove duplicate values in max_accel_values and delete the corresponding vibrs_values
# and interpolate the curves to get them smoother with more datapoints
unique_max_accel_values, unique_indices = np.unique(max_accel_values, return_index=True)
max_accel_values = unique_max_accel_values
vibrs_values = vibrs_values[unique_indices]
interp_func = interp1d(max_accel_values, vibrs_values, kind='cubic')
max_accel_fine = np.linspace(max_accel_values.min(), max_accel_values.max(), number_of_interpolated_points)
vibrs_fine = interp_func(max_accel_fine)
ax.plot(max_accel_fine, vibrs_fine, label=f'{shaper_type}', zorder=10)
axins.plot(max_accel_fine, vibrs_fine, label=f'{shaper_type}', zorder=15)
max_vibrations = max(max_vibrations, max(vibrs_fine))
ax.set_xlabel('Max Acceleration')
ax.set_ylabel('Remaining Vibrations (%)')
ax.set_xlim([min_accel_limit * 0.95, max_accel_limit * 1.1])
ax.set_ylim([-0.5, np.clip(max_vibrations * 1.05, 50, MAX_VIBRATIONS_PLOTTED)])
ax.set_title(
'Filters performances over acceleration',
fontsize=14,
color=KLIPPAIN_COLORS['dark_orange'],
weight='bold',
)
ax.legend(loc='best', prop=fontP)
def print_shaper_table(fig: plt.Figure, shaper_table_data: Dict[str, List[Dict[str, str]]]) -> None:
columns = ['Type', 'Frequency', 'Vibrations', 'Smoothing', 'Max Accel']
table_data = []
for shaper_info in shaper_table_data['shapers']:
row = [
f'{shaper_info["type"].upper()}',
f'{shaper_info["frequency"]:.1f} Hz',
f'{shaper_info["vibrations"] * 100:.1f} %',
f'{shaper_info["smoothing"]:.3f}',
f'{round(shaper_info["max_accel"] / 10) * 10:.0f}',
]
table_data.append(row)
table = plt.table(cellText=table_data, colLabels=columns, bbox=[1.130, -0.4, 0.803, 0.25], cellLoc='center')
table.auto_set_font_size(False)
table.set_fontsize(10)
table.auto_set_column_width([0, 1, 2, 3, 4])
table.set_zorder(100)
# Add the recommendations and damping ratio using fig.text
fig.text(
0.585,
0.235,
f'Estimated damping ratio (ζ): {shaper_table_data["damping_ratio"]:.3f}',
fontsize=14,
color=KLIPPAIN_COLORS['purple'],
)
if len(shaper_table_data['recommendations']) == 1:
fig.text(
0.585,
0.200,
shaper_table_data['recommendations'][0],
fontsize=14,
color=KLIPPAIN_COLORS['red_pink'],
)
elif len(shaper_table_data['recommendations']) == 2:
fig.text(
0.585,
0.200,
shaper_table_data['recommendations'][0],
fontsize=14,
color=KLIPPAIN_COLORS['red_pink'],
)
fig.text(
0.585,
0.175,
shaper_table_data['recommendations'][1],
fontsize=14,
color=KLIPPAIN_COLORS['red_pink'],
)
###################################################################### ######################################################################
# Startup and main routines # Startup and main routines
###################################################################### ######################################################################
@@ -375,8 +604,8 @@ def shaper_calibration(
ConsoleOutput.print('Warning: incorrect number of .csv files detected. Only the first one will be used!') ConsoleOutput.print('Warning: incorrect number of .csv files detected. Only the first one will be used!')
# Compute shapers, PSD outputs and spectrogram # Compute shapers, PSD outputs and spectrogram
klipper_shaper_choice, shapers, calibration_data, fr, zeta, compat = calibrate_shaper( klipper_shaper_choice, shapers, additional_shapers, calibration_data, fr, zeta, max_smoothing_computed, compat = (
datas[0], max_smoothing, scv, max_freq calibrate_shaper(datas[0], max_smoothing, scv, max_freq)
) )
pdata, bins, t = compute_spectrogram(datas[0]) pdata, bins, t = compute_spectrogram(datas[0])
del datas del datas
@@ -400,29 +629,31 @@ def shaper_calibration(
peak_freqs_formated = ['{:.1f}'.format(f) for f in peaks_freqs] peak_freqs_formated = ['{:.1f}'.format(f) for f in peaks_freqs]
num_peaks_above_effect_threshold = np.sum(calibration_data.psd_sum[peaks] > peaks_threshold[1]) num_peaks_above_effect_threshold = np.sum(calibration_data.psd_sum[peaks] > peaks_threshold[1])
ConsoleOutput.print( ConsoleOutput.print(
f"\nPeaks detected on the graph: {num_peaks} @ {', '.join(map(str, peak_freqs_formated))} Hz ({num_peaks_above_effect_threshold} above effect threshold)" f"Peaks detected on the graph: {num_peaks} @ {', '.join(map(str, peak_freqs_formated))} Hz ({num_peaks_above_effect_threshold} above effect threshold)"
) )
# Create graph layout # Create graph layout
fig, (ax1, ax2) = plt.subplots( fig, ((ax1, ax3), (ax2, ax4)) = plt.subplots(
2,
2, 2,
1,
gridspec_kw={ gridspec_kw={
'height_ratios': [4, 3], 'height_ratios': [4, 3],
'width_ratios': [5, 4],
'bottom': 0.050, 'bottom': 0.050,
'top': 0.890, 'top': 0.890,
'left': 0.085, 'left': 0.048,
'right': 0.966, 'right': 0.966,
'hspace': 0.169, 'hspace': 0.169,
'wspace': 0.200, 'wspace': 0.150,
}, },
) )
fig.set_size_inches(8.3, 11.6) ax4.remove()
fig.set_size_inches(15, 11.6)
# Add a title with some test info # Add a title with some test info
title_line1 = 'INPUT SHAPER CALIBRATION TOOL' title_line1 = 'INPUT SHAPER CALIBRATION TOOL'
fig.text( fig.text(
0.12, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold' 0.065, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold'
) )
try: try:
filename_parts = (lognames[0].split('/')[-1]).split('_') filename_parts = (lognames[0].split('/')[-1]).split('_')
@@ -433,8 +664,11 @@ def shaper_calibration(
title_line4 = '| and SCV are not used for filter recommendations!' title_line4 = '| and SCV are not used for filter recommendations!'
title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else '' title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else ''
else: else:
max_smoothing_string = (
f'maximum ({max_smoothing_computed:0.3f})' if max_smoothing is None else f'{max_smoothing:0.3f}'
)
title_line3 = f'| Square corner velocity: {scv} mm/s' title_line3 = f'| Square corner velocity: {scv} mm/s'
title_line4 = f'| Max allowed smoothing: {max_smoothing}' title_line4 = f'| Allowed smoothing: {max_smoothing_string}'
title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else '' title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else ''
except Exception: except Exception:
ConsoleOutput.print(f'Warning: CSV filename look to be different than expected ({lognames[0]})') ConsoleOutput.print(f'Warning: CSV filename look to be different than expected ({lognames[0]})')
@@ -442,19 +676,22 @@ def shaper_calibration(
title_line3 = '' title_line3 = ''
title_line4 = '' title_line4 = ''
title_line5 = '' title_line5 = ''
fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.065, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.58, 0.963, title_line3, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.50, 0.990, title_line3, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.58, 0.948, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.50, 0.968, title_line4, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.58, 0.933, title_line5, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.501, 0.945, title_line5, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'])
# Plot the graphs # Plot the graphs
plot_freq_response( shaper_table_data = plot_freq_response(
ax1, calibration_data, shapers, klipper_shaper_choice, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq ax1, calibration_data, shapers, klipper_shaper_choice, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq
) )
plot_spectrogram(ax2, t, bins, pdata, peaks_freqs, max_freq) plot_spectrogram(ax2, t, bins, pdata, peaks_freqs, max_freq)
plot_smoothing_vs_accel(ax3, shaper_table_data, additional_shapers)
print_shaper_table(fig, shaper_table_data)
# Adding a small Klippain logo to the top left corner of the figure # Adding a small Klippain logo to the top left corner of the figure
ax_logo = fig.add_axes([0.001, 0.8995, 0.1, 0.1], anchor='NW') ax_logo = fig.add_axes([0.001, 0.924, 0.075, 0.075], anchor='NW')
ax_logo.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png'))) ax_logo.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png')))
ax_logo.axis('off') ax_logo.axis('off')

View File

@@ -8,6 +8,7 @@
# loading of the plugin, and the registration of the tuning commands # loading of the plugin, and the registration of the tuning commands
import importlib
import os import os
from pathlib import Path from pathlib import Path
@@ -29,117 +30,176 @@ from .helpers.console_output import ConsoleOutput
from .shaketune_config import ShakeTuneConfig from .shaketune_config import ShakeTuneConfig
from .shaketune_process import ShakeTuneProcess from .shaketune_process import ShakeTuneProcess
DEFAULT_FOLDER = '~/printer_data/config/ShakeTune_results'
DEFAULT_NUMBER_OF_RESULTS = 3
DEFAULT_KEEP_RAW_CSV = False
DEFAULT_DPI = 150
DEFAULT_TIMEOUT = 300
DEFAULT_SHOW_MACROS = True
ST_COMMANDS = {
'EXCITATE_AXIS_AT_FREQ': (
'Maintain a specified excitation frequency for a period '
'of time to diagnose and locate a source of vibrations'
),
'AXES_MAP_CALIBRATION': (
'Perform a set of movements to measure the orientation of the accelerometer '
'and help you set the best axes_map configuration for your printer'
),
'COMPARE_BELTS_RESPONSES': (
'Perform a custom half-axis test to analyze and compare the '
'frequency profiles of individual belts on CoreXY or CoreXZ printers'
),
'AXES_SHAPER_CALIBRATION': 'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter',
'CREATE_VIBRATIONS_PROFILE': (
'Run a series of motions to find speed/angle ranges where the printer could be '
'exposed to VFAs to optimize your slicer speed profiles and TMC driver parameters'
),
}
class ShakeTune: class ShakeTune:
def __init__(self, config) -> None: def __init__(self, config) -> None:
self._pconfig = config self._config = config
self._printer = config.get_printer() self._printer = config.get_printer()
self._printer.register_event_handler('klippy:connect', self._on_klippy_connect)
# Check if Shake&Tune is running in DangerKlipper
self.IN_DANGER = importlib.util.find_spec('extras.danger_options') is not None
# Register the console print output callback to the corresponding Klipper function
gcode = self._printer.lookup_object('gcode') gcode = self._printer.lookup_object('gcode')
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.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._config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi)
ConsoleOutput.register_output_callback(gcode.respond_info) ConsoleOutput.register_output_callback(gcode.respond_info)
commands = [ self._initialize_config(config)
( self._register_commands()
'EXCITATE_AXIS_AT_FREQ',
self.cmd_EXCITATE_AXIS_AT_FREQ, # Initialize the ShakeTune object and its configuration
'Maintain a specified excitation frequency for a period of time to diagnose and locate a source of vibration', def _initialize_config(self, config) -> None:
), result_folder = config.get('result_folder', default=DEFAULT_FOLDER)
( result_folder_path = Path(result_folder).expanduser() if result_folder else None
'AXES_MAP_CALIBRATION', keep_n_results = config.getint('number_of_results_to_keep', default=DEFAULT_NUMBER_OF_RESULTS, minval=0)
self.cmd_AXES_MAP_CALIBRATION, keep_csv = config.getboolean('keep_raw_csv', default=DEFAULT_KEEP_RAW_CSV)
'Perform a set of movements to measure the orientation of the accelerometer and help you set the best axes_map configuration for your printer', dpi = config.getint('dpi', default=DEFAULT_DPI, minval=100, maxval=500)
), self._st_config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi)
(
'COMPARE_BELTS_RESPONSES', self.timeout = config.getfloat('timeout', 300, above=0.0)
self.cmd_COMPARE_BELTS_RESPONSES, self._show_macros = config.getboolean('show_macros_in_webui', default=True)
'Perform a custom half-axis test to analyze and compare the frequency profiles of individual belts on CoreXY printers',
), # Create the Klipper commands to allow the user to run Shake&Tune's tools
( def _register_commands(self) -> None:
'AXES_SHAPER_CALIBRATION', gcode = self._printer.lookup_object('gcode')
self.cmd_AXES_SHAPER_CALIBRATION, measurement_commands = [
'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter', ('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']),
'CREATE_VIBRATIONS_PROFILE', ('AXES_SHAPER_CALIBRATION', self.cmd_AXES_SHAPER_CALIBRATION, ST_COMMANDS['AXES_SHAPER_CALIBRATION']),
self.cmd_CREATE_VIBRATIONS_PROFILE, ('CREATE_VIBRATIONS_PROFILE', self.cmd_CREATE_VIBRATIONS_PROFILE, ST_COMMANDS['CREATE_VIBRATIONS_PROFILE']),
'Perform a set of movements to measure the orientation of the accelerometer and help you set the best axes_map configuration for your printer',
),
] ]
command_descriptions = {name: desc for name, _, desc in commands}
for name, command, description in commands: # Register Shake&Tune's measurement commands using the official Klipper API (gcode.register_command)
gcode.register_command(f'_{name}' if show_macros else name, command, desc=description) # 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)
# Load the dummy macros with their description in order to show them in the web interfaces # Then, a hack to inject the macros into Klipper's config system in order to show them in the web
if show_macros: # interfaces. This is not a good way to do it, but it's the only way to do it for now to get
pconfig = self._printer.lookup_object('configfile') # 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__)) dirname = os.path.dirname(os.path.realpath(__file__))
filename = os.path.join(dirname, 'dummy_macros.cfg') filename = os.path.join(dirname, 'dummy_macros.cfg')
try: try:
dummy_macros_cfg = pconfig.read_config(filename) dummy_macros_cfg = configfile.read_config(filename)
except Exception as err: 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 '): for gcode_macro in dummy_macros_cfg.get_prefix_sections('gcode_macro '):
gcode_macro_name = gcode_macro.get_name() gcode_macro_name = gcode_macro.get_name()
# Replace the dummy description by the one 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] 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) gcode_macro.fileconfig.set(gcode_macro_name, 'description', description)
# Add the section to the Klipper configuration object with all its options # Add the section to the Klipper configuration object with all its options
if not config.fileconfig.has_section(gcode_macro_name.lower()): if not self._config.fileconfig.has_section(gcode_macro_name.lower()):
config.fileconfig.add_section(gcode_macro_name.lower()) self._config.fileconfig.add_section(gcode_macro_name.lower())
for option in gcode_macro.fileconfig.options(gcode_macro_name): for option in gcode_macro.fileconfig.options(gcode_macro_name):
value = gcode_macro.fileconfig.get(gcode_macro_name, option) value = gcode_macro.fileconfig.get(gcode_macro_name, option)
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 # 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 # Finally, load the section within the printer objects
self._printer.load_object(config, gcode_macro_name.lower()) self._printer.load_object(self._config, gcode_macro_name.lower())
def _on_klippy_connect(self) -> None:
# Check if the resonance_tester object is available in the printer
# configuration as it is required for Shake&Tune to work properly
res_tester = self._printer.lookup_object('resonance_tester', None)
if res_tester is None:
raise self._config.error(
'No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune!'
)
# ------------------------------------------------------------------------------------------
# ------------------------------------------------------------------------------------------
# Following are all the Shake&Tune commands that are registered to the Klipper console
# ------------------------------------------------------------------------------------------
# ------------------------------------------------------------------------------------------
def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None: def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
static_freq_graph_creator = StaticGraphCreator(self._config) static_freq_graph_creator = StaticGraphCreator(self._st_config)
st_process = ShakeTuneProcess(self._config, static_freq_graph_creator, self.timeout) st_process = ShakeTuneProcess(
excitate_axis_at_freq(gcmd, self._pconfig, st_process) self._st_config,
self._printer.get_reactor(),
static_freq_graph_creator,
self.timeout,
)
excitate_axis_at_freq(gcmd, self._config, st_process)
def cmd_AXES_MAP_CALIBRATION(self, gcmd) -> None: def cmd_AXES_MAP_CALIBRATION(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
axes_map_graph_creator = AxesMapGraphCreator(self._config) axes_map_graph_creator = AxesMapGraphCreator(self._st_config)
st_process = ShakeTuneProcess(self._config, axes_map_graph_creator, self.timeout) st_process = ShakeTuneProcess(
axes_map_calibration(gcmd, self._pconfig, st_process) self._st_config,
self._printer.get_reactor(),
axes_map_graph_creator,
self.timeout,
)
axes_map_calibration(gcmd, self._config, st_process)
def cmd_COMPARE_BELTS_RESPONSES(self, gcmd) -> None: def cmd_COMPARE_BELTS_RESPONSES(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
belt_graph_creator = BeltsGraphCreator(self._config) belt_graph_creator = BeltsGraphCreator(self._st_config)
st_process = ShakeTuneProcess(self._config, belt_graph_creator, self.timeout) st_process = ShakeTuneProcess(
compare_belts_responses(gcmd, self._pconfig, st_process) self._st_config,
self._printer.get_reactor(),
belt_graph_creator,
self.timeout,
)
compare_belts_responses(gcmd, self._config, st_process)
def cmd_AXES_SHAPER_CALIBRATION(self, gcmd) -> None: def cmd_AXES_SHAPER_CALIBRATION(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
shaper_graph_creator = ShaperGraphCreator(self._config) shaper_graph_creator = ShaperGraphCreator(self._st_config)
st_process = ShakeTuneProcess(self._config, shaper_graph_creator, self.timeout) st_process = ShakeTuneProcess(
axes_shaper_calibration(gcmd, self._pconfig, st_process) self._st_config,
self._printer.get_reactor(),
shaper_graph_creator,
self.timeout,
)
axes_shaper_calibration(gcmd, self._config, st_process)
def cmd_CREATE_VIBRATIONS_PROFILE(self, gcmd) -> None: def cmd_CREATE_VIBRATIONS_PROFILE(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
vibration_profile_creator = VibrationsGraphCreator(self._config) vibration_profile_creator = VibrationsGraphCreator(self._st_config)
st_process = ShakeTuneProcess(self._config, vibration_profile_creator, self.timeout) st_process = ShakeTuneProcess(
create_vibrations_profile(gcmd, self._pconfig, st_process) self._st_config,
self._printer.get_reactor(),
vibration_profile_creator,
self.timeout,
)
create_vibrations_profile(gcmd, self._config, st_process)

View File

@@ -8,10 +8,10 @@
# vibration analysis processes in separate system processes. # vibration analysis processes in separate system processes.
import multiprocessing
import os import os
import threading import threading
import traceback import traceback
from multiprocessing import Process
from typing import Optional from typing import Optional
from .helpers.console_output import ConsoleOutput from .helpers.console_output import ConsoleOutput
@@ -19,11 +19,11 @@ from .shaketune_config import ShakeTuneConfig
class ShakeTuneProcess: class ShakeTuneProcess:
def __init__(self, config: ShakeTuneConfig, graph_creator, timeout: Optional[float] = None) -> None: def __init__(self, st_config: ShakeTuneConfig, reactor, graph_creator, timeout: Optional[float] = None) -> None:
self._config = config self._config = st_config
self._reactor = reactor
self.graph_creator = graph_creator self.graph_creator = graph_creator
self._timeout = timeout self._timeout = timeout
self._process = None self._process = None
def get_graph_creator(self): def get_graph_creator(self):
@@ -31,22 +31,32 @@ class ShakeTuneProcess:
def run(self) -> None: def run(self) -> None:
# Start the target function in a new process (a thread is known to cause issues with Klipper and CANbus due to the GIL) # Start the target function in a new process (a thread is known to cause issues with Klipper and CANbus due to the GIL)
self._process = multiprocessing.Process( self._process = Process(target=self._shaketune_process_wrapper, args=(self.graph_creator, self._timeout))
target=self._shaketune_process_wrapper, args=(self.graph_creator, self._timeout)
)
self._process.start() self._process.start()
def wait_for_completion(self) -> None: def wait_for_completion(self) -> None:
if self._process is not None: if self._process is None:
self._process.join() return # Nothing to wait for
eventtime = self._reactor.monotonic()
endtime = eventtime + self._timeout
complete = False
while eventtime < endtime:
eventtime = self._reactor.pause(eventtime + 0.05)
if not self._process.is_alive():
complete = True
break
if not complete:
self._handle_timeout()
# This function is a simple wrapper to start the Shake&Tune process. It's needed in order to get the timeout # This function is a simple wrapper to start the Shake&Tune process. It's needed in order to get the timeout
# as a Timer in a thread INSIDE the Shake&Tune child process to not interfere with the main Klipper process # as a Timer in a thread INSIDE the Shake&Tune child process to not interfere with the main Klipper process
def _shaketune_process_wrapper(self, graph_creator, timeout) -> None: def _shaketune_process_wrapper(self, graph_creator, timeout) -> None:
if timeout is not None: if timeout is not None:
# Add 5 seconds to the timeout for safety. The goal is to avoid the Timer to finish before the
# Shake&Tune process is done in case we call the wait_for_completion() function that uses Klipper's reactor.
timeout += 5
timer = threading.Timer(timeout, self._handle_timeout) timer = threading.Timer(timeout, self._handle_timeout)
timer.start() timer.start()
try: try:
self._shaketune_process(graph_creator) self._shaketune_process(graph_creator)
finally: finally:
@@ -58,10 +68,12 @@ class ShakeTuneProcess:
os._exit(1) # Forcefully exit the process os._exit(1) # Forcefully exit the process
def _shaketune_process(self, graph_creator) -> None: def _shaketune_process(self, graph_creator) -> None:
# Trying to reduce Shake&Tune process priority to avoid slowing down the main Klipper process # Reducing Shake&Tune process priority by putting the scheduler into batch mode with low priority. This in order to avoid
# as this could lead to random "Timer too close" errors when already running CANbus, etc... # slowing down the main Klipper process as this can lead to random "Timer too close" or "Move queue overflow" errors
# when also already running CANbus, neopixels and other consumming stuff in Klipper's main process.
try: try:
os.nice(19) param = os.sched_param(os.sched_get_priority_min(os.SCHED_BATCH))
os.sched_setscheduler(0, os.SCHED_BATCH, param)
except Exception: except Exception:
ConsoleOutput.print('Warning: failed reducing Shake&Tune process priority, continuing...') ConsoleOutput.print('Warning: failed reducing Shake&Tune process priority, continuing...')