cleaning up and automating the vibrations measurement

This commit is contained in:
Félix Boisselier
2024-03-28 17:46:14 +01:00
parent 37195051e4
commit 4297aef0f5
5 changed files with 44 additions and 628 deletions

View File

@@ -1,166 +0,0 @@
#######################################
###### SPEED VIBRATIONS ANALYSIS ######
#######################################
# Written by Frix_x#0161 #
[gcode_macro SPEED_VIBRATIONS_PROFILE]
gcode:
{% set size = params.SIZE|default(60)|int %} # size of the area where the movements are done
{% set direction = params.DIRECTION|default('XY') %} # can be set to either XY, AB, ABXY, A, B, X, Y, Z
{% set z_height = params.Z_HEIGHT|default(20)|int %} # z height to put the toolhead before starting the movements
{% set min_speed = params.MIN_SPEED|default(20)|float * 60 %} # minimum feedrate for the movements
{% set max_speed = params.MAX_SPEED|default(200)|float * 60 %} # maximum feedrate for the movements
{% set speed_increment = params.SPEED_INCREMENT|default(2)|float * 60 %} # feedrate increment between each move
{% set feedrate_travel = params.TRAVEL_SPEED|default(200)|int * 60 %} # travel feedrate between moves
{% set accel = params.ACCEL|default(3000)|int %} # accel value used to move on the pattern
{% set accel_chip = params.ACCEL_CHIP|default("adxl345") %} # ADXL chip name in the config
{% set keep_results = params.KEEP_N_RESULTS|default(3)|int %}
{% set keep_csv = params.KEEP_CSV|default(True) %}
{% set mid_x = printer.toolhead.axis_maximum.x|float / 2 %}
{% set mid_y = printer.toolhead.axis_maximum.y|float / 2 %}
{% set nb_samples = ((max_speed - min_speed) / speed_increment + 1) | int %}
{% set accel = [accel, printer.configfile.settings.printer.max_accel]|min %}
{% set old_accel = printer.toolhead.max_accel %}
{% set old_accel_to_decel = printer.toolhead.max_accel_to_decel %}
{% set old_sqv = printer.toolhead.square_corner_velocity %}
{% set direction_factor = {
'XY' : {
'start' : {'x': -0.5, 'y': -0.5 },
'move_factors' : {
'0' : {'x': 0.5, 'y': -0.5, 'z': 0.0 },
'1' : {'x': 0.5, 'y': 0.5, 'z': 0.0 },
'2' : {'x': -0.5, 'y': 0.5, 'z': 0.0 },
'3' : {'x': -0.5, 'y': -0.5, 'z': 0.0 }
}
},
'AB' : {
'start' : {'x': 0.0, 'y': 0.0 },
'move_factors' : {
'0' : {'x': 0.5, 'y': -0.5, 'z': 0.0 },
'1' : {'x': -0.5, 'y': 0.5, 'z': 0.0 },
'2' : {'x': 0.0, 'y': 0.0, 'z': 0.0 },
'3' : {'x': 0.5, 'y': 0.5, 'z': 0.0 },
'4' : {'x': -0.5, 'y': -0.5, 'z': 0.0 },
'5' : {'x': 0.0, 'y': 0.0, 'z': 0.0 }
}
},
'ABXY' : {
'start' : {'x': -0.5, 'y': 0.5 },
'move_factors' : {
'0' : {'x': -0.5, 'y': -0.5, 'z': 0.0 },
'1' : {'x': 0.5, 'y': -0.5, 'z': 0.0 },
'2' : {'x': -0.5, 'y': 0.5, 'z': 0.0 },
'3' : {'x': 0.5, 'y': 0.5, 'z': 0.0 },
'4' : {'x': -0.5, 'y': -0.5, 'z': 0.0 },
'5' : {'x': -0.5, 'y': 0.5, 'z': 0.0 }
}
},
'B' : {
'start' : {'x': 0.5, 'y': 0.5 },
'move_factors' : {
'0' : {'x': -0.5, 'y': -0.5, 'z': 0.0 },
'1' : {'x': 0.5, 'y': 0.5, 'z': 0.0 }
}
},
'A' : {
'start' : {'x': -0.5, 'y': 0.5 },
'move_factors' : {
'0' : {'x': 0.5, 'y': -0.5, 'z': 0.0 },
'1' : {'x': -0.5, 'y': 0.5, 'z': 0.0 }
}
},
'X' : {
'start' : {'x': -0.5, 'y': 0.0 },
'move_factors' : {
'0' : {'x': 0.5, 'y': 0.0, 'z': 0.0 },
'1' : {'x': -0.5, 'y': 0.0, 'z': 0.0 }
}
},
'Y' : {
'start' : {'x': 0.0, 'y': 0.5 },
'move_factors' : {
'0' : {'x': 0.0, 'y': -0.5, 'z': 0.0 },
'1' : {'x': 0.0, 'y': 0.5, 'z': 0.0 }
}
},
'Z' : {
'start' : {'x': 0.0, 'y': 0.0 },
'move_factors' : {
'0' : {'x': 0.0, 'y': 0.0, 'z': 1.0 },
'1' : {'x': 0.0, 'y': 0.0, 'z': 0.0 }
}
},
'E' : {
'start' : {'x': 0.0, 'y': 0.0 },
'move_factor' : 0.05
}
}
%}
{% if not 'xyz' in printer.toolhead.homed_axes %}
{ action_raise_error("Must Home printer first!") }
{% endif %}
{% if params.SPEED_INCREMENT|default(2)|float * 100 != (params.SPEED_INCREMENT|default(2)|float * 100)|int %}
{ action_raise_error("Only 2 decimal digits are allowed for SPEED_INCREMENT") }
{% endif %}
{% if (size / (max_speed / 60)) < 0.25 and direction != 'E' %}
{ action_raise_error("SIZE is too small for this MAX_SPEED. Increase SIZE or decrease MAX_SPEED!") }
{% endif %}
{% if not (direction in direction_factor) %}
{ action_raise_error("DIRECTION is not valid. Only XY, AB, ABXY, A, B, X, Y, Z or E is allowed!") }
{% endif %}
{action_respond_info("")}
{action_respond_info("Starting machine speed vibrations profile measurement")}
{action_respond_info("This operation can not be interrupted by normal means. Hit the \"emergency stop\" button to stop it if needed")}
{action_respond_info("")}
SAVE_GCODE_STATE NAME=STATE_SPEED_VIBRATIONS_PROFILE
G90
# Set the wanted acceleration values (not too high to avoid oscillation, not too low to be able to reach constant speed on each segments)
SET_VELOCITY_LIMIT ACCEL={accel} ACCEL_TO_DECEL={accel} SQUARE_CORNER_VELOCITY={[(accel / 1000), 5.0]|max}
# Going to the start position
G1 Z{z_height} F{feedrate_travel / 10}
G1 X{mid_x + (size * direction_factor[direction].start.x) } Y{mid_y + (size * direction_factor[direction].start.y)} F{feedrate_travel}
# vibration pattern for each frequency
{% for curr_sample in range(0, nb_samples) %}
{% set curr_speed = min_speed + curr_sample * speed_increment %}
RESPOND MSG="{"Current speed: %.2f mm/s" % (curr_speed / 60)|float}"
ACCELEROMETER_MEASURE CHIP={accel_chip}
{% if direction == 'E' %}
G0 E{curr_speed*direction_factor[direction].move_factor} F{curr_speed}
{% else %}
{% for key, factor in direction_factor[direction].move_factors|dictsort %}
G1 X{mid_x + (size * factor.x) } Y{mid_y + (size * factor.y)} Z{z_height + (size * factor.z)} F{curr_speed}
{% endfor %}
{% endif %}
ACCELEROMETER_MEASURE CHIP={accel_chip} NAME=sp{("%.2f" % (curr_speed / 60)|float)|replace('.','_')}n1
G4 P300
M400
{% endfor %}
RESPOND MSG="Machine speed vibrations profile generation..."
RESPOND MSG="This may take some time (3-5min)"
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type speed_vibrations --axis_name {direction} --accel {accel|int} --chip_name {accel_chip} {% if keep_csv %}--keep_csv{% endif %}"
M400
RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type clean --keep_results {keep_results}"
# Restore the previous acceleration values
SET_VELOCITY_LIMIT ACCEL={old_accel} ACCEL_TO_DECEL={old_accel_to_decel} SQUARE_CORNER_VELOCITY={old_sqv}
RESTORE_GCODE_STATE NAME=STATE_SPEED_VIBRATIONS_PROFILE

View File

@@ -1,9 +1,9 @@
############################################# #########################################
###### DIRECTIONAL VIBRATIONS ANALYSIS ###### ###### MACHINE VIBRATIONS ANALYSIS ######
############################################# #########################################
# Written by Frix_x#0161 # # Written by Frix_x#0161 #
[gcode_macro DIRECTIONAL_VIBRATIONS_PROFILE] [gcode_macro CREATE_VIBRATIONS_PROFILE]
gcode: gcode:
{% set size = params.SIZE|default(100)|int %} # size of the circle where the angled lines are done {% set size = params.SIZE|default(100)|int %} # size of the circle where the angled lines are done
{% set z_height = params.Z_HEIGHT|default(20)|int %} # z height to put the toolhead before starting the movements {% set z_height = params.Z_HEIGHT|default(20)|int %} # z height to put the toolhead before starting the movements
@@ -44,11 +44,11 @@ gcode:
{% endif %} {% endif %}
{action_respond_info("")} {action_respond_info("")}
{action_respond_info("Starting machine directional vibrations profile measurement")} {action_respond_info("Starting machine vibrations profile measurement")}
{action_respond_info("This operation can not be interrupted by normal means. Hit the \"emergency stop\" button to stop it if needed")} {action_respond_info("This operation can not be interrupted by normal means. Hit the \"emergency stop\" button to stop it if needed")}
{action_respond_info("")} {action_respond_info("")}
SAVE_GCODE_STATE NAME=STATE_DIRECTIONAL_VIBRATIONS_PROFILE SAVE_GCODE_STATE NAME=CREATE_VIBRATIONS_PROFILE
G90 G90
@@ -158,13 +158,13 @@ gcode:
{% endfor %} {% endfor %}
RESPOND MSG="Machine directional 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 dir_vibrations --accel {accel|int} --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 %}"
M400 M400
# RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type clean --keep_results {keep_results}" 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} ACCEL_TO_DECEL={old_accel_to_decel} SQUARE_CORNER_VELOCITY={old_sqv} SET_VELOCITY_LIMIT ACCEL={old_accel} ACCEL_TO_DECEL={old_accel_to_decel} SQUARE_CORNER_VELOCITY={old_sqv}
RESTORE_GCODE_STATE NAME=STATE_DIRECTIONAL_VIBRATIONS_PROFILE RESTORE_GCODE_STATE NAME=CREATE_VIBRATIONS_PROFILE

