69 Commits

Author SHA1 Message Date
Félix Boisselier
8d59e33775 code cleanup 2024-06-29 23:56:16 +02:00
Félix Boisselier
3d919898a6 added a bit of distance for TMC parameters in vib header 2024-06-29 23:26:25 +02:00
Félix Boisselier
c19af1c457 adapted motor profile to be independant 2024-06-29 23:20:00 +02:00
Félix Boisselier
e3e24184be small code cleaning and fixes 2024-06-29 18:55:58 +02:00
Félix Boisselier
a49a571911 motor resonances filters added 2024-06-23 23:30:37 +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
Félix Boisselier
c3fcc976c1 Merge pull request #115 from Frix-x/develop
Shake&Tune v4.0.0
2024-06-13 10:00:41 +02:00
Félix Boisselier
0ea659c4ce fixed undefined jinja variables when calling from the WebUI 2024-06-12 22:33:48 +02:00
Félix Boisselier
8408152093 added standard header to .py files 2024-06-12 13:17:07 +02:00
Félix Boisselier
ecd57ea3dc fixed items from code review 2024-06-11 21:26:15 +02:00
Félix Boisselier
6d1e53d4d1 Merge pull request #41 from Frix-x/dependabot/pip/gitpython-3.1.41
Bump gitpython from 3.1.40 to 3.1.41
2024-06-11 11:35:10 +02:00
Félix Boisselier
6db1d394ae Code cleanup before release (#114) 2024-06-10 23:42:10 +02:00
Félix Boisselier
9739f6220e Docs update for v4 (#113) 2024-06-09 23:12:36 +02:00
Félix Boisselier
da51082b44 Static freq optional graphs (#112) 2024-06-08 17:02:28 +02:00
Félix Boisselier
4384a8339e improved the amplitude delta percentage computation on belt graph 2024-06-07 18:43:17 +02:00
Félix Boisselier
f9394c5706 current unit for freq target 2024-06-05 11:58:02 +02:00
Félix Boisselier
abd3e2d98f fix macro parameter None value crashing when it's an empty string 2024-06-04 23:06:52 +02:00
Félix Boisselier
73b93107d7 fix crash when ACCEL_CHIP is an empty string 2024-06-04 23:04:11 +02:00
Félix Boisselier
867d0c90a0 fix gcmd error that wasn't sent correctly 2024-06-04 22:09:54 +02:00
Félix Boisselier
f9e5d64eac added PWM freq targets to vibration graphs 2024-06-04 18:45:21 +02:00
Félix Boisselier
bb6907e5e6 AXES_MAP detection reworked (#110) 2024-06-04 18:31:23 +02:00
Félix Boisselier
0fbdef4a17 fixed typo in ShakeTuneProcess 2024-06-03 19:23:12 +02:00
Félix Boisselier
d22b3fcbef switched to multiprocessing instead of threading 2024-06-03 17:49:28 +02:00
Félix Boisselier
9ce82e3dc0 updated documentation about shaper recommendations 2024-05-26 22:43:46 +02:00
Félix Boisselier
c892e1a03d fixed last commit about changes in recommendations 2024-05-26 22:23:49 +02:00
Félix Boisselier
1d3d22ef38 modified shaper recomendation to account for changes when injecting damping ratio 2024-05-24 22:57:18 +02:00
Félix Boisselier
b6ec4d0229 fixed vibration accelermeter chip selection logic 2024-05-24 21:17:58 +02:00
Félix Boisselier
d15e06b0c8 improved automatic accelerometer choice and removed filemanager.py 2024-05-24 20:18:41 +02:00
Félix Boisselier
e680a7ee6b fix corexz vibration tool error 2024-05-24 16:08:25 +02:00
Félix Boisselier
ee9d9f994a fixed S&T thread and potential timer too close errors 2024-05-24 14:49:27 +02:00
Félix Boisselier
0e96b36703 fixed CoreXZ kinematics in vibrations measurement tool 2024-05-24 09:26:29 +02:00
Félix Boisselier
339e8a6d7c fixed belt similarity and MHI for corexz printers 2024-05-23 23:07:52 +02:00
Félix Boisselier
150a8ee030 added CoreXZ support for the belt comparison tool 2024-05-23 22:46:43 +02:00
Félix Boisselier
8117e604c5 fixed macros injection into Klipper objects 2024-05-22 23:35:22 +02:00
Félix Boisselier
83655864e8 added dummy macros automatic loading 2024-05-20 16:38:30 +02:00
Félix Boisselier
a9f545887c Merge pull request #105 from Frix-x/cross-belts
Cross belts plot
2024-05-19 13:03:57 +02:00
Félix Boisselier
1d4c68265d small bug-fixes and greek alphabet for paired peaks 2024-05-19 13:02:56 +02:00
Félix Boisselier
4f100eac8f Merge branch 'develop' into cross-belts 2024-05-19 12:10:26 +02:00
Félix Boisselier
9f4da8b80d added accel_per_hertz to the graphs 2024-05-19 11:36:27 +02:00
Félix Boisselier
36b6965979 Merge pull request #106 from Frix-x/klipper-module-macros
Run S&T as a real Klipper extras plugin
2024-05-19 11:08:01 +02:00
Félix Boisselier
55895c1507 fixed most of the bugs now as a Klipper plugin 2024-05-19 11:07:25 +02:00
Félix Boisselier
dd08162616 added back the vibrations profile measurement 2024-05-15 13:51:23 +02:00
Félix Boisselier
a37ece7ece rename folders in measurement and post-processing 2024-05-13 17:22:05 +02:00
Félix Boisselier
375190610c using my own resonance tester algorithm 2024-05-13 17:15:17 +02:00
Félix Boisselier
187ba13c98 added my own accelerometer interface 2024-05-12 18:50:31 +02:00
Félix Boisselier
30a1910513 Klipper plugin refactoring with embedded macros 2024-05-12 17:13:47 +02:00
Félix Boisselier
d9060fed3b cleaning old Shake&Tune venv and configs 2024-05-09 12:26:43 +02:00
Oz Elentok
3a0c0c4173 Run ShakeTune as an in-process Klipper module (#100)
* feat: Run ShakeTune as an in-process Klipper module
* feat: install shaketune dependencies to klipper venv
* refactor: replace print_with_c_locale with klipper console output with stdout fallback
2024-05-08 23:02:23 +02:00
Félix Boisselier
e4f80a6f2e removed debug message 2024-05-08 22:18:54 +02:00
Félix Boisselier
e3a2a488b1 fixed the MHI LUT behavior when MHI=0 2024-05-08 22:17:22 +02:00
Félix Boisselier
efc0b86019 adjusted MHI calculation 2024-05-08 22:11:56 +02:00
Félix Boisselier
8753291cf7 documentation reviewed for cross-belts plots 2024-05-08 14:44:48 +02:00
Félix Boisselier
20ff9814b3 check the kinematics type for belt graph and move in the right direction 2024-05-06 14:42:52 +02:00
Félix Boisselier
303ed7060c fixed darkmode for tuning workflow mermaid 2024-05-06 13:43:51 +02:00
Félix Boisselier
e1a7681a4a typo in doc 2024-05-06 11:56:31 +02:00
Félix Boisselier
8fff10ada2 some documentation and tuning workflow 2024-05-06 11:55:38 +02:00
Félix Boisselier
8e517f2ca3 updated requirement to fix S&T on older Python version 2024-05-05 15:50:59 +02:00
Félix Boisselier
78dcce412f added kinematics info to belt comparison graph 2024-05-03 20:22:43 +02:00
Félix Boisselier
17b7e1a2d2 cross-belt comparison plot 2024-05-03 20:08:17 +02:00
Félix Boisselier
ab5600804f better CSV file filtering 2024-04-30 18:28:17 +02:00
Félix Boisselier
56a5502d81 fixed potential bug when moving files accross filesystems 2024-04-29 13:31:37 +02:00
dependabot[bot]
80269b791f Bump gitpython from 3.1.40 to 3.1.41
Bumps [gitpython](https://github.com/gitpython-developers/GitPython) from 3.1.40 to 3.1.41.
- [Release notes](https://github.com/gitpython-developers/GitPython/releases)
- [Changelog](https://github.com/gitpython-developers/GitPython/blob/main/CHANGES)
- [Commits](https://github.com/gitpython-developers/GitPython/compare/3.1.40...3.1.41)

---
updated-dependencies:
- dependency-name: gitpython
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-01-11 10:13:57 +00:00
56 changed files with 3934 additions and 2202 deletions

View File

@@ -1,60 +0,0 @@
############################################################
###### AXE_MAP DETECTION AND ACCELEROMETER VALIDATION ######
############################################################
# Written by Frix_x#0161 #
[gcode_macro AXES_MAP_CALIBRATION]
gcode:
{% set z_height = params.Z_HEIGHT|default(20)|int %} # z height to put the toolhead before starting the movements
{% set speed = params.SPEED|default(80)|float * 60 %} # feedrate for the movements
{% set accel = params.ACCEL|default(1500)|int %} # accel value used to move on the pattern
{% set feedrate_travel = params.TRAVEL_SPEED|default(120)|int * 60 %} # travel feedrate between moves
{% set accel_chip = params.ACCEL_CHIP|default("adxl345") %} # ADXL chip name in the config
{% set mid_x = printer.toolhead.axis_maximum.x|float / 2 %}
{% set mid_y = printer.toolhead.axis_maximum.y|float / 2 %}
{% set accel = [accel, printer.configfile.settings.printer.max_accel]|min %}
{% set old_accel = printer.toolhead.max_accel %}
{% set old_cruise_ratio = printer.toolhead.minimum_cruise_ratio %}
{% set old_sqv = printer.toolhead.square_corner_velocity %}
{% if not 'xyz' in printer.toolhead.homed_axes %}
{ action_raise_error("Must Home printer first!") }
{% endif %}
{action_respond_info("")}
{action_respond_info("Starting accelerometer axe_map calibration")}
{action_respond_info("This operation can not be interrupted by normal means. Hit the \"emergency stop\" button to stop it if needed")}
{action_respond_info("")}
SAVE_GCODE_STATE NAME=STATE_AXESMAP_CALIBRATION
G90
# Set the wanted acceleration values (not too high to avoid oscillation, not too low to be able to reach constant speed on each segments)
SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY={[(accel / 1000), 5.0]|max}
# Going to the start position
G1 Z{z_height} F{feedrate_travel / 8}
G1 X{mid_x - 15} Y{mid_y - 15} F{feedrate_travel}
G4 P500
ACCELEROMETER_MEASURE CHIP={accel_chip}
G4 P1000 # This first waiting time is to record the background accelerometer noise before moving
G1 X{mid_x + 15} F{speed}
G4 P1000
G1 Y{mid_y + 15} F{speed}
G4 P1000
G1 Z{z_height + 15} F{speed}
G4 P1000
ACCELEROMETER_MEASURE CHIP={accel_chip} NAME=axemap
RESPOND MSG="Analysis of the movements..."
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type axesmap --accel {accel|int} --chip_name {accel_chip}"
# Restore the previous acceleration values
SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_cruise_ratio} SQUARE_CORNER_VELOCITY={old_sqv}
RESTORE_GCODE_STATE NAME=STATE_AXESMAP_CALIBRATION

View File

@@ -1,54 +0,0 @@
################################################
###### STANDARD INPUT_SHAPER CALIBRATIONS ######
################################################
# Written by Frix_x#0161 #
[gcode_macro AXES_SHAPER_CALIBRATION]
description: Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter
gcode:
{% set min_freq = params.FREQ_START|default(5)|float %}
{% set max_freq = params.FREQ_END|default(133.3)|float %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1)|float %}
{% set axis = params.AXIS|default("all")|string|lower %}
{% set scv = params.SCV|default(None) %}
{% set max_sm = params.MAX_SMOOTHING|default(None) %}
{% set keep_results = params.KEEP_N_RESULTS|default(3)|int %}
{% set keep_csv = params.KEEP_CSV|default(0)|int %}
{% set X, Y = False, False %}
{% if axis == "all" %}
{% set X, Y = True, True %}
{% elif axis == "x" %}
{% set X = True %}
{% elif axis == "y" %}
{% set Y = True %}
{% else %}
{ action_raise_error("AXIS selection invalid. Should be either all, x or y!") }
{% endif %}
{% if scv is none or scv == "" %}
{% set scv = printer.toolhead.square_corner_velocity %}
{% endif %}
{% if max_sm == "" %}
{% set max_sm = none %}
{% endif %}
{% if X %}
TEST_RESONANCES AXIS=X OUTPUT=raw_data NAME=x FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
M400
RESPOND MSG="X axis frequency profile generation..."
RESPOND MSG="This may take some time (1-3min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type shaper --scv {scv} {% if max_sm is not none %}--max_smoothing {max_sm}{% endif %} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}"
{% endif %}
{% if Y %}
TEST_RESONANCES AXIS=Y OUTPUT=raw_data NAME=y FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
M400
RESPOND MSG="Y axis frequency profile generation..."
RESPOND MSG="This may take some time (1-3min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type shaper --scv {scv} {% if max_sm is not none %}--max_smoothing {max_sm}{% endif %} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}"
{% endif %}

View File

@@ -1,23 +0,0 @@
################################################
###### STANDARD INPUT_SHAPER CALIBRATIONS ######
################################################
# Written by Frix_x#0161 #
[gcode_macro COMPARE_BELTS_RESPONSES]
description: Perform a custom half-axis test to analyze and compare the frequency profiles of individual belts on CoreXY printers
gcode:
{% set min_freq = params.FREQ_START|default(5)|float %}
{% set max_freq = params.FREQ_END|default(133.33)|float %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1)|float %}
{% set keep_results = params.KEEP_N_RESULTS|default(3)|int %}
{% set keep_csv = params.KEEP_CSV|default(0)|int %}
TEST_RESONANCES AXIS=1,1 OUTPUT=raw_data NAME=b FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
M400
TEST_RESONANCES AXIS=1,-1 OUTPUT=raw_data NAME=a FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
M400
RESPOND MSG="Belts comparative frequency profile generation..."
RESPOND MSG="This may take some time (3-5min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type belts {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}"

View File

@@ -1,24 +0,0 @@
################################################
###### STANDARD INPUT_SHAPER CALIBRATIONS ######
################################################
# Written by Frix_x#0161 #
[gcode_macro EXCITATE_AXIS_AT_FREQ]
description: Maintain a specified excitation frequency for a period of time to diagnose and locate a source of vibration
gcode:
{% set frequency = params.FREQUENCY|default(25)|int %}
{% set time = params.TIME|default(10)|int %}
{% set axis = params.AXIS|default("x")|string|lower %}
{% if axis not in ["x", "y", "a", "b"] %}
{ action_raise_error("AXIS selection invalid. Should be either x, y, a or b!") }
{% endif %}
{% if axis == "a" %}
{% set axis = "1,-1" %}
{% elif axis == "b" %}
{% set axis = "1,1" %}
{% endif %}
TEST_RESONANCES OUTPUT=raw_data AXIS={axis} FREQ_START={frequency-1} FREQ_END={frequency+1} HZ_PER_SEC={1/(time/3)}
M400

View File

@@ -1,214 +0,0 @@
#########################################
###### MACHINE VIBRATIONS ANALYSIS ######
#########################################
# Written by Frix_x#0161 #
[gcode_macro CREATE_VIBRATIONS_PROFILE]
gcode:
{% set size = params.SIZE|default(100)|int %} # size of the circle where the angled lines are done
{% set z_height = params.Z_HEIGHT|default(20)|int %} # z height to put the toolhead before starting the movements
{% set max_speed = params.MAX_SPEED|default(200)|float * 60 %} # maximum feedrate for the movements
{% set speed_increment = params.SPEED_INCREMENT|default(2)|float * 60 %} # feedrate increment between each move
{% set feedrate_travel = params.TRAVEL_SPEED|default(200)|int * 60 %} # travel feedrate between moves
{% set accel = params.ACCEL|default(3000)|int %} # accel value used to move on the pattern
{% set accel_chip = params.ACCEL_CHIP|default("adxl345") %} # ADXL chip name in the config
{% set keep_results = params.KEEP_N_RESULTS|default(3)|int %}
{% set keep_csv = params.KEEP_CSV|default(0)|int %}
{% set mid_x = printer.toolhead.axis_maximum.x|float / 2 %}
{% set mid_y = printer.toolhead.axis_maximum.y|float / 2 %}
{% set min_speed = 2 * 60 %} # minimum feedrate for the movements is set to 2mm/s
{% set nb_speed_samples = ((max_speed - min_speed) / speed_increment + 1) | int %}
{% set accel = [accel, printer.configfile.settings.printer.max_accel]|min %}
{% set old_accel = printer.toolhead.max_accel %}
{% set old_cruise_ratio = printer.toolhead.minimum_cruise_ratio %}
{% set old_sqv = printer.toolhead.square_corner_velocity %}
{% set kinematics = printer.configfile.settings.printer.kinematics %}
{% if not 'xyz' in printer.toolhead.homed_axes %}
{ action_raise_error("Must Home printer first!") }
{% endif %}
{% if params.SPEED_INCREMENT|default(2)|float * 100 != (params.SPEED_INCREMENT|default(2)|float * 100)|int %}
{ action_raise_error("Only 2 decimal digits are allowed for SPEED_INCREMENT") }
{% endif %}
{% if (size / (max_speed / 60)) < 0.25 %}
{ action_raise_error("SIZE is too small for this MAX_SPEED. Increase SIZE or decrease MAX_SPEED!") }
{% endif %}
{action_respond_info("")}
{action_respond_info("Starting machine vibrations profile measurement")}
{action_respond_info("This operation can not be interrupted by normal means. Hit the \"emergency stop\" button to stop it if needed")}
{action_respond_info("")}
SAVE_GCODE_STATE NAME=CREATE_VIBRATIONS_PROFILE
G90
# Set the wanted acceleration values (not too high to avoid oscillation, not too low to be able to reach constant speed on each segments)
SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY={[(accel / 1000), 5.0]|max}
# Going to the start position
G1 Z{z_height} F{feedrate_travel / 10}
G1 X{mid_x } Y{mid_y} F{feedrate_travel}
{% if kinematics == "cartesian" %}
# Cartesian motors are on X and Y axis directly
RESPOND MSG="Cartesian kinematics mode"
{% set main_angles = [0, 90] %}
{% elif kinematics == "corexy" %}
# CoreXY motors are on A and B axis (45 and 135 degrees)
RESPOND MSG="CoreXY kinematics mode"
{% set main_angles = [45, 135] %}
{% else %}
{ action_raise_error("Only Cartesian and CoreXY kinematics are supported at the moment for the vibrations measurement tool!") }
{% endif %}
{% set pi = (3.141592653589793) | float %}
{% set tau = (pi * 2) | float %}
{% for curr_angle in main_angles %}
{% for curr_speed_sample in range(0, nb_speed_samples) %}
{% set curr_speed = min_speed + curr_speed_sample * speed_increment %}
{% set rad_angle_full = (curr_angle|float * pi / 180) %}
# -----------------------------------------------------------------------------------------------------------
# Here are some maths to approximate the sin and cos values of rad_angle in Jinja
# Thanks a lot to Aubey! for sharing the idea of using hardcoded Taylor series and
# the associated bit of code to do it easily! This is pure madness!
{% set rad_angle = ((rad_angle_full % tau) - (tau / 2)) | float %}
{% if rad_angle < (-(tau / 4)) %}
{% set rad_angle = (rad_angle + (tau / 2)) | float %}
{% set final_mult = (-1) %}
{% elif rad_angle > (tau / 4) %}
{% set rad_angle = (rad_angle - (tau / 2)) | float %}
{% set final_mult = (-1) %}
{% else %}
{% set final_mult = (1) %}
{% endif %}
{% set sin0 = (rad_angle) %}
{% set sin1 = ((rad_angle ** 3) / 6) | float %}
{% set sin2 = ((rad_angle ** 5) / 120) | float %}
{% set sin3 = ((rad_angle ** 7) / 5040) | float %}
{% set sin4 = ((rad_angle ** 9) / 362880) | float %}
{% set sin5 = ((rad_angle ** 11) / 39916800) | float %}
{% set sin6 = ((rad_angle ** 13) / 6227020800) | float %}
{% set sin7 = ((rad_angle ** 15) / 1307674368000) | float %}
{% set sin = (-(sin0 - sin1 + sin2 - sin3 + sin4 - sin5 + sin6 - sin7) * final_mult) | float %}
{% set cos0 = (1) | float %}
{% set cos1 = ((rad_angle ** 2) / 2) | float %}
{% set cos2 = ((rad_angle ** 4) / 24) | float %}
{% set cos3 = ((rad_angle ** 6) / 720) | float %}
{% set cos4 = ((rad_angle ** 8) / 40320) | float %}
{% set cos5 = ((rad_angle ** 10) / 3628800) | float %}
{% set cos6 = ((rad_angle ** 12) / 479001600) | float %}
{% set cos7 = ((rad_angle ** 14) / 87178291200) | float %}
{% set cos = (-(cos0 - cos1 + cos2 - cos3 + cos4 - cos5 + cos6 - cos7) * final_mult) | float %}
# -----------------------------------------------------------------------------------------------------------
# Reduce the segments length for the lower speed range (0-100mm/s). The minimum length is 1/3 of the SIZE and is gradually increased
# to the nominal SIZE at 100mm/s. No further size changes are made above this speed. The goal is to ensure that the print head moves
# enough to collect enough data for vibration analysis, without doing unnecessary distance to save time. At higher speeds, the full
# segments lengths are used because the head moves faster and travels more distance in the same amount of time and we want enough data
{% if curr_speed < (100 * 60) %}
{% set segment_length_multiplier = 1/5 + 4/5 * (curr_speed / 60) / 100 %}
{% else %}
{% set segment_length_multiplier = 1 %}
{% endif %}
# Calculate angle coordinates using trigonometry and length multiplier and move to start point
{% set dx = (size / 2) * cos * segment_length_multiplier %}
{% set dy = (size / 2) * sin * segment_length_multiplier %}
G1 X{mid_x - dx} Y{mid_y - dy} F{feedrate_travel}
# Adjust the number of back and forth movements based on speed to also save time on lower speed range
# 3 movements are done by default, reduced to 2 between 150-250mm/s and to 1 under 150mm/s.
{% set movements = 3 %}
{% if curr_speed < (150 * 60) %}
{% set movements = 1 %}
{% elif curr_speed < (250 * 60) %}
{% set movements = 2 %}
{% endif %}
ACCELEROMETER_MEASURE CHIP={accel_chip}
# Back and forth movements to record the vibrations at constant speed in both direction
{% for n in range(movements) %}
G1 X{mid_x + dx} Y{mid_y + dy} F{curr_speed}
G1 X{mid_x - dx} Y{mid_y - dy} F{curr_speed}
{% endfor %}
ACCELEROMETER_MEASURE CHIP={accel_chip} NAME=an{("%.2f" % curr_angle|float)|replace('.','_')}sp{("%.2f" % (curr_speed / 60)|float)|replace('.','_')}
G4 P300
M400
{% endfor %}
{% endfor %}
# Restore the previous acceleration values
SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_cruise_ratio} SQUARE_CORNER_VELOCITY={old_sqv}
# Extract the TMC names and configuration
{% set ns_x = namespace(path='') %}
{% set ns_y = namespace(path='') %}
{% for item in printer %}
{% set parts = item.split() %}
{% if parts|length == 2 and parts[0].startswith('tmc') and parts[0][3:].isdigit() %}
{% if parts[1] == 'stepper_x' %}
{% set ns_x.path = parts[0] %}
{% elif parts[1] == 'stepper_y' %}
{% set ns_y.path = parts[0] %}
{% endif %}
{% endif %}
{% endfor %}
{% if ns_x.path and ns_y.path %}
{% set metadata =
"stepper_x_tmc:" ~ ns_x.path ~ "|"
"stepper_x_run_current:" ~ (printer[ns_x.path + ' stepper_x'].run_current | round(2) | string) ~ "|"
"stepper_x_hold_current:" ~ (printer[ns_x.path + ' stepper_x'].hold_current | round(2) | string) ~ "|"
"stepper_y_tmc:" ~ ns_y.path ~ "|"
"stepper_y_run_current:" ~ (printer[ns_y.path + ' stepper_y'].run_current | round(2) | string) ~ "|"
"stepper_y_hold_current:" ~ (printer[ns_y.path + ' stepper_y'].hold_current | round(2) | string) ~ "|"
%}
{% set autotune_x = printer.configfile.config['autotune_tmc stepper_x'] if 'autotune_tmc stepper_x' in printer.configfile.config else none %}
{% set autotune_y = printer.configfile.config['autotune_tmc stepper_y'] if 'autotune_tmc stepper_y' in printer.configfile.config else none %}
{% if autotune_x and autotune_y %}
{% set stepper_x_voltage = autotune_x.voltage if autotune_x.voltage else '24.0' %}
{% set stepper_y_voltage = autotune_y.voltage if autotune_y.voltage else '24.0' %}
{% set metadata = metadata ~
"autotune_enabled:True|"
"stepper_x_motor:" ~ autotune_x.motor ~ "|"
"stepper_x_voltage:" ~ stepper_x_voltage ~ "|"
"stepper_y_motor:" ~ autotune_y.motor ~ "|"
"stepper_y_voltage:" ~ stepper_y_voltage ~ "|"
%}
{% else %}
{% set metadata = metadata ~ "autotune_enabled:False|" %}
{% endif %}
DUMP_TMC STEPPER=stepper_x
DUMP_TMC STEPPER=stepper_y
{% else %}
{ action_respond_info("No TMC drivers found for X and Y steppers") }
{% endif %}
RESPOND MSG="Machine vibrations profile generation..."
RESPOND MSG="This may take some time (3-5min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type vibrations --accel {accel|int} --kinematics {kinematics} {% if metadata %}--metadata {metadata}{% endif %} --chip_name {accel_chip} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}"
RESTORE_GCODE_STATE NAME=CREATE_VIBRATIONS_PROFILE

View File

@@ -1,10 +0,0 @@
#!/usr/bin/env bash
# This script is used to run the Shake&Tune Python scripts as a module
# from the project root directory using its virtual environment
# Usage: ./shaketune.sh <args>
source ~/klippain_shaketune-env/bin/activate
cd ~/klippain_shaketune
python -m src.is_workflow "$@"
deactivate

View File

@@ -1,6 +0,0 @@
[gcode_shell_command shaketune]
command: ~/printer_data/config/K-ShakeTune/shaketune.sh
timeout: 600.0
verbose: True
[respond]

View File

@@ -1,46 +1,57 @@
# Klipper Shake&Tune Module # Klipper Shake&Tune plugin
This "Shake&Tune" repository is a standalone module from the [Klippain](https://github.com/Frix-x/klippain) ecosystem, designed to automate and calibrate the input shaper system on your Klipper 3D printer with a streamlined workflow and insightful vizualisations. This can be installed on any Klipper machine. It is not limited to those using Klippain. Shake&Tune is a Klipper plugin from the [Klippain](https://github.com/Frix-x/klippain) ecosystem, designed to create insightful visualizations to help you troubleshoot your mechanical problems and give you tools to better calibrate the input shaper filters on your 3D printer. It can be installed on any Klipper machine and is not limited to those using the full Klippain.
Check out the **[detailed documentation here](./docs/README.md)**.
![logo banner](./docs/banner.png) ![logo banner](./docs/banner.png)
It operates in two steps:
1. Utilizing specially tailored Klipper macros, it initiates tests on either the belts or the printer X/Y axis to measure the machine axes behavior. This is basically an automated call to the Klipper `TEST_RESONANCES` macro with custom parameters.
2. Then a custom Python script is called to:
1. Generate insightful and improved graphs, aiding in parameter tuning for the Klipper `[input_shaper]` system (including best shaper choice, resonant frequency and damping ratio) or diagnosing and rectifying mechanical issues (like belt tension, defective bearings, etc..)
2. Relocates the graphs and associated CSV files to your Klipper config folder for easy access via Mainsail/Fluidd to eliminate the need for SSH.
3. Manages the folder by retaining only the most recent results (default setting of keeping the latest three sets).
Check out the **[detailed documentation of the Shake&Tune module here](./docs/README.md)**. You can also look at the documentation for each type of graph by directly clicking on them below to better understand your results and tune your machine!
| [Belts graph](./docs/macros/belts_tuning.md) | [Axis input shaper graphs](./docs/macros/axis_tuning.md) | [Vibrations graph](./docs/macros/vibrations_profile.md) |
|:----------------:|:------------:|:---------------------:|
| [<img src="./docs/images/belts_example.png">](./docs/macros/belts_tuning.md) | [<img src="./docs/images/axis_example.png">](./docs/macros/axis_tuning.md) | [<img src="./docs/images/vibrations_example.png">](./docs/macros/vibrations_profile.md) |
> **Note**:
>
> Be aware that Shake&Tune uses the [Gcode shell command plugin](https://github.com/dw-0/kiauh/blob/master/docs/gcode_shell_command.md) under the hood to call the Python scripts that generate the graphs. While my scripts should be safe, the Gcode shell command plugin also has great potential for abuse if not used carefully for other purposes, since it opens shell access from Klipper.
## Installation ## Installation
Follow these steps to install the Shake&Tune module in your printer: Follow these steps to install Shake&Tune on your printer:
1. Be sure to have a working accelerometer on your machine and a `[resonance_tester]` section defined. You can follow the official [Measuring Resonances Klipper documentation](https://www.klipper3d.org/Measuring_Resonances.html) to configure it. 1. Be sure to have a working accelerometer on your machine and a `[resonance_tester]` section defined. You can follow the official [Measuring Resonances Klipper documentation](https://www.klipper3d.org/Measuring_Resonances.html) to configure it.
1. Install the Shake&Tune package by running over SSH on your printer: 1. Install Shake&Tune by running over SSH on your printer:
```bash ```bash
wget -O - https://raw.githubusercontent.com/Frix-x/klippain-shaketune/main/install.sh | bash wget -O - https://raw.githubusercontent.com/Frix-x/klippain-shaketune/main/install.sh | bash
``` ```
1. Then, append the following to your `printer.cfg` file and restart Klipper (if prefered, you can include only the needed macros: using `*.cfg` is a convenient way to include them all at once): 1. Then, append the following to your `printer.cfg` file and restart Klipper:
``` ```
[include K-ShakeTune/*.cfg] [shaketune]
# result_folder: ~/printer_data/config/ShakeTune_results
# The folder where the results will be stored. It will be created if it doesn't exist.
# number_of_results_to_keep: 3
# The number of results to keep in the result_folder. The oldest results will
# be automatically deleted after each runs.
# keep_raw_csv: False
# If True, the raw CSV files will be kept in the result_folder alongside the
# PNG graphs. If False, they will be deleted and only the graphs will be kept.
# show_macros_in_webui: True
# Mainsail and Fluidd doesn't create buttons for "system" macros that are not in the
# printer.cfg file. If you want to see the macros in the webui, set this to True.
# timeout: 300
# The maximum time in seconds to let Shake&Tune process the CSV files and generate the graphs.
# motor_freq:
# /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\
# Frequencies of X and Y motor resonances to filter them by using
# composite shapers. This requires the `[input_shaper]` config
# section to be defined in your printer.cfg file to work.
# motor_freq_x:
# motor_freq_y:
# /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\
# If motor_freq is not set, these two parameters can be used
# to configure different filters for X and Y motors. The same
# values are supported as for motor_freq parameter.
# motor_damping_ratio: 0.05
# /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\
# Damping ratios for X and Y motor resonances.
# motor_damping_ratio_x:
# motor_damping_ratio_y:
# /!\ This option has limitations in stock Klipper and is best used with DangerKlipper /!\
# If motor_damping_ratio is not set, these two parameters can be used
# to configure different filters for X and Y motors. The same values
# are supported as for motor_damping_ratio parameter.
``` ```
## Usage Don't forget to check out **[Shake&Tune documentation here](./docs/README.md)**.
Ensure your machine is homed, then invoke one of the following macros as needed:
- `AXES_MAP_CALIBRATION` to automatically find Klipper's `axes_map` parameter for your accelerometer orientation (be careful, this is experimental for now and known to give bad results).
- `COMPARE_BELTS_RESPONSES` for a differential belt resonance graph, useful for checking relative belt tensions and belt path behaviors on a CoreXY printer.
- `AXES_SHAPER_CALIBRATION` for standard input shaper graphs, used to mitigate ringing/ghosting by tuning Klipper's input shaper filters.
- `CREATE_VIBRATIONS_PROFILE` for vibrations graphs as a function of toolhead direction and speed, used to find problematic ranges where the printer could be exposed to more VFAs and optimize your slicer speed profiles and TMC driver parameters.
- `EXCITATE_AXIS_AT_FREQ` to maintain a specific excitation frequency, useful to inspect and find out what is resonating.
For further insights on the usage of these macros and the generated graphs, refer to the [K-Shake&Tune module documentation](./docs/README.md).

View File

@@ -1,57 +1,79 @@
# Klippain Shake&Tune module documentation # Shake&Tune documentation
![](./banner_long.png) ![](./banner_long.png)
## Resonance testing
First, check out the **[input shaping and tuning generalities](./is_tuning_generalities.md)** documentation to understand how it all works and what to look for when taking these measurements. When perfecting 3D prints and tuning your printer, there is all that resonance testing stuff that Shake&Tune will try to help you with. But keep in mind that it's part of a complete process, and Shake&Tune alone won't magically make your printer print at lightning speed. Also, when using the tools, **it's important to get back to the original need: good prints**.
Then look at the documentation for each type of graph by clicking on them below tu run the tests and better understand your results to tune your machine! While there are some ideal goals described in this documentation, you need to understand that it's not always possible to achieve them due to a variety of factors unique to each printer, such as assembly precision, components quality and brand, components wear, etc. Even a different accelerometer can give different results. But that's not a problem; the primary goal is to produce clean and satisfactory prints. If your test prints look good and meet your standards, even if the response curves aren't perfect, you're on the right track. **Trust your printer and your print results more than chasing ideal graphs!** If it's satisfactory, there's no need for further adjustments.
| [Belt response comparison](./macros/belts_tuning.md) | [Axis input shaper graphs](./macros/axis_tuning.md) | [Vibrations profile](./macros/vibrations_profile.md) | First, you may want to read the **[input shaping and tuning generalities](./is_tuning_generalities.md)** documentation to understand how it all works and what to look for when taking these measurements.
|:----------------:|:------------:|:---------------------:|
| [<img src="./images/belts_example.png">](./macros/belts_tuning.md) | [<img src="./images/axis_example.png">](./macros/axis_tuning.md) | [<img src="./images/vibrations_example.png">](./macros/vibrations_profile.md) |
## Additional macros ## Shake&Tune macros
### AXES_MAP_CALIBRATION (experimental) | Shake&Tune command | Resulting graphs example |
|:------|:-------:|
|[`AXES_MAP_CALIBRATION`](./macros/axes_map_calibration.md)<br /><br />Verify that your accelerometer is working correctly and automatically find its Klipper's `axes_map` parameter | [<img src="./images/axesmap_example.png">](./macros/axes_map_calibration.md) |
|[`COMPARE_BELTS_RESPONSES`](./macros/compare_belts_responses.md)<br /><br />Generate a differential belt resonance graph to verify relative belt tensions and belt path behaviors on a CoreXY or CoreXZ printer | [<img src="./images/belts_example.png">](./macros/compare_belts_responses.md) |
|[`AXES_SHAPER_CALIBRATION`](./macros/axes_shaper_calibrations.md)<br /><br />Create the usual input shaper graphs to tune Klipper's input shaper filters and reduce ringing/ghosting | [<img src="./images/axis_example.png">](./macros/axes_shaper_calibrations.md) |
|[`CREATE_VIBRATIONS_PROFILE`](./macros/create_vibrations_profile.md)<br /><br />Measure your global machine vibrations as a function of toolhead direction and speed to find problematic ranges where the printer could be exposed to more VFAs in order to optimize your slicer speed profiles and TMC drivers parameters | [<img src="./images/vibrations_example.png">](./macros/create_vibrations_profile.md) |
|[`EXCITATE_AXIS_AT_FREQ`](./macros/excitate_axis_at_freq.md)<br /><br />Maintain a specific excitation frequency, useful to inspect parasite peaks and find out what is resonating | [<img src="./images/excitate_at_freq_example.png">](./macros/excitate_axis_at_freq.md) |
All graphs generated by this package show plots based on accelerometer measurements, typically labeled with the X, Y, and Z axes. It's important to note that if the accelerometer is rotated, its axes may not align correctly with the machine axes, making the plots more difficult to interpret, analyze, and understand. The `AXES_MAP_CALIBRATION` is designed to automatically measure the alignement of the accelerometer in order to set it correctly.
> **Note**: ## Resonance testing workflow
>
> This misalignment doesn't affect the measurements because the total sum across all axes is used to set the input shaper filters. It's just an optional but convenient way to configure Klipper's `[adxl345]` (or whichever accelerometer you have) "axes_map" parameter.
Here are the parameters available when calling this macro: A standard tuning workflow might look something like this:
| parameters | default value | description | ```mermaid
|-----------:|---------------|-------------| %%{
|Z_HEIGHT|20|z height to put the toolhead before starting the movements. Be careful, if your accelerometer is mounted under the nozzle, increase it to avoid crashing it on the bed of the machine| init: {
|SPEED|80|speed of the toolhead in mm/s for the movements| 'theme': 'base',
|ACCEL|1500 (or max printer accel)|accel in mm/s^2 used for all the moves| 'themeVariables': {
|TRAVEL_SPEED|120|speed in mm/s used for all the travels moves| 'lineColor': '#232323',
|ACCEL_CHIP|"adxl345"|accelerometer chip name in the config| 'primaryTextColor': '#F2055C',
'secondaryColor': '#D3D3D3',
'tertiaryColor': '#FFFFFF'
}
}
}%%
The machine will move slightly in +X, +Y, and +Z, and output in the console: `Detected axes_map: -z,y,x`. flowchart TB
subgraph Tuning Workflow
direction LR
start([Start]) --> tensionBelts[Tension your\nbelts as best\n as possible]
checkmotion --> tensionBelts
tensionBelts --> SnT_Belts[Run Shake&Tune\nbelts comparison tool]
SnT_Belts --> goodbelts{Check the documentation\nDoes belts comparison profiles\nlook decent?}
goodbelts --> |YES| SnT_IS[Run Shake&Tune\naxis input shaper tool]
goodbelts --> |NO| checkmotion[Fix your mechanical assembly\nand your motion system]
SnT_IS --> goodIS{Check the documentation\nDoes axis profiles and\n input shapers look decent?}
goodIS --> |YES| SnT_Vibrations[Run Shake&Tune\nvibration profile tool]
goodIS--> |NO| checkmotion
SnT_Vibrations --> goodvibs{Check the documentation\nAre the graphs OK?\nSet the speeds in\nyour slicer profile}
goodvibs --> |YES| pressureAdvance[Tune your\npressure advance]
goodvibs --> |NO| checkTMC[Dig into TMC drivers\ntuning if you want to]
goodvibs --> |NO| checkmotion
checkTMC --> SnT_Vibrations
pressureAdvance --> extrusionMultiplier[Tune your\nextrusion multiplier]
extrusionMultiplier --> testPrint[Do a test print]
testPrint --> printGood{Is the print good?}
printGood --> |YES| unicorn{want to chase unicorns}
printGood --> |NO -> Underextrusion / Overextrusion| extrusionMultiplier
printGood --> |NO -> Corner humps and no ghosting| pressureAdvance
printGood --> |NO -> Visible VFAs| SnT_Vibrations
printGood --> |NO -> Ghosting, ringing, resonance| SnT_IS
unicorn --> |NO| done
unicorn --> |YES| SnT_Belts
end
Use this value in your `printer.cfg` config file: classDef standard fill:#70088C,stroke:#150140,stroke-width:4px,color:#ffffff;
classDef questions fill:#FF8D32,stroke:#F24130,stroke-width:4px,color:#ffffff;
classDef startstop fill:#F2055C,stroke:#150140,stroke-width:3px,color:#ffffff;
class start,done startstop;
class goodbelts,goodIS,goodvibs,printGood,unicorn questions;
class tensionBelts,checkmotion,SnT_Belts,SnT_IS,SnT_Vibrations,pressureAdvance,extrusionMultiplier,testPrint,checkTMC standard;
``` ```
[adxl345] # replace "adxl345" by your correct accelerometer name
axes_map: -z,y,x
```
### EXCITATE_AXIS_AT_FREQ
The `EXCITATE_AXIS_AT_FREQ` macro is particularly useful for troubleshooting mechanical vibrations or resonance issues. This macro allows you to maintain a specific excitation frequency for a set duration, enabling hands-on diagnostics. By touching different components during the excitation, you can identify the source of the vibration, as contact usually stops it.
Here are the parameters available when calling this macro:
| parameters | default value | description |
|-----------:|---------------|-------------|
|FREQUENCY|25|excitation frequency (in Hz) that you want to maintain. Usually, it's the frequency of a peak on one of the graphs|
|TIME|10|time in second to maintain this excitation|
|AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"|
## Complementary ressources ## Complementary ressources

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 490 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 247 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 168 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 193 KiB

View File

@@ -0,0 +1,51 @@
# Accelerometer "axes_map" calibration
All graphs generated by Shake&Tune show plots based on accelerometer measurements, typically labeled with the X, Y, and Z axes. If the accelerometer is rotated, its axes may not align correctly with the machine axes, making the plots more challenging to interpret, analyze, and understand. The `AXES_MAP_CALIBRATION` macro is designed to automatically measure the alignment of the accelerometer in order to set it correctly, making it easier than ever to get the most out of your data!
> **Note**:
>
> This misalignment doesn't affect the accuracy of the measurements because the total sum across all axes is used in most Shake&Tune tools. It's just an optional but convenient way to configure Klipper's `[adxl345]` (or whichever accelerometer you have) "axes_map" parameter.
## Usage
Call the `AXES_MAP_CALIBRATION` macro and look for the graphs in the results folder. Here are the parameters available:
| parameters | default value | description |
|-----------:|---------------|-------------|
|Z_HEIGHT|20|z height to put the toolhead before starting the movements. Be careful, if your accelerometer is mounted under the nozzle, increase it to avoid crashing it on the bed of the machine|
|SPEED|80|speed of the toolhead in mm/s for the movements|
|ACCEL|1500 (or max printer accel)|accel in mm/s^2 used for all the moves|
|TRAVEL_SPEED|120|speed in mm/s used for all the travels moves|
> **Note**:
>
> This command only works if you can move the same accelerometer in the 3 directions, like on a Voron V2.4 printer. If you have 2 accelerometers on your machine, like on a Prusa, Switchwire or Ender3, it won't work because it's impossible to detect the accelerometer orientation with only one movement (like for the bed).
![](../images/axesmap_example.png)
During the measurement, the machine will move slightly in +X, +Y, and +Z. This allow to automatically detect the orientation of the accelerometer.
Use this value in your `printer.cfg` config file:
```
[adxl345] # replace "adxl345" by your correct accelerometer name
axes_map: -z,y,x
```
### Acceleration plot
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 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 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
This graph visualizes the estimated path of the tool head as recorded by the accelerometer in 3D space. Keep in mind that even though Shake&Tune uses some mathematical tricks to get something as accurate as possible, we don't have a gyroscope to compensate for accelerometer drift, and this plot is still pretty much an "estimate".
When examining it, look for path consistency by checking the smoothness of the paths (orange dotted lines): they should be mostly linear. Ideally, you should expect the computed direction vectors (in purple) to appear aligned along one of the primary axes (X, Y, or Z), with minimal angular error, indicating accurate alignment of the accelerometer chip with the machine axis.
Keep in mind that since this graph is an estimate, there may be some variation between successive runs, especially in the calculated angles. For example, on my machine I had these results over 20 consecutive runs (mean square error about 3 to 5 degrees):
![](../images/axes_map_inaccuracy.png)

View File

@@ -1,6 +1,6 @@
# Axis measurements # Input shaper filters calibration
The `AXES_SHAPER_CALIBRATION` macro is used to measure and plot the axis behavior in order to tune Klipper's input shaper system. The `AXES_SHAPER_CALIBRATION` macro is used to measure and plot your machine axis frequency profiles in order to tune Klipper's input shaper system.
## Usage ## Usage
@@ -11,23 +11,19 @@ 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|
|AXIS|"all"|Axis you want to test in the list of "all", "X" or "Y"| |ACCEL_PER_HZ|None (default to `[resonance_tester]` value)|accel per Hz value used for the test|
|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| |AXIS|"all"|axis you want to test in the list of "all", "X" or "Y"|
|MAX_SMOOTHING|None|Max smoothing allowed when calculating shaper recommendations| |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|
|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| |MAX_SMOOTHING|None|max smoothing allowed when calculating shaper recommendations|
|KEEP_CSV|0|Weither or not to keep the CSV data file alonside the PNG graphs| |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|
## Graphs description
![](../images/shaper_graphs/shaper_graph_explanation.png) ![](../images/shaper_graphs/shaper_graph_explanation.png)
## Analysis of the results ## Generalities on IS graphs
### Generalities
To effectively analyze input shaper graphs, there is no one-size-fits-all approach due to the variety of factors that can impact the 3D printer's performance or input shaper measurements. However, here are some hints on reading the graphs: To effectively analyze input shaper graphs, there is no one-size-fits-all approach due to the variety of factors that can impact the 3D printer's performance or input shaper measurements. However, here are some hints on reading the graphs:
- A graph with a **single and thin peak** well detached from the background noise is ideal, as it can be easily filtered by input shaping. But depending on the machine and its mechanical configuration, it's not always possible to obtain this shape. The key to getting better graphs is a clean mechanical assembly with a special focus on the rigidity and stiffness of everything, from the table the printer sits on to the frame and the toolhead. - A graph with a **single and thin peak** well detached from the background noise is ideal, as it can be easily filtered by input shaping. But depending on the machine and its mechanical configuration, it's not always possible to obtain this shape. The key to getting better graphs is a clean mechanical assembly with a special focus on the rigidity and stiffness of everything, from the table the printer sits on to the frame and the toolhead.
@@ -36,18 +32,18 @@ To effectively analyze input shaper graphs, there is no one-size-fits-all approa
![](../images/shaper_graphs/shaper_recommandations.png) ![](../images/shaper_graphs/shaper_recommandations.png)
For setting your Input Shaping filters, rely on the auto-computed values displayed in the top right corner of the graph. Here's a breakdown of the legend for a better grasp: For setting your Input Shaping filters, rely on the auto-computed values displayed in the top right corner of the graph. Here's a breakdown of the legend for a better grasp:
- **Filtering algortihms**: Klipper automatically computes these lines. This computation works pretty well if the graphs are clean enough. But if your graphs are junk, it can't do magic and will give you pretty bad recommendations. It's better to address the mechanical issues first before continuing. Each shapers has its pro and cons: - **Filtering algortihms**: This computation works pretty well if the graphs are clean enough. But if your graphs are junk, it can't do magic and will give you pretty bad recommendations. It's better to address the mechanical issues first before continuing. Each shapers has its pro and cons:
* `ZV` is a pretty light filter and usually has some remaining vibrations. My recommendation would be to use it only if you want to do speed benchies and get the highest acceleration values while maintaining a low amount of smoothing on your parts. If you have "perfect" graphs and do not care that much about some remaining ringing, you can try it. * `ZV` is a pretty light filter and usually has some remaining vibrations. Use it only if you want to do speed benchies and get the highest accelerations while maintaining a low amount of smoothing on your parts. If you have "perfect" graphs and do not care that much about some remaining ringing, you can try it.
* `MZV` is usually the top pick for well-adjusted machines. It's a good compromise for low remaining vibrations while still allowing pretty good acceleration values. Keep in mind, `MZV` is only recommended by Klipper on good graphs. * `MZV` is usually the top pick for well-adjusted machines. It's a good compromise for low remaining vibrations while still allowing pretty good accelerations. Keep in mind, `MZV` is only recommended on good graphs.
* `EI` can be used as a fallback for challenging graphs. But first, try to fix your mechanical issues before using it: almost every printer should be able to run `MZV` instead. * `EI` can be used as a fallback for challenging graphs. But first, try to fix your mechanical issues before using it: almost every printer should be able to run `MZV` instead.
* `2HUMP_EI` and `3HUMP_EI` are last-resort choices. Usually, they lead to a high level of smoothing in order to suppress the ringing while also using relatively low acceleration values. If they pop up as suggestions, it's likely your machine has underlying mechanical issues (that lead to pretty bad or "wide" graphs). * `2HUMP_EI` and `3HUMP_EI` are last-resort choices as they usually lead to a high level of smoothing. If they pop up as the main suggestions, it's likely your machine has underlying mechanical issues (that lead to pretty bad or "wide" graphs).
- **Recommended Acceleration** (`accel<=...`): This isn't a standalone figure. It's essential to also consider the `vibr` and `sm` values as it's a compromise between the three. They will give you the percentage of remaining vibrations and the smoothing after Input Shaping, when using the recommended acceleration. Nothing will prevent you from using higher acceleration values; they are not a limit. However, in this case, Input Shaping may not be able to suppress all the ringing on your parts, and more smoothing will occur. Finally, keep in mind that high acceleration values are not useful at all if there is still a high level of remaining vibrations: you should address any mechanical issues first. - **Recommended Acceleration** (`accel<=...`): This isn't a standalone value: you need to also consider the `vibr` and `sm` values as it's a compromise between the three. They will give you the remaining vibrations and the smoothing after Input Shaping, at the recommended acceleration. Nothing will prevent you from using higher acceleration values; they are not a limit. However, in this case, Input Shaping may not be able to suppress all the ringing on your parts, and more smoothing will occur. Finally, keep in mind that high accelerations are not useful at all if there is still a high level of remaining vibrations: you should address any mechanical issues first.
- **The remaining vibrations** (`vibr`): This directly correlates with ringing. It correspond to the total value of the "after shaper" signal. Ideally, you want a filter with minimal remaining vibrations. - **The remaining vibrations** (`vibr`): This directly correlates to ringing. Ideally, you want a filter with minimal remaining vibrations.
- **Shaper recommendations**: This script will give you some tailored recommendations based on your graphs. Pick the one that suit your needs: - **Shaper recommendations**: This script will give you some tailored recommendations based on your graphs. Pick the one that suit your needs:
* The "performance" shaper is Klipper's original suggestion, which is good for high acceleration, but sometimes allows a little residual vibration while minimizing smoothing. Use it if your goal is speed printing and you don't care much about some remaining ringing. * The "performance" shaper, which should be good for most people as it's a compromise for high accelerations, with little residual vibrations that should remove most ringing on your parts.
* The "low vibration" shaper aims for the lowest level of remaining vibration to ensure the best print quality with minimal ringing. This should be the best bet for most users. * The "low vibration" shaper aims for a lower level of remaining vibration to ensure the best print quality with minimal ringing. This should be used in case the performance shaper is not good enough for your needs.
* Sometimes only a single recommendation is given as the "best" shaper. This means that either no suitable "low vibration" shaper was found (due to a high level of residual vibration or too much smoothing), or that the "performance" shaper is also the one with the lowest vibration level. * Sometimes only a single recommendation is given as the "best" shaper. This means that either no suitable "performance" shaper was found (due to a high level of residual vibrations or too much smoothing), or that the "low vibration" shaper is the same as the "performance" shaper.
- **Damping Ratio**: Displayed at the end, this is an estimate based on your data that is used to improve the shaper recommendations for your machine. Defining it in the `[input_shaper]` section (instead of Klipper's default value of 0.1) can further reduce ringing at high accelerations and higher square corner velocities. - **Damping Ratio**: At the end, you will see an estimate based on your measured data, which will be used to better tailor the shaper recommendations to your machine. You need to define it in the `[input_shaper]` section.
Then, add to your configuration: Then, add to your configuration:
``` ```
@@ -60,7 +56,7 @@ damping_ratio_x: ... # damping ratio for the X axis
damping_ratio_y: ... # damping ratio for the Y axis damping_ratio_y: ... # damping ratio for the Y axis
``` ```
### Useful facts and myths debunking ## Useful facts and myths debunking
Some people suggest to cap data at 100 Hz by manually editing the .csv file, thinking values beyond that are wrong. But this can be misleading. The excitation and system's response frequencies differ, and aren't directly linked. You might see vibrations beyond the excitation range, and removing them from the file just hides potential issues. Though these high-frequency vibrations might not always affect print quality, they could signal mechanical problems. Instead of hiding them, look into resolving these issues. Some people suggest to cap data at 100 Hz by manually editing the .csv file, thinking values beyond that are wrong. But this can be misleading. The excitation and system's response frequencies differ, and aren't directly linked. You might see vibrations beyond the excitation range, and removing them from the file just hides potential issues. Though these high-frequency vibrations might not always affect print quality, they could signal mechanical problems. Instead of hiding them, look into resolving these issues.

View File

@@ -1,37 +1,56 @@
# Belt relative difference measurements # Measuring belts relative differences
The `COMPARE_BELTS_RESPONSES` macro is dedicated for CoreXY machines where it can help you to diagnose belt path problems by measuring and plotting the differences between their behavior. It will also help you tension your belts at the same tension. The `COMPARE_BELTS_RESPONSES` macro is dedicated for CoreXY or CoreXZ machines where it can help you to diagnose belt path problems by measuring and plotting the differences between their behaviors. It will also help you tension your belts at the same tension.
> **Note**:
>
> While it might be tempting to use it on other kinds of printers, such as Cartesian printers, it's probably not the best idea. After all, it's normal to have different responses in that case due to the belts paths being not symmetric.
## Usage ## Usage
**Before starting, ensure that the belts are properly tensioned**. For example, you can follow the [Voron belt tensioning documentation](https://docs.vorondesign.com/tuning/secondary_printer_tuning.html#belt-tension). This is crucial: you need a good starting point to then iterate from it! **Before starting, ensure that the belts are properly tensioned**. For example, you can follow the [Voron belt tensioning documentation](https://docs.vorondesign.com/tuning/secondary_printer_tuning.html#belt-tension). You've got to have a solid foundation to build on!
Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the results folder. Here are the parameters available: Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the results folder. Here are the parameters available:
| 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|
|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| |ACCEL_PER_HZ|None (default to `[resonance_tester]` value)|accel per Hz value used for the test|
|KEEP_CSV|0|Weither or not to keep the CSV data files alonside the PNG graphs| |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|
![](../images/belts_example.png)
## Graphs description ### Belts frequency profiles
![](../images/belt_graphs/belt_graph_explanation.png) On these graphs, **you want both curves to look similar and overlap to form a single curve**: try to make them fit as closely as possible in frequency **and** in amplitude. Usually a belt graph is composed of one or two main paired peaks (more than 2 peaks can hint about mechanical problems). It's acceptable to have "noise" around the main peaks, but it should be present on both curves with a comparable amplitude. Keep in mind that when you tighten a belt, its peaks should move diagonally toward the upper right corner, changing significantly in amplitude and slightly in frequency. Additionally, the magnitude order of the main peaks *should typically* range from ~500k to ~2M on most machines.
## Analysis of the results
On these graphs, **you want both curves to look similar and overlap to form a single curve**: try to make them fit as closely as possible in frequency **and** in amplitude. Usually a belt graph is composed of one or two main peaks (more than 2 peaks can hint about mechanical problems). It's acceptable to have "noise" around the main peaks, but it should be present on both curves with a comparable amplitude. Keep in mind that when you tighten a belt, its peaks should move diagonally toward the upper right corner, changing significantly in amplitude and slightly in frequency. Additionally, the magnitude order of the main peaks *should typically* range from ~500k to ~2M on most machines.
Aside from the actual belt tension, the resonant frequency/amplitude of the curves depends primarily on three parameters: Aside from the actual belt tension, the resonant frequency/amplitude of the curves depends primarily on three parameters:
- the *mass of the toolhead*, which is identical on CoreXY, CrossXY and H-Bot machines for both belts. So this will unlikely have any effect here - the *mass of the toolhead*, which is identical on CoreXY, CrossXY and H-Bot machines for both belts. So this will unlikely have any effect here
- the *belt "elasticity"*, which changes over time as the belt wears. Ensure that you use the **same belt brand and type** for both A and B belts and that they were **installed at the same time**: you want similar belts with a similar level of wear! - the *belt "elasticity"*, which changes over time as the belt wears. Ensure that you use the **same belt brand and type** for both A and B belts and that they were **installed at the same time**: you want similar belts with a similar level of wear!
- the *belt path length*, which is why they must have the **exact same number of teeth** so that one belt path is not longer than the other when tightened at the same tension. This specific point is very important: a single tooth difference is enough to prevent you from having a good superposition of the curves. Moreover, it is even one of the main causes of problems found in Discord resonance testing channels. - the *belt path length*, which is why they must have the **exact same number of teeth** so that one belt path is not longer than the other when tightened at the same tension. This specific point is very important: a single tooth difference is enough to prevent you from having a good superposition of the curves. Moreover, it is even one of the main causes of problems found in Discord resonance testing channels.
**If these three parameters are met, there is no way that the curves could be different** or you can be sure that there is an underlying problem in at least one of the belt paths. Also, if the belt graphs have low amplitude curves (no distinct peaks) and a lot of noise, you will probably also have poor input shaper graphs. So before you continue, ensure that you have good belt graphs or fix your belt paths. Start by checking the belt tension, bearings, gantry screws, alignment of the belts on the idlers, and so on. **If these three parameters are met, there is no way that the curves could be different** or you can be sure that there is an underlying problem in at least one of the belt paths. Also, if the belt graphs have low amplitude curves and/or a lot of noise, you will probably also have poor input shaper graphs. So before you continue, ensure that you have good belt graphs by fixing your mechanical issues first.
### Cross-belts comparison plot
The Cross-Belts plot is an innovative cool way to compare the frequency profiles of the belts at every frequency point. In this plot, each point marks the amplitude response of each belt at different frequencies, connected point by point to trace the frequency spectrum. Ideally, these points should align on the diagonal center line, indicating that both belts have matching energy response values at each frequency.
The good zone, wider at the bottom (low-amplitude regions where the deviation doesn't matter much) and narrower at the top right (high-energy region where the main peaks lie), represents acceptable deviations. So **you want all points to be close to the ideal center line and as many as possible within the green zone**, as this means that the bands are well tuned and behave similarly.
Paired peaks of exactly the same frequency will be on the same point (labeled α1/α2, β1/β2, ...) and the distance from the center line will show the difference in energy. For paired peaks that also have a frequency delta between them, they are displayed as two points (labeled α1 and α2, ...) and the additional distance between them along the plotted line represents their frequency delta.
### Estimated similarity and mechanical issues indicator
1. **The estimated similarity** measure provides a quantitative view of how closely the frequency profiles of the two belts match across their entire range. A similarity value close to 100% means that the belts are well matched, indicating equal tension and uniform mechanical behavior.
2. **The mechanical health indicator** provides another assessment of the printer's operating condition based on the estimated similarity and influenced by the number of paired and unpaired peaks. A noisy signal generally lowers the value of this indicator, indicating potential problems. However, this measure can sometimes be misleading, so it's important not to rely on it alone and to consider it in conjunction with the other information displayed.
> **Note**:
>
> If you're using this tool to check or adjust the tension after installing new belts, you'll want to measure again after a few hours of printing. This is because the tension can change slightly as the belts stretch and settle to their final tension. But don't worry, a few hours of printing should be more than enough!
## Advanced explanation on why 1 or 2 peaks ## Advanced explanation on why 1 or 2 peaks

View File

@@ -13,18 +13,13 @@ Call the `CREATE_VIBRATIONS_PROFILE` macro with the speed range you want to meas
| parameters | default value | description | | parameters | default value | description |
|-----------:|---------------|-------------| |-----------:|---------------|-------------|
|SIZE|100|maximum size in mm of the circle in which the recorded movements take place| |SIZE|100|diameter in mm of the circle in which the recorded movements take place|
|Z_HEIGHT|20|z height to put the toolhead before starting the movements. Be careful, if your accelerometer is mounted under the nozzle, increase it to avoid crashing it on the bed of the machine| |Z_HEIGHT|20|Z height to put the toolhead before starting the movements. Be careful, if your accelerometer is mounted under the nozzle, increase it to avoid crashing it on the bed of the machine|
|ACCEL|3000 (or max printer accel)|accel in mm/s^2 used for all moves. Try to keep it relatively low to avoid dynamic effects that alter the measurements, but high enough to achieve a constant speed for >~70% of the segments. 3000 is a reasonable default for most printers, unless you want to record at very high speed, in which case you will want to increase SIZE and decrease ACCEL a bit.|
|MAX_SPEED|200|maximum speed of the toolhead in mm/s to record for analysis| |MAX_SPEED|200|maximum speed of the toolhead in mm/s to record for analysis|
|SPEED_INCREMENT|2|toolhead speed increments in mm/s between each movement| |SPEED_INCREMENT|2|toolhead speed increments in mm/s between each movement|
|TRAVEL_SPEED|200|speed in mm/s used for all the travels moves| |ACCEL|3000|accel in mm/s^2 used for all moves. Try to keep it relatively low to avoid dynamic effects that alter the measurements, but high enough to achieve a constant speed for >~70% of the segments. 3000 is a reasonable default for most printers, unless you want to record at very high speed, in which case you will want to increase SIZE and decrease ACCEL a bit.|
|ACCEL_CHIP|"adxl345"|accelerometer chip name in the config| |TRAVEL_SPEED|120|speed in mm/s used for all the travels moves|
|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| |ACCEL_CHIP|None|accelerometer chip name from your Klipper config that you want to force for the test|
|KEEP_CSV|0|Weither or not to keep the CSV data files alonside the PNG graphs (archived in a tarball)|
## Graphs description
The `CREATE_VIBRATIONS_PROFILE` macro results are constituted of a set of 6 plots. At the top of the figure you can also see all the detected motor, current and TMC driver parameters. These notes are just for reference in case you want to tinker with them and don't forget what you changed between each run of the macro. The `CREATE_VIBRATIONS_PROFILE` macro results are constituted of a set of 6 plots. At the top of the figure you can also see all the detected motor, current and TMC driver parameters. These notes are just for reference in case you want to tinker with them and don't forget what you changed between each run of the macro.

View File

@@ -0,0 +1,38 @@
# Diagnosing problematic peaks
The `EXCITATE_AXIS_AT_FREQ` macro is particularly useful for troubleshooting mechanical vibrations or resonance issues. This macro allows you to maintain a specific excitation frequency for a set duration, enabling hands-on diagnostics.
## Usage
Here are the parameters available:
| parameters | default value | description |
|-----------:|---------------|-------------|
|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|
|DURATION|30|duration in second to maintain this excitation|
|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"|
|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|
|ACCEL_CHIP|None|accelerometer chip name from your Klipper config that you want to force for the test|
**By default, this macro does not generate a graph**, because by touching the various components of your machine with your fingers, you will dampen the vibrations and be able to easily identify those that are source of problems: touching them will stop the noise.
However, if you have something that is difficult to diagnose with your ears, or if you want to record your experiments or document the exact consequences and effects of your modifications with a more scientific approach, you can enable the creation of a graph. Just **keep in mind that since the accelerometer is usually mounted on the toolhead, the recording will correspond to the toolhead vibrations and not necessarily reflect another problematic component somewhere on the machine**, unless it's vibrating a lot and its vibrations are being transmitted up to the toolhead. So keep this in mind when looking at the graphs generated by this macro, and you may want to move the accelerometer to other locations to get a full overview.
![](../images/excitate_at_freq_example.png)
### Spectrogram and vibrations harmonics
The time-frequency spectrogram visualizes how the frequency content of the signal changes over time. This plot helps identify dominant frequencies and harmonics of the excitated vibration. Each vertical line is one of them and a piece of the vibrations and noise that you can hear.
### Energy accumulation plot
The energy accumulation plot shows the cumulative energy over time, integrated over all frequencies. Basically, this plot is the sum of all the vibrations at a given moment during the test. So it can help you assess the periods of significant vibration and how much things change when you touch this or that part of the machine. In the example above, I vibrated my machine's X-axis at its main resonance frequency (i.e., its main resonance peak on the IS graphs) and touched 3 components:
- From the 4th to the 8th second of the test, I touched the toolhead, which has the most vibration reduction because it's the main component vibrating at that frequency and touching it dampens it a lot.
- From the 14th to the 18th second, I touched the belts and this reduced the vibration a bit, but not as much as touching the toolhead.
- From the 23rd to the 27th second, I touched the left XY joint of my machine and it didn't have any noticeable effect on the vibrations.
But as mentioned above, **remember that this doesn't mean that the left XY joint doesn't contribute to the vibrations**. It means that its vibrations aren't causing a problem in the recorded toolhead vibrations (because the accelerometer was mounted on the toolhead!!!), but if you find that this actually also reduces the global noise to your ears, you may want to start a new recording by sticking the accelerometer directly on the XY joint (or the problematic component) instead to continue diagnosing.

View File

@@ -3,9 +3,10 @@
USER_CONFIG_PATH="${HOME}/printer_data/config" USER_CONFIG_PATH="${HOME}/printer_data/config"
MOONRAKER_CONFIG="${HOME}/printer_data/config/moonraker.conf" MOONRAKER_CONFIG="${HOME}/printer_data/config/moonraker.conf"
KLIPPER_PATH="${HOME}/klipper" KLIPPER_PATH="${HOME}/klipper"
KLIPPER_VENV_PATH="${HOME}/klippy-env"
OLD_K_SHAKETUNE_VENV="${HOME}/klippain_shaketune-env"
K_SHAKETUNE_PATH="${HOME}/klippain_shaketune" K_SHAKETUNE_PATH="${HOME}/klippain_shaketune"
K_SHAKETUNE_VENV_PATH="${HOME}/klippain_shaketune-env"
set -eu set -eu
export LC_ALL=C export LC_ALL=C
@@ -39,7 +40,7 @@ function is_package_installed {
} }
function install_package_requirements { function install_package_requirements {
packages=("python3-venv" "libopenblas-dev" "libatlas-base-dev") packages=("libopenblas-dev" "libatlas-base-dev")
packages_to_install="" packages_to_install=""
for package in "${packages[@]}"; do for package in "${packages[@]}"; do
@@ -76,14 +77,17 @@ function check_download {
} }
function setup_venv { function setup_venv {
if [ ! -d "${K_SHAKETUNE_VENV_PATH}" ]; then if [ ! -d "${KLIPPER_VENV_PATH}" ]; then
echo "[SETUP] Creating Python virtual environment..." echo "[ERROR] Klipper's Python virtual environment not found!"
python3 -m venv "${K_SHAKETUNE_VENV_PATH}" exit -1
else
echo "[SETUP] Virtual environment already exists. Continuing..."
fi fi
source "${K_SHAKETUNE_VENV_PATH}/bin/activate" if [ -d "${OLD_K_SHAKETUNE_VENV}" ]; then
echo "[INFO] Old K-Shake&Tune virtual environement found, cleaning it!"
rm -rf "${OLD_K_SHAKETUNE_VENV}"
fi
source "${KLIPPER_VENV_PATH}/bin/activate"
echo "[SETUP] Installing/Updating K-Shake&Tune dependencies..." echo "[SETUP] Installing/Updating K-Shake&Tune dependencies..."
pip install --upgrade pip pip install --upgrade pip
pip install -r "${K_SHAKETUNE_PATH}/requirements.txt" pip install -r "${K_SHAKETUNE_PATH}/requirements.txt"
@@ -92,22 +96,27 @@ function setup_venv {
} }
function link_extension { function link_extension {
echo "[INSTALL] Linking scripts to your config directory..." # Reusing the old linking extension function to cleanup and remove the macros for older S&T versions
if [ -d "${HOME}/klippain_config" ] && [ -f "${USER_CONFIG_PATH}/.VERSION" ]; then if [ -d "${HOME}/klippain_config" ] && [ -f "${USER_CONFIG_PATH}/.VERSION" ]; then
echo "[INSTALL] Klippain full installation found! Linking module to the script folder of Klippain" if [ -d "${USER_CONFIG_PATH}/scripts/K-ShakeTune" ]; then
ln -frsn ${K_SHAKETUNE_PATH}/K-ShakeTune ${USER_CONFIG_PATH}/scripts/K-ShakeTune echo "[INFO] Old K-Shake&Tune macro folder found, cleaning it!"
rm -d "${USER_CONFIG_PATH}/scripts/K-ShakeTune"
fi
else else
ln -frsn ${K_SHAKETUNE_PATH}/K-ShakeTune ${USER_CONFIG_PATH}/K-ShakeTune if [ -d "${USER_CONFIG_PATH}/K-ShakeTune" ]; then
echo "[INFO] Old K-Shake&Tune macro folder found, cleaning it!"
rm -d "${USER_CONFIG_PATH}/K-ShakeTune"
fi
fi fi
} }
function link_gcodeshellcommandpy { function link_module {
if [ ! -f "${KLIPPER_PATH}/klippy/extras/gcode_shell_command.py" ]; then if [ ! -d "${KLIPPER_PATH}/klippy/extras/shaketune" ]; then
echo "[INSTALL] Downloading gcode_shell_command.py Klipper extension needed for this module" echo "[INSTALL] Linking Shake&Tune module to Klipper extras"
wget -P ${KLIPPER_PATH}/klippy/extras https://raw.githubusercontent.com/Frix-x/klippain/main/scripts/gcode_shell_command.py ln -frsn ${K_SHAKETUNE_PATH}/shaketune ${KLIPPER_PATH}/klippy/extras/shaketune
else else
printf "[INSTALL] gcode_shell_command.py Klipper extension is already installed. Continuing...\n\n" printf "[INSTALL] Klippain Shake&Tune Klipper module is already installed. Continuing...\n\n"
fi fi
} }
@@ -140,7 +149,7 @@ preflight_checks
check_download check_download
setup_venv setup_venv
link_extension link_extension
link_module
add_updater add_updater
link_gcodeshellcommandpy
restart_klipper restart_klipper
restart_moonraker restart_moonraker

View File

@@ -4,7 +4,7 @@
type: git_repo type: git_repo
origin: https://github.com/Frix-x/klippain-shaketune.git origin: https://github.com/Frix-x/klippain-shaketune.git
path: ~/klippain_shaketune path: ~/klippain_shaketune
virtualenv: ~/klippain_shaketune-env virtualenv: ~/klippy-env
requirements: requirements.txt requirements: requirements.txt
system_dependencies: system-dependencies.json system_dependencies: system-dependencies.json
primary_branch: main primary_branch: main

View File

@@ -1,4 +1,5 @@
GitPython==3.1.40 GitPython==3.1.41
matplotlib==3.8.2 matplotlib==3.8.2
numpy==1.26.2 numpy==1.26.2
scipy==1.11.4 scipy==1.11.4
PyWavelets==1.6.0

19
shaketune/__init__.py Normal file
View File

@@ -0,0 +1,19 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: __init__.py
# Description: Functions as a plugin within Klipper to enhance printer diagnostics by:
# 1. Diagnosing and pinpointing vibration sources in the printer.
# 2. Conducting standard axis input shaper tests on the machine axes.
# 3. Executing a specialized half-axis test for CoreXY/CoreXZ printers to analyze
# and compare the frequency profiles of individual belts.
# 4. ...
from .shaketune import ShakeTune as ShakeTune
def load_config(config) -> ShakeTune:
return ShakeTune(config)

View File

@@ -0,0 +1,14 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: __init__.py
# Description: Imports various commands function (to run and record the tests) for the Shake&Tune package.
from .axes_map_calibration import axes_map_calibration as axes_map_calibration
from .axes_shaper_calibration import axes_shaper_calibration as axes_shaper_calibration
from .compare_belts_responses import compare_belts_responses as compare_belts_responses
from .create_vibrations_profile import create_vibrations_profile as create_vibrations_profile
from .excitate_axis_at_freq import excitate_axis_at_freq as excitate_axis_at_freq

View File

@@ -0,0 +1,107 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: accelerometer.py
# Description: Provides a custom and internal Shake&Tune Accelerometer helper that interfaces
# with Klipper's accelerometer classes. It includes functions to start and stop
# accelerometer measurements and write the data to a file in a blocking manner.
import os
import time
from multiprocessing import Process, Queue
FILE_WRITE_TIMEOUT = 10 # seconds
class Accelerometer:
def __init__(self, reactor, klipper_accelerometer):
self._k_accelerometer = klipper_accelerometer
self._reactor = reactor
self._bg_client = None
self._write_queue = Queue()
self._write_processes = []
@staticmethod
def find_axis_accelerometer(printer, axis: str = 'xy'):
accel_chip_names = printer.lookup_object('resonance_tester').accel_chip_names
for chip_axis, chip_name in accel_chip_names:
if axis in {'x', 'y'} and chip_axis == 'xy':
return chip_name
elif chip_axis == axis:
return chip_name
return None
def start_measurement(self):
if self._bg_client is None:
self._bg_client = self._k_accelerometer.start_internal_client()
else:
raise ValueError('measurements already started!')
def stop_measurement(self, name: str = None, append_time: bool = True):
if self._bg_client is None:
raise ValueError('measurements need to be started first!')
timestamp = time.strftime('%Y%m%d_%H%M%S')
if name is None:
name = timestamp
elif append_time:
name += f'_{timestamp}'
if not name.replace('-', '').replace('_', '').isalnum():
raise ValueError('invalid file name!')
bg_client = self._bg_client
self._bg_client = None
bg_client.finish_measurements()
filename = f'/tmp/shaketune-{name}.csv'
self._queue_file_write(bg_client, 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):
try:
os.nice(20)
except Exception:
pass
with open(filename, 'w') as f:
f.write('#time,accel_x,accel_y,accel_z\n')
samples = bg_client.samples or bg_client.get_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')
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

@@ -0,0 +1,113 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: axes_map_calibration.py
# Description: Provides a command for calibrating the axes map of a 3D printer using an accelerometer.
# The script moves the printer head along specified axes, starts and stops measurements,
# and performs post-processing to analyze the collected data.
from ..helpers.console_output import ConsoleOutput
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
SEGMENT_LENGTH = 30 # mm
def axes_map_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
z_height = gcmd.get_float('Z_HEIGHT', default=20.0)
speed = gcmd.get_float('SPEED', default=80.0, minval=20.0)
accel = gcmd.get_int('ACCEL', default=1500, minval=100)
feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0)
printer = config.get_printer()
gcode = printer.lookup_object('gcode')
toolhead = printer.lookup_object('toolhead')
systime = printer.get_reactor().monotonic()
accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy')
k_accelerometer = printer.lookup_object(accel_chip, None)
if k_accelerometer is None:
raise gcmd.error('Multi-accelerometer configurations are not supported for this macro!')
pconfig = printer.lookup_object('configfile')
current_axes_map = pconfig.status_raw_config[accel_chip].get('axes_map', None)
if current_axes_map is not None and current_axes_map.strip().replace(' ', '') != 'x,y,z':
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")!'
)
accelerometer = Accelerometer(printer.get_reactor(), k_accelerometer)
toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel']
old_sqv = toolhead_info['square_corner_velocity']
# set the wanted acceleration values
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
input_shaper = printer.lookup_object('input_shaper', None)
if input_shaper is not None:
input_shaper.disable_shaping()
else:
input_shaper = None
kin_info = toolhead.kin.get_status(systime)
mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2
mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2
_, _, _, E = toolhead.get_position()
# Going to the start position
toolhead.move([mid_x - SEGMENT_LENGTH / 2, mid_y - SEGMENT_LENGTH / 2, z_height, E], feedrate_travel)
toolhead.dwell(0.5)
# Start the measurements and do the movements (+X, +Y and then +Z)
accelerometer.start_measurement()
toolhead.dwell(0.5)
toolhead.move([mid_x + SEGMENT_LENGTH / 2, mid_y - SEGMENT_LENGTH / 2, z_height, E], speed)
toolhead.dwell(0.5)
accelerometer.stop_measurement('axesmap_X', append_time=True)
toolhead.dwell(0.5)
accelerometer.start_measurement()
toolhead.dwell(0.5)
toolhead.move([mid_x + SEGMENT_LENGTH / 2, mid_y + SEGMENT_LENGTH / 2, z_height, E], speed)
toolhead.dwell(0.5)
accelerometer.stop_measurement('axesmap_Y', append_time=True)
toolhead.dwell(0.5)
accelerometer.start_measurement()
toolhead.dwell(0.5)
toolhead.move([mid_x + SEGMENT_LENGTH / 2, mid_y + SEGMENT_LENGTH / 2, z_height + SEGMENT_LENGTH, E], speed)
toolhead.dwell(0.5)
accelerometer.stop_measurement('axesmap_Z', append_time=True)
accelerometer.wait_for_file_writes()
# Re-enable the input shaper if it was active
if input_shaper is not None:
input_shaper.enable_shaping()
# 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()
# Run post-processing
ConsoleOutput.print('Analysis of the movements...')
ConsoleOutput.print('This may take some time (1-3min)')
creator = st_process.get_graph_creator()
creator.configure(accel, SEGMENT_LENGTH)
st_process.run()
st_process.wait_for_completion()

View File

@@ -0,0 +1,127 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: axes_shaper_calibration.py
# Description: Provides a command for calibrating the input shaper of a 3D printer's axes using an accelerometer.
# The script performs resonance tests along specified axes, starts and stops measurements,
# and generates graphs for each axis to analyze the collected data.
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from ..helpers.resonance_test import vibrate_axis
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
printer = config.get_printer()
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)
accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None)
axis_input = gcmd.get('AXIS', default='all').lower()
if axis_input not in {'x', 'y', 'all'}:
raise gcmd.error('AXIS selection invalid. Should be either x, y, or all!')
scv = gcmd.get_float('SCV', default=toolhead_info['square_corner_velocity'], 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)
z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1)
if accel_per_hz == '':
accel_per_hz = None
if accel_per_hz is None:
accel_per_hz = res_tester.test.accel_per_hz
gcode = printer.lookup_object('gcode')
max_accel = max_freq * accel_per_hz
# Move to the starting point
test_points = res_tester.test.get_start_test_points()
if len(test_points) > 1:
raise gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.')
if test_points[0] == (-1, -1, -1):
if z_height is None:
raise gcmd.error(
'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1'
)
# Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1
# This is usefull to get something automatic and is also used in the Klippain modular config
kin_info = toolhead.kin.get_status(systime)
mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2
mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2
point = (mid_x, mid_y, z_height)
else:
x, y, z = test_points[0]
if z_height is not None:
z = z_height
point = (x, y, z)
toolhead.manual_move(point, feedrate_travel)
toolhead.dwell(0.5)
# Configure the graph creator
creator = st_process.get_graph_creator()
creator.configure(scv, max_sm, accel_per_hz)
# set the needed acceleration values for the test
toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel']
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={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
input_shaper = printer.lookup_object('input_shaper', None)
if input_shaper is not None:
input_shaper.disable_shaping()
else:
input_shaper = None
# Filter axis configurations based on user input, assuming 'axis_input' can be 'x', 'y', 'all' (that means 'x' and 'y')
filtered_config = [
a for a in AXIS_CONFIG if a['axis'] == axis_input or (axis_input == 'all' and a['axis'] in ('x', 'y'))
]
for config in filtered_config:
# First we need to find the accelerometer chip suited for the axis
accel_chip = Accelerometer.find_axis_accelerometer(printer, config['axis'])
if accel_chip is None:
raise gcmd.error('No suitable accelerometer found for measurement!')
accelerometer = Accelerometer(printer.get_reactor(), printer.lookup_object(accel_chip))
# Then do the actual measurements
accelerometer.start_measurement()
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.wait_for_file_writes()
# And finally generate the graph for each measured axis
ConsoleOutput.print(f'{config["axis"].upper()} axis frequency profile generation...')
ConsoleOutput.print('This may take some time (1-3min)')
st_process.run()
st_process.wait_for_completion()
toolhead.dwell(1)
toolhead.wait_moves()
# Re-enable the input shaper if it was active
if input_shaper is not None:
input_shaper.enable_shaping()
# 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}')
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

@@ -0,0 +1,128 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: compare_belts_responses.py
# Description: Provides a command for comparing the frequency response of belts in CoreXY and CoreXZ kinematics 3D printers.
# The script performs resonance tests along specified axes, starts and stops measurements, and generates graphs
# for each axis to analyze the collected data.
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from ..helpers.motors_config_parser import MotorsConfigParser
from ..helpers.resonance_test import vibrate_axis
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None:
printer = config.get_printer()
toolhead = printer.lookup_object('toolhead')
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)
feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0)
z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1)
if accel_per_hz == '':
accel_per_hz = None
if accel_per_hz is None:
accel_per_hz = res_tester.test.accel_per_hz
gcode = printer.lookup_object('gcode')
max_accel = max_freq * accel_per_hz
# Configure the graph creator
motors_config_parser = MotorsConfigParser(config, motors=None)
creator = st_process.get_graph_creator()
creator.configure(motors_config_parser.kinematics, accel_per_hz)
if motors_config_parser.kinematics == 'corexy':
filtered_config = [a for a in AXIS_CONFIG if a['axis'] in ('a', 'b')]
accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy')
elif motors_config_parser.kinematics == 'corexz':
filtered_config = [a for a in AXIS_CONFIG if a['axis'] in ('corexz_x', 'corexz_z')]
# For CoreXZ kinematics, we can use the X axis accelerometer as most of the time they are moving bed printers
accel_chip = Accelerometer.find_axis_accelerometer(printer, 'x')
else:
raise gcmd.error('Only CoreXY and CoreXZ kinematics are supported for the belt comparison tool!')
ConsoleOutput.print(f'{motors_config_parser.kinematics.upper()} kinematics mode')
if accel_chip is None:
raise gcmd.error(
'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.'
)
accelerometer = Accelerometer(printer.get_reactor(), printer.lookup_object(accel_chip))
# Move to the starting point
test_points = res_tester.test.get_start_test_points()
if len(test_points) > 1:
raise gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.')
if test_points[0] == (-1, -1, -1):
if z_height is None:
raise gcmd.error(
'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1'
)
# Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1
# This is usefull to get something automatic and is also used in the Klippain modular config
kin_info = toolhead.kin.get_status(systime)
mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2
mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2
point = (mid_x, mid_y, z_height)
else:
x, y, z = test_points[0]
if z_height is not None:
z = z_height
point = (x, y, z)
toolhead.manual_move(point, feedrate_travel)
toolhead.dwell(0.5)
# set the needed acceleration values for the test
toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel']
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={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
input_shaper = printer.lookup_object('input_shaper', None)
if input_shaper is not None:
input_shaper.disable_shaping()
else:
input_shaper = None
# Run the test for each axis
for config in filtered_config:
accelerometer.start_measurement()
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.wait_for_file_writes()
# Re-enable the input shaper if it was active
if input_shaper is not None:
input_shaper.enable_shaping()
# 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}')
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
ConsoleOutput.print('Belts comparative frequency profile generation...')
ConsoleOutput.print('This may take some time (1-3min)')
st_process.run()
st_process.wait_for_completion()

View File

@@ -0,0 +1,157 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: vibrations_profile.py
# Description: Provides a command to measure the vibrations generated by the kinematics and motors of a 3D printers
# at different speeds and angles increments. The data is collected from the accelerometer and used
# to generate a comprehensive vibration analysis graph.
import math
from ..helpers.console_output import ConsoleOutput
from ..helpers.motors_config_parser import MotorsConfigParser
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
MIN_SPEED = 2 # mm/s
def create_vibrations_profile(gcmd, config, st_process: ShakeTuneProcess) -> None:
size = gcmd.get_float('SIZE', default=100.0, minval=50.0)
z_height = gcmd.get_float('Z_HEIGHT', default=20.0)
max_speed = gcmd.get_float('MAX_SPEED', default=200.0, minval=10.0)
speed_increment = gcmd.get_float('SPEED_INCREMENT', default=2.0, minval=1.0)
accel = gcmd.get_int('ACCEL', default=3000, minval=100)
feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0)
accel_chip = gcmd.get('ACCEL_CHIP', default=None)
if accel_chip == '':
accel_chip = None
if (size / (max_speed / 60)) < 0.25:
raise gcmd.error(
'The size of the movement is too small for the given speed! Increase SIZE or decrease MAX_SPEED!'
)
printer = config.get_printer()
gcode = printer.lookup_object('gcode')
toolhead = printer.lookup_object('toolhead')
input_shaper = printer.lookup_object('input_shaper', None)
systime = printer.get_reactor().monotonic()
# Check that input shaper is already configured
if input_shaper is None:
raise gcmd.error('Input shaper is not configured! Please run the shaper calibration macro first.')
motors_config_parser = MotorsConfigParser(config, motors=['stepper_x', 'stepper_y'])
if motors_config_parser.kinematics in {'cartesian', 'corexz'}:
main_angles = [0, 90] # Cartesian motors are on X and Y axis directly, same for CoreXZ
elif motors_config_parser.kinematics == 'corexy':
main_angles = [45, 135] # CoreXY motors are on A and B axis (45 and 135 degrees)
else:
raise gcmd.error(
'Only Cartesian, CoreXY and CoreXZ kinematics are supported at the moment for the vibrations measurement tool!'
)
ConsoleOutput.print(f'{motors_config_parser.kinematics.upper()} kinematics mode')
toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel']
old_sqv = toolhead_info['square_corner_velocity']
# set the wanted acceleration values
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)
mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2
mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2
X, Y, _, E = toolhead.get_position()
# Going to the start position
toolhead.move([X, Y, z_height, E], feedrate_travel / 10)
toolhead.move([mid_x - 15, mid_y - 15, z_height, E], feedrate_travel)
toolhead.dwell(0.5)
nb_speed_samples = int((max_speed - MIN_SPEED) / speed_increment + 1)
for curr_angle in main_angles:
ConsoleOutput.print(f'-> Measuring angle: {curr_angle} degrees...')
radian_angle = math.radians(curr_angle)
# Map angles to accelerometer axes and default to 'xy' if angle is not 0 or 90 degrees
# and then find the best accelerometer chip for the current angle if not manually specified
angle_to_axis = {0: 'x', 90: 'y'}
accel_axis = angle_to_axis.get(curr_angle, 'xy')
current_accel_chip = accel_chip # to retain the manually specified chip
if current_accel_chip is None:
current_accel_chip = Accelerometer.find_axis_accelerometer(printer, accel_axis)
k_accelerometer = printer.lookup_object(current_accel_chip, None)
if k_accelerometer is None:
raise gcmd.error(f'Accelerometer [{current_accel_chip}] not found!')
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
for curr_speed_sample in range(nb_speed_samples):
curr_speed = MIN_SPEED + curr_speed_sample * speed_increment
ConsoleOutput.print(f'Current speed: {curr_speed} mm/s')
# Reduce the segments length for the lower speed range (0-100mm/s). The minimum length is 1/3 of the SIZE and is gradually increased
# to the nominal SIZE at 100mm/s. No further size changes are made above this speed. The goal is to ensure that the print head moves
# enough to collect enough data for vibration analysis, without doing unnecessary distance to save time. At higher speeds, the full
# segments lengths are used because the head moves faster and travels more distance in the same amount of time and we want enough data
if curr_speed < 100:
segment_length_multiplier = 1 / 5 + 4 / 5 * curr_speed / 100
else:
segment_length_multiplier = 1
# Calculate angle coordinates using trigonometry and length multiplier and move to start point
dX = (size / 2) * math.cos(radian_angle) * segment_length_multiplier
dY = (size / 2) * math.sin(radian_angle) * segment_length_multiplier
toolhead.move([mid_x - dX, mid_y - dY, z_height, E], feedrate_travel)
# Adjust the number of back and forth movements based on speed to also save time on lower speed range
# 3 movements are done by default, reduced to 2 between 150-250mm/s and to 1 under 150mm/s.
movements = 3
if curr_speed < 150:
movements = 1
elif curr_speed < 250:
movements = 2
# Back and forth movements to record the vibrations at constant speed in both direction
accelerometer.start_measurement()
for _ in range(movements):
toolhead.move([mid_x + dX, mid_y + dY, z_height, E], curr_speed)
toolhead.move([mid_x - dX, mid_y - dY, z_height, E], curr_speed)
name = f'vib_an{curr_angle:.2f}sp{curr_speed:.2f}'.replace('.', '_')
accelerometer.stop_measurement(name)
toolhead.dwell(0.3)
toolhead.wait_moves()
accelerometer.wait_for_file_writes()
# 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()
# Run post-processing
ConsoleOutput.print('Machine vibrations profile generation...')
ConsoleOutput.print('This may take some time (5-8min)')
creator = st_process.get_graph_creator()
creator.configure(motors_config_parser.kinematics, accel, motors_config_parser)
st_process.run()
st_process.wait_for_completion()

View File

@@ -0,0 +1,108 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: excitate_axis_at_freq.py
# Description: Provide a command to excites a specified axis at a given frequency for a duration
# and optionally creates a graph of the vibration data collected by the accelerometer.
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from ..helpers.resonance_test import vibrate_axis_at_static_freq
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
def excitate_axis_at_freq(gcmd, config, st_process: ShakeTuneProcess) -> None:
create_graph = gcmd.get_int('CREATE_GRAPH', default=0, minval=0, maxval=1) == 1
freq = gcmd.get_int('FREQUENCY', default=25, minval=1)
duration = gcmd.get_int('DURATION', default=30, minval=1)
accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None)
axis = gcmd.get('AXIS', default='x').lower()
feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0)
z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1)
accel_chip = gcmd.get('ACCEL_CHIP', default=None)
if accel_chip == '':
accel_chip = None
if accel_per_hz == '':
accel_per_hz = None
axis_config = next((item for item in AXIS_CONFIG if item['axis'] == axis), None)
if axis_config is None:
raise gcmd.error('AXIS selection invalid. Should be either x, y, a or b!')
if create_graph:
printer = config.get_printer()
if accel_chip is None:
accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy' if axis in {'a', 'b'} else axis)
k_accelerometer = printer.lookup_object(accel_chip, None)
if k_accelerometer is None:
raise gcmd.error(f'Accelerometer chip [{accel_chip}] was not found!')
accelerometer = Accelerometer(printer.get_reactor(), k_accelerometer)
ConsoleOutput.print(f'Excitating {axis.upper()} axis at {freq}Hz for {duration} seconds')
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:
accel_per_hz = res_tester.test.accel_per_hz
# Move to the starting point
test_points = res_tester.test.get_start_test_points()
if len(test_points) > 1:
raise gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.')
if test_points[0] == (-1, -1, -1):
if z_height is None:
raise gcmd.error(
'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1'
)
# Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1
# This is usefull to get something automatic and is also used in the Klippain modular config
kin_info = toolhead.kin.get_status(systime)
mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2
mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2
point = (mid_x, mid_y, z_height)
else:
x, y, z = test_points[0]
if z_height is not None:
z = z_height
point = (x, y, z)
toolhead.manual_move(point, feedrate_travel)
toolhead.dwell(0.5)
# Deactivate input shaper if it is active to get raw movements
input_shaper = printer.lookup_object('input_shaper', None)
if input_shaper is not None:
input_shaper.disable_shaping()
else:
input_shaper = None
# If the user want to create a graph, we start accelerometer recording
if create_graph:
accelerometer.start_measurement()
toolhead.dwell(0.5)
vibrate_axis_at_static_freq(toolhead, gcode, axis_config['direction'], freq, duration, accel_per_hz)
toolhead.dwell(0.5)
# Re-enable the input shaper if it was active
if input_shaper is not None:
input_shaper.enable_shaping()
# If the user wanted to create a graph, we stop the recording and generate it
if create_graph:
accelerometer.stop_measurement(f'staticfreq_{axis.upper()}', append_time=True)
accelerometer.wait_for_file_writes()
creator = st_process.get_graph_creator()
creator.configure(freq, duration, accel_per_hz)
st_process.run()
st_process.wait_for_completion()

101
shaketune/dummy_macros.cfg Normal file
View File

@@ -0,0 +1,101 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: dummy_macros.cfg
# Description: Contains dummy gcode macros to inject at Klipper startup for
# availability in the UI, improving user experience with Shake&Tune.
[gcode_macro EXCITATE_AXIS_AT_FREQ]
description: dummy
gcode:
{% set create_graph = params.CREATE_GRAPH|default(0) %}
{% set frequency = params.FREQUENCY|default(25) %}
{% set duration = params.DURATION|default(30) %}
{% set accel_per_hz = params.ACCEL_PER_HZ %}
{% set axis = params.AXIS|default('x') %}
{% set travel_speed = params.TRAVEL_SPEED|default(120) %}
{% set z_height = params.Z_HEIGHT %}
{% set accel_chip = params.ACCEL_CHIP %}
{% set params_filtered = {
"CREATE_GRAPH": create_graph,
"FREQUENCY": frequency,
"DURATION": duration,
"ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '',
"AXIS": axis,
"TRAVEL_SPEED": travel_speed,
"Z_HEIGHT": z_height if z_height is not none else '',
"ACCEL_CHIP": accel_chip if accel_chip is not none else ''
} %}
_EXCITATE_AXIS_AT_FREQ {% for key, value in params_filtered.items() if value is defined and value is not none and value != '' %}{key}={value} {% endfor %}
[gcode_macro AXES_MAP_CALIBRATION]
description: dummy
gcode:
{% set dummy = params.Z_HEIGHT|default(20) %}
{% set dummy = params.SPEED|default(80) %}
{% set dummy = params.ACCEL|default(1500) %}
{% set dummy = params.TRAVEL_SPEED|default(120) %}
_AXES_MAP_CALIBRATION {rawparams}
[gcode_macro COMPARE_BELTS_RESPONSES]
description: dummy
gcode:
{% set freq_start = params.FREQ_START %}
{% set freq_end = params.FREQ_END %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1) %}
{% set accel_per_hz = params.ACCEL_PER_HZ %}
{% set travel_speed = params.TRAVEL_SPEED|default(120) %}
{% set z_height = params.Z_HEIGHT %}
{% set params_filtered = {
"FREQ_START": freq_start if freq_start is not none else '',
"FREQ_END": freq_end if freq_end is not none else '',
"HZ_PER_SEC": hz_per_sec,
"ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '',
"TRAVEL_SPEED": travel_speed,
"Z_HEIGHT": z_height if z_height is not none else ''
} %}
_COMPARE_BELTS_RESPONSES {% for key, value in params_filtered.items() if value is defined and value is not none and value != '' %}{key}={value} {% endfor %}
[gcode_macro AXES_SHAPER_CALIBRATION]
description: dummy
gcode:
{% set freq_start = params.FREQ_START %}
{% set freq_end = params.FREQ_END %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1) %}
{% set accel_per_hz = params.ACCEL_PER_HZ %}
{% set axis = params.AXIS|default('all') %}
{% set scv = params.SCV %}
{% set max_smoothing = params.MAX_SMOOTHING %}
{% set travel_speed = params.TRAVEL_SPEED|default(120) %}
{% set z_height = params.Z_HEIGHT %}
{% set params_filtered = {
"FREQ_START": freq_start if freq_start is not none else '',
"FREQ_END": freq_end if freq_end is not none else '',
"HZ_PER_SEC": hz_per_sec,
"ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '',
"AXIS": axis,
"SCV": scv if scv is not none else '',
"MAX_SMOOTHING": max_smoothing if max_smoothing is not none else '',
"TRAVEL_SPEED": travel_speed,
"Z_HEIGHT": z_height if z_height is not none else ''
} %}
_AXES_SHAPER_CALIBRATION {% for key, value in params_filtered.items() if value is defined and value is not none and value != '' %}{key}={value} {% endfor %}
[gcode_macro CREATE_VIBRATIONS_PROFILE]
description: dummy
gcode:
{% set dummy = params.SIZE|default(100) %}
{% set dummy = params.Z_HEIGHT|default(20) %}
{% set dummy = params.MAX_SPEED|default(200) %}
{% set dummy = params.SPEED_INCREMENT|default(2) %}
{% set dummy = params.ACCEL|default(3000) %}
{% set dummy = params.TRAVEL_SPEED|default(120) %}
{% set dummy = params.ACCEL_CHIP %}
_CREATE_VIBRATIONS_PROFILE {rawparams}

View File

@@ -0,0 +1,15 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: __init__.py
# Description: Imports various graph creator classes for the Shake&Tune package.
from .axes_map_graph_creator import AxesMapGraphCreator as AxesMapGraphCreator
from .belts_graph_creator import BeltsGraphCreator as BeltsGraphCreator
from .graph_creator import GraphCreator as GraphCreator
from .shaper_graph_creator import ShaperGraphCreator as ShaperGraphCreator
from .static_graph_creator import StaticGraphCreator as StaticGraphCreator
from .vibrations_graph_creator import VibrationsGraphCreator as VibrationsGraphCreator

View File

@@ -0,0 +1,515 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: axes_map_graph_creator.py
# Description: Implements the axes map detection script for Shake&Tune, including
# calibration tools and graph creation for 3D printer vibration analysis.
import optparse
import os
from datetime import datetime
from typing import List, Optional, Tuple
import matplotlib
import matplotlib.colors
import matplotlib.font_manager
import matplotlib.pyplot as plt
import matplotlib.ticker
import numpy as np
import pywt
from scipy import stats
matplotlib.use('Agg')
from ..helpers.common_func import parse_log
from ..helpers.console_output import ConsoleOutput
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
KLIPPAIN_COLORS = {
'purple': '#70088C',
'orange': '#FF8D32',
'dark_purple': '#150140',
'dark_orange': '#F24130',
'red_pink': '#F2055C',
}
MACHINE_AXES = ['x', 'y', 'z']
class AxesMapGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'axes map')
self._accel: Optional[int] = None
self._segment_length: Optional[float] = None
def configure(self, accel: int, segment_length: float) -> None:
self._accel = accel
self._segment_length = segment_length
def create_graph(self) -> None:
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-axesmap_*.csv',
min_files_required=3,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = axesmap_calibration(
lognames=[str(path) for path in lognames],
accel=self._accel,
fixed_length=self._segment_length,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
for old_file in files[keep_results:]:
file_date = '_'.join(old_file.stem.split('_')[1:3])
for suffix in {'X', 'Y', 'Z'}:
csv_file = self._folder / f'axesmap_{file_date}_{suffix}.csv'
csv_file.unlink(missing_ok=True)
old_file.unlink()
######################################################################
# Computation
######################################################################
def wavelet_denoise(data: np.ndarray, wavelet: str = 'db1', level: int = 1) -> Tuple[np.ndarray, np.ndarray]:
coeffs = pywt.wavedec(data, wavelet, mode='smooth')
threshold = np.median(np.abs(coeffs[-level])) / 0.6745 * np.sqrt(2 * np.log(len(data)))
new_coeffs = [pywt.threshold(c, threshold, mode='soft') for c in coeffs]
denoised_data = pywt.waverec(new_coeffs, wavelet)
# Compute noise by subtracting denoised data from original data
noise = data - denoised_data[: len(data)]
return denoised_data, noise
def integrate_trapz(accel: np.ndarray, time: np.ndarray) -> np.ndarray:
return np.array([np.trapz(accel[:i], time[:i]) for i in range(2, len(time) + 1)])
def process_acceleration_data(
time: np.ndarray, accel_x: np.ndarray, accel_y: np.ndarray, accel_z: np.ndarray
) -> Tuple[float, float, float, np.ndarray, np.ndarray, np.ndarray, float]:
# Calculate the constant offset (gravity component)
offset_x = np.mean(accel_x)
offset_y = np.mean(accel_y)
offset_z = np.mean(accel_z)
# Remove the constant offset from acceleration data
accel_x -= offset_x
accel_y -= offset_y
accel_z -= offset_z
# Apply wavelet denoising
accel_x, noise_x = wavelet_denoise(accel_x)
accel_y, noise_y = wavelet_denoise(accel_y)
accel_z, noise_z = wavelet_denoise(accel_z)
# Integrate acceleration to get velocity using trapezoidal rule
velocity_x = integrate_trapz(accel_x, time)
velocity_y = integrate_trapz(accel_y, time)
velocity_z = integrate_trapz(accel_z, time)
# Correct drift in velocity by resetting to zero at the beginning and end
velocity_x -= np.linspace(velocity_x[0], velocity_x[-1], len(velocity_x))
velocity_y -= np.linspace(velocity_y[0], velocity_y[-1], len(velocity_y))
velocity_z -= np.linspace(velocity_z[0], velocity_z[-1], len(velocity_z))
# Integrate velocity to get position using trapezoidal rule
position_x = integrate_trapz(velocity_x, time[1:])
position_y = integrate_trapz(velocity_y, time[1:])
position_z = integrate_trapz(velocity_z, time[1:])
noise_intensity = np.mean([np.std(noise_x), np.std(noise_y), np.std(noise_z)])
return offset_x, offset_y, offset_z, position_x, position_y, position_z, noise_intensity
def scale_positions_to_fixed_length(
position_x: np.ndarray, position_y: np.ndarray, position_z: np.ndarray, fixed_length: float
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
# Calculate the total distance traveled in 3D space
total_distance = np.sqrt(np.diff(position_x) ** 2 + np.diff(position_y) ** 2 + np.diff(position_z) ** 2).sum()
scale_factor = fixed_length / total_distance
# Apply the scale factor to the positions
position_x *= scale_factor
position_y *= scale_factor
position_z *= scale_factor
return position_x, position_y, position_z
def find_nearest_perfect_vector(average_direction_vector: np.ndarray) -> Tuple[np.ndarray, float]:
# Define the perfect vectors
perfect_vectors = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [-1, 0, 0], [0, -1, 0], [0, 0, -1]])
# Find the nearest perfect vector
dot_products = perfect_vectors @ average_direction_vector
nearest_vector_idx = np.argmax(dot_products)
nearest_vector = perfect_vectors[nearest_vector_idx]
# Calculate the angle error
angle_error = np.arccos(dot_products[nearest_vector_idx]) * 180 / np.pi
return nearest_vector, angle_error
def linear_regression_direction(
position_x: np.ndarray, position_y: np.ndarray, position_z: np.ndarray, trim_length: float = 0.25
) -> np.ndarray:
# Trim the start and end of the position data to keep only the center of the segment
# as the start and stop positions are not always perfectly aligned and can be a bit noisy
t = len(position_x)
trim_start = int(t * trim_length)
trim_end = int(t * (1 - trim_length))
position_x = position_x[trim_start:trim_end]
position_y = position_y[trim_start:trim_end]
position_z = position_z[trim_start:trim_end]
# Compute the direction vector using linear regression over the position data
time = np.arange(len(position_x))
slope_x, intercept_x, _, _, _ = stats.linregress(time, position_x)
slope_y, intercept_y, _, _, _ = stats.linregress(time, position_y)
slope_z, intercept_z, _, _, _ = stats.linregress(time, position_z)
end_position = np.array(
[slope_x * time[-1] + intercept_x, slope_y * time[-1] + intercept_y, slope_z * time[-1] + intercept_z]
)
direction_vector = end_position - np.array([intercept_x, intercept_y, intercept_z])
direction_vector = direction_vector / np.linalg.norm(direction_vector)
return direction_vector
######################################################################
# Graphing
######################################################################
def plot_compare_frequency(
ax: plt.Axes,
time_data: List[np.ndarray],
accel_data: List[Tuple[np.ndarray, np.ndarray, np.ndarray]],
offset: float,
noise_level: str,
) -> None:
# Plot acceleration data
for i, (time, (accel_x, accel_y, accel_z)) in enumerate(zip(time_data, accel_data)):
ax.plot(
time,
accel_x,
label='X' if i == 0 else '',
color=KLIPPAIN_COLORS['purple'],
linewidth=0.5,
zorder=50 if i == 0 else 10,
)
ax.plot(
time,
accel_y,
label='Y' if i == 0 else '',
color=KLIPPAIN_COLORS['orange'],
linewidth=0.5,
zorder=50 if i == 1 else 10,
)
ax.plot(
time,
accel_z,
label='Z' if i == 0 else '',
color=KLIPPAIN_COLORS['red_pink'],
linewidth=0.5,
zorder=50 if i == 2 else 10,
)
ax.set_xlabel('Time (s)')
ax.set_ylabel('Acceleration (mm/s²)')
ax.xaxis.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.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('small')
ax.set_title(
'Acceleration (gravity offset removed)',
fontsize=14,
color=KLIPPAIN_COLORS['dark_orange'],
weight='bold',
)
ax.legend(loc='upper left', prop=fontP)
# Add the gravity and noise level to the graph legend
ax2 = ax.twinx()
ax2.yaxis.set_visible(False)
ax2.plot([], [], ' ', label=noise_level)
ax2.plot([], [], ' ', label=f'Measured gravity: {offset / 1000:0.3f} m/s²')
ax2.legend(loc='upper right', prop=fontP)
def plot_3d_path(
ax: plt.Axes,
position_data: List[Tuple[np.ndarray, np.ndarray, np.ndarray]],
direction_vectors: List[np.ndarray],
angle_errors: List[float],
) -> None:
# Plot the 3D path of the movement
for i, ((position_x, position_y, position_z), average_direction_vector, angle_error) in enumerate(
zip(position_data, direction_vectors, angle_errors)
):
ax.plot(position_x, position_y, position_z, color=KLIPPAIN_COLORS['orange'], linestyle=':', linewidth=2)
ax.scatter(position_x[0], position_y[0], position_z[0], color=KLIPPAIN_COLORS['red_pink'], zorder=10)
ax.text(
position_x[0] + 1,
position_y[0],
position_z[0],
str(i + 1),
color='black',
fontsize=16,
fontweight='bold',
zorder=20,
)
# Plot the average direction vector
start_position = np.array([position_x[0], position_y[0], position_z[0]])
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]]
)
ax.plot(
[start_position[0], end_position[0]],
[start_position[1], end_position[1]],
[start_position[2], end_position[2]],
label=f'{["X", "Y", "Z"][i]} angle: {angle_error:0.2f}°',
color=KLIPPAIN_COLORS['purple'],
linestyle='-',
linewidth=2,
)
ax.set_xlabel('X Position (mm)')
ax.set_ylabel('Y Position (mm)')
ax.set_zlabel('Z Position (mm)')
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('small')
ax.set_title(
'Estimated movement in 3D space',
fontsize=14,
color=KLIPPAIN_COLORS['dark_orange'],
weight='bold',
)
ax.legend(loc='upper left', prop=fontP)
def format_direction_vector(vectors: List[np.ndarray]) -> str:
formatted_vector = []
axes_count = {'x': 0, 'y': 0, 'z': 0}
for vector in vectors:
for i in range(len(vector)):
if vector[i] > 0:
formatted_vector.append(MACHINE_AXES[i])
axes_count[MACHINE_AXES[i]] += 1
break
elif vector[i] < 0:
formatted_vector.append(f'-{MACHINE_AXES[i]}')
axes_count[MACHINE_AXES[i]] += 1
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)
######################################################################
# Startup and main routines
######################################################################
def axesmap_calibration(
lognames: List[str], fixed_length: float, accel: Optional[float] = None, st_version: str = 'unknown'
) -> plt.Figure:
# Parse data from the log files while ignoring CSV in the wrong format (sorted by axis name)
raw_datas = {}
for logname in lognames:
data = parse_log(logname)
if data is not None:
_axis = logname.split('_')[-1].split('.')[0].lower()
raw_datas[_axis] = data
if len(raw_datas) != 3:
raise ValueError('This tool needs 3 CSVs to work with (like axesmap_X.csv, axesmap_Y.csv and axesmap_Z.csv)')
fig, ((ax1, ax2)) = plt.subplots(
1,
2,
gridspec_kw={
'width_ratios': [5, 3],
'bottom': 0.080,
'top': 0.840,
'left': 0.055,
'right': 0.960,
'hspace': 0.166,
'wspace': 0.060,
},
)
fig.set_size_inches(15, 7)
ax2.remove()
ax2 = fig.add_subplot(122, projection='3d')
cumulative_start_position = np.array([0, 0, 0])
direction_vectors = []
angle_errors = []
total_noise_intensity = 0.0
acceleration_data = []
position_data = []
gravities = []
for _, machine_axis in enumerate(MACHINE_AXES):
if machine_axis not in raw_datas:
raise ValueError(f'Missing CSV file for axis {machine_axis}')
# Get the accel data according to the current axes_map
time = raw_datas[machine_axis][:, 0]
accel_x = raw_datas[machine_axis][:, 1]
accel_y = raw_datas[machine_axis][:, 2]
accel_z = raw_datas[machine_axis][:, 3]
offset_x, offset_y, offset_z, position_x, position_y, position_z, noise_intensity = process_acceleration_data(
time, accel_x, accel_y, accel_z
)
position_x, position_y, position_z = scale_positions_to_fixed_length(
position_x, position_y, position_z, fixed_length
)
position_x += cumulative_start_position[0]
position_y += cumulative_start_position[1]
position_z += cumulative_start_position[2]
gravity = np.linalg.norm(np.array([offset_x, offset_y, offset_z]))
average_direction_vector = linear_regression_direction(position_x, position_y, position_z)
direction_vector, angle_error = find_nearest_perfect_vector(average_direction_vector)
ConsoleOutput.print(
f'Machine axis {machine_axis.upper()} -> nearest accelerometer direction vector: {direction_vector} (angle error: {angle_error:.2f}°)'
)
direction_vectors.append(direction_vector)
angle_errors.append(angle_error)
total_noise_intensity += noise_intensity
acceleration_data.append((time, (accel_x, accel_y, accel_z)))
position_data.append((position_x, position_y, position_z))
gravities.append(gravity)
# Update the cumulative start position for the next segment
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)
if average_noise_intensity <= 350:
average_noise_intensity_text = '-> OK'
elif 350 < average_noise_intensity <= 700:
average_noise_intensity_text = '-> WARNING: accelerometer noise is a bit high'
else:
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)
ConsoleOutput.print(f'--> Detected axes_map: {formatted_direction_vector}')
# 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
title_line1 = 'AXES MAP CALIBRATION TOOL'
fig.text(
0.060, 0.947, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold'
)
try:
filename = lognames[0].split('/')[-1]
dt = datetime.strptime(f"{filename.split('_')[1]} {filename.split('_')[2]}", '%Y%m%d %H%M%S')
title_line2 = dt.strftime('%x %X')
if accel is not None:
title_line2 += f' -- at {accel:0.0f} mm/s²'
except Exception:
ConsoleOutput.print(
f'Warning: CSV filenames look to be different than expected ({lognames[0]}, {lognames[1]}, {lognames[2]})'
)
title_line2 = lognames[0].split('/')[-1] + ' ...'
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}'
fig.text(0.50, 0.985, title_line3, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
# 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.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png')))
ax_logo.axis('off')
# Adding Shake&Tune version in the top right corner
if st_version != 'unknown':
fig.text(0.995, 0.980, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple'])
return fig
def main():
# Parse command-line arguments
usage = '%prog [options] <raw logs>'
opts = optparse.OptionParser(usage)
opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
opts.add_option(
'-a', '--accel', type='string', dest='accel', default=None, help='acceleration value used to do the movements'
)
opts.add_option(
'-l', '--length', type='float', dest='length', default=None, help='recorded length for each segment'
)
options, args = opts.parse_args()
if len(args) < 1:
opts.error('No CSV file(s) to analyse')
if options.accel is None:
opts.error('You must specify the acceleration value used when generating the CSV file (option -a)')
try:
accel_value = float(options.accel)
except ValueError:
opts.error('Invalid acceleration value. It should be a numeric value.')
if options.length is None:
opts.error('You must specify the length of the measured segments (option -l)')
try:
length_value = float(options.length)
except ValueError:
opts.error('Invalid length value. It should be a numeric value.')
if options.output is None:
opts.error('You must specify an output file.png to use the script (option -o)')
fig = axesmap_calibration(args, length_value, accel_value, 'unknown')
fig.savefig(options.output, dpi=150)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,633 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2022 - 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: belts_graph_creator.py
# Description: Implements the CoreXY/CoreXZ belts calibration script for Shake&Tune,
# including computation and graphing functions for 3D printer belt paths analysis.
import optparse
import os
from datetime import datetime
from typing import List, NamedTuple, Optional, Tuple
import matplotlib
import matplotlib.colors
import matplotlib.font_manager
import matplotlib.pyplot as plt
import matplotlib.ticker
import numpy as np
matplotlib.use('Agg')
from ..helpers.common_func import detect_peaks, parse_log, setup_klipper_import
from ..helpers.console_output import ConsoleOutput
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
ALPHABET = (
'αβγδεζηθικλμνξοπρστυφχψω' # For paired peak names (using the Greek alphabet to avoid confusion with belt names)
)
PEAKS_DETECTION_THRESHOLD = 0.1 # Threshold to detect peaks in the PSD signal (10% of max)
DC_MAX_PEAKS = 2 # Maximum ideal number of peaks
DC_MAX_UNPAIRED_PEAKS_ALLOWED = 0 # No unpaired peaks are tolerated
KLIPPAIN_COLORS = {
'purple': '#70088C',
'orange': '#FF8D32',
'dark_purple': '#150140',
'dark_orange': '#F24130',
'red_pink': '#F2055C',
}
# Define the SignalData type to store the data of a signal (PSD, peaks, etc.)
class SignalData(NamedTuple):
freqs: np.ndarray
psd: np.ndarray
peaks: np.ndarray
paired_peaks: Optional[List[Tuple[Tuple[int, float, float], Tuple[int, float, float]]]] = None
unpaired_peaks: Optional[List[int]] = None
# Define the PeakPairingResult type to store the result of the peak pairing function
class PeakPairingResult(NamedTuple):
paired_peaks: List[Tuple[Tuple[int, float, float], Tuple[int, float, float]]]
unpaired_peaks1: List[int]
unpaired_peaks2: List[int]
class BeltsGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'belts comparison')
self._kinematics: Optional[str] = None
self._accel_per_hz: Optional[float] = None
def configure(self, kinematics: Optional[str] = None, accel_per_hz: Optional[float] = None) -> None:
self._kinematics = kinematics
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-belt_*.csv',
min_files_required=2,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = belts_calibration(
lognames=[str(path) for path in lognames],
kinematics=self._kinematics,
klipperdir=str(self._config.klipper_folder),
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
for old_file in files[keep_results:]:
file_date = '_'.join(old_file.stem.split('_')[1:3])
for suffix in {'A', 'B'}:
csv_file = self._folder / f'beltscomparison_{file_date}_{suffix}.csv'
csv_file.unlink(missing_ok=True)
old_file.unlink()
######################################################################
# Computation of the PSD graph
######################################################################
# This function create pairs of peaks that are close in frequency on two curves (that are known
# to be resonances points and must be similar on both belts on a CoreXY kinematic)
def pair_peaks(
peaks1: np.ndarray, freqs1: np.ndarray, psd1: np.ndarray, peaks2: np.ndarray, freqs2: np.ndarray, psd2: np.ndarray
) -> PeakPairingResult:
# Compute a dynamic detection threshold to filter and pair peaks efficiently
# even if the signal is very noisy (this get clipped to a maximum of 10Hz diff)
distances = []
for p1 in peaks1:
for p2 in peaks2:
distances.append(abs(freqs1[p1] - freqs2[p2]))
distances = np.array(distances)
median_distance = np.median(distances)
iqr = np.percentile(distances, 75) - np.percentile(distances, 25)
threshold = median_distance + 1.5 * iqr
threshold = min(threshold, 10)
# Pair the peaks using the dynamic thresold
paired_peaks = []
unpaired_peaks1 = list(peaks1)
unpaired_peaks2 = list(peaks2)
while unpaired_peaks1 and unpaired_peaks2:
min_distance = threshold + 1
pair = None
for p1 in unpaired_peaks1:
for p2 in unpaired_peaks2:
distance = abs(freqs1[p1] - freqs2[p2])
if distance < min_distance:
min_distance = distance
pair = (p1, p2)
if pair is None: # No more pairs below the threshold
break
p1, p2 = pair
paired_peaks.append(((p1, freqs1[p1], psd1[p1]), (p2, freqs2[p2], psd2[p2])))
unpaired_peaks1.remove(p1)
unpaired_peaks2.remove(p2)
return PeakPairingResult(
paired_peaks=paired_peaks, unpaired_peaks1=unpaired_peaks1, unpaired_peaks2=unpaired_peaks2
)
######################################################################
# Computation of the differential spectrogram
######################################################################
def compute_mhi(similarity_factor: float, signal1: SignalData, signal2: SignalData) -> str:
num_unpaired_peaks = len(signal1.unpaired_peaks) + len(signal2.unpaired_peaks)
num_paired_peaks = len(signal1.paired_peaks)
# Combine unpaired peaks from both signals, tagging each peak with its respective signal
combined_unpaired_peaks = [(peak, signal1) for peak in signal1.unpaired_peaks] + [
(peak, signal2) for peak in signal2.unpaired_peaks
]
psd_highest_max = max(signal1.psd.max(), signal2.psd.max())
# Start with the similarity factor directly scaled to a percentage
mhi = similarity_factor
# Bonus for ideal number of total peaks (1 or 2)
if num_paired_peaks >= DC_MAX_PEAKS:
mhi *= DC_MAX_PEAKS / num_paired_peaks # Reduce MHI if more than ideal number of peaks
# Penalty from unpaired peaks weighted by their amplitude relative to the maximum PSD amplitude
unpaired_peak_penalty = 0
if num_unpaired_peaks > DC_MAX_UNPAIRED_PEAKS_ALLOWED:
for peak, signal in combined_unpaired_peaks:
unpaired_peak_penalty += (signal.psd[peak] / psd_highest_max) * 30
mhi -= unpaired_peak_penalty
# Ensure the result lies between 0 and 100 by clipping the computed value
mhi = np.clip(mhi, 0, 100)
return mhi_lut(mhi)
# LUT to transform the MHI into a textual value easy to understand for the users of the script
def mhi_lut(mhi: float) -> str:
ranges = [
(70, 100, 'Excellent mechanical health'),
(55, 70, 'Good mechanical health'),
(45, 55, 'Acceptable mechanical health'),
(30, 45, 'Potential signs of a mechanical issue'),
(15, 30, 'Likely a mechanical issue'),
(0, 15, 'Mechanical issue detected'),
]
mhi = np.clip(mhi, 1, 100)
return next(
(message for lower, upper, message in ranges if lower < mhi <= upper),
'Unknown mechanical health',
)
######################################################################
# Graphing
######################################################################
def plot_compare_frequency(
ax: plt.Axes, signal1: SignalData, signal2: SignalData, signal1_belt: str, signal2_belt: str, max_freq: float
) -> None:
# Plot the two belts PSD signals
ax.plot(signal1.freqs, signal1.psd, label='Belt ' + signal1_belt, color=KLIPPAIN_COLORS['purple'])
ax.plot(signal2.freqs, signal2.psd, label='Belt ' + signal2_belt, color=KLIPPAIN_COLORS['orange'])
psd_highest_max = max(signal1.psd.max(), signal2.psd.max())
# Trace and annotate the peaks on the graph
paired_peak_count = 0
unpaired_peak_count = 0
offsets_table_data = []
for _, (peak1, peak2) in enumerate(signal1.paired_peaks):
label = ALPHABET[paired_peak_count]
amplitude_offset = abs(((signal2.psd[peak2[0]] - signal1.psd[peak1[0]]) / psd_highest_max) * 100)
frequency_offset = abs(signal2.freqs[peak2[0]] - signal1.freqs[peak1[0]])
offsets_table_data.append([f'Peaks {label}', f'{frequency_offset:.1f} Hz', f'{amplitude_offset:.1f} %'])
ax.plot(signal1.freqs[peak1[0]], signal1.psd[peak1[0]], 'x', color='black')
ax.plot(signal2.freqs[peak2[0]], signal2.psd[peak2[0]], 'x', color='black')
ax.plot(
[signal1.freqs[peak1[0]], signal2.freqs[peak2[0]]],
[signal1.psd[peak1[0]], signal2.psd[peak2[0]]],
':',
color='gray',
)
ax.annotate(
label + '1',
(signal1.freqs[peak1[0]], signal1.psd[peak1[0]]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='black',
)
ax.annotate(
label + '2',
(signal2.freqs[peak2[0]], signal2.psd[peak2[0]]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='black',
)
paired_peak_count += 1
for peak in signal1.unpaired_peaks:
ax.plot(signal1.freqs[peak], signal1.psd[peak], 'x', color='black')
ax.annotate(
str(unpaired_peak_count + 1),
(signal1.freqs[peak], signal1.psd[peak]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='red',
weight='bold',
)
unpaired_peak_count += 1
for peak in signal2.unpaired_peaks:
ax.plot(signal2.freqs[peak], signal2.psd[peak], 'x', color='black')
ax.annotate(
str(unpaired_peak_count + 1),
(signal2.freqs[peak], signal2.psd[peak]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='red',
weight='bold',
)
unpaired_peak_count += 1
# Add estimated similarity to the graph
ax2 = ax.twinx() # To split the legends in two box
ax2.yaxis.set_visible(False)
ax2.plot([], [], ' ', label=f'Number of unpaired peaks: {unpaired_peak_count}')
# Setting axis parameters, grid and graph title
ax.set_xlabel('Frequency (Hz)')
ax.set_xlim([0, max_freq])
ax.set_ylabel('Power spectral density')
ax.set_ylim([0, psd_highest_max * 1.1])
ax.xaxis.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.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('small')
ax.set_title(
'Belts frequency profiles',
fontsize=14,
color=KLIPPAIN_COLORS['dark_orange'],
weight='bold',
)
# Print the table of offsets ontop of the graph below the original legend (upper right)
if len(offsets_table_data) > 0:
columns = [
'',
'Frequency delta',
'Amplitude delta',
]
offset_table = ax.table(
cellText=offsets_table_data,
colLabels=columns,
bbox=[0.66, 0.79, 0.33, 0.15],
loc='upper right',
cellLoc='center',
)
offset_table.auto_set_font_size(False)
offset_table.set_fontsize(8)
offset_table.auto_set_column_width([0, 1, 2])
offset_table.set_zorder(100)
cells = [key for key in offset_table.get_celld().keys()]
for cell in cells:
offset_table[cell].set_facecolor('white')
offset_table[cell].set_alpha(0.6)
ax.legend(loc='upper left', prop=fontP)
ax2.legend(loc='upper right', prop=fontP)
return
# Compute quantile-quantile plot to compare the two belts
def plot_versus_belts(
ax: plt.Axes,
common_freqs: np.ndarray,
signal1: SignalData,
signal2: SignalData,
interp_psd1: np.ndarray,
interp_psd2: np.ndarray,
signal1_belt: str,
signal2_belt: str,
) -> None:
ax.set_title('Cross-belts comparison plot', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
max_psd = max(np.max(interp_psd1), np.max(interp_psd2))
ideal_line = np.linspace(0, max_psd * 1.1, 500)
green_boundary = ideal_line + (0.35 * max_psd * np.exp(-ideal_line / (0.6 * max_psd)))
ax.fill_betweenx(ideal_line, ideal_line, green_boundary, color='green', alpha=0.15)
ax.fill_between(ideal_line, ideal_line, green_boundary, color='green', alpha=0.15, label='Good zone')
ax.plot(
ideal_line,
ideal_line,
'--',
label='Ideal line',
color='red',
linewidth=2,
)
ax.plot(interp_psd1, interp_psd2, color='dimgrey', marker='o', markersize=1.5)
ax.fill_betweenx(interp_psd2, interp_psd1, color=KLIPPAIN_COLORS['red_pink'], alpha=0.1)
paired_peak_count = 0
unpaired_peak_count = 0
for _, (peak1, peak2) in enumerate(signal1.paired_peaks):
label = ALPHABET[paired_peak_count]
freq1 = signal1.freqs[peak1[0]]
freq2 = signal2.freqs[peak2[0]]
nearest_idx1 = np.argmin(np.abs(common_freqs - freq1))
nearest_idx2 = np.argmin(np.abs(common_freqs - freq2))
if nearest_idx1 == nearest_idx2:
psd1_peak_value = interp_psd1[nearest_idx1]
psd2_peak_value = interp_psd2[nearest_idx1]
ax.plot(psd1_peak_value, psd2_peak_value, marker='o', color='black', markersize=7)
ax.annotate(
f'{label}1/{label}2',
(psd1_peak_value, psd2_peak_value),
textcoords='offset points',
xytext=(-7, 7),
fontsize=13,
color='black',
)
else:
psd1_peak_value = interp_psd1[nearest_idx1]
psd1_on_peak = interp_psd1[nearest_idx2]
psd2_peak_value = interp_psd2[nearest_idx2]
psd2_on_peak = interp_psd2[nearest_idx1]
ax.plot(psd1_on_peak, psd2_peak_value, marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7)
ax.plot(psd1_peak_value, psd2_on_peak, marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7)
ax.annotate(
f'{label}1',
(psd1_peak_value, psd2_on_peak),
textcoords='offset points',
xytext=(0, 7),
fontsize=13,
color='black',
)
ax.annotate(
f'{label}2',
(psd1_on_peak, psd2_peak_value),
textcoords='offset points',
xytext=(0, 7),
fontsize=13,
color='black',
)
paired_peak_count += 1
for _, peak_index in enumerate(signal1.unpaired_peaks):
freq1 = signal1.freqs[peak_index]
freq2 = signal2.freqs[peak_index]
nearest_idx1 = np.argmin(np.abs(common_freqs - freq1))
nearest_idx2 = np.argmin(np.abs(common_freqs - freq2))
psd1_peak_value = interp_psd1[nearest_idx1]
psd2_peak_value = interp_psd2[nearest_idx1]
ax.plot(psd1_peak_value, psd2_peak_value, marker='o', color=KLIPPAIN_COLORS['purple'], markersize=7)
ax.annotate(
str(unpaired_peak_count + 1),
(psd1_peak_value, psd2_peak_value),
textcoords='offset points',
fontsize=13,
weight='bold',
color=KLIPPAIN_COLORS['red_pink'],
xytext=(0, 7),
)
unpaired_peak_count += 1
for _, peak_index in enumerate(signal2.unpaired_peaks):
freq1 = signal1.freqs[peak_index]
freq2 = signal2.freqs[peak_index]
nearest_idx1 = np.argmin(np.abs(common_freqs - freq1))
nearest_idx2 = np.argmin(np.abs(common_freqs - freq2))
psd1_peak_value = interp_psd1[nearest_idx1]
psd2_peak_value = interp_psd2[nearest_idx1]
ax.plot(psd1_peak_value, psd2_peak_value, marker='o', color=KLIPPAIN_COLORS['orange'], markersize=7)
ax.annotate(
str(unpaired_peak_count + 1),
(psd1_peak_value, psd2_peak_value),
textcoords='offset points',
fontsize=13,
weight='bold',
color=KLIPPAIN_COLORS['red_pink'],
xytext=(0, 7),
)
unpaired_peak_count += 1
ax.set_xlabel(f'Belt {signal1_belt}')
ax.set_ylabel(f'Belt {signal2_belt}')
ax.set_xlim([0, max_psd * 1.1])
ax.set_ylim([0, max_psd * 1.1])
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.ticklabel_format(style='scientific', scilimits=(0, 0))
ax.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('medium')
ax.legend(loc='upper left', prop=fontP)
return
######################################################################
# Custom tools
######################################################################
# Original Klipper function to get the PSD data of a raw accelerometer signal
def compute_signal_data(data: np.ndarray, max_freq: float) -> SignalData:
helper = shaper_calibrate.ShaperCalibrate(printer=None)
calibration_data = helper.process_accelerometer_data(data)
freqs = calibration_data.freq_bins[calibration_data.freq_bins <= max_freq]
psd = calibration_data.get_psd('all')[calibration_data.freq_bins <= max_freq]
_, peaks, _ = detect_peaks(psd, freqs, PEAKS_DETECTION_THRESHOLD * psd.max())
return SignalData(freqs=freqs, psd=psd, peaks=peaks)
######################################################################
# Startup and main routines
######################################################################
def belts_calibration(
lognames: List[str],
kinematics: Optional[str],
klipperdir: str = '~/klipper',
max_freq: float = 200.0,
accel_per_hz: Optional[float] = None,
st_version: str = 'unknown',
) -> plt.Figure:
global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir)
# Parse data from the log files while ignoring CSV in the wrong format
datas = [data for data in (parse_log(fn) for fn in lognames) if data is not None]
if len(datas) != 2:
raise ValueError('Incorrect number of .csv files used (this function needs exactly two files to compare them)!')
# Get the belts name for the legend to avoid putting the full file name
belt_info = {'A': ' (axis 1,-1)', 'B': ' (axis 1, 1)'}
signal1_belt = (lognames[0].split('/')[-1]).split('_')[-1][0]
signal2_belt = (lognames[1].split('/')[-1]).split('_')[-1][0]
signal1_belt += belt_info.get(signal1_belt, '')
signal2_belt += belt_info.get(signal2_belt, '')
# Compute calibration data for the two datasets with automatic peaks detection
signal1 = compute_signal_data(datas[0], max_freq)
signal2 = compute_signal_data(datas[1], max_freq)
del datas
# Pair the peaks across the two datasets
pairing_result = pair_peaks(signal1.peaks, signal1.freqs, signal1.psd, signal2.peaks, signal2.freqs, signal2.psd)
signal1 = signal1._replace(paired_peaks=pairing_result.paired_peaks, unpaired_peaks=pairing_result.unpaired_peaks1)
signal2 = signal2._replace(paired_peaks=pairing_result.paired_peaks, unpaired_peaks=pairing_result.unpaired_peaks2)
# Re-interpolate the PSD signals to a common frequency range to be able to plot them one against the other point by point
common_freqs = np.linspace(0, max_freq, 500)
interp_psd1 = np.interp(common_freqs, signal1.freqs, signal1.psd)
interp_psd2 = np.interp(common_freqs, signal2.freqs, signal2.psd)
# Calculating R^2 to y=x line to compute the similarity between the two belts
ss_res = np.sum((interp_psd2 - interp_psd1) ** 2)
ss_tot = np.sum((interp_psd2 - np.mean(interp_psd2)) ** 2)
similarity_factor = (1 - (ss_res / ss_tot)) * 100
ConsoleOutput.print(f'Belts estimated similarity: {similarity_factor:.1f}%')
# mhi = compute_mhi(similarity_factor, num_peaks, num_unpaired_peaks)
mhi = compute_mhi(similarity_factor, signal1, signal2)
ConsoleOutput.print(f'[experimental] Mechanical health: {mhi}')
fig, ((ax1, ax3)) = plt.subplots(
1,
2,
gridspec_kw={
'width_ratios': [5, 3],
'bottom': 0.080,
'top': 0.840,
'left': 0.050,
'right': 0.985,
'hspace': 0.166,
'wspace': 0.138,
},
)
fig.set_size_inches(15, 7)
# Add title
title_line1 = 'RELATIVE BELTS CALIBRATION TOOL'
fig.text(
0.060, 0.947, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold'
)
try:
filename = lognames[0].split('/')[-1]
dt = datetime.strptime(f"{filename.split('_')[1]} {filename.split('_')[2]}", '%Y%m%d %H%M%S')
title_line2 = dt.strftime('%x %X')
if kinematics is not None:
title_line2 += ' -- ' + kinematics.upper() + ' kinematics'
except Exception:
ConsoleOutput.print(f'Warning: Unable to parse the date from the filename ({lognames[0]}, {lognames[1]})')
title_line2 = lognames[0].split('/')[-1] + ' / ' + lognames[1].split('/')[-1]
fig.text(0.060, 0.939, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
# We add the estimated similarity and the MHI value to the title only if the kinematics is CoreXY
# as it make no sense to compute these values for other kinematics that doesn't have paired belts
if kinematics in {'corexy', 'corexz'}:
title_line3 = f'| Estimated similarity: {similarity_factor:.1f}%'
title_line4 = f'| {mhi} (experimental)'
fig.text(0.55, 0.985, title_line3, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.55, 0.950, title_line4, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
# Add the accel_per_hz value to the title
title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz'
fig.text(0.55, 0.915, title_line5, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
# Plot the graphs
plot_compare_frequency(ax1, signal1, signal2, signal1_belt, signal2_belt, max_freq)
plot_versus_belts(ax3, common_freqs, signal1, signal2, interp_psd1, interp_psd2, signal1_belt, signal2_belt)
# 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.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png')))
ax_logo.axis('off')
# Adding Shake&Tune version in the top right corner
if st_version != 'unknown':
fig.text(0.995, 0.980, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple'])
return fig
def main():
# Parse command-line arguments
usage = '%prog [options] <raw logs>'
opts = optparse.OptionParser(usage)
opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
opts.add_option('-f', '--max_freq', type='float', default=200.0, help='maximum frequency to graph')
opts.add_option('--accel_per_hz', type='float', default=None, help='accel_per_hz used during the measurement')
opts.add_option(
'-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory'
)
opts.add_option(
'-m',
'--kinematics',
type='string',
dest='kinematics',
help='machine kinematics configuration',
)
options, args = opts.parse_args()
if len(args) < 1:
opts.error('Incorrect number of arguments')
if options.output is None:
opts.error('You must specify an output file.png to use the script (option -o)')
fig = belts_calibration(
args, options.kinematics, options.klipperdir, options.max_freq, options.accel_per_hz, 'unknown'
)
fig.savefig(options.output, dpi=150)
if __name__ == '__main__':
main()

View File

@@ -0,0 +1,84 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: graph_creator.py
# Description: Abstract base class for creating various types of graphs in Shake&Tune,
# including methods for moving, preparing, saving, and cleaning up files.
# This class is inherited by the AxesMapGraphCreator, BeltsGraphCreator,
# ShaperGraphCreator, VibrationsGraphCreator, StaticGraphCreator
import abc
import shutil
from datetime import datetime
from pathlib import Path
from typing import Callable, List, Optional
from matplotlib.figure import Figure
from ..shaketune_config import ShakeTuneConfig
class GraphCreator(abc.ABC):
def __init__(self, config: ShakeTuneConfig, graph_type: str):
self._config = config
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
self._version = ShakeTuneConfig.get_git_version()
self._type = graph_type
self._folder = self._config.get_results_folder(graph_type)
def _move_and_prepare_files(
self,
glob_pattern: str,
min_files_required: Optional[int] = None,
custom_name_func: Optional[Callable[[Path], str]] = None,
) -> List[Path]:
tmp_path = Path('/tmp')
globbed_files = list(tmp_path.glob(glob_pattern))
# If min_files_required is not set, use the number of globbed files as the minimum
min_files_required = min_files_required or len(globbed_files)
if not globbed_files:
raise FileNotFoundError(f'no CSV files found in the /tmp folder to create the {self._type} graphs!')
if len(globbed_files) < min_files_required:
raise FileNotFoundError(f'{min_files_required} CSV files are needed to create the {self._type} graphs!')
lognames = []
for filename in sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[:min_files_required]:
custom_name = custom_name_func(filename) if custom_name_func else filename.name
new_file = self._folder / f"{self._type.replace(' ', '')}_{self._graph_date}_{custom_name}.csv"
# shutil.move() is needed to move the file across filesystems (mainly for BTT CB1 Pi default OS image)
shutil.move(filename, new_file)
lognames.append(new_file)
return lognames
def _save_figure_and_cleanup(self, fig: Figure, lognames: List[Path], axis_label: Optional[str] = None) -> None:
axis_suffix = f'_{axis_label}' if axis_label else ''
png_filename = self._folder / f"{self._type.replace(' ', '')}_{self._graph_date}{axis_suffix}.png"
fig.savefig(png_filename, dpi=self._config.dpi)
if self._config.keep_csv:
self._archive_files(lognames)
else:
self._remove_files(lognames)
def _archive_files(self, lognames: List[Path]) -> None:
return
def _remove_files(self, lognames: List[Path]) -> None:
for csv in lognames:
csv.unlink(missing_ok=True)
def get_type(self) -> str:
return self._type
@abc.abstractmethod
def create_graph(self) -> None:
pass
@abc.abstractmethod
def clean_old_files(self, keep_results: int) -> None:
pass

View File

Before

Width:  |  Height:  |  Size: 607 KiB

After

Width:  |  Height:  |  Size: 607 KiB

View File

@@ -1,4 +1,15 @@
#!/usr/bin/env python3 # Shake&Tune: 3D printer analysis tools
#
# Derived from the calibrate_shaper.py official Klipper script
# Copyright (C) 2020 Dmitry Butyugin <dmbutyugin@google.com>
# Copyright (C) 2020 Kevin O'Connor <kevin@koconnor.net>
# Copyright (C) 2022 - 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: shaper_graph_creator.py
# Description: Implements the input shaper calibration script for Shake&Tune,
# including computation and graphing functions for 3D printer vibration analysis.
################################################# #################################################
######## INPUT SHAPER CALIBRATION SCRIPT ######## ######## INPUT SHAPER CALIBRATION SCRIPT ########
@@ -11,6 +22,7 @@
import optparse import optparse
import os import os
from datetime import datetime from datetime import datetime
from typing import List, Optional
import matplotlib import matplotlib
import matplotlib.font_manager import matplotlib.font_manager
@@ -27,12 +39,14 @@ from ..helpers.common_func import (
parse_log, parse_log,
setup_klipper_import, setup_klipper_import,
) )
from ..helpers.locale_utils import print_with_c_locale, set_locale from ..helpers.console_output import ConsoleOutput
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
PEAKS_DETECTION_THRESHOLD = 0.05 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_SMOOTHING = 0.1 MAX_VIBRATIONS = 5.0
KLIPPAIN_COLORS = { KLIPPAIN_COLORS = {
'purple': '#70088C', 'purple': '#70088C',
@@ -43,6 +57,49 @@ KLIPPAIN_COLORS = {
} }
class ShaperGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'input shaper')
self._max_smoothing: Optional[float] = None
self._scv: Optional[float] = None
self._accel_per_hz: Optional[float] = None
def configure(
self, scv: float, max_smoothing: Optional[float] = None, accel_per_hz: Optional[float] = None
) -> None:
self._scv = scv
self._max_smoothing = max_smoothing
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
if not self._scv:
raise ValueError('scv must be set to create the input shaper graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-axis_*.csv',
min_files_required=1,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = shaper_calibration(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
max_smoothing=self._max_smoothing,
scv=self._scv,
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1])
def clean_old_files(self, keep_results: int = 3) -> None:
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= 2 * keep_results:
return # No need to delete any files
for old_file in files[2 * keep_results :]:
csv_file = old_file.with_suffix('.csv')
csv_file.unlink(missing_ok=True)
old_file.unlink()
###################################################################### ######################################################################
# Computation # Computation
###################################################################### ######################################################################
@@ -50,7 +107,7 @@ KLIPPAIN_COLORS = {
# Find the best shaper parameters using Klipper's official algorithm selection with # Find the best shaper parameters using Klipper's official algorithm selection with
# a proper precomputed damping ratio (zeta) and using the configured printer SQV value # a proper precomputed damping ratio (zeta) and using the configured printer SQV value
def calibrate_shaper(datas, max_smoothing, scv, max_freq): def calibrate_shaper(datas: List[np.ndarray], max_smoothing: Optional[float], scv: float, max_freq: float):
helper = shaper_calibrate.ShaperCalibrate(printer=None) helper = shaper_calibrate.ShaperCalibrate(printer=None)
calibration_data = helper.process_accelerometer_data(datas) calibration_data = helper.process_accelerometer_data(datas)
calibration_data.normalize_to_frequencies() calibration_data.normalize_to_frequencies()
@@ -72,21 +129,20 @@ def calibrate_shaper(datas, max_smoothing, scv, max_freq):
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=print_with_c_locale, logger=ConsoleOutput.print,
) )
except TypeError: except TypeError:
print_with_c_locale( 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 Shake&Tune features!'
) )
print_with_c_locale( ConsoleOutput.print(
'Shake&Tune now runs in compatibility mode: be aware that the results may be slightly off, since the real damping ratio cannot be used to create the filter recommendations' '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'
) )
compat = True compat = True
shaper, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, print_with_c_locale) shaper, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, ConsoleOutput.print)
print_with_c_locale( ConsoleOutput.print(
'\n-> Recommended shaper is %s @ %.1f Hz (when using a square corner velocity of %.1f and a damping ratio of %.3f)' 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})'
% (shaper.name.upper(), shaper.freq, scv, zeta)
) )
return shaper.name, all_shapers, calibration_data, fr, zeta, compat return shaper.name, all_shapers, calibration_data, fr, zeta, compat
@@ -98,8 +154,17 @@ def calibrate_shaper(datas, max_smoothing, scv, max_freq):
def plot_freq_response( def plot_freq_response(
ax, calibration_data, shapers, performance_shaper, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq ax: plt.Axes,
): calibration_data,
shapers,
klipper_shaper_choice: str,
peaks: np.ndarray,
peaks_freqs: np.ndarray,
peaks_threshold: List[float],
fr: float,
zeta: float,
max_freq: float,
) -> None:
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
@@ -128,80 +193,74 @@ def plot_freq_response(
ax2 = ax.twinx() ax2 = ax.twinx()
ax2.yaxis.set_visible(False) ax2.yaxis.set_visible(False)
lowvib_shaper_vibrs = float('inf')
lowvib_shaper = None
lowvib_shaper_freq = None
lowvib_shaper_accel = 0
# Draw the shappers curves and add their specific parameters in the legend # Draw the shappers curves and add their specific parameters in the legend
# This adds also a way to find the best shaper with a low level of vibrations (with a resonable level of smoothing) perf_shaper_choice = None
perf_shaper_vals = None
perf_shaper_freq = None
perf_shaper_accel = 0
for shaper in shapers: for shaper in shapers:
shaper_max_accel = round(shaper.max_accel / 100.0) * 100.0 shaper_max_accel = round(shaper.max_accel / 100.0) * 100.0
label = '%s (%.1f Hz, vibr=%.1f%%, sm~=%.2f, accel<=%.f)' % ( label = f'{shaper.name.upper()} ({shaper.freq:.1f} Hz, vibr={shaper.vibrs * 100.0:.1f}%, sm~={shaper.smoothing:.2f}, accel<={shaper_max_accel:.0f})'
shaper.name.upper(),
shaper.freq,
shaper.vibrs * 100.0,
shaper.smoothing,
shaper_max_accel,
)
ax2.plot(freqs, shaper.vals, label=label, linestyle='dotted') ax2.plot(freqs, shaper.vals, label=label, linestyle='dotted')
# Get the performance shaper # Get the Klipper recommended shaper (usually it's a good low vibration compromise)
if shaper.name == performance_shaper: if shaper.name == klipper_shaper_choice:
performance_shaper_freq = shaper.freq klipper_shaper_freq = shaper.freq
performance_shaper_vibr = shaper.vibrs * 100.0 klipper_shaper_vals = shaper.vals
performance_shaper_vals = shaper.vals klipper_shaper_accel = shaper_max_accel
# Get the low vibration shaper # Find the shaper with the highest accel but with vibrs under MAX_VIBRATIONS as it's
if ( # a good performance compromise when injecting the SCV and damping ratio in the computation
shaper.vibrs * 100 < lowvib_shaper_vibrs if perf_shaper_accel < shaper_max_accel and shaper.vibrs * 100 < MAX_VIBRATIONS:
or (shaper.vibrs * 100 == lowvib_shaper_vibrs and shaper_max_accel > lowvib_shaper_accel) perf_shaper_choice = shaper.name
) and shaper.smoothing < MAX_SMOOTHING: perf_shaper_accel = shaper_max_accel
lowvib_shaper_accel = shaper_max_accel perf_shaper_freq = shaper.freq
lowvib_shaper = shaper.name perf_shaper_vals = shaper.vals
lowvib_shaper_freq = shaper.freq
lowvib_shaper_vibrs = shaper.vibrs * 100
lowvib_shaper_vals = shaper.vals
# User recommendations are added to the legend: one is Klipper's original suggestion that is usually good for performances # Recommendations are added to the legend: one is Klipper's original suggestion that is usually good for low vibrations
# and the other one is the custom "low vibration" recommendation that looks for a suitable shaper that doesn't have excessive # and the other one is the custom "performance" recommendation that looks for a suitable shaper that doesn't have excessive
# smoothing and that have a lower vibration level. If both recommendation are the same shaper, or if no suitable "low # vibrations level but have higher accelerations. If both recommendations are the same shaper, or if no suitable "performance"
# vibration" shaper is found, then only a single line as the "best shaper" recommendation is added to the legend # shaper is found, then only a single line as the "best shaper" recommendation is added to the legend
if ( if (
lowvib_shaper is not None perf_shaper_choice is not None
and lowvib_shaper != performance_shaper and perf_shaper_choice != klipper_shaper_choice
and lowvib_shaper_vibrs <= performance_shaper_vibr and perf_shaper_accel >= klipper_shaper_accel
): ):
ax2.plot( ax2.plot(
[], [],
[], [],
' ', ' ',
label='Recommended performance shaper: %s @ %.1f Hz' label=f'Recommended performance shaper: {perf_shaper_choice.upper()} @ {perf_shaper_freq:.1f} Hz',
% (performance_shaper.upper(), performance_shaper_freq),
) )
ax.plot( ax.plot(
freqs, psd * performance_shaper_vals, label='With %s applied' % (performance_shaper.upper()), color='cyan' freqs,
psd * perf_shaper_vals,
label=f'With {perf_shaper_choice.upper()} applied',
color='cyan',
) )
ax2.plot( ax2.plot(
[], [],
[], [],
' ', ' ',
label='Recommended low vibrations shaper: %s @ %.1f Hz' % (lowvib_shaper.upper(), lowvib_shaper_freq), label=f'Recommended low vibrations shaper: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz',
) )
ax.plot(freqs, psd * lowvib_shaper_vals, label='With %s applied' % (lowvib_shaper.upper()), color='lime') ax.plot(freqs, psd * klipper_shaper_vals, label=f'With {klipper_shaper_choice.upper()} applied', color='lime')
else: else:
ax2.plot( ax2.plot(
[], [],
[], [],
' ', ' ',
label='Recommended best shaper: %s @ %.1f Hz' % (performance_shaper.upper(), performance_shaper_freq), label=f'Recommended performance shaper: {klipper_shaper_choice.upper()} @ {klipper_shaper_freq:.1f} Hz',
) )
ax.plot( ax.plot(
freqs, psd * performance_shaper_vals, label='With %s applied' % (performance_shaper.upper()), color='cyan' freqs,
psd * klipper_shaper_vals,
label=f'With {klipper_shaper_choice.upper()} applied',
color='cyan',
) )
# And the estimated damping ratio is finally added at the end of the legend # And the estimated damping ratio is finally added at the end of the legend
ax2.plot([], [], ' ', label='Estimated damping ratio (ζ): %.3f' % (zeta)) 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")
@@ -230,7 +289,7 @@ def plot_freq_response(
# Add the main resonant frequency and damping ratio of the axis to the graph title # Add the main resonant frequency and damping ratio of the axis to the graph title
ax.set_title( ax.set_title(
'Axis Frequency Profile (ω0=%.1fHz, ζ=%.3f)' % (fr, zeta), f'Axis Frequency Profile (ω0={fr:.1f}Hz, ζ={zeta:.3f})',
fontsize=14, fontsize=14,
color=KLIPPAIN_COLORS['dark_orange'], color=KLIPPAIN_COLORS['dark_orange'],
weight='bold', weight='bold',
@@ -243,7 +302,9 @@ def plot_freq_response(
# 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
# resonnance test. This can highlight hidden spots from the standard PSD graph from other harmonics # resonnance test. This can highlight hidden spots from the standard PSD graph from other harmonics
def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq): def plot_spectrogram(
ax: plt.Axes, t: np.ndarray, bins: np.ndarray, pdata: np.ndarray, peaks: np.ndarray, max_freq: float
) -> None:
ax.set_title('Time-Frequency Spectrogram', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Time-Frequency Spectrogram', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
# We need to normalize the data to get a proper signal on the spectrogram # We need to normalize the data to get a proper signal on the spectrogram
@@ -294,18 +355,27 @@ def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq):
###################################################################### ######################################################################
def shaper_calibration(lognames, klipperdir='~/klipper', max_smoothing=None, scv=5.0, max_freq=200.0, st_version=None): def shaper_calibration(
set_locale() lognames: List[str],
klipperdir: str = '~/klipper',
max_smoothing: Optional[float] = None,
scv: float = 5.0,
max_freq: float = 200.0,
accel_per_hz: Optional[float] = None,
st_version: str = 'unknown',
) -> plt.Figure:
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
# Parse data # Parse data from the log files while ignoring CSV in the wrong format
datas = [parse_log(fn) for fn in lognames] datas = [data for data in (parse_log(fn) for fn in lognames) if data is not None]
if len(datas) == 0:
raise ValueError('No valid data found in the provided CSV files!')
if len(datas) > 1: if len(datas) > 1:
print_with_c_locale('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
performance_shaper, shapers, calibration_data, fr, zeta, compat = calibrate_shaper( klipper_shaper_choice, shapers, calibration_data, fr, zeta, compat = calibrate_shaper(
datas[0], max_smoothing, scv, max_freq datas[0], max_smoothing, scv, max_freq
) )
pdata, bins, t = compute_spectrogram(datas[0]) pdata, bins, t = compute_spectrogram(datas[0])
@@ -329,9 +399,8 @@ def shaper_calibration(lognames, klipperdir='~/klipper', max_smoothing=None, scv
# Print the peaks info in the console # Print the peaks info in the console
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])
print_with_c_locale( ConsoleOutput.print(
'\nPeaks detected on the graph: %d @ %s Hz (%d above effect threshold)' f"\nPeaks detected on the graph: {num_peaks} @ {', '.join(map(str, peak_freqs_formated))} Hz ({num_peaks_above_effect_threshold} above effect threshold)"
% (num_peaks, ', '.join(map(str, peak_freqs_formated)), num_peaks_above_effect_threshold)
) )
# Create graph layout # Create graph layout
@@ -360,23 +429,27 @@ def shaper_calibration(lognames, klipperdir='~/klipper', max_smoothing=None, scv
dt = datetime.strptime(f'{filename_parts[1]} {filename_parts[2]}', '%Y%m%d %H%M%S') dt = datetime.strptime(f'{filename_parts[1]} {filename_parts[2]}', '%Y%m%d %H%M%S')
title_line2 = dt.strftime('%x %X') + ' -- ' + filename_parts[3].upper().split('.')[0] + ' axis' title_line2 = dt.strftime('%x %X') + ' -- ' + filename_parts[3].upper().split('.')[0] + ' axis'
if compat: if compat:
title_line3 = '| Compatibility mode with older Klipper,' title_line3 = '| Older Klipper version detected, damping ratio'
title_line4 = '| and no custom S&T parameters are used!' title_line4 = '| and SCV are not used for filter recommendations!'
title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else ''
else: else:
title_line3 = '| Square corner velocity: ' + str(scv) + 'mm/s' title_line3 = f'| Square corner velocity: {scv} mm/s'
title_line4 = '| Max allowed smoothing: ' + str(max_smoothing) title_line4 = f'| Max allowed smoothing: {max_smoothing}'
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:
print_with_c_locale('Warning: CSV filename look to be different than expected (%s)' % (lognames[0])) ConsoleOutput.print(f'Warning: CSV filename look to be different than expected ({lognames[0]})')
title_line2 = lognames[0].split('/')[-1] title_line2 = lognames[0].split('/')[-1]
title_line3 = '' title_line3 = ''
title_line4 = '' title_line4 = ''
title_line5 = ''
fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.58, 0.960, title_line3, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.58, 0.963, title_line3, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.58, 0.946, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.58, 0.948, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.58, 0.933, title_line5, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'])
# Plot the graphs # Plot the graphs
plot_freq_response( plot_freq_response(
ax1, calibration_data, shapers, performance_shaper, 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)
@@ -402,6 +475,7 @@ def main():
opts.add_option( opts.add_option(
'--scv', '--square_corner_velocity', type='float', dest='scv', default=5.0, help='square corner velocity' '--scv', '--square_corner_velocity', type='float', dest='scv', default=5.0, help='square corner velocity'
) )
opts.add_option('--accel_per_hz', type='float', default=None, help='accel_per_hz used during the measurement')
opts.add_option( opts.add_option(
'-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory' '-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory'
) )
@@ -413,7 +487,9 @@ def main():
if options.max_smoothing is not None and options.max_smoothing < 0.05: if options.max_smoothing is not None and options.max_smoothing < 0.05:
opts.error('Too small max_smoothing specified (must be at least 0.05)') opts.error('Too small max_smoothing specified (must be at least 0.05)')
fig = shaper_calibration(args, options.klipperdir, options.max_smoothing, options.scv, options.max_freq) fig = shaper_calibration(
args, options.klipperdir, options.max_smoothing, options.scv, options.max_freq, options.accel_per_hz, 'unknown'
)
fig.savefig(options.output, dpi=150) fig.savefig(options.output, dpi=150)

View File

@@ -0,0 +1,227 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: static_graph_creator.py
# Description: Implements a static frequency profile measurement script for Shake&Tune to diagnose mechanical
# issues, including computation and graphing functions for 3D printer vibration analysis.
import optparse
import os
from datetime import datetime
from typing import List, Optional
import matplotlib
import matplotlib.font_manager
import matplotlib.pyplot as plt
import matplotlib.ticker
import numpy as np
matplotlib.use('Agg')
from ..helpers.common_func import compute_spectrogram, parse_log
from ..helpers.console_output import ConsoleOutput
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_EFFECT_THRESHOLD = 0.12
SPECTROGRAM_LOW_PERCENTILE_FILTER = 5
MAX_VIBRATIONS = 5.0
KLIPPAIN_COLORS = {
'purple': '#70088C',
'orange': '#FF8D32',
'dark_purple': '#150140',
'dark_orange': '#F24130',
'red_pink': '#F2055C',
}
class StaticGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'static frequency')
self._freq: Optional[float] = None
self._duration: Optional[float] = None
self._accel_per_hz: Optional[float] = None
def configure(self, freq: float, duration: float, accel_per_hz: Optional[float] = None) -> None:
self._freq = freq
self._duration = duration
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
if not self._freq or not self._duration or not self._accel_per_hz:
raise ValueError('freq, duration and accel_per_hz must be set to create the static frequency graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-staticfreq_*.csv',
min_files_required=1,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = static_frequency_tool(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
freq=self._freq,
duration=self._duration,
max_freq=200.0,
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1])
def clean_old_files(self, keep_results: int = 3) -> None:
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
for old_file in files[keep_results:]:
csv_file = old_file.with_suffix('.csv')
csv_file.unlink(missing_ok=True)
old_file.unlink()
######################################################################
# Graphing
######################################################################
def plot_spectrogram(ax: plt.Axes, t: np.ndarray, bins: np.ndarray, pdata: np.ndarray, max_freq: float) -> None:
ax.set_title('Time-Frequency Spectrogram', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
vmin_value = np.percentile(pdata, SPECTROGRAM_LOW_PERCENTILE_FILTER)
cm = 'inferno'
norm = matplotlib.colors.LogNorm(vmin=vmin_value)
ax.imshow(
pdata.T,
norm=norm,
cmap=cm,
aspect='auto',
extent=[t[0], t[-1], bins[0], bins[-1]],
origin='lower',
interpolation='antialiased',
)
ax.set_xlim([0.0, max_freq])
ax.set_ylabel('Time (s)')
ax.set_xlabel('Frequency (Hz)')
return
def plot_energy_accumulation(ax: plt.Axes, t: np.ndarray, bins: np.ndarray, pdata: np.ndarray) -> None:
# Integrate the energy over the frequency bins for each time step and plot this vertically
ax.plot(np.trapz(pdata, t, axis=0), bins, color=KLIPPAIN_COLORS['orange'])
ax.set_title('Vibrations', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Cumulative Energy')
ax.set_ylabel('Time (s)')
ax.set_ylim([bins[0], bins[-1]])
ax.xaxis.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.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
# ax.legend()
######################################################################
# Startup and main routines
######################################################################
def static_frequency_tool(
lognames: List[str],
klipperdir: str = '~/klipper',
freq: Optional[float] = None,
duration: Optional[float] = None,
max_freq: float = 500.0,
accel_per_hz: Optional[float] = None,
st_version: str = 'unknown',
) -> plt.Figure:
if freq is None or duration is None:
raise ValueError('Error: missing frequency or duration parameters!')
datas = [data for data in (parse_log(fn) for fn in lognames) if data is not None]
if len(datas) == 0:
raise ValueError('No valid data found in the provided CSV files!')
if len(datas) > 1:
ConsoleOutput.print('Warning: incorrect number of .csv files detected. Only the first one will be used!')
pdata, bins, t = compute_spectrogram(datas[0])
del datas
fig, ((ax1, ax3)) = plt.subplots(
1,
2,
gridspec_kw={
'width_ratios': [5, 3],
'bottom': 0.080,
'top': 0.840,
'left': 0.050,
'right': 0.985,
'hspace': 0.166,
'wspace': 0.138,
},
)
fig.set_size_inches(15, 7)
title_line1 = 'STATIC FREQUENCY HELPER TOOL'
fig.text(
0.060, 0.947, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold'
)
try:
filename_parts = (lognames[0].split('/')[-1]).split('_')
dt = datetime.strptime(f'{filename_parts[1]} {filename_parts[2]}', '%Y%m%d %H%M%S')
title_line2 = dt.strftime('%x %X') + ' -- ' + filename_parts[3].upper().split('.')[0] + ' axis'
title_line3 = f'| Maintained frequency: {freq}Hz for {duration}s'
title_line4 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else ''
except Exception:
ConsoleOutput.print(f'Warning: CSV filename look to be different than expected ({lognames[0]})')
title_line2 = lognames[0].split('/')[-1]
title_line3 = ''
title_line4 = ''
fig.text(0.060, 0.939, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.55, 0.985, title_line3, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.55, 0.950, title_line4, ha='left', va='top', fontsize=11, color=KLIPPAIN_COLORS['dark_purple'])
plot_spectrogram(ax1, t, bins, pdata, max_freq)
plot_energy_accumulation(ax3, t, bins, pdata)
ax_logo = fig.add_axes([0.001, 0.894, 0.105, 0.105], anchor='NW')
ax_logo.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png')))
ax_logo.axis('off')
if st_version != 'unknown':
fig.text(0.995, 0.980, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple'])
return fig
def main():
usage = '%prog [options] <logs>'
opts = optparse.OptionParser(usage)
opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
opts.add_option('-f', '--freq', type='float', default=None, help='frequency maintained during the measurement')
opts.add_option('-d', '--duration', type='float', default=None, help='duration of the measurement')
opts.add_option('--max_freq', type='float', default=500.0, help='maximum frequency to graph')
opts.add_option('--accel_per_hz', type='float', default=None, help='accel_per_hz used during the measurement')
opts.add_option(
'-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory'
)
options, args = opts.parse_args()
if len(args) < 1:
opts.error('Incorrect number of arguments')
if options.output is None:
opts.error('You must specify an output file.png to use the script (option -o)')
fig = static_frequency_tool(
args, options.klipperdir, options.freq, options.duration, options.max_freq, options.accel_per_hz, 'unknown'
)
fig.savefig(options.output, dpi=150)
if __name__ == '__main__':
main()

View File

@@ -1,16 +1,22 @@
#!/usr/bin/env python3 # Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: vibrations_graph_creator.py
# Description: Implements the directional vibrations plotting script for Shake&Tune,
# including computation and graphing functions for analyzing 3D printer vibration profiles.
##################################################
#### DIRECTIONAL VIBRATIONS PLOTTING SCRIPT ######
##################################################
# Written by Frix_x#0161 #
import math import math
import optparse import optparse
import os import os
import re import re
import tarfile
from collections import defaultdict from collections import defaultdict
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
import matplotlib import matplotlib
import matplotlib.font_manager import matplotlib.font_manager
@@ -28,8 +34,12 @@ from ..helpers.common_func import (
parse_log, parse_log,
setup_klipper_import, setup_klipper_import,
) )
from ..helpers.locale_utils import print_with_c_locale, set_locale from ..helpers.console_output import ConsoleOutput
from ..helpers.motors_config_parser import Motor, MotorsConfigParser
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
DEFAULT_LOW_FREQ_MAX = 30
PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04 PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04
CURVE_SIMILARITY_SIGMOID_K = 0.5 CURVE_SIMILARITY_SIGMOID_K = 0.5
@@ -46,67 +56,126 @@ KLIPPAIN_COLORS = {
} }
class VibrationsGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'vibrations profile')
self._kinematics: Optional[str] = None
self._accel: Optional[float] = None
self._motors: Optional[List[MotorsConfigParser]] = None
def configure(self, kinematics: str, accel: float, motor_config_parser: MotorsConfigParser) -> None:
self._kinematics = kinematics
self._accel = accel
self._motors: List[Motor] = motor_config_parser.get_motors()
def _archive_files(self, lognames: List[Path]) -> None:
tar_path = self._folder / f'{self._type}_{self._graph_date}.tar.gz'
with tarfile.open(tar_path, 'w:gz') as tar:
for csv_file in lognames:
tar.add(csv_file, arcname=csv_file.name, recursive=False)
csv_file.unlink()
def create_graph(self) -> None:
if not self._accel or not self._kinematics:
raise ValueError('accel and kinematics must be set to create the vibrations profile graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-vib_*.csv',
min_files_required=None,
custom_name_func=lambda f: re.search(r'shaketune-vib_(.*?)_\d{8}_\d{6}', f.name).group(1),
)
fig = vibrations_profile(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
kinematics=self._kinematics,
accel=self._accel,
st_version=self._version,
motors=self._motors,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
for old_file in files[keep_results:]:
old_file.unlink()
tar_file = old_file.with_suffix('.tar.gz')
tar_file.unlink(missing_ok=True)
###################################################################### ######################################################################
# Computation # Computation
###################################################################### ######################################################################
# Call to the official Klipper input shaper object to do the PSD computation # Call to the official Klipper input shaper object to do the PSD computation
def calc_freq_response(data): def calc_freq_response(data) -> Tuple[np.ndarray, np.ndarray]:
helper = shaper_calibrate.ShaperCalibrate(printer=None) helper = shaper_calibrate.ShaperCalibrate(printer=None)
return helper.process_accelerometer_data(data) return helper.process_accelerometer_data(data)
# Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements for the machine kinematics def find_motor_characteristics(motor: str, freqs: np.ndarray, psd: np.ndarray) -> Tuple[float, float, int]:
# main angles and then create a global motor profile as a weighted average (from their own vibrations) of all calculated profiles motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(psd, freqs, DEFAULT_LOW_FREQ_MAX)
def compute_motor_profiles(freqs, psds, all_angles_energy, measured_angles=None, energy_amplification_factor=2):
if measured_angles is None:
measured_angles = [0, 90]
if lowfreq_max:
ConsoleOutput.print(
(
f'[WARNING] {motor} motor has a lot of low frequency vibrations. This is '
'probably due to the test being performed at too high an acceleration!\n'
'Try lowering ACCEL and/or increasing SIZE before restarting the macro '
'to ensure that only constant speeds are being recorded and that the '
'dynamic behavior of the machine is not affecting the measurements.'
)
)
if motor_zeta is not None:
ConsoleOutput.print(
(
f'Motor {motor} have a main resonant frequency at {motor_fr:.1f}Hz '
f'with an estimated damping ratio of {motor_zeta:.3f}'
)
)
else:
ConsoleOutput.print(
(
f'Motor {motor} have a main resonant frequency at {motor_fr:.1f}Hz '
'but it was impossible to estimate its damping ratio.'
)
)
return motor_fr, motor_zeta, motor_res_idx
# Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements
# for the machine kinematics main angles
def compute_motor_profiles(freqs: np.ndarray, psds: dict, measured_angles: Optional[List[int]] = (0, 90)) -> dict:
motor_profiles = {} motor_profiles = {}
weighted_sum_profiles = np.zeros_like(freqs)
total_weight = 0
conv_filter = np.ones(20) / 20 conv_filter = np.ones(20) / 20
# Creating the PSD motor profiles for each angles # Creating the PSD motor profiles for each angle by summing the PSDs for each speed
for angle in measured_angles: for angle in measured_angles:
# Calculate the sum of PSDs for the current angle and then convolve
sum_curve = np.sum(np.array([psds[angle][speed] for speed in psds[angle]]), axis=0) sum_curve = np.sum(np.array([psds[angle][speed] for speed in psds[angle]]), axis=0)
motor_profiles[angle] = np.convolve(sum_curve / len(psds[angle]), conv_filter, mode='same') motor_profiles[angle] = np.convolve(sum_curve / len(psds[angle]), conv_filter, mode='same')
# Calculate weights return motor_profiles
angle_energy = (
all_angles_energy[angle] ** energy_amplification_factor
) # First weighting factor is based on the total vibrations of the machine at the specified angle
curve_area = (
np.trapz(motor_profiles[angle], freqs) ** energy_amplification_factor
) # Additional weighting factor is based on the area under the current motor profile at this specified angle
total_angle_weight = angle_energy * curve_area
# Update weighted sum profiles to get the global motor profile
weighted_sum_profiles += motor_profiles[angle] * total_angle_weight
total_weight += total_angle_weight
# Creating a global average motor profile that is the weighted average of all the PSD motor profiles
global_motor_profile = weighted_sum_profiles / total_weight if total_weight != 0 else weighted_sum_profiles
return motor_profiles, global_motor_profile
# Since it was discovered that there is no non-linear mixing in the stepper "steps" vibrations, instead of measuring # Since it was discovered that there is no non-linear mixing in the stepper "steps" vibrations, instead of measuring
# the effects of each speeds at each angles, this function simplify it by using only the main motors axes (X/Y for Cartesian # the effects of each speeds at each angles, this function simplify it by using only the main motors axes (X/Y for Cartesian
# printers and A/B for CoreXY) measurements and project each points on the [0, 360] degrees range using trigonometry # printers and A/B for CoreXY) measurements and project each points on the [0, 360] degrees range using trigonometry
# to "sum" the vibration impact of each axis at every points of the generated spectrogram. The result is very similar at the end. # to "sum" the vibration impact of each axis at every points of the generated spectrogram. The result is very similar at the end.
def compute_dir_speed_spectrogram(measured_speeds, data, kinematics='cartesian', measured_angles=None): def compute_dir_speed_spectrogram(
if measured_angles is None: measured_speeds: List[float],
measured_angles = [0, 90] data: dict,
kinematics: str = 'cartesian',
measured_angles: Optional[List[int]] = (0, 90),
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
# We want to project the motor vibrations measured on their own axes on the [0, 360] range # We want to project the motor vibrations measured on their own axes on the [0, 360] range
spectrum_angles = np.linspace(0, 360, 720) # One point every 0.5 degrees spectrum_angles = np.linspace(0, 360, 720) # One point every 0.5 degrees
spectrum_speeds = np.linspace(min(measured_speeds), max(measured_speeds), len(measured_speeds) * 6) spectrum_speeds = np.linspace(min(measured_speeds), max(measured_speeds), len(measured_speeds) * 6)
spectrum_vibrations = np.zeros((len(spectrum_angles), len(spectrum_speeds))) spectrum_vibrations = np.zeros((len(spectrum_angles), len(spectrum_speeds)))
def get_interpolated_vibrations(data, speed, speeds): def get_interpolated_vibrations(data: dict, speed: float, speeds: List[float]) -> float:
idx = np.clip(np.searchsorted(speeds, speed, side='left'), 1, len(speeds) - 1) idx = np.clip(np.searchsorted(speeds, speed, side='left'), 1, len(speeds) - 1)
lower_speed = speeds[idx - 1] lower_speed = speeds[idx - 1]
upper_speed = speeds[idx] upper_speed = speeds[idx]
@@ -125,7 +194,7 @@ def compute_dir_speed_spectrogram(measured_speeds, data, kinematics='cartesian',
# Compute the spectrum vibrations for each angle and speed combination # Compute the spectrum vibrations for each angle and speed combination
for target_angle_idx, (cos_val, sin_val) in enumerate(zip(cos_vals, sin_vals)): for target_angle_idx, (cos_val, sin_val) in enumerate(zip(cos_vals, sin_vals)):
for target_speed_idx, target_speed in enumerate(spectrum_speeds): for target_speed_idx, target_speed in enumerate(spectrum_speeds):
if kinematics == 'cartesian': if kinematics == 'cartesian' or kinematics == 'corexz':
speed_1 = np.abs(target_speed * cos_val) speed_1 = np.abs(target_speed * cos_val)
speed_2 = np.abs(target_speed * sin_val) speed_2 = np.abs(target_speed * sin_val)
elif kinematics == 'corexy': elif kinematics == 'corexy':
@@ -139,7 +208,7 @@ def compute_dir_speed_spectrogram(measured_speeds, data, kinematics='cartesian',
return spectrum_angles, spectrum_speeds, spectrum_vibrations return spectrum_angles, spectrum_speeds, spectrum_vibrations
def compute_angle_powers(spectrogram_data): def compute_angle_powers(spectrogram_data: np.ndarray) -> np.ndarray:
angles_powers = np.trapz(spectrogram_data, axis=1) angles_powers = np.trapz(spectrogram_data, axis=1)
# Since we want to plot it on a continuous polar plot later on, we need to append parts of # Since we want to plot it on a continuous polar plot later on, we need to append parts of
@@ -151,7 +220,7 @@ def compute_angle_powers(spectrogram_data):
return convolved_extended[9:-9] return convolved_extended[9:-9]
def compute_speed_powers(spectrogram_data, smoothing_window=15): def compute_speed_powers(spectrogram_data: np.ndarray, smoothing_window: int = 15) -> np.ndarray:
min_values = np.amin(spectrogram_data, axis=0) min_values = np.amin(spectrogram_data, axis=0)
max_values = np.amax(spectrogram_data, axis=0) max_values = np.amax(spectrogram_data, axis=0)
var_values = np.var(spectrogram_data, axis=0) var_values = np.var(spectrogram_data, axis=0)
@@ -167,7 +236,7 @@ def compute_speed_powers(spectrogram_data, smoothing_window=15):
conv_filter = np.ones(smoothing_window) / smoothing_window conv_filter = np.ones(smoothing_window) / smoothing_window
window = int(smoothing_window / 2) window = int(smoothing_window / 2)
def pad_and_smooth(data): def pad_and_smooth(data: np.ndarray) -> np.ndarray:
data_padded = np.pad(data, (window,), mode='edge') data_padded = np.pad(data, (window,), mode='edge')
smoothed_data = np.convolve(data_padded, conv_filter, mode='valid') smoothed_data = np.convolve(data_padded, conv_filter, mode='valid')
return smoothed_data return smoothed_data
@@ -182,7 +251,9 @@ def compute_speed_powers(spectrogram_data, smoothing_window=15):
# Function that filter and split the good_speed ranges. The goal is to remove some zones around # Function that filter and split the good_speed ranges. The goal is to remove some zones around
# additional detected small peaks in order to suppress them if there is a peak, even if it's low, # additional detected small peaks in order to suppress them if there is a peak, even if it's low,
# that's probably due to a crossing in the motor resonance pattern that still need to be removed # that's probably due to a crossing in the motor resonance pattern that still need to be removed
def filter_and_split_ranges(all_speeds, good_speeds, peak_speed_indices, deletion_range): def filter_and_split_ranges(
all_speeds: np.ndarray, good_speeds: List[Tuple[int, int, float]], peak_speed_indices: dict, deletion_range: int
) -> List[Tuple[int, int, float]]:
# Process each range to filter out and split based on peak indices # Process each range to filter out and split based on peak indices
filtered_good_speeds = [] filtered_good_speeds = []
for start, end, energy in good_speeds: for start, end, energy in good_speeds:
@@ -225,10 +296,9 @@ def filter_and_split_ranges(all_speeds, good_speeds, peak_speed_indices, deletio
# This function allow the computation of a symmetry score that reflect the spectrogram apparent symmetry between # This function allow the computation of a symmetry score that reflect the spectrogram apparent symmetry between
# measured axes on both the shape of the signal and the energy level consistency across both side of the signal # measured axes on both the shape of the signal and the energy level consistency across both side of the signal
def compute_symmetry_analysis(all_angles, spectrogram_data, measured_angles=None): def compute_symmetry_analysis(
if measured_angles is None: all_angles: np.ndarray, spectrogram_data: np.ndarray, measured_angles: Optional[List[int]] = (0, 90)
measured_angles = [0, 90] ) -> float:
total_spectrogram_angles = len(all_angles) total_spectrogram_angles = len(all_angles)
half_spectrogram_angles = total_spectrogram_angles // 2 half_spectrogram_angles = total_spectrogram_angles // 2
@@ -256,7 +326,13 @@ def compute_symmetry_analysis(all_angles, spectrogram_data, measured_angles=None
###################################################################### ######################################################################
def plot_angle_profile_polar(ax, angles, angles_powers, low_energy_zones, symmetry_factor): def plot_angle_profile_polar(
ax: plt.Axes,
angles: np.ndarray,
angles_powers: np.ndarray,
low_energy_zones: List[Tuple[int, int, float]],
symmetry_factor: float,
) -> None:
angles_radians = np.deg2rad(angles) angles_radians = np.deg2rad(angles)
ax.set_title('Polar angle energy profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Polar angle energy profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
@@ -315,16 +391,16 @@ def plot_angle_profile_polar(ax, angles, angles_powers, low_energy_zones, symmet
def plot_global_speed_profile( def plot_global_speed_profile(
ax, ax: plt.Axes,
all_speeds, all_speeds: np.ndarray,
sp_min_energy, sp_min_energy: np.ndarray,
sp_max_energy, sp_max_energy: np.ndarray,
sp_variance_energy, sp_variance_energy: np.ndarray,
vibration_metric, vibration_metric: np.ndarray,
num_peaks, num_peaks: int,
peaks, peaks: np.ndarray,
low_energy_zones, low_energy_zones: List[Tuple[int, int, float]],
): ) -> None:
ax.set_title('Global speed energy profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Global speed energy profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)') ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Energy') ax.set_ylabel('Energy')
@@ -389,7 +465,9 @@ def plot_global_speed_profile(
return return
def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics='cartesian'): def plot_angular_speed_profiles(
ax: plt.Axes, speeds: np.ndarray, angles: np.ndarray, spectrogram_data: np.ndarray, kinematics: str = 'cartesian'
) -> None:
ax.set_title('Angular speed energy profiles', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Angular speed energy profiles', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)') ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Energy') ax.set_ylabel('Energy')
@@ -408,7 +486,7 @@ def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics
ax.plot(speeds, spectrogram_data[idx], label=label, color=KLIPPAIN_COLORS[color], zorder=zorder) ax.plot(speeds, spectrogram_data[idx], label=label, color=KLIPPAIN_COLORS[color], zorder=zorder)
ax.set_xlim([speeds.min(), speeds.max()]) ax.set_xlim([speeds.min(), speeds.max()])
max_value = max(spectrogram_data[angle].max() for angle in [0, 45, 90, 135]) max_value = max(spectrogram_data[angle].max() for angle in {0, 45, 90, 135})
ax.set_ylim([0, max_value * 1.1]) ax.set_ylim([0, max_value * 1.1])
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
@@ -423,71 +501,41 @@ def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics
return return
def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_profile, max_freq): def plot_motor_profiles(
ax.set_title('Motor frequency profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax: plt.Axes, freqs: np.ndarray, main_angles: List[int], motor_profiles: dict, max_freq: float
) -> None:
ax.set_title('Motors frequency profiles', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_ylabel('Energy') ax.set_ylabel('Energy')
ax.set_xlabel('Frequency (Hz)') ax.set_xlabel('Frequency (Hz)')
ax2 = ax.twinx() ax2 = ax.twinx()
ax2.yaxis.set_visible(False) ax2.yaxis.set_visible(False)
# Global weighted average motor profile
ax.plot(freqs, global_motor_profile, label='Combined', color=KLIPPAIN_COLORS['purple'], zorder=5)
max_value = global_motor_profile.max()
# Mapping of angles to axis names # Mapping of angles to axis names
angle_settings = {0: 'X', 90: 'Y', 45: 'A', 135: 'B'} angle_settings = {0: 'X', 90: 'Y', 45: 'A', 135: 'B'}
# And then plot the motor profiles at each measured angles # And then plot the motor profiles at each measured angles with their characteristics
max_value = 0
for angle in main_angles: for angle in main_angles:
profile_max = motor_profiles[angle].max() profile_max = motor_profiles[angle].max()
if profile_max > max_value: if profile_max > max_value:
max_value = profile_max max_value = profile_max
label = f'{angle_settings[angle]} ({angle} deg)' if angle in angle_settings else f'{angle} deg' label = f'{angle_settings[angle]} ({angle} deg)' if angle in angle_settings else f'{angle} deg'
ax.plot(freqs, motor_profiles[angle], linestyle='--', label=label, zorder=2) ax.plot(freqs, motor_profiles[angle], label=label, zorder=2)
motor_fr, motor_zeta, motor_res_idx = find_motor_characteristics(
angle_settings[angle], freqs, motor_profiles[angle]
)
ax2.plot([], [], ' ', label=f'{angle_settings[angle]} resonant frequency (ω0): {motor_fr:.1f}Hz')
if motor_zeta is not None:
ax2.plot([], [], ' ', label=f'{angle_settings[angle]} damping ratio (ζ): {motor_zeta:.3f}')
else:
ax2.plot([], [], ' ', label=f'{angle_settings[angle]} damping ratio (ζ): unknown')
ax.set_xlim([0, max_freq]) ax.set_xlim([0, max_freq])
ax.set_ylim([0, max_value * 1.1]) ax.set_ylim([0, max_value * 1.1])
ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0)) ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0))
# Then add the motor resonance peak to the graph and print some infos about it
motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(global_motor_profile, freqs, 30)
if lowfreq_max:
print_with_c_locale(
'[WARNING] There are a lot of low frequency vibrations that can alter the readings. This is probably due to the test being performed at too high an acceleration!'
)
print_with_c_locale(
'Try lowering the ACCEL value and/or increasing the SIZE value before restarting the macro to ensure that only constant speeds are being recorded and that the dynamic behavior of the machine is not affecting the measurements'
)
if motor_zeta is not None:
print_with_c_locale(
'Motors have a main resonant frequency at %.1fHz with an estimated damping ratio of %.3f'
% (motor_fr, motor_zeta)
)
else:
print_with_c_locale(
'Motors have a main resonant frequency at %.1fHz but it was impossible to estimate a damping ratio.'
% (motor_fr)
)
ax.plot(freqs[motor_res_idx], global_motor_profile[motor_res_idx], 'x', color='black', markersize=10)
ax.annotate(
'R',
(freqs[motor_res_idx], global_motor_profile[motor_res_idx]),
textcoords='offset points',
xytext=(15, 5),
ha='right',
fontsize=14,
color=KLIPPAIN_COLORS['red_pink'],
weight='bold',
)
ax2.plot([], [], ' ', label='Motor resonant frequency (ω0): %.1fHz' % (motor_fr))
if motor_zeta is not None:
ax2.plot([], [], ' ', label='Motor damping ratio (ζ): %.3f' % (motor_zeta))
else:
ax2.plot([], [], ' ', label='No damping ratio computed')
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.grid(which='major', color='grey') ax.grid(which='major', color='grey')
@@ -501,7 +549,9 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro
return return
def plot_vibration_spectrogram_polar(ax, angles, speeds, spectrogram_data): def plot_vibration_spectrogram_polar(
ax: plt.Axes, angles: np.ndarray, speeds: np.ndarray, spectrogram_data: np.ndarray
) -> None:
angles_radians = np.radians(angles) angles_radians = np.radians(angles)
# Assuming speeds defines the radial distance from the center, we need to create a meshgrid # Assuming speeds defines the radial distance from the center, we need to create a meshgrid
@@ -527,7 +577,9 @@ def plot_vibration_spectrogram_polar(ax, angles, speeds, spectrogram_data):
return return
def plot_vibration_spectrogram(ax, angles, speeds, spectrogram_data, peaks): def plot_vibration_spectrogram(
ax: plt.Axes, angles: np.ndarray, speeds: np.ndarray, spectrogram_data: np.ndarray, peaks: np.ndarray
) -> None:
ax.set_title('Vibrations heatmap', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Vibrations heatmap', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)') ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Angle (deg)') ax.set_ylabel('Angle (deg)')
@@ -560,27 +612,29 @@ def plot_vibration_spectrogram(ax, angles, speeds, spectrogram_data, peaks):
return return
def plot_motor_config_txt(fig, motors, differences): def plot_motor_config_txt(fig: plt.Figure, motors: List[MotorsConfigParser], differences: Optional[str]) -> None:
motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')] motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')]
distance = 0.12 distance = 0.15
if motors[0].get_property('autotune_enabled'): if motors[0].get_config('autotune_enabled'):
distance = 0.24 distance = 0.27
config_blocks = [ config_blocks = [
f"| {lbl}: {mot.get_property('motor').upper()} on {mot.get_property('tmc').upper()} @ {mot.get_property('voltage')}V {mot.get_property('run_current')}A" f"| {lbl}: {mot.get_config('motor').upper()} on {mot.get_config('tmc').upper()} @ {mot.get_config('voltage'):0.1f}V {mot.get_config('run_current'):0.2f}A - {mot.get_config('microsteps')}usteps"
for mot, lbl in motor_details for mot, lbl in motor_details
] ]
config_blocks.append('| TMC Autotune enabled') config_blocks.append(
f'| TMC Autotune enabled (PWM freq target: X={int(motors[0].get_config("pwm_freq_target")/1000)}kHz / Y={int(motors[1].get_config("pwm_freq_target")/1000)}kHz)'
)
else: else:
config_blocks = [ config_blocks = [
f"| {lbl}: {mot.get_property('tmc').upper()} @ {mot.get_property('run_current')}A" f"| {lbl}: {mot.get_config('tmc').upper()} @ {mot.get_config('run_current'):0.2f}A - {mot.get_config('microsteps')}usteps"
for mot, lbl in motor_details for mot, lbl in motor_details
] ]
config_blocks.append('| TMC Autotune not detected') config_blocks.append('| TMC Autotune not detected')
for idx, block in enumerate(config_blocks): for idx, block in enumerate(config_blocks):
fig.text( fig.text(
0.40, 0.990 - 0.015 * idx, block, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'] 0.41, 0.990 - 0.015 * idx, block, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']
) )
tmc_registers = motors[0].get_registers() tmc_registers = motors[0].get_registers()
@@ -589,7 +643,7 @@ def plot_motor_config_txt(fig, motors, differences):
settings_str = ' '.join(f'{k}={v}' for k, v in settings.items()) settings_str = ' '.join(f'{k}={v}' for k, v in settings.items())
tmc_block = f'| {register.upper()}: {settings_str}' tmc_block = f'| {register.upper()}: {settings_str}'
fig.text( fig.text(
0.40 + distance, 0.41 + distance,
0.990 - 0.015 * idx, 0.990 - 0.015 * idx,
tmc_block, tmc_block,
ha='left', ha='left',
@@ -601,7 +655,7 @@ def plot_motor_config_txt(fig, motors, differences):
if differences is not None: if differences is not None:
differences_text = f'| Y motor diff: {differences}' differences_text = f'| Y motor diff: {differences}'
fig.text( fig.text(
0.40 + distance, 0.41 + distance,
0.990 - 0.015 * (idx + 1), 0.990 - 0.015 * (idx + 1),
differences_text, differences_text,
ha='left', ha='left',
@@ -616,7 +670,7 @@ def plot_motor_config_txt(fig, motors, differences):
###################################################################### ######################################################################
def extract_angle_and_speed(logname): def extract_angle_and_speed(logname: str) -> Tuple[float, float]:
try: try:
match = re.search(r'an(\d+)_\d+sp(\d+)_\d+', os.path.basename(logname)) match = re.search(r'an(\d+)_\d+sp(\d+)_\d+', os.path.basename(logname))
if match: if match:
@@ -632,18 +686,23 @@ def extract_angle_and_speed(logname):
def vibrations_profile( def vibrations_profile(
lognames, klipperdir='~/klipper', kinematics='cartesian', accel=None, max_freq=1000.0, st_version=None, motors=None lognames: List[str],
): klipperdir: str = '~/klipper',
set_locale() kinematics: str = 'cartesian',
accel: Optional[float] = None,
max_freq: float = 1000.0,
st_version: Optional[str] = None,
motors: Optional[List[MotorsConfigParser]] = None,
) -> plt.Figure:
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
if kinematics == 'cartesian': if kinematics == 'cartesian' or kinematics == 'corexz':
main_angles = [0, 90] main_angles = (0, 90)
elif kinematics == 'corexy': elif kinematics == 'corexy':
main_angles = [45, 135] main_angles = (45, 135)
else: else:
raise ValueError('Only Cartesian and CoreXY kinematics are supported by this tool at the moment!') raise ValueError('Only Cartesian, CoreXY and CoreXZ kinematics are supported by this tool at the moment!')
psds = defaultdict(lambda: defaultdict(list)) psds = defaultdict(lambda: defaultdict(list))
psds_sum = defaultdict(lambda: defaultdict(list)) psds_sum = defaultdict(lambda: defaultdict(list))
@@ -651,6 +710,8 @@ def vibrations_profile(
for logname in lognames: for logname in lognames:
data = parse_log(logname) data = parse_log(logname)
if data is None:
continue # File is not in the expected format, skip it
angle, speed = extract_angle_and_speed(logname) angle, speed = extract_angle_and_speed(logname)
freq_response = calc_freq_response(data) freq_response = calc_freq_response(data)
first_freqs = freq_response.freq_bins first_freqs = freq_response.freq_bins
@@ -680,11 +741,11 @@ def vibrations_profile(
) )
all_angles_energy = compute_angle_powers(spectrogram_data) all_angles_energy = compute_angle_powers(spectrogram_data)
sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric = compute_speed_powers(spectrogram_data) sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric = compute_speed_powers(spectrogram_data)
motor_profiles, global_motor_profile = compute_motor_profiles(target_freqs, psds, all_angles_energy, main_angles) motor_profiles = compute_motor_profiles(target_freqs, psds, main_angles)
# symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy) # symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy)
symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles) symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles)
print_with_c_locale(f'Machine estimated vibration symmetry: {symmetry_factor:.1f}%') ConsoleOutput.print(f'Machine estimated vibration symmetry: {symmetry_factor:.1f}%')
# Analyze low variance ranges of vibration energy across all angles for each speed to identify clean speeds # Analyze low variance ranges of vibration energy across all angles for each speed to identify clean speeds
# and highlight them. Also find the peaks to identify speeds to avoid due to high resonances # and highlight them. Also find the peaks to identify speeds to avoid due to high resonances
@@ -697,9 +758,8 @@ def vibrations_profile(
10, 10,
) )
formated_peaks_speeds = ['{:.1f}'.format(pspeed) for pspeed in peaks_speeds] formated_peaks_speeds = ['{:.1f}'.format(pspeed) for pspeed in peaks_speeds]
print_with_c_locale( ConsoleOutput.print(
'Vibrations peaks detected: %d @ %s mm/s (avoid setting a speed near these values in your slicer print profile)' f"Vibrations peaks detected: {num_peaks} @ {', '.join(map(str, formated_peaks_speeds))} mm/s (avoid setting a speed near these values in your slicer print profile)"
% (num_peaks, ', '.join(map(str, formated_peaks_speeds)))
) )
good_speeds = identify_low_energy_zones(vibration_metric, SPEEDS_VALLEY_DETECTION_THRESHOLD) good_speeds = identify_low_energy_zones(vibration_metric, SPEEDS_VALLEY_DETECTION_THRESHOLD)
@@ -711,16 +771,16 @@ def vibrations_profile(
good_speeds = filter_and_split_ranges(all_speeds, good_speeds, peak_speed_indices, deletion_range) good_speeds = filter_and_split_ranges(all_speeds, good_speeds, peak_speed_indices, deletion_range)
# Add some logging about the good speeds found # Add some logging about the good speeds found
print_with_c_locale(f'Lowest vibrations speeds ({len(good_speeds)} ranges sorted from best to worse):') ConsoleOutput.print(f'Lowest vibrations speeds ({len(good_speeds)} ranges sorted from best to worse):')
for idx, (start, end, _) in enumerate(good_speeds): for idx, (start, end, _) in enumerate(good_speeds):
print_with_c_locale(f'{idx+1}: {all_speeds[start]:.1f} to {all_speeds[end]:.1f} mm/s') ConsoleOutput.print(f'{idx+1}: {all_speeds[start]:.1f} to {all_speeds[end]:.1f} mm/s')
# Angle low energy valleys identification (good angles ranges) and print them to the console # Angle low energy valleys identification (good angles ranges) and print them to the console
good_angles = identify_low_energy_zones(all_angles_energy, ANGLES_VALLEY_DETECTION_THRESHOLD) good_angles = identify_low_energy_zones(all_angles_energy, ANGLES_VALLEY_DETECTION_THRESHOLD)
if good_angles is not None: if good_angles is not None:
print_with_c_locale(f'Lowest vibrations angles ({len(good_angles)} ranges sorted from best to worse):') ConsoleOutput.print(f'Lowest vibrations angles ({len(good_angles)} ranges sorted from best to worse):')
for idx, (start, end, energy) in enumerate(good_angles): for idx, (start, end, energy) in enumerate(good_angles):
print_with_c_locale( ConsoleOutput.print(
f'{idx+1}: {all_angles[start]:.1f}° to {all_angles[end]:.1f}° (mean vibrations energy: {energy:.2f}% of max)' f'{idx+1}: {all_angles[start]:.1f}° to {all_angles[end]:.1f}° (mean vibrations energy: {energy:.2f}% of max)'
) )
@@ -761,7 +821,7 @@ def vibrations_profile(
if accel is not None: if accel is not None:
title_line2 += ' at ' + str(accel) + ' mm/s² -- ' + kinematics.upper() + ' kinematics' title_line2 += ' at ' + str(accel) + ' mm/s² -- ' + kinematics.upper() + ' kinematics'
except Exception: except Exception:
print_with_c_locale('Warning: CSV filenames appear to be different than expected (%s)' % (lognames[0])) ConsoleOutput.print(f'Warning: CSV filenames appear to be different than expected ({lognames[0]})')
title_line2 = lognames[0].split('/')[-1] title_line2 = lognames[0].split('/')[-1]
fig.text(0.060, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.060, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
@@ -770,7 +830,7 @@ def vibrations_profile(
differences = motors[0].compare_to(motors[1]) differences = motors[0].compare_to(motors[1])
plot_motor_config_txt(fig, motors, differences) plot_motor_config_txt(fig, motors, differences)
if differences is not None and kinematics == 'corexy': if differences is not None and kinematics == 'corexy':
print_with_c_locale(f'Warning: motors have different TMC configurations!\n{differences}') ConsoleOutput.print(f'Warning: motors have different TMC configurations!\n{differences}')
# Plot the graphs # Plot the graphs
plot_angle_profile_polar(ax1, all_angles, all_angles_energy, good_angles, symmetry_factor) plot_angle_profile_polar(ax1, all_angles, all_angles_energy, good_angles, symmetry_factor)
@@ -790,7 +850,7 @@ def vibrations_profile(
plot_angular_speed_profiles(ax3, all_speeds, all_angles, spectrogram_data, kinematics) plot_angular_speed_profiles(ax3, all_speeds, all_angles, spectrogram_data, kinematics)
plot_vibration_spectrogram(ax5, all_angles, all_speeds, spectrogram_data, vibration_peaks) plot_vibration_spectrogram(ax5, all_angles, all_speeds, spectrogram_data, vibration_peaks)
plot_motor_profiles(ax6, target_freqs, main_angles, motor_profiles, global_motor_profile, max_freq) plot_motor_profiles(ax6, target_freqs, main_angles, motor_profiles, max_freq)
# Adding a small Klippain logo to the top left corner of the figure # Adding a small Klippain logo to the top left corner of the figure
ax_logo = fig.add_axes([0.001, 0.924, 0.075, 0.075], anchor='NW') ax_logo = fig.add_axes([0.001, 0.924, 0.075, 0.075], anchor='NW')
@@ -829,8 +889,8 @@ def main():
opts.error('No CSV file(s) to analyse') opts.error('No CSV file(s) to analyse')
if options.output is None: if options.output is None:
opts.error('You must specify an output file.png to use the script (option -o)') opts.error('You must specify an output file.png to use the script (option -o)')
if options.kinematics not in ['cartesian', 'corexy']: if options.kinematics not in {'cartesian', 'corexy', 'corexz'}:
opts.error('Only cartesian and corexy kinematics are supported by this tool at the moment!') opts.error('Only cartesian, corexy and corexz kinematics are supported by this tool at the moment!')
fig = vibrations_profile(args, options.klipperdir, options.kinematics, options.accel, options.max_freq) fig = vibrations_profile(args, options.klipperdir, options.kinematics, options.accel, options.max_freq)
fig.savefig(options.output, dpi=150) fig.savefig(options.output, dpi=150)

View File

@@ -0,0 +1,6 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: __init__.py

View File

@@ -1,7 +1,12 @@
#!/usr/bin/env python3 # Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: common_func.py
# Description: Contains common functions and constants used across the Shake&Tune
# package for 3D printer vibration analysis and diagnostics.
# Common functions for the Shake&Tune package
# Written by Frix_x#0161 #
import math import math
import os import os
@@ -10,24 +15,63 @@ from importlib import import_module
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from git import GitCommandError, Repo
from scipy.signal import spectrogram from scipy.signal import spectrogram
from .console_output import ConsoleOutput
# Constant used to define the standard axis direction and names
AXIS_CONFIG = [
{'axis': 'x', 'direction': (1, 0, 0), 'label': 'axis_X'},
{'axis': 'y', 'direction': (0, 1, 0), 'label': 'axis_Y'},
{'axis': 'a', 'direction': (1, -1, 0), 'label': 'belt_A'},
{'axis': 'b', 'direction': (1, 1, 0), 'label': 'belt_B'},
{'axis': 'corexz_x', 'direction': (1, 0, 1), 'label': 'belt_X'},
{'axis': 'corexz_z', 'direction': (-1, 0, 1), 'label': 'belt_Z'},
]
def parse_log(logname): def parse_log(logname):
try:
with open(logname) as f: with open(logname) as f:
for header in f: header = None
if not header.startswith('#'): for line in f:
break cleaned_line = line.strip()
if not header.startswith('freq,psd_x,psd_y,psd_z,psd_xyz'):
# Raw accelerometer data # Check for a PSD file generated by Klipper and raise a warning
return np.loadtxt(logname, comments='#', delimiter=',') if cleaned_line.startswith('#freq,psd_x,psd_y,psd_z,psd_xyz'):
# Power spectral density data or shaper calibration data ConsoleOutput.print(
raise ValueError( f'Warning: {logname} does not contain raw accelerometer data. '
'File %s does not contain raw accelerometer data and therefore ' 'Please use the official Klipper script to process it instead. '
'is not supported by Shake&Tune. Please use the official Klipper ' 'It will be ignored by Shake&Tune!'
'script to process it instead.' % (logname,)
) )
return None
# Check for the expected header for Shake&Tune (raw accelerometer data from Klipper)
elif cleaned_line.startswith('#time,accel_x,accel_y,accel_z'):
header = cleaned_line
break
if not header:
ConsoleOutput.print(
f'Warning: file {logname} has an incorrect header and will be ignored by Shake&Tune!\n'
f"Expected '#time,accel_x,accel_y,accel_z', but got '{header.strip()}'."
)
return None
# If we have the correct raw data header, proceed to load the data
data = np.loadtxt(logname, comments='#', delimiter=',', skiprows=1)
if data.ndim == 1 or data.shape[1] != 4:
ConsoleOutput.print(
f'Warning: {logname} does not have the correct data format; expected 4 columns. '
'It will be ignored by Shake&Tune!'
)
return None
return data
except Exception as err:
ConsoleOutput.print(f'Error while reading {logname}: {err}. It will be ignored by Shake&Tune!')
return None
def setup_klipper_import(kdir): def setup_klipper_import(kdir):
@@ -41,6 +85,8 @@ def get_git_version():
try: try:
# Get the absolute path of the script, resolving any symlinks # Get the absolute path of the script, resolving any symlinks
# Then get 2 times to parent dir to be at the git root folder # Then get 2 times to parent dir to be at the git root folder
from git import GitCommandError, Repo
script_path = Path(__file__).resolve() script_path = Path(__file__).resolve()
repo_path = script_path.parents[1] repo_path = script_path.parents[1]
repo = Repo(repo_path) repo = Repo(repo_path)
@@ -204,25 +250,3 @@ def identify_low_energy_zones(power_total, detection_threshold=0.1):
sorted_valleys = sorted(valley_means_percentage, key=lambda x: x[2]) sorted_valleys = sorted(valley_means_percentage, key=lambda x: x[2])
return sorted_valleys return sorted_valleys
# Calculate or estimate a "similarity" factor between two PSD curves and scale it to a percentage. This is
# used here to quantify how close the two belts path behavior and responses are close together.
def compute_curve_similarity_factor(x1, y1, x2, y2, sim_sigmoid_k=0.6):
# Interpolate PSDs to match the same frequency bins and do a cross-correlation
y2_interp = np.interp(x1, x2, y2)
cross_corr = np.correlate(y1, y2_interp, mode='full')
# Find the peak of the cross-correlation and compute a similarity normalized by the energy of the signals
peak_value = np.max(cross_corr)
similarity = peak_value / (np.sqrt(np.sum(y1**2) * np.sum(y2_interp**2)))
# Apply sigmoid scaling to get better numbers and get a final percentage value
scaled_similarity = sigmoid_scale(-np.log(1 - similarity), sim_sigmoid_k)
return scaled_similarity
# Simple helper to compute a sigmoid scalling (from 0 to 100%)
def sigmoid_scale(x, k=1):
return 1 / (1 + np.exp(-k * x)) * 100

View File

@@ -0,0 +1,34 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: console_output.py
# Description: Defines the ConsoleOutput class for printing output to stdout or an alternative
# callback function, such as the Klipper console.
import io
from typing import Callable, Optional
class ConsoleOutput:
"""
Print output to stdout or to an alternative like the Klipper console through a callback
"""
_output_func: Optional[Callable[[str], None]] = None
@classmethod
def register_output_callback(cls, output_func: Optional[Callable[[str], None]]):
cls._output_func = output_func
@classmethod
def print(cls, *args, **kwargs):
if not cls._output_func:
print(*args, **kwargs)
return
with io.StringIO() as mem_output:
print(*args, file=mem_output, **kwargs)
cls._output_func(mem_output.getvalue())

View File

@@ -0,0 +1,188 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: motors_config_parser.py
# Description: Contains classes to retrieve motor information and extract relevant data
# from the Klipper configuration and TMC registers.
from typing import Any, Dict, List, Optional
TRINAMIC_DRIVERS = ['tmc2130', 'tmc2208', 'tmc2209', 'tmc2240', 'tmc2660', 'tmc5160']
MOTORS = ['stepper_x', 'stepper_y', 'stepper_x1', 'stepper_y1', 'stepper_z', 'stepper_z1', 'stepper_z2', 'stepper_z3']
RELEVANT_TMC_REGISTERS = ['CHOPCONF', 'PWMCONF', 'COOLCONF', 'TPWMTHRS', 'TCOOLTHRS']
class Motor:
def __init__(self, name: str):
self.name: str = name
self._registers: Dict[str, Dict[str, Any]] = {}
self._config: Dict[str, Any] = {}
def set_register(self, register: str, value_dict: dict) -> None:
# First we filter out entries with a value of 0 to avoid having too much uneeded data
value_dict = {k: v for k, v in value_dict.items() if v != 0}
# Special parsing for CHOPCONF to extract meaningful values
if register == 'CHOPCONF':
# Add intpol=0 if missing from the register dump to force printing it as it's important
if 'intpol' not in value_dict:
value_dict['intpol'] = '0'
# Remove the microsteps entry as the format here is not easy to read and
# it's already read in the correct format directly from the Klipper config
if 'mres' in value_dict:
del value_dict['mres']
# Special parsing for CHOPCONF to avoid pwm_ before each values
if register == 'PWMCONF':
new_value_dict = {}
for key, val in value_dict.items():
if key.startswith('pwm_'):
key = key[4:]
new_value_dict[key] = val
value_dict = new_value_dict
# Then gets merged all the thresholds into the same THRS virtual register
if register in {'TPWMTHRS', 'TCOOLTHRS'}:
existing_thrs = self._registers.get('THRS', {})
merged_values = {**existing_thrs, **value_dict}
self._registers['THRS'] = merged_values
else:
self._registers[register] = value_dict
def get_register(self, register: str) -> Optional[Dict[str, Any]]:
return self._registers.get(register)
def get_registers(self) -> Dict[str, Dict[str, Any]]:
return self._registers
def set_config(self, field: str, value: Any) -> None:
self._config[field] = value
def get_config(self, field: str) -> Optional[Any]:
return self._config.get(field)
def __str__(self):
return f'Stepper: {self.name}\nKlipper config: {self._config}\nTMC Registers: {self._registers}'
# Return the other motor config and registers that are different from the current motor
def compare_to(self, other: 'Motor') -> Optional[Dict[str, Dict[str, Any]]]:
differences = {'config': {}, 'registers': {}}
# Compare Klipper config
all_keys = self._config.keys() | other._config.keys()
for key in all_keys:
val1 = self._config.get(key)
val2 = other._config.get(key)
if val1 != val2:
differences['config'][key] = val2
# Compare TMC registers
all_keys = self._registers.keys() | other._registers.keys()
for key in all_keys:
reg1 = self._registers.get(key, {})
reg2 = other._registers.get(key, {})
if reg1 != reg2:
reg_diffs = {}
sub_keys = reg1.keys() | reg2.keys()
for sub_key in sub_keys:
reg_val1 = reg1.get(sub_key)
reg_val2 = reg2.get(sub_key)
if reg_val1 != reg_val2:
reg_diffs[sub_key] = reg_val2
if reg_diffs:
differences['registers'][key] = reg_diffs
# Clean up: remove empty sections if there are no differences
if not differences['config']:
del differences['config']
if not differences['registers']:
del differences['registers']
return None if not differences else differences
class MotorsConfigParser:
def __init__(self, config, motors: List[str] = MOTORS, drivers: List[str] = TRINAMIC_DRIVERS):
self._printer = config.get_printer()
self._motors: List[Motor] = []
if motors is not None:
for motor_name in motors:
for driver in drivers:
tmc_object = self._printer.lookup_object(f'{driver} {motor_name}', None)
if tmc_object is None:
continue
motor = self._create_motor(motor_name, driver, tmc_object)
self._motors.append(motor)
pconfig = self._printer.lookup_object('configfile')
self.kinematics = pconfig.status_raw_config['printer']['kinematics']
# Create a Motor object with the given name, driver and TMC object
# and fill it with the relevant configuration and registers
def _create_motor(self, motor_name: str, driver: str, tmc_object: Any) -> Motor:
motor = Motor(motor_name)
motor.set_config('tmc', driver)
self._parse_klipper_config(motor, tmc_object)
self._parse_tmc_registers(motor, tmc_object)
return motor
def _parse_klipper_config(self, motor: Motor, tmc_object: Any) -> None:
# The TMCCommandHelper isn't a direct member of the TMC object... but we can still get it this way
tmc_cmdhelper = tmc_object.get_status.__self__
motor_currents = tmc_cmdhelper.current_helper.get_current()
motor.set_config('run_current', motor_currents[0])
motor.set_config('hold_current', motor_currents[1])
pconfig = self._printer.lookup_object('configfile')
motor.set_config('microsteps', int(pconfig.status_raw_config[motor.name]['microsteps']))
autotune_object = self._printer.lookup_object(f'autotune_tmc {motor.name}', None)
if autotune_object is not None:
motor.set_config('autotune_enabled', True)
motor.set_config('motor', autotune_object.motor)
motor.set_config('voltage', autotune_object.voltage)
motor.set_config('pwm_freq_target', autotune_object.pwm_freq_target)
else:
motor.set_config('autotune_enabled', False)
def _parse_tmc_registers(self, motor: Motor, tmc_object: Any) -> None:
# The TMCCommandHelper isn't a direct member of the TMC object... but we can still get it this way
tmc_cmdhelper = tmc_object.get_status.__self__
for register in RELEVANT_TMC_REGISTERS:
val = tmc_cmdhelper.fields.registers.get(register)
if (val is not None) and (register not in tmc_cmdhelper.read_registers):
# write-only register
fields_string = self._extract_register_values(tmc_cmdhelper, register, val)
elif register in tmc_cmdhelper.read_registers:
# readable register
val = tmc_cmdhelper.mcu_tmc.get_register(register)
if tmc_cmdhelper.read_translate is not None:
register, val = tmc_cmdhelper.read_translate(register, val)
fields_string = self._extract_register_values(tmc_cmdhelper, register, val)
motor.set_register(register, fields_string)
def _extract_register_values(self, tmc_cmdhelper, register, val):
# Provide a dictionary of register values
reg_fields = tmc_cmdhelper.fields.all_fields.get(register, {})
reg_fields = sorted([(mask, name) for name, mask in reg_fields.items()])
fields = {}
for _, field_name in reg_fields:
field_value = tmc_cmdhelper.fields.get_field(field_name, val, register)
fields[field_name] = field_value
return fields
# Find and return the motor by its name
def get_motor(self, motor_name: str) -> Optional[Motor]:
return next((motor for motor in self._motors if motor.name == motor_name), None)
# Get all the motor list at once
def get_motors(self) -> List[Motor]:
return self._motors

View File

@@ -0,0 +1,87 @@
# Shake&Tune: 3D printer analysis tools
#
# Adapted from Klipper's original resonance_tester.py file by Dmitry Butyugin <dmbutyugin@google.com>
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: resonance_test.py
# Description: Contains functions to test the resonance frequency of the printer and its components
# by vibrating the toolhead in specific axis directions. This derive a bit from Klipper's
# implementation as there are two main changes:
# 1. Original code doesn't use euclidean distance with projection for the coordinates calculation.
# The new approach implemented here ensures that the vector's total length remains constant (= L),
# regardless of the direction components. It's especially important when the direction vector
# involves combinations of movements along multiple axes like for the diagonal belt tests.
# 2. Original code doesn't allow Z axis movements that was added in order to test the Z axis resonance
# or CoreXZ belts frequency profiles as well.
import math
from ..helpers.console_output import ConsoleOutput
# This function is used to vibrate the toolhead in a specific axis direction
# to test the resonance frequency of the printer and its components
def vibrate_axis(toolhead, gcode, axis_direction, min_freq, max_freq, hz_per_sec, accel_per_hz):
freq = min_freq
X, Y, Z, E = toolhead.get_position()
sign = 1.0
while freq <= max_freq + 0.000001:
t_seg = 0.25 / freq # Time segment for one vibration cycle
accel = accel_per_hz * freq # Acceleration for each half-cycle
max_v = accel * t_seg # Max velocity for each half-cycle
toolhead.cmd_M204(gcode.create_gcode_command('M204', 'M204', {'S': accel}))
L = 0.5 * accel * t_seg**2 # Distance for each half-cycle
# Calculate move points based on axis direction (X, Y and Z)
magnitude = math.sqrt(sum([component**2 for component in axis_direction]))
normalized_direction = tuple(component / magnitude for component in axis_direction)
dX, dY, dZ = normalized_direction[0] * L, normalized_direction[1] * L, normalized_direction[2] * L
nX = X + sign * dX
nY = Y + sign * dY
nZ = Z + sign * dZ
# Execute movement
toolhead.move([nX, nY, nZ, E], max_v)
toolhead.move([X, Y, Z, E], max_v)
sign *= -1
# Increase frequency for next cycle
old_freq = freq
freq += 2 * t_seg * hz_per_sec
if int(freq) > int(old_freq):
ConsoleOutput.print(f'Testing frequency: {freq:.0f} Hz')
toolhead.wait_moves()
# This function is used to vibrate the toolhead in a specific axis direction at a static frequency for a specific duration
def vibrate_axis_at_static_freq(toolhead, gcode, axis_direction, freq, duration, accel_per_hz):
X, Y, Z, E = toolhead.get_position()
sign = 1.0
# Compute movements values
t_seg = 0.25 / freq
accel = accel_per_hz * freq
max_v = accel * t_seg
toolhead.cmd_M204(gcode.create_gcode_command('M204', 'M204', {'S': accel}))
L = 0.5 * accel * t_seg**2
# Calculate move points based on axis direction (X, Y and Z)
magnitude = math.sqrt(sum([component**2 for component in axis_direction]))
normalized_direction = tuple(component / magnitude for component in axis_direction)
dX, dY, dZ = normalized_direction[0] * L, normalized_direction[1] * L, normalized_direction[2] * L
# Start a timer to measure the duration of the test and execute the vibration within the specified time
start_time = toolhead.reactor.monotonic()
while toolhead.reactor.monotonic() - start_time < duration:
nX = X + sign * dX
nY = Y + sign * dY
nZ = Z + sign * dZ
toolhead.move([nX, nY, nZ, E], max_v)
toolhead.move([X, Y, Z, E], max_v)
sign *= -1
toolhead.wait_moves()

View File

@@ -0,0 +1,142 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: motor_res_filter.py
# Description: This script defines the MotorResonanceFilter class that applies and removes motor resonance filters
# into the input shaper initial Klipper object. This is done by convolving a motor resonance targeted
# input shaper filter with the current configured axis input shapers.
import math
from .helpers.console_output import ConsoleOutput
class MotorResonanceFilter:
def __init__(self, printer, freq_x: float, freq_y: float, damping_x: float, damping_y: float, in_danger: bool):
self._printer = printer
self.freq_x = freq_x
self.freq_y = freq_y
self.damping_x = damping_x
self.damping_y = damping_y
self._in_danger = in_danger
self._original_shapers = {}
# Convolve two Klipper shapers into a new custom composite input shaping filter
@staticmethod
def convolve_shapers(L, R):
As = [a * b for a in L[0] for b in R[0]]
Ts = [a + b for a in L[1] for b in R[1]]
C = sorted(list(zip(Ts, As)))
return ([a for _, a in C], [t for t, _ in C])
def apply_filters(self) -> None:
input_shaper = self._printer.lookup_object('input_shaper', None)
if input_shaper is None:
raise ValueError(
'Unable to apply Shake&Tune motor resonance filters: no [input_shaper] config section found!'
)
shapers = input_shaper.get_shapers()
for shaper in shapers:
axis = shaper.axis
shaper_type = shaper.params.get_status()['shaper_type']
# Ignore the motor resonance filters for smoothers from DangerKlipper
if shaper_type.startswith('smooth_'):
ConsoleOutput.print(
(
f'Warning: {shaper_type} type shaper on {axis} axis is a smoother from DangerKlipper '
'Bleeding-Edge that already filters the motor resonance frequency range. Shake&Tune '
'motor resonance filters will be ignored for this axis...'
)
)
continue
# Ignore the motor resonance filters for custom shapers as users can set their own A&T values
if shaper_type == 'custom':
ConsoleOutput.print(
(
f'Warning: custom type shaper on {axis} axis is a manually crafted filter. So you have '
'already set custom A&T values for this axis and you should be able to convolve the motor '
'resonance frequency range to this custom shaper. Shake&Tune motor resonance filters will '
'be ignored for this axis...'
)
)
continue
# At the moment, when running stock Klipper, only ZV type shapers are supported to get combined with
# the motor resonance filters. This is due to the size of the pulse train that is too small and is not
# allowing the convolved shapers to be applied. This unless this PR is merged: https://github.com/Klipper3d/klipper/pull/6460
if not self._in_danger and shaper_type != 'zv':
ConsoleOutput.print(
(
f'Error: the {axis} axis is not a ZV type shaper. Shake&Tune motor resonance filters '
'will be ignored for this axis... This is due to the size of the pulse train being too '
'small and not allowing the convolved shapers to be applied... unless this PR is '
'merged: https://github.com/Klipper3d/klipper/pull/6460'
)
)
continue
# Get the current shaper parameters and store them for later restoration
_, axis_shaper_A, axis_shaper_T = shaper.get_shaper()
self._original_shapers[axis] = (axis_shaper_A, axis_shaper_T)
# Creating the new combined shapers that contains the motor resonance filters
if axis in {'x', 'y'}:
if self._in_danger:
# In DangerKlipper, the pulse train is large enough to allow the
# convolution of any shapers in order to craft the new combined shapers
# so we can use the MZV shaper (that looks to be the best for this purpose)
df = math.sqrt(1.0 - self.damping_x**2)
K = math.exp(-0.75 * self.damping_x * math.pi / df)
t_d = 1.0 / (self.freq_x * df)
a1 = 1.0 - 1.0 / math.sqrt(2.0)
a2 = (math.sqrt(2.0) - 1.0) * K
a3 = a1 * K * K
motor_filter_A = [a1, a2, a3]
motor_filter_T = [0.0, 0.375 * t_d, 0.75 * t_d]
else:
# In stock Klipper, the pulse train is too small for most shapers
# to be convolved. So we need to use the ZV shaper instead for the
# motor resonance filters... even if it's not the best for this purpose
df = math.sqrt(1.0 - self.damping_x**2)
K = math.exp(-self.damping_x * math.pi / df)
t_d = 1.0 / (self.freq_x * df)
motor_filter_A = [1.0, K]
motor_filter_T = [0.0, 0.5 * t_d]
combined_filter_A, combined_filter_T = MotorResonanceFilter.convolve_shapers(
(axis_shaper_A, axis_shaper_T),
(motor_filter_A, motor_filter_T),
)
shaper.A = combined_filter_A
shaper.T = combined_filter_T
shaper.n = len(combined_filter_A)
# Update the running input shaper filter with the new parameters
input_shaper._update_input_shaping()
def remove_filters(self) -> None:
input_shaper = self._printer.lookup_object('input_shaper', None)
if input_shaper is None:
raise ValueError(
'Unable to deactivate Shake&Tune motor resonance filters: no [input_shaper] config section found!'
)
shapers = input_shaper.get_shapers()
for shaper in shapers:
axis = shaper.axis
if axis in self._original_shapers:
A, T = self._original_shapers[axis]
shaper.A = A
shaper.T = T
shaper.n = len(A)
# Update the running input shaper filter with the restored initial parameters
# to keep only standard axis input shapers activated
input_shaper._update_input_shaping()

260
shaketune/shaketune.py Normal file
View File

@@ -0,0 +1,260 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: shaketune.py
# Description: Main class implementation for Shake&Tune, handling Klipper initialization and
# loading of the plugin, and the registration of the tuning commands
import importlib
import os
from pathlib import Path
from .commands import (
axes_map_calibration,
axes_shaper_calibration,
compare_belts_responses,
create_vibrations_profile,
excitate_axis_at_freq,
)
from .graph_creators import (
AxesMapGraphCreator,
BeltsGraphCreator,
ShaperGraphCreator,
StaticGraphCreator,
VibrationsGraphCreator,
)
from .helpers.console_output import ConsoleOutput
from .motor_res_filter import MotorResonanceFilter
from .shaketune_config import ShakeTuneConfig
from .shaketune_process import ShakeTuneProcess
DEFAULT_MOTOR_DAMPING_RATIO = 0.05
ST_COMMANDS = {
'EXCITATE_AXIS_AT_FREQ': (
'Maintain a specified excitation frequency for a period '
'of time to diagnose and locate a source of vibrations'
),
'AXES_MAP_CALIBRATION': (
'Perform a set of movements to measure the orientation of the accelerometer '
'and help you set the best axes_map configuration for your printer'
),
'COMPARE_BELTS_RESPONSES': (
'Perform a custom half-axis test to analyze and compare the '
'frequency profiles of individual belts on CoreXY or CoreXZ printers'
),
'AXES_SHAPER_CALIBRATION': 'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter',
'CREATE_VIBRATIONS_PROFILE': (
'Run a series of motions to find speed/angle ranges where the printer could be '
'exposed to VFAs to optimize your slicer speed profiles and TMC driver parameters'
),
}
class ShakeTune:
def __init__(self, config) -> None:
self._config = config
self._printer = config.get_printer()
self._printer.register_event_handler('klippy:connect', self._on_klippy_connect)
# Check if Shake&Tune is running in DangerKlipper
self.IN_DANGER = importlib.util.find_spec('extras.danger_options') is not None
# Register the console print output callback to the corresponding Klipper function
gcode = self._printer.lookup_object('gcode')
ConsoleOutput.register_output_callback(gcode.respond_info)
self._initialize_config(config)
self._register_commands()
self._initialize_motor_resonance_filter()
# Initialize the ShakeTune object and its configuration
def _initialize_config(self, config) -> None:
result_folder = config.get('result_folder', default='~/printer_data/config/ShakeTune_results')
result_folder_path = Path(result_folder).expanduser() if result_folder else None
keep_n_results = config.getint('number_of_results_to_keep', default=3, minval=0)
keep_csv = config.getboolean('keep_raw_csv', default=False)
dpi = config.getint('dpi', default=150, minval=100, maxval=500)
self._st_config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi)
self.timeout = config.getfloat('timeout', 300, above=0.0)
self._show_macros = config.getboolean('show_macros_in_webui', default=True)
motor_freq = config.getfloat('motor_freq', None, minval=0.0)
self._motor_freq_x = config.getfloat('motor_freq_x', motor_freq, minval=0.0)
self._motor_freq_y = config.getfloat('motor_freq_y', motor_freq, minval=0.0)
motor_damping = config.getfloat('motor_damping_ratio', DEFAULT_MOTOR_DAMPING_RATIO, minval=0.0)
self._motor_damping_x = config.getfloat('motor_damping_ratio_x', motor_damping, minval=0.0)
self._motor_damping_y = config.getfloat('motor_damping_ratio_y', motor_damping, minval=0.0)
# Create the Klipper commands to allow the user to run Shake&Tune's tools
def _register_commands(self) -> None:
gcode = self._printer.lookup_object('gcode')
measurement_commands = [
('EXCITATE_AXIS_AT_FREQ', self.cmd_EXCITATE_AXIS_AT_FREQ, ST_COMMANDS['EXCITATE_AXIS_AT_FREQ']),
('AXES_MAP_CALIBRATION', self.cmd_AXES_MAP_CALIBRATION, ST_COMMANDS['AXES_MAP_CALIBRATION']),
('COMPARE_BELTS_RESPONSES', self.cmd_COMPARE_BELTS_RESPONSES, ST_COMMANDS['COMPARE_BELTS_RESPONSES']),
('AXES_SHAPER_CALIBRATION', self.cmd_AXES_SHAPER_CALIBRATION, ST_COMMANDS['AXES_SHAPER_CALIBRATION']),
('CREATE_VIBRATIONS_PROFILE', self.cmd_CREATE_VIBRATIONS_PROFILE, ST_COMMANDS['CREATE_VIBRATIONS_PROFILE']),
]
# Register Shake&Tune's measurement commands using the official Klipper API (gcode.register_command)
# Doing this makes the commands available in Klipper but they are not shown in the web interfaces
# and are only available by typing the full name in the console (like all the other Klipper commands)
for name, command, description in measurement_commands:
gcode.register_command(f'_{name}' if self._show_macros else name, command, desc=description)
# Then, a hack to inject the macros into Klipper's config system in order to show them in the web
# interfaces. This is not a good way to do it, but it's the only way to do it for now to get
# a good user experience while using Shake&Tune (it's indeed easier to just click a macro button)
if self._show_macros:
configfile = self._printer.lookup_object('configfile')
dirname = os.path.dirname(os.path.realpath(__file__))
filename = os.path.join(dirname, 'dummy_macros.cfg')
try:
dummy_macros_cfg = configfile.read_config(filename)
except Exception as err:
raise self._config.error(f'Cannot load Shake&Tune dummy macro {filename}') from err
for gcode_macro in dummy_macros_cfg.get_prefix_sections('gcode_macro '):
gcode_macro_name = gcode_macro.get_name()
# Replace the dummy description by the one from ST_COMMANDS (to avoid code duplication and define it in only one place)
command = gcode_macro_name.split(' ', 1)[1]
description = ST_COMMANDS.get(command, 'Shake&Tune macro')
gcode_macro.fileconfig.set(gcode_macro_name, 'description', description)
# Add the section to the Klipper configuration object with all its options
if not self._config.fileconfig.has_section(gcode_macro_name.lower()):
self._config.fileconfig.add_section(gcode_macro_name.lower())
for option in gcode_macro.fileconfig.options(gcode_macro_name):
value = gcode_macro.fileconfig.get(gcode_macro_name, option)
self._config.fileconfig.set(gcode_macro_name.lower(), option, value)
# Small trick to ensure the new injected sections are considered valid by Klipper config system
self._config.access_tracking[(gcode_macro_name.lower(), option.lower())] = 1
# Finally, load the section within the printer objects
self._printer.load_object(self._config, gcode_macro_name.lower())
# Register the motor resonance filters if they are defined in the config
# DangerKlipper is required for the full feature but a degraded system forcing the ZV filter for
# both input shaping and motor resonance filter will be used instead in stock Klipper. But this might
# be improved in the future if https://github.com/Klipper3d/klipper/pull/6460 get merged
# TODO: To mitigate this issue, add an automated patch to klippy/chelper/kin_shaper.c
# (using a .diff file) to enable the motor filters in stock Klipper as well.
# But this will make the Klipper repo dirty to moonraker update manager, so I'm not
# sure how to handle this. Maybe with also a command to revert the patch? Or a
# manual command to apply the patch with a required user action?
def _initialize_motor_resonance_filter(self) -> None:
if self._motor_freq_x is not None and self._motor_freq_y is not None:
self._printer.register_event_handler('klippy:ready', self._on_klippy_ready)
gcode = self._printer.lookup_object('gcode')
gcode.register_command(
'MOTOR_RESONANCE_FILTER',
self.cmd_MOTOR_RESONANCE_FILTER,
desc='Enable/disable the motor resonance filters',
)
self.motor_resonance_filter = MotorResonanceFilter(
self._printer,
self._motor_freq_x,
self._motor_freq_y,
self._motor_damping_x,
self._motor_damping_y,
self.IN_DANGER,
)
def _on_klippy_connect(self) -> None:
# Check if the resonance_tester object is available in the printer
# configuration as it is required for Shake&Tune to work properly
res_tester = self._printer.lookup_object('resonance_tester', None)
if res_tester is None:
raise self._config.error(
'No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune!'
)
# In case the user has configured a motor resonance filter, we need to make sure
# that the input shaper is configured as well in order to use them. This is because
# the input shaper object is the one used to actually applies the additional filters
if self._motor_freq_x is not None and self._motor_freq_y is not None:
input_shaper = self._printer.lookup_object('input_shaper', None)
if input_shaper is None:
raise self._config.error(
(
'No [input_shaper] config section found in printer.cfg! Please add one to use Shake&Tune '
'motor resonance filters!'
)
)
def _on_klippy_ready(self) -> None:
self.motor_resonance_filter.apply_filters()
# ------------------------------------------------------------------------------------------
# ------------------------------------------------------------------------------------------
# Following are all the Shake&Tune commands that are registered to the Klipper console
# ------------------------------------------------------------------------------------------
# ------------------------------------------------------------------------------------------
def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
static_freq_graph_creator = StaticGraphCreator(self._st_config)
st_process = ShakeTuneProcess(
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:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
axes_map_graph_creator = AxesMapGraphCreator(self._st_config)
st_process = ShakeTuneProcess(
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:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
belt_graph_creator = BeltsGraphCreator(self._st_config)
st_process = ShakeTuneProcess(
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:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
shaper_graph_creator = ShaperGraphCreator(self._st_config)
st_process = ShakeTuneProcess(
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:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
vibration_profile_creator = VibrationsGraphCreator(self._st_config)
st_process = ShakeTuneProcess(
self._st_config,
self._printer.get_reactor(),
vibration_profile_creator,
self.timeout,
)
create_vibrations_profile(gcmd, self._config, st_process)
def cmd_MOTOR_RESONANCE_FILTER(self, gcmd) -> None:
enable = gcmd.get_int('ENABLE', default=1, minval=0, maxval=1)
if enable:
self.motor_resonance_filter.apply_filters()
else:
self.motor_resonance_filter.remove_filters()
ConsoleOutput.print(f'Motor resonance filter {"enabled" if enable else "disabled"}.')

View File

@@ -0,0 +1,67 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: shaketune_config.py
# Description: Defines the ShakeTuneConfig class for handling configuration settings
# and file paths related to Shake&Tune operations.
from pathlib import Path
from .helpers.console_output import ConsoleOutput
KLIPPER_FOLDER = Path.home() / 'klipper'
KLIPPER_LOG_FOLDER = Path.home() / 'printer_data/logs'
RESULTS_BASE_FOLDER = Path.home() / 'printer_data/config/K-ShakeTune_results'
RESULTS_SUBFOLDERS = {
'axes map': 'axes_map',
'belts comparison': 'belts',
'input shaper': 'input_shaper',
'vibrations profile': 'vibrations',
'static frequency': 'static_freq',
}
class ShakeTuneConfig:
def __init__(
self, result_folder: Path = RESULTS_BASE_FOLDER, keep_n_results: int = 3, keep_csv: bool = False, dpi: int = 150
) -> None:
self._result_folder = result_folder
self.keep_n_results = keep_n_results
self.keep_csv = keep_csv
self.dpi = dpi
self.klipper_folder = KLIPPER_FOLDER
self.klipper_log_folder = KLIPPER_LOG_FOLDER
def get_results_folder(self, type: str = None) -> Path:
if type is None:
return self._result_folder
else:
return self._result_folder / RESULTS_SUBFOLDERS[type]
def get_results_subfolders(self) -> Path:
subfolders = [self._result_folder / subfolder for subfolder in RESULTS_SUBFOLDERS.values()]
return subfolders
@staticmethod
def get_git_version() -> str:
try:
from git import GitCommandError, Repo
# Get the absolute path of the script, resolving any symlinks
# Then get 1 times to parent dir to be at the git root folder
script_path = Path(__file__).resolve()
repo_path = script_path.parents[1]
repo = Repo(repo_path)
try:
version = repo.git.describe('--tags')
except GitCommandError:
version = repo.head.commit.hexsha[:7] # If no tag is found, use the simplified commit SHA instead
return version
except Exception as e:
ConsoleOutput.print(f'Warning: unable to retrieve Shake&Tune version number: {e}')
return 'unknown'

View File

@@ -0,0 +1,102 @@
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier <felix@fboisselier.fr> (Frix_x on Discord)
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: shaketune_process.py
# Description: Implements the ShakeTuneProcess class for managing the execution of
# vibration analysis processes in separate system processes.
import os
import threading
import traceback
from multiprocessing import Process
from typing import Optional
from .helpers.console_output import ConsoleOutput
from .shaketune_config import ShakeTuneConfig
class ShakeTuneProcess:
def __init__(self, st_config: ShakeTuneConfig, reactor, graph_creator, timeout: Optional[float] = None) -> None:
self._config = st_config
self._reactor = reactor
self.graph_creator = graph_creator
self._timeout = timeout
self._process = None
def get_graph_creator(self):
return self.graph_creator
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)
self._process = Process(target=self._shaketune_process_wrapper, args=(self.graph_creator, self._timeout))
self._process.start()
def wait_for_completion(self) -> None:
if self._process is None:
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
# 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:
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.start()
try:
self._shaketune_process(graph_creator)
finally:
if timeout is not None:
timer.cancel()
def _handle_timeout(self) -> None:
ConsoleOutput.print('Timeout: Shake&Tune computation did not finish within the specified timeout!')
os._exit(1) # Forcefully exit the process
def _shaketune_process(self, graph_creator) -> None:
# Reducing Shake&Tune process priority by putting the scheduler into batch mode with low priority. This in order to avoid
# 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:
param = os.sched_param(os.sched_get_priority_min(os.SCHED_BATCH))
os.sched_setscheduler(0, os.SCHED_BATCH, param)
except Exception:
ConsoleOutput.print('Warning: failed reducing Shake&Tune process priority, continuing...')
# Ensure the output folders exist
for folder in self._config.get_results_subfolders():
folder.mkdir(parents=True, exist_ok=True)
# Generate the graphs
try:
graph_creator.create_graph()
except FileNotFoundError as e:
ConsoleOutput.print(f'FileNotFound error: {e}')
return
except TimeoutError as e:
ConsoleOutput.print(f'Timeout error: {e}')
return
except Exception as e:
ConsoleOutput.print(f'Error while generating the graphs: {e}\n{traceback.print_exc()}')
return
graph_creator.clean_old_files(self._config.keep_n_results)
ConsoleOutput.print(f'{graph_creator.get_type()} graphs created successfully!')
ConsoleOutput.print(
f'Cleaned up the output folder (only the last {self._config.keep_n_results} results were kept)!'
)

View File

@@ -1,154 +0,0 @@
#!/usr/bin/env python3
######################################
###### AXE_MAP DETECTION SCRIPT ######
######################################
# Written by Frix_x#0161 #
import optparse
import numpy as np
from scipy.signal import butter, filtfilt
from ..helpers.locale_utils import print_with_c_locale
NUM_POINTS = 500
######################################################################
# Computation
######################################################################
def accel_signal_filter(data, cutoff=2, fs=100, order=5):
nyq = 0.5 * fs
normal_cutoff = cutoff / nyq
b, a = butter(order, normal_cutoff, btype='low', analog=False)
filtered_data = filtfilt(b, a, data)
filtered_data -= np.mean(filtered_data)
return filtered_data
def find_first_spike(data):
min_index, max_index = np.argmin(data), np.argmax(data)
return ('-', min_index) if min_index < max_index else ('', max_index)
def get_movement_vector(data, start_idx, end_idx):
if start_idx < end_idx:
vector = []
for i in range(3):
vector.append(np.mean(data[i][start_idx:end_idx], axis=0))
return vector
else:
return np.zeros(3)
def angle_between(v1, v2):
v1_u = v1 / np.linalg.norm(v1)
v2_u = v2 / np.linalg.norm(v2)
return np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0))
def compute_errors(filtered_data, spikes_sorted, accel_value, num_points):
# Get the movement start points in the correct order from the sorted bag of spikes
movement_starts = [spike[0][1] for spike in spikes_sorted]
# Theoretical unit vectors for X, Y, Z printer axes
printer_axes = {'x': np.array([1, 0, 0]), 'y': np.array([0, 1, 0]), 'z': np.array([0, 0, 1])}
alignment_errors = {}
sensitivity_errors = {}
for i, axis in enumerate(['x', 'y', 'z']):
movement_start = movement_starts[i]
movement_end = movement_start + num_points
movement_vector = get_movement_vector(filtered_data, movement_start, movement_end)
alignment_errors[axis] = angle_between(movement_vector, printer_axes[axis])
measured_accel_magnitude = np.linalg.norm(movement_vector)
if accel_value != 0:
sensitivity_errors[axis] = abs(measured_accel_magnitude - accel_value) / accel_value * 100
else:
sensitivity_errors[axis] = None
return alignment_errors, sensitivity_errors
######################################################################
# Startup and main routines
######################################################################
def parse_log(logname):
with open(logname) as f:
for header in f:
if not header.startswith('#'):
break
if not header.startswith('freq,psd_x,psd_y,psd_z,psd_xyz'):
# Raw accelerometer data
return np.loadtxt(logname, comments='#', delimiter=',')
# Power spectral density data or shaper calibration data
raise ValueError(
'File %s does not contain raw accelerometer data and therefore '
'is not supported by this script. Please use the official Klipper '
'calibrate_shaper.py script to process it instead.' % (logname,)
)
def axesmap_calibration(lognames, accel=None):
# Parse the raw data and get them ready for analysis
raw_datas = [parse_log(filename) for filename in lognames]
if len(raw_datas) > 1:
raise ValueError('Analysis of multiple CSV files at once is not possible with this script')
filtered_data = [accel_signal_filter(raw_datas[0][:, i + 1]) for i in range(3)]
spikes = [find_first_spike(filtered_data[i]) for i in range(3)]
spikes_sorted = sorted([(spikes[0], 'x'), (spikes[1], 'y'), (spikes[2], 'z')], key=lambda x: x[0][1])
# Using the previous variables to get the axes_map and errors
axes_map = ','.join([f'{spike[0][0]}{spike[1]}' for spike in spikes_sorted])
# alignment_error, sensitivity_error = compute_errors(filtered_data, spikes_sorted, accel, NUM_POINTS)
results = f'Detected axes_map:\n {axes_map}\n'
# TODO: work on this function that is currently not giving good results...
# results += "Accelerometer angle deviation:\n"
# for axis, angle in alignment_error.items():
# angle_degrees = np.degrees(angle) # Convert radians to degrees
# results += f" {axis.upper()} axis: {angle_degrees:.2f} degrees\n"
# results += "Accelerometer sensitivity error:\n"
# for axis, error in sensitivity_error.items():
# results += f" {axis.upper()} axis: {error:.2f}%\n"
return results
def main():
# Parse command-line arguments
usage = '%prog [options] <raw logs>'
opts = optparse.OptionParser(usage)
opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
opts.add_option(
'-a', '--accel', type='string', dest='accel', default=None, help='acceleration value used to do the movements'
)
options, args = opts.parse_args()
if len(args) < 1:
opts.error('No CSV file(s) to analyse')
if options.accel is None:
opts.error('You must specify the acceleration value used when generating the CSV file (option -a)')
try:
accel_value = float(options.accel)
except ValueError:
opts.error('Invalid acceleration value. It should be a numeric value.')
results = axesmap_calibration(args, accel_value)
print_with_c_locale(results)
if options.output is not None:
with open(options.output, 'w') as f:
f.write(results)
if __name__ == '__main__':
main()

View File

@@ -1,558 +0,0 @@
#!/usr/bin/env python3
#################################################
######## CoreXY BELTS CALIBRATION SCRIPT ########
#################################################
# Written by Frix_x#0161 #
import optparse
import os
from collections import namedtuple
from datetime import datetime
import matplotlib
import matplotlib.colors
import matplotlib.font_manager
import matplotlib.pyplot as plt
import matplotlib.ticker
import numpy as np
from scipy.interpolate import griddata
matplotlib.use('Agg')
from ..helpers.common_func import (
compute_curve_similarity_factor,
compute_spectrogram,
detect_peaks,
parse_log,
setup_klipper_import,
)
from ..helpers.locale_utils import print_with_c_locale, set_locale
ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' # For paired peaks names
PEAKS_DETECTION_THRESHOLD = 0.20
CURVE_SIMILARITY_SIGMOID_K = 0.6
DC_GRAIN_OF_SALT_FACTOR = 0.75
DC_THRESHOLD_METRIC = 1.5e9
DC_MAX_UNPAIRED_PEAKS_ALLOWED = 4
# Define the SignalData namedtuple
SignalData = namedtuple('CalibrationData', ['freqs', 'psd', 'peaks', 'paired_peaks', 'unpaired_peaks'])
KLIPPAIN_COLORS = {
'purple': '#70088C',
'orange': '#FF8D32',
'dark_purple': '#150140',
'dark_orange': '#F24130',
'red_pink': '#F2055C',
}
######################################################################
# Computation of the PSD graph
######################################################################
# This function create pairs of peaks that are close in frequency on two curves (that are known
# to be resonances points and must be similar on both belts on a CoreXY kinematic)
def pair_peaks(peaks1, freqs1, psd1, peaks2, freqs2, psd2):
# Compute a dynamic detection threshold to filter and pair peaks efficiently
# even if the signal is very noisy (this get clipped to a maximum of 10Hz diff)
distances = []
for p1 in peaks1:
for p2 in peaks2:
distances.append(abs(freqs1[p1] - freqs2[p2]))
distances = np.array(distances)
median_distance = np.median(distances)
iqr = np.percentile(distances, 75) - np.percentile(distances, 25)
threshold = median_distance + 1.5 * iqr
threshold = min(threshold, 10)
# Pair the peaks using the dynamic thresold
paired_peaks = []
unpaired_peaks1 = list(peaks1)
unpaired_peaks2 = list(peaks2)
while unpaired_peaks1 and unpaired_peaks2:
min_distance = threshold + 1
pair = None
for p1 in unpaired_peaks1:
for p2 in unpaired_peaks2:
distance = abs(freqs1[p1] - freqs2[p2])
if distance < min_distance:
min_distance = distance
pair = (p1, p2)
if pair is None: # No more pairs below the threshold
break
p1, p2 = pair
paired_peaks.append(((p1, freqs1[p1], psd1[p1]), (p2, freqs2[p2], psd2[p2])))
unpaired_peaks1.remove(p1)
unpaired_peaks2.remove(p2)
return paired_peaks, unpaired_peaks1, unpaired_peaks2
######################################################################
# Computation of the differential spectrogram
######################################################################
# Interpolate source_data (2D) to match target_x and target_y in order to
# get similar time and frequency dimensions for the differential spectrogram
def interpolate_2d(target_x, target_y, source_x, source_y, source_data):
# Create a grid of points in the source and target space
source_points = np.array([(x, y) for y in source_y for x in source_x])
target_points = np.array([(x, y) for y in target_y for x in target_x])
# Flatten the source data to match the flattened source points
source_values = source_data.flatten()
# Interpolate and reshape the interpolated data to match the target grid shape and replace NaN with zeros
interpolated_data = griddata(source_points, source_values, target_points, method='nearest')
interpolated_data = interpolated_data.reshape((len(target_y), len(target_x)))
interpolated_data = np.nan_to_num(interpolated_data)
return interpolated_data
# Main logic function to combine two similar spectrogram - ie. from both belts paths - by substracting signals in order to create
# a new composite spectrogram. This result of a divergent but mostly centered new spectrogram (center will be white) with some colored zones
# highlighting differences in the belts paths. The summative spectrogram is used for the MHI calculation.
def compute_combined_spectrogram(data1, data2):
pdata1, bins1, t1 = compute_spectrogram(data1)
pdata2, bins2, t2 = compute_spectrogram(data2)
# Interpolate the spectrograms
pdata2_interpolated = interpolate_2d(bins1, t1, bins2, t2, pdata2)
# Combine them in two form: a summed diff for the MHI computation and a diverging diff for the spectrogram colors
combined_sum = np.abs(pdata1 - pdata2_interpolated)
combined_divergent = pdata1 - pdata2_interpolated
return combined_sum, combined_divergent, bins1, t1
# Compute a composite and highly subjective value indicating the "mechanical health of the printer (0 to 100%)" that represent the
# likelihood of mechanical issues on the printer. It is based on the differential spectrogram sum of gradient, salted with a bit
# of the estimated similarity cross-correlation from compute_curve_similarity_factor() and with a bit of the number of unpaired peaks.
# This result in a percentage value quantifying the machine behavior around the main resonances that give an hint if only touching belt tension
# will give good graphs or if there is a chance of mechanical issues in the background (above 50% should be considered as probably problematic)
def compute_mhi(combined_data, similarity_coefficient, num_unpaired_peaks):
# filtered_data = combined_data[combined_data > 100]
filtered_data = np.abs(combined_data)
# First compute a "total variability metric" based on the sum of the gradient that sum the magnitude of will emphasize regions of the
# spectrogram where there are rapid changes in magnitude (like the edges of resonance peaks).
total_variability_metric = np.sum(np.abs(np.gradient(filtered_data)))
# Scale the metric to a percentage using the threshold (found empirically on a large number of user data shared to me)
base_percentage = (np.log1p(total_variability_metric) / np.log1p(DC_THRESHOLD_METRIC)) * 100
# Adjust the percentage based on the similarity_coefficient to add a grain of salt
adjusted_percentage = base_percentage * (1 - DC_GRAIN_OF_SALT_FACTOR * (similarity_coefficient / 100))
# Adjust the percentage again based on the number of unpaired peaks to add a second grain of salt
peak_confidence = num_unpaired_peaks / DC_MAX_UNPAIRED_PEAKS_ALLOWED
final_percentage = (1 - peak_confidence) * adjusted_percentage + peak_confidence * 100
# Ensure the result lies between 0 and 100 by clipping the computed value
final_percentage = np.clip(final_percentage, 0, 100)
return final_percentage, mhi_lut(final_percentage)
# LUT to transform the MHI into a textual value easy to understand for the users of the script
def mhi_lut(mhi):
ranges = [
(0, 30, 'Excellent mechanical health'),
(30, 45, 'Good mechanical health'),
(45, 55, 'Acceptable mechanical health'),
(55, 70, 'Potential signs of a mechanical issue'),
(70, 85, 'Likely a mechanical issue'),
(85, 100, 'Mechanical issue detected'),
]
for lower, upper, message in ranges:
if lower < mhi <= upper:
return message
return 'Error computing MHI value'
######################################################################
# Graphing
######################################################################
def plot_compare_frequency(ax, lognames, signal1, signal2, similarity_factor, max_freq):
# Get the belt name for the legend to avoid putting the full file name
signal1_belt = (lognames[0].split('/')[-1]).split('_')[-1][0]
signal2_belt = (lognames[1].split('/')[-1]).split('_')[-1][0]
if signal1_belt == 'A' and signal2_belt == 'B':
signal1_belt += ' (axis 1,-1)'
signal2_belt += ' (axis 1, 1)'
elif signal1_belt == 'B' and signal2_belt == 'A':
signal1_belt += ' (axis 1, 1)'
signal2_belt += ' (axis 1,-1)'
else:
print_with_c_locale(
"Warning: belts doesn't seem to have the correct name A and B (extracted from the filename.csv)"
)
# Plot the two belts PSD signals
ax.plot(signal1.freqs, signal1.psd, label='Belt ' + signal1_belt, color=KLIPPAIN_COLORS['purple'])
ax.plot(signal2.freqs, signal2.psd, label='Belt ' + signal2_belt, color=KLIPPAIN_COLORS['orange'])
# Trace the "relax region" (also used as a threshold to filter and detect the peaks)
psd_lowest_max = min(signal1.psd.max(), signal2.psd.max())
peaks_warning_threshold = PEAKS_DETECTION_THRESHOLD * psd_lowest_max
ax.axhline(y=peaks_warning_threshold, color='black', linestyle='--', linewidth=0.5)
ax.fill_between(signal1.freqs, 0, peaks_warning_threshold, color='green', alpha=0.15, label='Relax Region')
# Trace and annotate the peaks on the graph
paired_peak_count = 0
unpaired_peak_count = 0
offsets_table_data = []
for _, (peak1, peak2) in enumerate(signal1.paired_peaks):
label = ALPHABET[paired_peak_count]
amplitude_offset = abs(
((signal2.psd[peak2[0]] - signal1.psd[peak1[0]]) / max(signal1.psd[peak1[0]], signal2.psd[peak2[0]])) * 100
)
frequency_offset = abs(signal2.freqs[peak2[0]] - signal1.freqs[peak1[0]])
offsets_table_data.append([f'Peaks {label}', f'{frequency_offset:.1f} Hz', f'{amplitude_offset:.1f} %'])
ax.plot(signal1.freqs[peak1[0]], signal1.psd[peak1[0]], 'x', color='black')
ax.plot(signal2.freqs[peak2[0]], signal2.psd[peak2[0]], 'x', color='black')
ax.plot(
[signal1.freqs[peak1[0]], signal2.freqs[peak2[0]]],
[signal1.psd[peak1[0]], signal2.psd[peak2[0]]],
':',
color='gray',
)
ax.annotate(
label + '1',
(signal1.freqs[peak1[0]], signal1.psd[peak1[0]]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='black',
)
ax.annotate(
label + '2',
(signal2.freqs[peak2[0]], signal2.psd[peak2[0]]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='black',
)
paired_peak_count += 1
for peak in signal1.unpaired_peaks:
ax.plot(signal1.freqs[peak], signal1.psd[peak], 'x', color='black')
ax.annotate(
str(unpaired_peak_count + 1),
(signal1.freqs[peak], signal1.psd[peak]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='red',
weight='bold',
)
unpaired_peak_count += 1
for peak in signal2.unpaired_peaks:
ax.plot(signal2.freqs[peak], signal2.psd[peak], 'x', color='black')
ax.annotate(
str(unpaired_peak_count + 1),
(signal2.freqs[peak], signal2.psd[peak]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='red',
weight='bold',
)
unpaired_peak_count += 1
# Add estimated similarity to the graph
ax2 = ax.twinx() # To split the legends in two box
ax2.yaxis.set_visible(False)
ax2.plot([], [], ' ', label=f'Estimated similarity: {similarity_factor:.1f}%')
ax2.plot([], [], ' ', label=f'Number of unpaired peaks: {unpaired_peak_count}')
# Setting axis parameters, grid and graph title
ax.set_xlabel('Frequency (Hz)')
ax.set_xlim([0, max_freq])
ax.set_ylabel('Power spectral density')
psd_highest_max = max(signal1.psd.max(), signal2.psd.max())
ax.set_ylim([0, psd_highest_max + psd_highest_max * 0.05])
ax.xaxis.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.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('small')
ax.set_title(
'Belts Frequency Profiles (estimated similarity: {:.1f}%)'.format(similarity_factor),
fontsize=14,
color=KLIPPAIN_COLORS['dark_orange'],
weight='bold',
)
# Print the table of offsets ontop of the graph below the original legend (upper right)
if len(offsets_table_data) > 0:
columns = [
'',
'Frequency delta',
'Amplitude delta',
]
offset_table = ax.table(
cellText=offsets_table_data,
colLabels=columns,
bbox=[0.66, 0.75, 0.33, 0.15],
loc='upper right',
cellLoc='center',
)
offset_table.auto_set_font_size(False)
offset_table.set_fontsize(8)
offset_table.auto_set_column_width([0, 1, 2])
offset_table.set_zorder(100)
cells = [key for key in offset_table.get_celld().keys()]
for cell in cells:
offset_table[cell].set_facecolor('white')
offset_table[cell].set_alpha(0.6)
ax.legend(loc='upper left', prop=fontP)
ax2.legend(loc='upper right', prop=fontP)
return
def plot_difference_spectrogram(ax, signal1, signal2, t, bins, combined_divergent, textual_mhi, max_freq):
ax.set_title('Differential Spectrogram', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.plot([], [], ' ', label=f'{textual_mhi} (experimental)')
# Draw the differential spectrogram with a specific custom norm to get orange or purple values where there is signal or white near zeros
# imgshow is better suited here than pcolormesh since its result is already rasterized and we doesn't need to keep vector graphics
# when saving to a final .png file. Using it also allow to save ~150-200MB of RAM during the "fig.savefig" operation.
colors = [
KLIPPAIN_COLORS['dark_orange'],
KLIPPAIN_COLORS['orange'],
'white',
KLIPPAIN_COLORS['purple'],
KLIPPAIN_COLORS['dark_purple'],
]
cm = matplotlib.colors.LinearSegmentedColormap.from_list(
'klippain_divergent', list(zip([0, 0.25, 0.5, 0.75, 1], colors))
)
norm = matplotlib.colors.TwoSlopeNorm(vmin=np.min(combined_divergent), vcenter=0, vmax=np.max(combined_divergent))
ax.imshow(
combined_divergent.T,
cmap=cm,
norm=norm,
aspect='auto',
extent=[t[0], t[-1], bins[0], bins[-1]],
interpolation='bilinear',
origin='lower',
)
ax.set_xlabel('Frequency (hz)')
ax.set_xlim([0.0, max_freq])
ax.set_ylabel('Time (s)')
ax.set_ylim([0, bins[-1]])
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('medium')
ax.legend(loc='best', prop=fontP)
# Plot vertical lines for unpaired peaks
unpaired_peak_count = 0
for _, peak in enumerate(signal1.unpaired_peaks):
ax.axvline(signal1.freqs[peak], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5)
ax.annotate(
f'Peak {unpaired_peak_count + 1}',
(signal1.freqs[peak], t[-1] * 0.05),
textcoords='data',
color=KLIPPAIN_COLORS['red_pink'],
rotation=90,
fontsize=10,
verticalalignment='bottom',
horizontalalignment='right',
)
unpaired_peak_count += 1
for _, peak in enumerate(signal2.unpaired_peaks):
ax.axvline(signal2.freqs[peak], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5)
ax.annotate(
f'Peak {unpaired_peak_count + 1}',
(signal2.freqs[peak], t[-1] * 0.05),
textcoords='data',
color=KLIPPAIN_COLORS['red_pink'],
rotation=90,
fontsize=10,
verticalalignment='bottom',
horizontalalignment='right',
)
unpaired_peak_count += 1
# Plot vertical lines and zones for paired peaks
for idx, (peak1, peak2) in enumerate(signal1.paired_peaks):
label = ALPHABET[idx]
x_min = min(peak1[1], peak2[1])
x_max = max(peak1[1], peak2[1])
ax.axvline(x_min, color=KLIPPAIN_COLORS['dark_purple'], linestyle='dotted', linewidth=1.5)
ax.axvline(x_max, color=KLIPPAIN_COLORS['dark_purple'], linestyle='dotted', linewidth=1.5)
ax.fill_between([x_min, x_max], 0, np.max(combined_divergent), color=KLIPPAIN_COLORS['dark_purple'], alpha=0.3)
ax.annotate(
f'Peaks {label}',
(x_min, t[-1] * 0.05),
textcoords='data',
color=KLIPPAIN_COLORS['dark_purple'],
rotation=90,
fontsize=10,
verticalalignment='bottom',
horizontalalignment='right',
)
return
######################################################################
# Custom tools
######################################################################
# Original Klipper function to get the PSD data of a raw accelerometer signal
def compute_signal_data(data, max_freq):
helper = shaper_calibrate.ShaperCalibrate(printer=None)
calibration_data = helper.process_accelerometer_data(data)
freqs = calibration_data.freq_bins[calibration_data.freq_bins <= max_freq]
psd = calibration_data.get_psd('all')[calibration_data.freq_bins <= max_freq]
_, peaks, _ = detect_peaks(psd, freqs, PEAKS_DETECTION_THRESHOLD * psd.max())
return SignalData(freqs=freqs, psd=psd, peaks=peaks, paired_peaks=None, unpaired_peaks=None)
######################################################################
# Startup and main routines
######################################################################
def belts_calibration(lognames, klipperdir='~/klipper', max_freq=200.0, st_version=None):
set_locale()
global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir)
# Parse data
datas = [parse_log(fn) for fn in lognames]
if len(datas) > 2:
raise ValueError('Incorrect number of .csv files used (this function needs exactly two files to compare them)!')
# Compute calibration data for the two datasets with automatic peaks detection
signal1 = compute_signal_data(datas[0], max_freq)
signal2 = compute_signal_data(datas[1], max_freq)
combined_sum, combined_divergent, bins, t = compute_combined_spectrogram(datas[0], datas[1])
del datas
# Pair the peaks across the two datasets
paired_peaks, unpaired_peaks1, unpaired_peaks2 = pair_peaks(
signal1.peaks, signal1.freqs, signal1.psd, signal2.peaks, signal2.freqs, signal2.psd
)
signal1 = signal1._replace(paired_peaks=paired_peaks, unpaired_peaks=unpaired_peaks1)
signal2 = signal2._replace(paired_peaks=paired_peaks, unpaired_peaks=unpaired_peaks2)
# Compute the similarity (using cross-correlation of the PSD signals)
similarity_factor = compute_curve_similarity_factor(
signal1.freqs, signal1.psd, signal2.freqs, signal2.psd, CURVE_SIMILARITY_SIGMOID_K
)
print_with_c_locale(f'Belts estimated similarity: {similarity_factor:.1f}%')
# Compute the MHI value from the differential spectrogram sum of gradient, salted with the similarity factor and the number of
# unpaired peaks from the belts frequency profile. Be careful, this value is highly opinionated and is pretty experimental!
mhi, textual_mhi = compute_mhi(
combined_sum, similarity_factor, len(signal1.unpaired_peaks) + len(signal2.unpaired_peaks)
)
print_with_c_locale(f'[experimental] Mechanical Health Indicator: {textual_mhi.lower()} ({mhi:.1f}%)')
# Create graph layout
fig, (ax1, ax2) = plt.subplots(
2,
1,
gridspec_kw={
'height_ratios': [4, 3],
'bottom': 0.050,
'top': 0.890,
'left': 0.085,
'right': 0.966,
'hspace': 0.169,
'wspace': 0.200,
},
)
fig.set_size_inches(8.3, 11.6)
# Add title
title_line1 = 'RELATIVE BELTS CALIBRATION TOOL'
fig.text(
0.12, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold'
)
try:
filename = lognames[0].split('/')[-1]
dt = datetime.strptime(f"{filename.split('_')[1]} {filename.split('_')[2]}", '%Y%m%d %H%M%S')
title_line2 = dt.strftime('%x %X')
except Exception:
print_with_c_locale(
'Warning: CSV filenames look to be different than expected (%s , %s)' % (lognames[0], lognames[1])
)
title_line2 = lognames[0].split('/')[-1] + ' / ' + lognames[1].split('/')[-1]
fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
# Plot the graphs
plot_compare_frequency(ax1, lognames, signal1, signal2, similarity_factor, max_freq)
plot_difference_spectrogram(ax2, signal1, signal2, t, bins, combined_divergent, textual_mhi, max_freq)
# 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.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png')))
ax_logo.axis('off')
# Adding Shake&Tune version in the top right corner
if st_version != 'unknown':
fig.text(0.995, 0.985, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple'])
return fig
def main():
# Parse command-line arguments
usage = '%prog [options] <raw logs>'
opts = optparse.OptionParser(usage)
opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
opts.add_option('-f', '--max_freq', type='float', default=200.0, help='maximum frequency to graph')
opts.add_option(
'-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory'
)
options, args = opts.parse_args()
if len(args) < 1:
opts.error('Incorrect number of arguments')
if options.output is None:
opts.error('You must specify an output file.png to use the script (option -o)')
fig = belts_calibration(args, options.klipperdir, options.max_freq)
fig.savefig(options.output, dpi=150)
if __name__ == '__main__':
main()

View File

@@ -1,38 +0,0 @@
#!/usr/bin/env python3
# Common file management functions for the Shake&Tune package
# Written by Frix_x#0161 #
import os
import time
from pathlib import Path
def wait_file_ready(filepath: Path, timeout: int = 60) -> None:
file_busy = True
loop_count = 0
while file_busy:
if loop_count >= timeout:
raise TimeoutError(f'Klipper is taking too long to release the CSV file ({filepath})!')
# Try to open the file in write-only mode to check if it is in use
# If we successfully open and close the file, it is not in use
try:
fd = os.open(filepath, os.O_WRONLY)
os.close(fd)
file_busy = False
except OSError:
# If OSError is caught, it indicates the file is still being used
pass
except Exception:
# If another exception is raised, it's not a problem, we just loop again
pass
loop_count += 1
time.sleep(1)
def ensure_folders_exist(folders: list[Path]) -> None:
for folder in folders:
folder.mkdir(parents=True, exist_ok=True)

View File

@@ -1,34 +0,0 @@
#!/usr/bin/env python3
# Special utility functions to manage locale settings and printing
# Written by Frix_x#0161 #
import locale
# Set the best locale for time and date formating (generation of the titles)
def set_locale():
try:
current_locale = locale.getlocale(locale.LC_TIME)
if current_locale is None or current_locale[0] is None:
locale.setlocale(locale.LC_TIME, 'C')
except locale.Error:
locale.setlocale(locale.LC_TIME, 'C')
# Print function to avoid problem in Klipper console (that doesn't support special characters) due to locale settings
def print_with_c_locale(*args, **kwargs):
try:
original_locale = locale.getlocale()
locale.setlocale(locale.LC_ALL, 'C')
except locale.Error as e:
print(
'Warning: Failed to set a basic locale. Special characters may not display correctly in Klipper console:', e
)
finally:
print(*args, **kwargs) # Proceed with printing regardless of locale setting success
try:
locale.setlocale(locale.LC_ALL, original_locale)
except locale.Error as e:
print('Warning: Failed to restore the original locale setting:', e)

View File

@@ -1,205 +0,0 @@
#!/usr/bin/env python3
# Classes to parse the Klipper log and parse the TMC dump to extract the relevant information
# Written by Frix_x#0161 #
import re
from pathlib import Path
from typing import Any, Dict, List, Optional, Union
class Motor:
def __init__(self, name: str):
self._name: str = name
self._registers: Dict[str, Dict[str, Any]] = {}
self._properties: Dict[str, Any] = {}
def set_register(self, register: str, value: Any) -> None:
# Special parsing for CHOPCONF to extract meaningful values
if register == 'CHOPCONF':
# Add intpol=0 if missing from the register dump
if 'intpol=' not in value:
value += ' intpol=0'
# Simplify the microstep resolution format
mres_match = re.search(r'mres=\d+\((\d+)usteps\)', value)
if mres_match:
value = re.sub(r'mres=\d+\(\d+usteps\)', f'mres={mres_match.group(1)}', value)
# Special parsing for CHOPCONF to avoid pwm_ before each values
if register == 'PWMCONF':
parts = value.split()
new_parts = []
for part in parts:
key, val = part.split('=', 1)
if key.startswith('pwm_'):
key = key[4:]
new_parts.append(f'{key}={val}')
value = ' '.join(new_parts)
# General cleaning to remove extraneous labels and colons and parse the whole into Motor _registers
cleaned_values = re.sub(r'\b\w+:\s+\S+\s+', '', value)
# Then fill the registers while merging all the thresholds into the same THRS virtual register
if register in ['TPWMTHRS', 'TCOOLTHRS']:
existing_thrs = self._registers.get('THRS', {})
new_values = self._parse_register_values(cleaned_values)
merged_values = {**existing_thrs, **new_values}
self._registers['THRS'] = merged_values
else:
self._registers[register] = self._parse_register_values(cleaned_values)
def _parse_register_values(self, register_string: str) -> Dict[str, Any]:
parsed = {}
parts = register_string.split()
for part in parts:
if '=' in part:
k, v = part.split('=', 1)
parsed[k] = v
return parsed
def get_register(self, register: str) -> Optional[Dict[str, Any]]:
return self._registers.get(register)
def get_registers(self) -> Dict[str, Dict[str, Any]]:
return self._registers
def set_property(self, property: str, value: Any) -> None:
self._properties[property] = value
def get_property(self, property: str) -> Optional[Any]:
return self._properties.get(property)
def __str__(self):
return f'Stepper: {self._name}\nKlipper config: {self._properties}\nTMC Registers: {self._registers}'
# Return the other motor properties and registers that are different from the current motor
def compare_to(self, other: 'Motor') -> Optional[Dict[str, Dict[str, Any]]]:
differences = {'properties': {}, 'registers': {}}
# Compare properties
all_keys = self._properties.keys() | other._properties.keys()
for key in all_keys:
val1 = self._properties.get(key)
val2 = other._properties.get(key)
if val1 != val2:
differences['properties'][key] = val2
# Compare registers
all_keys = self._registers.keys() | other._registers.keys()
for key in all_keys:
reg1 = self._registers.get(key, {})
reg2 = other._registers.get(key, {})
if reg1 != reg2:
reg_diffs = {}
sub_keys = reg1.keys() | reg2.keys()
for sub_key in sub_keys:
reg_val1 = reg1.get(sub_key)
reg_val2 = reg2.get(sub_key)
if reg_val1 != reg_val2:
reg_diffs[sub_key] = reg_val2
if reg_diffs:
differences['registers'][key] = reg_diffs
# Clean up: remove empty sections if there are no differences
if not differences['properties']:
del differences['properties']
if not differences['registers']:
del differences['registers']
if not differences:
return None
return differences
class MotorLogParser:
_section_pattern: str = r'DUMP_TMC stepper_(x|y)'
_register_patterns: Dict[str, str] = {
'CHOPCONF': r'CHOPCONF:\s+\S+\s+(.*)',
'PWMCONF': r'PWMCONF:\s+\S+\s+(.*)',
'COOLCONF': r'COOLCONF:\s+(.*)',
'TPWMTHRS': r'TPWMTHRS:\s+\S+\s+(.*)',
'TCOOLTHRS': r'TCOOLTHRS:\s+\S+\s+(.*)',
}
def __init__(self, filepath: Path, config_string: Optional[str] = None):
self._filepath = filepath
self._motors: List[Motor] = []
self._config = self._parse_config(config_string) if config_string else {}
self._parse_registers()
def _parse_config(self, config_string: str) -> Dict[str, Any]:
config = {}
entries = config_string.split('|')
for entry in entries:
if entry:
key, value = entry.split(':')
config[key.strip()] = self._convert_value(value.strip())
return config
def _convert_value(self, value: str) -> Union[int, float, bool, str]:
if value.isdigit():
return int(value)
try:
return float(value)
except ValueError:
if value.lower() in ['true', 'false']:
return value.lower() == 'true'
return value
def _parse_registers(self) -> None:
with open(self._filepath, 'r') as file:
log_content = file.read()
sections = re.split(self._section_pattern, log_content)
# Detect only the latest dumps from the log (to ignore potential previous and outdated dumps)
last_sections: Dict[str, int] = {}
for i in range(1, len(sections), 2):
stepper_name = 'stepper_' + sections[i].strip()
last_sections[stepper_name] = i
for stepper_name, index in last_sections.items():
content = sections[index + 1]
motor = Motor(stepper_name)
# Apply general properties from config string
for key, value in self._config.items():
if stepper_name in key:
prop_key = key.replace(stepper_name + '_', '')
motor.set_property(prop_key, value)
elif 'autotune' in key:
motor.set_property(key, value)
# Parse TMC registers
for key, pattern in self._register_patterns.items():
match = re.search(pattern, content)
if match:
values = match.group(1).strip()
motor.set_register(key, values)
self._motors.append(motor)
# Find and return the motor by its name
def get_motor(self, motor_name: str) -> Optional[Motor]:
for motor in self._motors:
if motor._name == motor_name:
return motor
return None
# Get all the motor list at once
def get_motors(self) -> List[Motor]:
return self._motors
# # Usage example:
# config_string = "stepper_x_tmc:tmc2240|stepper_x_run_current:0.9|stepper_x_hold_current:0.9|stepper_y_tmc:tmc2240|stepper_y_run_current:0.9|stepper_y_hold_current:0.9|autotune_enabled:True|stepper_x_motor:ldo-35sth48-1684ah|stepper_x_voltage:|stepper_y_motor:ldo-35sth48-1684ah|stepper_y_voltage:|"
# parser = MotorLogParser('/path/to/your/logfile.log', config_string)
# stepper_x = parser.get_motor('stepper_x')
# stepper_y = parser.get_motor('stepper_y')
# print(stepper_x)
# print(stepper_y)

View File

@@ -1,424 +0,0 @@
#!/usr/bin/env python3
############################################
###### INPUT SHAPER KLIPPAIN WORKFLOW ######
############################################
# Written by Frix_x#0161 #
# This script is designed to be used with gcode_shell_commands directly from Klipper
# Use the provided Shake&Tune macros instead!
import abc
import argparse
import tarfile
import traceback
from datetime import datetime
from pathlib import Path
from typing import Callable, Optional
from git import GitCommandError, Repo
from matplotlib.figure import Figure
import src.helpers.filemanager as fm
from src.graph_creators.analyze_axesmap import axesmap_calibration
from src.graph_creators.graph_belts import belts_calibration
from src.graph_creators.graph_shaper import shaper_calibration
from src.graph_creators.graph_vibrations import vibrations_profile
from src.helpers.locale_utils import print_with_c_locale
from src.helpers.motorlogparser import MotorLogParser
class Config:
KLIPPER_FOLDER = Path.home() / 'klipper'
KLIPPER_LOG_FOLDER = Path.home() / 'printer_data/logs'
RESULTS_BASE_FOLDER = Path.home() / 'printer_data/config/K-ShakeTune_results'
RESULTS_SUBFOLDERS = {'belts': 'belts', 'shaper': 'inputshaper', 'vibrations': 'vibrations'}
@staticmethod
def get_results_folder(type: str) -> Path:
return Config.RESULTS_BASE_FOLDER / Config.RESULTS_SUBFOLDERS[type]
@staticmethod
def get_git_version() -> str:
try:
# Get the absolute path of the script, resolving any symlinks
# Then get 1 times to parent dir to be at the git root folder
script_path = Path(__file__).resolve()
repo_path = script_path.parents[1]
repo = Repo(repo_path)
try:
version = repo.git.describe('--tags')
except GitCommandError:
version = repo.head.commit.hexsha[:7] # If no tag is found, use the simplified commit SHA instead
return version
except Exception as e:
print_with_c_locale(f'Warning: unable to retrieve Shake&Tune version number: {e}')
return 'unknown'
@staticmethod
def parse_arguments() -> argparse.Namespace:
parser = argparse.ArgumentParser(description='Shake&Tune graphs generation script')
parser.add_argument(
'-t',
'--type',
dest='type',
choices=['belts', 'shaper', 'vibrations', 'axesmap'],
required=True,
help='Type of output graph to produce',
)
parser.add_argument(
'--accel',
type=int,
default=None,
dest='accel_used',
help='Accelerometion used for vibrations profile creation or axes map calibration',
)
parser.add_argument(
'--chip_name',
type=str,
default='adxl345',
dest='chip_name',
help='Accelerometer chip name used for vibrations profile creation or axes map calibration',
)
parser.add_argument(
'--max_smoothing',
type=float,
default=None,
dest='max_smoothing',
help='Maximum smoothing to allow for input shaper filter recommendations',
)
parser.add_argument(
'--scv',
'--square_corner_velocity',
type=float,
default=5.0,
dest='scv',
help='Square corner velocity used to compute max accel for input shapers filter recommendations',
)
parser.add_argument(
'-m',
'--kinematics',
dest='kinematics',
default='cartesian',
choices=['cartesian', 'corexy'],
help='Machine kinematics configuration used for the vibrations profile creation',
)
parser.add_argument(
'--metadata',
type=str,
default=None,
dest='metadata',
help='Motor configuration metadata printed on the vibrations profiles',
)
parser.add_argument(
'-c',
'--keep_csv',
action='store_true',
default=False,
dest='keep_csv',
help='Whether to keep the raw CSV files after processing in addition to the PNG graphs',
)
parser.add_argument(
'-n',
'--keep_results',
type=int,
default=3,
dest='keep_results',
help='Number of results to keep in the result folder after each run of the script',
)
parser.add_argument('--dpi', type=int, default=150, dest='dpi', help='DPI of the output PNG files')
parser.add_argument('-v', '--version', action='version', version=f'Shake&Tune {Config.get_git_version()}')
return parser.parse_args()
class GraphCreator(abc.ABC):
def __init__(self, keep_csv: bool, dpi: int):
self._keep_csv = keep_csv
self._dpi = dpi
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
self._version = Config.get_git_version()
self._type = None
self._folder = None
def _setup_folder(self, graph_type: str) -> None:
self._type = graph_type
self._folder = Config.get_results_folder(graph_type)
def _move_and_prepare_files(
self,
glob_pattern: str,
min_files_required: Optional[int] = None,
custom_name_func: Optional[Callable[[Path], str]] = None,
) -> list[Path]:
tmp_path = Path('/tmp')
globbed_files = list(tmp_path.glob(glob_pattern))
# If min_files_required is not set, use the number of globbed files as the minimum
min_files_required = min_files_required or len(globbed_files)
if not globbed_files:
raise FileNotFoundError(f'no CSV files found in the /tmp folder to create the {self._type} graphs!')
if len(globbed_files) < min_files_required:
raise FileNotFoundError(f'{min_files_required} CSV files are needed to create the {self._type} graphs!')
lognames = []
for filename in sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[:min_files_required]:
fm.wait_file_ready(filename)
custom_name = custom_name_func(filename) if custom_name_func else filename.name
new_file = self._folder / f'{self._type}_{self._graph_date}_{custom_name}.csv'
filename.rename(new_file)
fm.wait_file_ready(new_file)
lognames.append(new_file)
return lognames
def _save_figure_and_cleanup(self, fig: Figure, lognames: list[Path], axis_label: Optional[str] = None) -> None:
axis_suffix = f'_{axis_label}' if axis_label else ''
png_filename = self._folder / f'{self._type}_{self._graph_date}{axis_suffix}.png'
fig.savefig(png_filename, dpi=self._dpi)
if self._keep_csv:
self._archive_files(lognames)
else:
self._remove_files(lognames)
def _archive_files(self, _: list[Path]) -> None:
return
def _remove_files(self, lognames: list[Path]) -> None:
for csv in lognames:
csv.unlink(missing_ok=True)
@abc.abstractmethod
def create_graph(self) -> None:
pass
@abc.abstractmethod
def clean_old_files(self, keep_results: int) -> None:
pass
class BeltsGraphCreator(GraphCreator):
def __init__(self, keep_csv: bool = False, dpi: int = 150):
super().__init__(keep_csv, dpi)
self._setup_folder('belts')
def create_graph(self) -> None:
lognames = self._move_and_prepare_files(
glob_pattern='raw_data_axis*.csv',
min_files_required=2,
custom_name_func=lambda f: f.stem.split('_')[3].upper(),
)
fig = belts_calibration(
lognames=[str(path) for path in lognames],
klipperdir=str(Config.KLIPPER_FOLDER),
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
# Get all PNG files in the directory as a list of Path objects
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
# Delete the older files
for old_file in files[keep_results:]:
file_date = '_'.join(old_file.stem.split('_')[1:3])
for suffix in ['A', 'B']:
csv_file = self._folder / f'belts_{file_date}_{suffix}.csv'
csv_file.unlink(missing_ok=True)
old_file.unlink()
class ShaperGraphCreator(GraphCreator):
def __init__(self, keep_csv: bool = False, dpi: int = 150):
super().__init__(keep_csv, dpi)
self._max_smoothing = None
self._scv = None
self._setup_folder('shaper')
def configure(self, scv: float, max_smoothing: float = None) -> None:
self._scv = scv
self._max_smoothing = max_smoothing
def create_graph(self) -> None:
if not self._scv:
raise ValueError('scv must be set to create the input shaper graph!')
lognames = self._move_and_prepare_files(
glob_pattern='raw_data*.csv',
min_files_required=1,
custom_name_func=lambda f: f.stem.split('_')[3].upper(),
)
fig = shaper_calibration(
lognames=[str(path) for path in lognames],
klipperdir=str(Config.KLIPPER_FOLDER),
max_smoothing=self._max_smoothing,
scv=self._scv,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1])
def clean_old_files(self, keep_results: int = 3) -> None:
# Get all PNG files in the directory as a list of Path objects
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= 2 * keep_results:
return # No need to delete any files
# Delete the older files
for old_file in files[2 * keep_results :]:
csv_file = old_file.with_suffix('.csv')
csv_file.unlink(missing_ok=True)
old_file.unlink()
class VibrationsGraphCreator(GraphCreator):
def __init__(self, keep_csv: bool = False, dpi: int = 150):
super().__init__(keep_csv, dpi)
self._kinematics = None
self._accel = None
self._chip_name = None
self._motors = None
self._setup_folder('vibrations')
def configure(self, kinematics: str, accel: float, chip_name: str, metadata: str) -> None:
self._kinematics = kinematics
self._accel = accel
self._chip_name = chip_name
parser = MotorLogParser(Config.KLIPPER_LOG_FOLDER / 'klippy.log', metadata)
self._motors = parser.get_motors()
def _archive_files(self, lognames: list[Path]) -> None:
tar_path = self._folder / f'{self._type}_{self._graph_date}.tar.gz'
with tarfile.open(tar_path, 'w:gz') as tar:
for csv_file in lognames:
tar.add(csv_file, arcname=csv_file.name, recursive=False)
def create_graph(self) -> None:
if not self._accel or not self._chip_name or not self._kinematics:
raise ValueError('accel, chip_name and kinematics must be set to create the vibrations profile graph!')
lognames = self._move_and_prepare_files(
glob_pattern=f'{self._chip_name}-*.csv',
min_files_required=None,
custom_name_func=lambda f: f.name.replace(self._chip_name, self._type),
)
fig = vibrations_profile(
lognames=[str(path) for path in lognames],
klipperdir=str(Config.KLIPPER_FOLDER),
kinematics=self._kinematics,
accel=self._accel,
st_version=self._version,
motors=self._motors,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
# Get all PNG files in the directory as a list of Path objects
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
# Delete the older files
for old_file in files[keep_results:]:
old_file.unlink()
tar_file = old_file.with_suffix('.tar.gz')
tar_file.unlink(missing_ok=True)
class AxesMapFinder:
def __init__(self, accel: float, chip_name: str):
self._accel = accel
self._chip_name = chip_name
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
self._type = 'axesmap'
self._folder = Config.RESULTS_BASE_FOLDER
def find_axesmap(self) -> None:
tmp_folder = Path('/tmp')
globbed_files = list(tmp_folder.glob(f'{self._chip_name}-*.csv'))
if not globbed_files:
raise FileNotFoundError('no CSV files found in the /tmp folder to find the axes map!')
# Find the CSV files with the latest timestamp and wait for it to be released by Klipper
logname = sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[0]
fm.wait_file_ready(logname)
results = axesmap_calibration(
lognames=[str(logname)],
accel=self._accel,
)
result_filename = self._folder / f'{self._type}_{self._graph_date}.txt'
with result_filename.open('w') as f:
f.write(results)
def main():
options = Config.parse_arguments()
fm.ensure_folders_exist(
folders=[Config.RESULTS_BASE_FOLDER / subfolder for subfolder in Config.RESULTS_SUBFOLDERS.values()]
)
print_with_c_locale(f'Shake&Tune version: {Config.get_git_version()}')
graph_creators = {
'belts': (BeltsGraphCreator, None),
'shaper': (ShaperGraphCreator, lambda gc: gc.configure(options.scv, options.max_smoothing)),
'vibrations': (
VibrationsGraphCreator,
lambda gc: gc.configure(options.kinematics, options.accel_used, options.chip_name, options.metadata),
),
'axesmap': (AxesMapFinder, None),
}
creator_info = graph_creators.get(options.type)
if not creator_info:
print_with_c_locale('Error: invalid graph type specified!')
return
# Instantiate the graph creator
graph_creator_class, configure_func = creator_info
graph_creator = graph_creator_class(options.keep_csv, options.dpi)
# Configure it if needed
if configure_func:
configure_func(graph_creator)
# And then run it
try:
graph_creator.create_graph()
except FileNotFoundError as e:
print_with_c_locale(f'FileNotFound error: {e}')
return
except TimeoutError as e:
print_with_c_locale(f'Timeout error: {e}')
return
except Exception as e:
print_with_c_locale(f'Error while generating the graphs: {e}')
traceback.print_exc()
return
print_with_c_locale(f'{options.type} graphs created successfully!')
graph_creator.clean_old_files(options.keep_results)
print_with_c_locale(f'Cleaned output folder to keep only the last {options.keep_results} results!')
if __name__ == '__main__':
main()