Code cleanup before release (#114)
This commit is contained in:
@@ -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)**.
|
||||
|
||||
7
shaketune/commands/__init__.py
Normal file
7
shaketune/commands/__init__.py
Normal 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
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
|
||||
8
shaketune/graph_creators/__init__.py
Normal file
8
shaketune/graph_creators/__init__.py
Normal 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
|
||||
@@ -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:
|
||||
@@ -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)
|
||||
74
shaketune/graph_creators/graph_creator.py
Normal file
74
shaketune/graph_creators/graph_creator.py
Normal 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
|
||||
|
Before Width: | Height: | Size: 607 KiB After Width: | Height: | Size: 607 KiB |
@@ -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)
|
||||
|
||||
@@ -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 = ''
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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:
|
||||
|
||||
@@ -9,10 +9,10 @@ KLIPPER_LOG_FOLDER = Path.home() / 'printer_data/logs'
|
||||
RESULTS_BASE_FOLDER = Path.home() / 'printer_data/config/K-ShakeTune_results'
|
||||
RESULTS_SUBFOLDERS = {
|
||||
'axes map': 'axes_map',
|
||||
'belts': 'belts',
|
||||
'shaper': 'input_shaper',
|
||||
'vibrations': 'vibrations',
|
||||
'staticfreq': 'static_freq',
|
||||
'belts comparison': 'belts',
|
||||
'input shaper': 'input_shaper',
|
||||
'vibrations profile': 'vibrations',
|
||||
'static frequency': 'static_freq',
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user