Merge branch 'develop' into cross-belts

This commit is contained in:
Félix Boisselier
2024-05-19 12:10:26 +02:00
43 changed files with 1554 additions and 1177 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,36 +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 %}
{% set kinematics = printer.configfile.settings.printer.kinematics %}
RESPOND MSG="{kinematics} kinematics detected"
{% if kinematics != "corexy" %}
RESPOND MSG="Note that this test is not useful for this kinematics because the belt paths are not symmetrical!"
{% endif %}
{% if kinematics == "corexy" %}
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
{% else %}
TEST_RESONANCES AXIS=1,0 OUTPUT=raw_data NAME=x FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
M400
TEST_RESONANCES AXIS=0,1 OUTPUT=raw_data NAME=y FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
M400
{% endif %}
RESPOND MSG="Belts comparative frequency profile generation..."
RESPOND MSG="This may take some time (3-5min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type belts --kinematics {kinematics} {% 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,4 +1,4 @@
# 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. 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.
@@ -17,9 +17,6 @@ Check out the **[detailed documentation of the Shake&Tune module here](./docs/RE
|:----------------:|:------------:|:---------------------:| |:----------------:|:------------:|:---------------------:|
| [<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) | | [<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
@@ -31,7 +28,10 @@ Follow these steps to install the Shake&Tune module in your printer:
``` ```
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 (if prefered, you can include only the needed macros: using `*.cfg` is a convenient way to include them all at once):
``` ```
[include K-ShakeTune/*.cfg] [shaketune]
# result_folder: ~/printer_data/config/K-ShakeTune_results
# number_of_results_to_keep: 3
# keep_raw_csv: False
``` ```
## Usage ## Usage

View File

@@ -1,12 +1,70 @@
# Klippain Shake&Tune module documentation # Klipper Shake&Tune plugin documentation
![](./banner_long.png) ![](./banner_long.png)
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**.
While there are some ideal goals described in this documentation, you need to understand that it's not always possible to achieve the ideal resonance graphs due to a variety of factors unique to each printer, such as precision of the assembly, quality and brand of components, 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.
First, you might want to 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.
## Resonance testing ## 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. A standard tuning workflow might look something like this:
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! ```mermaid
%%{
init: {
'theme': 'base',
'themeVariables': {
'lineColor': '#232323',
'primaryTextColor': '#F2055C',
'secondaryColor': '#D3D3D3',
'tertiaryColor': '#FFFFFF'
}
}
}%%
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
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;
```
You can access the documentation for each graph type by clicking on it in the table below.
| [Belt response comparison](./macros/belts_tuning.md) | [Axis input shaper graphs](./macros/axis_tuning.md) | [Vibrations profile](./macros/vibrations_profile.md) | | [Belt response comparison](./macros/belts_tuning.md) | [Axis input shaper graphs](./macros/axis_tuning.md) | [Vibrations profile](./macros/vibrations_profile.md) |
|:----------------:|:------------:|:---------------------:| |:----------------:|:------------:|:---------------------:|
@@ -31,7 +89,7 @@ Here are the parameters available when calling this macro:
|SPEED|80|speed of the toolhead in mm/s for the movements| |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| |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| |TRAVEL_SPEED|120|speed in mm/s used for all the travels moves|
|ACCEL_CHIP|"adxl345"|accelerometer chip name in the config| |ACCEL_CHIP|None|accelerometer to use for the test. If unset, it will automatically select the proper accelerometer based on what is configured in your `[resonance_tester]` config section|
The machine will move slightly in +X, +Y, and +Z, and output in the console: `Detected axes_map: -z,y,x`. The machine will move slightly in +X, +Y, and +Z, and output in the console: `Detected axes_map: -z,y,x`.
@@ -50,8 +108,11 @@ Here are the parameters available when calling this macro:
| parameters | default value | description | | 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| |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| |DURATION|10|duration in second to maintain this excitation|
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)|
|AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"| |AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"|
|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)|
|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|
## Complementary ressources ## Complementary ressources

View File

@@ -11,14 +11,15 @@ 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|5|starting excitation frequency|
|FREQ_END|133|Maximum excitation frequency| |FREQ_END|133|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|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)|
|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 ## Graphs description

View File

@@ -1,6 +1,6 @@
# Belt relative difference measurements # Belt relative difference measurements
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 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. Using it on Cartesian printers doesn't really make sense, as it's normal to have different responses in that case.
## Usage ## Usage
@@ -11,11 +11,12 @@ Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the re
| parameters | default value | description | | parameters | default value | description |
|-----------:|---------------|-------------| |-----------:|---------------|-------------|
|FREQ_START|5|Starting excitation frequency| |FREQ_START|5|starting excitation frequency|
|FREQ_END|133|Maximum excitation frequency| |FREQ_END|133|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|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)|
|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|
## Graphs description ## Graphs description
@@ -46,6 +47,10 @@ Paired peaks of exactly the same frequency will be on the same point (labeled A1
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. 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. 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 are using this tool to check or adjust the tension after installing new belts, you will need to measure again after a few hours of printing, as the tension can change slightly as the belts stretch and settle to their final tension. Usually 24 hours should be sufficient.
## Advanced explanation on why 1 or 2 peaks ## Advanced explanation on why 1 or 2 peaks

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,7 @@
GitPython==3.1.40 GitPython==3.1.40
matplotlib==3.8.2 matplotlib==3.8.2 ; python_version >= '3.9'
numpy==1.26.2 matplotlib==3.3.4 ; python_version < '3.9'
scipy==1.11.4 numpy==1.26.2 ; python_version >= '3.9'
numpy==1.19.5 ; python_version < '3.9'
scipy==1.11.4 ; python_version >= '3.9'
scipy==1.7.3 ; python_version < '3.9'

18
shaketune/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env python3
############################################
###### INPUT SHAPER KLIPPAIN WORKFLOW ######
############################################
# Written by Frix_x#0161 #
# This module functions as a plugin within Klipper, aimed at enhancing printer diagnostics. It serves multiple purposes:
# 1. Diagnosing and pinpointing vibration sources in the printer.
# 2. Conducting standard axis input shaper tests on the XY axes to determine the optimal input shaper filter.
# 3. Executing a specialized half-axis test for CoreXY printers to analyze and compare the frequency profiles of individual belts.
from .shaketune import ShakeTune as ShakeTune
def load_config(config) -> ShakeTune:
return ShakeTune(config)

View File

@@ -10,9 +10,18 @@ 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'},
]
def parse_log(logname): def parse_log(logname):
try: try:
@@ -23,7 +32,7 @@ def parse_log(logname):
# Check for a PSD file generated by Klipper and raise a warning # Check for a PSD file generated by Klipper and raise a warning
if cleaned_line.startswith('#freq,psd_x,psd_y,psd_z,psd_xyz'): if cleaned_line.startswith('#freq,psd_x,psd_y,psd_z,psd_xyz'):
print( ConsoleOutput.print(
'Warning: %s does not contain raw accelerometer data. ' 'Warning: %s does not contain raw accelerometer data. '
'Please use the official Klipper script to process it instead. ' 'Please use the official Klipper script to process it instead. '
'It will be ignored by Shake&Tune!' % (logname,) 'It will be ignored by Shake&Tune!' % (logname,)
@@ -36,7 +45,7 @@ def parse_log(logname):
break break
if not header: if not header:
print( ConsoleOutput.print(
'Warning: file %s has an incorrect header and will be ignored by Shake&Tune!\n' 'Warning: file %s has an incorrect header and will be ignored by Shake&Tune!\n'
"Expected '#time,accel_x,accel_y,accel_z', but got '%s'." % (logname, header.strip()) "Expected '#time,accel_x,accel_y,accel_z', but got '%s'." % (logname, header.strip())
) )
@@ -45,7 +54,7 @@ def parse_log(logname):
# If we have the correct raw data header, proceed to load the data # If we have the correct raw data header, proceed to load the data
data = np.loadtxt(logname, comments='#', delimiter=',', skiprows=1) data = np.loadtxt(logname, comments='#', delimiter=',', skiprows=1)
if data.ndim == 1 or data.shape[1] != 4: if data.ndim == 1 or data.shape[1] != 4:
print( ConsoleOutput.print(
'Warning: %s does not have the correct data format; expected 4 columns. ' 'Warning: %s does not have the correct data format; expected 4 columns. '
'It will be ignored by Shake&Tune!' % (logname,) 'It will be ignored by Shake&Tune!' % (logname,)
) )
@@ -54,7 +63,7 @@ def parse_log(logname):
return data return data
except Exception as err: except Exception as err:
print(f'Error while reading {logname}: {err}. It will be ignored by Shake&Tune!') ConsoleOutput.print(f'Error while reading {logname}: {err}. It will be ignored by Shake&Tune!')
return None return None
@@ -69,6 +78,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)

View File

@@ -0,0 +1,24 @@
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,7 @@
#!/usr/bin/env python3
from .axes_input_shaper import axes_shaper_calibration as axes_shaper_calibration
from .axes_map import axes_map_calibration as axes_map_calibration
from .belts_comparison import compare_belts_responses as compare_belts_responses
from .static_freq import excitate_axis_at_freq as excitate_axis_at_freq
from .vibrations_profile import create_vibrations_profile as create_vibrations_profile

View File

@@ -0,0 +1,60 @@
#!/usr/bin/env python3
# This file provides a custom and internal Shake&Tune Accelerometer helper that is
# an interface to Klipper's own accelerometer classes. It is used to start and
# stop accelerometer measurements and write the data to a file in a blocking manner.
import time
# from ..helpers.console_output import ConsoleOutput
class Accelerometer:
def __init__(self, klipper_accelerometer):
self._k_accelerometer = klipper_accelerometer
self._bg_client = None
@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()
# ConsoleOutput.print('Accelerometer measurements started')
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._write_to_file(bg_client, filename)
# ConsoleOutput.print(f'Accelerometer measurements stopped. Data written to {filename}')
def _write_to_file(self, bg_client, filename):
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('%.6f,%.6f,%.6f,%.6f\n' % (t, accel_x, accel_y, accel_z))

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from ..shaketune_thread import ShakeTuneThread
from .accelerometer import Accelerometer
from .resonance_test import vibrate_axis
def axes_shaper_calibration(gcmd, config, st_thread: ShakeTuneThread) -> None:
min_freq = gcmd.get_float('FREQ_START', default=5, minval=1)
max_freq = gcmd.get_float('FREQ_END', default=133.33, 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']:
gcmd.error('AXIS selection invalid. Should be either x, y, or all!')
scv = gcmd.get_float('SCV', default=None, minval=0)
max_sm = gcmd.get_float('MAX_SMOOTHING', default=None, minval=0)
feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0)
z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1)
printer = config.get_printer()
gcode = printer.lookup_object('gcode')
toolhead = printer.lookup_object('toolhead')
res_tester = printer.lookup_object('resonance_tester')
systime = printer.get_reactor().monotonic()
if scv is None:
toolhead_info = toolhead.get_status(systime)
scv = toolhead_info['square_corner_velocity']
if accel_per_hz is None:
accel_per_hz = res_tester.test.accel_per_hz
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:
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:
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)
# Configure the graph creator
creator = st_thread.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']
old_mcr = toolhead_info['minimum_cruise_ratio']
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=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
# 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:
gcmd.error(
'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.'
)
accelerometer = Accelerometer(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)
# 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_thread.run()
# Re-enable the input shaper if it was active
if input_shaper is not None:
input_shaper.enable_shaping()
# Restore the previous acceleration values
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr}')

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env python3
from ..helpers.console_output import ConsoleOutput
from ..shaketune_thread import ShakeTuneThread
from .accelerometer import Accelerometer
def axes_map_calibration(gcmd, config, st_thread: ShakeTuneThread) -> 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)
accel_chip = gcmd.get('ACCEL_CHIP', default=None)
printer = config.get_printer()
gcode = printer.lookup_object('gcode')
toolhead = printer.lookup_object('toolhead')
systime = printer.get_reactor().monotonic()
if accel_chip is None:
accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy')
if accel_chip is None:
gcmd.error(
'No accelerometer specified for measurement! Multi-accelerometer configurations are not supported for this macro.'
)
accelerometer = Accelerometer(printer.lookup_object(accel_chip))
toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel']
old_mcr = toolhead_info['minimum_cruise_ratio']
old_sqv = toolhead_info['square_corner_velocity']
# set the wanted acceleration values
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY=5.0')
# 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 - 15, mid_y - 15, 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(1)
toolhead.move([mid_x + 15, mid_y - 15, z_height, E], speed)
toolhead.dwell(1)
toolhead.move([mid_x + 15, mid_y + 15, z_height, E], speed)
toolhead.dwell(1)
toolhead.move([mid_x + 15, mid_y + 15, z_height + 15, E], speed)
toolhead.dwell(1)
accelerometer.stop_measurement('axemap')
# Re-enable the input shaper if it was active
if input_shaper is not None:
input_shaper.enable_shaping()
# Restore the previous acceleration values
gcode.run_script_from_command(
f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr} SQUARE_CORNER_VELOCITY={old_sqv}'
)
toolhead.wait_moves()
# Run post-processing
ConsoleOutput.print('Analysis of the movements...')
creator = st_thread.get_graph_creator()
creator.configure(accel)
st_thread.run()

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env python3
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from ..shaketune_thread import ShakeTuneThread
from .accelerometer import Accelerometer
from .motorsconfigparser import MotorsConfigParser
from .resonance_test import vibrate_axis
def compare_belts_responses(gcmd, config, st_thread: ShakeTuneThread) -> None:
min_freq = gcmd.get_float('FREQ_START', default=5.0, minval=1)
max_freq = gcmd.get_float('FREQ_END', default=133.33, minval=1)
hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1.0, 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)
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()
accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy')
if accel_chip is None:
gcmd.error(
'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.'
)
accelerometer = Accelerometer(printer.lookup_object(accel_chip))
if accel_per_hz is None:
accel_per_hz = res_tester.test.accel_per_hz
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:
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:
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)
# Configure the graph creator
motors_config_parser = MotorsConfigParser(config, motors=None)
creator = st_thread.get_graph_creator()
creator.configure(motors_config_parser.kinematics, accel_per_hz)
# set the needed acceleration values for the test
toolhead_info = toolhead.get_status(systime)
old_accel = toolhead_info['max_accel']
old_mcr = toolhead_info['minimum_cruise_ratio']
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=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
# Filter axis configurations to get the A and B axis only
filtered_config = [a for a in AXIS_CONFIG if a['axis'] in ('a', 'b')]
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)
# Re-enable the input shaper if it was active
if input_shaper is not None:
input_shaper.enable_shaping()
# Restore the previous acceleration values
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr}')
# Run post-processing
ConsoleOutput.print('Belts comparative frequency profile generation...')
ConsoleOutput.print('This may take some time (3-5min)')
st_thread.run()

View File

@@ -0,0 +1,8 @@
# [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

View File

@@ -0,0 +1,189 @@
#!/usr/bin/env python3
# Classes to retrieve a couple of motors infos and extract the relevant information
# from the Klipper configuration and the TMC registers
# Written by Frix_x#0161 #
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']
if not differences:
return None
return 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)
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]:
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

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env python3
# The logic in this file was "extracted" from Klipper's orignal resonance_tester.py file
# Courtesy of Dmitry Butyugin <dmbutyugin@google.com> for the original implementation
# This derive a bit from Klipper's implementation as there are two main changes:
# 1. Original code doesn't use euclidean distance for the moves calculation with projection. 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 movement that was added here for later use
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() # Get current 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()

View File

@@ -0,0 +1,57 @@
#!/usr/bin/env python3
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from .resonance_test import vibrate_axis
def excitate_axis_at_freq(gcmd, config) -> None:
freq = gcmd.get_int('FREQUENCY', default=25, minval=1)
duration = gcmd.get_int('DURATION', default=10, 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)
axis_config = next((item for item in AXIS_CONFIG if item['axis'] == axis), None)
if axis_config is None:
gcmd.error('AXIS selection invalid. Should be either x, y, a or b!')
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:
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:
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)
min_freq = freq - 1
max_freq = freq + 1
hz_per_sec = 1 / (duration / 3)
vibrate_axis(toolhead, gcode, axis_config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz)

View File

@@ -0,0 +1,137 @@
#!/usr/bin/env python3
import math
from ..helpers.console_output import ConsoleOutput
from ..shaketune_thread import ShakeTuneThread
from .accelerometer import Accelerometer
from .motorsconfigparser import MotorsConfigParser
MIN_SPEED = 2 # mm/s
def create_vibrations_profile(gcmd, config, st_thread: ShakeTuneThread) -> 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 (size / (max_speed / 60)) < 0.25:
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:
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 == 'cartesian' or motors_config_parser.kinematics == 'corexz':
# Cartesian motors are on X and Y axis directly, same for CoreXZ
main_angles = [0, 90]
elif motors_config_parser.kinematics == 'corexy':
# CoreXY motors are on A and B axis (45 and 135 degrees)
main_angles = [45, 135]
else:
gcmd.error(
'Only Cartesian and CoreXY 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_mcr = toolhead_info['minimum_cruise_ratio']
old_sqv = toolhead_info['square_corner_velocity']
# set the wanted acceleration values
gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY=5.0')
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)
# Find the best accelerometer chip for the current angle if not specified
if curr_angle == 0:
accel_axis = 'x'
elif curr_angle == 90:
accel_axis = 'y'
else:
accel_axis = 'xy'
if accel_chip is None:
accel_chip = Accelerometer.find_axis_accelerometer(printer, accel_axis)
if accel_chip is None:
gcmd.error(
'No accelerometer specified for measurement! Multi-accelerometer configurations are not supported for this macro.'
)
accelerometer = Accelerometer(printer.lookup_object(accel_chip))
# 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()
# Restore the previous acceleration values
gcode.run_script_from_command(
f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr} 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_thread.get_graph_creator()
creator.configure(motors_config_parser.kinematics, accel, motors_config_parser)
st_thread.run()

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python3
from .graph_creator import AxesMapFinder as AxesMapFinder
from .graph_creator import BeltsGraphCreator as BeltsGraphCreator
from .graph_creator import GraphCreator as GraphCreator
from .graph_creator import ShaperGraphCreator as ShaperGraphCreator
from .graph_creator import VibrationsGraphCreator as VibrationsGraphCreator

View File

@@ -10,7 +10,7 @@ import optparse
import numpy as np import numpy as np
from scipy.signal import butter, filtfilt from scipy.signal import butter, filtfilt
from ..helpers.locale_utils import print_with_c_locale from ..helpers.console_output import ConsoleOutput
NUM_POINTS = 500 NUM_POINTS = 500
@@ -109,7 +109,8 @@ def axesmap_calibration(lognames, accel=None):
axes_map = ','.join([f'{spike[0][0]}{spike[1]}' for spike in spikes_sorted]) 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) # alignment_error, sensitivity_error = compute_errors(filtered_data, spikes_sorted, accel, NUM_POINTS)
results = f'Detected axes_map:\n {axes_map}\n' results = f'Be aware that this macro is experimental and has been known to sometimes produce incorrect results. Use it with caution and always check the results!\n'
results += f'Detected axes_map:\n {axes_map}\n'
# TODO: work on this function that is currently not giving good results... # TODO: work on this function that is currently not giving good results...
# results += "Accelerometer angle deviation:\n" # results += "Accelerometer angle deviation:\n"
@@ -143,7 +144,7 @@ def main():
opts.error('Invalid acceleration value. It should be a numeric value.') opts.error('Invalid acceleration value. It should be a numeric value.')
results = axesmap_calibration(args, accel_value) results = axesmap_calibration(args, accel_value)
print_with_c_locale(results) ConsoleOutput.print(results)
if options.output is not None: if options.output is not None:
with open(options.output, 'w') as f: with open(options.output, 'w') as f:

View File

@@ -20,7 +20,7 @@ import numpy as np
matplotlib.use('Agg') matplotlib.use('Agg')
from ..helpers.common_func import detect_peaks, parse_log, setup_klipper_import from ..helpers.common_func import detect_peaks, parse_log, setup_klipper_import
from ..helpers.locale_utils import print_with_c_locale, set_locale from ..helpers.console_output import ConsoleOutput
ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' # For paired peaks names ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' # For paired peaks names
@@ -423,8 +423,9 @@ def compute_signal_data(data, max_freq):
###################################################################### ######################################################################
def belts_calibration(lognames, kinematics, klipperdir='~/klipper', max_freq=200.0, st_version=None): def belts_calibration(
set_locale() lognames, kinematics, klipperdir='~/klipper', max_freq=200.0, accel_per_hz=None, st_version='unknown'
):
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
@@ -461,11 +462,11 @@ def belts_calibration(lognames, kinematics, klipperdir='~/klipper', max_freq=200
ss_res = np.sum((interp_psd2 - interp_psd1) ** 2) ss_res = np.sum((interp_psd2 - interp_psd1) ** 2)
ss_tot = np.sum((interp_psd2 - np.mean(interp_psd2)) ** 2) ss_tot = np.sum((interp_psd2 - np.mean(interp_psd2)) ** 2)
similarity_factor = (1 - (ss_res / ss_tot)) * 100 similarity_factor = (1 - (ss_res / ss_tot)) * 100
print_with_c_locale(f'Belts estimated similarity: {similarity_factor:.1f}%') ConsoleOutput.print(f'Belts estimated similarity: {similarity_factor:.1f}%')
# mhi = compute_mhi(similarity_factor, num_peaks, num_unpaired_peaks) # mhi = compute_mhi(similarity_factor, num_peaks, num_unpaired_peaks)
mhi = compute_mhi(similarity_factor, signal1, signal2) mhi = compute_mhi(similarity_factor, signal1, signal2)
print_with_c_locale(f'[experimental] Mechanical health: {mhi}') ConsoleOutput.print(f'[experimental] Mechanical health: {mhi}')
fig, ((ax1, ax3)) = plt.subplots( fig, ((ax1, ax3)) = plt.subplots(
1, 1,
@@ -494,7 +495,7 @@ def belts_calibration(lognames, kinematics, klipperdir='~/klipper', max_freq=200
if kinematics is not None: if kinematics is not None:
title_line2 += ' -- ' + kinematics.upper() + ' kinematics' title_line2 += ' -- ' + kinematics.upper() + ' kinematics'
except Exception: except Exception:
print_with_c_locale( ConsoleOutput.print(
'Warning: CSV filenames look to be different than expected (%s , %s)' % (lognames[0], lognames[1]) '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] title_line2 = lognames[0].split('/')[-1] + ' / ' + lognames[1].split('/')[-1]
@@ -505,8 +506,12 @@ def belts_calibration(lognames, kinematics, klipperdir='~/klipper', max_freq=200
if kinematics == 'corexy': if kinematics == 'corexy':
title_line3 = f'| Estimated similarity: {similarity_factor:.1f}%' title_line3 = f'| Estimated similarity: {similarity_factor:.1f}%'
title_line4 = f'| {mhi} (experimental)' title_line4 = f'| {mhi} (experimental)'
fig.text(0.55, 0.980, title_line3, ha='left', va='top', fontsize=14, 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.945, title_line4, 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 the graphs
plot_compare_frequency(ax1, signal1, signal2, signal1_belt, signal2_belt, max_freq) plot_compare_frequency(ax1, signal1, signal2, signal1_belt, signal2_belt, max_freq)
@@ -530,6 +535,7 @@ def main():
opts = optparse.OptionParser(usage) opts = optparse.OptionParser(usage)
opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph') 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('-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( 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'
) )
@@ -546,7 +552,9 @@ def main():
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)')
fig = belts_calibration(args, options.kinematics, options.klipperdir, options.max_freq) fig = belts_calibration(
args, options.kinematics, options.klipperdir, 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,279 @@
#!/usr/bin/env python3
import abc
import re
import shutil
import tarfile
from datetime import datetime
from pathlib import Path
from typing import Callable, Optional
from matplotlib.figure import Figure
from ..helpers import filemanager as fm
from ..helpers.console_output import ConsoleOutput
from ..measurement.motorsconfigparser import MotorsConfigParser
from ..shaketune_config import ShakeTuneConfig
from .analyze_axesmap import axesmap_calibration
from .graph_belts import belts_calibration
from .graph_shaper import shaper_calibration
from .graph_vibrations import vibrations_profile
class GraphCreator(abc.ABC):
def __init__(self, config: ShakeTuneConfig):
self._config = config
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
self._version = ShakeTuneConfig.get_git_version()
self._type = None
self._folder = None
def _setup_folder(self, graph_type: str) -> None:
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]:
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'
# 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}_{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, _: 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
class BeltsGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config)
self._kinematics = None
self._accel_per_hz = None
self._setup_folder('belts')
def configure(self, kinematics: str = None, accel_per_hz: 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],
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:
# 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, config: ShakeTuneConfig):
super().__init__(config)
self._max_smoothing = None
self._scv = None
self._setup_folder('shaper')
def configure(self, scv: float, max_smoothing: float = None, accel_per_hz: 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:
# 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, config: ShakeTuneConfig):
super().__init__(config)
self._kinematics = None
self._accel = None
self._motors = None
self._setup_folder('vibrations')
def configure(self, kinematics: str, accel: float, motor_config_parser: MotorsConfigParser) -> None:
self._kinematics = kinematics
self._accel = accel
self._motors = 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, chip_name 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:
# 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(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config)
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
self._type = 'axesmap'
self._folder = config.get_results_folder()
self._accel = None
def configure(self, accel: int) -> None:
self._accel = accel
def find_axesmap(self) -> None:
tmp_folder = Path('/tmp')
globbed_files = list(tmp_folder.glob('shaketune-axemap_*.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 process it
logname = sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[0]
results = axesmap_calibration(
lognames=[str(logname)],
accel=self._accel,
)
ConsoleOutput.print(results)
result_filename = self._folder / f'{self._type}_{self._graph_date}.txt'
with result_filename.open('w') as f:
f.write(results)
# While the AxesMapFinder doesn't directly create a graph, we need to implement this
# method to allow using it seemlessly like all the other GraphCreator objects
def create_graph(self) -> None:
self.find_axesmap()
def clean_old_files(self, keep_results: int) -> None:
tmp_folder = Path('/tmp')
globbed_files = list(tmp_folder.glob('shaketune-axemap_*.csv'))
for csv_file in globbed_files:
csv_file.unlink()

View File

@@ -27,7 +27,7 @@ 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
PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_EFFECT_THRESHOLD = 0.12 PEAKS_EFFECT_THRESHOLD = 0.12
@@ -72,19 +72,19 @@ 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)' '\n-> Recommended shaper is %s @ %.1f Hz (when using a square corner velocity of %.1f and a damping ratio of %.3f)'
% (shaper.name.upper(), shaper.freq, scv, zeta) % (shaper.name.upper(), shaper.freq, scv, zeta)
) )
@@ -294,15 +294,22 @@ 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,
klipperdir='~/klipper',
max_smoothing=None,
scv=5.0,
max_freq=200.0,
accel_per_hz=None,
st_version='unknown',
):
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
# Parse data from the log files while ignoring CSV in the wrong format # 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] datas = [data for data in (parse_log(fn) for fn in lognames) if data is not None]
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( performance_shaper, shapers, calibration_data, fr, zeta, compat = calibrate_shaper(
@@ -329,7 +336,7 @@ 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)' '\nPeaks detected on the graph: %d @ %s Hz (%d above effect threshold)'
% (num_peaks, ', '.join(map(str, peak_freqs_formated)), num_peaks_above_effect_threshold) % (num_peaks, ', '.join(map(str, peak_freqs_formated)), num_peaks_above_effect_threshold)
) )
@@ -360,19 +367,23 @@ 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('Warning: CSV filename look to be different than expected (%s)' % (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.965, 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.951, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'])
fig.text(0.58, 0.919, 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(
@@ -402,6 +413,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 +425,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

@@ -28,7 +28,7 @@ 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
PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04 PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04
@@ -453,19 +453,19 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro
# Then add the motor resonance peak to the graph and print some infos about it # 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) motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(global_motor_profile, freqs, 30)
if lowfreq_max: if lowfreq_max:
print_with_c_locale( ConsoleOutput.print(
'[WARNING] There are a lot of low frequency vibrations that can alter the readings. This is probably due to the test being performed at too high an acceleration!' '[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( ConsoleOutput.print(
'Try lowering the ACCEL value and/or increasing the SIZE value before restarting the macro to ensure that only constant speeds are being recorded and that the dynamic behavior of the machine is not affecting the measurements' '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: if motor_zeta is not None:
print_with_c_locale( ConsoleOutput.print(
'Motors have a main resonant frequency at %.1fHz with an estimated damping ratio of %.3f' 'Motors have a main resonant frequency at %.1fHz with an estimated damping ratio of %.3f'
% (motor_fr, motor_zeta) % (motor_fr, motor_zeta)
) )
else: else:
print_with_c_locale( ConsoleOutput.print(
'Motors have a main resonant frequency at %.1fHz but it was impossible to estimate a damping ratio.' 'Motors have a main resonant frequency at %.1fHz but it was impossible to estimate a damping ratio.'
% (motor_fr) % (motor_fr)
) )
@@ -564,23 +564,23 @@ def plot_motor_config_txt(fig, motors, differences):
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.12
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('| TMC Autotune enabled')
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 +589,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 +601,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',
@@ -634,7 +634,6 @@ 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, klipperdir='~/klipper', kinematics='cartesian', accel=None, max_freq=1000.0, st_version=None, motors=None
): ):
set_locale()
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
@@ -686,7 +685,7 @@ def vibrations_profile(
# 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
@@ -699,7 +698,7 @@ 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)' 'Vibrations peaks detected: %d @ %s mm/s (avoid setting a speed near these values in your slicer print profile)'
% (num_peaks, ', '.join(map(str, formated_peaks_speeds))) % (num_peaks, ', '.join(map(str, formated_peaks_speeds)))
) )
@@ -713,16 +712,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)'
) )
@@ -763,7 +762,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('Warning: CSV filenames appear to be different than expected (%s)' % (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'])
@@ -772,7 +771,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)

View File

Before

Width:  |  Height:  |  Size: 607 KiB

After

Width:  |  Height:  |  Size: 607 KiB

106
shaketune/shaketune.py Normal file
View File

@@ -0,0 +1,106 @@
#!/usr/bin/env python3
from pathlib import Path
from .helpers.console_output import ConsoleOutput
from .measurement import (
axes_map_calibration,
axes_shaper_calibration,
compare_belts_responses,
create_vibrations_profile,
excitate_axis_at_freq,
)
from .post_processing import AxesMapFinder, BeltsGraphCreator, ShaperGraphCreator, VibrationsGraphCreator
from .shaketune_config import ShakeTuneConfig
from .shaketune_thread import ShakeTuneThread
class ShakeTune:
def __init__(self, config) -> None:
self._pconfig = config
self._printer = config.get_printer()
gcode = self._printer.lookup_object('gcode')
res_tester = self._printer.lookup_object('resonance_tester')
if res_tester is None:
config.error('No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune')
self.timeout = config.getfloat('timeout', 2.0, above=0.0)
result_folder = config.get('result_folder', default='~/printer_data/config/K-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._config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi)
ConsoleOutput.register_output_callback(gcode.respond_info)
gcode.register_command(
'EXCITATE_AXIS_AT_FREQ',
self.cmd_EXCITATE_AXIS_AT_FREQ,
desc=self.cmd_EXCITATE_AXIS_AT_FREQ_help,
)
gcode.register_command(
'AXES_MAP_CALIBRATION',
self.cmd_AXES_MAP_CALIBRATION,
desc=self.cmd_AXES_MAP_CALIBRATION_help,
)
gcode.register_command(
'COMPARE_BELTS_RESPONSES',
self.cmd_COMPARE_BELTS_RESPONSES,
desc=self.cmd_COMPARE_BELTS_RESPONSES_help,
)
gcode.register_command(
'AXES_SHAPER_CALIBRATION',
self.cmd_AXES_SHAPER_CALIBRATION,
desc=self.cmd_AXES_SHAPER_CALIBRATION_help,
)
gcode.register_command(
'CREATE_VIBRATIONS_PROFILE',
self.cmd_CREATE_VIBRATIONS_PROFILE,
desc=self.cmd_CREATE_VIBRATIONS_PROFILE_help,
)
cmd_EXCITATE_AXIS_AT_FREQ_help = (
'Maintain a specified excitation frequency for a period of time to diagnose and locate a source of vibration'
)
def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
excitate_axis_at_freq(gcmd, self._pconfig)
cmd_AXES_MAP_CALIBRATION_help = 'Perform a set of movements to measure the orientation of the accelerometer and help you set the best axes_map configuration for your printer'
def cmd_AXES_MAP_CALIBRATION(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
axes_map_finder = AxesMapFinder(self._config)
st_thread = ShakeTuneThread(self._config, axes_map_finder, self._printer.get_reactor(), self.timeout)
axes_map_calibration(gcmd, self._pconfig, st_thread)
cmd_COMPARE_BELTS_RESPONSES_help = 'Perform a custom half-axis test to analyze and compare the frequency profiles of individual belts on CoreXY printers'
def cmd_COMPARE_BELTS_RESPONSES(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
belt_graph_creator = BeltsGraphCreator(self._config)
st_thread = ShakeTuneThread(self._config, belt_graph_creator, self._printer.get_reactor(), self.timeout)
compare_belts_responses(gcmd, self._pconfig, st_thread)
cmd_AXES_SHAPER_CALIBRATION_help = (
'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter'
)
def cmd_AXES_SHAPER_CALIBRATION(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
shaper_graph_creator = ShaperGraphCreator(self._config)
st_thread = ShakeTuneThread(self._config, shaper_graph_creator, self._printer.get_reactor(), self.timeout)
axes_shaper_calibration(gcmd, self._pconfig, st_thread)
cmd_CREATE_VIBRATIONS_PROFILE_help = 'Perform a set of movements to measure the orientation of the accelerometer and help you set the best axes_map configuration for your printer'
def cmd_CREATE_VIBRATIONS_PROFILE(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
vibration_profile_creator = VibrationsGraphCreator(self._config)
st_thread = ShakeTuneThread(self._config, vibration_profile_creator, self._printer.get_reactor(), self.timeout)
create_vibrations_profile(gcmd, self._pconfig, st_thread)

View File

@@ -0,0 +1,53 @@
#!/usr/bin/env python3
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 = {'belts': 'belts', 'shaper': 'inputshaper', 'vibrations': 'vibrations'}
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,66 @@
#!/usr/bin/env python3
import os
import threading
import traceback
from .helpers import filemanager as fm
from .helpers.console_output import ConsoleOutput
from .shaketune_config import ShakeTuneConfig
class ShakeTuneThread(threading.Thread):
def __init__(self, config: ShakeTuneConfig, graph_creator, reactor, timeout: float):
super(ShakeTuneThread, self).__init__()
self._config = config
self.graph_creator = graph_creator
self._reactor = reactor
self._timeout = timeout
def get_graph_creator(self):
return self.graph_creator
def run(self) -> None:
# Start the target function in a new thread
internal_thread = threading.Thread(target=self._shaketune_thread, args=(self.graph_creator,))
internal_thread.start()
# Monitor the thread execution and stop it if it takes too long
event_time = self._reactor.monotonic()
end_time = event_time + self._timeout
while event_time < end_time:
event_time = self._reactor.pause(event_time + 0.05)
if not internal_thread.is_alive():
break
# This function run in its own thread is used to do the CSV analysis and create the graphs
def _shaketune_thread(self, graph_creator) -> None:
# Trying to reduce the Shake&Tune prost-processing thread priority to avoid slowing down the main Klipper process
# as this could lead to random "Timer" errors when already running CANbus, etc...
try:
os.nice(20)
except Exception:
ConsoleOutput.print('Warning: failed reducing Shake&Tune thread priority, continuing...')
fm.ensure_folders_exist(self._config.get_results_subfolders())
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)
if graph_creator.get_type() != 'axesmap':
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,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,432 +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 shutil
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'
# shutil.move() is needed to move the file across filesystems (mainly for BTT CB1 Pi default OS image)
shutil.move(filename, 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._kinematics = None
self._setup_folder('belts')
def configure(self, kinematics: str) -> None:
self._kinematics = kinematics
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],
kinematics=self._kinematics,
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, lambda gc: gc.configure(options.kinematics)),
'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()