Merge pull request #106 from Frix-x/klipper-module-macros
Run S&T as a real Klipper extras plugin
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..."
|
|
||||||
SHAKETUNE_POSTPROCESS 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)"
|
|
||||||
SHAKETUNE_POSTPROCESS 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)"
|
|
||||||
SHAKETUNE_POSTPROCESS 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,23 +0,0 @@
|
|||||||
################################################
|
|
||||||
###### STANDARD INPUT_SHAPER CALIBRATIONS ######
|
|
||||||
################################################
|
|
||||||
# Written by Frix_x#0161 #
|
|
||||||
|
|
||||||
[gcode_macro COMPARE_BELTS_RESPONSES]
|
|
||||||
description: Perform a custom half-axis test to analyze and compare the frequency profiles of individual belts on CoreXY printers
|
|
||||||
gcode:
|
|
||||||
{% set min_freq = params.FREQ_START|default(5)|float %}
|
|
||||||
{% set max_freq = params.FREQ_END|default(133.33)|float %}
|
|
||||||
{% set hz_per_sec = params.HZ_PER_SEC|default(1)|float %}
|
|
||||||
{% set keep_results = params.KEEP_N_RESULTS|default(3)|int %}
|
|
||||||
{% set keep_csv = params.KEEP_CSV|default(0)|int %}
|
|
||||||
|
|
||||||
TEST_RESONANCES AXIS=1,1 OUTPUT=raw_data NAME=b FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
|
|
||||||
M400
|
|
||||||
|
|
||||||
TEST_RESONANCES AXIS=1,-1 OUTPUT=raw_data NAME=a FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
|
|
||||||
M400
|
|
||||||
|
|
||||||
RESPOND MSG="Belts comparative frequency profile generation..."
|
|
||||||
RESPOND MSG="This may take some time (3-5min)"
|
|
||||||
SHAKETUNE_POSTPROCESS PARAMS="--type belts {% 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)"
|
|
||||||
SHAKETUNE_POSTPROCESS 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,4 +1,4 @@
|
|||||||
# Klipper Shake&Tune Module
|
# Klipper Shake&Tune plugin
|
||||||
|
|
||||||
This "Shake&Tune" repository is a standalone module from the [Klippain](https://github.com/Frix-x/klippain) ecosystem, designed to automate and calibrate the input shaper system on your Klipper 3D printer with a streamlined workflow and insightful vizualisations. This can be installed on any Klipper machine. It is not limited to those using Klippain.
|
This "Shake&Tune" repository is a standalone module from the [Klippain](https://github.com/Frix-x/klippain) ecosystem, designed to automate and calibrate the input shaper system on your Klipper 3D printer with a streamlined workflow and insightful vizualisations. This can be installed on any Klipper machine. It is not limited to those using Klippain.
|
||||||
|
|
||||||
@@ -29,7 +29,9 @@ Follow these steps to install the Shake&Tune module in your printer:
|
|||||||
1. Then, append the following to your `printer.cfg` file and restart Klipper (if prefered, you can include only the needed macros: using `*.cfg` is a convenient way to include them all at once):
|
1. Then, append the following to your `printer.cfg` file and restart Klipper (if prefered, you can include only the needed macros: using `*.cfg` is a convenient way to include them all at once):
|
||||||
```
|
```
|
||||||
[shaketune]
|
[shaketune]
|
||||||
[include K-ShakeTune/*.cfg]
|
# result_folder: ~/printer_data/config/K-ShakeTune_results
|
||||||
|
# number_of_results_to_keep: 3
|
||||||
|
# keep_raw_csv: False
|
||||||
```
|
```
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# Klippain Shake&Tune module documentation
|
# Klipper Shake&Tune plugin documentation
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ Here are the parameters available when calling this macro:
|
|||||||
|SPEED|80|speed of the toolhead in mm/s for the movements|
|
|SPEED|80|speed of the toolhead in mm/s for the movements|
|
||||||
|ACCEL|1500 (or max printer accel)|accel in mm/s^2 used for all the moves|
|
|ACCEL|1500 (or max printer accel)|accel in mm/s^2 used for all the moves|
|
||||||
|TRAVEL_SPEED|120|speed in mm/s used for all the travels moves|
|
|TRAVEL_SPEED|120|speed in mm/s used for all the travels moves|
|
||||||
|ACCEL_CHIP|"adxl345"|accelerometer chip name in the config|
|
|ACCEL_CHIP|None|accelerometer to use for the test. If unset, it will automatically select the proper accelerometer based on what is configured in your `[resonance_tester]` config section|
|
||||||
|
|
||||||
The machine will move slightly in +X, +Y, and +Z, and output in the console: `Detected axes_map: -z,y,x`.
|
The machine will move slightly in +X, +Y, and +Z, and output in the console: `Detected axes_map: -z,y,x`.
|
||||||
|
|
||||||
@@ -108,8 +108,11 @@ Here are the parameters available when calling this macro:
|
|||||||
| parameters | default value | description |
|
| parameters | default value | description |
|
||||||
|-----------:|---------------|-------------|
|
|-----------:|---------------|-------------|
|
||||||
|FREQUENCY|25|excitation frequency (in Hz) that you want to maintain. Usually, it's the frequency of a peak on one of the graphs|
|
|FREQUENCY|25|excitation frequency (in Hz) that you want to maintain. Usually, it's the frequency of a peak on one of the graphs|
|
||||||
|TIME|10|time in second to maintain this excitation|
|
|DURATION|10|duration in second to maintain this excitation|
|
||||||
|
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)|
|
||||||
|AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"|
|
|AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"|
|
||||||
|
|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)|
|
||||||
|
|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section|
|
||||||
|
|
||||||
|
|
||||||
## Complementary ressources
|
## Complementary ressources
|
||||||
|
|||||||
@@ -11,14 +11,15 @@ Then, call the `AXES_SHAPER_CALIBRATION` macro and look for the graphs in the re
|
|||||||
|
|
||||||
| parameters | default value | description |
|
| parameters | default value | description |
|
||||||
|-----------:|---------------|-------------|
|
|-----------:|---------------|-------------|
|
||||||
|FREQ_START|5|Starting excitation frequency|
|
|FREQ_START|5|starting excitation frequency|
|
||||||
|FREQ_END|133|Maximum excitation frequency|
|
|FREQ_END|133|maximum excitation frequency|
|
||||||
|HZ_PER_SEC|1|Number of Hz per seconds for the test|
|
|HZ_PER_SEC|1|number of Hz per seconds for the test|
|
||||||
|AXIS|"all"|Axis you want to test in the list of "all", "X" or "Y"|
|
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)|
|
||||||
|SCV|printer square corner velocity|Square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations|
|
|AXIS|"all"|axis you want to test in the list of "all", "X" or "Y"|
|
||||||
|MAX_SMOOTHING|None|Max smoothing allowed when calculating shaper recommendations|
|
|SCV|printer square corner velocity|square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations|
|
||||||
|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up|
|
|MAX_SMOOTHING|None|max smoothing allowed when calculating shaper recommendations|
|
||||||
|KEEP_CSV|0|Weither or not to keep the CSV data file alonside the PNG graphs|
|
|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)|
|
||||||
|
|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section|
|
||||||
|
|
||||||
|
|
||||||
## Graphs description
|
## Graphs description
|
||||||
|
|||||||
@@ -11,11 +11,12 @@ Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the re
|
|||||||
|
|
||||||
| parameters | default value | description |
|
| parameters | default value | description |
|
||||||
|-----------:|---------------|-------------|
|
|-----------:|---------------|-------------|
|
||||||
|FREQ_START|5|Starting excitation frequency|
|
|FREQ_START|5|starting excitation frequency|
|
||||||
|FREQ_END|133|Maximum excitation frequency|
|
|FREQ_END|133|maximum excitation frequency|
|
||||||
|HZ_PER_SEC|1|Number of Hz per seconds for the test|
|
|HZ_PER_SEC|1|number of Hz per seconds for the test|
|
||||||
|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up|
|
|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)|
|
||||||
|KEEP_CSV|0|Weither or not to keep the CSV data files alonside the PNG graphs|
|
|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)|
|
||||||
|
|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section|
|
||||||
|
|
||||||
|
|
||||||
## Graphs description
|
## Graphs description
|
||||||
|
|||||||
@@ -5,470 +5,13 @@
|
|||||||
############################################
|
############################################
|
||||||
# Written by Frix_x#0161 #
|
# Written by Frix_x#0161 #
|
||||||
|
|
||||||
# This script is designed to be run from inside Klipper Console
|
# This module functions as a plugin within Klipper, aimed at enhancing printer diagnostics. It serves multiple purposes:
|
||||||
# Use the provided Shake&Tune macros instead!
|
# 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.
|
||||||
|
|
||||||
|
|
||||||
import abc
|
from .shaketune import ShakeTune as ShakeTune
|
||||||
import argparse
|
|
||||||
import os
|
|
||||||
import shutil
|
|
||||||
import tarfile
|
|
||||||
import threading
|
|
||||||
import traceback
|
|
||||||
from datetime import datetime
|
|
||||||
from pathlib import Path
|
|
||||||
from typing import Callable, List, Optional
|
|
||||||
|
|
||||||
from matplotlib.figure import Figure
|
|
||||||
|
|
||||||
from .graph_creators.analyze_axesmap import axesmap_calibration
|
|
||||||
from .graph_creators.graph_belts import belts_calibration
|
|
||||||
from .graph_creators.graph_shaper import shaper_calibration
|
|
||||||
from .graph_creators.graph_vibrations import vibrations_profile
|
|
||||||
from .helpers import filemanager as fm
|
|
||||||
from .helpers.motorlogparser import MotorLogParser
|
|
||||||
from .helpers.console_output import ConsoleOutput
|
|
||||||
|
|
||||||
|
|
||||||
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:
|
|
||||||
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'
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def parse_arguments(params: Optional[List] = None) -> 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(params)
|
|
||||||
|
|
||||||
|
|
||||||
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._setup_folder('belts')
|
|
||||||
|
|
||||||
def create_graph(self) -> None:
|
|
||||||
lognames = self._move_and_prepare_files(
|
|
||||||
glob_pattern='raw_data_axis*.csv',
|
|
||||||
min_files_required=2,
|
|
||||||
custom_name_func=lambda f: f.stem.split('_')[3].upper(),
|
|
||||||
)
|
|
||||||
fig = belts_calibration(
|
|
||||||
lognames=[str(path) for path in lognames],
|
|
||||||
klipperdir=str(Config.KLIPPER_FOLDER),
|
|
||||||
st_version=self._version,
|
|
||||||
)
|
|
||||||
self._save_figure_and_cleanup(fig, lognames)
|
|
||||||
|
|
||||||
def clean_old_files(self, keep_results: int = 3) -> None:
|
|
||||||
# Get all PNG files in the directory as a list of Path objects
|
|
||||||
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
||||||
|
|
||||||
if len(files) <= keep_results:
|
|
||||||
return # No need to delete any files
|
|
||||||
|
|
||||||
# Delete the older files
|
|
||||||
for old_file in files[keep_results:]:
|
|
||||||
file_date = '_'.join(old_file.stem.split('_')[1:3])
|
|
||||||
for suffix in ['A', 'B']:
|
|
||||||
csv_file = self._folder / f'belts_{file_date}_{suffix}.csv'
|
|
||||||
csv_file.unlink(missing_ok=True)
|
|
||||||
old_file.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
class ShaperGraphCreator(GraphCreator):
|
|
||||||
def __init__(self, keep_csv: bool = False, dpi: int = 150):
|
|
||||||
super().__init__(keep_csv, dpi)
|
|
||||||
|
|
||||||
self._max_smoothing = None
|
|
||||||
self._scv = None
|
|
||||||
|
|
||||||
self._setup_folder('shaper')
|
|
||||||
|
|
||||||
def configure(self, scv: float, max_smoothing: float = None) -> None:
|
|
||||||
self._scv = scv
|
|
||||||
self._max_smoothing = max_smoothing
|
|
||||||
|
|
||||||
def create_graph(self) -> None:
|
|
||||||
if not self._scv:
|
|
||||||
raise ValueError('scv must be set to create the input shaper graph!')
|
|
||||||
|
|
||||||
lognames = self._move_and_prepare_files(
|
|
||||||
glob_pattern='raw_data*.csv',
|
|
||||||
min_files_required=1,
|
|
||||||
custom_name_func=lambda f: f.stem.split('_')[3].upper(),
|
|
||||||
)
|
|
||||||
fig = shaper_calibration(
|
|
||||||
lognames=[str(path) for path in lognames],
|
|
||||||
klipperdir=str(Config.KLIPPER_FOLDER),
|
|
||||||
max_smoothing=self._max_smoothing,
|
|
||||||
scv=self._scv,
|
|
||||||
st_version=self._version,
|
|
||||||
)
|
|
||||||
self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1])
|
|
||||||
|
|
||||||
def clean_old_files(self, keep_results: int = 3) -> None:
|
|
||||||
# Get all PNG files in the directory as a list of Path objects
|
|
||||||
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
||||||
|
|
||||||
if len(files) <= 2 * keep_results:
|
|
||||||
return # No need to delete any files
|
|
||||||
|
|
||||||
# Delete the older files
|
|
||||||
for old_file in files[2 * keep_results :]:
|
|
||||||
csv_file = old_file.with_suffix('.csv')
|
|
||||||
csv_file.unlink(missing_ok=True)
|
|
||||||
old_file.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
class VibrationsGraphCreator(GraphCreator):
|
|
||||||
def __init__(self, keep_csv: bool = False, dpi: int = 150):
|
|
||||||
super().__init__(keep_csv, dpi)
|
|
||||||
|
|
||||||
self._kinematics = None
|
|
||||||
self._accel = None
|
|
||||||
self._chip_name = None
|
|
||||||
self._motors = None
|
|
||||||
|
|
||||||
self._setup_folder('vibrations')
|
|
||||||
|
|
||||||
def configure(self, kinematics: str, accel: float, chip_name: str, metadata: str) -> None:
|
|
||||||
self._kinematics = kinematics
|
|
||||||
self._accel = accel
|
|
||||||
self._chip_name = chip_name
|
|
||||||
|
|
||||||
parser = MotorLogParser(Config.KLIPPER_LOG_FOLDER / 'klippy.log', metadata)
|
|
||||||
self._motors = parser.get_motors()
|
|
||||||
|
|
||||||
def _archive_files(self, lognames: list[Path]) -> None:
|
|
||||||
tar_path = self._folder / f'{self._type}_{self._graph_date}.tar.gz'
|
|
||||||
with tarfile.open(tar_path, 'w:gz') as tar:
|
|
||||||
for csv_file in lognames:
|
|
||||||
tar.add(csv_file, arcname=csv_file.name, recursive=False)
|
|
||||||
|
|
||||||
def create_graph(self) -> None:
|
|
||||||
if not self._accel or not self._chip_name or not self._kinematics:
|
|
||||||
raise ValueError('accel, chip_name and kinematics must be set to create the vibrations profile graph!')
|
|
||||||
|
|
||||||
lognames = self._move_and_prepare_files(
|
|
||||||
glob_pattern=f'{self._chip_name}-*.csv',
|
|
||||||
min_files_required=None,
|
|
||||||
custom_name_func=lambda f: f.name.replace(self._chip_name, self._type),
|
|
||||||
)
|
|
||||||
fig = vibrations_profile(
|
|
||||||
lognames=[str(path) for path in lognames],
|
|
||||||
klipperdir=str(Config.KLIPPER_FOLDER),
|
|
||||||
kinematics=self._kinematics,
|
|
||||||
accel=self._accel,
|
|
||||||
st_version=self._version,
|
|
||||||
motors=self._motors,
|
|
||||||
)
|
|
||||||
self._save_figure_and_cleanup(fig, lognames)
|
|
||||||
|
|
||||||
def clean_old_files(self, keep_results: int = 3) -> None:
|
|
||||||
# Get all PNG files in the directory as a list of Path objects
|
|
||||||
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
|
|
||||||
|
|
||||||
if len(files) <= keep_results:
|
|
||||||
return # No need to delete any files
|
|
||||||
|
|
||||||
# Delete the older files
|
|
||||||
for old_file in files[keep_results:]:
|
|
||||||
old_file.unlink()
|
|
||||||
tar_file = old_file.with_suffix('.tar.gz')
|
|
||||||
tar_file.unlink(missing_ok=True)
|
|
||||||
|
|
||||||
|
|
||||||
class AxesMapFinder(GraphCreator):
|
|
||||||
def __init__(self, keep_csv: bool = False, dpi: int = 150):
|
|
||||||
super().__init__(keep_csv, dpi)
|
|
||||||
|
|
||||||
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
|
|
||||||
self._type = 'axesmap'
|
|
||||||
self._folder = Config.RESULTS_BASE_FOLDER
|
|
||||||
|
|
||||||
self._accel = None
|
|
||||||
self._chip_name = None
|
|
||||||
|
|
||||||
def configure(self, accel: int, chip_name: str) -> None:
|
|
||||||
self._accel = accel
|
|
||||||
self._chip_name = chip_name
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
ConsoleOutput.print(f'Detected axes_map: {results}')
|
|
||||||
|
|
||||||
def create_graph(self) -> None:
|
|
||||||
self.find_axesmap()
|
|
||||||
|
|
||||||
def clean_old_files(self, keep_results: int) -> None:
|
|
||||||
pass
|
|
||||||
|
|
||||||
|
|
||||||
def create_graph(options: argparse.Namespace) -> None:
|
|
||||||
fm.ensure_folders_exist(
|
|
||||||
folders=[Config.RESULTS_BASE_FOLDER / subfolder for subfolder in Config.RESULTS_SUBFOLDERS.values()]
|
|
||||||
)
|
|
||||||
|
|
||||||
ConsoleOutput.print(f'Shake&Tune version: {Config.get_git_version()}')
|
|
||||||
|
|
||||||
graph_creators = {
|
|
||||||
'belts': (BeltsGraphCreator, None),
|
|
||||||
'shaper': (ShaperGraphCreator, lambda gc: gc.configure(options.scv, options.max_smoothing)),
|
|
||||||
'vibrations': (
|
|
||||||
VibrationsGraphCreator,
|
|
||||||
lambda gc: gc.configure(options.kinematics, options.accel_used, options.chip_name, options.metadata),
|
|
||||||
),
|
|
||||||
'axesmap': (AxesMapFinder, lambda gc: gc.configure(options.accel_used, options.chip_name)),
|
|
||||||
}
|
|
||||||
|
|
||||||
creator_info = graph_creators.get(options.type)
|
|
||||||
if not creator_info:
|
|
||||||
ConsoleOutput.print('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:
|
|
||||||
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
|
|
||||||
|
|
||||||
ConsoleOutput.print(f'{options.type} graphs created successfully!')
|
|
||||||
graph_creator.clean_old_files(options.keep_results)
|
|
||||||
ConsoleOutput.print(f'Cleaned output folder to keep only the last {options.keep_results} results!')
|
|
||||||
|
|
||||||
|
|
||||||
class ShakeTune:
|
|
||||||
def __init__(self, config) -> None:
|
|
||||||
self._printer = config.get_printer()
|
|
||||||
self._gcode = self._printer.lookup_object('gcode')
|
|
||||||
self.timeout = config.getfloat('timeout', 2.0, above=0.0)
|
|
||||||
|
|
||||||
ConsoleOutput.register_output_callback(self._gcode.respond_info)
|
|
||||||
|
|
||||||
self._gcode.register_command(
|
|
||||||
'SHAKETUNE_POSTPROCESS',
|
|
||||||
self.cmd_SHAKETUNE_POSTPROCESS,
|
|
||||||
desc='Post process data for ShakeTune graph creation',
|
|
||||||
)
|
|
||||||
|
|
||||||
def shaketune_thread(self, options):
|
|
||||||
try:
|
|
||||||
os.nice(20)
|
|
||||||
except Exception:
|
|
||||||
ConsoleOutput.print('Failed reducing ShakeTune thread priority, continuing.')
|
|
||||||
create_graph(options)
|
|
||||||
|
|
||||||
def cmd_SHAKETUNE_POSTPROCESS(self, gcmd) -> None:
|
|
||||||
options = Config.parse_arguments(gcmd.get('PARAMS').split())
|
|
||||||
t = threading.Thread(target=self.shaketune_thread, args=(options,))
|
|
||||||
t.start()
|
|
||||||
|
|
||||||
reactor = self._printer.get_reactor()
|
|
||||||
event_time = reactor.monotonic()
|
|
||||||
end_time = event_time + self.timeout
|
|
||||||
while event_time < end_time:
|
|
||||||
event_time = reactor.pause(event_time + 0.05)
|
|
||||||
if not t.is_alive():
|
|
||||||
break
|
|
||||||
|
|
||||||
|
|
||||||
def load_config(config) -> ShakeTune:
|
def load_config(config) -> ShakeTune:
|
||||||
|
|||||||
@@ -1,10 +0,0 @@
|
|||||||
from . import Config, create_graph
|
|
||||||
|
|
||||||
|
|
||||||
def main() -> None:
|
|
||||||
options = Config.parse_arguments()
|
|
||||||
create_graph(options)
|
|
||||||
|
|
||||||
|
|
||||||
if __name__ == '__main__':
|
|
||||||
main()
|
|
||||||
@@ -11,8 +11,17 @@ from pathlib import Path
|
|||||||
|
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from scipy.signal import spectrogram
|
from scipy.signal import spectrogram
|
||||||
|
|
||||||
from .console_output import ConsoleOutput
|
from .console_output import ConsoleOutput
|
||||||
|
|
||||||
|
# Constant used to define the standard axis direction and names
|
||||||
|
AXIS_CONFIG = [
|
||||||
|
{'axis': 'x', 'direction': (1, 0, 0), 'label': 'axis_X'},
|
||||||
|
{'axis': 'y', 'direction': (0, 1, 0), 'label': 'axis_Y'},
|
||||||
|
{'axis': 'a', 'direction': (1, -1, 0), 'label': 'belt_A'},
|
||||||
|
{'axis': 'b', 'direction': (1, 1, 0), 'label': 'belt_B'},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def parse_log(logname):
|
def parse_log(logname):
|
||||||
try:
|
try:
|
||||||
@@ -70,6 +79,7 @@ def get_git_version():
|
|||||||
# Get the absolute path of the script, resolving any symlinks
|
# Get the absolute path of the script, resolving any symlinks
|
||||||
# Then get 2 times to parent dir to be at the git root folder
|
# Then get 2 times to parent dir to be at the git root folder
|
||||||
from git import GitCommandError, Repo
|
from git import GitCommandError, Repo
|
||||||
|
|
||||||
script_path = Path(__file__).resolve()
|
script_path = Path(__file__).resolve()
|
||||||
repo_path = script_path.parents[1]
|
repo_path = script_path.parents[1]
|
||||||
repo = Repo(repo_path)
|
repo = Repo(repo_path)
|
||||||
|
|||||||
@@ -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)
|
|
||||||
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)
|
||||||
|
|
||||||
|
# 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()
|
||||||
89
shaketune/measurement/belts_comparison.py
Normal file
89
shaketune/measurement/belts_comparison.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
#!/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 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)
|
||||||
|
|
||||||
|
# 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
|
||||||
188
shaketune/measurement/motorsconfigparser.py
Normal file
188
shaketune/measurement/motorsconfigparser.py
Normal file
@@ -0,0 +1,188 @@
|
|||||||
|
#!/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] = []
|
||||||
|
|
||||||
|
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
|
||||||
@@ -109,7 +109,8 @@ def axesmap_calibration(lognames, accel=None):
|
|||||||
axes_map = ','.join([f'{spike[0][0]}{spike[1]}' for spike in spikes_sorted])
|
axes_map = ','.join([f'{spike[0][0]}{spike[1]}' for spike in spikes_sorted])
|
||||||
# alignment_error, sensitivity_error = compute_errors(filtered_data, spikes_sorted, accel, NUM_POINTS)
|
# alignment_error, sensitivity_error = compute_errors(filtered_data, spikes_sorted, accel, NUM_POINTS)
|
||||||
|
|
||||||
results = f'Detected axes_map:\n {axes_map}\n'
|
results = f'Be aware that this macro is experimental and has been known to sometimes produce incorrect results. Use it with caution and always check the results!\n'
|
||||||
|
results += f'Detected axes_map:\n {axes_map}\n'
|
||||||
|
|
||||||
# TODO: work on this function that is currently not giving good results...
|
# TODO: work on this function that is currently not giving good results...
|
||||||
# results += "Accelerometer angle deviation:\n"
|
# results += "Accelerometer angle deviation:\n"
|
||||||
269
shaketune/post_processing/graph_creator.py
Normal file
269
shaketune/post_processing/graph_creator.py
Normal file
@@ -0,0 +1,269 @@
|
|||||||
|
#!/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._setup_folder('belts')
|
||||||
|
|
||||||
|
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),
|
||||||
|
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) -> 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='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,
|
||||||
|
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()
|
||||||
@@ -564,23 +564,23 @@ def plot_motor_config_txt(fig, motors, differences):
|
|||||||
motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')]
|
motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')]
|
||||||
|
|
||||||
distance = 0.12
|
distance = 0.12
|
||||||
if motors[0].get_property('autotune_enabled'):
|
if motors[0].get_config('autotune_enabled'):
|
||||||
distance = 0.24
|
distance = 0.27
|
||||||
config_blocks = [
|
config_blocks = [
|
||||||
f"| {lbl}: {mot.get_property('motor').upper()} on {mot.get_property('tmc').upper()} @ {mot.get_property('voltage')}V {mot.get_property('run_current')}A"
|
f"| {lbl}: {mot.get_config('motor').upper()} on {mot.get_config('tmc').upper()} @ {mot.get_config('voltage'):0.1f}V {mot.get_config('run_current'):0.2f}A - {mot.get_config('microsteps')}usteps"
|
||||||
for mot, lbl in motor_details
|
for mot, lbl in motor_details
|
||||||
]
|
]
|
||||||
config_blocks.append('| TMC Autotune enabled')
|
config_blocks.append('| TMC Autotune enabled')
|
||||||
else:
|
else:
|
||||||
config_blocks = [
|
config_blocks = [
|
||||||
f"| {lbl}: {mot.get_property('tmc').upper()} @ {mot.get_property('run_current')}A"
|
f"| {lbl}: {mot.get_config('tmc').upper()} @ {mot.get_config('run_current'):0.2f}A - {mot.get_config('microsteps')}usteps"
|
||||||
for mot, lbl in motor_details
|
for mot, lbl in motor_details
|
||||||
]
|
]
|
||||||
config_blocks.append('| TMC Autotune not detected')
|
config_blocks.append('| TMC Autotune not detected')
|
||||||
|
|
||||||
for idx, block in enumerate(config_blocks):
|
for idx, block in enumerate(config_blocks):
|
||||||
fig.text(
|
fig.text(
|
||||||
0.40, 0.990 - 0.015 * idx, block, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']
|
0.41, 0.990 - 0.015 * idx, block, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']
|
||||||
)
|
)
|
||||||
|
|
||||||
tmc_registers = motors[0].get_registers()
|
tmc_registers = motors[0].get_registers()
|
||||||
@@ -589,7 +589,7 @@ def plot_motor_config_txt(fig, motors, differences):
|
|||||||
settings_str = ' '.join(f'{k}={v}' for k, v in settings.items())
|
settings_str = ' '.join(f'{k}={v}' for k, v in settings.items())
|
||||||
tmc_block = f'| {register.upper()}: {settings_str}'
|
tmc_block = f'| {register.upper()}: {settings_str}'
|
||||||
fig.text(
|
fig.text(
|
||||||
0.40 + distance,
|
0.41 + distance,
|
||||||
0.990 - 0.015 * idx,
|
0.990 - 0.015 * idx,
|
||||||
tmc_block,
|
tmc_block,
|
||||||
ha='left',
|
ha='left',
|
||||||
@@ -601,7 +601,7 @@ def plot_motor_config_txt(fig, motors, differences):
|
|||||||
if differences is not None:
|
if differences is not None:
|
||||||
differences_text = f'| Y motor diff: {differences}'
|
differences_text = f'| Y motor diff: {differences}'
|
||||||
fig.text(
|
fig.text(
|
||||||
0.40 + distance,
|
0.41 + distance,
|
||||||
0.990 - 0.015 * (idx + 1),
|
0.990 - 0.015 * (idx + 1),
|
||||||
differences_text,
|
differences_text,
|
||||||
ha='left',
|
ha='left',
|
||||||
|
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)!'
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user