Code cleanup before release (#114)

This commit is contained in:
Félix Boisselier
2024-06-10 23:42:10 +02:00
committed by GitHub
parent 9739f6220e
commit 6db1d394ae
24 changed files with 575 additions and 471 deletions

View File

@@ -0,0 +1,7 @@
#!/usr/bin/env python3
from .axes_map_calibration import axes_map_calibration as axes_map_calibration
from .axes_shaper_calibration import axes_shaper_calibration as axes_shaper_calibration
from .compare_belts_responses import compare_belts_responses as compare_belts_responses
from .create_vibrations_profile import create_vibrations_profile as create_vibrations_profile
from .excitate_axis_at_freq import excitate_axis_at_freq as excitate_axis_at_freq

View File

@@ -86,6 +86,8 @@ def axes_map_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
# Run post-processing
ConsoleOutput.print('Analysis of the movements...')
ConsoleOutput.print('This may take some time (1-3min)')
creator = st_process.get_graph_creator()
creator.configure(accel, SEGMENT_LENGTH)
st_process.run()
st_process.wait_for_completion()

View File

@@ -3,9 +3,9 @@
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from ..helpers.resonance_test import vibrate_axis
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
from .resonance_test import vibrate_axis
def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
@@ -100,6 +100,8 @@ def axes_shaper_calibration(gcmd, config, st_process: ShakeTuneProcess) -> None:
ConsoleOutput.print('This may take some time (1-3min)')
st_process.run()
st_process.wait_for_completion()
toolhead.dwell(1)
toolhead.wait_moves()
# Re-enable the input shaper if it was active
if input_shaper is not None:

View File

@@ -3,10 +3,10 @@
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from ..helpers.motors_config_parser import MotorsConfigParser
from ..helpers.resonance_test import vibrate_axis
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
from .motorsconfigparser import MotorsConfigParser
from .resonance_test import vibrate_axis
def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None:
@@ -104,5 +104,6 @@ def compare_belts_responses(gcmd, config, st_process: ShakeTuneProcess) -> None:
# Run post-processing
ConsoleOutput.print('Belts comparative frequency profile generation...')
ConsoleOutput.print('This may take some time (3-5min)')
ConsoleOutput.print('This may take some time (1-3min)')
st_process.run()
st_process.wait_for_completion()

View File

@@ -4,9 +4,9 @@
import math
from ..helpers.console_output import ConsoleOutput
from ..helpers.motors_config_parser import MotorsConfigParser
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
from .motorsconfigparser import MotorsConfigParser
MIN_SPEED = 2 # mm/s
@@ -24,7 +24,9 @@ def create_vibrations_profile(gcmd, config, st_process: ShakeTuneProcess) -> Non
accel_chip = None
if (size / (max_speed / 60)) < 0.25:
raise gcmd.error('The size of the movement is too small for the given speed! Increase SIZE or decrease MAX_SPEED!')
raise gcmd.error(
'The size of the movement is too small for the given speed! Increase SIZE or decrease MAX_SPEED!'
)
printer = config.get_printer()
gcode = printer.lookup_object('gcode')
@@ -133,3 +135,4 @@ def create_vibrations_profile(gcmd, config, st_process: ShakeTuneProcess) -> Non
creator = st_process.get_graph_creator()
creator.configure(motors_config_parser.kinematics, accel, motors_config_parser)
st_process.run()
st_process.wait_for_completion()

View File

@@ -2,9 +2,9 @@
from ..helpers.common_func import AXIS_CONFIG
from ..helpers.console_output import ConsoleOutput
from ..helpers.resonance_test import vibrate_axis_at_static_freq
from ..shaketune_process import ShakeTuneProcess
from .accelerometer import Accelerometer
from .resonance_test import vibrate_axis_at_static_freq
def excitate_axis_at_freq(gcmd, config, st_process: ShakeTuneProcess) -> None:
@@ -96,3 +96,4 @@ def excitate_axis_at_freq(gcmd, config, st_process: ShakeTuneProcess) -> None:
creator = st_process.get_graph_creator()
creator.configure(freq, duration, accel_per_hz)
st_process.run()
st_process.wait_for_completion()

View File

@@ -7,15 +7,25 @@
[gcode_macro EXCITATE_AXIS_AT_FREQ]
description: dummy
gcode:
{% set dummy = params.CREATE_GRAPH|default(0) %}
{% set dummy = params.FREQUENCY|default(25) %}
{% set dummy = params.DURATION|default(30) %}
{% set dummy = params.ACCEL_PER_HZ %}
{% set dummy = params.AXIS|default('x') %}
{% set dummy = params.TRAVEL_SPEED|default(120) %}
{% set dummy = params.Z_HEIGHT %}
{% set dummy = params.ACCEL_CHIP %}
_EXCITATE_AXIS_AT_FREQ {rawparams}
{% set create_graph = params.CREATE_GRAPH|default(0) %}
{% set frequency = params.FREQUENCY|default(25) %}
{% set duration = params.DURATION|default(30) %}
{% set accel_per_hz = params.ACCEL_PER_HZ %}
{% set axis = params.AXIS|default('x') %}
{% set travel_speed = params.TRAVEL_SPEED|default(120) %}
{% set z_height = params.Z_HEIGHT %}
{% set accel_chip = params.ACCEL_CHIP %}
{% set params_filtered = {
"CREATE_GRAPH": create_graph,
"FREQUENCY": frequency,
"DURATION": duration,
"ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '',
"AXIS": axis,
"TRAVEL_SPEED": travel_speed,
"Z_HEIGHT": z_height if z_height is not none else '',
"ACCEL_CHIP": accel_chip if accel_chip is not none else ''
} %}
_EXCITATE_AXIS_AT_FREQ {% for key, value in params_filtered.items() if value is not none and value != '' %}{key}={value} {% endfor %}
[gcode_macro AXES_MAP_CALIBRATION]
@@ -31,28 +41,47 @@ gcode:
[gcode_macro COMPARE_BELTS_RESPONSES]
description: dummy
gcode:
{% set dummy = params.FREQ_START|default(5) %}
{% set dummy = params.FREQ_END|default(133.33) %}
{% set dummy = params.HZ_PER_SEC|default(1) %}
{% set dummy = params.ACCEL_PER_HZ %}
{% set dummy = params.TRAVEL_SPEED|default(120) %}
{% set dummy = params.Z_HEIGHT %}
_COMPARE_BELTS_RESPONSES {rawparams}
{% set freq_start = params.FREQ_START|default(5) %}
{% set freq_end = params.FREQ_END|default(133.33) %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1) %}
{% set accel_per_hz = params.ACCEL_PER_HZ %}
{% set travel_speed = params.TRAVEL_SPEED|default(120) %}
{% set z_height = params.Z_HEIGHT %}
{% set params_filtered = {
"FREQ_START": freq_start,
"FREQ_END": freq_end,
"HZ_PER_SEC": hz_per_sec,
"ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '',
"TRAVEL_SPEED": travel_speed,
"Z_HEIGHT": z_height if z_height is not none else ''
} %}
_COMPARE_BELTS_RESPONSES {% for key, value in params_filtered.items() if value is not none and value != '' %}{key}={value} {% endfor %}
[gcode_macro AXES_SHAPER_CALIBRATION]
description: dummy
gcode:
{% set dummy = params.FREQ_START|default(5) %}
{% set dummy = params.FREQ_END|default(133.33) %}
{% set dummy = params.HZ_PER_SEC|default(1) %}
{% set dummy = params.ACCEL_PER_HZ %}
{% set dummy = params.AXIS|default('all') %}
{% set dummy = params.SCV %}
{% set dummy = params.MAX_SMOOTHING %}
{% set dummy = params.TRAVEL_SPEED|default(120) %}
{% set dummy = params.Z_HEIGHT %}
_AXES_SHAPER_CALIBRATION {rawparams}
{% set freq_start = params.FREQ_START|default(5) %}
{% set freq_end = params.FREQ_END|default(133.33) %}
{% set hz_per_sec = params.HZ_PER_SEC|default(1) %}
{% set accel_per_hz = params.ACCEL_PER_HZ %}
{% set axis = params.AXIS|default('all') %}
{% set scv = params.SCV %}
{% set max_smoothing = params.MAX_SMOOTHING %}
{% set travel_speed = params.TRAVEL_SPEED|default(120) %}
{% set z_height = params.Z_HEIGHT %}
{% set params_filtered = {
"FREQ_START": freq_start,
"FREQ_END": freq_end,
"HZ_PER_SEC": hz_per_sec,
"ACCEL_PER_HZ": accel_per_hz if accel_per_hz is not none else '',
"AXIS": axis,
"SCV": scv if scv is not none else '',
"MAX_SMOOTHING": max_smoothing if max_smoothing is not none else '',
"TRAVEL_SPEED": travel_speed,
"Z_HEIGHT": z_height if z_height is not none else ''
} %}
_AXES_SHAPER_CALIBRATION {% for key, value in params_filtered.items() if value is not none and value != '' %}{key}={value} {% endfor %}
[gcode_macro CREATE_VIBRATIONS_PROFILE]

