Merge pull request #91 from Frix-x/refact

refactoring code to OOP and with better linting and formating
This commit is contained in:
Félix Boisselier
2024-04-24 16:43:56 +02:00
committed by GitHub
24 changed files with 1298 additions and 763 deletions

1
.git-blame-ignore-revs Normal file
View File

@@ -0,0 +1 @@
ef006dbd1e31cc7cae2fae978401a818ee2025d1

3
.gitignore vendored
View File

@@ -158,3 +158,6 @@ cython_debug/
# and can be added to the global gitignore or merged into this file. For a more nuclear # and can be added to the global gitignore or merged into this file. For a more nuclear
# option (not recommended) you can uncomment the following to ignore the entire idea folder. # option (not recommended) you can uncomment the following to ignore the entire idea folder.
#.idea/ #.idea/
test/
.vscode/

View File

@@ -13,7 +13,7 @@ gcode:
{% set scv = params.SCV|default(None) %} {% set scv = params.SCV|default(None) %}
{% set max_sm = params.MAX_SMOOTHING|default(None) %} {% set max_sm = params.MAX_SMOOTHING|default(None) %}
{% set keep_results = params.KEEP_N_RESULTS|default(3)|int %} {% set keep_results = params.KEEP_N_RESULTS|default(3)|int %}
{% set keep_csv = params.KEEP_CSV|default(True) %} {% set keep_csv = params.KEEP_CSV|default(0)|int %}
{% set X, Y = False, False %} {% set X, Y = False, False %}
@@ -27,17 +27,21 @@ gcode:
{ action_raise_error("AXIS selection invalid. Should be either all, x or y!") } { action_raise_error("AXIS selection invalid. Should be either all, x or y!") }
{% endif %} {% endif %}
{% if scv is none %} {% if scv is none or scv == "" %}
{% set scv = printer.toolhead.square_corner_velocity %} {% set scv = printer.toolhead.square_corner_velocity %}
{% endif %} {% endif %}
{% if max_sm == "" %}
{% set max_sm = none %}
{% endif %}
{% if X %} {% 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} TEST_RESONANCES AXIS=X OUTPUT=raw_data NAME=x FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}
M400 M400
RESPOND MSG="X axis frequency profile generation..." RESPOND MSG="X axis frequency profile generation..."
RESPOND MSG="This may take some time (1-3min)" RESPOND MSG="This may take some time (1-3min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type shaper --scv {scv} {% if max_sm is not none %}--max_smoothing {max_sm}{% endif %} {% if keep_csv %}--keep_csv{% endif %}" RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type shaper --scv {scv} {% if max_sm is not none %}--max_smoothing {max_sm}{% endif %} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}"
{% endif %} {% endif %}
{% if Y %} {% if Y %}
@@ -46,8 +50,5 @@ gcode:
RESPOND MSG="Y axis frequency profile generation..." RESPOND MSG="Y axis frequency profile generation..."
RESPOND MSG="This may take some time (1-3min)" RESPOND MSG="This may take some time (1-3min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type shaper --scv {scv} {% if max_sm is not none %}--max_smoothing {max_sm}{% endif %} {% if keep_csv %}--keep_csv{% endif %}" RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type shaper --scv {scv} {% if max_sm is not none %}--max_smoothing {max_sm}{% endif %} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}"
{% endif %} {% endif %}
M400
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type clean --keep_results {keep_results}"

View File

@@ -10,7 +10,7 @@ gcode:
{% set max_freq = params.FREQ_END|default(133.33)|float %} {% set max_freq = params.FREQ_END|default(133.33)|float %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1)|float %} {% set hz_per_sec = params.HZ_PER_SEC|default(1)|float %}
{% set keep_results = params.KEEP_N_RESULTS|default(3)|int %} {% set keep_results = params.KEEP_N_RESULTS|default(3)|int %}
{% set keep_csv = params.KEEP_CSV|default(True) %} {% 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} 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 M400
@@ -20,6 +20,4 @@ gcode:
RESPOND MSG="Belts comparative frequency profile generation..." RESPOND MSG="Belts comparative frequency profile generation..."
RESPOND MSG="This may take some time (3-5min)" RESPOND MSG="This may take some time (3-5min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type belts {% if keep_csv %}--keep_csv{% endif %}" RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type belts {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}"
M400
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type clean --keep_results {keep_results}"

View File

@@ -15,7 +15,7 @@ gcode:
{% set accel_chip = params.ACCEL_CHIP|default("adxl345") %} # ADXL chip name in the config {% 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_results = params.KEEP_N_RESULTS|default(3)|int %}
{% set keep_csv = params.KEEP_CSV|default(True) %} {% set keep_csv = params.KEEP_CSV|default(0)|int %}
{% set mid_x = printer.toolhead.axis_maximum.x|float / 2 %} {% set mid_x = printer.toolhead.axis_maximum.x|float / 2 %}
{% set mid_y = printer.toolhead.axis_maximum.y|float / 2 %} {% set mid_y = printer.toolhead.axis_maximum.y|float / 2 %}
@@ -159,9 +159,7 @@ gcode:
RESPOND MSG="Machine vibrations profile generation..." RESPOND MSG="Machine vibrations profile generation..."
RESPOND MSG="This may take some time (3-5min)" RESPOND MSG="This may take some time (3-5min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type vibrations --accel {accel|int} --kinematics {kinematics} --chip_name {accel_chip} {% if keep_csv %}--keep_csv{% endif %}" RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type vibrations --accel {accel|int} --kinematics {kinematics} --chip_name {accel_chip} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}"
M400
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type clean --keep_results {keep_results}"
# Restore the previous acceleration values # Restore the previous acceleration values
SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_cruise_ratio} SQUARE_CORNER_VELOCITY={old_sqv} SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_cruise_ratio} SQUARE_CORNER_VELOCITY={old_sqv}

View File

@@ -1,311 +0,0 @@
#!/usr/bin/env python3
############################################
###### INPUT SHAPER KLIPPAIN WORKFLOW ######
############################################
# Written by Frix_x#0161 #
# This script is designed to be used with gcode_shell_commands directly from Klipper
# Use the provided Shake&Tune macros instead!
import optparse
import os
import time
import glob
import sys
import shutil
import tarfile
from datetime import datetime
#################################################################################################################
RESULTS_FOLDER = os.path.expanduser('~/printer_data/config/K-ShakeTune_results')
KLIPPER_FOLDER = os.path.expanduser('~/klipper')
#################################################################################################################
from graph_belts import belts_calibration
from graph_shaper import shaper_calibration
from graph_vibrations import vibrations_profile
from analyze_axesmap import axesmap_calibration
RESULTS_SUBFOLDERS = ['belts', 'inputshaper', 'vibrations']
def is_file_open(filepath):
for proc in os.listdir('/proc'):
if proc.isdigit():
for fd in glob.glob(f'/proc/{proc}/fd/*'):
try:
if os.path.samefile(fd, filepath):
return True
except FileNotFoundError:
# Klipper has already released the CSV file
pass
except PermissionError:
# Unable to check for this particular process due to permissions
pass
return False
def create_belts_graph(keep_csv):
current_date = datetime.now().strftime('%Y%m%d_%H%M%S')
lognames = []
globbed_files = glob.glob('/tmp/raw_data_axis*.csv')
if not globbed_files:
print("No CSV files found in the /tmp folder to create the belt graphs!")
sys.exit(1)
if len(globbed_files) < 2:
print("Not enough CSV files found in the /tmp folder. Two files are required for the belt graphs!")
sys.exit(1)
sorted_files = sorted(globbed_files, key=os.path.getmtime, reverse=True)
for filename in sorted_files[:2]:
# Wait for the file handler to be released by Klipper
while is_file_open(filename):
time.sleep(2)
# Extract the tested belt from the filename and rename/move the CSV file to the result folder
belt = os.path.basename(filename).split('_')[3].split('.')[0].upper()
new_file = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[0], f'belt_{current_date}_{belt}.csv')
shutil.move(filename, new_file)
os.sync() # Sync filesystem to avoid problems
# Save the file path for later
lognames.append(new_file)
# Wait for the file handler to be released by the move command
while is_file_open(new_file):
time.sleep(2)
# Generate the belts graph and its name
fig = belts_calibration(lognames, KLIPPER_FOLDER)
png_filename = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[0], f'belts_{current_date}.png')
fig.savefig(png_filename, dpi=150)
# Remove the CSV files if the user don't want to keep them
if not keep_csv:
for csv in lognames:
if os.path.exists(csv):
os.remove(csv)
return
def create_shaper_graph(keep_csv, max_smoothing, scv):
current_date = datetime.now().strftime('%Y%m%d_%H%M%S')
# Get all the files and sort them based on last modified time to select the most recent one
globbed_files = glob.glob('/tmp/raw_data*.csv')
if not globbed_files:
print("No CSV files found in the /tmp folder to create the input shaper graphs!")
sys.exit(1)
sorted_files = sorted(globbed_files, key=os.path.getmtime, reverse=True)
filename = sorted_files[0]
# Wait for the file handler to be released by Klipper
while is_file_open(filename):
time.sleep(2)
# Extract the tested axis from the filename and rename/move the CSV file to the result folder
axis = os.path.basename(filename).split('_')[3].split('.')[0].upper()
new_file = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[1], f'resonances_{current_date}_{axis}.csv')
shutil.move(filename, new_file)
os.sync() # Sync filesystem to avoid problems
# Wait for the file handler to be released by the move command
while is_file_open(new_file):
time.sleep(2)
# Generate the shaper graph and its name
fig = shaper_calibration([new_file], KLIPPER_FOLDER, max_smoothing=max_smoothing, scv=scv)
png_filename = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[1], f'resonances_{current_date}_{axis}.png')
fig.savefig(png_filename, dpi=150)
# Remove the CSV file if the user don't want to keep it
if not keep_csv:
if os.path.exists(new_file):
os.remove(new_file)
return axis
def create_vibrations_graph(accel, kinematics, chip_name, keep_csv):
current_date = datetime.now().strftime('%Y%m%d_%H%M%S')
lognames = []
globbed_files = glob.glob(f'/tmp/{chip_name}-*.csv')
if not globbed_files:
print("No CSV files found in the /tmp folder to create the vibration graphs!")
sys.exit(1)
if len(globbed_files) < 3:
print("Not enough CSV files found in the /tmp folder. At least 3 files are required for the vibration graphs!")
sys.exit(1)
for filename in globbed_files:
# Wait for the file handler to be released by Klipper
while is_file_open(filename):
time.sleep(2)
# Cleanup of the filename and moving it in the result folder
cleanfilename = os.path.basename(filename).replace(chip_name, f'vibr_{current_date}')
new_file = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[2], cleanfilename)
shutil.move(filename, new_file)
# Save the file path for later
lognames.append(new_file)
# Sync filesystem to avoid problems as there is a lot of file copied
os.sync()
time.sleep(5)
# Generate the vibration graph and its name
fig = vibrations_profile(lognames, KLIPPER_FOLDER, kinematics, accel)
png_filename = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[2], f'vibrations_{current_date}.png')
fig.savefig(png_filename, dpi=150)
# Archive all the csv files in a tarball in case the user want to keep them
if keep_csv:
with tarfile.open(os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[2], f'vibrations_{current_date}.tar.gz'), 'w:gz') as tar:
for csv_file in lognames:
tar.add(csv_file, arcname=os.path.basename(csv_file), recursive=False)
# Remove the remaining CSV files not needed anymore (tarball is safe if it was created)
for csv_file in lognames:
if os.path.exists(csv_file):
os.remove(csv_file)
return
def find_axesmap(accel, chip_name):
current_date = datetime.now().strftime('%Y%m%d_%H%M%S')
result_filename = os.path.join(RESULTS_FOLDER, f'axes_map_{current_date}.txt')
lognames = []
globbed_files = glob.glob(f'/tmp/{chip_name}-*.csv')
if not globbed_files:
print("No CSV files found in the /tmp folder to analyze and find the axes_map!")
sys.exit(1)
sorted_files = sorted(globbed_files, key=os.path.getmtime, reverse=True)
filename = sorted_files[0]
# Wait for the file handler to be released by Klipper
while is_file_open(filename):
time.sleep(2)
# Analyze the CSV to find the axes_map parameter
lognames.append(filename)
results = axesmap_calibration(lognames, accel)
with open(result_filename, 'w') as f:
f.write(results)
return
# Utility function to get old files based on their modification time
def get_old_files(folder, extension, limit):
files = [os.path.join(folder, f) for f in os.listdir(folder) if f.endswith(extension)]
files.sort(key=lambda x: os.path.getmtime(x), reverse=True)
return files[limit:]
def clean_files(keep_results):
# Define limits based on STORE_RESULTS
keep1 = keep_results + 1
keep2 = 2 * keep_results + 1
# Find old files in each directory
old_belts_files = get_old_files(os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[0]), '.png', keep1)
old_inputshaper_files = get_old_files(os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[1]), '.png', keep2)
old_speed_vibr_files = get_old_files(os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[2]), '.png', keep1)
# Remove the old belt files
for old_file in old_belts_files:
file_date = "_".join(os.path.splitext(os.path.basename(old_file))[0].split('_')[1:3])
for suffix in ['A', 'B']:
csv_file = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[0], f'belt_{file_date}_{suffix}.csv')
if os.path.exists(csv_file):
os.remove(csv_file)
os.remove(old_file)
# Remove the old shaper files
for old_file in old_inputshaper_files:
csv_file = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[1], os.path.splitext(os.path.basename(old_file))[0] + ".csv")
if os.path.exists(csv_file):
os.remove(csv_file)
os.remove(old_file)
# Remove the old vibrations files
for old_file in old_speed_vibr_files:
os.remove(old_file)
tar_file = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[2], os.path.splitext(os.path.basename(old_file))[0] + ".tar.gz")
if os.path.exists(tar_file):
os.remove(tar_file)
def main():
# Parse command-line arguments
usage = "%prog [options] <logs>"
opts = optparse.OptionParser(usage)
opts.add_option("-t", "--type", type="string", dest="type",
default=None, help="type of output graph to produce")
opts.add_option("--accel", type="int", default=None, dest="accel_used",
help="acceleration used during the vibration macro or axesmap macro")
opts.add_option("--axis_name", type="string", default=None, dest="axis_name",
help="axis tested during the vibration macro")
opts.add_option("--chip_name", type="string", default="adxl345", dest="chip_name",
help="accelerometer chip name in klipper used during the vibration macro or the axesmap macro")
opts.add_option("-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")
opts.add_option("-c", "--keep_csv", action="store_true", default=False, dest="keep_csv",
help="weither or not to keep the CSV files alongside the PNG graphs image results")
opts.add_option("--scv", "--square_corner_velocity", type="float", dest="scv", default=5.,
help="square corner velocity used to compute max accel for axis shapers graphs")
opts.add_option("--max_smoothing", type="float", dest="max_smoothing", default=None,
help="maximum shaper smoothing to allow")
opts.add_option("-m", "--kinematics", type="string", dest="kinematics",
default="cartesian", help="machine kinematics configuration used for the vibrations graphs")
options, args = opts.parse_args()
if options.type is None:
opts.error("You must specify the type of output graph you want to produce (option -t)")
elif options.type.lower() is None or options.type.lower() not in ['belts', 'shaper', 'vibrations', 'axesmap', 'clean']:
opts.error("Type of output graph need to be in the list of 'belts', 'shaper', 'vibrations', 'axesmap' or 'clean'")
else:
graph_mode = options.type
if graph_mode.lower() == "vibrations" and options.kinematics not in ["cartesian", "corexy"]:
opts.error("Only Cartesian and CoreXY kinematics are supported by this tool at the moment!")
# Check if results folders are there or create them before doing anything else
for result_subfolder in RESULTS_SUBFOLDERS:
folder = os.path.join(RESULTS_FOLDER, result_subfolder)
if not os.path.exists(folder):
os.makedirs(folder)
if graph_mode.lower() == 'belts':
create_belts_graph(keep_csv=options.keep_csv)
print(f"Belt graph created. You will find the results in {RESULTS_FOLDER}/{RESULTS_SUBFOLDERS[0]}")
elif graph_mode.lower() == 'shaper':
axis = create_shaper_graph(keep_csv=options.keep_csv, max_smoothing=options.max_smoothing, scv=options.scv)
print(f"{axis} input shaper graph created. You will find the results in {RESULTS_FOLDER}/{RESULTS_SUBFOLDERS[1]}")
elif graph_mode.lower() == 'vibrations':
create_vibrations_graph(accel=options.accel_used, kinematics=options.kinematics, chip_name=options.chip_name, keep_csv=options.keep_csv)
print(f"Vibrations graph created. You will find the results in {RESULTS_FOLDER}/{RESULTS_SUBFOLDERS[2]}")
elif graph_mode.lower() == 'axesmap':
print(f"WARNING: AXES_MAP_CALIBRATION is currently very experimental and may produce incorrect results... Please validate the output!")
find_axesmap(accel=options.accel_used, chip_name=options.chip_name)
elif graph_mode.lower() == 'clean':
print(f"Cleaning output folder to keep only the last {options.keep_results} results...")
clean_files(keep_results=options.keep_results)
if options.keep_csv is False and graph_mode.lower() != 'clean':
print(f"Deleting raw CSV files... If you want to keep them, use the --keep_csv option!")
if __name__ == '__main__':
main()

