diff --git a/K-ShakeTune/K-SnT_axes_map.cfg b/K-ShakeTune/K-SnT_axes_map.cfg deleted file mode 100644 index d175e41..0000000 --- a/K-ShakeTune/K-SnT_axes_map.cfg +++ /dev/null @@ -1,60 +0,0 @@ -############################################################ -###### AXE_MAP DETECTION AND ACCELEROMETER VALIDATION ###### -############################################################ -# Written by Frix_x#0161 # - -[gcode_macro AXES_MAP_CALIBRATION] -gcode: - {% set z_height = params.Z_HEIGHT|default(20)|int %} # z height to put the toolhead before starting the movements - {% set speed = params.SPEED|default(80)|float * 60 %} # feedrate for the movements - {% set accel = params.ACCEL|default(1500)|int %} # accel value used to move on the pattern - {% set feedrate_travel = params.TRAVEL_SPEED|default(120)|int * 60 %} # travel feedrate between moves - {% set accel_chip = params.ACCEL_CHIP|default("adxl345") %} # ADXL chip name in the config - - {% set mid_x = printer.toolhead.axis_maximum.x|float / 2 %} - {% set mid_y = printer.toolhead.axis_maximum.y|float / 2 %} - - {% set accel = [accel, printer.configfile.settings.printer.max_accel]|min %} - {% set old_accel = printer.toolhead.max_accel %} - {% set old_cruise_ratio = printer.toolhead.minimum_cruise_ratio %} - {% set old_sqv = printer.toolhead.square_corner_velocity %} - - - {% if not 'xyz' in printer.toolhead.homed_axes %} - { action_raise_error("Must Home printer first!") } - {% endif %} - - {action_respond_info("")} - {action_respond_info("Starting accelerometer axe_map calibration")} - {action_respond_info("This operation can not be interrupted by normal means. Hit the \"emergency stop\" button to stop it if needed")} - {action_respond_info("")} - - SAVE_GCODE_STATE NAME=STATE_AXESMAP_CALIBRATION - - G90 - - # Set the wanted acceleration values (not too high to avoid oscillation, not too low to be able to reach constant speed on each segments) - SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY={[(accel / 1000), 5.0]|max} - - # Going to the start position - G1 Z{z_height} F{feedrate_travel / 8} - G1 X{mid_x - 15} Y{mid_y - 15} F{feedrate_travel} - G4 P500 - - ACCELEROMETER_MEASURE CHIP={accel_chip} - G4 P1000 # This first waiting time is to record the background accelerometer noise before moving - G1 X{mid_x + 15} F{speed} - G4 P1000 - G1 Y{mid_y + 15} F{speed} - G4 P1000 - G1 Z{z_height + 15} F{speed} - G4 P1000 - ACCELEROMETER_MEASURE CHIP={accel_chip} NAME=axemap - - RESPOND MSG="Analysis of the movements..." - SHAKETUNE_POSTPROCESS PARAMS="--type axesmap --accel {accel|int} --chip_name {accel_chip}" - - # Restore the previous acceleration values - SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_cruise_ratio} SQUARE_CORNER_VELOCITY={old_sqv} - - RESTORE_GCODE_STATE NAME=STATE_AXESMAP_CALIBRATION diff --git a/K-ShakeTune/K-SnT_axis.cfg b/K-ShakeTune/K-SnT_axis.cfg deleted file mode 100644 index dadf02f..0000000 --- a/K-ShakeTune/K-SnT_axis.cfg +++ /dev/null @@ -1,54 +0,0 @@ -################################################ -###### STANDARD INPUT_SHAPER CALIBRATIONS ###### -################################################ -# Written by Frix_x#0161 # - -[gcode_macro AXES_SHAPER_CALIBRATION] -description: Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter -gcode: - {% set min_freq = params.FREQ_START|default(5)|float %} - {% set max_freq = params.FREQ_END|default(133.3)|float %} - {% set hz_per_sec = params.HZ_PER_SEC|default(1)|float %} - {% set axis = params.AXIS|default("all")|string|lower %} - {% set scv = params.SCV|default(None) %} - {% set max_sm = params.MAX_SMOOTHING|default(None) %} - {% set keep_results = params.KEEP_N_RESULTS|default(3)|int %} - {% set keep_csv = params.KEEP_CSV|default(0)|int %} - - {% set X, Y = False, False %} - - {% if axis == "all" %} - {% set X, Y = True, True %} - {% elif axis == "x" %} - {% set X = True %} - {% elif axis == "y" %} - {% set Y = True %} - {% else %} - { action_raise_error("AXIS selection invalid. Should be either all, x or y!") } - {% endif %} - - {% if scv is none or scv == "" %} - {% set scv = printer.toolhead.square_corner_velocity %} - {% endif %} - - {% if max_sm == "" %} - {% set max_sm = none %} - {% endif %} - - {% if X %} - TEST_RESONANCES AXIS=X OUTPUT=raw_data NAME=x FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec} - M400 - - RESPOND MSG="X axis frequency profile generation..." - RESPOND MSG="This may take some time (1-3min)" - SHAKETUNE_POSTPROCESS PARAMS="--type shaper --scv {scv} {% if max_sm is not none %}--max_smoothing {max_sm}{% endif %} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}" - {% endif %} - - {% if Y %} - TEST_RESONANCES AXIS=Y OUTPUT=raw_data NAME=y FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec} - M400 - - RESPOND MSG="Y axis frequency profile generation..." - RESPOND MSG="This may take some time (1-3min)" - SHAKETUNE_POSTPROCESS PARAMS="--type shaper --scv {scv} {% if max_sm is not none %}--max_smoothing {max_sm}{% endif %} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}" - {% endif %} diff --git a/K-ShakeTune/K-SnT_belts.cfg b/K-ShakeTune/K-SnT_belts.cfg deleted file mode 100644 index 0c5a86a..0000000 --- a/K-ShakeTune/K-SnT_belts.cfg +++ /dev/null @@ -1,23 +0,0 @@ -################################################ -###### STANDARD INPUT_SHAPER CALIBRATIONS ###### -################################################ -# Written by Frix_x#0161 # - -[gcode_macro COMPARE_BELTS_RESPONSES] -description: Perform a custom half-axis test to analyze and compare the frequency profiles of individual belts on CoreXY printers -gcode: - {% set min_freq = params.FREQ_START|default(5)|float %} - {% set max_freq = params.FREQ_END|default(133.33)|float %} - {% set hz_per_sec = params.HZ_PER_SEC|default(1)|float %} - {% set keep_results = params.KEEP_N_RESULTS|default(3)|int %} - {% set keep_csv = params.KEEP_CSV|default(0)|int %} - - TEST_RESONANCES AXIS=1,1 OUTPUT=raw_data NAME=b FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec} - M400 - - TEST_RESONANCES AXIS=1,-1 OUTPUT=raw_data NAME=a FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec} - M400 - - RESPOND MSG="Belts comparative frequency profile generation..." - RESPOND MSG="This may take some time (3-5min)" - SHAKETUNE_POSTPROCESS PARAMS="--type belts {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}" diff --git a/K-ShakeTune/K-SnT_static_freq.cfg b/K-ShakeTune/K-SnT_static_freq.cfg deleted file mode 100644 index fb410d8..0000000 --- a/K-ShakeTune/K-SnT_static_freq.cfg +++ /dev/null @@ -1,24 +0,0 @@ -################################################ -###### STANDARD INPUT_SHAPER CALIBRATIONS ###### -################################################ -# Written by Frix_x#0161 # - -[gcode_macro EXCITATE_AXIS_AT_FREQ] -description: Maintain a specified excitation frequency for a period of time to diagnose and locate a source of vibration -gcode: - {% set frequency = params.FREQUENCY|default(25)|int %} - {% set time = params.TIME|default(10)|int %} - {% set axis = params.AXIS|default("x")|string|lower %} - - {% if axis not in ["x", "y", "a", "b"] %} - { action_raise_error("AXIS selection invalid. Should be either x, y, a or b!") } - {% endif %} - - {% if axis == "a" %} - {% set axis = "1,-1" %} - {% elif axis == "b" %} - {% set axis = "1,1" %} - {% endif %} - - TEST_RESONANCES OUTPUT=raw_data AXIS={axis} FREQ_START={frequency-1} FREQ_END={frequency+1} HZ_PER_SEC={1/(time/3)} - M400 diff --git a/K-ShakeTune/shaketune.sh b/K-ShakeTune/shaketune.sh deleted file mode 100755 index 53af59f..0000000 --- a/K-ShakeTune/shaketune.sh +++ /dev/null @@ -1,10 +0,0 @@ -#!/usr/bin/env bash - -# This script is used to run the Shake&Tune Python scripts as a module -# from the project root directory using its virtual environment -# Usage: ./shaketune.sh - -source ~/klippain_shaketune-env/bin/activate -cd ~/klippain_shaketune -python -m src.is_workflow "$@" -deactivate diff --git a/README.md b/README.md index 16eeb20..1bb6546 100644 --- a/README.md +++ b/README.md @@ -29,7 +29,9 @@ Follow these steps to install the Shake&Tune module in your printer: 1. Then, append the following to your `printer.cfg` file and restart Klipper (if prefered, you can include only the needed macros: using `*.cfg` is a convenient way to include them all at once): ``` [shaketune] - [include K-ShakeTune/*.cfg] + # result_folder: ~/printer_data/config/K-ShakeTune_results + # number_of_results_to_keep: 3 + # keep_raw_csv: False ``` ## Usage diff --git a/shaketune/__init__.py b/shaketune/__init__.py index f167f4b..297f10a 100644 --- a/shaketune/__init__.py +++ b/shaketune/__init__.py @@ -5,470 +5,13 @@ ############################################ # Written by Frix_x#0161 # -# This script is designed to be run from inside Klipper Console -# Use the provided Shake&Tune macros instead! +# This module functions as a plugin within Klipper, aimed at enhancing printer diagnostics. It serves multiple purposes: +# 1. Diagnosing and pinpointing vibration sources in the printer. +# 2. Conducting standard axis input shaper tests on the XY axes to determine the optimal input shaper filter. +# 3. Executing a specialized half-axis test for CoreXY printers to analyze and compare the frequency profiles of individual belts. -import abc -import argparse -import os -import shutil -import tarfile -import threading -import traceback -from datetime import datetime -from pathlib import Path -from typing import Callable, List, Optional - -from matplotlib.figure import Figure - -from .graph_creators.analyze_axesmap import axesmap_calibration -from .graph_creators.graph_belts import belts_calibration -from .graph_creators.graph_shaper import shaper_calibration -from .graph_creators.graph_vibrations import vibrations_profile -from .helpers import filemanager as fm -from .helpers.motorlogparser import MotorLogParser -from .helpers.console_output import ConsoleOutput - - -class Config: - 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 = {'belts': 'belts', 'shaper': 'inputshaper', 'vibrations': 'vibrations'} - - @staticmethod - def get_results_folder(type: str) -> Path: - return Config.RESULTS_BASE_FOLDER / Config.RESULTS_SUBFOLDERS[type] - - @staticmethod - def get_git_version() -> str: - try: - from git import GitCommandError, Repo - - # Get the absolute path of the script, resolving any symlinks - # Then get 1 times to parent dir to be at the git root folder - script_path = Path(__file__).resolve() - repo_path = script_path.parents[1] - repo = Repo(repo_path) - try: - version = repo.git.describe('--tags') - except GitCommandError: - version = repo.head.commit.hexsha[:7] # If no tag is found, use the simplified commit SHA instead - return version - except Exception as e: - ConsoleOutput.print(f'Warning: unable to retrieve Shake&Tune version number: {e}') - return 'unknown' - - @staticmethod - def parse_arguments(params: Optional[List] = None) -> argparse.Namespace: - parser = argparse.ArgumentParser(description='Shake&Tune graphs generation script') - parser.add_argument( - '-t', - '--type', - dest='type', - choices=['belts', 'shaper', 'vibrations', 'axesmap'], - required=True, - help='Type of output graph to produce', - ) - parser.add_argument( - '--accel', - type=int, - default=None, - dest='accel_used', - help='Accelerometion used for vibrations profile creation or axes map calibration', - ) - parser.add_argument( - '--chip_name', - type=str, - default='adxl345', - dest='chip_name', - help='Accelerometer chip name used for vibrations profile creation or axes map calibration', - ) - parser.add_argument( - '--max_smoothing', - type=float, - default=None, - dest='max_smoothing', - help='Maximum smoothing to allow for input shaper filter recommendations', - ) - parser.add_argument( - '--scv', - '--square_corner_velocity', - type=float, - default=5.0, - dest='scv', - help='Square corner velocity used to compute max accel for input shapers filter recommendations', - ) - parser.add_argument( - '-m', - '--kinematics', - dest='kinematics', - default='cartesian', - choices=['cartesian', 'corexy'], - help='Machine kinematics configuration used for the vibrations profile creation', - ) - parser.add_argument( - '--metadata', - type=str, - default=None, - dest='metadata', - help='Motor configuration metadata printed on the vibrations profiles', - ) - parser.add_argument( - '-c', - '--keep_csv', - action='store_true', - default=False, - dest='keep_csv', - help='Whether to keep the raw CSV files after processing in addition to the PNG graphs', - ) - parser.add_argument( - '-n', - '--keep_results', - type=int, - default=3, - dest='keep_results', - help='Number of results to keep in the result folder after each run of the script', - ) - parser.add_argument('--dpi', type=int, default=150, dest='dpi', help='DPI of the output PNG files') - parser.add_argument('-v', '--version', action='version', version=f'Shake&Tune {Config.get_git_version()}') - - return parser.parse_args(params) - - -class GraphCreator(abc.ABC): - def __init__(self, keep_csv: bool, dpi: int): - self._keep_csv = keep_csv - self._dpi = dpi - - self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S') - self._version = Config.get_git_version() - - self._type = None - self._folder = None - - def _setup_folder(self, graph_type: str) -> None: - self._type = graph_type - self._folder = 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]: - fm.wait_file_ready(filename) - 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) - fm.wait_file_ready(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._dpi) - - if self._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) - - @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, keep_csv: bool = False, dpi: int = 150): - super().__init__(keep_csv, dpi) - - self._setup_folder('belts') - - def create_graph(self) -> None: - lognames = self._move_and_prepare_files( - glob_pattern='raw_data_axis*.csv', - min_files_required=2, - custom_name_func=lambda f: f.stem.split('_')[3].upper(), - ) - fig = belts_calibration( - lognames=[str(path) for path in lognames], - klipperdir=str(Config.KLIPPER_FOLDER), - 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, keep_csv: bool = False, dpi: int = 150): - super().__init__(keep_csv, dpi) - - self._max_smoothing = None - self._scv = None - - self._setup_folder('shaper') - - def configure(self, scv: float, max_smoothing: float = None) -> None: - self._scv = scv - self._max_smoothing = max_smoothing - - 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='raw_data*.csv', - min_files_required=1, - custom_name_func=lambda f: f.stem.split('_')[3].upper(), - ) - fig = shaper_calibration( - lognames=[str(path) for path in lognames], - klipperdir=str(Config.KLIPPER_FOLDER), - max_smoothing=self._max_smoothing, - scv=self._scv, - 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, keep_csv: bool = False, dpi: int = 150): - super().__init__(keep_csv, dpi) - - self._kinematics = None - self._accel = None - self._chip_name = None - self._motors = None - - self._setup_folder('vibrations') - - def configure(self, kinematics: str, accel: float, chip_name: str, metadata: str) -> None: - self._kinematics = kinematics - self._accel = accel - self._chip_name = chip_name - - parser = MotorLogParser(Config.KLIPPER_LOG_FOLDER / 'klippy.log', metadata) - self._motors = 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) - - def create_graph(self) -> None: - if not self._accel or not self._chip_name 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=f'{self._chip_name}-*.csv', - min_files_required=None, - custom_name_func=lambda f: f.name.replace(self._chip_name, self._type), - ) - fig = vibrations_profile( - lognames=[str(path) for path in lognames], - klipperdir=str(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, keep_csv: bool = False, dpi: int = 150): - super().__init__(keep_csv, dpi) - - self._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S') - self._type = 'axesmap' - self._folder = Config.RESULTS_BASE_FOLDER - - self._accel = None - self._chip_name = None - - def configure(self, accel: int, chip_name: str) -> None: - self._accel = accel - self._chip_name = chip_name - - def find_axesmap(self) -> None: - tmp_folder = Path('/tmp') - globbed_files = list(tmp_folder.glob(f'{self._chip_name}-*.csv')) - - if not globbed_files: - raise FileNotFoundError('no CSV files found in the /tmp folder to find the axes map!') - - # Find the CSV files with the latest timestamp and wait for it to be released by Klipper - logname = sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[0] - fm.wait_file_ready(logname) - - results = axesmap_calibration( - lognames=[str(logname)], - accel=self._accel, - ) - - result_filename = self._folder / f'{self._type}_{self._graph_date}.txt' - with result_filename.open('w') as f: - f.write(results) - - ConsoleOutput.print(f'Detected axes_map: {results}') - - def create_graph(self) -> None: - self.find_axesmap() - - def clean_old_files(self, keep_results: int) -> None: - pass - - -def create_graph(options: argparse.Namespace) -> None: - fm.ensure_folders_exist( - folders=[Config.RESULTS_BASE_FOLDER / subfolder for subfolder in Config.RESULTS_SUBFOLDERS.values()] - ) - - ConsoleOutput.print(f'Shake&Tune version: {Config.get_git_version()}') - - graph_creators = { - 'belts': (BeltsGraphCreator, None), - 'shaper': (ShaperGraphCreator, lambda gc: gc.configure(options.scv, options.max_smoothing)), - 'vibrations': ( - VibrationsGraphCreator, - lambda gc: gc.configure(options.kinematics, options.accel_used, options.chip_name, options.metadata), - ), - 'axesmap': (AxesMapFinder, lambda gc: gc.configure(options.accel_used, options.chip_name)), - } - - creator_info = graph_creators.get(options.type) - if not creator_info: - ConsoleOutput.print('Error: invalid graph type specified!') - return - - # Instantiate the graph creator - graph_creator_class, configure_func = creator_info - graph_creator = graph_creator_class(options.keep_csv, options.dpi) - - # Configure it if needed - if configure_func: - configure_func(graph_creator) - - # And then run it - try: - graph_creator.create_graph() - except FileNotFoundError as e: - ConsoleOutput.print(f'FileNotFound error: {e}') - return - except TimeoutError as e: - ConsoleOutput.print(f'Timeout error: {e}') - return - except Exception as e: - ConsoleOutput.print(f'Error while generating the graphs: {e}\n{traceback.print_exc()}') - return - - ConsoleOutput.print(f'{options.type} graphs created successfully!') - graph_creator.clean_old_files(options.keep_results) - ConsoleOutput.print(f'Cleaned output folder to keep only the last {options.keep_results} results!') - - -class ShakeTune: - def __init__(self, config) -> None: - self._printer = config.get_printer() - self._gcode = self._printer.lookup_object('gcode') - self.timeout = config.getfloat('timeout', 2.0, above=0.0) - - ConsoleOutput.register_output_callback(self._gcode.respond_info) - - self._gcode.register_command( - 'SHAKETUNE_POSTPROCESS', - self.cmd_SHAKETUNE_POSTPROCESS, - desc='Post process data for ShakeTune graph creation', - ) - - def shaketune_thread(self, options): - try: - os.nice(20) - except Exception: - ConsoleOutput.print('Failed reducing ShakeTune thread priority, continuing.') - create_graph(options) - - def cmd_SHAKETUNE_POSTPROCESS(self, gcmd) -> None: - options = Config.parse_arguments(gcmd.get('PARAMS').split()) - t = threading.Thread(target=self.shaketune_thread, args=(options,)) - t.start() - - reactor = self._printer.get_reactor() - event_time = reactor.monotonic() - end_time = event_time + self.timeout - while event_time < end_time: - event_time = reactor.pause(event_time + 0.05) - if not t.is_alive(): - break +from .shaketune import ShakeTune as ShakeTune def load_config(config) -> ShakeTune: diff --git a/shaketune/__main__.py b/shaketune/__main__.py deleted file mode 100644 index 6fa9e52..0000000 --- a/shaketune/__main__.py +++ /dev/null @@ -1,10 +0,0 @@ -from . import Config, create_graph - - -def main() -> None: - options = Config.parse_arguments() - create_graph(options) - - -if __name__ == '__main__': - main() diff --git a/shaketune/graph_creators/__init__.py b/shaketune/graph_creators/__init__.py index e69de29..5ec0700 100644 --- a/shaketune/graph_creators/__init__.py +++ b/shaketune/graph_creators/__init__.py @@ -0,0 +1,7 @@ +#!/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 VibrationsGraphCreator as VibrationsGraphCreator diff --git a/shaketune/graph_creators/graph_creator.py b/shaketune/graph_creators/graph_creator.py new file mode 100644 index 0000000..75fc3da --- /dev/null +++ b/shaketune/graph_creators/graph_creator.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 + +import abc +import shutil +import tarfile +from datetime import datetime +from pathlib import Path +from typing import Callable, Optional + +from matplotlib.figure import Figure + +from ..helpers import filemanager as fm +from ..helpers.console_output import ConsoleOutput +from ..helpers.motorlogparser import MotorLogParser +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_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]: + fm.wait_file_ready(filename) + 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) + fm.wait_file_ready(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._setup_folder('belts') + + def create_graph(self) -> None: + lognames = self._move_and_prepare_files( + glob_pattern='raw_data_axis*.csv', + min_files_required=2, + custom_name_func=lambda f: f.stem.split('_')[3].upper(), + ) + fig = belts_calibration( + lognames=[str(path) for path in lognames], + klipperdir=str(self._config.klipper_folder), + 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) -> None: + self._scv = scv + self._max_smoothing = max_smoothing + + 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='raw_data*.csv', + min_files_required=1, + custom_name_func=lambda f: f.stem.split('_')[3].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, + 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._chip_name = None + self._motors = None + + self._setup_folder('vibrations') + + def configure(self, kinematics: str, accel: float, chip_name: str, metadata: str) -> None: + self._kinematics = kinematics + self._accel = accel + self._chip_name = chip_name + + parser = MotorLogParser(self._config.klipper_log_folder / 'klippy.log', metadata) + self._motors = 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) + + def create_graph(self) -> None: + if not self._accel or not self._chip_name 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=f'{self._chip_name}-*.csv', + min_files_required=None, + custom_name_func=lambda f: f.name.replace(self._chip_name, self._type), + ) + 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._graph_date = datetime.now().strftime('%Y%m%d_%H%M%S') + self._type = 'axesmap' + self._folder = config.get_results_folder() + + self._accel = None + self._chip_name = None + + def configure(self, accel: int, chip_name: str) -> None: + self._accel = accel + self._chip_name = chip_name + + def find_axesmap(self) -> None: + tmp_folder = Path('/tmp') + globbed_files = list(tmp_folder.glob(f'{self._chip_name}-*.csv')) + + if not globbed_files: + raise FileNotFoundError('no CSV files found in the /tmp folder to find the axes map!') + + # Find the CSV files with the latest timestamp and wait for it to be released by Klipper + logname = sorted(globbed_files, key=lambda f: f.stat().st_mtime, reverse=True)[0] + fm.wait_file_ready(logname) + + results = axesmap_calibration( + lognames=[str(logname)], + accel=self._accel, + ) + ConsoleOutput.print(results) + + result_filename = self._folder / f'{self._type}_{self._graph_date}.txt' + with result_filename.open('w') as f: + f.write(results) + + # While the AxesMapFinder doesn't directly create a graph, we need to implement this + # method to allow using it seemlessly like all the other GraphCreator objects + def create_graph(self) -> None: + self.find_axesmap() + + def clean_old_files(self, keep_results: int) -> None: + tmp_folder = Path('/tmp') + globbed_files = list(tmp_folder.glob(f'{self._chip_name}-*.csv')) + for csv_file in globbed_files: + csv_file.unlink() diff --git a/shaketune/helpers/common_func.py b/shaketune/helpers/common_func.py index 56edff5..6546347 100644 --- a/shaketune/helpers/common_func.py +++ b/shaketune/helpers/common_func.py @@ -11,6 +11,7 @@ from pathlib import Path import numpy as np from scipy.signal import spectrogram + from .console_output import ConsoleOutput @@ -70,6 +71,7 @@ def get_git_version(): # Get the absolute path of the script, resolving any symlinks # Then get 2 times to parent dir to be at the git root folder from git import GitCommandError, Repo + script_path = Path(__file__).resolve() repo_path = script_path.parents[1] repo = Repo(repo_path) diff --git a/K-ShakeTune/K-SnT_vibrations.cfg b/shaketune/macros/K-SnT_vibrations.cfg similarity index 100% rename from K-ShakeTune/K-SnT_vibrations.cfg rename to shaketune/macros/K-SnT_vibrations.cfg diff --git a/shaketune/macros/__init__.py b/shaketune/macros/__init__.py new file mode 100644 index 0000000..5486211 --- /dev/null +++ b/shaketune/macros/__init__.py @@ -0,0 +1,16 @@ +#!/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 + +# graph_creators = { +# 'axesmap': (AxesMapFinder, lambda gc: gc.configure(options.accel_used, options.chip_name)), +# 'belts': (BeltsGraphCreator, None), +# 'shaper': (ShaperGraphCreator, lambda gc: gc.configure(options.scv, options.max_smoothing)), +# 'vibrations': ( +# VibrationsGraphCreator, +# lambda gc: gc.configure(options.kinematics, options.accel_used, options.chip_name, options.metadata), +# ), +# } diff --git a/shaketune/macros/accelerometer.py b/shaketune/macros/accelerometer.py new file mode 100644 index 0000000..63f77b6 --- /dev/null +++ b/shaketune/macros/accelerometer.py @@ -0,0 +1,2 @@ +#!/usr/bin/env python3 + diff --git a/shaketune/macros/axes_input_shaper.py b/shaketune/macros/axes_input_shaper.py new file mode 100644 index 0000000..47b72d7 --- /dev/null +++ b/shaketune/macros/axes_input_shaper.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 + + +from ..helpers.console_output import ConsoleOutput +from ..shaketune_thread import ShakeTuneThread + + +def axes_shaper_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: + min_freq = gcmd.get_float('FREQ_START', default=5, minval=1) + max_freq = gcmd.get_float('FREQ_END', default=133.33, minval=1) + hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1, minval=1) + axis = gcmd.get('AXIS', default='all') + if axis not in ['x', 'y', 'all']: + gcmd.error('AXIS selection invalid. Should be either x, y, or all!') + scv = gcmd.get_float('SCV', default=None, minval=0) + max_sm = gcmd.get_float('MAX_SMOOTHING', default=None, minval=0) + + if scv is None: + systime = printer.get_reactor().monotonic() + toolhead = printer.lookup_object('toolhead') + toolhead_info = toolhead.get_status(systime) + scv = toolhead_info['square_corner_velocity'] + + creator = st_thread.get_graph_creator() + creator.configure(scv, max_sm) + + axis_flags = {'x': axis in ('x', 'all'), 'y': axis in ('y', 'all')} + for axis in ['x', 'y']: + if axis_flags[axis]: + gcode.run_script_from_command( + f'TEST_RESONANCES AXIS={axis.upper()} OUTPUT=raw_data NAME={axis} FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}' + ) + ConsoleOutput.print(f'{axis.upper()} axis frequency profile generation...') + ConsoleOutput.print('This may take some time (1-3min)') + st_thread.run() diff --git a/shaketune/macros/axes_map.py b/shaketune/macros/axes_map.py new file mode 100644 index 0000000..279f95d --- /dev/null +++ b/shaketune/macros/axes_map.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 + + +from ..helpers.console_output import ConsoleOutput +from ..shaketune_thread import ShakeTuneThread + + +def find_axis_accelerometer(printer, axis: str = 'xy'): + accel_chip_names = printer.lookup_object('resonance_tester').accel_chip_names + for chip_axis, chip_name in accel_chip_names: + if axis in ['x', 'y'] and chip_axis == 'xy': + return chip_name + elif chip_axis == axis: + return chip_name + return None + + +def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: + z_height = gcmd.get_float('Z_HEIGHT', default=20.0) + speed = gcmd.get_float('SPEED', default=80.0, minval=20.0) + accel = gcmd.get_int('ACCEL', default=1500, minval=100) + feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) + accel_chip = gcmd.get('ACCEL_CHIP', default=None) + + if accel_chip is None: + accel_chip = find_axis_accelerometer(printer, 'xy') + if accel_chip is None: + gcmd.error( + 'No accelerometer specified for measurement! Multi-accelerometer configurations are not supported for this macro.' + ) + + systime = printer.get_reactor().monotonic() + toolhead = printer.lookup_object('toolhead') + toolhead_info = toolhead.get_status(systime) + old_accel = toolhead_info['max_accel'] + old_mcr = toolhead_info['minimum_cruise_ratio'] + old_sqv = toolhead_info['square_corner_velocity'] + + # set the wanted acceleration values + gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={accel} MINIMUM_CRUISE_RATIO=0 SQUARE_CORNER_VELOCITY=5.0') + + # 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 + + kin_info = toolhead.kin.get_status(systime) + mid_x = (kin_info['axis_minimum'].x + kin_info['axis_maximum'].x) / 2 + mid_y = (kin_info['axis_minimum'].y + kin_info['axis_maximum'].y) / 2 + _, _, _, E = toolhead.get_position() + + # Going to the start position + toolhead.move([mid_x - 15, mid_y - 15, z_height, E], feedrate_travel) + toolhead.dwell(0.5) + + # Start the measurements and do the movements (+X, +Y and then +Z) + gcode.run_script_from_command(f'ACCELEROMETER_MEASURE CHIP={accel_chip}') + toolhead.dwell(1) + toolhead.move([mid_x + 15, mid_y - 15, z_height, E], speed) + toolhead.dwell(1) + toolhead.move([mid_x + 15, mid_y + 15, z_height, E], speed) + toolhead.dwell(1) + toolhead.move([mid_x + 15, mid_y + 15, z_height + 15, E], speed) + toolhead.dwell(1) + gcode.run_script_from_command(f'ACCELEROMETER_MEASURE CHIP={accel_chip} NAME=axemap') + + # Re-enable the input shaper if it was active + if input_shaper is not None: + input_shaper.enable_shaping() + + # Restore the previous acceleration values + gcode.run_script_from_command( + f'SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_mcr} SQUARE_CORNER_VELOCITY={old_sqv}' + ) + toolhead.wait_moves() + + # Run post-processing + ConsoleOutput.print('Analysis of the movements...') + creator = st_thread.get_graph_creator() + creator.configure(accel, accel_chip) + st_thread.run() diff --git a/shaketune/macros/belts_comparison.py b/shaketune/macros/belts_comparison.py new file mode 100644 index 0000000..7a4abf6 --- /dev/null +++ b/shaketune/macros/belts_comparison.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 + + +from ..helpers.console_output import ConsoleOutput +from ..shaketune_thread import ShakeTuneThread + + +def compare_belts_responses(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: + min_freq = gcmd.get_float('FREQ_START', default=5, minval=1) + max_freq = gcmd.get_float('FREQ_END', default=133.33, minval=1) + hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1, minval=1) + + toolhead = printer.lookup_object('toolhead') + + gcode.run_script_from_command( + f'TEST_RESONANCES AXIS=1,1 OUTPUT=raw_data NAME=b FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}' + ) + toolhead.wait_moves() + + gcode.run_script_from_command( + f'TEST_RESONANCES AXIS=1,-1 OUTPUT=raw_data NAME=a FREQ_START={min_freq} FREQ_END={max_freq} HZ_PER_SEC={hz_per_sec}' + ) + toolhead.wait_moves() + + # Run post-processing + ConsoleOutput.print('Belts comparative frequency profile generation...') + ConsoleOutput.print('This may take some time (3-5min)') + st_thread.run() diff --git a/shaketune/macros/static_freq.py b/shaketune/macros/static_freq.py new file mode 100644 index 0000000..596a32c --- /dev/null +++ b/shaketune/macros/static_freq.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 + +from ..helpers.console_output import ConsoleOutput + + +def excitate_axis_at_freq(gcmd, gcode) -> None: + freq = gcmd.get_int('FREQUENCY', default=25, minval=1) + duration = gcmd.get_int('DURATION', default=10, minval=1) + axis = gcmd.get('AXIS', default='x') + if axis not in ['x', 'y', 'a', 'b']: + gcmd.error('AXIS selection invalid. Should be either x, y, a or b!') + + ConsoleOutput.print(f'Excitating {axis.upper()} axis at {freq}Hz for {duration} seconds') + + if axis == 'a': + axis = '1,-1' + elif axis == 'b': + axis = '1,1' + + gcode.run_script_from_command( + f'TEST_RESONANCES OUTPUT=raw_data AXIS={axis} FREQ_START={freq-1} FREQ_END={freq+1} HZ_PER_SEC={1/(duration/3)}' + ) diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py new file mode 100644 index 0000000..17ff7bf --- /dev/null +++ b/shaketune/shaketune.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 + + +from pathlib import Path + +from .graph_creators import AxesMapFinder, BeltsGraphCreator, ShaperGraphCreator +from .helpers.console_output import ConsoleOutput +from .macros import axes_map_calibration, axes_shaper_calibration, compare_belts_responses, excitate_axis_at_freq +from .shaketune_config import ShakeTuneConfig +from .shaketune_thread import ShakeTuneThread + + +class ShakeTune: + def __init__(self, config) -> None: + self._printer = config.get_printer() + self._gcode = self._printer.lookup_object('gcode') + + res_tester = self._printer.lookup_object('resonance_tester') + if res_tester is None: + config.error('No [resonance_tester] config section found in printer.cfg! Please add one to use Shake&Tune') + + self.timeout = config.getfloat('timeout', 2.0, above=0.0) + + result_folder = config.get('result_folder', default='~/printer_data/config/K-ShakeTune_results') + result_folder_path = Path(result_folder).expanduser() if result_folder else None + keep_n_results = config.getint('number_of_results_to_keep', default=3, minval=0) + keep_csv = config.getboolean('keep_raw_csv', default=False) + dpi = config.getint('dpi', default=150, minval=100, maxval=500) + + self._config = ShakeTuneConfig(result_folder_path, keep_n_results, keep_csv, dpi) + ConsoleOutput.register_output_callback(self._gcode.respond_info) + + self._gcode.register_command( + 'EXCITATE_AXIS_AT_FREQ', + self.cmd_EXCITATE_AXIS_AT_FREQ, + desc=self.cmd_EXCITATE_AXIS_AT_FREQ_help, + ) + self._gcode.register_command( + 'COMPARE_BELTS_RESPONSES', + self.cmd_COMPARE_BELTS_RESPONSES, + desc=self.cmd_COMPARE_BELTS_RESPONSES_help, + ) + self._gcode.register_command( + 'AXES_SHAPER_CALIBRATION', + self.cmd_AXES_SHAPER_CALIBRATION, + desc=self.cmd_AXES_SHAPER_CALIBRATION_help, + ) + self._gcode.register_command( + 'AXES_MAP_CALIBRATION', + self.cmd_AXES_MAP_CALIBRATION, + desc=self.cmd_AXES_MAP_CALIBRATION_help, + ) + + cmd_EXCITATE_AXIS_AT_FREQ_help = ( + 'Maintain a specified excitation frequency for a period of time to diagnose and locate a source of vibration' + ) + + 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._gcode) + + cmd_COMPARE_BELTS_RESPONSES_help = 'Perform a custom half-axis test to analyze and compare the frequency profiles of individual belts on CoreXY printers' + + def cmd_COMPARE_BELTS_RESPONSES(self, gcmd) -> None: + ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') + belt_graph_creator = BeltsGraphCreator(self._config) + st_thread = ShakeTuneThread(self._config, belt_graph_creator, self._printer.get_reactor(), self.timeout) + compare_belts_responses(gcmd, self._gcode, self._printer, st_thread) + + cmd_AXES_SHAPER_CALIBRATION_help = ( + 'Perform standard axis input shaper tests on one or both XY axes to select the best input shaper filter' + ) + + def cmd_AXES_SHAPER_CALIBRATION(self, gcmd) -> None: + ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') + shaper_graph_creator = ShaperGraphCreator(self._config) + st_thread = ShakeTuneThread(self._config, shaper_graph_creator, self._printer.get_reactor(), self.timeout) + axes_shaper_calibration(gcmd, self._gcode, self._printer, st_thread) + + cmd_AXES_MAP_CALIBRATION_help = 'Perform a set of movements to measure the orientation of the accelerometer and help you set the best axes_map configuration for your printer' + + 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_thread = ShakeTuneThread(self._config, axes_map_finder, self._printer.get_reactor(), self.timeout) + axes_map_calibration(gcmd, self._gcode, self._printer, st_thread) diff --git a/shaketune/shaketune_config.py b/shaketune/shaketune_config.py new file mode 100644 index 0000000..bf0e96d --- /dev/null +++ b/shaketune/shaketune_config.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 + +from pathlib import Path + +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 = {'belts': 'belts', 'shaper': 'inputshaper', 'vibrations': 'vibrations'} + + +class ShakeTuneConfig: + def __init__( + self, result_folder: Path = RESULTS_BASE_FOLDER, keep_n_results: int = 3, keep_csv: bool = False, dpi: int = 150 + ) -> None: + self._result_folder = result_folder + + self.keep_n_results = keep_n_results + self.keep_csv = keep_csv + self.dpi = dpi + + self.klipper_folder = KLIPPER_FOLDER + self.klipper_log_folder = KLIPPER_LOG_FOLDER + + def get_results_folder(self, type: str = None) -> Path: + if type is None: + return self._result_folder + else: + return self._result_folder / RESULTS_SUBFOLDERS[type] + + def get_results_subfolders(self) -> Path: + subfolders = [self._result_folder / subfolder for subfolder in RESULTS_SUBFOLDERS.values()] + return subfolders + + @staticmethod + def get_git_version() -> str: + try: + from git import GitCommandError, Repo + + # Get the absolute path of the script, resolving any symlinks + # Then get 1 times to parent dir to be at the git root folder + script_path = Path(__file__).resolve() + repo_path = script_path.parents[1] + repo = Repo(repo_path) + try: + version = repo.git.describe('--tags') + except GitCommandError: + version = repo.head.commit.hexsha[:7] # If no tag is found, use the simplified commit SHA instead + return version + except Exception as e: + ConsoleOutput.print(f'Warning: unable to retrieve Shake&Tune version number: {e}') + return 'unknown' + + # @staticmethod + # def parse_arguments(params: Optional[List] = None) -> argparse.Namespace: + # parser = argparse.ArgumentParser(description='Shake&Tune graphs generation script') + # parser.add_argument( + # '-t', + # '--type', + # dest='type', + # choices=['belts', 'shaper', 'vibrations', 'axesmap'], + # required=True, + # help='Type of output graph to produce', + # ) + # parser.add_argument( + # '--accel', + # type=int, + # default=None, + # dest='accel_used', + # help='Accelerometion used for vibrations profile creation or axes map calibration', + # ) + # parser.add_argument( + # '--chip_name', + # type=str, + # default='adxl345', + # dest='chip_name', + # help='Accelerometer chip name used for vibrations profile creation or axes map calibration', + # ) + # parser.add_argument( + # '--max_smoothing', + # type=float, + # default=None, + # dest='max_smoothing', + # help='Maximum smoothing to allow for input shaper filter recommendations', + # ) + # parser.add_argument( + # '--scv', + # '--square_corner_velocity', + # type=float, + # default=5.0, + # dest='scv', + # help='Square corner velocity used to compute max accel for input shapers filter recommendations', + # ) + # parser.add_argument( + # '-m', + # '--kinematics', + # dest='kinematics', + # default='cartesian', + # choices=['cartesian', 'corexy'], + # help='Machine kinematics configuration used for the vibrations profile creation', + # ) + # parser.add_argument( + # '--metadata', + # type=str, + # default=None, + # dest='metadata', + # help='Motor configuration metadata printed on the vibrations profiles', + # ) + # parser.add_argument( + # '-c', + # '--keep_csv', + # action='store_true', + # default=False, + # dest='keep_csv', + # help='Whether to keep the raw CSV files after processing in addition to the PNG graphs', + # ) + # parser.add_argument( + # '-n', + # '--keep_results', + # type=int, + # default=3, + # dest='keep_results', + # help='Number of results to keep in the result folder after each run of the script', + # ) + # parser.add_argument('--dpi', type=int, default=150, dest='dpi', help='DPI of the output PNG files') + # parser.add_argument( + # '-v', '--version', action='version', version=f'Shake&Tune {ShakeTuneConfig.get_git_version()}' + # ) + + # return parser.parse_args(params) diff --git a/shaketune/shaketune_thread.py b/shaketune/shaketune_thread.py new file mode 100644 index 0000000..4cd0bd1 --- /dev/null +++ b/shaketune/shaketune_thread.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 + + +import os +import threading +import traceback + +from .helpers import filemanager as fm +from .helpers.console_output import ConsoleOutput +from .shaketune_config import ShakeTuneConfig + + +class ShakeTuneThread(threading.Thread): + def __init__(self, config: ShakeTuneConfig, graph_creator, reactor, timeout: float): + super(ShakeTuneThread, self).__init__() + self._config = config + self.graph_creator = graph_creator + self._reactor = reactor + self._timeout = timeout + + def get_graph_creator(self): + return self.graph_creator + + def run(self) -> None: + # Start the target function in a new thread + internal_thread = threading.Thread(target=self._shaketune_thread, args=(self.graph_creator,)) + internal_thread.start() + + # Monitor the thread execution and stop it if it takes too long + event_time = self._reactor.monotonic() + end_time = event_time + self._timeout + while event_time < end_time: + event_time = self._reactor.pause(event_time + 0.05) + if not internal_thread.is_alive(): + break + + # This function run in its own thread is used to do the CSV analysis and create the graphs + def _shaketune_thread(self, graph_creator) -> None: + # Trying to reduce the Shake&Tune prost-processing thread priority to avoid slowing down the main Klipper process + # as this could lead to random "Timer" errors when already running CANbus, etc... + try: + os.nice(20) + except Exception: + ConsoleOutput.print('Warning: failed reducing Shake&Tune thread priority, continuing...') + + fm.ensure_folders_exist(self._config.get_results_subfolders()) + + try: + graph_creator.create_graph() + except FileNotFoundError as e: + ConsoleOutput.print(f'FileNotFound error: {e}') + return + except TimeoutError as e: + ConsoleOutput.print(f'Timeout error: {e}') + return + except Exception as e: + ConsoleOutput.print(f'Error while generating the graphs: {e}\n{traceback.print_exc()}') + return + + graph_creator.clean_old_files(self._config.keep_n_results) + + if graph_creator.get_type() != 'axesmap': + ConsoleOutput.print(f'{graph_creator.get_type()} graphs created successfully!') + ConsoleOutput.print( + f'Cleaned up the output folder (only the last {self._config.keep_n_results} results were kept)!' + )