View File

@@ -1,426 +0,0 @@
#!/usr/bin/env python3
##################################################
###### SPEED AND VIBRATIONS PLOTTING SCRIPT ######
##################################################
# Written by Frix_x#0161 #
# Be sure to make this script executable using SSH: type 'chmod +x ./graph_speed_vibrations.py' when in the folder !
#####################################################################
################ !!! DO NOT EDIT BELOW THIS LINE !!! ################
#####################################################################
import optparse, matplotlib, re, os, operator
from datetime import datetime
from collections import OrderedDict
import numpy as np
import matplotlib.pyplot as plt
import matplotlib.font_manager, matplotlib.ticker, matplotlib.gridspec
matplotlib.use('Agg')
from locale_utils import set_locale, print_with_c_locale
from common_func import compute_mechanical_parameters, detect_peaks, get_git_version, parse_log, setup_klipper_import, identify_low_energy_zones
PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04
VALLEY_DETECTION_THRESHOLD = 0.1 # Lower is more sensitive
KLIPPAIN_COLORS = {
"purple": "#70088C",
"orange": "#FF8D32",
"dark_purple": "#150140",
"dark_orange": "#F24130",
"red_pink": "#F2055C"
}
######################################################################
# Computation
######################################################################
# Call to the official Klipper input shaper object to do the PSD computation
def calc_freq_response(data):
helper = shaper_calibrate.ShaperCalibrate(printer=None)
return helper.process_accelerometer_data(data)
def compute_vibration_spectrogram(datas, group, max_freq):
psd_list = []
first_freqs = None
signal_axes = ['x', 'y', 'z', 'all']
for i in range(0, len(datas), group):
# Round up to the nearest power of 2 for faster FFT
N = datas[i].shape[0]
T = datas[i][-1,0] - datas[i][0,0]
M = 1 << int((N/T) * 0.5 - 1).bit_length()
if N <= M:
# If there is not enough lines in the array to be able to round up to the
# nearest power of 2, we need to pad some zeros at the end of the array to
# avoid entering a blocking state from Klipper shaper_calibrate.py
datas[i] = np.pad(datas[i], [(0, (M-N)+1), (0, 0)], mode='constant', constant_values=0)
freqrsp = calc_freq_response(datas[i])
for n in range(group - 1):
data = datas[i + n + 1]
# Round up to the nearest power of 2 for faster FFT
N = data.shape[0]
T = data[-1,0] - data[0,0]
M = 1 << int((N/T) * 0.5 - 1).bit_length()
if N <= M:
# If there is not enough lines in the array to be able to round up to the
# nearest power of 2, we need to pad some zeros at the end of the array to
# avoid entering a blocking state from Klipper shaper_calibrate.py
data = np.pad(data, [(0, (M-N)+1), (0, 0)], mode='constant', constant_values=0)
freqrsp.add_data(calc_freq_response(data))
if not psd_list:
# First group, just put it in the result list
first_freqs = freqrsp.freq_bins
psd = freqrsp.psd_sum[first_freqs <= max_freq]
px = freqrsp.psd_x[first_freqs <= max_freq]
py = freqrsp.psd_y[first_freqs <= max_freq]
pz = freqrsp.psd_z[first_freqs <= max_freq]
psd_list.append([psd, px, py, pz])
else:
# Not the first group, we need to interpolate every new signals
# to the first one to equalize the frequency_bins between them
signal_normalized = dict()
freqs = freqrsp.freq_bins
for axe in signal_axes:
signal = freqrsp.get_psd(axe)
signal_normalized[axe] = np.interp(first_freqs, freqs, signal)
# Remove data above max_freq on all axes and add to the result list
psd = signal_normalized['all'][first_freqs <= max_freq]
px = signal_normalized['x'][first_freqs <= max_freq]
py = signal_normalized['y'][first_freqs <= max_freq]
pz = signal_normalized['z'][first_freqs <= max_freq]
psd_list.append([psd, px, py, pz])
return np.array(first_freqs[first_freqs <= max_freq]), np.array(psd_list)
def compute_speed_profile(speeds, freqs, psd_list):
# Preallocate arrays as psd_list is known and consistent
pwrtot_sum = np.zeros(len(psd_list))
pwrtot_x = np.zeros(len(psd_list))
pwrtot_y = np.zeros(len(psd_list))
pwrtot_z = np.zeros(len(psd_list))
for i, psd in enumerate(psd_list):
pwrtot_sum[i] = np.trapz(psd[0], freqs)
pwrtot_x[i] = np.trapz(psd[1], freqs)
pwrtot_y[i] = np.trapz(psd[2], freqs)
pwrtot_z[i] = np.trapz(psd[3], freqs)
# Resample the signals to get a better detection of the valleys of low energy
# and avoid getting limited by the speed increment defined by the user
resampled_speeds, resampled_power_sum = resample_signal(speeds, pwrtot_sum)
_, resampled_pwrtot_x = resample_signal(speeds, pwrtot_x)
_, resampled_pwrtot_y = resample_signal(speeds, pwrtot_y)
_, resampled_pwrtot_z = resample_signal(speeds, pwrtot_z)
return resampled_speeds, [resampled_power_sum, resampled_pwrtot_x, resampled_pwrtot_y, resampled_pwrtot_z]
def compute_motor_profile(power_spectral_densities):
# Sum the PSD across all speeds for each frequency of the spectrogram. Basically this
# is equivalent to sum up all the spectrogram column by column to plot the total on the right
motor_total_vibration = np.sum([psd[0] for psd in power_spectral_densities], axis=0)
# Then a very little smoothing of the signal is applied to avoid too much noise and sharp peaks on it and simplify
# the resonance frequency and damping ratio estimation later on. Also, too much smoothing is bad and would alter the results
smoothed_motor_total_vibration = np.convolve(motor_total_vibration, np.ones(10)/10, mode='same')
return smoothed_motor_total_vibration
# Resample the signal to achieve denser data points in order to get more precise valley placing and
# avoid having to use the original sampling of the signal (that is equal to the speed increment used for the test)
def resample_signal(speeds, power_total, new_spacing=0.1):
new_speeds = np.arange(speeds[0], speeds[-1] + new_spacing, new_spacing)
new_power_total = np.interp(new_speeds, speeds, power_total)
return np.array(new_speeds), np.array(new_power_total)
######################################################################
# Graphing
######################################################################
def plot_speed_profile(ax, speeds, power_total, num_peaks, peaks, low_energy_zones):
# For this function, we have two array for the speeds. Indeed, since the power total sum was resampled to better detect
# the valleys of low energy later on, we also need the resampled speed array to plot it. For the rest
ax.set_title("Machine speed profile", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Energy')
ax2 = ax.twinx()
ax2.yaxis.set_visible(False)
max_y = power_total[0].max() + power_total[0].max() * 0.05
ax.set_xlim([speeds.min(), speeds.max()])
ax.set_ylim([0, max_y])
ax2.set_ylim([0, max_y])
ax.plot(speeds, power_total[0], label="X+Y+Z", color='purple', zorder=5)
ax.plot(speeds, power_total[1], label="X", color='red')
ax.plot(speeds, power_total[2], label="Y", color='green')
ax.plot(speeds, power_total[3], label="Z", color='blue')
if peaks.size:
ax.plot(speeds[peaks], power_total[0][peaks], "x", color='black', markersize=8)
for idx, peak in enumerate(peaks):
fontcolor = 'red'
fontweight = 'bold'
ax.annotate(f"{idx+1}", (speeds[peak], power_total[0][peak]),
textcoords="offset points", xytext=(8, 5),
ha='left', fontsize=13, color=fontcolor, weight=fontweight)
ax2.plot([], [], ' ', label=f'Number of peaks: {num_peaks}')
else:
ax2.plot([], [], ' ', label=f'No peaks detected')
for idx, (start, end, energy) in enumerate(low_energy_zones):
ax.axvline(speeds[start], color='red', linestyle='dotted', linewidth=1.5)
ax.axvline(speeds[end], color='red', linestyle='dotted', linewidth=1.5)
ax2.fill_between(speeds[start:end], 0, power_total[0][start:end], color='green', alpha=0.2, label=f'Zone {idx+1}: {speeds[start]:.1f} to {speeds[end]:.1f} mm/s (mean energy: {energy:.2f}%)')
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('small')
ax.legend(loc='upper left', prop=fontP)
ax2.legend(loc='upper right', prop=fontP)
return
def plot_vibration_spectrogram(ax, speeds, freqs, power_spectral_densities, peaks, fr, max_freq):
# Prepare the spectrum data
spectrum = np.empty([len(freqs), len(speeds)])
for i in range(len(speeds)):
for j in range(len(freqs)):
spectrum[j, i] = power_spectral_densities[i][0][j]
ax.set_title("Speed vibrations spectrogram", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
# ax.pcolormesh(speeds, freqs, spectrum, norm=matplotlib.colors.LogNorm(),
# cmap='inferno', shading='gouraud')
ax.imshow(spectrum, norm=matplotlib.colors.LogNorm(), cmap='inferno',
aspect='auto', extent=[speeds[0], speeds[-1], freqs[0], freqs[-1]],
origin='lower', interpolation='antialiased')
# Add peaks lines in the spectrogram to get hint from peaks found in the first graph
if peaks is not None:
for idx, peak in enumerate(peaks):
ax.axvline(peak, color='cyan', linestyle='dotted', linewidth=0.75)
ax.annotate(f"Peak {idx+1}", (peak, freqs[-1]*0.9),
textcoords="data", color='cyan', rotation=90, fontsize=10,
verticalalignment='top', horizontalalignment='right')
# Add motor resonance line
if fr is not None and fr > 25:
ax.axhline(fr, color='cyan', linestyle='dotted', linewidth=1)
ax.annotate(f"Motor resonance", (speeds[-1]*0.95, fr+2),
textcoords="data", color='cyan', fontsize=10,
verticalalignment='bottom', horizontalalignment='right')
ax.set_ylim([0., max_freq])
ax.set_ylabel('Frequency (hz)')
ax.set_xlabel('Speed (mm/s)')
return
def plot_motor_profile(ax, freqs, motor_vibration_power, motor_fr, motor_zeta, motor_max_power_index):
ax.set_title("Motors frequency profile", fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Energy')
ax.set_ylabel('Frequency (hz)')
ax2 = ax.twinx()
ax2.yaxis.set_visible(False)
ax.set_ylim([freqs.min(), freqs.max()])
ax.set_xlim([0, motor_vibration_power.max() + motor_vibration_power.max() * 0.1])
# Plot the profile curve
ax.plot(motor_vibration_power, freqs, color=KLIPPAIN_COLORS['orange'])
# Tag the resonance peak
ax.plot(motor_vibration_power[motor_max_power_index], freqs[motor_max_power_index], "x", color='black', markersize=8)
fontcolor = KLIPPAIN_COLORS['purple']
fontweight = 'bold'
ax.annotate(f"R", (motor_vibration_power[motor_max_power_index], freqs[motor_max_power_index]),
textcoords="offset points", xytext=(8, 8),
ha='right', fontsize=13, color=fontcolor, weight=fontweight)
# Add the legend
ax2.plot([], [], ' ', label="Motor resonant frequency (ω0): %.1fHz" % (motor_fr))
ax2.plot([], [], ' ', label="Motor damping ratio (ζ): %.3f" % (motor_zeta))
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('small')
ax2.legend(loc='upper right', prop=fontP)
return
######################################################################
# Startup and main routines
######################################################################
def extract_speed(logname):
try:
speed = re.search('sp(.+?)n', os.path.basename(logname)).group(1).replace('_','.')
except AttributeError:
raise ValueError("File %s does not contain speed in its name and therefore "
"is not supported by this script." % (logname,))
return float(speed)
def sort_and_slice(raw_speeds, raw_datas, remove):
# Sort to get the speeds and their datas aligned and in ascending order
raw_speeds, raw_datas = zip(*sorted(zip(raw_speeds, raw_datas), key=operator.itemgetter(0)))
# Optionally remove the beginning and end of each data file to get only
# the constant speed part of the segments and remove the start/stop phase
sliced_datas = []
for data in raw_datas:
sliced = round((len(data) * remove / 100) / 2)
sliced_datas.append(data[sliced:len(data)-sliced])
return raw_speeds, sliced_datas
def speed_vibrations_profile(lognames, klipperdir="~/klipper", axisname=None, accel=None, max_freq=1000., remove=0):
set_locale()
global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir)
# Parse the raw data and get them ready for analysis
raw_datas = [parse_log(filename) for filename in lognames]
raw_speeds = [extract_speed(filename) for filename in lognames]
speeds, datas = sort_and_slice(raw_speeds, raw_datas, remove)
del raw_datas, raw_speeds
# As we assume that we have the same number of file for each speed increment, we can group
# the PSD results by this number (to combine all the segments of the pattern at a constant speed)
group_by = speeds.count(speeds[0])
# Remove speeds duplicates and graph the processed datas
speeds = list(OrderedDict((x, True) for x in speeds).keys())
# Compute speed profile, vibration spectrogram and motor resonance profile
freqs, psd = compute_vibration_spectrogram(datas, group_by, max_freq)
upsampled_speeds, speeds_powers = compute_speed_profile(speeds, freqs, psd)
motor_vibration_power = compute_motor_profile(psd)
# Peak detection and low energy valleys (good speeds) identification between the peaks
num_peaks, vibration_peaks, peaks_speeds = detect_peaks(
speeds_powers[0], upsampled_speeds,
PEAKS_DETECTION_THRESHOLD * speeds_powers[0].max(),
PEAKS_RELATIVE_HEIGHT_THRESHOLD, 10, 10
)
low_energy_zones = identify_low_energy_zones(speeds_powers[0], VALLEY_DETECTION_THRESHOLD)
# Print the vibration peaks info in the console
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))))
# Motor resonance estimation
motor_fr, motor_zeta, motor_max_power_index = compute_mechanical_parameters(motor_vibration_power, freqs)
if motor_fr > 25:
print_with_c_locale("Motors have a main resonant frequency at %.1fHz with an estimated damping ratio of %.3f" % (motor_fr, motor_zeta))
else:
print_with_c_locale("The detected resonance frequency of the motors is too low (%.1fHz). This is probably due to the test run with too high acceleration!" % motor_fr)
print_with_c_locale("Try lowering the ACCEL value before restarting the macro to ensure that only constant speeds are recorded and that the dynamic behavior in the corners is not impacting the measurements.")
# Create graph layout
fig, ((ax1, ax2), (ax3, ax4)) = plt.subplots(2, 2, gridspec_kw={
'height_ratios':[4, 3],
'width_ratios':[5, 3],
'bottom':0.050,
'top':0.890,
'left':0.057,
'right':0.985,
'hspace':0.166,
'wspace':0.138
})
ax2.remove() # top right graph is not used and left blank for now...
fig.set_size_inches(14, 11.6)
# Add title
title_line1 = "SPEED VIBRATIONS ANALYSIS"
fig.text(0.075, 0.965, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold')
try:
filename_parts = (lognames[0].split('/')[-1]).split('_')
dt = datetime.strptime(f"{filename_parts[1]} {filename_parts[2].split('-')[0]}", "%Y%m%d %H%M%S")
title_line2 = dt.strftime('%x %X')
if axisname is not None:
title_line2 += ' -- ' + str(axisname).upper() + ' axis'
if accel is not None:
title_line2 += ' at ' + str(accel) + ' mm/s²'
except:
print_with_c_locale("Warning: CSV filename look to be different than expected (%s)" % (lognames[0]))
title_line2 = lognames[0].split('/')[-1]
fig.text(0.075, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple'])
# Plot the graphs
plot_speed_profile(ax1, upsampled_speeds, speeds_powers, num_peaks, vibration_peaks, low_energy_zones)
plot_motor_profile(ax4, freqs, motor_vibration_power, motor_fr, motor_zeta, motor_max_power_index)
plot_vibration_spectrogram(ax3, speeds, freqs, psd, peaks_speeds, motor_fr, max_freq)
# Adding a small Klippain logo to the top left corner of the figure
ax_logo = fig.add_axes([0.001, 0.924, 0.075, 0.075], anchor='NW')
ax_logo.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png')))
ax_logo.axis('off')
# Adding Shake&Tune version in the top right corner
st_version = get_git_version()
if st_version is not None:
fig.text(0.995, 0.985, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple'])
return fig
def main():
# Parse command-line arguments
usage = "%prog [options] <raw logs>"
opts = optparse.OptionParser(usage)
opts.add_option("-o", "--output", type="string", dest="output",
default=None, help="filename of output graph")
opts.add_option("-a", "--axis", type="string", dest="axisname",
default=None, help="axis name to be printed on the graph")
opts.add_option("-c", "--accel", type="int", dest="accel",
default=None, help="accel value to be printed on the graph")
opts.add_option("-f", "--max_freq", type="float", default=1000.,
help="maximum frequency to graph")
opts.add_option("-r", "--remove", type="int", default=0,
help="percentage of data removed at start/end of each CSV files")
opts.add_option("-k", "--klipper_dir", type="string", dest="klipperdir",
default="~/klipper", help="main klipper directory")
options, args = opts.parse_args()
if len(args) < 1:
opts.error("No CSV file(s) to analyse")
if options.output is None:
opts.error("You must specify an output file.png to use the script (option -o)")
if options.remove > 50 or options.remove < 0:
opts.error("You must specify a correct percentage (option -r) in the 0-50 range")
fig = speed_vibrations_profile(args, options.klipperdir, options.axisname, options.accel, options.max_freq, options.remove)
fig.savefig(options.output, dpi=150)
if __name__ == '__main__':
main()

View File

@@ -255,7 +255,7 @@ def plot_speed_profile(ax, all_speeds, sp_min_energy, sp_max_energy, sp_avg_ener
ax2.plot(all_speeds[peaks], sp_energy_variance[peaks], "x", color='black', markersize=8, zorder=10) ax2.plot(all_speeds[peaks], sp_energy_variance[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], sp_energy_variance[peak]), ax2.annotate(f"{idx+1}", (all_speeds[peak], sp_energy_variance[peak]),
textcoords="offset points", xytext=(8, 5), fontweight='bold', textcoords="offset points", xytext=(5, 5), fontweight='bold',
ha='left', fontsize=13, color=KLIPPAIN_COLORS['red_pink'], zorder=10) 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):
@@ -275,7 +275,7 @@ def plot_speed_profile(ax, all_speeds, sp_min_energy, sp_max_energy, sp_avg_ener
return return
def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_profile): 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)')
@@ -291,7 +291,7 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro
max_value = profile_max max_value = profile_max
ax.plot(freqs, motor_profiles[angle], linestyle='--', label=f'{angle} deg', zorder=2) ax.plot(freqs, motor_profiles[angle], linestyle='--', label=f'{angle} deg', zorder=2)
ax.set_xlim([0, 400]) ax.set_xlim([0, max_freq])
ax.set_ylim([0, max_value * 1.1]) ax.set_ylim([0, max_value * 1.1])
# 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
@@ -302,15 +302,15 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro
print_with_c_locale("The detected resonance frequency of the motors is too low (%.1fHz). This is probably due to the test run with too high acceleration!" % motor_fr) print_with_c_locale("The detected resonance frequency of the motors is too low (%.1fHz). This is probably due to the test run with too high acceleration!" % motor_fr)
print_with_c_locale("Try lowering the ACCEL value before restarting the macro to ensure that only constant speeds are recorded and that the dynamic behavior of the machine is not impacting the measurements.") print_with_c_locale("Try lowering the ACCEL value before restarting the macro to ensure that only constant speeds are recorded and that the dynamic behavior of the machine is not impacting the measurements.")
ax.plot(freqs[motor_res_idx], global_motor_profile[motor_res_idx], "x", color='black', markersize=8) 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(f"R", (freqs[motor_res_idx], global_motor_profile[motor_res_idx]),
textcoords="offset points", xytext=(10, 5), textcoords="offset points", xytext=(15, 5),
ha='right', fontsize=13, color=KLIPPAIN_COLORS['purple'], weight='bold') ha='right', fontsize=14, color=KLIPPAIN_COLORS['red_pink'], weight='bold')
legend_texts = ["Motor resonant frequency (ω0): %.1fHz" % (motor_fr), legend_texts = ["Resonant frequency (ω0): %.1fHz" % (motor_fr),
"Motor damping ratio (ζ): %.3f" % (motor_zeta)] "Damping ratio (ζ): %.3f" % (motor_zeta)]
for i, text in enumerate(legend_texts): for i, text in enumerate(legend_texts):
ax.text(0.90 + i*0.05, 0.98, text, transform=ax.transAxes, color=KLIPPAIN_COLORS['red_pink'], fontsize=12, ax.text(0.90 + i*0.05, 0.85, text, transform=ax.transAxes, color=KLIPPAIN_COLORS['red_pink'], fontsize=12,
fontweight='bold', verticalalignment='top', rotation='vertical', zorder=10) fontweight='bold', verticalalignment='top', rotation='vertical', zorder=10)
ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
@@ -320,7 +320,7 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro
fontP = matplotlib.font_manager.FontProperties() fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('small') fontP.set_size('small')
ax.legend(loc='upper left', prop=fontP) ax.legend(loc='upper right', prop=fontP)
return return
@@ -382,7 +382,7 @@ def extract_angle_and_speed(logname):
return float(angle), float(speed) return float(angle), float(speed)
def dir_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.):
set_locale() set_locale()
global shaper_calibrate global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir) shaper_calibrate = setup_klipper_import(klipperdir)
@@ -484,7 +484,7 @@ def dir_vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesi
if accel is not None: if accel is not None:
title_line2 += ' at ' + str(accel) + ' mm/s²' title_line2 += ' at ' + str(accel) + ' mm/s²'
except: except:
print_with_c_locale("Warning: CSV filename look 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'])
@@ -492,7 +492,7 @@ def dir_vibrations_profile(lognames, klipperdir="~/klipper", kinematics="cartesi
plot_angle_profile_polar(ax3, all_angles, all_angles_energy, good_angles, symmetry_factor) plot_angle_profile_polar(ax3, 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_motor_profiles(ax1, target_freqs, main_angles, motor_profiles, global_motor_profile) plot_motor_profiles(ax1, target_freqs, main_angles, motor_profiles, global_motor_profile, max_freq)
plot_angle_profile(ax6, all_angles, all_angles_energy, good_angles) plot_angle_profile(ax6, all_angles, all_angles_energy, good_angles)
plot_speed_profile(ax2, all_speeds, sp_min_energy, sp_max_energy, sp_avg_energy, sp_energy_variance, num_peaks, vibration_peaks, good_speeds) plot_speed_profile(ax2, all_speeds, sp_min_energy, sp_max_energy, sp_avg_energy, sp_energy_variance, num_peaks, vibration_peaks, good_speeds)
@@ -533,7 +533,7 @@ def main():
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 = dir_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

@@ -25,10 +25,10 @@ KLIPPER_FOLDER = os.path.expanduser('~/klipper')
from graph_belts import belts_calibration from graph_belts import belts_calibration
from graph_shaper import shaper_calibration from graph_shaper import shaper_calibration
from graph_speed_vibrations import speed_vibrations_profile from graph_vibrations import vibrations_profile
from analyze_axesmap import axesmap_calibration from analyze_axesmap import axesmap_calibration
RESULTS_SUBFOLDERS = ['belts', 'inputshaper', 'speed_vibrations', 'dir_vibrations'] RESULTS_SUBFOLDERS = ['belts', 'inputshaper', 'vibrations']
def is_file_open(filepath): def is_file_open(filepath):
@@ -132,7 +132,7 @@ def create_shaper_graph(keep_csv, max_smoothing, scv):
return axis return axis
def create_speed_vibrations_graph(axis_name, accel, chip_name, keep_csv): def create_vibrations_graph(accel, kinematics, chip_name, keep_csv):
current_date = datetime.now().strftime('%Y%m%d_%H%M%S') current_date = datetime.now().strftime('%Y%m%d_%H%M%S')
lognames = [] lognames = []
@@ -162,15 +162,15 @@ def create_speed_vibrations_graph(axis_name, accel, chip_name, keep_csv):
time.sleep(5) time.sleep(5)
# Generate the vibration graph and its name # Generate the vibration graph and its name
fig = speed_vibrations_profile(lognames, KLIPPER_FOLDER, axis_name, accel) fig = vibrations_profile(lognames, KLIPPER_FOLDER, kinematics, accel)
png_filename = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[2], f'vibrations_{current_date}_{axis_name}.png') png_filename = os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[2], f'vibrations_{current_date}.png')
fig.savefig(png_filename, dpi=150) fig.savefig(png_filename, dpi=150)
# Archive all the csv files in a tarball in case the user want to keep them # Archive all the csv files in a tarball in case the user want to keep them
if keep_csv: if keep_csv:
with tarfile.open(os.path.join(RESULTS_FOLDER, RESULTS_SUBFOLDERS[2], f'vibrations_{current_date}_{axis_name}.tar.gz'), 'w:gz') as tar: 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: for csv_file in lognames:
tar.add(csv_file, recursive=False) 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) # Remove the remaining CSV files not needed anymore (tarball is safe if it was created)
for csv_file in lognames: for csv_file in lognames:
@@ -267,15 +267,20 @@ def main():
help="square corner velocity used to compute max accel for axis shapers graphs") 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, opts.add_option("--max_smoothing", type="float", dest="max_smoothing", default=None,
help="maximum shaper smoothing to allow") 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() options, args = opts.parse_args()
if options.type is None: if options.type is None:
opts.error("You must specify the type of output graph you want to produce (option -t)") 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', 'speed_vibrations', 'axesmap', 'clean']: 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', 'speed_vibrations', 'axesmap' or 'clean'") opts.error("Type of output graph need to be in the list of 'belts', 'shaper', 'vibrations', 'axesmap' or 'clean'")
else: else:
graph_mode = options.type 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 # Check if results folders are there or create them before doing anything else
for result_subfolder in RESULTS_SUBFOLDERS: for result_subfolder in RESULTS_SUBFOLDERS:
folder = os.path.join(RESULTS_FOLDER, result_subfolder) folder = os.path.join(RESULTS_FOLDER, result_subfolder)
@@ -288,9 +293,9 @@ def main():
elif graph_mode.lower() == 'shaper': elif graph_mode.lower() == 'shaper':
axis = create_shaper_graph(keep_csv=options.keep_csv, max_smoothing=options.max_smoothing, scv=options.scv) 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]}") print(f"{axis} input shaper graph created. You will find the results in {RESULTS_FOLDER}/{RESULTS_SUBFOLDERS[1]}")
elif graph_mode.lower() == 'speed_vibrations': elif graph_mode.lower() == 'vibrations':
create_speed_vibrations_graph(axis_name=options.axis_name, accel=options.accel_used, chip_name=options.chip_name, keep_csv=options.keep_csv) create_vibrations_graph(accel=options.accel_used, kinematics=options.kinematics, chip_name=options.chip_name, keep_csv=options.keep_csv)
print(f"{options.axis_name} vibration graph created. You will find the results in {RESULTS_FOLDER}/{RESULTS_SUBFOLDERS[2]}") print(f"Vibrations graph created. You will find the results in {RESULTS_FOLDER}/{RESULTS_SUBFOLDERS[2]}")
elif graph_mode.lower() == 'axesmap': elif graph_mode.lower() == 'axesmap':
print(f"WARNING: AXES_MAP_CALIBRATION is currently very experimental and may produce incorrect results... Please validate the output!") 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) find_axesmap(accel=options.accel_used, chip_name=options.chip_name)
@@ -298,6 +303,9 @@ def main():
print(f"Cleaning output folder to keep only the last {options.keep_results} results...") print(f"Cleaning output folder to keep only the last {options.keep_results} results...")
clean_files(keep_results=options.keep_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__': if __name__ == '__main__':
main() main()