View File

@@ -1,5 +0,0 @@
#!/usr/bin/env bash
source ~/klippain_shaketune-env/bin/activate
python ~/klippain_shaketune/K-ShakeTune/scripts/is_workflow.py "$@"
deactivate

10
K-ShakeTune/shaketune.sh Executable file
View File

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

View File

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

View File

@@ -18,7 +18,7 @@ Then, call the `AXES_SHAPER_CALIBRATION` macro and look for the graphs in the re
|SCV|printer square corner velocity|Square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations| |SCV|printer square corner velocity|Square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations|
|MAX_SMOOTHING|None|Max smoothing allowed when calculating shaper recommendations| |MAX_SMOOTHING|None|Max smoothing allowed when calculating shaper recommendations|
|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| |KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up|
|KEEP_CSV|True|Weither or not to keep the CSV data file alonside the PNG graphs| |KEEP_CSV|0|Weither or not to keep the CSV data file alonside the PNG graphs|
## Graphs description ## Graphs description

View File

@@ -15,7 +15,7 @@ Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the re
|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| |KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up|
|KEEP_CSV|True|Weither or not to keep the CSV data files alonside the PNG graphs| |KEEP_CSV|0|Weither or not to keep the CSV data files alonside the PNG graphs|
## Graphs description ## Graphs description

View File

@@ -21,7 +21,7 @@ Call the `CREATE_VIBRATIONS_PROFILE` macro with the speed range you want to meas
|TRAVEL_SPEED|200|speed in mm/s used for all the travels moves| |TRAVEL_SPEED|200|speed in mm/s used for all the travels moves|
|ACCEL_CHIP|"adxl345"|accelerometer chip name in the config| |ACCEL_CHIP|"adxl345"|accelerometer chip name in the config|
|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| |KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up|
|KEEP_CSV|True|Weither or not to keep the CSV data files alonside the PNG graphs (archived in a tarball)| |KEEP_CSV|0|Weither or not to keep the CSV data files alonside the PNG graphs (archived in a tarball)|
## Graphs description ## Graphs description

29
pyproject.toml Normal file
View File

@@ -0,0 +1,29 @@
[project]
name = "Shake&Tune"
description = "Klipper streamlined input shaper workflow and calibration tools"
readme = "README.md"
requires-python = ">= 3.9"
authors = [
{name = "Félix Boisselier", email = "felix@fboisselier.fr"}
]
keywords = ["klipper", "input shaper", "calibration", "3d printer"]
license = {file = "LICENSE"}
[project.urls]
Repository = "https://github.com/Frix-x/klippain-shaketune"
Documentation = "https://github.com/Frix-x/klippain-shaketune/tree/main/docs"
Issues = "https://github.com/Frix-x/klippain-shaketune/issues"
Changelog = "https://github.com/Frix-x/klippain-shaketune/releases"
[tool.ruff]
line-length = 120 # We all have modern screens now and I believe this should be brought in line with current technology
indent-width = 4
target-version = "py39"
[tool.ruff.lint]
select = ["E4", "E7", "E9", "F", "B"]
unfixable = ["B"]
[tool.ruff.format]
quote-style = "single"
skip-magic-trailing-comma = false

View File

View File

@@ -5,17 +5,12 @@
###################################### ######################################
# Written by Frix_x#0161 # # Written by Frix_x#0161 #
# Be sure to make this script executable using SSH: type 'chmod +x ./analyze_axesmap.py' when in the folder !
#####################################################################
################ !!! DO NOT EDIT BELOW THIS LINE !!! ################
#####################################################################
import optparse import optparse
import numpy as np import numpy as np
from locale_utils import print_with_c_locale
from scipy.signal import butter, filtfilt from scipy.signal import butter, filtfilt
from ..helpers.locale_utils import print_with_c_locale
NUM_POINTS = 500 NUM_POINTS = 500
@@ -24,6 +19,7 @@ NUM_POINTS = 500
# Computation # Computation
###################################################################### ######################################################################
def accel_signal_filter(data, cutoff=2, fs=100, order=5): def accel_signal_filter(data, cutoff=2, fs=100, order=5):
nyq = 0.5 * fs nyq = 0.5 * fs
normal_cutoff = cutoff / nyq normal_cutoff = cutoff / nyq
@@ -32,10 +28,12 @@ def accel_signal_filter(data, cutoff=2, fs=100, order=5):
filtered_data -= np.mean(filtered_data) filtered_data -= np.mean(filtered_data)
return filtered_data return filtered_data
def find_first_spike(data): def find_first_spike(data):
min_index, max_index = np.argmin(data), np.argmax(data) min_index, max_index = np.argmin(data), np.argmax(data)
return ('-', min_index) if min_index < max_index else ('', max_index) return ('-', min_index) if min_index < max_index else ('', max_index)
def get_movement_vector(data, start_idx, end_idx): def get_movement_vector(data, start_idx, end_idx):
if start_idx < end_idx: if start_idx < end_idx:
vector = [] vector = []
@@ -45,21 +43,19 @@ def get_movement_vector(data, start_idx, end_idx):
else: else:
return np.zeros(3) return np.zeros(3)
def angle_between(v1, v2): def angle_between(v1, v2):
v1_u = v1 / np.linalg.norm(v1) v1_u = v1 / np.linalg.norm(v1)
v2_u = v2 / np.linalg.norm(v2) v2_u = v2 / np.linalg.norm(v2)
return np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0)) return np.arccos(np.clip(np.dot(v1_u, v2_u), -1.0, 1.0))
def compute_errors(filtered_data, spikes_sorted, accel_value, num_points): def compute_errors(filtered_data, spikes_sorted, accel_value, num_points):
# Get the movement start points in the correct order from the sorted bag of spikes # Get the movement start points in the correct order from the sorted bag of spikes
movement_starts = [spike[0][1] for spike in spikes_sorted] movement_starts = [spike[0][1] for spike in spikes_sorted]
# Theoretical unit vectors for X, Y, Z printer axes # Theoretical unit vectors for X, Y, Z printer axes
printer_axes = { printer_axes = {'x': np.array([1, 0, 0]), 'y': np.array([0, 1, 0]), 'z': np.array([0, 0, 1])}
'x': np.array([1, 0, 0]),
'y': np.array([0, 1, 0]),
'z': np.array([0, 0, 1])
}
alignment_errors = {} alignment_errors = {}
sensitivity_errors = {} sensitivity_errors = {}
@@ -82,6 +78,7 @@ def compute_errors(filtered_data, spikes_sorted, accel_value, num_points):
# Startup and main routines # Startup and main routines
###################################################################### ######################################################################
def parse_log(logname): def parse_log(logname):
with open(logname) as f: with open(logname) as f:
for header in f: for header in f:
@@ -91,26 +88,28 @@ def parse_log(logname):
# Raw accelerometer data # Raw accelerometer data
return np.loadtxt(logname, comments='#', delimiter=',') return np.loadtxt(logname, comments='#', delimiter=',')
# Power spectral density data or shaper calibration data # Power spectral density data or shaper calibration data
raise ValueError("File %s does not contain raw accelerometer data and therefore " raise ValueError(
"is not supported by this script. Please use the official Klipper " 'File %s does not contain raw accelerometer data and therefore '
"calibrate_shaper.py script to process it instead." % (logname,)) 'is not supported by this script. Please use the official Klipper '
'calibrate_shaper.py script to process it instead.' % (logname,)
)
def axesmap_calibration(lognames, accel=None): def axesmap_calibration(lognames, accel=None):
# Parse the raw data and get them ready for analysis # Parse the raw data and get them ready for analysis
raw_datas = [parse_log(filename) for filename in lognames] raw_datas = [parse_log(filename) for filename in lognames]
if len(raw_datas) > 1: if len(raw_datas) > 1:
raise ValueError("Analysis of multiple CSV files at once is not possible with this script") raise ValueError('Analysis of multiple CSV files at once is not possible with this script')
filtered_data = [accel_signal_filter(raw_datas[0][:, i+1]) for i in range(3)] filtered_data = [accel_signal_filter(raw_datas[0][:, i + 1]) for i in range(3)]
spikes = [find_first_spike(filtered_data[i]) for i in range(3)] spikes = [find_first_spike(filtered_data[i]) for i in range(3)]
spikes_sorted = sorted([(spikes[0], 'x'), (spikes[1], 'y'), (spikes[2], 'z')], key=lambda x: x[0][1]) spikes_sorted = sorted([(spikes[0], 'x'), (spikes[1], 'y'), (spikes[2], 'z')], key=lambda x: x[0][1])
# Using the previous variables to get the axes_map and errors # Using the previous variables to get the axes_map and errors
axes_map = ','.join([f"{spike[0][0]}{spike[1]}" for spike in spikes_sorted]) 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'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"
@@ -127,21 +126,21 @@ def axesmap_calibration(lognames, accel=None):
def main(): def main():
# Parse command-line arguments # Parse command-line arguments
usage = "%prog [options] <raw logs>" usage = '%prog [options] <raw logs>'
opts = optparse.OptionParser(usage) opts = optparse.OptionParser(usage)
opts.add_option("-o", "--output", type="string", dest="output", opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
default=None, help="filename of output graph") opts.add_option(
opts.add_option("-a", "--accel", type="string", dest="accel", '-a', '--accel', type='string', dest='accel', default=None, help='acceleration value used to do the movements'
default=None, help="acceleration value used to do the movements") )
options, args = opts.parse_args() options, args = opts.parse_args()
if len(args) < 1: if len(args) < 1:
opts.error("No CSV file(s) to analyse") opts.error('No CSV file(s) to analyse')
if options.accel is None: if options.accel is None:
opts.error("You must specify the acceleration value used when generating the CSV file (option -a)") opts.error('You must specify the acceleration value used when generating the CSV file (option -a)')
try: try:
accel_value = float(options.accel) accel_value = float(options.accel)
except ValueError: except ValueError:
opts.error("Invalid acceleration value. It should be a numeric value.") opts.error('Invalid acceleration value. It should be a numeric value.')
results = axesmap_calibration(args, accel_value) results = axesmap_calibration(args, accel_value)
print_with_c_locale(results) print_with_c_locale(results)

View File

