diff --git a/README.md b/README.md index 644df13..b69f6b1 100644 --- a/README.md +++ b/README.md @@ -32,3 +32,5 @@ Follow these steps to install Shake&Tune on your printer: # timeout: 300 # The maximum time in seconds to let Shake&Tune process the CSV files and generate the graphs. ``` + +Don't forget to check out **[Shake&Tune documentation here](./docs/README.md)**. diff --git a/shaketune/commands/__init__.py b/shaketune/commands/__init__.py new file mode 100644 index 0000000..17c7707 --- /dev/null +++ b/shaketune/commands/__init__.py @@ -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 diff --git a/shaketune/measurement/accelerometer.py b/shaketune/commands/accelerometer.py similarity index 100% rename from shaketune/measurement/accelerometer.py rename to shaketune/commands/accelerometer.py diff --git a/shaketune/measurement/axes_map.py b/shaketune/commands/axes_map_calibration.py similarity index 97% rename from shaketune/measurement/axes_map.py rename to shaketune/commands/axes_map_calibration.py index f43a584..2cf22d3 100644 --- a/shaketune/measurement/axes_map.py +++ b/shaketune/commands/axes_map_calibration.py @@ -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() diff --git a/shaketune/measurement/axes_input_shaper.py b/shaketune/commands/axes_shaper_calibration.py similarity index 97% rename from shaketune/measurement/axes_input_shaper.py rename to shaketune/commands/axes_shaper_calibration.py index a0985b2..26009fa 100644 --- a/shaketune/measurement/axes_input_shaper.py +++ b/shaketune/commands/axes_shaper_calibration.py @@ -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: diff --git a/shaketune/measurement/belts_comparison.py b/shaketune/commands/compare_belts_responses.py similarity index 95% rename from shaketune/measurement/belts_comparison.py rename to shaketune/commands/compare_belts_responses.py index 4f5e235..54ac8cb 100644 --- a/shaketune/measurement/belts_comparison.py +++ b/shaketune/commands/compare_belts_responses.py @@ -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() diff --git a/shaketune/measurement/vibrations_profile.py b/shaketune/commands/create_vibrations_profile.py similarity index 96% rename from shaketune/measurement/vibrations_profile.py rename to shaketune/commands/create_vibrations_profile.py index d8a0f78..62f1785 100644 --- a/shaketune/measurement/vibrations_profile.py +++ b/shaketune/commands/create_vibrations_profile.py @@ -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() diff --git a/shaketune/measurement/static_freq.py b/shaketune/commands/excitate_axis_at_freq.py similarity index 97% rename from shaketune/measurement/static_freq.py rename to shaketune/commands/excitate_axis_at_freq.py index 011e305..0aa0afd 100644 --- a/shaketune/measurement/static_freq.py +++ b/shaketune/commands/excitate_axis_at_freq.py @@ -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() diff --git a/shaketune/dummy_macros.cfg b/shaketune/dummy_macros.cfg index 5e7b43d..a8d7f8c 100644 --- a/shaketune/dummy_macros.cfg +++ b/shaketune/dummy_macros.cfg @@ -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] diff --git a/shaketune/graph_creators/__init__.py b/shaketune/graph_creators/__init__.py new file mode 100644 index 0000000..1b3c530 --- /dev/null +++ b/shaketune/graph_creators/__init__.py @@ -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 diff --git a/shaketune/post_processing/analyze_axesmap.py b/shaketune/graph_creators/axes_map_graph_creator.py similarity index 83% rename from shaketune/post_processing/analyze_axesmap.py rename to shaketune/graph_creators/axes_map_graph_creator.py index 4c968eb..10f0ee9 100644 --- a/shaketune/post_processing/analyze_axesmap.py +++ b/shaketune/graph_creators/axes_map_graph_creator.py @@ -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: diff --git a/shaketune/post_processing/graph_belts.py b/shaketune/graph_creators/belts_graph_creator.py similarity index 84% rename from shaketune/post_processing/graph_belts.py rename to shaketune/graph_creators/belts_graph_creator.py index 17d1204..0ab1411 100644 --- a/shaketune/post_processing/graph_belts.py +++ b/shaketune/graph_creators/belts_graph_creator.py @@ -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) diff --git a/shaketune/graph_creators/graph_creator.py b/shaketune/graph_creators/graph_creator.py new file mode 100644 index 0000000..b86c97c --- /dev/null +++ b/shaketune/graph_creators/graph_creator.py @@ -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 diff --git a/shaketune/post_processing/klippain.png b/shaketune/graph_creators/klippain.png similarity index 100% rename from shaketune/post_processing/klippain.png rename to shaketune/graph_creators/klippain.png diff --git a/shaketune/post_processing/graph_shaper.py b/shaketune/graph_creators/shaper_graph_creator.py similarity index 87% rename from shaketune/post_processing/graph_shaper.py rename to shaketune/graph_creators/shaper_graph_creator.py index 7f3e276..73a871a 100644 --- a/shaketune/post_processing/graph_shaper.py +++ b/shaketune/graph_creators/shaper_graph_creator.py @@ -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) diff --git a/shaketune/post_processing/graph_static.py b/shaketune/graph_creators/static_graph_creator.py similarity index 69% rename from shaketune/post_processing/graph_static.py rename to shaketune/graph_creators/static_graph_creator.py index 54564ca..a02ed61 100644 --- a/shaketune/post_processing/graph_static.py +++ b/shaketune/graph_creators/static_graph_creator.py @@ -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 = '' diff --git a/shaketune/post_processing/graph_vibrations.py b/shaketune/graph_creators/vibrations_graph_creator.py similarity index 87% rename from shaketune/post_processing/graph_vibrations.py rename to shaketune/graph_creators/vibrations_graph_creator.py index 7fbf8e9..49065ff 100644 --- a/shaketune/post_processing/graph_vibrations.py +++ b/shaketune/graph_creators/vibrations_graph_creator.py @@ -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) diff --git a/shaketune/measurement/motorsconfigparser.py b/shaketune/helpers/motors_config_parser.py similarity index 100% rename from shaketune/measurement/motorsconfigparser.py rename to shaketune/helpers/motors_config_parser.py diff --git a/shaketune/measurement/resonance_test.py b/shaketune/helpers/resonance_test.py similarity index 100% rename from shaketune/measurement/resonance_test.py rename to shaketune/helpers/resonance_test.py diff --git a/shaketune/measurement/__init__.py b/shaketune/measurement/__init__.py deleted file mode 100644 index 72d968b..0000000 --- a/shaketune/measurement/__init__.py +++ /dev/null @@ -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 diff --git a/shaketune/post_processing/__init__.py b/shaketune/post_processing/__init__.py deleted file mode 100644 index 247c2ca..0000000 --- a/shaketune/post_processing/__init__.py +++ /dev/null @@ -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 diff --git a/shaketune/post_processing/graph_creator.py b/shaketune/post_processing/graph_creator.py deleted file mode 100644 index a4c6b35..0000000 --- a/shaketune/post_processing/graph_creator.py +++ /dev/null @@ -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() diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py index 266a94f..0d01d1b 100644 --- a/shaketune/shaketune.py +++ b/shaketune/shaketune.py @@ -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: diff --git a/shaketune/shaketune_config.py b/shaketune/shaketune_config.py index 7046d0f..ae2e7d1 100644 --- a/shaketune/shaketune_config.py +++ b/shaketune/shaketune_config.py @@ -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', }