Merge branch 'develop' into cross-belts
This commit is contained in:
@@ -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
|
||||
@@ -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 %}
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -1,6 +0,0 @@
|
||||
[gcode_shell_command shaketune]
|
||||
command: ~/printer_data/config/K-ShakeTune/shaketune.sh
|
||||
timeout: 600.0
|
||||
verbose: True
|
||||
|
||||
[respond]
|
||||
10
README.md
10
README.md
@@ -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.
|
||||
|
||||
@@ -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) |
|
||||
|
||||
> **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
|
||||
|
||||
@@ -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):
|
||||
```
|
||||
[include K-ShakeTune/*.cfg]
|
||||
[shaketune]
|
||||
# result_folder: ~/printer_data/config/K-ShakeTune_results
|
||||
# number_of_results_to_keep: 3
|
||||
# keep_raw_csv: False
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
||||
@@ -1,12 +1,70 @@
|
||||
# Klippain Shake&Tune module documentation
|
||||
# Klipper Shake&Tune plugin documentation
|
||||
|
||||

|
||||
|
||||
|
||||
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
|
||||
|
||||
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) |
|
||||
|:----------------:|:------------:|:---------------------:|
|
||||
@@ -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|
|
||||
|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|
|
||||
|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`.
|
||||
|
||||
@@ -50,8 +108,11 @@ Here are the parameters available when calling this macro:
|
||||
| parameters | default value | description |
|
||||
|-----------:|---------------|-------------|
|
||||
|FREQUENCY|25|excitation frequency (in Hz) that you want to maintain. Usually, it's the frequency of a peak on one of the graphs|
|
||||
|TIME|10|time in second to maintain this excitation|
|
||||
|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"|
|
||||
|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
|
||||
|
||||
@@ -11,14 +11,15 @@ Then, call the `AXES_SHAPER_CALIBRATION` macro and look for the graphs in the re
|
||||
|
||||
| parameters | default value | description |
|
||||
|-----------:|---------------|-------------|
|
||||
|FREQ_START|5|Starting excitation frequency|
|
||||
|FREQ_END|133|Maximum excitation frequency|
|
||||
|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"|
|
||||
|SCV|printer square corner velocity|Square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations|
|
||||
|MAX_SMOOTHING|None|Max smoothing allowed when calculating shaper recommendations|
|
||||
|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|
|
||||
|KEEP_CSV|0|Weither or not to keep the CSV data file alonside the PNG graphs|
|
||||
|FREQ_START|5|starting excitation frequency|
|
||||
|FREQ_END|133|maximum excitation frequency|
|
||||
|HZ_PER_SEC|1|number of Hz per seconds for the test|
|
||||
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)|
|
||||
|AXIS|"all"|axis you want to test in the list of "all", "X" or "Y"|
|
||||
|SCV|printer square corner velocity|square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations|
|
||||
|MAX_SMOOTHING|None|max smoothing allowed when calculating shaper recommendations|
|
||||
|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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
@@ -11,11 +11,12 @@ Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the re
|
||||
|
||||
| parameters | default value | description |
|
||||
|-----------:|---------------|-------------|
|
||||
|FREQ_START|5|Starting excitation frequency|
|
||||
|FREQ_END|133|Maximum excitation frequency|
|
||||
|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|
|
||||
|KEEP_CSV|0|Weither or not to keep the CSV data files alonside the PNG graphs|
|
||||
|FREQ_START|5|starting excitation frequency|
|
||||
|FREQ_END|133|maximum excitation frequency|
|
||||
|HZ_PER_SEC|1|number of Hz per seconds for the test|
|
||||
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)|
|
||||
|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
|
||||
@@ -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.
|
||||
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
|
||||
|
||||
|
||||
45
install.sh
45
install.sh
@@ -3,9 +3,10 @@
|
||||
USER_CONFIG_PATH="${HOME}/printer_data/config"
|
||||
MOONRAKER_CONFIG="${HOME}/printer_data/config/moonraker.conf"
|
||||
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_VENV_PATH="${HOME}/klippain_shaketune-env"
|
||||
|
||||
set -eu
|
||||
export LC_ALL=C
|
||||
@@ -39,7 +40,7 @@ function is_package_installed {
|
||||
}
|
||||
|
||||
function install_package_requirements {
|
||||
packages=("python3-venv" "libopenblas-dev" "libatlas-base-dev")
|
||||
packages=("libopenblas-dev" "libatlas-base-dev")
|
||||
packages_to_install=""
|
||||
|
||||
for package in "${packages[@]}"; do
|
||||
@@ -76,14 +77,17 @@ function check_download {
|
||||
}
|
||||
|
||||
function setup_venv {
|
||||
if [ ! -d "${K_SHAKETUNE_VENV_PATH}" ]; then
|
||||
echo "[SETUP] Creating Python virtual environment..."
|
||||
python3 -m venv "${K_SHAKETUNE_VENV_PATH}"
|
||||
else
|
||||
echo "[SETUP] Virtual environment already exists. Continuing..."
|
||||
if [ ! -d "${KLIPPER_VENV_PATH}" ]; then
|
||||
echo "[ERROR] Klipper's Python virtual environment not found!"
|
||||
exit -1
|
||||
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..."
|
||||
pip install --upgrade pip
|
||||
pip install -r "${K_SHAKETUNE_PATH}/requirements.txt"
|
||||
@@ -92,22 +96,27 @@ function setup_venv {
|
||||
}
|
||||
|
||||
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
|
||||
echo "[INSTALL] Klippain full installation found! Linking module to the script folder of Klippain"
|
||||
ln -frsn ${K_SHAKETUNE_PATH}/K-ShakeTune ${USER_CONFIG_PATH}/scripts/K-ShakeTune
|
||||
if [ -d "${USER_CONFIG_PATH}/scripts/K-ShakeTune" ]; then
|
||||
echo "[INFO] Old K-Shake&Tune macro folder found, cleaning it!"
|
||||
rm -d "${USER_CONFIG_PATH}/scripts/K-ShakeTune"
|
||||
fi
|
||||
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
|
||||
}
|
||||
|
||||
function link_gcodeshellcommandpy {
|
||||
if [ ! -f "${KLIPPER_PATH}/klippy/extras/gcode_shell_command.py" ]; then
|
||||
echo "[INSTALL] Downloading gcode_shell_command.py Klipper extension needed for this module"
|
||||
wget -P ${KLIPPER_PATH}/klippy/extras https://raw.githubusercontent.com/Frix-x/klippain/main/scripts/gcode_shell_command.py
|
||||
function link_module {
|
||||
if [ ! -d "${KLIPPER_PATH}/klippy/extras/shaketune" ]; then
|
||||
echo "[INSTALL] Linking Shake&Tune module to Klipper extras"
|
||||
ln -frsn ${K_SHAKETUNE_PATH}/shaketune ${KLIPPER_PATH}/klippy/extras/shaketune
|
||||
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
|
||||
}
|
||||
|
||||
@@ -140,7 +149,7 @@ preflight_checks
|
||||
check_download
|
||||
setup_venv
|
||||
link_extension
|
||||
link_module
|
||||
add_updater
|
||||
link_gcodeshellcommandpy
|
||||
restart_klipper
|
||||
restart_moonraker
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
type: git_repo
|
||||
origin: https://github.com/Frix-x/klippain-shaketune.git
|
||||
path: ~/klippain_shaketune
|
||||
virtualenv: ~/klippain_shaketune-env
|
||||
virtualenv: ~/klippy-env
|
||||
requirements: requirements.txt
|
||||
system_dependencies: system-dependencies.json
|
||||
primary_branch: main
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
GitPython==3.1.40
|
||||
matplotlib==3.8.2
|
||||
numpy==1.26.2
|
||||
scipy==1.11.4
|
||||
matplotlib==3.8.2 ; python_version >= '3.9'
|
||||
matplotlib==3.3.4 ; python_version < '3.9'
|
||||
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
18
shaketune/__init__.py
Normal 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)
|
||||
@@ -10,9 +10,18 @@ from importlib import import_module
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from git import GitCommandError, Repo
|
||||
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):
|
||||
try:
|
||||
@@ -23,7 +32,7 @@ def parse_log(logname):
|
||||
|
||||
# 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'):
|
||||
print(
|
||||
ConsoleOutput.print(
|
||||
'Warning: %s does not contain raw accelerometer data. '
|
||||
'Please use the official Klipper script to process it instead. '
|
||||
'It will be ignored by Shake&Tune!' % (logname,)
|
||||
@@ -36,7 +45,7 @@ def parse_log(logname):
|
||||
break
|
||||
|
||||
if not header:
|
||||
print(
|
||||
ConsoleOutput.print(
|
||||
'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())
|
||||
)
|
||||
@@ -45,7 +54,7 @@ def parse_log(logname):
|
||||
# If we have the correct raw data header, proceed to load the data
|
||||
data = np.loadtxt(logname, comments='#', delimiter=',', skiprows=1)
|
||||
if data.ndim == 1 or data.shape[1] != 4:
|
||||
print(
|
||||
ConsoleOutput.print(
|
||||
'Warning: %s does not have the correct data format; expected 4 columns. '
|
||||
'It will be ignored by Shake&Tune!' % (logname,)
|
||||
)
|
||||
@@ -54,7 +63,7 @@ def parse_log(logname):
|
||||
return data
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -69,6 +78,8 @@ def get_git_version():
|
||||
try:
|
||||
# Get the absolute path of the script, resolving any symlinks
|
||||
# Then get 2 times to parent dir to be at the git root folder
|
||||
from git import GitCommandError, Repo
|
||||
|
||||
script_path = Path(__file__).resolve()
|
||||
repo_path = script_path.parents[1]
|
||||
repo = Repo(repo_path)
|
||||
24
shaketune/helpers/console_output.py
Normal file
24
shaketune/helpers/console_output.py
Normal 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())
|
||||
7
shaketune/measurement/__init__.py
Normal file
7
shaketune/measurement/__init__.py
Normal 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
|
||||
60
shaketune/measurement/accelerometer.py
Normal file
60
shaketune/measurement/accelerometer.py
Normal 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))
|
||||
106
shaketune/measurement/axes_input_shaper.py
Normal file
106
shaketune/measurement/axes_input_shaper.py
Normal 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}')
|
||||
78
shaketune/measurement/axes_map.py
Normal file
78
shaketune/measurement/axes_map.py
Normal 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()
|
||||
95
shaketune/measurement/belts_comparison.py
Normal file
95
shaketune/measurement/belts_comparison.py
Normal 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()
|
||||
8
shaketune/measurement/macros.cfg
Normal file
8
shaketune/measurement/macros.cfg
Normal 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
|
||||
189
shaketune/measurement/motorsconfigparser.py
Normal file
189
shaketune/measurement/motorsconfigparser.py
Normal 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
|
||||
50
shaketune/measurement/resonance_test.py
Normal file
50
shaketune/measurement/resonance_test.py
Normal 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()
|
||||
57
shaketune/measurement/static_freq.py
Normal file
57
shaketune/measurement/static_freq.py
Normal 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)
|
||||
137
shaketune/measurement/vibrations_profile.py
Normal file
137
shaketune/measurement/vibrations_profile.py
Normal 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()
|
||||
7
shaketune/post_processing/__init__.py
Normal file
7
shaketune/post_processing/__init__.py
Normal 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
|
||||
@@ -10,7 +10,7 @@ import optparse
|
||||
import numpy as np
|
||||
from scipy.signal import butter, filtfilt
|
||||
|
||||
from ..helpers.locale_utils import print_with_c_locale
|
||||
from ..helpers.console_output import ConsoleOutput
|
||||
|
||||
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])
|
||||
# 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...
|
||||
# results += "Accelerometer angle deviation:\n"
|
||||
@@ -143,7 +144,7 @@ def main():
|
||||
opts.error('Invalid acceleration value. It should be a numeric value.')
|
||||
|
||||
results = axesmap_calibration(args, accel_value)
|
||||
print_with_c_locale(results)
|
||||
ConsoleOutput.print(results)
|
||||
|
||||
if options.output is not None:
|
||||
with open(options.output, 'w') as f:
|
||||
@@ -20,7 +20,7 @@ import numpy as np
|
||||
matplotlib.use('Agg')
|
||||
|
||||
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
|
||||
|
||||
@@ -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):
|
||||
set_locale()
|
||||
def belts_calibration(
|
||||
lognames, kinematics, klipperdir='~/klipper', max_freq=200.0, accel_per_hz=None, st_version='unknown'
|
||||
):
|
||||
global shaper_calibrate
|
||||
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_tot = np.sum((interp_psd2 - np.mean(interp_psd2)) ** 2)
|
||||
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, signal1, signal2)
|
||||
print_with_c_locale(f'[experimental] Mechanical health: {mhi}')
|
||||
ConsoleOutput.print(f'[experimental] Mechanical health: {mhi}')
|
||||
|
||||
fig, ((ax1, ax3)) = plt.subplots(
|
||||
1,
|
||||
@@ -494,7 +495,7 @@ def belts_calibration(lognames, kinematics, klipperdir='~/klipper', max_freq=200
|
||||
if kinematics is not None:
|
||||
title_line2 += ' -- ' + kinematics.upper() + ' kinematics'
|
||||
except Exception:
|
||||
print_with_c_locale(
|
||||
ConsoleOutput.print(
|
||||
'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]
|
||||
@@ -505,8 +506,12 @@ def belts_calibration(lognames, kinematics, klipperdir='~/klipper', max_freq=200
|
||||
if kinematics == 'corexy':
|
||||
title_line3 = f'| Estimated similarity: {similarity_factor:.1f}%'
|
||||
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.945, title_line4, 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.950, title_line4, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
|
||||
|
||||
# Add the accel_per_hz value to the title
|
||||
title_line5 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz'
|
||||
fig.text(0.55, 0.915, title_line5, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple'])
|
||||
|
||||
# Plot the graphs
|
||||
plot_compare_frequency(ax1, signal1, signal2, signal1_belt, signal2_belt, max_freq)
|
||||
@@ -530,6 +535,7 @@ def main():
|
||||
opts = optparse.OptionParser(usage)
|
||||
opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
|
||||
opts.add_option('-f', '--max_freq', type='float', default=200.0, help='maximum frequency to graph')
|
||||
opts.add_option('--accel_per_hz', type='float', default=None, help='accel_per_hz used during the measurement')
|
||||
opts.add_option(
|
||||
'-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory'
|
||||
)
|
||||
@@ -546,7 +552,9 @@ def main():
|
||||
if options.output is None:
|
||||
opts.error('You must specify an output file.png to use the script (option -o)')
|
||||
|
||||
fig = belts_calibration(args, options.kinematics, options.klipperdir, options.max_freq)
|
||||
fig = belts_calibration(
|
||||
args, options.kinematics, options.klipperdir, options.max_freq, options.accel_per_hz, 'unknown'
|
||||
)
|
||||
fig.savefig(options.output, dpi=150)
|
||||
|
||||
|
||||
279
shaketune/post_processing/graph_creator.py
Normal file
279
shaketune/post_processing/graph_creator.py
Normal 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()
|
||||
@@ -27,7 +27,7 @@ from ..helpers.common_func import (
|
||||
parse_log,
|
||||
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_EFFECT_THRESHOLD = 0.12
|
||||
@@ -72,19 +72,19 @@ def calibrate_shaper(datas, max_smoothing, scv, max_freq):
|
||||
max_smoothing=max_smoothing,
|
||||
test_damping_ratios=None,
|
||||
max_freq=max_freq,
|
||||
logger=print_with_c_locale,
|
||||
logger=ConsoleOutput.print,
|
||||
)
|
||||
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!'
|
||||
)
|
||||
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'
|
||||
)
|
||||
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)'
|
||||
% (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):
|
||||
set_locale()
|
||||
def shaper_calibration(
|
||||
lognames,
|
||||
klipperdir='~/klipper',
|
||||
max_smoothing=None,
|
||||
scv=5.0,
|
||||
max_freq=200.0,
|
||||
accel_per_hz=None,
|
||||
st_version='unknown',
|
||||
):
|
||||
global shaper_calibrate
|
||||
shaper_calibrate = setup_klipper_import(klipperdir)
|
||||
|
||||
# Parse data from the log files while ignoring CSV in the wrong format
|
||||
datas = [data for data in (parse_log(fn) for fn in lognames) if data is not None]
|
||||
if len(datas) > 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
|
||||
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
|
||||
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])
|
||||
print_with_c_locale(
|
||||
ConsoleOutput.print(
|
||||
'\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)
|
||||
)
|
||||
@@ -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')
|
||||
title_line2 = dt.strftime('%x %X') + ' -- ' + filename_parts[3].upper().split('.')[0] + ' axis'
|
||||
if compat:
|
||||
title_line3 = '| Compatibility mode with older Klipper,'
|
||||
title_line4 = '| and no custom S&T parameters are used!'
|
||||
title_line3 = '| Older Klipper version detected, damping ratio'
|
||||
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:
|
||||
title_line3 = '| Square corner velocity: ' + str(scv) + 'mm/s'
|
||||
title_line4 = '| Max allowed smoothing: ' + str(max_smoothing)
|
||||
title_line3 = f'| Square corner velocity: {scv} mm/s'
|
||||
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:
|
||||
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_line3 = ''
|
||||
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.58, 0.960, 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.965, title_line3, 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_freq_response(
|
||||
@@ -402,6 +413,7 @@ def main():
|
||||
opts.add_option(
|
||||
'--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(
|
||||
'-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:
|
||||
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)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ from ..helpers.common_func import (
|
||||
parse_log,
|
||||
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_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
|
||||
motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(global_motor_profile, freqs, 30)
|
||||
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!'
|
||||
)
|
||||
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'
|
||||
)
|
||||
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'
|
||||
% (motor_fr, motor_zeta)
|
||||
)
|
||||
else:
|
||||
print_with_c_locale(
|
||||
ConsoleOutput.print(
|
||||
'Motors have a main resonant frequency at %.1fHz but it was impossible to estimate a damping ratio.'
|
||||
% (motor_fr)
|
||||
)
|
||||
@@ -564,23 +564,23 @@ def plot_motor_config_txt(fig, motors, differences):
|
||||
motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')]
|
||||
|
||||
distance = 0.12
|
||||
if motors[0].get_property('autotune_enabled'):
|
||||
distance = 0.24
|
||||
if motors[0].get_config('autotune_enabled'):
|
||||
distance = 0.27
|
||||
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
|
||||
]
|
||||
config_blocks.append('| TMC Autotune enabled')
|
||||
else:
|
||||
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
|
||||
]
|
||||
config_blocks.append('| TMC Autotune not detected')
|
||||
|
||||
for idx, block in enumerate(config_blocks):
|
||||
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()
|
||||
@@ -589,7 +589,7 @@ def plot_motor_config_txt(fig, motors, differences):
|
||||
settings_str = ' '.join(f'{k}={v}' for k, v in settings.items())
|
||||
tmc_block = f'| {register.upper()}: {settings_str}'
|
||||
fig.text(
|
||||
0.40 + distance,
|
||||
0.41 + distance,
|
||||
0.990 - 0.015 * idx,
|
||||
tmc_block,
|
||||
ha='left',
|
||||
@@ -601,7 +601,7 @@ def plot_motor_config_txt(fig, motors, differences):
|
||||
if differences is not None:
|
||||
differences_text = f'| Y motor diff: {differences}'
|
||||
fig.text(
|
||||
0.40 + distance,
|
||||
0.41 + distance,
|
||||
0.990 - 0.015 * (idx + 1),
|
||||
differences_text,
|
||||
ha='left',
|
||||
@@ -634,7 +634,6 @@ def extract_angle_and_speed(logname):
|
||||
def vibrations_profile(
|
||||
lognames, klipperdir='~/klipper', kinematics='cartesian', accel=None, max_freq=1000.0, st_version=None, motors=None
|
||||
):
|
||||
set_locale()
|
||||
global shaper_calibrate
|
||||
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, 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
|
||||
# and highlight them. Also find the peaks to identify speeds to avoid due to high resonances
|
||||
@@ -699,7 +698,7 @@ def vibrations_profile(
|
||||
10,
|
||||
)
|
||||
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)'
|
||||
% (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)
|
||||
|
||||
# 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):
|
||||
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
|
||||
good_angles = identify_low_energy_zones(all_angles_energy, ANGLES_VALLEY_DETECTION_THRESHOLD)
|
||||
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):
|
||||
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)'
|
||||
)
|
||||
|
||||
@@ -763,7 +762,7 @@ def vibrations_profile(
|
||||
if accel is not None:
|
||||
title_line2 += ' at ' + str(accel) + ' mm/s² -- ' + kinematics.upper() + ' kinematics'
|
||||
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]
|
||||
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])
|
||||
plot_motor_config_txt(fig, motors, differences)
|
||||
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_angle_profile_polar(ax1, all_angles, all_angles_energy, good_angles, symmetry_factor)
|
||||
|
Before Width: | Height: | Size: 607 KiB After Width: | Height: | Size: 607 KiB |
106
shaketune/shaketune.py
Normal file
106
shaketune/shaketune.py
Normal 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)
|
||||
53
shaketune/shaketune_config.py
Normal file
53
shaketune/shaketune_config.py
Normal 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'
|
||||
66
shaketune/shaketune_thread.py
Normal file
66
shaketune/shaketune_thread.py
Normal 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)!'
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user