@@ -5,27 +5,31 @@
################################################# #################################################
# Written by Frix_x#0161 # # Written by Frix_x#0161 #
# Be sure to make this script executable using SSH: type 'chmod +x ./graph_belts.py' when in the folder! import optparse
import os
#####################################################################
################ !!! DO NOT EDIT BELOW THIS LINE !!! ################
#####################################################################
import optparse, matplotlib, os
from datetime import datetime
from collections import namedtuple from collections import namedtuple
import numpy as np from datetime import datetime
import matplotlib
import matplotlib.colors
import matplotlib.font_manager
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.font_manager, matplotlib.ticker, matplotlib.colors import matplotlib.ticker
import numpy as np
from scipy.interpolate import griddata from scipy.interpolate import griddata
matplotlib.use('Agg') matplotlib.use('Agg')
from locale_utils import set_locale, print_with_c_locale from ..helpers.common_func import (
from common_func import compute_spectrogram, detect_peaks, get_git_version, parse_log, setup_klipper_import, compute_curve_similarity_factor compute_curve_similarity_factor,
compute_spectrogram,
detect_peaks,
parse_log,
setup_klipper_import,
)
from ..helpers.locale_utils import print_with_c_locale, set_locale
ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' # For paired peaks names
ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" # For paired peaks names
PEAKS_DETECTION_THRESHOLD = 0.20 PEAKS_DETECTION_THRESHOLD = 0.20
CURVE_SIMILARITY_SIGMOID_K = 0.6 CURVE_SIMILARITY_SIGMOID_K = 0.6
@@ -37,11 +41,11 @@ DC_MAX_UNPAIRED_PEAKS_ALLOWED = 4
SignalData = namedtuple('CalibrationData', ['freqs', 'psd', 'peaks', 'paired_peaks', 'unpaired_peaks']) SignalData = namedtuple('CalibrationData', ['freqs', 'psd', 'peaks', 'paired_peaks', 'unpaired_peaks'])
KLIPPAIN_COLORS = { KLIPPAIN_COLORS = {
"purple": "#70088C", 'purple': '#70088C',
"orange": "#FF8D32", 'orange': '#FF8D32',
"dark_purple": "#150140", 'dark_purple': '#150140',
"dark_orange": "#F24130", 'dark_orange': '#F24130',
"red_pink": "#F2055C" 'red_pink': '#F2055C',
} }
@@ -49,6 +53,7 @@ KLIPPAIN_COLORS = {
# Computation of the PSD graph # Computation of the PSD graph
###################################################################### ######################################################################
# This function create pairs of peaks that are close in frequency on two curves (that are known # This function create pairs of peaks that are close in frequency on two curves (that are known
# to be resonances points and must be similar on both belts on a CoreXY kinematic) # to be resonances points and must be similar on both belts on a CoreXY kinematic)
def pair_peaks(peaks1, freqs1, psd1, peaks2, freqs2, psd2): def pair_peaks(peaks1, freqs1, psd1, peaks2, freqs2, psd2):
@@ -97,6 +102,7 @@ def pair_peaks(peaks1, freqs1, psd1, peaks2, freqs2, psd2):
# Computation of the differential spectrogram # Computation of the differential spectrogram
###################################################################### ######################################################################
# Interpolate source_data (2D) to match target_x and target_y in order to # Interpolate source_data (2D) to match target_x and target_y in order to
# get similar time and frequency dimensions for the differential spectrogram # get similar time and frequency dimensions for the differential spectrogram
def interpolate_2d(target_x, target_y, source_x, source_y, source_data): def interpolate_2d(target_x, target_y, source_x, source_y, source_data):
@@ -163,41 +169,44 @@ def compute_mhi(combined_data, similarity_coefficient, num_unpaired_peaks):
# LUT to transform the MHI into a textual value easy to understand for the users of the script # LUT to transform the MHI into a textual value easy to understand for the users of the script
def mhi_lut(mhi): def mhi_lut(mhi):
ranges = [ ranges = [
(0, 30, "Excellent mechanical health"), (0, 30, 'Excellent mechanical health'),
(30, 45, "Good mechanical health"), (30, 45, 'Good mechanical health'),
(45, 55, "Acceptable mechanical health"), (45, 55, 'Acceptable mechanical health'),
(55, 70, "Potential signs of a mechanical issue"), (55, 70, 'Potential signs of a mechanical issue'),
(70, 85, "Likely a mechanical issue"), (70, 85, 'Likely a mechanical issue'),
(85, 100, "Mechanical issue detected") (85, 100, 'Mechanical issue detected'),
] ]
for lower, upper, message in ranges: for lower, upper, message in ranges:
if lower < mhi <= upper: if lower < mhi <= upper:
return message return message
return "Error computing MHI value" return 'Error computing MHI value'
###################################################################### ######################################################################
# Graphing # Graphing
###################################################################### ######################################################################
def plot_compare_frequency(ax, lognames, signal1, signal2, similarity_factor, max_freq): def plot_compare_frequency(ax, lognames, signal1, signal2, similarity_factor, max_freq):
# Get the belt name for the legend to avoid putting the full file name # Get the belt name for the legend to avoid putting the full file name
signal1_belt = (lognames[0].split('/')[-1]).split('_')[-1][0] signal1_belt = (lognames[0].split('/')[-1]).split('_')[-1][0]
signal2_belt = (lognames[1].split('/')[-1]).split('_')[-1][0] signal2_belt = (lognames[1].split('/')[-1]).split('_')[-1][0]
if signal1_belt == 'A' and signal2_belt == 'B': if signal1_belt == 'A' and signal2_belt == 'B':
signal1_belt += " (axis 1,-1)" signal1_belt += ' (axis 1,-1)'
signal2_belt += " (axis 1, 1)" signal2_belt += ' (axis 1, 1)'
elif signal1_belt == 'B' and signal2_belt == 'A': elif signal1_belt == 'B' and signal2_belt == 'A':
signal1_belt += " (axis 1, 1)" signal1_belt += ' (axis 1, 1)'
signal2_belt += " (axis 1,-1)" signal2_belt += ' (axis 1,-1)'
else: else:
print_with_c_locale("Warning: belts doesn't seem to have the correct name A and B (extracted from the filename.csv)") print_with_c_locale(
"Warning: belts doesn't seem to have the correct name A and B (extracted from the filename.csv)"
)
# Plot the two belts PSD signals # Plot the two belts PSD signals
ax.plot(signal1.freqs, signal1.psd, label="Belt " + signal1_belt, color=KLIPPAIN_COLORS['purple']) ax.plot(signal1.freqs, signal1.psd, label='Belt ' + signal1_belt, color=KLIPPAIN_COLORS['purple'])
ax.plot(signal2.freqs, signal2.psd, label="Belt " + signal2_belt, color=KLIPPAIN_COLORS['orange']) ax.plot(signal2.freqs, signal2.psd, label='Belt ' + signal2_belt, color=KLIPPAIN_COLORS['orange'])
# Trace the "relax region" (also used as a threshold to filter and detect the peaks) # Trace the "relax region" (also used as a threshold to filter and detect the peaks)
psd_lowest_max = min(signal1.psd.max(), signal2.psd.max()) psd_lowest_max = min(signal1.psd.max(), signal2.psd.max())
@@ -212,34 +221,67 @@ def plot_compare_frequency(ax, lognames, signal1, signal2, similarity_factor, ma
for _, (peak1, peak2) in enumerate(signal1.paired_peaks): for _, (peak1, peak2) in enumerate(signal1.paired_peaks):
label = ALPHABET[paired_peak_count] label = ALPHABET[paired_peak_count]
amplitude_offset = abs(((signal2.psd[peak2[0]] - signal1.psd[peak1[0]]) / max(signal1.psd[peak1[0]], signal2.psd[peak2[0]])) * 100) amplitude_offset = abs(
((signal2.psd[peak2[0]] - signal1.psd[peak1[0]]) / max(signal1.psd[peak1[0]], signal2.psd[peak2[0]])) * 100
)
frequency_offset = abs(signal2.freqs[peak2[0]] - signal1.freqs[peak1[0]]) frequency_offset = abs(signal2.freqs[peak2[0]] - signal1.freqs[peak1[0]])
offsets_table_data.append([f"Peaks {label}", f"{frequency_offset:.1f} Hz", f"{amplitude_offset:.1f} %"]) offsets_table_data.append([f'Peaks {label}', f'{frequency_offset:.1f} Hz', f'{amplitude_offset:.1f} %'])
ax.plot(signal1.freqs[peak1[0]], signal1.psd[peak1[0]], "x", color='black') ax.plot(signal1.freqs[peak1[0]], signal1.psd[peak1[0]], 'x', color='black')
ax.plot(signal2.freqs[peak2[0]], signal2.psd[peak2[0]], "x", color='black') ax.plot(signal2.freqs[peak2[0]], signal2.psd[peak2[0]], 'x', color='black')
ax.plot([signal1.freqs[peak1[0]], signal2.freqs[peak2[0]]], [signal1.psd[peak1[0]], signal2.psd[peak2[0]]], ":", color='gray') ax.plot(
[signal1.freqs[peak1[0]], signal2.freqs[peak2[0]]],
[signal1.psd[peak1[0]], signal2.psd[peak2[0]]],
':',
color='gray',
)
ax.annotate(label + "1", (signal1.freqs[peak1[0]], signal1.psd[peak1[0]]), ax.annotate(
textcoords="offset points", xytext=(8, 5), label + '1',
ha='left', fontsize=13, color='black') (signal1.freqs[peak1[0]], signal1.psd[peak1[0]]),
ax.annotate(label + "2", (signal2.freqs[peak2[0]], signal2.psd[peak2[0]]), textcoords='offset points',
textcoords="offset points", xytext=(8, 5), xytext=(8, 5),
ha='left', fontsize=13, color='black') ha='left',
fontsize=13,
color='black',
)
ax.annotate(
label + '2',
(signal2.freqs[peak2[0]], signal2.psd[peak2[0]]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='black',
)
paired_peak_count += 1 paired_peak_count += 1
for peak in signal1.unpaired_peaks: for peak in signal1.unpaired_peaks:
ax.plot(signal1.freqs[peak], signal1.psd[peak], "x", color='black') ax.plot(signal1.freqs[peak], signal1.psd[peak], 'x', color='black')
ax.annotate(str(unpaired_peak_count + 1), (signal1.freqs[peak], signal1.psd[peak]), ax.annotate(
textcoords="offset points", xytext=(8, 5), str(unpaired_peak_count + 1),
ha='left', fontsize=13, color='red', weight='bold') (signal1.freqs[peak], signal1.psd[peak]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='red',
weight='bold',
)
unpaired_peak_count += 1 unpaired_peak_count += 1
for peak in signal2.unpaired_peaks: for peak in signal2.unpaired_peaks:
ax.plot(signal2.freqs[peak], signal2.psd[peak], "x", color='black') ax.plot(signal2.freqs[peak], signal2.psd[peak], 'x', color='black')
ax.annotate(str(unpaired_peak_count + 1), (signal2.freqs[peak], signal2.psd[peak]), ax.annotate(
textcoords="offset points", xytext=(8, 5), str(unpaired_peak_count + 1),
ha='left', fontsize=13, color='red', weight='bold') (signal2.freqs[peak], signal2.psd[peak]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color='red',
weight='bold',
)
unpaired_peak_count += 1 unpaired_peak_count += 1
# Add estimated similarity to the graph # Add estimated similarity to the graph
@@ -257,17 +299,32 @@ def plot_compare_frequency(ax, lognames, signal1, signal2, similarity_factor, ma
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.ticklabel_format(axis='y', style='scientific', scilimits=(0,0)) ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0))
ax.grid(which='major', color='grey') ax.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey') ax.grid(which='minor', color='lightgrey')
fontP = matplotlib.font_manager.FontProperties() fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('small') fontP.set_size('small')
ax.set_title('Belts Frequency Profiles (estimated similarity: {:.1f}%)'.format(similarity_factor), fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title(
'Belts Frequency Profiles (estimated similarity: {:.1f}%)'.format(similarity_factor),
fontsize=14,
color=KLIPPAIN_COLORS['dark_orange'],
weight='bold',
)
# Print the table of offsets ontop of the graph below the original legend (upper right) # Print the table of offsets ontop of the graph below the original legend (upper right)
if len(offsets_table_data) > 0: if len(offsets_table_data) > 0:
columns = ["", "Frequency delta", "Amplitude delta", ] columns = [
offset_table = ax.table(cellText=offsets_table_data, colLabels=columns, bbox=[0.66, 0.75, 0.33, 0.15], loc='upper right', cellLoc='center') '',
'Frequency delta',
'Amplitude delta',
]
offset_table = ax.table(
cellText=offsets_table_data,
colLabels=columns,
bbox=[0.66, 0.75, 0.33, 0.15],
loc='upper right',
cellLoc='center',
)
offset_table.auto_set_font_size(False) offset_table.auto_set_font_size(False)
offset_table.set_fontsize(8) offset_table.set_fontsize(8)
offset_table.auto_set_column_width([0, 1, 2]) offset_table.auto_set_column_width([0, 1, 2])
@@ -284,19 +341,35 @@ def plot_compare_frequency(ax, lognames, signal1, signal2, similarity_factor, ma
def plot_difference_spectrogram(ax, signal1, signal2, t, bins, combined_divergent, textual_mhi, max_freq): def plot_difference_spectrogram(ax, signal1, signal2, t, bins, combined_divergent, textual_mhi, max_freq):
ax.set_title(f"Differential Spectrogram", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Differential Spectrogram', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.plot([], [], ' ', label=f'{textual_mhi} (experimental)') ax.plot([], [], ' ', label=f'{textual_mhi} (experimental)')
# Draw the differential spectrogram with a specific custom norm to get orange or purple values where there is signal or white near zeros # Draw the differential spectrogram with a specific custom norm to get orange or purple values where there is signal or white near zeros
# imgshow is better suited here than pcolormesh since its result is already rasterized and we doesn't need to keep vector graphics # imgshow is better suited here than pcolormesh since its result is already rasterized and we doesn't need to keep vector graphics
# when saving to a final .png file. Using it also allow to save ~150-200MB of RAM during the "fig.savefig" operation. # when saving to a final .png file. Using it also allow to save ~150-200MB of RAM during the "fig.savefig" operation.
colors = [KLIPPAIN_COLORS['dark_orange'], KLIPPAIN_COLORS['orange'], 'white', KLIPPAIN_COLORS['purple'], KLIPPAIN_COLORS['dark_purple']] colors = [
cm = matplotlib.colors.LinearSegmentedColormap.from_list('klippain_divergent', list(zip([0, 0.25, 0.5, 0.75, 1], colors))) KLIPPAIN_COLORS['dark_orange'],
KLIPPAIN_COLORS['orange'],
'white',
KLIPPAIN_COLORS['purple'],
KLIPPAIN_COLORS['dark_purple'],
]
cm = matplotlib.colors.LinearSegmentedColormap.from_list(
'klippain_divergent', list(zip([0, 0.25, 0.5, 0.75, 1], colors))
)
norm = matplotlib.colors.TwoSlopeNorm(vmin=np.min(combined_divergent), vcenter=0, vmax=np.max(combined_divergent)) norm = matplotlib.colors.TwoSlopeNorm(vmin=np.min(combined_divergent), vcenter=0, vmax=np.max(combined_divergent))
ax.imshow(combined_divergent.T, cmap=cm, norm=norm, aspect='auto', extent=[t[0], t[-1], bins[0], bins[-1]], interpolation='bilinear', origin='lower') ax.imshow(
combined_divergent.T,
cmap=cm,
norm=norm,
aspect='auto',
extent=[t[0], t[-1], bins[0], bins[-1]],
interpolation='bilinear',
origin='lower',
)
ax.set_xlabel('Frequency (hz)') ax.set_xlabel('Frequency (hz)')
ax.set_xlim([0., max_freq]) ax.set_xlim([0.0, max_freq])
ax.set_ylabel('Time (s)') ax.set_ylabel('Time (s)')
ax.set_ylim([0, bins[-1]]) ax.set_ylim([0, bins[-1]])
@@ -308,17 +381,31 @@ def plot_difference_spectrogram(ax, signal1, signal2, t, bins, combined_divergen
unpaired_peak_count = 0 unpaired_peak_count = 0
for _, peak in enumerate(signal1.unpaired_peaks): for _, peak in enumerate(signal1.unpaired_peaks):
ax.axvline(signal1.freqs[peak], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5) ax.axvline(signal1.freqs[peak], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5)
ax.annotate(f"Peak {unpaired_peak_count + 1}", (signal1.freqs[peak], t[-1]*0.05), ax.annotate(
textcoords="data", color=KLIPPAIN_COLORS['red_pink'], rotation=90, fontsize=10, f'Peak {unpaired_peak_count + 1}',
verticalalignment='bottom', horizontalalignment='right') (signal1.freqs[peak], t[-1] * 0.05),
unpaired_peak_count +=1 textcoords='data',
color=KLIPPAIN_COLORS['red_pink'],
rotation=90,
fontsize=10,
verticalalignment='bottom',
horizontalalignment='right',
)
unpaired_peak_count += 1
for _, peak in enumerate(signal2.unpaired_peaks): for _, peak in enumerate(signal2.unpaired_peaks):
ax.axvline(signal2.freqs[peak], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5) ax.axvline(signal2.freqs[peak], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5)
ax.annotate(f"Peak {unpaired_peak_count + 1}", (signal2.freqs[peak], t[-1]*0.05), ax.annotate(
textcoords="data", color=KLIPPAIN_COLORS['red_pink'], rotation=90, fontsize=10, f'Peak {unpaired_peak_count + 1}',
verticalalignment='bottom', horizontalalignment='right') (signal2.freqs[peak], t[-1] * 0.05),
unpaired_peak_count +=1 textcoords='data',
color=KLIPPAIN_COLORS['red_pink'],
rotation=90,
fontsize=10,
verticalalignment='bottom',
horizontalalignment='right',
)
unpaired_peak_count += 1
# Plot vertical lines and zones for paired peaks # Plot vertical lines and zones for paired peaks
for idx, (peak1, peak2) in enumerate(signal1.paired_peaks): for idx, (peak1, peak2) in enumerate(signal1.paired_peaks):
@@ -328,9 +415,16 @@ def plot_difference_spectrogram(ax, signal1, signal2, t, bins, combined_divergen
ax.axvline(x_min, color=KLIPPAIN_COLORS['dark_purple'], linestyle='dotted', linewidth=1.5) ax.axvline(x_min, color=KLIPPAIN_COLORS['dark_purple'], linestyle='dotted', linewidth=1.5)
ax.axvline(x_max, color=KLIPPAIN_COLORS['dark_purple'], linestyle='dotted', linewidth=1.5) ax.axvline(x_max, color=KLIPPAIN_COLORS['dark_purple'], linestyle='dotted', linewidth=1.5)
ax.fill_between([x_min, x_max], 0, np.max(combined_divergent), color=KLIPPAIN_COLORS['dark_purple'], alpha=0.3) ax.fill_between([x_min, x_max], 0, np.max(combined_divergent), color=KLIPPAIN_COLORS['dark_purple'], alpha=0.3)
ax.annotate(f"Peaks {label}", (x_min, t[-1]*0.05), ax.annotate(
textcoords="data", color=KLIPPAIN_COLORS['dark_purple'], rotation=90, fontsize=10, f'Peaks {label}',
verticalalignment='bottom', horizontalalignment='right') (x_min, t[-1] * 0.05),
textcoords='data',
color=KLIPPAIN_COLORS['dark_purple'],
rotation=90,
fontsize=10,
verticalalignment='bottom',
horizontalalignment='right',
)
return return
@@ -339,6 +433,7 @@ def plot_difference_spectrogram(ax, signal1, signal2, t, bins, combined_divergen
# Custom tools # Custom tools
###################################################################### ######################################################################
# Original Klipper function to get the PSD data of a raw accelerometer signal # Original Klipper function to get the PSD data of a raw accelerometer signal
def compute_signal_data(data, max_freq): def compute_signal_data(data, max_freq):
helper = shaper_calibrate.ShaperCalibrate(printer=None) helper = shaper_calibrate.ShaperCalibrate(printer=None)
@@ -356,7 +451,8 @@ def compute_signal_data(data, max_freq):
# Startup and main routines # Startup and main routines
###################################################################### ######################################################################
def belts_calibration(lognames, klipperdir="~/klipper", max_freq=200.):
def belts_calibration(lognames, klipperdir='~/klipper', max_freq=200.0, st_version=None):
set_locale() set_locale()
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
@@ -364,7 +460,7 @@ def belts_calibration(lognames, klipperdir="~/klipper", max_freq=200.):
# Parse data # Parse data
datas = [parse_log(fn) for fn in lognames] datas = [parse_log(fn) for fn in lognames]
if len(datas) > 2: if len(datas) > 2:
raise ValueError("Incorrect number of .csv files used (this function needs exactly two files to compare them)!") raise ValueError('Incorrect number of .csv files used (this function needs exactly two files to compare them)!')
# Compute calibration data for the two datasets with automatic peaks detection # Compute calibration data for the two datasets with automatic peaks detection
signal1 = compute_signal_data(datas[0], max_freq) signal1 = compute_signal_data(datas[0], max_freq)
@@ -373,41 +469,54 @@ def belts_calibration(lognames, klipperdir="~/klipper", max_freq=200.):
del datas del datas
# Pair the peaks across the two datasets # Pair the peaks across the two datasets
paired_peaks, unpaired_peaks1, unpaired_peaks2 = pair_peaks(signal1.peaks, signal1.freqs, signal1.psd, paired_peaks, unpaired_peaks1, unpaired_peaks2 = pair_peaks(
signal2.peaks, signal2.freqs, signal2.psd) signal1.peaks, signal1.freqs, signal1.psd, signal2.peaks, signal2.freqs, signal2.psd
signal1 = signal1._replace(paired_peaks = paired_peaks, unpaired_peaks = unpaired_peaks1) )
signal2 = signal2._replace(paired_peaks = paired_peaks, unpaired_peaks = unpaired_peaks2) signal1 = signal1._replace(paired_peaks=paired_peaks, unpaired_peaks=unpaired_peaks1)
signal2 = signal2._replace(paired_peaks=paired_peaks, unpaired_peaks=unpaired_peaks2)
# Compute the similarity (using cross-correlation of the PSD signals) # Compute the similarity (using cross-correlation of the PSD signals)
similarity_factor = compute_curve_similarity_factor(signal1.freqs, signal1.psd, signal2.freqs, signal2.psd, CURVE_SIMILARITY_SIGMOID_K) similarity_factor = compute_curve_similarity_factor(
print_with_c_locale(f"Belts estimated similarity: {similarity_factor:.1f}%") signal1.freqs, signal1.psd, signal2.freqs, signal2.psd, CURVE_SIMILARITY_SIGMOID_K
)
print_with_c_locale(f'Belts estimated similarity: {similarity_factor:.1f}%')
# Compute the MHI value from the differential spectrogram sum of gradient, salted with the similarity factor and the number of # Compute the MHI value from the differential spectrogram sum of gradient, salted with the similarity factor and the number of
# unpaired peaks from the belts frequency profile. Be careful, this value is highly opinionated and is pretty experimental! # unpaired peaks from the belts frequency profile. Be careful, this value is highly opinionated and is pretty experimental!
mhi, textual_mhi = compute_mhi(combined_sum, similarity_factor, len(signal1.unpaired_peaks) + len(signal2.unpaired_peaks)) mhi, textual_mhi = compute_mhi(
print_with_c_locale(f"[experimental] Mechanical Health Indicator: {textual_mhi.lower()} ({mhi:.1f}%)") combined_sum, similarity_factor, len(signal1.unpaired_peaks) + len(signal2.unpaired_peaks)
)
print_with_c_locale(f'[experimental] Mechanical Health Indicator: {textual_mhi.lower()} ({mhi:.1f}%)')
# Create graph layout # Create graph layout
fig, (ax1, ax2) = plt.subplots(2, 1, gridspec_kw={ fig, (ax1, ax2) = plt.subplots(
'height_ratios':[4, 3], 2,
'bottom':0.050, 1,
'top':0.890, gridspec_kw={
'left':0.085, 'height_ratios': [4, 3],
'right':0.966, 'bottom': 0.050,
'hspace':0.169, 'top': 0.890,
'wspace':0.200 'left': 0.085,
}) 'right': 0.966,
'hspace': 0.169,
'wspace': 0.200,
},
)
fig.set_size_inches(8.3, 11.6) fig.set_size_inches(8.3, 11.6)
# Add title # Add title
title_line1 = "RELATIVE BELTS CALIBRATION TOOL" title_line1 = 'RELATIVE BELTS CALIBRATION TOOL'
fig.text(0.12, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold') fig.text(
0.12, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold'
)
try: try:
filename = lognames[0].split('/')[-1] filename = lognames[0].split('/')[-1]
dt = datetime.strptime(f"{filename.split('_')[1]} {filename.split('_')[2]}", "%Y%m%d %H%M%S") dt = datetime.strptime(f"{filename.split('_')[1]} {filename.split('_')[2]}", '%Y%m%d %H%M%S')
title_line2 = dt.strftime('%x %X') title_line2 = dt.strftime('%x %X')
except: except Exception:
print_with_c_locale("Warning: CSV filenames look to be different than expected (%s , %s)" % (lognames[0], lognames[1])) print_with_c_locale(
title_line2 = lognames[0].split('/')[-1] + " / " + lognames[1].split('/')[-1] 'Warning: CSV filenames look to be different than expected (%s , %s)' % (lognames[0], lognames[1])
)
title_line2 = lognames[0].split('/')[-1] + ' / ' + lognames[1].split('/')[-1]
fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
# Plot the graphs # Plot the graphs
@@ -420,8 +529,7 @@ def belts_calibration(lognames, klipperdir="~/klipper", max_freq=200.):
ax_logo.axis('off') ax_logo.axis('off')
# Adding Shake&Tune version in the top right corner # Adding Shake&Tune version in the top right corner
st_version = get_git_version() if st_version != 'unknown':
if st_version is not None:
fig.text(0.995, 0.985, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple']) fig.text(0.995, 0.985, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple'])
return fig return fig
@@ -429,19 +537,18 @@ def belts_calibration(lognames, klipperdir="~/klipper", max_freq=200.):
def main(): def main():
# Parse command-line arguments # Parse command-line arguments
usage = "%prog [options] <raw logs>" usage = '%prog [options] <raw logs>'
opts = optparse.OptionParser(usage) opts = optparse.OptionParser(usage)
opts.add_option("-o", "--output", type="string", dest="output", opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
default=None, help="filename of output graph") opts.add_option('-f', '--max_freq', type='float', default=200.0, help='maximum frequency to graph')
opts.add_option("-f", "--max_freq", type="float", default=200., opts.add_option(
help="maximum frequency to graph") '-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory'
opts.add_option("-k", "--klipper_dir", type="string", dest="klipperdir", )
default="~/klipper", help="main klipper directory")
options, args = opts.parse_args() options, args = opts.parse_args()
if len(args) < 1: if len(args) < 1:
opts.error("Incorrect number of arguments") opts.error('Incorrect number of arguments')
if options.output is None: if options.output is None:
opts.error("You must specify an output file.png to use the script (option -o)") opts.error('You must specify an output file.png to use the script (option -o)')
fig = belts_calibration(args, options.klipperdir, options.max_freq) fig = belts_calibration(args, options.klipperdir, options.max_freq)
fig.savefig(options.output, dpi=150) fig.savefig(options.output, dpi=150)

View File

@@ -6,25 +6,28 @@
# Derived from the calibrate_shaper.py official Klipper script # Derived from the calibrate_shaper.py official Klipper script
# Copyright (C) 2020 Dmitry Butyugin <dmbutyugin@google.com> # Copyright (C) 2020 Dmitry Butyugin <dmbutyugin@google.com>
# Copyright (C) 2020 Kevin O'Connor <kevin@koconnor.net> # Copyright (C) 2020 Kevin O'Connor <kevin@koconnor.net>
# Written by Frix_x#0161 # # Highly modified and improved by Frix_x#0161 #
# Be sure to make this script executable using SSH: type 'chmod +x ./graph_shaper.py' when in the folder! import optparse
import os
#####################################################################
################ !!! DO NOT EDIT BELOW THIS LINE !!! ################
#####################################################################
import optparse, matplotlib, os
from datetime import datetime from datetime import datetime
import numpy as np
import matplotlib
import matplotlib.font_manager
import matplotlib.pyplot as plt import matplotlib.pyplot as plt
import matplotlib.font_manager, matplotlib.ticker import matplotlib.ticker
import numpy as np
matplotlib.use('Agg') matplotlib.use('Agg')
from locale_utils import set_locale, print_with_c_locale from ..helpers.common_func import (
from common_func import compute_mechanical_parameters, compute_spectrogram, detect_peaks, get_git_version, parse_log, setup_klipper_import compute_mechanical_parameters,
compute_spectrogram,
detect_peaks,
parse_log,
setup_klipper_import,
)
from ..helpers.locale_utils import print_with_c_locale, set_locale
PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_EFFECT_THRESHOLD = 0.12 PEAKS_EFFECT_THRESHOLD = 0.12
@@ -32,11 +35,11 @@ SPECTROGRAM_LOW_PERCENTILE_FILTER = 5
MAX_SMOOTHING = 0.1 MAX_SMOOTHING = 0.1
KLIPPAIN_COLORS = { KLIPPAIN_COLORS = {
"purple": "#70088C", 'purple': '#70088C',
"orange": "#FF8D32", 'orange': '#FF8D32',
"dark_purple": "#150140", 'dark_purple': '#150140',
"dark_orange": "#F24130", 'dark_orange': '#F24130',
"red_pink": "#F2055C" 'red_pink': '#F2055C',
} }
@@ -44,6 +47,7 @@ KLIPPAIN_COLORS = {
# Computation # Computation
###################################################################### ######################################################################
# Find the best shaper parameters using Klipper's official algorithm selection with # Find the best shaper parameters using Klipper's official algorithm selection with
# a proper precomputed damping ratio (zeta) and using the configured printer SQV value # a proper precomputed damping ratio (zeta) and using the configured printer SQV value
def calibrate_shaper(datas, max_smoothing, scv, max_freq): def calibrate_shaper(datas, max_smoothing, scv, max_freq):
@@ -54,22 +58,36 @@ def calibrate_shaper(datas, max_smoothing, scv, max_freq):
fr, zeta, _, _ = compute_mechanical_parameters(calibration_data.psd_sum, calibration_data.freq_bins) fr, zeta, _, _ = compute_mechanical_parameters(calibration_data.psd_sum, calibration_data.freq_bins)
# If the damping ratio computation fail, we use Klipper default value instead # If the damping ratio computation fail, we use Klipper default value instead
if zeta is None: zeta = 0.1 if zeta is None:
zeta = 0.1
compat = False compat = False
try: try:
shaper, all_shapers = helper.find_best_shaper( shaper, all_shapers = helper.find_best_shaper(
calibration_data, shapers=None, damping_ratio=zeta, calibration_data,
scv=scv, shaper_freqs=None, max_smoothing=max_smoothing, shapers=None,
test_damping_ratios=None, max_freq=max_freq, damping_ratio=zeta,
logger=print_with_c_locale) scv=scv,
shaper_freqs=None,
max_smoothing=max_smoothing,
test_damping_ratios=None,
max_freq=max_freq,
logger=print_with_c_locale,
)
except TypeError: except TypeError:
print_with_c_locale("[WARNING] You seem to be using an older version of Klipper that is not compatible with all the latest Shake&Tune features!") print_with_c_locale(
print_with_c_locale("Shake&Tune now runs in compatibility mode: be aware that the results may be slightly off, since the real damping ratio cannot be used to create the filter recommendations") '[WARNING] You seem to be using an older version of Klipper that is not compatible with all the latest Shake&Tune features!'
)
print_with_c_locale(
'Shake&Tune now runs in compatibility mode: be aware that the results may be slightly off, since the real damping ratio cannot be used to create the filter recommendations'
)
compat = True compat = True
shaper, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, print_with_c_locale) shaper, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, print_with_c_locale)
print_with_c_locale("\n-> Recommended shaper is %s @ %.1f Hz (when using a square corner velocity of %.1f and a damping ratio of %.3f)" % (shaper.name.upper(), shaper.freq, scv, zeta)) print_with_c_locale(
'\n-> Recommended shaper is %s @ %.1f Hz (when using a square corner velocity of %.1f and a damping ratio of %.3f)'
% (shaper.name.upper(), shaper.freq, scv, zeta)
)
return shaper.name, all_shapers, calibration_data, fr, zeta, compat return shaper.name, all_shapers, calibration_data, fr, zeta, compat
@@ -78,7 +96,10 @@ def calibrate_shaper(datas, max_smoothing, scv, max_freq):
# Graphing # Graphing
###################################################################### ######################################################################
def plot_freq_response(ax, calibration_data, shapers, performance_shaper, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq):
def plot_freq_response(
ax, calibration_data, shapers, performance_shaper, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq
):
freqs = calibration_data.freqs freqs = calibration_data.freqs
psd = calibration_data.psd_sum psd = calibration_data.psd_sum
px = calibration_data.psd_x px = calibration_data.psd_x
@@ -100,7 +121,7 @@ def plot_freq_response(ax, calibration_data, shapers, performance_shaper, peaks,
ax.xaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(5)) ax.xaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(5))
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.ticklabel_format(axis='y', style='scientific', scilimits=(0,0)) ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0))
ax.grid(which='major', color='grey') ax.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey') ax.grid(which='minor', color='lightgrey')
@@ -115,21 +136,27 @@ def plot_freq_response(ax, calibration_data, shapers, performance_shaper, peaks,
# Draw the shappers curves and add their specific parameters in the legend # Draw the shappers curves and add their specific parameters in the legend
# This adds also a way to find the best shaper with a low level of vibrations (with a resonable level of smoothing) # This adds also a way to find the best shaper with a low level of vibrations (with a resonable level of smoothing)
for shaper in shapers: for shaper in shapers:
shaper_max_accel = round(shaper.max_accel / 100.) * 100. shaper_max_accel = round(shaper.max_accel / 100.0) * 100.0
label = "%s (%.1f Hz, vibr=%.1f%%, sm~=%.2f, accel<=%.f)" % ( label = '%s (%.1f Hz, vibr=%.1f%%, sm~=%.2f, accel<=%.f)' % (
shaper.name.upper(), shaper.freq, shaper.name.upper(),
shaper.vibrs * 100., shaper.smoothing, shaper.freq,
shaper_max_accel) shaper.vibrs * 100.0,
shaper.smoothing,
shaper_max_accel,
)
ax2.plot(freqs, shaper.vals, label=label, linestyle='dotted') ax2.plot(freqs, shaper.vals, label=label, linestyle='dotted')
# Get the performance shaper # Get the performance shaper
if shaper.name == performance_shaper: if shaper.name == performance_shaper:
performance_shaper_freq = shaper.freq performance_shaper_freq = shaper.freq
performance_shaper_vibr = shaper.vibrs * 100. performance_shaper_vibr = shaper.vibrs * 100.0
performance_shaper_vals = shaper.vals performance_shaper_vals = shaper.vals
# Get the low vibration shaper # Get the low vibration shaper
if (shaper.vibrs * 100 < lowvib_shaper_vibrs or (shaper.vibrs * 100 == lowvib_shaper_vibrs and shaper_max_accel > lowvib_shaper_accel)) and shaper.smoothing < MAX_SMOOTHING: if (
shaper.vibrs * 100 < lowvib_shaper_vibrs
or (shaper.vibrs * 100 == lowvib_shaper_vibrs and shaper_max_accel > lowvib_shaper_accel)
) and shaper.smoothing < MAX_SMOOTHING:
lowvib_shaper_accel = shaper_max_accel lowvib_shaper_accel = shaper_max_accel
lowvib_shaper = shaper.name lowvib_shaper = shaper.name
lowvib_shaper_freq = shaper.freq lowvib_shaper_freq = shaper.freq
@@ -140,21 +167,45 @@ def plot_freq_response(ax, calibration_data, shapers, performance_shaper, peaks,
# and the other one is the custom "low vibration" recommendation that looks for a suitable shaper that doesn't have excessive # and the other one is the custom "low vibration" recommendation that looks for a suitable shaper that doesn't have excessive
# smoothing and that have a lower vibration level. If both recommendation are the same shaper, or if no suitable "low # smoothing and that have a lower vibration level. If both recommendation are the same shaper, or if no suitable "low
# vibration" shaper is found, then only a single line as the "best shaper" recommendation is added to the legend # vibration" shaper is found, then only a single line as the "best shaper" recommendation is added to the legend
if lowvib_shaper != None and lowvib_shaper != performance_shaper and lowvib_shaper_vibrs <= performance_shaper_vibr: if (
ax2.plot([], [], ' ', label="Recommended performance shaper: %s @ %.1f Hz" % (performance_shaper.upper(), performance_shaper_freq)) lowvib_shaper is not None
ax.plot(freqs, psd * performance_shaper_vals, label='With %s applied' % (performance_shaper.upper()), color='cyan') and lowvib_shaper != performance_shaper
ax2.plot([], [], ' ', label="Recommended low vibrations shaper: %s @ %.1f Hz" % (lowvib_shaper.upper(), lowvib_shaper_freq)) and lowvib_shaper_vibrs <= performance_shaper_vibr
):
ax2.plot(
[],
[],
' ',
label='Recommended performance shaper: %s @ %.1f Hz'
% (performance_shaper.upper(), performance_shaper_freq),
)
ax.plot(
freqs, psd * performance_shaper_vals, label='With %s applied' % (performance_shaper.upper()), color='cyan'
)
ax2.plot(
[],
[],
' ',
label='Recommended low vibrations shaper: %s @ %.1f Hz' % (lowvib_shaper.upper(), lowvib_shaper_freq),
)
ax.plot(freqs, psd * lowvib_shaper_vals, label='With %s applied' % (lowvib_shaper.upper()), color='lime') ax.plot(freqs, psd * lowvib_shaper_vals, label='With %s applied' % (lowvib_shaper.upper()), color='lime')
else: else:
ax2.plot([], [], ' ', label="Recommended best shaper: %s @ %.1f Hz" % (performance_shaper.upper(), performance_shaper_freq)) ax2.plot(
ax.plot(freqs, psd * performance_shaper_vals, label='With %s applied' % (performance_shaper.upper()), color='cyan') [],
[],
' ',
label='Recommended best shaper: %s @ %.1f Hz' % (performance_shaper.upper(), performance_shaper_freq),
)
ax.plot(
freqs, psd * performance_shaper_vals, label='With %s applied' % (performance_shaper.upper()), color='cyan'
)
# And the estimated damping ratio is finally added at the end of the legend # And the estimated damping ratio is finally added at the end of the legend
ax2.plot([], [], ' ', label="Estimated damping ratio (ζ): %.3f" % (zeta)) ax2.plot([], [], ' ', label='Estimated damping ratio (ζ): %.3f' % (zeta))
# Draw the detected peaks and name them # Draw the detected peaks and name them
# This also draw the detection threshold and warning threshold (aka "effect zone") # This also draw the detection threshold and warning threshold (aka "effect zone")
ax.plot(peaks_freqs, psd[peaks], "x", color='black', markersize=8) ax.plot(peaks_freqs, psd[peaks], 'x', color='black', markersize=8)
for idx, peak in enumerate(peaks): for idx, peak in enumerate(peaks):
if psd[peak] > peaks_threshold[1]: if psd[peak] > peaks_threshold[1]:
fontcolor = 'red' fontcolor = 'red'
@@ -162,16 +213,28 @@ def plot_freq_response(ax, calibration_data, shapers, performance_shaper, peaks,
else: else:
fontcolor = 'black' fontcolor = 'black'
fontweight = 'normal' fontweight = 'normal'
ax.annotate(f"{idx+1}", (freqs[peak], psd[peak]), ax.annotate(
textcoords="offset points", xytext=(8, 5), f'{idx+1}',
ha='left', fontsize=13, color=fontcolor, weight=fontweight) (freqs[peak], psd[peak]),
textcoords='offset points',
xytext=(8, 5),
ha='left',
fontsize=13,
color=fontcolor,
weight=fontweight,
)
ax.axhline(y=peaks_threshold[0], color='black', linestyle='--', linewidth=0.5) ax.axhline(y=peaks_threshold[0], color='black', linestyle='--', linewidth=0.5)
ax.axhline(y=peaks_threshold[1], color='black', linestyle='--', linewidth=0.5) ax.axhline(y=peaks_threshold[1], color='black', linestyle='--', linewidth=0.5)
ax.fill_between(freqs, 0, peaks_threshold[0], color='green', alpha=0.15, label='Relax Region') ax.fill_between(freqs, 0, peaks_threshold[0], color='green', alpha=0.15, label='Relax Region')
ax.fill_between(freqs, peaks_threshold[0], peaks_threshold[1], color='orange', alpha=0.2, label='Warning Region') ax.fill_between(freqs, peaks_threshold[0], peaks_threshold[1], color='orange', alpha=0.2, label='Warning Region')
# Add the main resonant frequency and damping ratio of the axis to the graph title # Add the main resonant frequency and damping ratio of the axis to the graph title
ax.set_title("Axis Frequency Profile (ω0=%.1fHz, ζ=%.3f)" % (fr, zeta), fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title(
'Axis Frequency Profile (ω0=%.1fHz, ζ=%.3f)' % (fr, zeta),
fontsize=14,
color=KLIPPAIN_COLORS['dark_orange'],
weight='bold',
)
ax.legend(loc='upper left', prop=fontP) ax.legend(loc='upper left', prop=fontP)
ax2.legend(loc='upper right', prop=fontP) ax2.legend(loc='upper right', prop=fontP)
@@ -181,7 +244,7 @@ def plot_freq_response(ax, calibration_data, shapers, performance_shaper, peaks,
# Plot a time-frequency spectrogram to see how the system respond over time during the # Plot a time-frequency spectrogram to see how the system respond over time during the
# resonnance test. This can highlight hidden spots from the standard PSD graph from other harmonics # resonnance test. This can highlight hidden spots from the standard PSD graph from other harmonics
def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq): def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq):
ax.set_title("Time-Frequency Spectrogram", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Time-Frequency Spectrogram', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
# We need to normalize the data to get a proper signal on the spectrogram # We need to normalize the data to get a proper signal on the spectrogram
# However, while using "LogNorm" provide too much background noise, using # However, while using "LogNorm" provide too much background noise, using
@@ -194,9 +257,17 @@ def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq):
# save ~150-200MB of RAM during the "fig.savefig" operation. # save ~150-200MB of RAM during the "fig.savefig" operation.
cm = 'inferno' cm = 'inferno'
norm = matplotlib.colors.LogNorm(vmin=vmin_value) norm = matplotlib.colors.LogNorm(vmin=vmin_value)
ax.imshow(pdata.T, norm=norm, cmap=cm, aspect='auto', extent=[t[0], t[-1], bins[0], bins[-1]], origin='lower', interpolation='antialiased') ax.imshow(
pdata.T,
norm=norm,
cmap=cm,
aspect='auto',
extent=[t[0], t[-1], bins[0], bins[-1]],
origin='lower',
interpolation='antialiased',
)
ax.set_xlim([0., max_freq]) ax.set_xlim([0.0, max_freq])
ax.set_ylabel('Time (s)') ax.set_ylabel('Time (s)')
ax.set_xlabel('Frequency (Hz)') ax.set_xlabel('Frequency (Hz)')
@@ -204,9 +275,16 @@ def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq):
if peaks is not None: if peaks is not None:
for idx, peak in enumerate(peaks): for idx, peak in enumerate(peaks):
ax.axvline(peak, color='cyan', linestyle='dotted', linewidth=1) ax.axvline(peak, color='cyan', linestyle='dotted', linewidth=1)
ax.annotate(f"Peak {idx+1}", (peak, bins[-1]*0.9), ax.annotate(
textcoords="data", color='cyan', rotation=90, fontsize=10, f'Peak {idx+1}',
verticalalignment='top', horizontalalignment='right') (peak, bins[-1] * 0.9),
textcoords='data',
color='cyan',
rotation=90,
fontsize=10,
verticalalignment='top',
horizontalalignment='right',
)
return return
@@ -215,7 +293,8 @@ def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq):
# Startup and main routines # Startup and main routines
###################################################################### ######################################################################
def shaper_calibration(lognames, klipperdir="~/klipper", max_smoothing=None, scv=5. , max_freq=200.):
def shaper_calibration(lognames, klipperdir='~/klipper', max_smoothing=None, scv=5.0, max_freq=200.0, st_version=None):
set_locale() set_locale()
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
@@ -223,10 +302,12 @@ def shaper_calibration(lognames, klipperdir="~/klipper", max_smoothing=None, scv
# Parse data # Parse data
datas = [parse_log(fn) for fn in lognames] datas = [parse_log(fn) for fn in lognames]
if len(datas) > 1: if len(datas) > 1:
print_with_c_locale("Warning: incorrect number of .csv files detected. Only the first one will be used!") print_with_c_locale('Warning: incorrect number of .csv files detected. Only the first one will be used!')
# Compute shapers, PSD outputs and spectrogram # Compute shapers, PSD outputs and spectrogram
performance_shaper, shapers, calibration_data, fr, zeta, compat = calibrate_shaper(datas[0], max_smoothing, scv, max_freq) performance_shaper, shapers, calibration_data, fr, zeta, compat = calibrate_shaper(
datas[0], max_smoothing, scv, max_freq
)
pdata, bins, t = compute_spectrogram(datas[0]) pdata, bins, t = compute_spectrogram(datas[0])
del datas del datas
@@ -241,42 +322,51 @@ def shaper_calibration(lognames, klipperdir="~/klipper", max_smoothing=None, scv
# Peak detection algorithm # Peak detection algorithm
peaks_threshold = [ peaks_threshold = [
PEAKS_DETECTION_THRESHOLD * calibration_data.psd_sum.max(), PEAKS_DETECTION_THRESHOLD * calibration_data.psd_sum.max(),
PEAKS_EFFECT_THRESHOLD * calibration_data.psd_sum.max() PEAKS_EFFECT_THRESHOLD * calibration_data.psd_sum.max(),
] ]
num_peaks, peaks, peaks_freqs = detect_peaks(calibration_data.psd_sum, calibration_data.freqs, peaks_threshold[0]) num_peaks, peaks, peaks_freqs = detect_peaks(calibration_data.psd_sum, calibration_data.freqs, peaks_threshold[0])
# Print the peaks info in the console # Print the peaks info in the console
peak_freqs_formated = ["{:.1f}".format(f) for f in peaks_freqs] peak_freqs_formated = ['{:.1f}'.format(f) for f in peaks_freqs]
num_peaks_above_effect_threshold = np.sum(calibration_data.psd_sum[peaks] > peaks_threshold[1]) num_peaks_above_effect_threshold = np.sum(calibration_data.psd_sum[peaks] > peaks_threshold[1])
print_with_c_locale("\nPeaks detected on the graph: %d @ %s Hz (%d above effect threshold)" % (num_peaks, ", ".join(map(str, peak_freqs_formated)), num_peaks_above_effect_threshold)) print_with_c_locale(
'\nPeaks detected on the graph: %d @ %s Hz (%d above effect threshold)'
% (num_peaks, ', '.join(map(str, peak_freqs_formated)), num_peaks_above_effect_threshold)
)
# Create graph layout # Create graph layout
fig, (ax1, ax2) = plt.subplots(2, 1, gridspec_kw={ fig, (ax1, ax2) = plt.subplots(
'height_ratios':[4, 3], 2,
'bottom':0.050, 1,
'top':0.890, gridspec_kw={
'left':0.085, 'height_ratios': [4, 3],
'right':0.966, 'bottom': 0.050,
'hspace':0.169, 'top': 0.890,
'wspace':0.200 'left': 0.085,
}) 'right': 0.966,
'hspace': 0.169,
'wspace': 0.200,
},
)
fig.set_size_inches(8.3, 11.6) fig.set_size_inches(8.3, 11.6)
# Add a title with some test info # Add a title with some test info
title_line1 = "INPUT SHAPER CALIBRATION TOOL" title_line1 = 'INPUT SHAPER CALIBRATION TOOL'
fig.text(0.12, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold') fig.text(
0.12, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold'
)
try: try:
filename_parts = (lognames[0].split('/')[-1]).split('_') filename_parts = (lognames[0].split('/')[-1]).split('_')
dt = datetime.strptime(f"{filename_parts[1]} {filename_parts[2]}", "%Y%m%d %H%M%S") dt = datetime.strptime(f'{filename_parts[1]} {filename_parts[2]}', '%Y%m%d %H%M%S')
title_line2 = dt.strftime('%x %X') + ' -- ' + filename_parts[3].upper().split('.')[0] + ' axis' title_line2 = dt.strftime('%x %X') + ' -- ' + filename_parts[3].upper().split('.')[0] + ' axis'
if compat: if compat:
title_line3: '| Compatibility mode with older Klipper,' title_line3 = '| Compatibility mode with older Klipper,'
title_line4: '| and no custom S&T parameters are used!' title_line4 = '| and no custom S&T parameters are used!'
else: else:
title_line3 = '| Square corner velocity: ' + str(scv) + 'mm/s' title_line3 = '| Square corner velocity: ' + str(scv) + 'mm/s'
title_line4 = '| Max allowed smoothing: ' + str(max_smoothing) title_line4 = '| Max allowed smoothing: ' + str(max_smoothing)
except: except Exception:
print_with_c_locale("Warning: CSV filename look to be different than expected (%s)" % (lognames[0])) print_with_c_locale('Warning: CSV filename look to be different than expected (%s)' % (lognames[0]))
title_line2 = lognames[0].split('/')[-1] title_line2 = lognames[0].split('/')[-1]
title_line3 = '' title_line3 = ''
title_line4 = '' title_line4 = ''
@@ -285,7 +375,9 @@ def shaper_calibration(lognames, klipperdir="~/klipper", max_smoothing=None, scv
fig.text(0.58, 0.946, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.58, 0.946, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'])
# Plot the graphs # Plot the graphs
plot_freq_response(ax1, calibration_data, shapers, performance_shaper, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq) plot_freq_response(
ax1, calibration_data, shapers, performance_shaper, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq
)
plot_spectrogram(ax2, t, bins, pdata, peaks_freqs, max_freq) plot_spectrogram(ax2, t, bins, pdata, peaks_freqs, max_freq)
# Adding a small Klippain logo to the top left corner of the figure # Adding a small Klippain logo to the top left corner of the figure
@@ -294,8 +386,7 @@ def shaper_calibration(lognames, klipperdir="~/klipper", max_smoothing=None, scv
ax_logo.axis('off') ax_logo.axis('off')
# Adding Shake&Tune version in the top right corner # Adding Shake&Tune version in the top right corner
st_version = get_git_version() if st_version != 'unknown':
if st_version is not None:
fig.text(0.995, 0.985, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple']) fig.text(0.995, 0.985, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple'])
return fig return fig
@@ -303,25 +394,24 @@ def shaper_calibration(lognames, klipperdir="~/klipper", max_smoothing=None, scv
def main(): def main():
# Parse command-line arguments # Parse command-line arguments
usage = "%prog [options] <logs>" usage = '%prog [options] <logs>'
opts = optparse.OptionParser(usage) opts = optparse.OptionParser(usage)
opts.add_option("-o", "--output", type="string", dest="output", opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
default=None, help="filename of output graph") opts.add_option('-f', '--max_freq', type='float', default=200.0, help='maximum frequency to graph')
opts.add_option("-f", "--max_freq", type="float", default=200., opts.add_option('-s', '--max_smoothing', type='float', default=None, help='maximum shaper smoothing to allow')
help="maximum frequency to graph") opts.add_option(
opts.add_option("-s", "--max_smoothing", type="float", default=None, '--scv', '--square_corner_velocity', type='float', dest='scv', default=5.0, help='square corner velocity'
help="maximum shaper smoothing to allow") )
opts.add_option("--scv", "--square_corner_velocity", type="float", opts.add_option(
dest="scv", default=5., help="square corner velocity") '-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory'
opts.add_option("-k", "--klipper_dir", type="string", dest="klipperdir", )
default="~/klipper", help="main klipper directory")
options, args = opts.parse_args() options, args = opts.parse_args()
if len(args) < 1: if len(args) < 1:
opts.error("Incorrect number of arguments") opts.error('Incorrect number of arguments')
if options.output is None: if options.output is None:
opts.error("You must specify an output file.png to use the script (option -o)") opts.error('You must specify an output file.png to use the script (option -o)')
if options.max_smoothing is not None and options.max_smoothing < 0.05: if options.max_smoothing is not None and options.max_smoothing < 0.05:
opts.error("Too small max_smoothing specified (must be at least 0.05)") opts.error('Too small max_smoothing specified (must be at least 0.05)')
fig = shaper_calibration(args, options.klipperdir, options.max_smoothing, options.scv, options.max_freq) fig = shaper_calibration(args, options.klipperdir, options.max_smoothing, options.scv, options.max_freq)
fig.savefig(options.output, dpi=150) fig.savefig(options.output, dpi=150)

View File

@@ -5,26 +5,30 @@
################################################## ##################################################
# Written by Frix_x#0161 # # Written by Frix_x#0161 #
# Be sure to make this script executable using SSH: type 'chmod +x ./graph_dir_vibrations.py' when in the folder !
#####################################################################
################ !!! DO NOT EDIT BELOW THIS LINE !!! ################
#####################################################################
import math import math
import optparse, matplotlib, re, os import optparse
from datetime import datetime import os
import re
from collections import defaultdict from collections import defaultdict
import numpy as np from datetime import datetime
import matplotlib.pyplot as plt
import matplotlib.font_manager, matplotlib.ticker, matplotlib.gridspec
import matplotlib
import matplotlib.font_manager
import matplotlib.gridspec
import matplotlib.pyplot as plt
import matplotlib.ticker
import numpy as np
matplotlib.use('Agg') matplotlib.use('Agg')
from locale_utils import set_locale, print_with_c_locale from ..helpers.common_func import (
from common_func import get_git_version, parse_log, setup_klipper_import, identify_low_energy_zones, compute_curve_similarity_factor, compute_mechanical_parameters, detect_peaks compute_mechanical_parameters,
detect_peaks,
identify_low_energy_zones,
parse_log,
setup_klipper_import,
)
from ..helpers.locale_utils import print_with_c_locale, set_locale
PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04 PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04
@@ -34,11 +38,11 @@ SPEEDS_AROUND_PEAK_DELETION = 3 # to delete +-3mm/s around a peak
ANGLES_VALLEY_DETECTION_THRESHOLD = 1.1 # Lower is more sensitive ANGLES_VALLEY_DETECTION_THRESHOLD = 1.1 # Lower is more sensitive
KLIPPAIN_COLORS = { KLIPPAIN_COLORS = {
"purple": "#70088C", 'purple': '#70088C',
"orange": "#FF8D32", 'orange': '#FF8D32',
"dark_purple": "#150140", 'dark_purple': '#150140',
"dark_orange": "#F24130", 'dark_orange': '#F24130',
"red_pink": "#F2055C" 'red_pink': '#F2055C',
} }
@@ -46,6 +50,7 @@ KLIPPAIN_COLORS = {
# Computation # Computation
###################################################################### ######################################################################
# Call to the official Klipper input shaper object to do the PSD computation # Call to the official Klipper input shaper object to do the PSD computation
def calc_freq_response(data): def calc_freq_response(data):
helper = shaper_calibrate.ShaperCalibrate(printer=None) helper = shaper_calibrate.ShaperCalibrate(printer=None)
@@ -54,7 +59,10 @@ def calc_freq_response(data):
# Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements for the machine kinematics # Calculate motor frequency profiles based on the measured Power Spectral Density (PSD) measurements for the machine kinematics
# main angles and then create a global motor profile as a weighted average (from their own vibrations) of all calculated profiles # main angles and then create a global motor profile as a weighted average (from their own vibrations) of all calculated profiles
def compute_motor_profiles(freqs, psds, all_angles_energy, measured_angles=[0, 90], energy_amplification_factor=2): def compute_motor_profiles(freqs, psds, all_angles_energy, measured_angles=None, energy_amplification_factor=2):
if measured_angles is None:
measured_angles = [0, 90]
motor_profiles = {} motor_profiles = {}
weighted_sum_profiles = np.zeros_like(freqs) weighted_sum_profiles = np.zeros_like(freqs)
total_weight = 0 total_weight = 0
@@ -67,8 +75,12 @@ def compute_motor_profiles(freqs, psds, all_angles_energy, measured_angles=[0, 9
motor_profiles[angle] = np.convolve(sum_curve / len(psds[angle]), conv_filter, mode='same') motor_profiles[angle] = np.convolve(sum_curve / len(psds[angle]), conv_filter, mode='same')
# Calculate weights # Calculate weights
angle_energy = all_angles_energy[angle] ** energy_amplification_factor # First weighting factor is based on the total vibrations of the machine at the specified angle angle_energy = (
curve_area = np.trapz(motor_profiles[angle], freqs) ** energy_amplification_factor # Additional weighting factor is based on the area under the current motor profile at this specified angle all_angles_energy[angle] ** energy_amplification_factor
) # First weighting factor is based on the total vibrations of the machine at the specified angle
curve_area = (
np.trapz(motor_profiles[angle], freqs) ** energy_amplification_factor
) # Additional weighting factor is based on the area under the current motor profile at this specified angle
total_angle_weight = angle_energy * curve_area total_angle_weight = angle_energy * curve_area
# Update weighted sum profiles to get the global motor profile # Update weighted sum profiles to get the global motor profile
@@ -85,19 +97,24 @@ def compute_motor_profiles(freqs, psds, all_angles_energy, measured_angles=[0, 9
# the effects of each speeds at each angles, this function simplify it by using only the main motors axes (X/Y for Cartesian # the effects of each speeds at each angles, this function simplify it by using only the main motors axes (X/Y for Cartesian
# printers and A/B for CoreXY) measurements and project each points on the [0,360] degrees range using trigonometry # printers and A/B for CoreXY) measurements and project each points on the [0,360] degrees range using trigonometry
# to "sum" the vibration impact of each axis at every points of the generated spectrogram. The result is very similar at the end. # to "sum" the vibration impact of each axis at every points of the generated spectrogram. The result is very similar at the end.
def compute_dir_speed_spectrogram(measured_speeds, data, kinematics="cartesian", measured_angles=[0, 90]): def compute_dir_speed_spectrogram(measured_speeds, data, kinematics='cartesian', measured_angles=None):
if measured_angles is None:
measured_angles = [0, 90]
# We want to project the motor vibrations measured on their own axes on the [0, 360] range # We want to project the motor vibrations measured on their own axes on the [0, 360] range
spectrum_angles = np.linspace(0, 360, 720) # One point every 0.5 degrees spectrum_angles = np.linspace(0, 360, 720) # One point every 0.5 degrees
spectrum_speeds = np.linspace(min(measured_speeds), max(measured_speeds), len(measured_speeds) * 6) spectrum_speeds = np.linspace(min(measured_speeds), max(measured_speeds), len(measured_speeds) * 6)
spectrum_vibrations = np.zeros((len(spectrum_angles), len(spectrum_speeds))) spectrum_vibrations = np.zeros((len(spectrum_angles), len(spectrum_speeds)))
def get_interpolated_vibrations(data, speed, speeds): def get_interpolated_vibrations(data, speed, speeds):
idx = np.clip(np.searchsorted(speeds, speed, side="left"), 1, len(speeds) - 1) idx = np.clip(np.searchsorted(speeds, speed, side='left'), 1, len(speeds) - 1)
lower_speed = speeds[idx - 1] lower_speed = speeds[idx - 1]
upper_speed = speeds[idx] upper_speed = speeds[idx]
lower_vibrations = data.get(lower_speed, 0) lower_vibrations = data.get(lower_speed, 0)
upper_vibrations = data.get(upper_speed, 0) upper_vibrations = data.get(upper_speed, 0)
return lower_vibrations + (speed - lower_speed) * (upper_vibrations - lower_vibrations) / (upper_speed - lower_speed) return lower_vibrations + (speed - lower_speed) * (upper_vibrations - lower_vibrations) / (
upper_speed - lower_speed
)
# Precompute trigonometric values and constant before the loop # Precompute trigonometric values and constant before the loop
angle_radians = np.deg2rad(spectrum_angles) angle_radians = np.deg2rad(spectrum_angles)
@@ -108,10 +125,10 @@ def compute_dir_speed_spectrogram(measured_speeds, data, kinematics="cartesian",
# Compute the spectrum vibrations for each angle and speed combination # Compute the spectrum vibrations for each angle and speed combination
for target_angle_idx, (cos_val, sin_val) in enumerate(zip(cos_vals, sin_vals)): for target_angle_idx, (cos_val, sin_val) in enumerate(zip(cos_vals, sin_vals)):
for target_speed_idx, target_speed in enumerate(spectrum_speeds): for target_speed_idx, target_speed in enumerate(spectrum_speeds):
if kinematics == "cartesian": if kinematics == 'cartesian':
speed_1 = np.abs(target_speed * cos_val) speed_1 = np.abs(target_speed * cos_val)
speed_2 = np.abs(target_speed * sin_val) speed_2 = np.abs(target_speed * sin_val)
elif kinematics == "corexy": elif kinematics == 'corexy':
speed_1 = np.abs(target_speed * (cos_val + sin_val) * sqrt_2_inv) speed_1 = np.abs(target_speed * (cos_val + sin_val) * sqrt_2_inv)
speed_2 = np.abs(target_speed * (cos_val - sin_val) * sqrt_2_inv) speed_2 = np.abs(target_speed * (cos_val - sin_val) * sqrt_2_inv)
@@ -129,7 +146,7 @@ def compute_angle_powers(spectrogram_data):
# the array to start and end of it to smooth transitions when doing the convolution # the array to start and end of it to smooth transitions when doing the convolution
# and get the same value at modulo 360. Then we return the array without the extras # and get the same value at modulo 360. Then we return the array without the extras
extended_angles_powers = np.concatenate([angles_powers[-9:], angles_powers, angles_powers[:9]]) extended_angles_powers = np.concatenate([angles_powers[-9:], angles_powers, angles_powers[:9]])
convolved_extended = np.convolve(extended_angles_powers, np.ones(15)/15, mode='same') convolved_extended = np.convolve(extended_angles_powers, np.ones(15) / 15, mode='same')
return convolved_extended[9:-9] return convolved_extended[9:-9]
@@ -149,6 +166,7 @@ def compute_speed_powers(spectrogram_data, smoothing_window=15):
# utility function to pad and smooth the data avoiding edge effects # utility function to pad and smooth the data avoiding edge effects
conv_filter = np.ones(smoothing_window) / smoothing_window conv_filter = np.ones(smoothing_window) / smoothing_window
window = int(smoothing_window / 2) window = int(smoothing_window / 2)
def pad_and_smooth(data): def pad_and_smooth(data):
data_padded = np.pad(data, (window,), mode='edge') data_padded = np.pad(data, (window,), mode='edge')
smoothed_data = np.convolve(data_padded, conv_filter, mode='valid') smoothed_data = np.convolve(data_padded, conv_filter, mode='valid')
@@ -207,7 +225,10 @@ def filter_and_split_ranges(all_speeds, good_speeds, peak_speed_indices, deletio
# This function allow the computation of a symmetry score that reflect the spectrogram apparent symmetry between # This function allow the computation of a symmetry score that reflect the spectrogram apparent symmetry between
# measured axes on both the shape of the signal and the energy level consistency across both side of the signal # measured axes on both the shape of the signal and the energy level consistency across both side of the signal
def compute_symmetry_analysis(all_angles, spectrogram_data, measured_angles=[0, 90]): def compute_symmetry_analysis(all_angles, spectrogram_data, measured_angles=None):
if measured_angles is None:
measured_angles = [0, 90]
total_spectrogram_angles = len(all_angles) total_spectrogram_angles = len(all_angles)
half_spectrogram_angles = total_spectrogram_angles // 2 half_spectrogram_angles = total_spectrogram_angles // 2
@@ -220,8 +241,8 @@ def compute_symmetry_analysis(all_angles, spectrogram_data, measured_angles=[0,
half_segment_length = half_spectrogram_angles // 2 half_segment_length = half_spectrogram_angles // 2
# Slice out the two segments of the spectrogram and flatten them for comparison # Slice out the two segments of the spectrogram and flatten them for comparison
segment_1_flattened = extended_spectrogram[split_index - half_segment_length:split_index].flatten() segment_1_flattened = extended_spectrogram[split_index - half_segment_length : split_index].flatten()
segment_2_flattened = extended_spectrogram[split_index:split_index + half_segment_length].flatten() segment_2_flattened = extended_spectrogram[split_index : split_index + half_segment_length].flatten()
# Compute the correlation coefficient between the two segments of spectrogram # Compute the correlation coefficient between the two segments of spectrogram
correlation = np.corrcoef(segment_1_flattened, segment_2_flattened)[0, 1] correlation = np.corrcoef(segment_1_flattened, segment_2_flattened)[0, 1]
@@ -234,10 +255,11 @@ def compute_symmetry_analysis(all_angles, spectrogram_data, measured_angles=[0,
# Graphing # Graphing
###################################################################### ######################################################################
def plot_angle_profile_polar(ax, angles, angles_powers, low_energy_zones, symmetry_factor): def plot_angle_profile_polar(ax, angles, angles_powers, low_energy_zones, symmetry_factor):
angles_radians = np.deg2rad(angles) angles_radians = np.deg2rad(angles)
ax.set_title("Polar angle energy profile", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Polar angle energy profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_theta_zero_location('E') ax.set_theta_zero_location('E')
ax.set_theta_direction(1) ax.set_theta_direction(1)
@@ -246,14 +268,38 @@ def plot_angle_profile_polar(ax, angles, angles_powers, low_energy_zones, symmet
ax.set_xlim([0, np.deg2rad(360)]) ax.set_xlim([0, np.deg2rad(360)])
ymax = angles_powers.max() * 1.05 ymax = angles_powers.max() * 1.05
ax.set_ylim([0, ymax]) ax.set_ylim([0, ymax])
ax.set_thetagrids([theta * 15 for theta in range(360//15)]) ax.set_thetagrids([theta * 15 for theta in range(360 // 15)])
ax.text(0, 0, f'Symmetry: {symmetry_factor:.1f}%', ha='center', va='center', color=KLIPPAIN_COLORS['red_pink'], fontsize=12, fontweight='bold', zorder=6) ax.text(
0,
0,
f'Symmetry: {symmetry_factor:.1f}%',
ha='center',
va='center',
color=KLIPPAIN_COLORS['red_pink'],
fontsize=12,
fontweight='bold',
zorder=6,
)
for _, (start, end, _) in enumerate(low_energy_zones): for _, (start, end, _) in enumerate(low_energy_zones):
ax.axvline(angles_radians[start], angles_powers[start]/ymax, color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5) ax.axvline(
ax.axvline(angles_radians[end], angles_powers[end]/ymax, color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5) angles_radians[start],
ax.fill_between(angles_radians[start:end], angles_powers[start:end], angles_powers.max() * 1.05, color='green', alpha=0.2) angles_powers[start] / ymax,
color=KLIPPAIN_COLORS['red_pink'],
linestyle='dotted',
linewidth=1.5,
)
ax.axvline(
angles_radians[end],
angles_powers[end] / ymax,
color=KLIPPAIN_COLORS['red_pink'],
linestyle='dotted',
linewidth=1.5,
)
ax.fill_between(
angles_radians[start:end], angles_powers[start:end], angles_powers.max() * 1.05, color='green', alpha=0.2
)
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
@@ -267,8 +313,19 @@ def plot_angle_profile_polar(ax, angles, angles_powers, low_energy_zones, symmet
return return
def plot_global_speed_profile(ax, all_speeds, sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric, num_peaks, peaks, low_energy_zones):
ax.set_title("Global speed energy profile", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') def plot_global_speed_profile(
ax,
all_speeds,
sp_min_energy,
sp_max_energy,
sp_variance_energy,
vibration_metric,
num_peaks,
peaks,
low_energy_zones,
):
ax.set_title('Global speed energy profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)') ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Energy') ax.set_ylabel('Energy')
ax2 = ax.twinx() ax2 = ax.twinx()
@@ -277,7 +334,13 @@ def plot_global_speed_profile(ax, all_speeds, sp_min_energy, sp_max_energy, sp_v
ax.plot(all_speeds, sp_min_energy, label='Minimum', color=KLIPPAIN_COLORS['dark_purple'], zorder=5) ax.plot(all_speeds, sp_min_energy, label='Minimum', color=KLIPPAIN_COLORS['dark_purple'], zorder=5)
ax.plot(all_speeds, sp_max_energy, label='Maximum', color=KLIPPAIN_COLORS['purple'], zorder=5) ax.plot(all_speeds, sp_max_energy, label='Maximum', color=KLIPPAIN_COLORS['purple'], zorder=5)
ax.plot(all_speeds, sp_variance_energy, label='Variance', color=KLIPPAIN_COLORS['orange'], zorder=5, linestyle='--') ax.plot(all_speeds, sp_variance_energy, label='Variance', color=KLIPPAIN_COLORS['orange'], zorder=5, linestyle='--')
ax2.plot(all_speeds, vibration_metric, label=f'Vibration metric ({num_peaks} bad peaks)', color=KLIPPAIN_COLORS['red_pink'], zorder=5) ax2.plot(
all_speeds,
vibration_metric,
label=f'Vibration metric ({num_peaks} bad peaks)',
color=KLIPPAIN_COLORS['red_pink'],
zorder=5,
)
ax.set_xlim([all_speeds.min(), all_speeds.max()]) ax.set_xlim([all_speeds.min(), all_speeds.max()])
ax.set_ylim([0, sp_max_energy.max() * 1.15]) ax.set_ylim([0, sp_max_energy.max() * 1.15])
@@ -286,17 +349,32 @@ def plot_global_speed_profile(ax, all_speeds, sp_min_energy, sp_max_energy, sp_v
y2max = vibration_metric.max() * 1.07 y2max = vibration_metric.max() * 1.07
ax2.set_ylim([y2min, y2max]) ax2.set_ylim([y2min, y2max])
if peaks is not None: if peaks is not None and len(peaks) > 0:
ax2.plot(all_speeds[peaks], vibration_metric[peaks], "x", color='black', markersize=8, zorder=10) ax2.plot(all_speeds[peaks], vibration_metric[peaks], 'x', color='black', markersize=8, zorder=10)
for idx, peak in enumerate(peaks): for idx, peak in enumerate(peaks):
ax2.annotate(f"{idx+1}", (all_speeds[peak], vibration_metric[peak]), ax2.annotate(
textcoords="offset points", xytext=(5, 5), fontweight='bold', f'{idx+1}',
ha='left', fontsize=13, color=KLIPPAIN_COLORS['red_pink'], zorder=10) (all_speeds[peak], vibration_metric[peak]),
textcoords='offset points',
xytext=(5, 5),
fontweight='bold',
ha='left',
fontsize=13,
color=KLIPPAIN_COLORS['red_pink'],
zorder=10,
)
for idx, (start, end, _) in enumerate(low_energy_zones): for idx, (start, end, _) in enumerate(low_energy_zones):
# ax2.axvline(all_speeds[start], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5, zorder=8) # ax2.axvline(all_speeds[start], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5, zorder=8)
# ax2.axvline(all_speeds[end], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5, zorder=8) # ax2.axvline(all_speeds[end], color=KLIPPAIN_COLORS['red_pink'], linestyle='dotted', linewidth=1.5, zorder=8)
ax2.fill_between(all_speeds[start:end], y2min, vibration_metric[start:end], color='green', alpha=0.2, label=f'Zone {idx+1}: {all_speeds[start]:.1f} to {all_speeds[end]:.1f} mm/s') ax2.fill_between(
all_speeds[start:end],
y2min,
vibration_metric[start:end],
color='green',
alpha=0.2,
label=f'Zone {idx+1}: {all_speeds[start]:.1f} to {all_speeds[end]:.1f} mm/s',
)
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
@@ -310,8 +388,9 @@ def plot_global_speed_profile(ax, all_speeds, sp_min_energy, sp_max_energy, sp_v
return return
def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics="cartesian"):
ax.set_title("Angular speed energy profiles", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics='cartesian'):
ax.set_title('Angular speed energy profiles', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)') ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Energy') ax.set_ylabel('Energy')
@@ -319,13 +398,13 @@ def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics
angle_settings = { angle_settings = {
0: ('X (0 deg)', 'purple', 10), 0: ('X (0 deg)', 'purple', 10),
90: ('Y (90 deg)', 'dark_purple', 5), 90: ('Y (90 deg)', 'dark_purple', 5),
45: ('A (45 deg)' if kinematics == "corexy" else '45 deg', 'orange', 10), 45: ('A (45 deg)' if kinematics == 'corexy' else '45 deg', 'orange', 10),
135: ('B (135 deg)' if kinematics == "corexy" else '135 deg', 'dark_orange', 5), 135: ('B (135 deg)' if kinematics == 'corexy' else '135 deg', 'dark_orange', 5),
} }
# Plot each angle using settings from the dictionary # Plot each angle using settings from the dictionary
for angle, (label, color, zorder) in angle_settings.items(): for angle, (label, color, zorder) in angle_settings.items():
idx = np.searchsorted(angles, angle, side="left") idx = np.searchsorted(angles, angle, side='left')
ax.plot(speeds, spectrogram_data[idx], label=label, color=KLIPPAIN_COLORS[color], zorder=zorder) ax.plot(speeds, spectrogram_data[idx], label=label, color=KLIPPAIN_COLORS[color], zorder=zorder)
ax.set_xlim([speeds.min(), speeds.max()]) ax.set_xlim([speeds.min(), speeds.max()])
@@ -343,8 +422,9 @@ def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics
return return
def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_profile, max_freq): def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_profile, max_freq):
ax.set_title("Motor frequency profile", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Motor frequency profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_ylabel('Energy') ax.set_ylabel('Energy')
ax.set_xlabel('Frequency (Hz)') ax.set_xlabel('Frequency (Hz)')
@@ -352,49 +432,61 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro
ax2.yaxis.set_visible(False) ax2.yaxis.set_visible(False)
# Global weighted average motor profile # Global weighted average motor profile
ax.plot(freqs, global_motor_profile, label="Combined", color=KLIPPAIN_COLORS['purple'], zorder=5) ax.plot(freqs, global_motor_profile, label='Combined', color=KLIPPAIN_COLORS['purple'], zorder=5)
max_value = global_motor_profile.max() max_value = global_motor_profile.max()
# Mapping of angles to axis names # Mapping of angles to axis names
angle_settings = { angle_settings = {0: 'X', 90: 'Y', 45: 'A', 135: 'B'}
0: "X",
90: "Y",
45: "A",
135: "B"
}
# And then plot the motor profiles at each measured angles # And then plot the motor profiles at each measured angles
for angle in main_angles: for angle in main_angles:
profile_max = motor_profiles[angle].max() profile_max = motor_profiles[angle].max()
if profile_max > max_value: if profile_max > max_value:
max_value = profile_max max_value = profile_max
label = f"{angle_settings[angle]} ({angle} deg)" if angle in angle_settings else f"{angle} deg" label = f'{angle_settings[angle]} ({angle} deg)' if angle in angle_settings else f'{angle} deg'
ax.plot(freqs, motor_profiles[angle], linestyle='--', label=label, zorder=2) ax.plot(freqs, motor_profiles[angle], linestyle='--', label=label, zorder=2)
ax.set_xlim([0, max_freq]) ax.set_xlim([0, max_freq])
ax.set_ylim([0, max_value * 1.1]) ax.set_ylim([0, max_value * 1.1])
ax.ticklabel_format(axis='y', style='scientific', scilimits=(0,0)) ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0))
# Then add the motor resonance peak to the graph and print some infos about it # Then add the motor resonance peak to the graph and print some infos about it
motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(global_motor_profile, freqs, 30) motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(global_motor_profile, freqs, 30)
if lowfreq_max: if lowfreq_max:
print_with_c_locale("[WARNING] There are a lot of low frequency vibrations that can alter the readings. This is probably due to the test being performed at too high an acceleration!") print_with_c_locale(
print_with_c_locale("Try lowering the ACCEL value and/or increasing the SIZE value before restarting the macro to ensure that only constant speeds are being recorded and that the dynamic behavior of the machine is not affecting the measurements") '[WARNING] There are a lot of low frequency vibrations that can alter the readings. This is probably due to the test being performed at too high an acceleration!'
)
print_with_c_locale(
'Try lowering the ACCEL value and/or increasing the SIZE value before restarting the macro to ensure that only constant speeds are being recorded and that the dynamic behavior of the machine is not affecting the measurements'
)
if motor_zeta is not None: if motor_zeta is not None:
print_with_c_locale("Motors have a main resonant frequency at %.1fHz with an estimated damping ratio of %.3f" % (motor_fr, motor_zeta)) print_with_c_locale(
'Motors have a main resonant frequency at %.1fHz with an estimated damping ratio of %.3f'
% (motor_fr, motor_zeta)
)
else: else:
print_with_c_locale("Motors have a main resonant frequency at %.1fHz but it was impossible to estimate a damping ratio." % (motor_fr)) print_with_c_locale(
'Motors have a main resonant frequency at %.1fHz but it was impossible to estimate a damping ratio.'
% (motor_fr)
)
ax.plot(freqs[motor_res_idx], global_motor_profile[motor_res_idx], "x", color='black', markersize=10) ax.plot(freqs[motor_res_idx], global_motor_profile[motor_res_idx], 'x', color='black', markersize=10)
ax.annotate(f"R", (freqs[motor_res_idx], global_motor_profile[motor_res_idx]), ax.annotate(
textcoords="offset points", xytext=(15, 5), 'R',
ha='right', fontsize=14, color=KLIPPAIN_COLORS['red_pink'], weight='bold') (freqs[motor_res_idx], global_motor_profile[motor_res_idx]),
textcoords='offset points',
xytext=(15, 5),
ha='right',
fontsize=14,
color=KLIPPAIN_COLORS['red_pink'],
weight='bold',
)
ax2.plot([], [], ' ', label="Motor resonant frequency (ω0): %.1fHz" % (motor_fr)) ax2.plot([], [], ' ', label='Motor resonant frequency (ω0): %.1fHz' % (motor_fr))
if motor_zeta is not None: if motor_zeta is not None:
ax2.plot([], [], ' ', label="Motor damping ratio (ζ): %.3f" % (motor_zeta)) ax2.plot([], [], ' ', label='Motor damping ratio (ζ): %.3f' % (motor_zeta))
else: else:
ax2.plot([], [], ' ', label="No damping ratio computed") ax2.plot([], [], ' ', label='No damping ratio computed')
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
@@ -408,6 +500,7 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro
return return
def plot_vibration_spectrogram_polar(ax, angles, speeds, spectrogram_data): def plot_vibration_spectrogram_polar(ax, angles, speeds, spectrogram_data):
angles_radians = np.radians(angles) angles_radians = np.radians(angles)
@@ -415,12 +508,14 @@ def plot_vibration_spectrogram_polar(ax, angles, speeds, spectrogram_data):
# for both angles and speeds to map the spectrogram data onto a polar plot correctly # for both angles and speeds to map the spectrogram data onto a polar plot correctly
radius, theta = np.meshgrid(speeds, angles_radians) radius, theta = np.meshgrid(speeds, angles_radians)
ax.set_title("Polar vibrations heatmap", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold', va='bottom') ax.set_title(
ax.set_theta_zero_location("E") 'Polar vibrations heatmap', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold', va='bottom'
)
ax.set_theta_zero_location('E')
ax.set_theta_direction(1) ax.set_theta_direction(1)
ax.pcolormesh(theta, radius, spectrogram_data, norm=matplotlib.colors.LogNorm(), cmap='inferno', shading='auto') ax.pcolormesh(theta, radius, spectrogram_data, norm=matplotlib.colors.LogNorm(), cmap='inferno', shading='auto')
ax.set_thetagrids([theta * 15 for theta in range(360//15)]) ax.set_thetagrids([theta * 15 for theta in range(360 // 15)])
ax.tick_params(axis='y', which='both', colors='white', labelsize='medium') ax.tick_params(axis='y', which='both', colors='white', labelsize='medium')
ax.set_ylim([0, max(speeds)]) ax.set_ylim([0, max(speeds)])
@@ -431,22 +526,36 @@ def plot_vibration_spectrogram_polar(ax, angles, speeds, spectrogram_data):
return return
def plot_vibration_spectrogram(ax, angles, speeds, spectrogram_data, peaks): def plot_vibration_spectrogram(ax, angles, speeds, spectrogram_data, peaks):
ax.set_title("Vibrations heatmap", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') ax.set_title('Vibrations heatmap', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)') ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Angle (deg)') ax.set_ylabel('Angle (deg)')
ax.imshow(spectrogram_data, norm=matplotlib.colors.LogNorm(), cmap='inferno', ax.imshow(
aspect='auto', extent=[speeds[0], speeds[-1], angles[0], angles[-1]], spectrogram_data,
origin='lower', interpolation='antialiased') norm=matplotlib.colors.LogNorm(),
cmap='inferno',
aspect='auto',
extent=[speeds[0], speeds[-1], angles[0], angles[-1]],
origin='lower',
interpolation='antialiased',
)
# Add peaks lines in the spectrogram to get hint from peaks found in the first graph # Add peaks lines in the spectrogram to get hint from peaks found in the first graph
if peaks is not None: if peaks is not None and len(peaks) > 0:
for idx, peak in enumerate(peaks): for idx, peak in enumerate(peaks):
ax.axvline(speeds[peak], color='cyan', linewidth=0.75) ax.axvline(speeds[peak], color='cyan', linewidth=0.75)
ax.annotate(f"Peak {idx+1}", (speeds[peak], angles[-1]*0.9), ax.annotate(
textcoords="data", color='cyan', rotation=90, fontsize=10, f'Peak {idx+1}',
verticalalignment='top', horizontalalignment='right') (speeds[peak], angles[-1] * 0.9),
textcoords='data',
color='cyan',
rotation=90,
fontsize=10,
verticalalignment='top',
horizontalalignment='right',
)
return return
@@ -455,26 +564,31 @@ def plot_vibration_spectrogram(ax, angles, speeds, spectrogram_data, peaks):
# Startup and main routines # Startup and main routines
###################################################################### ######################################################################
def extract_angle_and_speed(logname): def extract_angle_and_speed(logname):
try: try:
match = re.search(r'an(\d+)_\d+sp(\d+)_\d+', os.path.basename(logname)) match = re.search(r'an(\d+)_\d+sp(\d+)_\d+', os.path.basename(logname))
if match: if match:
angle = match.group(1) angle = match.group(1)
speed = match.group(2) speed = match.group(2)
except AttributeError: except AttributeError as err:
raise ValueError(f"File {logname} does not match expected format.") raise ValueError(f'File {logname} does not match expected format.') from err
return float(angle), float(speed) return float(angle), float(speed)
def vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesian", accel=None, max_freq=1000.): def vibrations_profile(
lognames, klipperdir='~/klipper', kinematics='cartesian', accel=None, max_freq=1000.0, st_version=None
):
set_locale() set_locale()
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
if kinematics == "cartesian": main_angles = [0, 90] if kinematics == 'cartesian':
elif kinematics == "corexy": main_angles = [45, 135] main_angles = [0, 90]
elif kinematics == 'corexy':
main_angles = [45, 135]
else: else:
raise ValueError("Only Cartesian and CoreXY kinematics are supported by this tool at the moment!") raise ValueError('Only Cartesian and CoreXY kinematics are supported by this tool at the moment!')
psds = defaultdict(lambda: defaultdict(list)) psds = defaultdict(lambda: defaultdict(list))
psds_sum = defaultdict(lambda: defaultdict(list)) psds_sum = defaultdict(lambda: defaultdict(list))
@@ -503,27 +617,35 @@ def vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesian",
for main_angle in main_angles: for main_angle in main_angles:
if main_angle not in measured_angles: if main_angle not in measured_angles:
raise ValueError("Measurements not taken at the correct angles for the specified kinematics!") raise ValueError('Measurements not taken at the correct angles for the specified kinematics!')
# Precompute the variables used in plot functions # Precompute the variables used in plot functions
all_angles, all_speeds, spectrogram_data = compute_dir_speed_spectrogram(measured_speeds, psds_sum, kinematics, main_angles) all_angles, all_speeds, spectrogram_data = compute_dir_speed_spectrogram(
measured_speeds, psds_sum, kinematics, main_angles
)
all_angles_energy = compute_angle_powers(spectrogram_data) all_angles_energy = compute_angle_powers(spectrogram_data)
sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric = compute_speed_powers(spectrogram_data) sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric = compute_speed_powers(spectrogram_data)
motor_profiles, global_motor_profile = compute_motor_profiles(target_freqs, psds, all_angles_energy, main_angles) motor_profiles, global_motor_profile = compute_motor_profiles(target_freqs, psds, all_angles_energy, main_angles)
# symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy) # symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy)
symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles) symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles)
print_with_c_locale(f"Machine estimated vibration symmetry: {symmetry_factor:.1f}%") print_with_c_locale(f'Machine estimated vibration symmetry: {symmetry_factor:.1f}%')
# Analyze low variance ranges of vibration energy across all angles for each speed to identify clean speeds # Analyze low variance ranges of vibration energy across all angles for each speed to identify clean speeds
# and highlight them. Also find the peaks to identify speeds to avoid due to high resonances # and highlight them. Also find the peaks to identify speeds to avoid due to high resonances
num_peaks, vibration_peaks, peaks_speeds = detect_peaks( num_peaks, vibration_peaks, peaks_speeds = detect_peaks(
vibration_metric, all_speeds, vibration_metric,
all_speeds,
PEAKS_DETECTION_THRESHOLD * vibration_metric.max(), PEAKS_DETECTION_THRESHOLD * vibration_metric.max(),
PEAKS_RELATIVE_HEIGHT_THRESHOLD, 10, 10 PEAKS_RELATIVE_HEIGHT_THRESHOLD,
10,
10,
)
formated_peaks_speeds = ['{:.1f}'.format(pspeed) for pspeed in peaks_speeds]
print_with_c_locale(
'Vibrations peaks detected: %d @ %s mm/s (avoid setting a speed near these values in your slicer print profile)'
% (num_peaks, ', '.join(map(str, formated_peaks_speeds)))
) )
formated_peaks_speeds = ["{:.1f}".format(pspeed) for pspeed in peaks_speeds]
print_with_c_locale("Vibrations peaks detected: %d @ %s mm/s (avoid setting a speed near these values in your slicer print profile)" % (num_peaks, ", ".join(map(str, formated_peaks_speeds))))
good_speeds = identify_low_energy_zones(vibration_metric, SPEEDS_VALLEY_DETECTION_THRESHOLD) good_speeds = identify_low_energy_zones(vibration_metric, SPEEDS_VALLEY_DETECTION_THRESHOLD)
if good_speeds is not None: if good_speeds is not None:
@@ -543,19 +665,25 @@ def vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesian",
if good_angles is not None: if good_angles is not None:
print_with_c_locale(f'Lowest vibrations angles ({len(good_angles)} ranges sorted from best to worse):') print_with_c_locale(f'Lowest vibrations angles ({len(good_angles)} ranges sorted from best to worse):')
for idx, (start, end, energy) in enumerate(good_angles): for idx, (start, end, energy) in enumerate(good_angles):
print_with_c_locale(f'{idx+1}: {all_angles[start]:.1f}° to {all_angles[end]:.1f}° (mean vibrations energy: {energy:.2f}% of max)') print_with_c_locale(
f'{idx+1}: {all_angles[start]:.1f}° to {all_angles[end]:.1f}° (mean vibrations energy: {energy:.2f}% of max)'
)
# Create graph layout # Create graph layout
fig, ((ax1, ax2, ax3), (ax4, ax5, ax6)) = plt.subplots(2, 3, gridspec_kw={ fig, ((ax1, ax2, ax3), (ax4, ax5, ax6)) = plt.subplots(
'height_ratios':[1, 1], 2,
'width_ratios':[4, 8, 6], 3,
'bottom':0.050, gridspec_kw={
'top':0.890, 'height_ratios': [1, 1],
'left':0.040, 'width_ratios': [4, 8, 6],
'right':0.985, 'bottom': 0.050,
'hspace':0.166, 'top': 0.890,
'wspace':0.138 'left': 0.040,
}) 'right': 0.985,
'hspace': 0.166,
'wspace': 0.138,
},
)
# Transform ax3 and ax4 to polar plots # Transform ax3 and ax4 to polar plots
ax1.remove() ax1.remove()
@@ -567,16 +695,18 @@ def vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesian",
fig.set_size_inches(20, 11.5) fig.set_size_inches(20, 11.5)
# Add title # Add title
title_line1 = "MACHINE VIBRATIONS ANALYSIS TOOL" title_line1 = 'MACHINE VIBRATIONS ANALYSIS TOOL'
fig.text(0.060, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold') fig.text(
0.060, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold'
)
try: try:
filename_parts = (lognames[0].split('/')[-1]).split('_') filename_parts = (lognames[0].split('/')[-1]).split('_')
dt = datetime.strptime(f"{filename_parts[1]} {filename_parts[2].split('-')[0]}", "%Y%m%d %H%M%S") dt = datetime.strptime(f"{filename_parts[1]} {filename_parts[2].split('-')[0]}", '%Y%m%d %H%M%S')
title_line2 = dt.strftime('%x %X') title_line2 = dt.strftime('%x %X')
if accel is not None: if accel is not None:
title_line2 += ' at ' + str(accel) + ' mm/s² -- ' + kinematics.upper() + ' kinematics' title_line2 += ' at ' + str(accel) + ' mm/s² -- ' + kinematics.upper() + ' kinematics'
except: except Exception:
print_with_c_locale("Warning: CSV filenames appear to be different than expected (%s)" % (lognames[0])) print_with_c_locale('Warning: CSV filenames appear to be different than expected (%s)' % (lognames[0]))
title_line2 = lognames[0].split('/')[-1] title_line2 = lognames[0].split('/')[-1]
fig.text(0.060, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) fig.text(0.060, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
@@ -584,7 +714,17 @@ def vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesian",
plot_angle_profile_polar(ax1, all_angles, all_angles_energy, good_angles, symmetry_factor) plot_angle_profile_polar(ax1, all_angles, all_angles_energy, good_angles, symmetry_factor)
plot_vibration_spectrogram_polar(ax4, all_angles, all_speeds, spectrogram_data) plot_vibration_spectrogram_polar(ax4, all_angles, all_speeds, spectrogram_data)
plot_global_speed_profile(ax2, all_speeds, sp_min_energy, sp_max_energy, sp_variance_energy, vibration_metric, num_peaks, vibration_peaks, good_speeds) plot_global_speed_profile(
ax2,
all_speeds,
sp_min_energy,
sp_max_energy,
sp_variance_energy,
vibration_metric,
num_peaks,
vibration_peaks,
good_speeds,
)
plot_angular_speed_profiles(ax3, all_speeds, all_angles, spectrogram_data, kinematics) plot_angular_speed_profiles(ax3, all_speeds, all_angles, spectrogram_data, kinematics)
plot_vibration_spectrogram(ax5, all_angles, all_speeds, spectrogram_data, vibration_peaks) plot_vibration_spectrogram(ax5, all_angles, all_speeds, spectrogram_data, vibration_peaks)
@@ -596,8 +736,7 @@ def vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesian",
ax_logo.axis('off') ax_logo.axis('off')
# Adding Shake&Tune version in the top right corner # Adding Shake&Tune version in the top right corner
st_version = get_git_version() if st_version != 'unknown':
if st_version is not None:
fig.text(0.995, 0.985, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple']) fig.text(0.995, 0.985, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple'])
return fig return fig
@@ -605,25 +744,31 @@ def vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesian",
def main(): def main():
# Parse command-line arguments # Parse command-line arguments
usage = "%prog [options] <raw logs>" usage = '%prog [options] <raw logs>'
opts = optparse.OptionParser(usage) opts = optparse.OptionParser(usage)
opts.add_option("-o", "--output", type="string", dest="output", opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph')
default=None, help="filename of output graph") opts.add_option(
opts.add_option("-c", "--accel", type="int", dest="accel", '-c', '--accel', type='int', dest='accel', default=None, help='accel value to be printed on the graph'
default=None, help="accel value to be printed on the graph") )
opts.add_option("-f", "--max_freq", type="float", default=1000., opts.add_option('-f', '--max_freq', type='float', default=1000.0, help='maximum frequency to graph')
help="maximum frequency to graph") opts.add_option(
opts.add_option("-k", "--klipper_dir", type="string", dest="klipperdir", '-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory'
default="~/klipper", help="main klipper directory") )
opts.add_option("-m", "--kinematics", type="string", dest="kinematics", opts.add_option(
default="cartesian", help="machine kinematics configuration") '-m',
'--kinematics',
type='string',
dest='kinematics',
default='cartesian',
help='machine kinematics configuration',
)
options, args = opts.parse_args() options, args = opts.parse_args()
if len(args) < 1: if len(args) < 1:
opts.error("No CSV file(s) to analyse") opts.error('No CSV file(s) to analyse')
if options.output is None: if options.output is None:
opts.error("You must specify an output file.png to use the script (option -o)") opts.error('You must specify an output file.png to use the script (option -o)')
if options.kinematics not in ["cartesian", "corexy"]: if options.kinematics not in ['cartesian', 'corexy']:
opts.error("Only cartesian and corexy kinematics are supported by this tool at the moment!") opts.error('Only cartesian and corexy kinematics are supported by this tool at the moment!')
fig = vibrations_profile(args, options.klipperdir, options.kinematics, options.accel, options.max_freq) fig = vibrations_profile(args, options.klipperdir, options.kinematics, options.accel, options.max_freq)
fig.savefig(options.output, dpi=150) fig.savefig(options.output, dpi=150)

View File

Before

Width:  |  Height:  |  Size: 607 KiB

After

Width:  |  Height:  |  Size: 607 KiB

0
src/helpers/__init__.py Normal file
View File

View File

@@ -4,12 +4,14 @@
# Written by Frix_x#0161 # # Written by Frix_x#0161 #
import math import math
import os, sys import os
import sys
from importlib import import_module from importlib import import_module
from pathlib import Path from pathlib import Path
import numpy as np import numpy as np
from scipy.signal import spectrogram
from git import GitCommandError, Repo from git import GitCommandError, Repo
from scipy.signal import spectrogram
def parse_log(logname): def parse_log(logname):
@@ -21,9 +23,11 @@ def parse_log(logname):
# Raw accelerometer data # Raw accelerometer data
return np.loadtxt(logname, comments='#', delimiter=',') return np.loadtxt(logname, comments='#', delimiter=',')
# Power spectral density data or shaper calibration data # Power spectral density data or shaper calibration data
raise ValueError("File %s does not contain raw accelerometer data and therefore " raise ValueError(
"is not supported by Shake&Tune. Please use the official Klipper " 'File %s does not contain raw accelerometer data and therefore '
"script to process it instead." % (logname,)) 'is not supported by Shake&Tune. Please use the official Klipper '
'script to process it instead.' % (logname,)
)
def setup_klipper_import(kdir): def setup_klipper_import(kdir):
@@ -38,7 +42,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
script_path = Path(__file__).resolve() script_path = Path(__file__).resolve()
repo_path = script_path.parents[2] repo_path = script_path.parents[1]
repo = Repo(repo_path) repo = Repo(repo_path)
try: try:
@@ -48,7 +52,7 @@ def get_git_version():
version = repo.head.commit.hexsha[:7] version = repo.head.commit.hexsha[:7]
return version return version
except Exception as e: except Exception:
return None return None
@@ -57,12 +61,13 @@ def compute_spectrogram(data):
N = data.shape[0] N = data.shape[0]
Fs = N / (data[-1, 0] - data[0, 0]) Fs = N / (data[-1, 0] - data[0, 0])
# Round up to a power of 2 for faster FFT # Round up to a power of 2 for faster FFT
M = 1 << int(.5 * Fs - 1).bit_length() M = 1 << int(0.5 * Fs - 1).bit_length()
window = np.kaiser(M, 6.) window = np.kaiser(M, 6.0)
def _specgram(x): def _specgram(x):
return spectrogram(x, fs=Fs, window=window, nperseg=M, noverlap=M//2, return spectrogram(
detrend='constant', scaling='density', mode='psd') x, fs=Fs, window=window, nperseg=M, noverlap=M // 2, detrend='constant', scaling='density', mode='psd'
)
d = {'x': data[:, 1], 'y': data[:, 2], 'z': data[:, 3]} d = {'x': data[:, 1], 'y': data[:, 2], 'z': data[:, 3]}
f, t, pdata = _specgram(d['x']) f, t, pdata = _specgram(d['x'])
@@ -104,17 +109,26 @@ def compute_mechanical_parameters(psd, freqs, min_freq=None):
idx_below = indices_below[-1] idx_below = indices_below[-1]
idx_above = indices_above[0] + max_power_index idx_above = indices_above[0] + max_power_index
freq_below_half_power = freqs[idx_below] + (half_power - psd[idx_below]) * (freqs[idx_below + 1] - freqs[idx_below]) / (psd[idx_below + 1] - psd[idx_below]) freq_below_half_power = freqs[idx_below] + (half_power - psd[idx_below]) * (
freq_above_half_power = freqs[idx_above - 1] + (half_power - psd[idx_above - 1]) * (freqs[idx_above] - freqs[idx_above - 1]) / (psd[idx_above] - psd[idx_above - 1]) freqs[idx_below + 1] - freqs[idx_below]
) / (psd[idx_below + 1] - psd[idx_below])
freq_above_half_power = freqs[idx_above - 1] + (half_power - psd[idx_above - 1]) * (
freqs[idx_above] - freqs[idx_above - 1]
) / (psd[idx_above] - psd[idx_above - 1])
bandwidth = freq_above_half_power - freq_below_half_power bandwidth = freq_above_half_power - freq_below_half_power
bw1 = math.pow(bandwidth/fr, 2) bw1 = math.pow(bandwidth / fr, 2)
bw2 = math.pow(bandwidth/fr, 4) bw2 = math.pow(bandwidth / fr, 4)
try:
zeta = math.sqrt(0.5 - math.sqrt(1 / (4 + 4 * bw1 - bw2))) zeta = math.sqrt(0.5 - math.sqrt(1 / (4 + 4 * bw1 - bw2)))
except ValueError:
# If a math problem arise such as a negative sqrt term, we also return None instead for damping ratio
return fr, None, max_power_index, max_under_min_freq
return fr, zeta, max_power_index, max_under_min_freq return fr, zeta, max_power_index, max_under_min_freq
# This find all the peaks in a curve by looking at when the derivative term goes from positive to negative # This find all the peaks in a curve by looking at when the derivative term goes from positive to negative
# Then only the peaks found above a threshold are kept to avoid capturing peaks in the low amplitude noise of a signal # Then only the peaks found above a threshold are kept to avoid capturing peaks in the low amplitude noise of a signal
def detect_peaks(data, indices, detection_threshold, relative_height_threshold=None, window_size=5, vicinity=3): def detect_peaks(data, indices, detection_threshold, relative_height_threshold=None, window_size=5, vicinity=3):
@@ -125,7 +139,9 @@ def detect_peaks(data, indices, detection_threshold, relative_height_threshold=N
smoothed_data = np.concatenate((mean_pad, smoothed_data)) smoothed_data = np.concatenate((mean_pad, smoothed_data))
# Find peaks on the smoothed curve # Find peaks on the smoothed curve
smoothed_peaks = np.where((smoothed_data[:-2] < smoothed_data[1:-1]) & (smoothed_data[1:-1] > smoothed_data[2:]))[0] + 1 smoothed_peaks = (
np.where((smoothed_data[:-2] < smoothed_data[1:-1]) & (smoothed_data[1:-1] > smoothed_data[2:]))[0] + 1
)
smoothed_peaks = smoothed_peaks[smoothed_data[smoothed_peaks] > detection_threshold] smoothed_peaks = smoothed_peaks[smoothed_data[smoothed_peaks] > detection_threshold]
# Additional validation for peaks based on relative height # Additional validation for peaks based on relative height
@@ -133,14 +149,16 @@ def detect_peaks(data, indices, detection_threshold, relative_height_threshold=N
if relative_height_threshold is not None: if relative_height_threshold is not None:
valid_peaks = [] valid_peaks = []
for peak in smoothed_peaks: for peak in smoothed_peaks:
peak_height = smoothed_data[peak] - np.min(smoothed_data[max(0, peak-vicinity):min(len(smoothed_data), peak+vicinity+1)]) peak_height = smoothed_data[peak] - np.min(
smoothed_data[max(0, peak - vicinity) : min(len(smoothed_data), peak + vicinity + 1)]
)
if peak_height > relative_height_threshold * smoothed_data[peak]: if peak_height > relative_height_threshold * smoothed_data[peak]:
valid_peaks.append(peak) valid_peaks.append(peak)
# Refine peak positions on the original curve # Refine peak positions on the original curve
refined_peaks = [] refined_peaks = []
for peak in valid_peaks: for peak in valid_peaks:
local_max = peak + np.argmax(data[max(0, peak-vicinity):min(len(data), peak+vicinity+1)]) - vicinity local_max = peak + np.argmax(data[max(0, peak - vicinity) : min(len(data), peak + vicinity + 1)]) - vicinity
refined_peaks.append(local_max) refined_peaks.append(local_max)
num_peaks = len(refined_peaks) num_peaks = len(refined_peaks)
@@ -153,7 +171,7 @@ def identify_low_energy_zones(power_total, detection_threshold=0.1):
valleys = [] valleys = []
# Calculate the a "mean + 1/4" and standard deviation of the entire power_total # Calculate the a "mean + 1/4" and standard deviation of the entire power_total
mean_energy = np.mean(power_total) + (np.max(power_total) - np.min(power_total))/4 mean_energy = np.mean(power_total) + (np.max(power_total) - np.min(power_total)) / 4
std_energy = np.std(power_total) std_energy = np.std(power_total)
# Define a threshold value as "mean + 1/4" minus a certain number of standard deviations # Define a threshold value as "mean + 1/4" minus a certain number of standard deviations

View File

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

View File

@@ -6,6 +6,7 @@
import locale import locale
# Set the best locale for time and date formating (generation of the titles) # Set the best locale for time and date formating (generation of the titles)
def set_locale(): def set_locale():
try: try:
@@ -15,16 +16,19 @@ def set_locale():
except locale.Error: except locale.Error:
locale.setlocale(locale.LC_TIME, 'C') locale.setlocale(locale.LC_TIME, 'C')
# Print function to avoid problem in Klipper console (that doesn't support special characters) due to locale settings # Print function to avoid problem in Klipper console (that doesn't support special characters) due to locale settings
def print_with_c_locale(*args, **kwargs): def print_with_c_locale(*args, **kwargs):
try: try:
original_locale = locale.getlocale() original_locale = locale.getlocale()
locale.setlocale(locale.LC_ALL, 'C') locale.setlocale(locale.LC_ALL, 'C')
except locale.Error as e: except locale.Error as e:
print("Warning: Failed to set a basic locale. Special characters may not display correctly in Klipper console:", e) print(
'Warning: Failed to set a basic locale. Special characters may not display correctly in Klipper console:', e
)
finally: finally:
print(*args, **kwargs) # Proceed with printing regardless of locale setting success print(*args, **kwargs) # Proceed with printing regardless of locale setting success
try: try:
locale.setlocale(locale.LC_ALL, original_locale) locale.setlocale(locale.LC_ALL, original_locale)
except locale.Error as e: except locale.Error as e:
print("Warning: Failed to restore the original locale setting:", e) print('Warning: Failed to restore the original locale setting:', e)

410
src/is_workflow.py Executable file
View File

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