View File

@@ -0,0 +1,8 @@
#!/usr/bin/env python3
from .axes_map_graph_creator import AxesMapGraphCreator as AxesMapGraphCreator
from .belts_graph_creator import BeltsGraphCreator as BeltsGraphCreator
from .graph_creator import GraphCreator as GraphCreator
from .shaper_graph_creator import ShaperGraphCreator as ShaperGraphCreator
from .static_graph_creator import StaticGraphCreator as StaticGraphCreator
from .vibrations_graph_creator import VibrationsGraphCreator as VibrationsGraphCreator

View File

@@ -8,6 +8,7 @@
import optparse
import os
from datetime import datetime
from typing import List, Optional, Tuple
import matplotlib
import matplotlib.colors
@@ -22,6 +23,8 @@ matplotlib.use('Agg')
from ..helpers.common_func import parse_log
from ..helpers.console_output import ConsoleOutput
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
KLIPPAIN_COLORS = {
'purple': '#70088C',
@@ -33,12 +36,48 @@ KLIPPAIN_COLORS = {
MACHINE_AXES = ['x', 'y', 'z']
class AxesMapGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'axes map')
self._accel: Optional[int] = None
self._segment_length: Optional[float] = None
def configure(self, accel: int, segment_length: float) -> None:
self._accel = accel
self._segment_length = segment_length
def create_graph(self) -> None:
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-axesmap_*.csv',
min_files_required=3,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = axesmap_calibration(
lognames=[str(path) for path in lognames],
accel=self._accel,
fixed_length=self._segment_length,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
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
for old_file in files[keep_results:]:
file_date = '_'.join(old_file.stem.split('_')[1:3])
for suffix in ['X', 'Y', 'Z']:
csv_file = self._folder / f'axesmap_{file_date}_{suffix}.csv'
csv_file.unlink(missing_ok=True)
old_file.unlink()
######################################################################
# Computation
######################################################################
def wavelet_denoise(data, wavelet='db1', level=1):
def wavelet_denoise(data: np.ndarray, wavelet: str = 'db1', level: int = 1) -> Tuple[np.ndarray, np.ndarray]:
coeffs = pywt.wavedec(data, wavelet, mode='smooth')
threshold = np.median(np.abs(coeffs[-level])) / 0.6745 * np.sqrt(2 * np.log(len(data)))
new_coeffs = [pywt.threshold(c, threshold, mode='soft') for c in coeffs]
@@ -49,11 +88,13 @@ def wavelet_denoise(data, wavelet='db1', level=1):
return denoised_data, noise
def integrate_trapz(accel, time):
def integrate_trapz(accel: np.ndarray, time: np.ndarray) -> np.ndarray:
return np.array([np.trapz(accel[:i], time[:i]) for i in range(2, len(time) + 1)])
def process_acceleration_data(time, accel_x, accel_y, accel_z):
def process_acceleration_data(
time: np.ndarray, accel_x: np.ndarray, accel_y: np.ndarray, accel_z: np.ndarray
) -> Tuple[float, float, float, np.ndarray, np.ndarray, np.ndarray, float]:
# Calculate the constant offset (gravity component)
offset_x = np.mean(accel_x)
offset_y = np.mean(accel_y)
@@ -89,7 +130,9 @@ def process_acceleration_data(time, accel_x, accel_y, accel_z):
return offset_x, offset_y, offset_z, position_x, position_y, position_z, noise_intensity
def scale_positions_to_fixed_length(position_x, position_y, position_z, fixed_length):
def scale_positions_to_fixed_length(
position_x: np.ndarray, position_y: np.ndarray, position_z: np.ndarray, fixed_length: float
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
# Calculate the total distance traveled in 3D space
total_distance = np.sqrt(np.diff(position_x) ** 2 + np.diff(position_y) ** 2 + np.diff(position_z) ** 2).sum()
scale_factor = fixed_length / total_distance
@@ -102,7 +145,7 @@ def scale_positions_to_fixed_length(position_x, position_y, position_z, fixed_le
return position_x, position_y, position_z
def find_nearest_perfect_vector(average_direction_vector):
def find_nearest_perfect_vector(average_direction_vector: np.ndarray) -> Tuple[np.ndarray, float]:
# Define the perfect vectors
perfect_vectors = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1], [-1, 0, 0], [0, -1, 0], [0, 0, -1]])
@@ -117,7 +160,9 @@ def find_nearest_perfect_vector(average_direction_vector):
return nearest_vector, angle_error
def linear_regression_direction(position_x, position_y, position_z, trim_length=0.25):
def linear_regression_direction(
position_x: np.ndarray, position_y: np.ndarray, position_z: np.ndarray, trim_length: float = 0.25
) -> np.ndarray:
# Trim the start and end of the position data to keep only the center of the segment
# as the start and stop positions are not always perfectly aligned and can be a bit noisy
t = len(position_x)
@@ -145,7 +190,9 @@ def linear_regression_direction(position_x, position_y, position_z, trim_length=
######################################################################
def plot_compare_frequency(ax, time, accel_x, accel_y, accel_z, offset, i):
def plot_compare_frequency(
ax: plt.Axes, time: np.ndarray, accel_x: np.ndarray, accel_y: np.ndarray, accel_z: np.ndarray, offset: float, i: int
) -> None:
# Plot acceleration data
ax.plot(
time,
@@ -200,7 +247,15 @@ def plot_compare_frequency(ax, time, accel_x, accel_y, accel_z, offset, i):
ax2.legend(loc='upper right', prop=fontP)
def plot_3d_path(ax, i, position_x, position_y, position_z, average_direction_vector, angle_error):
def plot_3d_path(
ax: plt.Axes,
i: int,
position_x: np.ndarray,
position_y: np.ndarray,
position_z: np.ndarray,
average_direction_vector: np.ndarray,
angle_error: float,
) -> None:
ax.plot(position_x, position_y, position_z, color=KLIPPAIN_COLORS['orange'], linestyle=':', linewidth=2)
ax.scatter(position_x[0], position_y[0], position_z[0], color=KLIPPAIN_COLORS['red_pink'], zorder=10)
ax.text(
@@ -251,7 +306,7 @@ def plot_3d_path(ax, i, position_x, position_y, position_z, average_direction_ve
ax.legend(loc='upper left', prop=fontP)
def format_direction_vector(vectors):
def format_direction_vector(vectors: List[np.ndarray]) -> str:
formatted_vector = []
for vector in vectors:
for i in range(len(vector)):
@@ -269,7 +324,9 @@ def format_direction_vector(vectors):
######################################################################
def axesmap_calibration(lognames, fixed_length, accel=None, st_version='unknown'):
def axesmap_calibration(
lognames: List[str], fixed_length: float, accel: Optional[float] = None, st_version: str = 'unknown'
) -> plt.Figure:
# Parse data from the log files while ignoring CSV in the wrong format (sorted by axis name)
raw_datas = {}
for logname in lognames:

View File

@@ -7,8 +7,8 @@
import optparse
import os
from collections import namedtuple
from datetime import datetime
from typing import List, NamedTuple, Optional, Tuple
import matplotlib
import matplotlib.colors
@@ -21,6 +21,8 @@ matplotlib.use('Agg')
from ..helpers.common_func import detect_peaks, parse_log, setup_klipper_import
from ..helpers.console_output import ConsoleOutput
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
ALPHABET = (
'αβγδεζηθικλμνξοπρστυφχψω' # For paired peak names (using the Greek alphabet to avoid confusion with belt names)
@@ -30,9 +32,6 @@ PEAKS_DETECTION_THRESHOLD = 0.1 # Threshold to detect peaks in the PSD signal (
DC_MAX_PEAKS = 2 # Maximum ideal number of peaks
DC_MAX_UNPAIRED_PEAKS_ALLOWED = 0 # No unpaired peaks are tolerated
# Define the SignalData namedtuple
SignalData = namedtuple('CalibrationData', ['freqs', 'psd', 'peaks', 'paired_peaks', 'unpaired_peaks'])
KLIPPAIN_COLORS = {
'purple': '#70088C',
'orange': '#FF8D32',
@@ -42,6 +41,59 @@ KLIPPAIN_COLORS = {
}
# Define the SignalData type to store the data of a signal (PSD, peaks, etc.)
class SignalData(NamedTuple):
freqs: np.ndarray
psd: np.ndarray
peaks: np.ndarray
paired_peaks: Optional[List[Tuple[Tuple[int, float, float], Tuple[int, float, float]]]] = None
unpaired_peaks: Optional[List[int]] = None
# Define the PeakPairingResult type to store the result of the peak pairing function
class PeakPairingResult(NamedTuple):
paired_peaks: List[Tuple[Tuple[int, float, float], Tuple[int, float, float]]]
unpaired_peaks1: List[int]
unpaired_peaks2: List[int]
class BeltsGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'belts comparison')
self._kinematics: Optional[str] = None
self._accel_per_hz: Optional[float] = None
def configure(self, kinematics: Optional[str] = None, accel_per_hz: Optional[float] = None) -> None:
self._kinematics = kinematics
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-belt_*.csv',
min_files_required=2,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = belts_calibration(
lognames=[str(path) for path in lognames],
kinematics=self._kinematics,
klipperdir=str(self._config.klipper_folder),
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
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
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'beltscomparison_{file_date}_{suffix}.csv'
csv_file.unlink(missing_ok=True)
old_file.unlink()
######################################################################
# Computation of the PSD graph
######################################################################
@@ -49,7 +101,9 @@ KLIPPAIN_COLORS = {
# 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)
def pair_peaks(peaks1, freqs1, psd1, peaks2, freqs2, psd2):
def pair_peaks(
peaks1: np.ndarray, freqs1: np.ndarray, psd1: np.ndarray, peaks2: np.ndarray, freqs2: np.ndarray, psd2: np.ndarray
) -> PeakPairingResult:
# Compute a dynamic detection threshold to filter and pair peaks efficiently
# even if the signal is very noisy (this get clipped to a maximum of 10Hz diff)
distances = []
@@ -88,7 +142,9 @@ def pair_peaks(peaks1, freqs1, psd1, peaks2, freqs2, psd2):
unpaired_peaks1.remove(p1)
unpaired_peaks2.remove(p2)
return paired_peaks, unpaired_peaks1, unpaired_peaks2
return PeakPairingResult(
paired_peaks=paired_peaks, unpaired_peaks1=unpaired_peaks1, unpaired_peaks2=unpaired_peaks2
)
######################################################################
@@ -96,7 +152,7 @@ def pair_peaks(peaks1, freqs1, psd1, peaks2, freqs2, psd2):
######################################################################
def compute_mhi(similarity_factor, signal1, signal2):
def compute_mhi(similarity_factor: float, signal1: SignalData, signal2: SignalData) -> str:
num_unpaired_peaks = len(signal1.unpaired_peaks) + len(signal2.unpaired_peaks)
num_paired_peaks = len(signal1.paired_peaks)
# Combine unpaired peaks from both signals, tagging each peak with its respective signal
@@ -126,7 +182,7 @@ def compute_mhi(similarity_factor, signal1, signal2):
# 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: float) -> str:
ranges = [
(70, 100, 'Excellent mechanical health'),
(55, 70, 'Good mechanical health'),
@@ -148,7 +204,9 @@ def mhi_lut(mhi):
######################################################################
def plot_compare_frequency(ax, signal1, signal2, signal1_belt, signal2_belt, max_freq):
def plot_compare_frequency(
ax: plt.Axes, signal1: SignalData, signal2: SignalData, signal1_belt: str, signal2_belt: str, max_freq: float
) -> None:
# Plot the two belts PSD signals
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'])
@@ -281,7 +339,16 @@ def plot_compare_frequency(ax, signal1, signal2, signal1_belt, signal2_belt, max
# Compute quantile-quantile plot to compare the two belts
def plot_versus_belts(ax, common_freqs, signal1, signal2, interp_psd1, interp_psd2, signal1_belt, signal2_belt):
def plot_versus_belts(
ax: plt.Axes,
common_freqs: np.ndarray,
signal1: SignalData,
signal2: SignalData,
interp_psd1: np.ndarray,
interp_psd2: np.ndarray,
signal1_belt: str,
signal2_belt: str,
) -> None:
ax.set_title('Cross-belts comparison plot', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
max_psd = max(np.max(interp_psd1), np.max(interp_psd2))
@@ -410,7 +477,7 @@ def plot_versus_belts(ax, common_freqs, signal1, signal2, interp_psd1, interp_ps
# 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: np.ndarray, max_freq: float) -> SignalData:
helper = shaper_calibrate.ShaperCalibrate(printer=None)
calibration_data = helper.process_accelerometer_data(data)
@@ -419,7 +486,7 @@ def compute_signal_data(data, max_freq):
_, peaks, _ = detect_peaks(psd, freqs, PEAKS_DETECTION_THRESHOLD * psd.max())
return SignalData(freqs=freqs, psd=psd, peaks=peaks, paired_peaks=None, unpaired_peaks=None)
return SignalData(freqs=freqs, psd=psd, peaks=peaks)
######################################################################
@@ -428,8 +495,13 @@ def compute_signal_data(data, max_freq):
def belts_calibration(
lognames, kinematics, klipperdir='~/klipper', max_freq=200.0, accel_per_hz=None, st_version='unknown'
):
lognames: List[str],
kinematics: Optional[str],
klipperdir: str = '~/klipper',
max_freq: float = 200.0,
accel_per_hz: Optional[float] = None,
st_version: str = 'unknown',
) -> plt.Figure:
global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir)
@@ -451,11 +523,9 @@ def belts_calibration(
del datas
# Pair the peaks across the two datasets
paired_peaks, unpaired_peaks1, unpaired_peaks2 = pair_peaks(
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)
pairing_result = pair_peaks(signal1.peaks, signal1.freqs, signal1.psd, signal2.peaks, signal2.freqs, signal2.psd)
signal1 = signal1._replace(paired_peaks=pairing_result.paired_peaks, unpaired_peaks=pairing_result.unpaired_peaks1)
signal2 = signal2._replace(paired_peaks=pairing_result.paired_peaks, unpaired_peaks=pairing_result.unpaired_peaks2)
# Re-interpolate the PSD signals to a common frequency range to be able to plot them one against the other point by point
common_freqs = np.linspace(0, max_freq, 500)

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env python3
import abc
import shutil
from datetime import datetime
from pathlib import Path
from typing import Callable, List, Optional
from matplotlib.figure import Figure
from ..shaketune_config import ShakeTuneConfig
class GraphCreator(abc.ABC):
def __init__(self, config: ShakeTuneConfig, graph_type: str):
self._config = config
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
self._version = ShakeTuneConfig.get_git_version()
self._type = graph_type
self._folder = self._config.get_results_folder(graph_type)
def _move_and_prepare_files(
self,
glob_pattern: str,
min_files_required: Optional[int] = None,
custom_name_func: Optional[Callable[[Path], str]] = None,
) -> List[Path]:
tmp_path = Path('/tmp')
globbed_files = list(tmp_path.glob(glob_pattern))
# If min_files_required is not set, use the number of globbed files as the minimum
min_files_required = min_files_required or len(globbed_files)
if not globbed_files:
raise FileNotFoundError(f'no CSV files found in the /tmp folder to create the {self._type} graphs!')
if len(globbed_files) < min_files_required:
raise FileNotFoundError(f'{min_files_required} CSV files are needed to create the {self._type} graphs!')
lognames = []
for filename in sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[:min_files_required]:
custom_name = custom_name_func(filename) if custom_name_func else filename.name
new_file = self._folder / f"{self._type.replace(' ', '')}_{self._graph_date}_{custom_name}.csv"
# shutil.move() is needed to move the file across filesystems (mainly for BTT CB1 Pi default OS image)
shutil.move(filename, new_file)
lognames.append(new_file)
return lognames
def _save_figure_and_cleanup(self, fig: Figure, lognames: List[Path], axis_label: Optional[str] = None) -> None:
axis_suffix = f'_{axis_label}' if axis_label else ''
png_filename = self._folder / f"{self._type.replace(' ', '')}_{self._graph_date}{axis_suffix}.png"
fig.savefig(png_filename, dpi=self._config.dpi)
if self._config.keep_csv:
self._archive_files(lognames)
else:
self._remove_files(lognames)
def _archive_files(self, lognames: List[Path]) -> None:
return
def _remove_files(self, lognames: List[Path]) -> None:
for csv in lognames:
csv.unlink(missing_ok=True)
def get_type(self) -> str:
return self._type
@abc.abstractmethod
def create_graph(self) -> None:
pass
@abc.abstractmethod
def clean_old_files(self, keep_results: int) -> None:
pass

View File

Before

Width:  |  Height:  |  Size: 607 KiB

After

Width:  |  Height:  |  Size: 607 KiB

View File

@@ -11,6 +11,7 @@
import optparse
import os
from datetime import datetime
from typing import List, Optional
import matplotlib
import matplotlib.font_manager
@@ -28,6 +29,8 @@ from ..helpers.common_func import (
setup_klipper_import,
)
from ..helpers.console_output import ConsoleOutput
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_EFFECT_THRESHOLD = 0.12
@@ -43,6 +46,49 @@ KLIPPAIN_COLORS = {
}
class ShaperGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'input shaper')
self._max_smoothing: Optional[float] = None
self._scv: Optional[float] = None
self._accel_per_hz: Optional[float] = None
def configure(
self, scv: float, max_smoothing: Optional[float] = None, accel_per_hz: Optional[float] = None
) -> None:
self._scv = scv
self._max_smoothing = max_smoothing
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
if not self._scv:
raise ValueError('scv must be set to create the input shaper graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-axis_*.csv',
min_files_required=1,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = shaper_calibration(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
max_smoothing=self._max_smoothing,
scv=self._scv,
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1])
def clean_old_files(self, keep_results: int = 3) -> None:
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
for old_file in files[2 * keep_results :]:
csv_file = old_file.with_suffix('.csv')
csv_file.unlink(missing_ok=True)
old_file.unlink()
######################################################################
# Computation
######################################################################
@@ -50,7 +96,7 @@ KLIPPAIN_COLORS = {
# 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
def calibrate_shaper(datas, max_smoothing, scv, max_freq):
def calibrate_shaper(datas: List[np.ndarray], max_smoothing: Optional[float], scv: float, max_freq: float):
helper = shaper_calibrate.ShaperCalibrate(printer=None)
calibration_data = helper.process_accelerometer_data(datas)
calibration_data.normalize_to_frequencies()
@@ -98,8 +144,17 @@ def calibrate_shaper(datas, max_smoothing, scv, max_freq):
def plot_freq_response(
ax, calibration_data, shapers, klipper_shaper_choice, peaks, peaks_freqs, peaks_threshold, fr, zeta, max_freq
):
ax: plt.Axes,
calibration_data,
shapers,
klipper_shaper_choice: str,
peaks: np.ndarray,
peaks_freqs: np.ndarray,
peaks_threshold: List[float],
fr: float,
zeta: float,
max_freq: float,
) -> None:
freqs = calibration_data.freqs
psd = calibration_data.psd_sum
px = calibration_data.psd_x
@@ -246,7 +301,9 @@ def plot_freq_response(
# 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
def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq):
def plot_spectrogram(
ax: plt.Axes, t: np.ndarray, bins: np.ndarray, pdata: np.ndarray, peaks: np.ndarray, max_freq: float
) -> None:
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
@@ -298,14 +355,14 @@ def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq):
def shaper_calibration(
lognames,
klipperdir='~/klipper',
max_smoothing=None,
scv=5.0,
max_freq=200.0,
accel_per_hz=None,
st_version='unknown',
):
lognames: List[str],
klipperdir: str = '~/klipper',
max_smoothing: Optional[float] = None,
scv: float = 5.0,
max_freq: float = 200.0,
accel_per_hz: Optional[float] = None,
st_version: str = 'unknown',
) -> plt.Figure:
global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir)

View File

@@ -3,6 +3,7 @@
import optparse
import os
from datetime import datetime
from typing import List, Optional
import matplotlib
import matplotlib.font_manager
@@ -12,11 +13,10 @@ import numpy as np
matplotlib.use('Agg')
from ..helpers.common_func import (
compute_spectrogram,
parse_log,
)
from ..helpers.common_func import compute_spectrogram, parse_log
from ..helpers.console_output import ConsoleOutput
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_EFFECT_THRESHOLD = 0.12
@@ -32,12 +32,54 @@ KLIPPAIN_COLORS = {
}
class StaticGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'static frequency')
self._freq: Optional[float] = None
self._duration: Optional[float] = None
self._accel_per_hz: Optional[float] = None
def configure(self, freq: float, duration: float, accel_per_hz: Optional[float] = None) -> None:
self._freq = freq
self._duration = duration
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
if not self._freq or not self._duration or not self._accel_per_hz:
raise ValueError('freq, duration and accel_per_hz must be set to create the static frequency graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-staticfreq_*.csv',
min_files_required=1,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = static_frequency_tool(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
freq=self._freq,
duration=self._duration,
max_freq=200.0,
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1])
def clean_old_files(self, keep_results: int = 3) -> None:
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
for old_file in files[keep_results:]:
csv_file = old_file.with_suffix('.csv')
csv_file.unlink(missing_ok=True)
old_file.unlink()
######################################################################
# Graphing
######################################################################
def plot_spectrogram(ax, t, bins, pdata, max_freq):
def plot_spectrogram(ax: plt.Axes, t: np.ndarray, bins: np.ndarray, pdata: np.ndarray, max_freq: float) -> None:
ax.set_title('Time-Frequency Spectrogram', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
vmin_value = np.percentile(pdata, SPECTROGRAM_LOW_PERCENTILE_FILTER)
@@ -61,7 +103,7 @@ def plot_spectrogram(ax, t, bins, pdata, max_freq):
return
def plot_energy_accumulation(ax, t, bins, pdata):
def plot_energy_accumulation(ax: plt.Axes, t: np.ndarray, bins: np.ndarray, pdata: np.ndarray) -> None:
# Integrate the energy over the frequency bins for each time step and plot this vertically
ax.plot(np.trapz(pdata, t, axis=0), bins, color=KLIPPAIN_COLORS['orange'])
ax.set_title('Vibrations', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
@@ -83,14 +125,14 @@ def plot_energy_accumulation(ax, t, bins, pdata):
def static_frequency_tool(
lognames,
klipperdir='~/klipper',
freq=None,
duration=None,
max_freq=500.0,
accel_per_hz=None,
st_version='unknown',
):
lognames: List[str],
klipperdir: str = '~/klipper',
freq: Optional[float] = None,
duration: Optional[float] = None,
max_freq: float = 500.0,
accel_per_hz: Optional[float] = None,
st_version: str = 'unknown',
) -> plt.Figure:
if freq is None or duration is None:
raise ValueError('Error: missing frequency or duration parameters!')
@@ -127,7 +169,7 @@ def static_frequency_tool(
title_line3 = f'| Maintained frequency: {freq}Hz for {duration}s'
title_line4 = f'| Accel per Hz used: {accel_per_hz} mm/s²/Hz' if accel_per_hz is not None else ''
except Exception:
ConsoleOutput.print('Warning: CSV filename look to be different than expected (%s)' % (lognames[0]))
ConsoleOutput.print(f'Warning: CSV filename look to be different than expected ({lognames[0]})')
title_line2 = lognames[0].split('/')[-1]
title_line3 = ''
title_line4 = ''

View File

@@ -9,8 +9,11 @@ import math
import optparse
import os
import re
import tarfile
from collections import defaultdict
from datetime import datetime
from pathlib import Path
from typing import List, Optional, Tuple
import matplotlib
import matplotlib.font_manager
@@ -29,6 +32,9 @@ from ..helpers.common_func import (
setup_klipper_import,
)
from ..helpers.console_output import ConsoleOutput
from ..helpers.motors_config_parser import MotorsConfigParser
from ..shaketune_config import ShakeTuneConfig
from .graph_creator import GraphCreator
PEAKS_DETECTION_THRESHOLD = 0.05
PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04
@@ -46,20 +52,74 @@ KLIPPAIN_COLORS = {
}
class VibrationsGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config, 'vibrations profile')
self._kinematics: Optional[str] = None
self._accel: Optional[float] = None
self._motors: Optional[List[MotorsConfigParser]] = None
def configure(self, kinematics: str, accel: float, motor_config_parser: MotorsConfigParser) -> None:
self._kinematics = kinematics
self._accel = accel
self._motors = motor_config_parser.get_motors()
def _archive_files(self, lognames: List[Path]) -> None:
tar_path = self._folder / f'{self._type}_{self._graph_date}.tar.gz'
with tarfile.open(tar_path, 'w:gz') as tar:
for csv_file in lognames:
tar.add(csv_file, arcname=csv_file.name, recursive=False)
csv_file.unlink()
def create_graph(self) -> None:
if not self._accel or not self._kinematics:
raise ValueError('accel and kinematics must be set to create the vibrations profile graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-vib_*.csv',
min_files_required=None,
custom_name_func=lambda f: re.search(r'shaketune-vib_(.*?)_\d{8}_\d{6}', f.name).group(1),
)
fig = vibrations_profile(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
kinematics=self._kinematics,
accel=self._accel,
st_version=self._version,
motors=self._motors,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
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
for old_file in files[keep_results:]:
old_file.unlink()
tar_file = old_file.with_suffix('.tar.gz')
tar_file.unlink(missing_ok=True)
######################################################################
# Computation
######################################################################
# Call to the official Klipper input shaper object to do the PSD computation
def calc_freq_response(data):
def calc_freq_response(data) -> Tuple[np.ndarray, np.ndarray]:
helper = shaper_calibrate.ShaperCalibrate(printer=None)
return helper.process_accelerometer_data(data)
# 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
def compute_motor_profiles(freqs, psds, all_angles_energy, measured_angles=None, energy_amplification_factor=2):
def compute_motor_profiles(
freqs: np.ndarray,
psds: dict,
all_angles_energy: dict,
measured_angles: Optional[List[int]] = None,
energy_amplification_factor: int = 2,
) -> Tuple[dict, np.ndarray]:
if measured_angles is None:
measured_angles = [0, 90]
@@ -97,7 +157,9 @@ def compute_motor_profiles(freqs, psds, all_angles_energy, measured_angles=None,
# 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
# 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=None):
def compute_dir_speed_spectrogram(
measured_speeds: List[float], data: dict, kinematics: str = 'cartesian', measured_angles: Optional[List[int]] = None
) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
if measured_angles is None:
measured_angles = [0, 90]
@@ -106,7 +168,7 @@ def compute_dir_speed_spectrogram(measured_speeds, data, kinematics='cartesian',
spectrum_speeds = np.linspace(min(measured_speeds), max(measured_speeds), len(measured_speeds) * 6)
spectrum_vibrations = np.zeros((len(spectrum_angles), len(spectrum_speeds)))
def get_interpolated_vibrations(data, speed, speeds):
def get_interpolated_vibrations(data: dict, speed: float, speeds: List[float]) -> float:
idx = np.clip(np.searchsorted(speeds, speed, side='left'), 1, len(speeds) - 1)
lower_speed = speeds[idx - 1]
upper_speed = speeds[idx]
@@ -139,7 +201,7 @@ def compute_dir_speed_spectrogram(measured_speeds, data, kinematics='cartesian',
return spectrum_angles, spectrum_speeds, spectrum_vibrations
def compute_angle_powers(spectrogram_data):
def compute_angle_powers(spectrogram_data: np.ndarray) -> np.ndarray:
angles_powers = np.trapz(spectrogram_data, axis=1)
# Since we want to plot it on a continuous polar plot later on, we need to append parts of
@@ -151,7 +213,7 @@ def compute_angle_powers(spectrogram_data):
return convolved_extended[9:-9]
def compute_speed_powers(spectrogram_data, smoothing_window=15):
def compute_speed_powers(spectrogram_data: np.ndarray, smoothing_window: int = 15) -> np.ndarray:
min_values = np.amin(spectrogram_data, axis=0)
max_values = np.amax(spectrogram_data, axis=0)
var_values = np.var(spectrogram_data, axis=0)
@@ -167,7 +229,7 @@ def compute_speed_powers(spectrogram_data, smoothing_window=15):
conv_filter = np.ones(smoothing_window) / smoothing_window
window = int(smoothing_window / 2)
def pad_and_smooth(data):
def pad_and_smooth(data: np.ndarray) -> np.ndarray:
data_padded = np.pad(data, (window,), mode='edge')
smoothed_data = np.convolve(data_padded, conv_filter, mode='valid')
return smoothed_data
@@ -182,7 +244,9 @@ def compute_speed_powers(spectrogram_data, smoothing_window=15):
# Function that filter and split the good_speed ranges. The goal is to remove some zones around
# additional detected small peaks in order to suppress them if there is a peak, even if it's low,
# that's probably due to a crossing in the motor resonance pattern that still need to be removed
def filter_and_split_ranges(all_speeds, good_speeds, peak_speed_indices, deletion_range):
def filter_and_split_ranges(
all_speeds: np.ndarray, good_speeds: List[Tuple[int, int, float]], peak_speed_indices: dict, deletion_range: int
) -> List[Tuple[int, int, float]]:
# Process each range to filter out and split based on peak indices
filtered_good_speeds = []
for start, end, energy in good_speeds:
@@ -225,7 +289,9 @@ 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
# 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=None):
def compute_symmetry_analysis(
all_angles: np.ndarray, spectrogram_data: np.ndarray, measured_angles: Optional[List[int]] = None
) -> float:
if measured_angles is None:
measured_angles = [0, 90]
@@ -256,7 +322,13 @@ def compute_symmetry_analysis(all_angles, spectrogram_data, measured_angles=None
######################################################################
def plot_angle_profile_polar(ax, angles, angles_powers, low_energy_zones, symmetry_factor):
def plot_angle_profile_polar(
ax: plt.Axes,
angles: np.ndarray,
angles_powers: np.ndarray,
low_energy_zones: List[Tuple[int, int, float]],
symmetry_factor: float,
) -> None:
angles_radians = np.deg2rad(angles)
ax.set_title('Polar angle energy profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
@@ -315,16 +387,16 @@ def plot_angle_profile_polar(ax, angles, angles_powers, low_energy_zones, symmet
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: plt.Axes,
all_speeds: np.ndarray,
sp_min_energy: np.ndarray,
sp_max_energy: np.ndarray,
sp_variance_energy: np.ndarray,
vibration_metric: np.ndarray,
num_peaks: int,
peaks: np.ndarray,
low_energy_zones: List[Tuple[int, int, float]],
) -> None:
ax.set_title('Global speed energy profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Energy')
@@ -389,7 +461,9 @@ def plot_global_speed_profile(
return
def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics='cartesian'):
def plot_angular_speed_profiles(
ax: plt.Axes, speeds: np.ndarray, angles: np.ndarray, spectrogram_data: np.ndarray, kinematics: str = 'cartesian'
) -> None:
ax.set_title('Angular speed energy profiles', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Energy')
@@ -423,7 +497,14 @@ def plot_angular_speed_profiles(ax, speeds, angles, spectrogram_data, kinematics
return
def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_profile, max_freq):
def plot_motor_profiles(
ax: plt.Axes,
freqs: np.ndarray,
main_angles: List[int],
motor_profiles: dict,
global_motor_profile: np.ndarray,
max_freq: float,
) -> None:
ax.set_title('Motor frequency profile', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_ylabel('Energy')
ax.set_xlabel('Frequency (Hz)')
@@ -501,7 +582,9 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro
return
def plot_vibration_spectrogram_polar(ax, angles, speeds, spectrogram_data):
def plot_vibration_spectrogram_polar(
ax: plt.Axes, angles: np.ndarray, speeds: np.ndarray, spectrogram_data: np.ndarray
) -> None:
angles_radians = np.radians(angles)
# Assuming speeds defines the radial distance from the center, we need to create a meshgrid
@@ -527,7 +610,9 @@ def plot_vibration_spectrogram_polar(ax, angles, speeds, spectrogram_data):
return
def plot_vibration_spectrogram(ax, angles, speeds, spectrogram_data, peaks):
def plot_vibration_spectrogram(
ax: plt.Axes, angles: np.ndarray, speeds: np.ndarray, spectrogram_data: np.ndarray, peaks: np.ndarray
) -> None:
ax.set_title('Vibrations heatmap', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold')
ax.set_xlabel('Speed (mm/s)')
ax.set_ylabel('Angle (deg)')
@@ -560,7 +645,7 @@ def plot_vibration_spectrogram(ax, angles, speeds, spectrogram_data, peaks):
return
def plot_motor_config_txt(fig, motors, differences):
def plot_motor_config_txt(fig: plt.Figure, motors: List[MotorsConfigParser], differences: Optional[str]) -> None:
motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')]
distance = 0.12
@@ -618,7 +703,7 @@ def plot_motor_config_txt(fig, motors, differences):
######################################################################
def extract_angle_and_speed(logname):
def extract_angle_and_speed(logname: str) -> Tuple[float, float]:
try:
match = re.search(r'an(\d+)_\d+sp(\d+)_\d+', os.path.basename(logname))
if match:
@@ -634,8 +719,14 @@ def extract_angle_and_speed(logname):
def vibrations_profile(
lognames, klipperdir='~/klipper', kinematics='cartesian', accel=None, max_freq=1000.0, st_version=None, motors=None
):
lognames: List[str],
klipperdir: str = '~/klipper',
kinematics: str = 'cartesian',
accel: Optional[float] = None,
max_freq: float = 1000.0,
st_version: Optional[str] = None,
motors: Optional[List[MotorsConfigParser]] = None,
) -> plt.Figure:
global shaper_calibrate
shaper_calibrate = setup_klipper_import(klipperdir)

View File

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

View File

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

View File

@@ -1,327 +0,0 @@
#!/usr/bin/env python3
import abc
import re
import shutil
import tarfile
from datetime import datetime
from pathlib import Path
from typing import Callable, Optional
from matplotlib.figure import Figure
from ..measurement.motorsconfigparser import MotorsConfigParser
from ..shaketune_config import ShakeTuneConfig
from .analyze_axesmap import axesmap_calibration
from .graph_belts import belts_calibration
from .graph_shaper import shaper_calibration
from .graph_static import static_frequency_tool
from .graph_vibrations import vibrations_profile
class GraphCreator(abc.ABC):
def __init__(self, config: ShakeTuneConfig):
self._config = config
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
self._version = ShakeTuneConfig.get_git_version()
self._type = None
self._folder = None
def _setup_folder(self, graph_type: str) -> None:
self._type = graph_type
self._folder = self._config.get_results_folder(graph_type)
def _move_and_prepare_files(
self,
glob_pattern: str,
min_files_required: Optional[int] = None,
custom_name_func: Optional[Callable[[Path], str]] = None,
) -> list[Path]:
tmp_path = Path('/tmp')
globbed_files = list(tmp_path.glob(glob_pattern))
# If min_files_required is not set, use the number of globbed files as the minimum
min_files_required = min_files_required or len(globbed_files)
if not globbed_files:
raise FileNotFoundError(f'no CSV files found in the /tmp folder to create the {self._type} graphs!')
if len(globbed_files) < min_files_required:
raise FileNotFoundError(f'{min_files_required} CSV files are needed to create the {self._type} graphs!')
lognames = []
for filename in sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[:min_files_required]:
custom_name = custom_name_func(filename) if custom_name_func else filename.name
new_file = self._folder / f'{self._type}_{self._graph_date}_{custom_name}.csv'
# shutil.move() is needed to move the file across filesystems (mainly for BTT CB1 Pi default OS image)
shutil.move(filename, new_file)
lognames.append(new_file)
return lognames
def _save_figure_and_cleanup(self, fig: Figure, lognames: list[Path], axis_label: Optional[str] = None) -> None:
axis_suffix = f'_{axis_label}' if axis_label else ''
png_filename = self._folder / f'{self._type}_{self._graph_date}{axis_suffix}.png'
fig.savefig(png_filename, dpi=self._config.dpi)
if self._config.keep_csv:
self._archive_files(lognames)
else:
self._remove_files(lognames)
def _archive_files(self, _: list[Path]) -> None:
return
def _remove_files(self, lognames: list[Path]) -> None:
for csv in lognames:
csv.unlink(missing_ok=True)
def get_type(self) -> str:
return self._type
@abc.abstractmethod
def create_graph(self) -> None:
pass
@abc.abstractmethod
def clean_old_files(self, keep_results: int) -> None:
pass
class BeltsGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config)
self._kinematics = None
self._accel_per_hz = None
self._setup_folder('belts')
def configure(self, kinematics: str = None, accel_per_hz: float = None) -> None:
self._kinematics = kinematics
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-belt_*.csv',
min_files_required=2,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = belts_calibration(
lognames=[str(path) for path in lognames],
kinematics=self._kinematics,
klipperdir=str(self._config.klipper_folder),
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
# Get all PNG files in the directory as a list of Path objects
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
# Delete the older files
for old_file in files[keep_results:]:
file_date = '_'.join(old_file.stem.split('_')[1:3])
for suffix in ['A', 'B']:
csv_file = self._folder / f'belts_{file_date}_{suffix}.csv'
csv_file.unlink(missing_ok=True)
old_file.unlink()
class ShaperGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config)
self._max_smoothing = None
self._scv = None
self._setup_folder('shaper')
def configure(self, scv: float, max_smoothing: float = None, accel_per_hz: float = None) -> None:
self._scv = scv
self._max_smoothing = max_smoothing
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
if not self._scv:
raise ValueError('scv must be set to create the input shaper graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-axis_*.csv',
min_files_required=1,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = shaper_calibration(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
max_smoothing=self._max_smoothing,
scv=self._scv,
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1])
def clean_old_files(self, keep_results: int = 3) -> None:
# Get all PNG files in the directory as a list of Path objects
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= 2 * keep_results:
return # No need to delete any files
# Delete the older files
for old_file in files[2 * keep_results :]:
csv_file = old_file.with_suffix('.csv')
csv_file.unlink(missing_ok=True)
old_file.unlink()
class VibrationsGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config)
self._kinematics = None
self._accel = None
self._motors = None
self._setup_folder('vibrations')
def configure(self, kinematics: str, accel: float, motor_config_parser: MotorsConfigParser) -> None:
self._kinematics = kinematics
self._accel = accel
self._motors = motor_config_parser.get_motors()
def _archive_files(self, lognames: list[Path]) -> None:
tar_path = self._folder / f'{self._type}_{self._graph_date}.tar.gz'
with tarfile.open(tar_path, 'w:gz') as tar:
for csv_file in lognames:
tar.add(csv_file, arcname=csv_file.name, recursive=False)
csv_file.unlink()
def create_graph(self) -> None:
if not self._accel or not self._kinematics:
raise ValueError('accel, chip_name and kinematics must be set to create the vibrations profile graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-vib_*.csv',
min_files_required=None,
custom_name_func=lambda f: re.search(r'shaketune-vib_(.*?)_\d{8}_\d{6}', f.name).group(1),
)
fig = vibrations_profile(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
kinematics=self._kinematics,
accel=self._accel,
st_version=self._version,
motors=self._motors,
)
self._save_figure_and_cleanup(fig, lognames)
def clean_old_files(self, keep_results: int = 3) -> None:
# Get all PNG files in the directory as a list of Path objects
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
# Delete the older files
for old_file in files[keep_results:]:
old_file.unlink()
tar_file = old_file.with_suffix('.tar.gz')
tar_file.unlink(missing_ok=True)
class AxesMapFinder(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config)
self._accel = None
self._segment_length = None
self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S')
self._setup_folder('axesmap')
def configure(self, accel: int, segment_length: float) -> None:
self._accel = accel
self._segment_length = segment_length
def create_graph(self) -> None:
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-axesmap_*.csv',
min_files_required=3,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = axesmap_calibration(
lognames=[str(path) for path in lognames],
accel=self._accel,
fixed_length=self._segment_length,
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 ['X', 'Y', 'Z']:
csv_file = self._folder / f'axesmap_{file_date}_{suffix}.csv'
csv_file.unlink(missing_ok=True)
old_file.unlink()
class StaticGraphCreator(GraphCreator):
def __init__(self, config: ShakeTuneConfig):
super().__init__(config)
self._freq = None
self._duration = None
self._accel_per_hz = None
self._setup_folder('staticfreq')
def configure(self, freq: float, duration: float, accel_per_hz: float = None) -> None:
self._freq = freq
self._duration = duration
self._accel_per_hz = accel_per_hz
def create_graph(self) -> None:
if not self._freq or not self._duration or not self._accel_per_hz:
raise ValueError('freq, duration and accel_per_hz must be set to create the static frequency graph!')
lognames = self._move_and_prepare_files(
glob_pattern='shaketune-staticfreq_*.csv',
min_files_required=1,
custom_name_func=lambda f: f.stem.split('_')[1].upper(),
)
fig = static_frequency_tool(
lognames=[str(path) for path in lognames],
klipperdir=str(self._config.klipper_folder),
freq=self._freq,
duration=self._duration,
max_freq=200.0,
accel_per_hz=self._accel_per_hz,
st_version=self._version,
)
self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1])
def clean_old_files(self, keep_results: int = 3) -> None:
# Get all PNG files in the directory as a list of Path objects
files = sorted(self._folder.glob('*.png'), key=lambda f: f.stat().st_mtime, reverse=True)
if len(files) <= keep_results:
return # No need to delete any files
# Delete the older files
for old_file in files[keep_results:]:
csv_file = old_file.with_suffix('.csv')
csv_file.unlink(missing_ok=True)
old_file.unlink()

View File

@@ -4,21 +4,21 @@
import os
from pathlib import Path
from .helpers.console_output import ConsoleOutput
from .measurement import (
from .commands import (
axes_map_calibration,
axes_shaper_calibration,
compare_belts_responses,
create_vibrations_profile,
excitate_axis_at_freq,
)
from .post_processing import (
AxesMapFinder,
from .graph_creators import (
AxesMapGraphCreator,
BeltsGraphCreator,
ShaperGraphCreator,
StaticGraphCreator,
VibrationsGraphCreator,
)
from .helpers.console_output import ConsoleOutput
from .shaketune_config import ShakeTuneConfig
from .shaketune_process import ShakeTuneProcess
@@ -29,7 +29,7 @@ class ShakeTune:
self._printer = config.get_printer()
gcode = self._printer.lookup_object('gcode')
res_tester = self._printer.lookup_object('resonance_tester')
res_tester = self._printer.lookup_object('resonance_tester', None)
if res_tester is None:
config.error('No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune.')
@@ -115,8 +115,8 @@ class ShakeTune:
def cmd_AXES_MAP_CALIBRATION(self, gcmd) -> None:
ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}')
axes_map_finder = AxesMapFinder(self._config)
st_process = ShakeTuneProcess(self._config, axes_map_finder, self.timeout)
axes_map_graph_creator = AxesMapGraphCreator(self._config)
st_process = ShakeTuneProcess(self._config, axes_map_graph_creator, self.timeout)
axes_map_calibration(gcmd, self._pconfig, st_process)
def cmd_COMPARE_BELTS_RESPONSES(self, gcmd) -> None:

View File

@@ -8,11 +8,11 @@ KLIPPER_FOLDER = Path.home() / 'klipper'
KLIPPER_LOG_FOLDER = Path.home() / 'printer_data/logs'
RESULTS_BASE_FOLDER = Path.home() / 'printer_data/config/K-ShakeTune_results'
RESULTS_SUBFOLDERS = {
'axesmap': 'axes_map',
'belts': 'belts',
'shaper': 'input_shaper',
'vibrations': 'vibrations',
'staticfreq': 'static_freq',
'axes map': 'axes_map',
'belts comparison': 'belts',
'input shaper': 'input_shaper',
'vibrations profile': 'vibrations',
'static frequency': 'static_freq',
}