From da51082b44cec0c64662920dbdc1892884efdf6b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sat, 8 Jun 2024 17:02:28 +0200 Subject: [PATCH] Static freq optional graphs (#112) --- shaketune/dummy_macros.cfg | 4 +- shaketune/measurement/resonance_test.py | 32 +++- shaketune/measurement/static_freq.py | 51 +++++- shaketune/post_processing/__init__.py | 1 + shaketune/post_processing/graph_belts.py | 2 +- shaketune/post_processing/graph_creator.py | 50 ++++++ shaketune/post_processing/graph_static.py | 175 +++++++++++++++++++++ shaketune/shaketune.py | 12 +- shaketune/shaketune_config.py | 8 +- 9 files changed, 322 insertions(+), 13 deletions(-) create mode 100644 shaketune/post_processing/graph_static.py diff --git a/shaketune/dummy_macros.cfg b/shaketune/dummy_macros.cfg index d3909f8..5e7b43d 100644 --- a/shaketune/dummy_macros.cfg +++ b/shaketune/dummy_macros.cfg @@ -7,12 +7,14 @@ [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(10) %} + {% 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} diff --git a/shaketune/measurement/resonance_test.py b/shaketune/measurement/resonance_test.py index 6913626..9ce31b5 100644 --- a/shaketune/measurement/resonance_test.py +++ b/shaketune/measurement/resonance_test.py @@ -18,7 +18,7 @@ from ..helpers.console_output import ConsoleOutput # to test the resonance frequency of the printer and its components def vibrate_axis(toolhead, gcode, axis_direction, min_freq, max_freq, hz_per_sec, accel_per_hz): freq = min_freq - X, Y, Z, E = toolhead.get_position() # Get current position + X, Y, Z, E = toolhead.get_position() sign = 1.0 while freq <= max_freq + 0.000001: @@ -48,3 +48,33 @@ def vibrate_axis(toolhead, gcode, axis_direction, min_freq, max_freq, hz_per_sec ConsoleOutput.print(f'Testing frequency: {freq:.0f} Hz') toolhead.wait_moves() + + +# This function is used to vibrate the toolhead in a specific axis direction at a static frequency for a specific duration +def vibrate_axis_at_static_freq(toolhead, gcode, axis_direction, freq, duration, accel_per_hz): + X, Y, Z, E = toolhead.get_position() + sign = 1.0 + + # Compute movements values + t_seg = 0.25 / freq + accel = accel_per_hz * freq + max_v = accel * t_seg + toolhead.cmd_M204(gcode.create_gcode_command('M204', 'M204', {'S': accel})) + L = 0.5 * accel * t_seg**2 + + # Calculate move points based on axis direction (X, Y and Z) + magnitude = math.sqrt(sum([component**2 for component in axis_direction])) + normalized_direction = tuple(component / magnitude for component in axis_direction) + dX, dY, dZ = normalized_direction[0] * L, normalized_direction[1] * L, normalized_direction[2] * L + + # Start a timer to measure the duration of the test and execute the vibration within the specified time + start_time = toolhead.reactor.monotonic() + while toolhead.reactor.monotonic() - start_time < duration: + nX = X + sign * dX + nY = Y + sign * dY + nZ = Z + sign * dZ + toolhead.move([nX, nY, nZ, E], max_v) + toolhead.move([X, Y, Z, E], max_v) + sign *= -1 + + toolhead.wait_moves() diff --git a/shaketune/measurement/static_freq.py b/shaketune/measurement/static_freq.py index bc77251..011e305 100644 --- a/shaketune/measurement/static_freq.py +++ b/shaketune/measurement/static_freq.py @@ -2,17 +2,23 @@ from ..helpers.common_func import AXIS_CONFIG from ..helpers.console_output import ConsoleOutput -from .resonance_test import vibrate_axis +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) -> None: +def excitate_axis_at_freq(gcmd, config, st_process: ShakeTuneProcess) -> None: + create_graph = gcmd.get_int('CREATE_GRAPH', default=0, minval=0, maxval=1) == 1 freq = gcmd.get_int('FREQUENCY', default=25, minval=1) - duration = gcmd.get_int('DURATION', default=10, minval=1) + duration = gcmd.get_int('DURATION', default=30, minval=1) accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) axis = gcmd.get('AXIS', default='x').lower() feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) + accel_chip = gcmd.get('ACCEL_CHIP', default=None) + if accel_chip == '': + accel_chip = None if accel_per_hz == '': accel_per_hz = None @@ -20,6 +26,15 @@ def excitate_axis_at_freq(gcmd, config) -> None: if axis_config is None: raise gcmd.error('AXIS selection invalid. Should be either x, y, a or b!') + if create_graph: + printer = config.get_printer() + if accel_chip is None: + accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy' if axis in ['a', 'b'] else axis) + k_accelerometer = printer.lookup_object(accel_chip, None) + if k_accelerometer is None: + raise gcmd.error(f'Accelerometer chip [{accel_chip}] was not found!') + accelerometer = Accelerometer(k_accelerometer) + ConsoleOutput.print(f'Excitating {axis.upper()} axis at {freq}Hz for {duration} seconds') printer = config.get_printer() @@ -55,7 +70,29 @@ def excitate_axis_at_freq(gcmd, config) -> None: toolhead.manual_move(point, feedrate_travel) toolhead.dwell(0.5) - min_freq = freq - 1 - max_freq = freq + 1 - hz_per_sec = 1 / (duration / 3) - vibrate_axis(toolhead, gcode, axis_config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz) + # Deactivate input shaper if it is active to get raw movements + input_shaper = printer.lookup_object('input_shaper', None) + if input_shaper is not None: + input_shaper.disable_shaping() + else: + input_shaper = None + + # If the user want to create a graph, we start accelerometer recording + if create_graph: + accelerometer.start_measurement() + + toolhead.dwell(0.5) + vibrate_axis_at_static_freq(toolhead, gcode, axis_config['direction'], freq, duration, accel_per_hz) + toolhead.dwell(0.5) + + # Re-enable the input shaper if it was active + if input_shaper is not None: + input_shaper.enable_shaping() + + # If the user wanted to create a graph, we stop the recording and generate it + if create_graph: + accelerometer.stop_measurement(f'staticfreq_{axis.upper()}', append_time=True) + + creator = st_process.get_graph_creator() + creator.configure(freq, duration, accel_per_hz) + st_process.run() diff --git a/shaketune/post_processing/__init__.py b/shaketune/post_processing/__init__.py index 5ec0700..247c2ca 100644 --- a/shaketune/post_processing/__init__.py +++ b/shaketune/post_processing/__init__.py @@ -4,4 +4,5 @@ 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_belts.py b/shaketune/post_processing/graph_belts.py index 80ca952..17d1204 100644 --- a/shaketune/post_processing/graph_belts.py +++ b/shaketune/post_processing/graph_belts.py @@ -239,7 +239,7 @@ def plot_compare_frequency(ax, signal1, signal2, signal1_belt, signal2_belt, max ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) - ax.ticklabel_format(axis='y', style='scientific', scilimits=(0, 0)) + ax.ticklabel_format(axis='x', style='scientific', scilimits=(0, 0)) ax.grid(which='major', color='grey') ax.grid(which='minor', color='lightgrey') fontP = matplotlib.font_manager.FontProperties() diff --git a/shaketune/post_processing/graph_creator.py b/shaketune/post_processing/graph_creator.py index 401ff6b..a4c6b35 100644 --- a/shaketune/post_processing/graph_creator.py +++ b/shaketune/post_processing/graph_creator.py @@ -15,6 +15,7 @@ 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 @@ -275,3 +276,52 @@ class AxesMapFinder(GraphCreator): 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/post_processing/graph_static.py b/shaketune/post_processing/graph_static.py new file mode 100644 index 0000000..54564ca --- /dev/null +++ b/shaketune/post_processing/graph_static.py @@ -0,0 +1,175 @@ +#!/usr/bin/env python3 + +import optparse +import os +from datetime import datetime + +import matplotlib +import matplotlib.font_manager +import matplotlib.pyplot as plt +import matplotlib.ticker +import numpy as np + +matplotlib.use('Agg') + +from ..helpers.common_func import ( + compute_spectrogram, + parse_log, +) +from ..helpers.console_output import ConsoleOutput + +PEAKS_DETECTION_THRESHOLD = 0.05 +PEAKS_EFFECT_THRESHOLD = 0.12 +SPECTROGRAM_LOW_PERCENTILE_FILTER = 5 +MAX_VIBRATIONS = 5.0 + +KLIPPAIN_COLORS = { + 'purple': '#70088C', + 'orange': '#FF8D32', + 'dark_purple': '#150140', + 'dark_orange': '#F24130', + 'red_pink': '#F2055C', +} + + +###################################################################### +# Graphing +###################################################################### + + +def plot_spectrogram(ax, t, bins, pdata, max_freq): + ax.set_title('Time-Frequency Spectrogram', fontsize=14, color=KLIPPAIN_COLORS['dark_orange'], weight='bold') + + vmin_value = np.percentile(pdata, SPECTROGRAM_LOW_PERCENTILE_FILTER) + + cm = 'inferno' + norm = matplotlib.colors.LogNorm(vmin=vmin_value) + ax.imshow( + pdata.T, + norm=norm, + cmap=cm, + aspect='auto', + extent=[t[0], t[-1], bins[0], bins[-1]], + origin='lower', + interpolation='antialiased', + ) + + ax.set_xlim([0.0, max_freq]) + ax.set_ylabel('Time (s)') + ax.set_xlabel('Frequency (Hz)') + + return + + +def plot_energy_accumulation(ax, t, bins, pdata): + # 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') + ax.set_xlabel('Cumulative Energy') + ax.set_ylabel('Time (s)') + ax.set_ylim([bins[0], bins[-1]]) + + ax.xaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) + ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator()) + ax.ticklabel_format(axis='x', style='scientific', scilimits=(0, 0)) + ax.grid(which='major', color='grey') + ax.grid(which='minor', color='lightgrey') + # ax.legend() + + +###################################################################### +# Startup and main routines +###################################################################### + + +def static_frequency_tool( + lognames, + klipperdir='~/klipper', + freq=None, + duration=None, + max_freq=500.0, + accel_per_hz=None, + st_version='unknown', +): + if freq is None or duration is None: + raise ValueError('Error: missing frequency or duration parameters!') + + datas = [data for data in (parse_log(fn) for fn in lognames) if data is not None] + if len(datas) > 1: + ConsoleOutput.print('Warning: incorrect number of .csv files detected. Only the first one will be used!') + + pdata, bins, t = compute_spectrogram(datas[0]) + del datas + + fig, ((ax1, ax3)) = plt.subplots( + 1, + 2, + gridspec_kw={ + 'width_ratios': [5, 3], + 'bottom': 0.080, + 'top': 0.840, + 'left': 0.050, + 'right': 0.985, + 'hspace': 0.166, + 'wspace': 0.138, + }, + ) + fig.set_size_inches(15, 7) + + title_line1 = 'STATIC FREQUENCY HELPER TOOL' + fig.text( + 0.060, 0.947, title_line1, ha='left', va='bottom', fontsize=20, color=KLIPPAIN_COLORS['purple'], weight='bold' + ) + try: + filename_parts = (lognames[0].split('/')[-1]).split('_') + dt = datetime.strptime(f'{filename_parts[1]} {filename_parts[2]}', '%Y%m%d %H%M%S') + title_line2 = dt.strftime('%x %X') + ' -- ' + filename_parts[3].upper().split('.')[0] + ' axis' + 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])) + title_line2 = lognames[0].split('/')[-1] + title_line3 = '' + title_line4 = '' + fig.text(0.060, 0.939, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.55, 0.985, title_line3, ha='left', va='top', fontsize=14, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.55, 0.950, title_line4, ha='left', va='top', fontsize=11, color=KLIPPAIN_COLORS['dark_purple']) + + plot_spectrogram(ax1, t, bins, pdata, max_freq) + plot_energy_accumulation(ax3, t, bins, pdata) + + ax_logo = fig.add_axes([0.001, 0.894, 0.105, 0.105], anchor='NW') + ax_logo.imshow(plt.imread(os.path.join(os.path.dirname(os.path.abspath(__file__)), 'klippain.png'))) + ax_logo.axis('off') + + if st_version != 'unknown': + fig.text(0.995, 0.980, st_version, ha='right', va='bottom', fontsize=8, color=KLIPPAIN_COLORS['purple']) + + return fig + + +def main(): + usage = '%prog [options] ' + opts = optparse.OptionParser(usage) + opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph') + opts.add_option('-f', '--freq', type='float', default=None, help='frequency maintained during the measurement') + opts.add_option('-d', '--duration', type='float', default=None, help='duration of the measurement') + opts.add_option('--max_freq', type='float', default=500.0, help='maximum frequency to graph') + opts.add_option('--accel_per_hz', type='float', default=None, help='accel_per_hz used during the measurement') + opts.add_option( + '-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory' + ) + options, args = opts.parse_args() + if len(args) < 1: + opts.error('Incorrect number of arguments') + if options.output is None: + opts.error('You must specify an output file.png to use the script (option -o)') + + fig = static_frequency_tool( + args, options.klipperdir, options.freq, options.duration, options.max_freq, options.accel_per_hz, 'unknown' + ) + fig.savefig(options.output, dpi=150) + + +if __name__ == '__main__': + main() diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py index 93d26eb..266a94f 100644 --- a/shaketune/shaketune.py +++ b/shaketune/shaketune.py @@ -12,7 +12,13 @@ from .measurement import ( create_vibrations_profile, excitate_axis_at_freq, ) -from .post_processing import AxesMapFinder, BeltsGraphCreator, ShaperGraphCreator, VibrationsGraphCreator +from .post_processing import ( + AxesMapFinder, + BeltsGraphCreator, + ShaperGraphCreator, + StaticGraphCreator, + VibrationsGraphCreator, +) from .shaketune_config import ShakeTuneConfig from .shaketune_process import ShakeTuneProcess @@ -103,7 +109,9 @@ class ShakeTune: def cmd_EXCITATE_AXIS_AT_FREQ(self, gcmd) -> None: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') - excitate_axis_at_freq(gcmd, self._pconfig) + static_freq_graph_creator = StaticGraphCreator(self._config) + st_process = ShakeTuneProcess(self._config, static_freq_graph_creator, self.timeout) + excitate_axis_at_freq(gcmd, self._pconfig, st_process) def cmd_AXES_MAP_CALIBRATION(self, gcmd) -> None: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') diff --git a/shaketune/shaketune_config.py b/shaketune/shaketune_config.py index 71a8930..7046d0f 100644 --- a/shaketune/shaketune_config.py +++ b/shaketune/shaketune_config.py @@ -7,7 +7,13 @@ from .helpers.console_output import ConsoleOutput 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': 'axesmap', 'belts': 'belts', 'shaper': 'inputshaper', 'vibrations': 'vibrations'} +RESULTS_SUBFOLDERS = { + 'axesmap': 'axes_map', + 'belts': 'belts', + 'shaper': 'input_shaper', + 'vibrations': 'vibrations', + 'staticfreq': 'static_freq', +} class ShakeTuneConfig: