From 30a19105130c8c1f87cd0b580a5594e445af2d33 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Thu, 9 May 2024 16:08:47 +0200 Subject: [PATCH 1/6] Klipper plugin refactoring with embedded macros --- K-ShakeTune/K-SnT_axes_map.cfg | 60 --- K-ShakeTune/K-SnT_axis.cfg | 54 -- K-ShakeTune/K-SnT_belts.cfg | 23 - K-ShakeTune/K-SnT_static_freq.cfg | 24 - K-ShakeTune/shaketune.sh | 10 - README.md | 4 +- shaketune/__init__.py | 467 +----------------- shaketune/__main__.py | 10 - shaketune/graph_creators/__init__.py | 7 + shaketune/graph_creators/graph_creator.py | 276 +++++++++++ shaketune/helpers/common_func.py | 2 + .../macros}/K-SnT_vibrations.cfg | 0 shaketune/macros/__init__.py | 16 + shaketune/macros/accelerometer.py | 2 + shaketune/macros/axes_input_shaper.py | 35 ++ shaketune/macros/axes_map.py | 83 ++++ shaketune/macros/belts_comparison.py | 28 ++ shaketune/macros/static_freq.py | 22 + shaketune/shaketune.py | 86 ++++ shaketune/shaketune_config.py | 131 +++++ shaketune/shaketune_thread.py | 66 +++ 21 files changed, 762 insertions(+), 644 deletions(-) delete mode 100644 K-ShakeTune/K-SnT_axes_map.cfg delete mode 100644 K-ShakeTune/K-SnT_axis.cfg delete mode 100644 K-ShakeTune/K-SnT_belts.cfg delete mode 100644 K-ShakeTune/K-SnT_static_freq.cfg delete mode 100755 K-ShakeTune/shaketune.sh delete mode 100644 shaketune/__main__.py create mode 100644 shaketune/graph_creators/graph_creator.py rename {K-ShakeTune => shaketune/macros}/K-SnT_vibrations.cfg (100%) create mode 100644 shaketune/macros/__init__.py create mode 100644 shaketune/macros/accelerometer.py create mode 100644 shaketune/macros/axes_input_shaper.py create mode 100644 shaketune/macros/axes_map.py create mode 100644 shaketune/macros/belts_comparison.py create mode 100644 shaketune/macros/static_freq.py create mode 100644 shaketune/shaketune.py create mode 100644 shaketune/shaketune_config.py create mode 100644 shaketune/shaketune_thread.py 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)!' + ) From 187ba13c98a199681935fa78070792360cdceeba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sun, 12 May 2024 18:50:31 +0200 Subject: [PATCH 2/6] added my own accelerometer interface --- shaketune/macros/accelerometer.py | 38 +++++++++++++++++++++++++++++++ shaketune/macros/axes_map.py | 10 ++++++-- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/shaketune/macros/accelerometer.py b/shaketune/macros/accelerometer.py index 63f77b6..6b3b182 100644 --- a/shaketune/macros/accelerometer.py +++ b/shaketune/macros/accelerometer.py @@ -1,2 +1,40 @@ #!/usr/bin/env python3 +import time + +from ..helpers.console_output import ConsoleOutput + + +class Accelerometer: + def __init__(self, klipper_accelerometer): + self._k_accelerometer = klipper_accelerometer + + def start_measurement(self): + if self._k_accelerometer.bg_client is None: + self._k_accelerometer.bg_client = self._k_accelerometer.chip.start_internal_client() + ConsoleOutput.print('accelerometer measurements started') + else: + raise ValueError('measurements already started!') + + def stop_measurement(self, name: str = None): + if self._k_accelerometer.bg_client is not None: + name = name or time.strftime('%Y%m%d_%H%M%S') + if not name.replace('-', '').replace('_', '').isalnum(): + raise ValueError('invalid file name!') + + bg_client = self._k_accelerometer.bg_client + self._k_accelerometer.bg_client = None + bg_client.finish_measurements() + + filename = f'/tmp/shaketune-{name}.csv' + self._write_to_file(bg_client, filename) + ConsoleOutput.print(f'Measurements stopped. Data written to {filename}') + else: + raise ValueError('measurements need to be started first!') + + def _write_to_file(self, bg_client, filename): + with open(filename, 'w') as f: + f.write('#time,accel_x,accel_y,accel_z\n') + samples = bg_client.samples or bg_client.get_samples() + for t, accel_x, accel_y, accel_z in samples: + f.write('%.6f,%.6f,%.6f,%.6f\n' % (t, accel_x, accel_y, accel_z)) diff --git a/shaketune/macros/axes_map.py b/shaketune/macros/axes_map.py index 279f95d..877b856 100644 --- a/shaketune/macros/axes_map.py +++ b/shaketune/macros/axes_map.py @@ -3,6 +3,7 @@ from ..helpers.console_output import ConsoleOutput from ..shaketune_thread import ShakeTuneThread +from .accelerometer import Accelerometer def find_axis_accelerometer(printer, axis: str = 'xy'): @@ -56,7 +57,10 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No 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}') + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) + accelerometer.start_measurement() + # 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) @@ -64,7 +68,9 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No 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') + + accelerometer.stop_measurement('axemap') + # 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: From 375190610cc6d7d09f736ce57d71c152ac259171 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sun, 12 May 2024 20:58:53 +0200 Subject: [PATCH 3/6] using my own resonance tester algorithm --- README.md | 2 +- docs/README.md | 9 ++- docs/macros/axis_tuning.md | 17 +++-- docs/macros/belts_tuning.md | 11 +-- shaketune/graph_creators/graph_creator.py | 13 ++-- shaketune/macros/__init__.py | 7 ++ shaketune/macros/accelerometer.py | 56 +++++++++----- shaketune/macros/axes_input_shaper.py | 93 ++++++++++++++++++++--- shaketune/macros/axes_map.py | 18 +---- shaketune/macros/belts_comparison.py | 79 ++++++++++++++++--- shaketune/macros/resonance_test.py | 50 ++++++++++++ shaketune/macros/static_freq.py | 53 ++++++++++--- 12 files changed, 317 insertions(+), 91 deletions(-) create mode 100644 shaketune/macros/resonance_test.py diff --git a/README.md b/README.md index 1bb6546..e4156ac 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Klipper Shake&Tune Module +# Klipper Shake&Tune plugin This "Shake&Tune" repository is a standalone module from the [Klippain](https://github.com/Frix-x/klippain) ecosystem, designed to automate and calibrate the input shaper system on your Klipper 3D printer with a streamlined workflow and insightful vizualisations. This can be installed on any Klipper machine. It is not limited to those using Klippain. diff --git a/docs/README.md b/docs/README.md index 714db9d..3e45ec6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,4 +1,4 @@ -# Klippain Shake&Tune module documentation +# Klipper Shake&Tune plugin documentation ![](./banner_long.png) @@ -89,7 +89,7 @@ Here are the parameters available when calling this macro: |SPEED|80|speed of the toolhead in mm/s for the movements| |ACCEL|1500 (or max printer accel)|accel in mm/s^2 used for all the moves| |TRAVEL_SPEED|120|speed in mm/s used for all the travels moves| -|ACCEL_CHIP|"adxl345"|accelerometer chip name in the config| +|ACCEL_CHIP|None|accelerometer to use for the test. If unset, it will automatically select the proper accelerometer based on what is configured in your `[resonance_tester]` config section| The machine will move slightly in +X, +Y, and +Z, and output in the console: `Detected axes_map: -z,y,x`. @@ -108,8 +108,11 @@ Here are the parameters available when calling this macro: | parameters | default value | description | |-----------:|---------------|-------------| |FREQUENCY|25|excitation frequency (in Hz) that you want to maintain. Usually, it's the frequency of a peak on one of the graphs| -|TIME|10|time in second to maintain this excitation| +|DURATION|10|duration in second to maintain this excitation| +|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| |AXIS|x|axis you want to excitate. Can be set to either "x", "y", "a", "b"| +|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)| +|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section| ## Complementary ressources diff --git a/docs/macros/axis_tuning.md b/docs/macros/axis_tuning.md index a37168b..3a4c1e1 100644 --- a/docs/macros/axis_tuning.md +++ b/docs/macros/axis_tuning.md @@ -11,14 +11,15 @@ Then, call the `AXES_SHAPER_CALIBRATION` macro and look for the graphs in the re | parameters | default value | description | |-----------:|---------------|-------------| -|FREQ_START|5|Starting excitation frequency| -|FREQ_END|133|Maximum excitation frequency| -|HZ_PER_SEC|1|Number of Hz per seconds for the test| -|AXIS|"all"|Axis you want to test in the list of "all", "X" or "Y"| -|SCV|printer square corner velocity|Square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations| -|MAX_SMOOTHING|None|Max smoothing allowed when calculating shaper recommendations| -|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| -|KEEP_CSV|0|Weither or not to keep the CSV data file alonside the PNG graphs| +|FREQ_START|5|starting excitation frequency| +|FREQ_END|133|maximum excitation frequency| +|HZ_PER_SEC|1|number of Hz per seconds for the test| +|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| +|AXIS|"all"|axis you want to test in the list of "all", "X" or "Y"| +|SCV|printer square corner velocity|square corner velocity you want to use to calculate shaper recommendations. Using higher SCV values usually results in more smoothing and lower maximum accelerations| +|MAX_SMOOTHING|None|max smoothing allowed when calculating shaper recommendations| +|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)| +|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section| ## Graphs description diff --git a/docs/macros/belts_tuning.md b/docs/macros/belts_tuning.md index 0ce42c0..d1b3fd2 100644 --- a/docs/macros/belts_tuning.md +++ b/docs/macros/belts_tuning.md @@ -11,11 +11,12 @@ Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the re | parameters | default value | description | |-----------:|---------------|-------------| -|FREQ_START|5|Starting excitation frequency| -|FREQ_END|133|Maximum excitation frequency| -|HZ_PER_SEC|1|Number of Hz per seconds for the test| -|KEEP_N_RESULTS|3|Total number of results to keep in the result folder after running the test. The older results are automatically cleaned up| -|KEEP_CSV|0|Weither or not to keep the CSV data files alonside the PNG graphs| +|FREQ_START|5|starting excitation frequency| +|FREQ_END|133|maximum excitation frequency| +|HZ_PER_SEC|1|number of Hz per seconds for the test| +|ACCEL_PER_HZ|None|accel per Hz value used for the test. If unset, it will use the value from your `[resonance_tester]` config section (75 is the default)| +|TRAVEL_SPEED|120|speed in mm/s used for all the travel movements (to go to the start position prior to the test)| +|Z_HEIGHT|None|Z height wanted for the test. This value can be used if needed to override the Z value of the probe_point set in your `[resonance_tester]` config section| ## Graphs description diff --git a/shaketune/graph_creators/graph_creator.py b/shaketune/graph_creators/graph_creator.py index 75fc3da..4902d37 100644 --- a/shaketune/graph_creators/graph_creator.py +++ b/shaketune/graph_creators/graph_creator.py @@ -57,7 +57,6 @@ class GraphCreator(abc.ABC): 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 @@ -98,9 +97,9 @@ class BeltsGraphCreator(GraphCreator): def create_graph(self) -> None: lognames = self._move_and_prepare_files( - glob_pattern='raw_data_axis*.csv', + glob_pattern='shaketune-belt_*.csv', min_files_required=2, - custom_name_func=lambda f: f.stem.split('_')[3].upper(), + custom_name_func=lambda f: f.stem.split('_')[1].upper(), ) fig = belts_calibration( lognames=[str(path) for path in lognames], @@ -245,15 +244,13 @@ class AxesMapFinder(GraphCreator): def find_axesmap(self) -> None: tmp_folder = Path('/tmp') - globbed_files = list(tmp_folder.glob(f'{self._chip_name}-*.csv')) + globbed_files = list(tmp_folder.glob('shaketune-axemap_*.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 + # Find the CSV files with the latest timestamp and process it 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, @@ -271,6 +268,6 @@ class AxesMapFinder(GraphCreator): def clean_old_files(self, keep_results: int) -> None: tmp_folder = Path('/tmp') - globbed_files = list(tmp_folder.glob(f'{self._chip_name}-*.csv')) + globbed_files = list(tmp_folder.glob('shaketune-axemap_*.csv')) for csv_file in globbed_files: csv_file.unlink() diff --git a/shaketune/macros/__init__.py b/shaketune/macros/__init__.py index 5486211..e6338e4 100644 --- a/shaketune/macros/__init__.py +++ b/shaketune/macros/__init__.py @@ -5,6 +5,13 @@ 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 +AXIS_CONFIG = [ + {'axis': 'x', 'direction': (1, 0, 0), 'label': 'axis_X'}, + {'axis': 'y', 'direction': (0, 1, 0), 'label': 'axis_Y'}, + {'axis': 'a', 'direction': (1, -1, 0), 'label': 'belt_A'}, + {'axis': 'b', 'direction': (1, 1, 0), 'label': 'belt_B'}, +] + # graph_creators = { # 'axesmap': (AxesMapFinder, lambda gc: gc.configure(options.accel_used, options.chip_name)), # 'belts': (BeltsGraphCreator, None), diff --git a/shaketune/macros/accelerometer.py b/shaketune/macros/accelerometer.py index 6b3b182..1a2e3c3 100644 --- a/shaketune/macros/accelerometer.py +++ b/shaketune/macros/accelerometer.py @@ -1,37 +1,57 @@ #!/usr/bin/env python3 +# This file provides a custom and internal Shake&Tune Accelerometer helper that is +# an interface to Klipper's own accelerometer classes. It is used to start and +# stop accelerometer measurements and write the data to a file in a blocking manner. + import time -from ..helpers.console_output import ConsoleOutput +# from ..helpers.console_output import ConsoleOutput class Accelerometer: def __init__(self, klipper_accelerometer): self._k_accelerometer = klipper_accelerometer + self._bg_client = None + + @staticmethod + 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 start_measurement(self): - if self._k_accelerometer.bg_client is None: - self._k_accelerometer.bg_client = self._k_accelerometer.chip.start_internal_client() - ConsoleOutput.print('accelerometer measurements started') + if self._bg_client is None: + self._bg_client = self._k_accelerometer.start_internal_client() + # ConsoleOutput.print('Accelerometer measurements started') else: raise ValueError('measurements already started!') - def stop_measurement(self, name: str = None): - if self._k_accelerometer.bg_client is not None: - name = name or time.strftime('%Y%m%d_%H%M%S') - if not name.replace('-', '').replace('_', '').isalnum(): - raise ValueError('invalid file name!') - - bg_client = self._k_accelerometer.bg_client - self._k_accelerometer.bg_client = None - bg_client.finish_measurements() - - filename = f'/tmp/shaketune-{name}.csv' - self._write_to_file(bg_client, filename) - ConsoleOutput.print(f'Measurements stopped. Data written to {filename}') - else: + def stop_measurement(self, name: str = None, append_time: bool = True): + if self._bg_client is None: raise ValueError('measurements need to be started first!') + timestamp = time.strftime('%Y%m%d_%H%M%S') + if name is None: + name = timestamp + elif append_time: + name += f'_{timestamp}' + + if not name.replace('-', '').replace('_', '').isalnum(): + raise ValueError('invalid file name!') + + bg_client = self._bg_client + self._bg_client = None + bg_client.finish_measurements() + + filename = f'/tmp/shaketune-{name}.csv' + self._write_to_file(bg_client, filename) + # ConsoleOutput.print(f'Accelerometer measurements stopped. Data written to {filename}') + def _write_to_file(self, bg_client, filename): with open(filename, 'w') as f: f.write('#time,accel_x,accel_y,accel_z\n') diff --git a/shaketune/macros/axes_input_shaper.py b/shaketune/macros/axes_input_shaper.py index 47b72d7..d25209d 100644 --- a/shaketune/macros/axes_input_shaper.py +++ b/shaketune/macros/axes_input_shaper.py @@ -3,33 +3,102 @@ from ..helpers.console_output import ConsoleOutput from ..shaketune_thread import ShakeTuneThread +from . import AXIS_CONFIG +from .accelerometer import Accelerometer +from .resonance_test import vibrate_axis 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']: + accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) + axis_input = gcmd.get('AXIS', default='all').lower() + if axis_input 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) + feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) + z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) + + systime = printer.get_reactor().monotonic() + toolhead = printer.lookup_object('toolhead') + res_tester = printer.lookup_object('resonance_tester') 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'] + if accel_per_hz is None: + accel_per_hz = res_tester.test.accel_per_hz + max_accel = max_freq * accel_per_hz + + # Move to the starting point + test_points = res_tester.test.get_start_test_points() + if len(test_points) > 1: + gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.') + if test_points[0] == (-1, -1, -1): + if z_height is None: + gcmd.error( + 'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1' + ) + # Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1 + # This is usefull to get something automatic and is also used in the Klippain modular config + 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 + point = (mid_x, mid_y, z_height) + else: + x, y, z = test_points[0] + if z_height is not None: + z = z_height + point = (x, y, z) + + toolhead.manual_move(point, feedrate_travel) + + # Configure the graph creator 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}' + # set the needed acceleration values for the test + toolhead_info = toolhead.get_status(systime) + old_accel = toolhead_info['max_accel'] + old_mcr = toolhead_info['minimum_cruise_ratio'] + gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=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 + + # Filter axis configurations based on user input, assuming 'axis_input' can be 'x', 'y', 'all' (that means 'x' and 'y') + filtered_config = [ + a for a in AXIS_CONFIG if a['axis'] == axis_input or (axis_input == 'all' and a['axis'] in ('x', 'y')) + ] + for config in filtered_config: + # First we need to find the accelerometer chip suited for the axis + accel_chip = Accelerometer.find_axis_accelerometer(printer, config['axis']) + if accel_chip is None: + gcmd.error( + 'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.' ) - ConsoleOutput.print(f'{axis.upper()} axis frequency profile generation...') - ConsoleOutput.print('This may take some time (1-3min)') - st_thread.run() + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) + + # Then do the actual measurements + accelerometer.start_measurement() + vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz) + accelerometer.stop_measurement(config['label'], append_time=True) + + # And finally generate the graph for each measured axis + ConsoleOutput.print(f'{config['axis'].upper()} axis frequency profile generation...') + ConsoleOutput.print('This may take some time (1-3min)') + st_thread.run() + + # 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}') diff --git a/shaketune/macros/axes_map.py b/shaketune/macros/axes_map.py index 877b856..38d1e0e 100644 --- a/shaketune/macros/axes_map.py +++ b/shaketune/macros/axes_map.py @@ -6,16 +6,6 @@ from ..shaketune_thread import ShakeTuneThread from .accelerometer import Accelerometer -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) @@ -24,11 +14,12 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No accel_chip = gcmd.get('ACCEL_CHIP', default=None) if accel_chip is None: - accel_chip = find_axis_accelerometer(printer, 'xy') + accel_chip = Accelerometer.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.' ) + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) systime = printer.get_reactor().monotonic() toolhead = printer.lookup_object('toolhead') @@ -57,10 +48,7 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No toolhead.dwell(0.5) # Start the measurements and do the movements (+X, +Y and then +Z) - accelerometer = Accelerometer(printer.lookup_object(accel_chip)) accelerometer.start_measurement() - # 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) @@ -68,9 +56,7 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No toolhead.dwell(1) toolhead.move([mid_x + 15, mid_y + 15, z_height + 15, E], speed) toolhead.dwell(1) - accelerometer.stop_measurement('axemap') - # 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: diff --git a/shaketune/macros/belts_comparison.py b/shaketune/macros/belts_comparison.py index 7a4abf6..3e3a546 100644 --- a/shaketune/macros/belts_comparison.py +++ b/shaketune/macros/belts_comparison.py @@ -3,24 +3,83 @@ from ..helpers.console_output import ConsoleOutput from ..shaketune_thread import ShakeTuneThread +from . import AXIS_CONFIG +from .accelerometer import Accelerometer +from .resonance_test import vibrate_axis def compare_belts_responses(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: - min_freq = gcmd.get_float('FREQ_START', default=5, minval=1) + min_freq = gcmd.get_float('FREQ_START', default=5.0, 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) + hz_per_sec = gcmd.get_float('HZ_PER_SEC', default=1.0, minval=1) + accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) + feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) + z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) + systime = printer.get_reactor().monotonic() toolhead = printer.lookup_object('toolhead') + res_tester = printer.lookup_object('resonance_tester') - 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() + accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy') + if accel_chip is None: + gcmd.error( + 'No suitable accelerometer found for measurement! Multi-accelerometer configurations are not supported for this macro.' + ) + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) - 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() + if accel_per_hz is None: + accel_per_hz = res_tester.test.accel_per_hz + max_accel = max_freq * accel_per_hz + + # Move to the starting point + test_points = res_tester.test.get_start_test_points() + if len(test_points) > 1: + gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.') + if test_points[0] == (-1, -1, -1): + if z_height is None: + gcmd.error( + 'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1' + ) + # Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1 + # This is usefull to get something automatic and is also used in the Klippain modular config + 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 + point = (mid_x, mid_y, z_height) + else: + x, y, z = test_points[0] + if z_height is not None: + z = z_height + point = (x, y, z) + + toolhead.manual_move(point, feedrate_travel) + + # set the needed acceleration values for the test + toolhead_info = toolhead.get_status(systime) + old_accel = toolhead_info['max_accel'] + old_mcr = toolhead_info['minimum_cruise_ratio'] + gcode.run_script_from_command(f'SET_VELOCITY_LIMIT ACCEL={max_accel} MINIMUM_CRUISE_RATIO=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 + + # Filter axis configurations to get the A and B axis only + filtered_config = [a for a in AXIS_CONFIG if a['axis'] in ('x', 'y')] + for config in filtered_config: + accelerometer.start_measurement() + vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz) + accelerometer.stop_measurement(config['label'], append_time=True) + + # 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}') # Run post-processing ConsoleOutput.print('Belts comparative frequency profile generation...') diff --git a/shaketune/macros/resonance_test.py b/shaketune/macros/resonance_test.py new file mode 100644 index 0000000..6913626 --- /dev/null +++ b/shaketune/macros/resonance_test.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 + +# The logic in this file was "extracted" from Klipper's orignal resonance_tester.py file +# Courtesy of Dmitry Butyugin for the original implementation + +# This derive a bit from Klipper's implementation as there are two main changes: +# 1. Original code doesn't use euclidean distance for the moves calculation with projection. The new approach implemented here +# ensures that the vector's total length remains constant (= L), regardless of the direction components. It's especially +# important when the direction vector involves combinations of movements along multiple axes like for the diagonal belt tests. +# 2. Original code doesn't allow Z axis movement that was added here for later use + +import math + +from ..helpers.console_output import ConsoleOutput + + +# This function is used to vibrate the toolhead in a specific axis direction +# 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 + sign = 1.0 + + while freq <= max_freq + 0.000001: + t_seg = 0.25 / freq # Time segment for one vibration cycle + accel = accel_per_hz * freq # Acceleration for each half-cycle + max_v = accel * t_seg # Max velocity for each half-cycle + toolhead.cmd_M204(gcode.create_gcode_command('M204', 'M204', {'S': accel})) + L = 0.5 * accel * t_seg**2 # Distance for each half-cycle + + # 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 + nX = X + sign * dX + nY = Y + sign * dY + nZ = Z + sign * dZ + + # Execute movement + toolhead.move([nX, nY, nZ, E], max_v) + toolhead.move([X, Y, Z, E], max_v) + sign *= -1 + + # Increase frequency for next cycle + old_freq = freq + freq += 2 * t_seg * hz_per_sec + if int(freq) > int(old_freq): + ConsoleOutput.print(f'Testing frequency: {freq:.0f} Hz') + + toolhead.wait_moves() diff --git a/shaketune/macros/static_freq.py b/shaketune/macros/static_freq.py index 596a32c..b6bbf12 100644 --- a/shaketune/macros/static_freq.py +++ b/shaketune/macros/static_freq.py @@ -1,22 +1,55 @@ #!/usr/bin/env python3 from ..helpers.console_output import ConsoleOutput +from . import AXIS_CONFIG +from .resonance_test import vibrate_axis -def excitate_axis_at_freq(gcmd, gcode) -> None: +def excitate_axis_at_freq(gcmd, printer, 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']: + 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) + + axis_config = next((item for item in AXIS_CONFIG if item['axis'] == axis), None) + if axis_config is None: 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' + systime = printer.get_reactor().monotonic() + toolhead = printer.lookup_object('toolhead') + res_tester = printer.lookup_object('resonance_tester') - 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)}' - ) + if accel_per_hz is None: + accel_per_hz = res_tester.test.accel_per_hz + + # Move to the starting point + test_points = res_tester.test.get_start_test_points() + if len(test_points) > 1: + gcmd.error('Only one test point in the [resonance_tester] section is supported by Shake&Tune.') + if test_points[0] == (-1, -1, -1): + if z_height is None: + gcmd.error( + 'Z_HEIGHT parameter is required if the test_point in [resonance_tester] section is set to -1,-1,-1' + ) + # Use center of bed in case the test point in [resonance_tester] is set to -1,-1,-1 + # This is usefull to get something automatic and is also used in the Klippain modular config + 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 + point = (mid_x, mid_y, z_height) + else: + x, y, z = test_points[0] + if z_height is not None: + z = z_height + point = (x, y, z) + + toolhead.manual_move(point, feedrate_travel) + + 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) From a37ece7ece67a8e97d841bfbc9bdd67a44042a7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Mon, 13 May 2024 17:22:05 +0200 Subject: [PATCH 4/6] rename folders in measurement and post-processing --- .../{macros => measurement}/K-SnT_vibrations.cfg | 0 shaketune/{macros => measurement}/__init__.py | 0 shaketune/{macros => measurement}/accelerometer.py | 0 .../{macros => measurement}/axes_input_shaper.py | 0 shaketune/{macros => measurement}/axes_map.py | 0 .../{macros => measurement}/belts_comparison.py | 0 shaketune/{macros => measurement}/resonance_test.py | 0 shaketune/{macros => measurement}/static_freq.py | 0 .../{graph_creators => post_processing}/__init__.py | 0 .../analyze_axesmap.py | 0 .../graph_belts.py | 0 .../graph_creator.py | 0 .../graph_shaper.py | 0 .../graph_vibrations.py | 0 .../klippain.png | Bin shaketune/shaketune.py | 4 ++-- 16 files changed, 2 insertions(+), 2 deletions(-) rename shaketune/{macros => measurement}/K-SnT_vibrations.cfg (100%) rename shaketune/{macros => measurement}/__init__.py (100%) rename shaketune/{macros => measurement}/accelerometer.py (100%) rename shaketune/{macros => measurement}/axes_input_shaper.py (100%) rename shaketune/{macros => measurement}/axes_map.py (100%) rename shaketune/{macros => measurement}/belts_comparison.py (100%) rename shaketune/{macros => measurement}/resonance_test.py (100%) rename shaketune/{macros => measurement}/static_freq.py (100%) rename shaketune/{graph_creators => post_processing}/__init__.py (100%) rename shaketune/{graph_creators => post_processing}/analyze_axesmap.py (100%) rename shaketune/{graph_creators => post_processing}/graph_belts.py (100%) rename shaketune/{graph_creators => post_processing}/graph_creator.py (100%) rename shaketune/{graph_creators => post_processing}/graph_shaper.py (100%) rename shaketune/{graph_creators => post_processing}/graph_vibrations.py (100%) rename shaketune/{graph_creators => post_processing}/klippain.png (100%) diff --git a/shaketune/macros/K-SnT_vibrations.cfg b/shaketune/measurement/K-SnT_vibrations.cfg similarity index 100% rename from shaketune/macros/K-SnT_vibrations.cfg rename to shaketune/measurement/K-SnT_vibrations.cfg diff --git a/shaketune/macros/__init__.py b/shaketune/measurement/__init__.py similarity index 100% rename from shaketune/macros/__init__.py rename to shaketune/measurement/__init__.py diff --git a/shaketune/macros/accelerometer.py b/shaketune/measurement/accelerometer.py similarity index 100% rename from shaketune/macros/accelerometer.py rename to shaketune/measurement/accelerometer.py diff --git a/shaketune/macros/axes_input_shaper.py b/shaketune/measurement/axes_input_shaper.py similarity index 100% rename from shaketune/macros/axes_input_shaper.py rename to shaketune/measurement/axes_input_shaper.py diff --git a/shaketune/macros/axes_map.py b/shaketune/measurement/axes_map.py similarity index 100% rename from shaketune/macros/axes_map.py rename to shaketune/measurement/axes_map.py diff --git a/shaketune/macros/belts_comparison.py b/shaketune/measurement/belts_comparison.py similarity index 100% rename from shaketune/macros/belts_comparison.py rename to shaketune/measurement/belts_comparison.py diff --git a/shaketune/macros/resonance_test.py b/shaketune/measurement/resonance_test.py similarity index 100% rename from shaketune/macros/resonance_test.py rename to shaketune/measurement/resonance_test.py diff --git a/shaketune/macros/static_freq.py b/shaketune/measurement/static_freq.py similarity index 100% rename from shaketune/macros/static_freq.py rename to shaketune/measurement/static_freq.py diff --git a/shaketune/graph_creators/__init__.py b/shaketune/post_processing/__init__.py similarity index 100% rename from shaketune/graph_creators/__init__.py rename to shaketune/post_processing/__init__.py diff --git a/shaketune/graph_creators/analyze_axesmap.py b/shaketune/post_processing/analyze_axesmap.py similarity index 100% rename from shaketune/graph_creators/analyze_axesmap.py rename to shaketune/post_processing/analyze_axesmap.py diff --git a/shaketune/graph_creators/graph_belts.py b/shaketune/post_processing/graph_belts.py similarity index 100% rename from shaketune/graph_creators/graph_belts.py rename to shaketune/post_processing/graph_belts.py diff --git a/shaketune/graph_creators/graph_creator.py b/shaketune/post_processing/graph_creator.py similarity index 100% rename from shaketune/graph_creators/graph_creator.py rename to shaketune/post_processing/graph_creator.py diff --git a/shaketune/graph_creators/graph_shaper.py b/shaketune/post_processing/graph_shaper.py similarity index 100% rename from shaketune/graph_creators/graph_shaper.py rename to shaketune/post_processing/graph_shaper.py diff --git a/shaketune/graph_creators/graph_vibrations.py b/shaketune/post_processing/graph_vibrations.py similarity index 100% rename from shaketune/graph_creators/graph_vibrations.py rename to shaketune/post_processing/graph_vibrations.py diff --git a/shaketune/graph_creators/klippain.png b/shaketune/post_processing/klippain.png similarity index 100% rename from shaketune/graph_creators/klippain.png rename to shaketune/post_processing/klippain.png diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py index 17ff7bf..1c6ffe5 100644 --- a/shaketune/shaketune.py +++ b/shaketune/shaketune.py @@ -3,9 +3,9 @@ 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 .measurement import axes_map_calibration, axes_shaper_calibration, compare_belts_responses, excitate_axis_at_freq +from .post_processing import AxesMapFinder, BeltsGraphCreator, ShaperGraphCreator from .shaketune_config import ShakeTuneConfig from .shaketune_thread import ShakeTuneThread From dd081626162e0fa5bc3dee73c3488238de69bfb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Mon, 13 May 2024 18:38:35 +0200 Subject: [PATCH 5/6] added back the vibrations profile measurement --- shaketune/helpers/motorlogparser.py | 205 ------------------- shaketune/measurement/K-SnT_vibrations.cfg | 214 -------------------- shaketune/measurement/__init__.py | 1 + shaketune/measurement/axes_map.py | 4 +- shaketune/measurement/motorsconfigparser.py | 190 +++++++++++++++++ shaketune/measurement/static_freq.py | 2 +- shaketune/measurement/vibrations_profile.py | 137 +++++++++++++ shaketune/post_processing/graph_creator.py | 24 +-- shaketune/shaketune.py | 41 +++- shaketune/shaketune_config.py | 78 ------- 10 files changed, 370 insertions(+), 526 deletions(-) delete mode 100644 shaketune/helpers/motorlogparser.py delete mode 100644 shaketune/measurement/K-SnT_vibrations.cfg create mode 100644 shaketune/measurement/motorsconfigparser.py create mode 100644 shaketune/measurement/vibrations_profile.py diff --git a/shaketune/helpers/motorlogparser.py b/shaketune/helpers/motorlogparser.py deleted file mode 100644 index 4e6e743..0000000 --- a/shaketune/helpers/motorlogparser.py +++ /dev/null @@ -1,205 +0,0 @@ -#!/usr/bin/env python3 - -# Classes to parse the Klipper log and parse the TMC dump to extract the relevant information -# Written by Frix_x#0161 # - -import re -from pathlib import Path -from typing import Any, Dict, List, Optional, Union - - -class Motor: - def __init__(self, name: str): - self._name: str = name - self._registers: Dict[str, Dict[str, Any]] = {} - self._properties: Dict[str, Any] = {} - - def set_register(self, register: str, value: Any) -> None: - # Special parsing for CHOPCONF to extract meaningful values - if register == 'CHOPCONF': - # Add intpol=0 if missing from the register dump - if 'intpol=' not in value: - value += ' intpol=0' - # Simplify the microstep resolution format - mres_match = re.search(r'mres=\d+\((\d+)usteps\)', value) - if mres_match: - value = re.sub(r'mres=\d+\(\d+usteps\)', f'mres={mres_match.group(1)}', value) - - # Special parsing for CHOPCONF to avoid pwm_ before each values - if register == 'PWMCONF': - parts = value.split() - new_parts = [] - for part in parts: - key, val = part.split('=', 1) - if key.startswith('pwm_'): - key = key[4:] - new_parts.append(f'{key}={val}') - value = ' '.join(new_parts) - - # General cleaning to remove extraneous labels and colons and parse the whole into Motor _registers - cleaned_values = re.sub(r'\b\w+:\s+\S+\s+', '', value) - - # Then fill the registers while merging all the thresholds into the same THRS virtual register - if register in ['TPWMTHRS', 'TCOOLTHRS']: - existing_thrs = self._registers.get('THRS', {}) - new_values = self._parse_register_values(cleaned_values) - merged_values = {**existing_thrs, **new_values} - self._registers['THRS'] = merged_values - else: - self._registers[register] = self._parse_register_values(cleaned_values) - - def _parse_register_values(self, register_string: str) -> Dict[str, Any]: - parsed = {} - parts = register_string.split() - for part in parts: - if '=' in part: - k, v = part.split('=', 1) - parsed[k] = v - return parsed - - def get_register(self, register: str) -> Optional[Dict[str, Any]]: - return self._registers.get(register) - - def get_registers(self) -> Dict[str, Dict[str, Any]]: - return self._registers - - def set_property(self, property: str, value: Any) -> None: - self._properties[property] = value - - def get_property(self, property: str) -> Optional[Any]: - return self._properties.get(property) - - def __str__(self): - return f'Stepper: {self._name}\nKlipper config: {self._properties}\nTMC Registers: {self._registers}' - - # Return the other motor properties and registers that are different from the current motor - def compare_to(self, other: 'Motor') -> Optional[Dict[str, Dict[str, Any]]]: - differences = {'properties': {}, 'registers': {}} - - # Compare properties - all_keys = self._properties.keys() | other._properties.keys() - for key in all_keys: - val1 = self._properties.get(key) - val2 = other._properties.get(key) - if val1 != val2: - differences['properties'][key] = val2 - - # Compare registers - all_keys = self._registers.keys() | other._registers.keys() - for key in all_keys: - reg1 = self._registers.get(key, {}) - reg2 = other._registers.get(key, {}) - if reg1 != reg2: - reg_diffs = {} - sub_keys = reg1.keys() | reg2.keys() - for sub_key in sub_keys: - reg_val1 = reg1.get(sub_key) - reg_val2 = reg2.get(sub_key) - if reg_val1 != reg_val2: - reg_diffs[sub_key] = reg_val2 - if reg_diffs: - differences['registers'][key] = reg_diffs - - # Clean up: remove empty sections if there are no differences - if not differences['properties']: - del differences['properties'] - if not differences['registers']: - del differences['registers'] - - if not differences: - return None - - return differences - - -class MotorLogParser: - _section_pattern: str = r'DUMP_TMC stepper_(x|y)' - _register_patterns: Dict[str, str] = { - 'CHOPCONF': r'CHOPCONF:\s+\S+\s+(.*)', - 'PWMCONF': r'PWMCONF:\s+\S+\s+(.*)', - 'COOLCONF': r'COOLCONF:\s+(.*)', - 'TPWMTHRS': r'TPWMTHRS:\s+\S+\s+(.*)', - 'TCOOLTHRS': r'TCOOLTHRS:\s+\S+\s+(.*)', - } - - def __init__(self, filepath: Path, config_string: Optional[str] = None): - self._filepath = filepath - - self._motors: List[Motor] = [] - self._config = self._parse_config(config_string) if config_string else {} - - self._parse_registers() - - def _parse_config(self, config_string: str) -> Dict[str, Any]: - config = {} - entries = config_string.split('|') - for entry in entries: - if entry: - key, value = entry.split(':') - config[key.strip()] = self._convert_value(value.strip()) - return config - - def _convert_value(self, value: str) -> Union[int, float, bool, str]: - if value.isdigit(): - return int(value) - try: - return float(value) - except ValueError: - if value.lower() in ['true', 'false']: - return value.lower() == 'true' - return value - - def _parse_registers(self) -> None: - with open(self._filepath, 'r') as file: - log_content = file.read() - - sections = re.split(self._section_pattern, log_content) - - # Detect only the latest dumps from the log (to ignore potential previous and outdated dumps) - last_sections: Dict[str, int] = {} - for i in range(1, len(sections), 2): - stepper_name = 'stepper_' + sections[i].strip() - last_sections[stepper_name] = i - - for stepper_name, index in last_sections.items(): - content = sections[index + 1] - motor = Motor(stepper_name) - - # Apply general properties from config string - for key, value in self._config.items(): - if stepper_name in key: - prop_key = key.replace(stepper_name + '_', '') - motor.set_property(prop_key, value) - elif 'autotune' in key: - motor.set_property(key, value) - - # Parse TMC registers - for key, pattern in self._register_patterns.items(): - match = re.search(pattern, content) - if match: - values = match.group(1).strip() - motor.set_register(key, values) - - self._motors.append(motor) - - # Find and return the motor by its name - def get_motor(self, motor_name: str) -> Optional[Motor]: - for motor in self._motors: - if motor._name == motor_name: - return motor - return None - - # Get all the motor list at once - def get_motors(self) -> List[Motor]: - return self._motors - - -# # Usage example: -# config_string = "stepper_x_tmc:tmc2240|stepper_x_run_current:0.9|stepper_x_hold_current:0.9|stepper_y_tmc:tmc2240|stepper_y_run_current:0.9|stepper_y_hold_current:0.9|autotune_enabled:True|stepper_x_motor:ldo-35sth48-1684ah|stepper_x_voltage:|stepper_y_motor:ldo-35sth48-1684ah|stepper_y_voltage:|" -# parser = MotorLogParser('/path/to/your/logfile.log', config_string) - -# stepper_x = parser.get_motor('stepper_x') -# stepper_y = parser.get_motor('stepper_y') - -# print(stepper_x) -# print(stepper_y) diff --git a/shaketune/measurement/K-SnT_vibrations.cfg b/shaketune/measurement/K-SnT_vibrations.cfg deleted file mode 100644 index d6ebacd..0000000 --- a/shaketune/measurement/K-SnT_vibrations.cfg +++ /dev/null @@ -1,214 +0,0 @@ -######################################### -###### MACHINE VIBRATIONS ANALYSIS ###### -######################################### -# Written by Frix_x#0161 # - -[gcode_macro CREATE_VIBRATIONS_PROFILE] -gcode: - {% set size = params.SIZE|default(100)|int %} # size of the circle where the angled lines are done - {% set z_height = params.Z_HEIGHT|default(20)|int %} # z height to put the toolhead before starting the movements - {% set max_speed = params.MAX_SPEED|default(200)|float * 60 %} # maximum feedrate for the movements - {% set speed_increment = params.SPEED_INCREMENT|default(2)|float * 60 %} # feedrate increment between each move - - {% set feedrate_travel = params.TRAVEL_SPEED|default(200)|int * 60 %} # travel feedrate between moves - {% set accel = params.ACCEL|default(3000)|int %} # accel value used to move on the pattern - {% set accel_chip = params.ACCEL_CHIP|default("adxl345") %} # ADXL chip name in the config - - {% set keep_results = params.KEEP_N_RESULTS|default(3)|int %} - {% set keep_csv = params.KEEP_CSV|default(0)|int %} - - {% set mid_x = printer.toolhead.axis_maximum.x|float / 2 %} - {% set mid_y = printer.toolhead.axis_maximum.y|float / 2 %} - {% set min_speed = 2 * 60 %} # minimum feedrate for the movements is set to 2mm/s - {% set nb_speed_samples = ((max_speed - min_speed) / speed_increment + 1) | int %} - - {% 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 %} - - {% set kinematics = printer.configfile.settings.printer.kinematics %} - - - {% if not 'xyz' in printer.toolhead.homed_axes %} - { action_raise_error("Must Home printer first!") } - {% endif %} - - {% if params.SPEED_INCREMENT|default(2)|float * 100 != (params.SPEED_INCREMENT|default(2)|float * 100)|int %} - { action_raise_error("Only 2 decimal digits are allowed for SPEED_INCREMENT") } - {% endif %} - - {% if (size / (max_speed / 60)) < 0.25 %} - { action_raise_error("SIZE is too small for this MAX_SPEED. Increase SIZE or decrease MAX_SPEED!") } - {% endif %} - - {action_respond_info("")} - {action_respond_info("Starting machine vibrations profile measurement")} - {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=CREATE_VIBRATIONS_PROFILE - - 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 / 10} - G1 X{mid_x } Y{mid_y} F{feedrate_travel} - - - {% if kinematics == "cartesian" %} - # Cartesian motors are on X and Y axis directly - RESPOND MSG="Cartesian kinematics mode" - {% set main_angles = [0, 90] %} - {% elif kinematics == "corexy" %} - # CoreXY motors are on A and B axis (45 and 135 degrees) - RESPOND MSG="CoreXY kinematics mode" - {% set main_angles = [45, 135] %} - {% else %} - { action_raise_error("Only Cartesian and CoreXY kinematics are supported at the moment for the vibrations measurement tool!") } - {% endif %} - - {% set pi = (3.141592653589793) | float %} - {% set tau = (pi * 2) | float %} - - - {% for curr_angle in main_angles %} - {% for curr_speed_sample in range(0, nb_speed_samples) %} - {% set curr_speed = min_speed + curr_speed_sample * speed_increment %} - {% set rad_angle_full = (curr_angle|float * pi / 180) %} - - # ----------------------------------------------------------------------------------------------------------- - # Here are some maths to approximate the sin and cos values of rad_angle in Jinja - # Thanks a lot to Aubey! for sharing the idea of using hardcoded Taylor series and - # the associated bit of code to do it easily! This is pure madness! - {% set rad_angle = ((rad_angle_full % tau) - (tau / 2)) | float %} - - {% if rad_angle < (-(tau / 4)) %} - {% set rad_angle = (rad_angle + (tau / 2)) | float %} - {% set final_mult = (-1) %} - {% elif rad_angle > (tau / 4) %} - {% set rad_angle = (rad_angle - (tau / 2)) | float %} - {% set final_mult = (-1) %} - {% else %} - {% set final_mult = (1) %} - {% endif %} - - {% set sin0 = (rad_angle) %} - {% set sin1 = ((rad_angle ** 3) / 6) | float %} - {% set sin2 = ((rad_angle ** 5) / 120) | float %} - {% set sin3 = ((rad_angle ** 7) / 5040) | float %} - {% set sin4 = ((rad_angle ** 9) / 362880) | float %} - {% set sin5 = ((rad_angle ** 11) / 39916800) | float %} - {% set sin6 = ((rad_angle ** 13) / 6227020800) | float %} - {% set sin7 = ((rad_angle ** 15) / 1307674368000) | float %} - {% set sin = (-(sin0 - sin1 + sin2 - sin3 + sin4 - sin5 + sin6 - sin7) * final_mult) | float %} - - {% set cos0 = (1) | float %} - {% set cos1 = ((rad_angle ** 2) / 2) | float %} - {% set cos2 = ((rad_angle ** 4) / 24) | float %} - {% set cos3 = ((rad_angle ** 6) / 720) | float %} - {% set cos4 = ((rad_angle ** 8) / 40320) | float %} - {% set cos5 = ((rad_angle ** 10) / 3628800) | float %} - {% set cos6 = ((rad_angle ** 12) / 479001600) | float %} - {% set cos7 = ((rad_angle ** 14) / 87178291200) | float %} - {% set cos = (-(cos0 - cos1 + cos2 - cos3 + cos4 - cos5 + cos6 - cos7) * final_mult) | float %} - # ----------------------------------------------------------------------------------------------------------- - - # Reduce the segments length for the lower speed range (0-100mm/s). The minimum length is 1/3 of the SIZE and is gradually increased - # to the nominal SIZE at 100mm/s. No further size changes are made above this speed. The goal is to ensure that the print head moves - # enough to collect enough data for vibration analysis, without doing unnecessary distance to save time. At higher speeds, the full - # segments lengths are used because the head moves faster and travels more distance in the same amount of time and we want enough data - {% if curr_speed < (100 * 60) %} - {% set segment_length_multiplier = 1/5 + 4/5 * (curr_speed / 60) / 100 %} - {% else %} - {% set segment_length_multiplier = 1 %} - {% endif %} - - # Calculate angle coordinates using trigonometry and length multiplier and move to start point - {% set dx = (size / 2) * cos * segment_length_multiplier %} - {% set dy = (size / 2) * sin * segment_length_multiplier %} - G1 X{mid_x - dx} Y{mid_y - dy} F{feedrate_travel} - - # Adjust the number of back and forth movements based on speed to also save time on lower speed range - # 3 movements are done by default, reduced to 2 between 150-250mm/s and to 1 under 150mm/s. - {% set movements = 3 %} - {% if curr_speed < (150 * 60) %} - {% set movements = 1 %} - {% elif curr_speed < (250 * 60) %} - {% set movements = 2 %} - {% endif %} - - ACCELEROMETER_MEASURE CHIP={accel_chip} - - # Back and forth movements to record the vibrations at constant speed in both direction - {% for n in range(movements) %} - G1 X{mid_x + dx} Y{mid_y + dy} F{curr_speed} - G1 X{mid_x - dx} Y{mid_y - dy} F{curr_speed} - {% endfor %} - - ACCELEROMETER_MEASURE CHIP={accel_chip} NAME=an{("%.2f" % curr_angle|float)|replace('.','_')}sp{("%.2f" % (curr_speed / 60)|float)|replace('.','_')} - G4 P300 - - M400 - {% endfor %} - {% endfor %} - - # Restore the previous acceleration values - SET_VELOCITY_LIMIT ACCEL={old_accel} MINIMUM_CRUISE_RATIO={old_cruise_ratio} SQUARE_CORNER_VELOCITY={old_sqv} - - # Extract the TMC names and configuration - {% set ns_x = namespace(path='') %} - {% set ns_y = namespace(path='') %} - - {% for item in printer %} - {% set parts = item.split() %} - {% if parts|length == 2 and parts[0].startswith('tmc') and parts[0][3:].isdigit() %} - {% if parts[1] == 'stepper_x' %} - {% set ns_x.path = parts[0] %} - {% elif parts[1] == 'stepper_y' %} - {% set ns_y.path = parts[0] %} - {% endif %} - {% endif %} - {% endfor %} - - {% if ns_x.path and ns_y.path %} - {% set metadata = - "stepper_x_tmc:" ~ ns_x.path ~ "|" - "stepper_x_run_current:" ~ (printer[ns_x.path + ' stepper_x'].run_current | round(2) | string) ~ "|" - "stepper_x_hold_current:" ~ (printer[ns_x.path + ' stepper_x'].hold_current | round(2) | string) ~ "|" - "stepper_y_tmc:" ~ ns_y.path ~ "|" - "stepper_y_run_current:" ~ (printer[ns_y.path + ' stepper_y'].run_current | round(2) | string) ~ "|" - "stepper_y_hold_current:" ~ (printer[ns_y.path + ' stepper_y'].hold_current | round(2) | string) ~ "|" - %} - - {% set autotune_x = printer.configfile.config['autotune_tmc stepper_x'] if 'autotune_tmc stepper_x' in printer.configfile.config else none %} - {% set autotune_y = printer.configfile.config['autotune_tmc stepper_y'] if 'autotune_tmc stepper_y' in printer.configfile.config else none %} - {% if autotune_x and autotune_y %} - {% set stepper_x_voltage = autotune_x.voltage if autotune_x.voltage else '24.0' %} - {% set stepper_y_voltage = autotune_y.voltage if autotune_y.voltage else '24.0' %} - {% set metadata = metadata ~ - "autotune_enabled:True|" - "stepper_x_motor:" ~ autotune_x.motor ~ "|" - "stepper_x_voltage:" ~ stepper_x_voltage ~ "|" - "stepper_y_motor:" ~ autotune_y.motor ~ "|" - "stepper_y_voltage:" ~ stepper_y_voltage ~ "|" - %} - {% else %} - {% set metadata = metadata ~ "autotune_enabled:False|" %} - {% endif %} - - DUMP_TMC STEPPER=stepper_x - DUMP_TMC STEPPER=stepper_y - - {% else %} - { action_respond_info("No TMC drivers found for X and Y steppers") } - {% endif %} - - RESPOND MSG="Machine vibrations profile generation..." - RESPOND MSG="This may take some time (3-5min)" - SHAKETUNE_POSTPROCESS PARAMS="--type vibrations --accel {accel|int} --kinematics {kinematics} {% if metadata %}--metadata {metadata}{% endif %} --chip_name {accel_chip} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}" - - RESTORE_GCODE_STATE NAME=CREATE_VIBRATIONS_PROFILE diff --git a/shaketune/measurement/__init__.py b/shaketune/measurement/__init__.py index e6338e4..8cffad7 100644 --- a/shaketune/measurement/__init__.py +++ b/shaketune/measurement/__init__.py @@ -4,6 +4,7 @@ from .axes_input_shaper import axes_shaper_calibration as axes_shaper_calibratio 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 AXIS_CONFIG = [ {'axis': 'x', 'direction': (1, 0, 0), 'label': 'axis_X'}, diff --git a/shaketune/measurement/axes_map.py b/shaketune/measurement/axes_map.py index 38d1e0e..bbf0dc5 100644 --- a/shaketune/measurement/axes_map.py +++ b/shaketune/measurement/axes_map.py @@ -19,7 +19,7 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No gcmd.error( 'No accelerometer specified for measurement! Multi-accelerometer configurations are not supported for this macro.' ) - accelerometer = Accelerometer(printer.lookup_object(accel_chip)) + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) systime = printer.get_reactor().monotonic() toolhead = printer.lookup_object('toolhead') @@ -71,5 +71,5 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No # Run post-processing ConsoleOutput.print('Analysis of the movements...') creator = st_thread.get_graph_creator() - creator.configure(accel, accel_chip) + creator.configure(accel) st_thread.run() diff --git a/shaketune/measurement/motorsconfigparser.py b/shaketune/measurement/motorsconfigparser.py new file mode 100644 index 0000000..3dab656 --- /dev/null +++ b/shaketune/measurement/motorsconfigparser.py @@ -0,0 +1,190 @@ +#!/usr/bin/env python3 + +# Classes to retrieve a couple of motors infos and extract the relevant information +# from the Klipper configuration and the TMC registers +# Written by Frix_x#0161 # + +import re +from typing import Any, Dict, List, Optional, Tuple + +TRINAMIC_DRIVERS = ['tmc2130', 'tmc2208', 'tmc2209', 'tmc2240', 'tmc2660', 'tmc5160'] +MOTORS = ['stepper_x', 'stepper_y', 'stepper_x1', 'stepper_y1', 'stepper_z', 'stepper_z1', 'stepper_z2', 'stepper_z3'] +RELEVANT_TMC_REGISTERS = ['CHOPCONF', 'PWMCONF', 'COOLCONF', 'TPWMTHRS', 'TCOOLTHRS'] + + +class Motor: + def __init__(self, name: str): + self.name: str = name + self._registers: Dict[str, Dict[str, Any]] = {} + self._config: Dict[str, Any] = {} + self._driver: Tuple[str, Any] = ('', None) + + def set_driver(self, driver_name: str, tmc_object: Any) -> None: + self._driver = (driver_name, tmc_object) + + def get_driver(self) -> Tuple[str, Any]: + return self._driver + + def set_register(self, register: str, value_dict: dict) -> None: + # Special parsing for CHOPCONF to extract meaningful values + if register == 'CHOPCONF': + # Add intpol=0 if missing from the register dump + if 'intpol=' not in value_dict: + value_dict['intpol'] = '0' + # Simplify the microstep resolution format + if 'mres' in value_dict: + mres_match = re.search(r'(\d+)usteps', value_dict['mres']) + if mres_match: + value_dict['mres'] = mres_match.group(1) + + # Special parsing for CHOPCONF to avoid pwm_ before each values + if register == 'PWMCONF': + new_value_dict = {} + for key, val in value_dict.items(): + if key.startswith('pwm_'): + key = key[4:] + new_value_dict[key] = val + value_dict = new_value_dict + + # Then fill the registers while merging all the thresholds into the same THRS virtual register + if register in ['TPWMTHRS', 'TCOOLTHRS']: + existing_thrs = self._registers.get('THRS', {}) + merged_values = {**existing_thrs, **value_dict} + self._registers['THRS'] = merged_values + else: + self._registers[register] = value_dict + + def get_register(self, register: str) -> Optional[Dict[str, Any]]: + return self._registers.get(register) + + def get_registers(self) -> Dict[str, Dict[str, Any]]: + return self._registers + + def set_config(self, field: str, value: Any) -> None: + self._config[field] = value + + def get_config(self, field: str) -> Optional[Any]: + return self._config.get(field) + + def __str__(self): + return f'Stepper: {self.name}\nKlipper config: {self._config}\nTMC Registers: {self._registers}' + + # Return the other motor config and registers that are different from the current motor + def compare_to(self, other: 'Motor') -> Optional[Dict[str, Dict[str, Any]]]: + differences = {'config': {}, 'registers': {}} + + # Compare Klipper config + all_keys = self._config.keys() | other._config.keys() + for key in all_keys: + val1 = self._config.get(key) + val2 = other._config.get(key) + if val1 != val2: + differences['config'][key] = val2 + + # Compare TMC registers + all_keys = self._registers.keys() | other._registers.keys() + for key in all_keys: + reg1 = self._registers.get(key, {}) + reg2 = other._registers.get(key, {}) + if reg1 != reg2: + reg_diffs = {} + sub_keys = reg1.keys() | reg2.keys() + for sub_key in sub_keys: + reg_val1 = reg1.get(sub_key) + reg_val2 = reg2.get(sub_key) + if reg_val1 != reg_val2: + reg_diffs[sub_key] = reg_val2 + if reg_diffs: + differences['registers'][key] = reg_diffs + + # Clean up: remove empty sections if there are no differences + if not differences['config']: + del differences['config'] + if not differences['registers']: + del differences['registers'] + + if not differences: + return None + + return differences + + +class MotorsConfigParser: + def __init__(self, printer, motors: List[str] = MOTORS, drivers: List[str] = TRINAMIC_DRIVERS): + self._motors: List[Motor] = [] + self._printer = printer + + for motor_name in motors: + for driver in drivers: + tmc_object = printer.lookup_object(f'{driver} {motor_name}', None) + if tmc_object is None: + continue + motor = self._create_motor(motor_name, driver, tmc_object) + self._motors.append(motor) + + # Create a Motor object with the given name, driver and TMC object + # and fill it with the relevant configuration and registers + def _create_motor(self, motor_name: str, driver: str, tmc_object: Any) -> Motor: + motor = Motor(motor_name) + motor.set_driver(driver.upper(), tmc_object) + self._parse_klipper_config(motor, tmc_object) + self._parse_tmc_registers(motor, tmc_object) + return motor + + def _parse_klipper_config(self, motor: Motor, tmc: Any) -> None: + # The TMCCommandHelper isn't a direct member of the TMC object... but we can still get it this way + tmc_cmdhelper = tmc.get_status.__self__ + + motor_currents = tmc_cmdhelper.current_helper.get_current() + motor.set_config('run_current', motor_currents[0]) + motor.set_config('hold_current', motor_currents[1]) + + autotune_object = self._printer.lookup_object(f'autotune_tmc {motor.name}', None) + if autotune_object is not None: + motor.set_config('autotune_enabled', True) + motor.set_config('motor', autotune_object.motor) + motor.set_config('voltage', autotune_object.voltage) + else: + motor.set_config('autotune_enabled', False) + + def _parse_tmc_registers(self, motor: Motor, tmc: Any) -> None: + # The TMCCommandHelper isn't a direct member of the TMC object... but we can still get it this way + tmc_cmdhelper = tmc.get_status.__self__ + + for register in RELEVANT_TMC_REGISTERS: + # value = tmc_cmdhelper.read_register(register) + # motor.set_register(register, value) + + val = tmc_cmdhelper.fields.registers.get(register) + if (val is not None) and (register not in tmc_cmdhelper.read_registers): + # write-only register + fields_string = self._extract_register_values(register, val) + elif register in tmc_cmdhelper.read_registers: + # readable register + val = tmc_cmdhelper.mcu_tmc.get_register(register) + if tmc_cmdhelper.read_translate is not None: + register, val = tmc_cmdhelper.read_translate(register, val) + fields_string = self._extract_register_values(register, val) + + motor.set_register(register, fields_string) + + def _extract_register_values(self, tmc_cmdhelper, register, val): + # Provide a dictionary of register values + reg_fields = tmc_cmdhelper.fields.all_fields.get(register, {}) + reg_fields = sorted([(mask, name) for name, mask in reg_fields.items()]) + fields = {} + for mask, field_name in reg_fields: + field_value = tmc_cmdhelper.fields.get_field(field_name, val, register) + fields[field_name] = field_value + return fields + + # Find and return the motor by its name + def get_motor(self, motor_name: str) -> Optional[Motor]: + for motor in self._motors: + if motor._name == motor_name: + return motor + return None + + # Get all the motor list at once + def get_motors(self) -> List[Motor]: + return self._motors diff --git a/shaketune/measurement/static_freq.py b/shaketune/measurement/static_freq.py index b6bbf12..7bc41b1 100644 --- a/shaketune/measurement/static_freq.py +++ b/shaketune/measurement/static_freq.py @@ -5,7 +5,7 @@ from . import AXIS_CONFIG from .resonance_test import vibrate_axis -def excitate_axis_at_freq(gcmd, printer, gcode) -> None: +def excitate_axis_at_freq(gcmd, gcode, printer) -> None: freq = gcmd.get_int('FREQUENCY', default=25, minval=1) duration = gcmd.get_int('DURATION', default=10, minval=1) accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) diff --git a/shaketune/measurement/vibrations_profile.py b/shaketune/measurement/vibrations_profile.py new file mode 100644 index 0000000..f580d9f --- /dev/null +++ b/shaketune/measurement/vibrations_profile.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 + + +import math + +from ..helpers.console_output import ConsoleOutput +from ..shaketune_thread import ShakeTuneThread +from .accelerometer import Accelerometer +from .motorsconfigparser import MotorsConfigParser + +MIN_SPEED = 2 # mm/s + + +def create_vibrations_profile(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: + size = gcmd.get_float('SIZE', default=100.0, minval=50.0) + z_height = gcmd.get_float('Z_HEIGHT', default=20.0) + max_speed = gcmd.get_float('MAX_SPEED', default=200.0, minval=10.0) + speed_increment = gcmd.get_float('SPEED_INCREMENT', default=2.0, minval=1.0) + accel = gcmd.get_int('ACCEL', default=3000, minval=100) + feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) + accel_chip = gcmd.get('ACCEL_CHIP', default=None) + + if (size / (max_speed / 60)) < 0.25: + gcmd.error('The size of the movement is too small for the given speed! Increase SIZE or decrease MAX_SPEED!') + + # Check that input shaper is already configured + input_shaper = printer.lookup_object('input_shaper', None) + if input_shaper is None: + gcmd.error('Input shaper is not configured! Please run the shaper calibration macro first.') + + # TODO: Add the kinematics check to define the main_angles + # but this needs to retrieve it from the printer configuration + # {% if kinematics == "cartesian" %} + # # Cartesian motors are on X and Y axis directly + # RESPOND MSG="Cartesian kinematics mode" + # {% set main_angles = [0, 90] %} + # {% elif kinematics == "corexy" %} + # # CoreXY motors are on A and B axis (45 and 135 degrees) + # RESPOND MSG="CoreXY kinematics mode" + # {% set main_angles = [45, 135] %} + # {% else %} + # { action_raise_error("Only Cartesian and CoreXY kinematics are supported at the moment for the vibrations measurement tool!") } + # {% endif %} + kinematics = 'cartesian' + main_angles = [0, 90] + + 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') + + 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 + X, Y, _, E = toolhead.get_position() + + # Going to the start position + toolhead.move([X, Y, z_height, E], feedrate_travel / 10) + toolhead.move([mid_x - 15, mid_y - 15, z_height, E], feedrate_travel) + toolhead.dwell(0.5) + + nb_speed_samples = int((max_speed - MIN_SPEED) / speed_increment + 1) + for curr_angle in main_angles: + radian_angle = math.radians(curr_angle) + + # Find the best accelerometer chip for the current angle if not specified + if curr_angle == 0: + accel_axis = 'x' + elif curr_angle == 90: + accel_axis = 'y' + else: + accel_axis = 'xy' + if accel_chip is None: + accel_chip = Accelerometer.find_axis_accelerometer(printer, accel_axis) + if accel_chip is None: + gcmd.error( + 'No accelerometer specified for measurement! Multi-accelerometer configurations are not supported for this macro.' + ) + accelerometer = Accelerometer(printer.lookup_object(accel_chip)) + + # Sweep the speed range to record the vibrations at different speeds + for curr_speed_sample in range(nb_speed_samples): + curr_speed = MIN_SPEED + curr_speed_sample * speed_increment + + # Reduce the segments length for the lower speed range (0-100mm/s). The minimum length is 1/3 of the SIZE and is gradually increased + # to the nominal SIZE at 100mm/s. No further size changes are made above this speed. The goal is to ensure that the print head moves + # enough to collect enough data for vibration analysis, without doing unnecessary distance to save time. At higher speeds, the full + # segments lengths are used because the head moves faster and travels more distance in the same amount of time and we want enough data + if curr_speed < 100: + segment_length_multiplier = 1 / 5 + 4 / 5 * curr_speed / 100 + else: + segment_length_multiplier = 1 + + # Calculate angle coordinates using trigonometry and length multiplier and move to start point + dX = (size / 2) * math.cos(radian_angle) * segment_length_multiplier + dY = (size / 2) * math.sin(radian_angle) * segment_length_multiplier + toolhead.move([mid_x - dX, mid_y - dY, z_height, E], feedrate_travel) + + # Adjust the number of back and forth movements based on speed to also save time on lower speed range + # 3 movements are done by default, reduced to 2 between 150-250mm/s and to 1 under 150mm/s. + movements = 3 + if curr_speed < 150: + movements = 1 + elif curr_speed < 250: + movements = 2 + + # Back and forth movements to record the vibrations at constant speed in both direction + accelerometer.start_measurement() + for _ in range(movements): + toolhead.move([mid_x + dX, mid_y + dY, z_height, E], curr_speed) + toolhead.move([mid_x - dX, mid_y - dY, z_height, E], curr_speed) + name = f'vib_an{curr_angle:.2f}sp{curr_speed:.2f}'.replace('.', '_') + accelerometer.stop_measurement(name) + + toolhead.dwell(0.3) + toolhead.wait_moves() + + # 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() + + # Get the motors and TMC configurations from Klipper + motors_config_parser = MotorsConfigParser(printer, motors=['stepper_x', 'stepper_y']) + + # Run post-processing + ConsoleOutput.print('Machine vibrations profile generation...') + ConsoleOutput.print('This may take some time (5-8min)') + creator = st_thread.get_graph_creator() + creator.configure(kinematics, accel, motors_config_parser) + st_thread.run() diff --git a/shaketune/post_processing/graph_creator.py b/shaketune/post_processing/graph_creator.py index 4902d37..d8af166 100644 --- a/shaketune/post_processing/graph_creator.py +++ b/shaketune/post_processing/graph_creator.py @@ -11,7 +11,7 @@ from matplotlib.figure import Figure from ..helpers import filemanager as fm from ..helpers.console_output import ConsoleOutput -from ..helpers.motorlogparser import MotorLogParser +from ..measurement.motorsconfigparser import MotorsConfigParser from ..shaketune_config import ShakeTuneConfig from .analyze_axesmap import axesmap_calibration from .graph_belts import belts_calibration @@ -142,9 +142,9 @@ class ShaperGraphCreator(GraphCreator): raise ValueError('scv must be set to create the input shaper graph!') lognames = self._move_and_prepare_files( - glob_pattern='raw_data*.csv', + glob_pattern='shaketune-axis_*.csv', min_files_required=1, - custom_name_func=lambda f: f.stem.split('_')[3].upper(), + custom_name_func=lambda f: f.stem.split('_')[1].upper(), ) fig = shaper_calibration( lognames=[str(path) for path in lognames], @@ -175,18 +175,14 @@ class VibrationsGraphCreator(GraphCreator): 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: + def configure(self, kinematics: str, accel: float, motor_config_parser: MotorsConfigParser) -> 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() + 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' @@ -195,13 +191,13 @@ class VibrationsGraphCreator(GraphCreator): 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: + 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=f'{self._chip_name}-*.csv', + glob_pattern='shaketune-vib_*.csv', min_files_required=None, - custom_name_func=lambda f: f.name.replace(self._chip_name, self._type), + custom_name_func=lambda f: f.name, ) fig = vibrations_profile( lognames=[str(path) for path in lognames], @@ -236,11 +232,9 @@ class AxesMapFinder(GraphCreator): self._folder = config.get_results_folder() self._accel = None - self._chip_name = None - def configure(self, accel: int, chip_name: str) -> None: + def configure(self, accel: int) -> None: self._accel = accel - self._chip_name = chip_name def find_axesmap(self) -> None: tmp_folder = Path('/tmp') diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py index 1c6ffe5..6999bd2 100644 --- a/shaketune/shaketune.py +++ b/shaketune/shaketune.py @@ -4,8 +4,14 @@ from pathlib import Path from .helpers.console_output import ConsoleOutput -from .measurement import axes_map_calibration, axes_shaper_calibration, compare_belts_responses, excitate_axis_at_freq -from .post_processing import AxesMapFinder, BeltsGraphCreator, ShaperGraphCreator +from .measurement import ( + axes_map_calibration, + axes_shaper_calibration, + compare_belts_responses, + create_vibrations_profile, + excitate_axis_at_freq, +) +from .post_processing import AxesMapFinder, BeltsGraphCreator, ShaperGraphCreator, VibrationsGraphCreator from .shaketune_config import ShakeTuneConfig from .shaketune_thread import ShakeTuneThread @@ -35,6 +41,11 @@ class ShakeTune: self.cmd_EXCITATE_AXIS_AT_FREQ, desc=self.cmd_EXCITATE_AXIS_AT_FREQ_help, ) + self._gcode.register_command( + 'AXES_MAP_CALIBRATION', + self.cmd_AXES_MAP_CALIBRATION, + desc=self.cmd_AXES_MAP_CALIBRATION_help, + ) self._gcode.register_command( 'COMPARE_BELTS_RESPONSES', self.cmd_COMPARE_BELTS_RESPONSES, @@ -46,9 +57,9 @@ class ShakeTune: 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, + 'CREATE_VIBRATIONS_PROFILE', + self.cmd_CREATE_VIBRATIONS_PROFILE, + desc=self.cmd_CREATE_VIBRATIONS_PROFILE_help, ) cmd_EXCITATE_AXIS_AT_FREQ_help = ( @@ -57,7 +68,15 @@ 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._gcode) + excitate_axis_at_freq(gcmd, self._gcode, self._printer) + + 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) cmd_COMPARE_BELTS_RESPONSES_help = 'Perform a custom half-axis test to analyze and compare the frequency profiles of individual belts on CoreXY printers' @@ -77,10 +96,10 @@ class ShakeTune: 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' + cmd_CREATE_VIBRATIONS_PROFILE_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: + def cmd_CREATE_VIBRATIONS_PROFILE(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) + vibration_profile_creator = VibrationsGraphCreator(self._config) + st_thread = ShakeTuneThread(self._config, vibration_profile_creator, self._printer.get_reactor(), self.timeout) + create_vibrations_profile(gcmd, self._gcode, self._printer, st_thread) diff --git a/shaketune/shaketune_config.py b/shaketune/shaketune_config.py index bf0e96d..057900a 100644 --- a/shaketune/shaketune_config.py +++ b/shaketune/shaketune_config.py @@ -51,81 +51,3 @@ class ShakeTuneConfig: 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) From 55895c150785779b16b59bff3b95f0acef2085c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Thu, 16 May 2024 23:33:49 +0200 Subject: [PATCH 6/6] fixed most of the bugs now as a Klipper plugin --- shaketune/helpers/common_func.py | 8 +++ shaketune/measurement/__init__.py | 17 ------ shaketune/measurement/axes_input_shaper.py | 10 ++-- shaketune/measurement/axes_map.py | 9 ++- shaketune/measurement/belts_comparison.py | 10 ++-- shaketune/measurement/macros.cfg | 8 +++ shaketune/measurement/motorsconfigparser.py | 60 +++++++++---------- shaketune/measurement/static_freq.py | 8 ++- shaketune/measurement/vibrations_profile.py | 46 +++++++------- shaketune/post_processing/analyze_axesmap.py | 3 +- shaketune/post_processing/graph_creator.py | 4 +- shaketune/post_processing/graph_vibrations.py | 14 ++--- shaketune/shaketune.py | 25 ++++---- 13 files changed, 116 insertions(+), 106 deletions(-) create mode 100644 shaketune/measurement/macros.cfg diff --git a/shaketune/helpers/common_func.py b/shaketune/helpers/common_func.py index 6546347..b908da2 100644 --- a/shaketune/helpers/common_func.py +++ b/shaketune/helpers/common_func.py @@ -14,6 +14,14 @@ from scipy.signal import spectrogram from .console_output import ConsoleOutput +# Constant used to define the standard axis direction and names +AXIS_CONFIG = [ + {'axis': 'x', 'direction': (1, 0, 0), 'label': 'axis_X'}, + {'axis': 'y', 'direction': (0, 1, 0), 'label': 'axis_Y'}, + {'axis': 'a', 'direction': (1, -1, 0), 'label': 'belt_A'}, + {'axis': 'b', 'direction': (1, 1, 0), 'label': 'belt_B'}, +] + def parse_log(logname): try: diff --git a/shaketune/measurement/__init__.py b/shaketune/measurement/__init__.py index 8cffad7..72d968b 100644 --- a/shaketune/measurement/__init__.py +++ b/shaketune/measurement/__init__.py @@ -5,20 +5,3 @@ 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 - -AXIS_CONFIG = [ - {'axis': 'x', 'direction': (1, 0, 0), 'label': 'axis_X'}, - {'axis': 'y', 'direction': (0, 1, 0), 'label': 'axis_Y'}, - {'axis': 'a', 'direction': (1, -1, 0), 'label': 'belt_A'}, - {'axis': 'b', 'direction': (1, 1, 0), 'label': 'belt_B'}, -] - -# 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/measurement/axes_input_shaper.py b/shaketune/measurement/axes_input_shaper.py index d25209d..48b4d64 100644 --- a/shaketune/measurement/axes_input_shaper.py +++ b/shaketune/measurement/axes_input_shaper.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 +from ..helpers.common_func import AXIS_CONFIG from ..helpers.console_output import ConsoleOutput from ..shaketune_thread import ShakeTuneThread -from . import AXIS_CONFIG from .accelerometer import Accelerometer from .resonance_test import vibrate_axis -def axes_shaper_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: +def axes_shaper_calibration(gcmd, config, 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) @@ -21,9 +21,11 @@ def axes_shaper_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) - systime = printer.get_reactor().monotonic() + printer = config.get_printer() + gcode = printer.lookup_object('gcode') toolhead = printer.lookup_object('toolhead') res_tester = printer.lookup_object('resonance_tester') + systime = printer.get_reactor().monotonic() if scv is None: toolhead_info = toolhead.get_status(systime) @@ -92,7 +94,7 @@ def axes_shaper_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> accelerometer.stop_measurement(config['label'], append_time=True) # And finally generate the graph for each measured axis - ConsoleOutput.print(f'{config['axis'].upper()} axis frequency profile generation...') + ConsoleOutput.print(f'{config["axis"].upper()} axis frequency profile generation...') ConsoleOutput.print('This may take some time (1-3min)') st_thread.run() diff --git a/shaketune/measurement/axes_map.py b/shaketune/measurement/axes_map.py index bbf0dc5..c386976 100644 --- a/shaketune/measurement/axes_map.py +++ b/shaketune/measurement/axes_map.py @@ -6,13 +6,18 @@ from ..shaketune_thread import ShakeTuneThread from .accelerometer import Accelerometer -def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: +def axes_map_calibration(gcmd, config, 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) + printer = config.get_printer() + gcode = printer.lookup_object('gcode') + toolhead = printer.lookup_object('toolhead') + systime = printer.get_reactor().monotonic() + if accel_chip is None: accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy') if accel_chip is None: @@ -21,8 +26,6 @@ def axes_map_calibration(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> No ) accelerometer = Accelerometer(printer.lookup_object(accel_chip)) - 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'] diff --git a/shaketune/measurement/belts_comparison.py b/shaketune/measurement/belts_comparison.py index 3e3a546..e0d312d 100644 --- a/shaketune/measurement/belts_comparison.py +++ b/shaketune/measurement/belts_comparison.py @@ -1,14 +1,14 @@ #!/usr/bin/env python3 +from ..helpers.common_func import AXIS_CONFIG from ..helpers.console_output import ConsoleOutput from ..shaketune_thread import ShakeTuneThread -from . import AXIS_CONFIG from .accelerometer import Accelerometer from .resonance_test import vibrate_axis -def compare_belts_responses(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: +def compare_belts_responses(gcmd, config, st_thread: ShakeTuneThread) -> None: min_freq = gcmd.get_float('FREQ_START', default=5.0, 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.0, minval=1) @@ -16,9 +16,11 @@ def compare_belts_responses(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> feedrate_travel = gcmd.get_float('TRAVEL_SPEED', default=120.0, minval=20.0) z_height = gcmd.get_float('Z_HEIGHT', default=None, minval=1) - systime = printer.get_reactor().monotonic() + printer = config.get_printer() + gcode = printer.lookup_object('gcode') toolhead = printer.lookup_object('toolhead') res_tester = printer.lookup_object('resonance_tester') + systime = printer.get_reactor().monotonic() accel_chip = Accelerometer.find_axis_accelerometer(printer, 'xy') if accel_chip is None: @@ -68,7 +70,7 @@ def compare_belts_responses(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> input_shaper = None # Filter axis configurations to get the A and B axis only - filtered_config = [a for a in AXIS_CONFIG if a['axis'] in ('x', 'y')] + filtered_config = [a for a in AXIS_CONFIG if a['axis'] in ('a', 'b')] for config in filtered_config: accelerometer.start_measurement() vibrate_axis(toolhead, gcode, config['direction'], min_freq, max_freq, hz_per_sec, accel_per_hz) diff --git a/shaketune/measurement/macros.cfg b/shaketune/measurement/macros.cfg new file mode 100644 index 0000000..c34a9d9 --- /dev/null +++ b/shaketune/measurement/macros.cfg @@ -0,0 +1,8 @@ + +# [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 diff --git a/shaketune/measurement/motorsconfigparser.py b/shaketune/measurement/motorsconfigparser.py index 3dab656..4da86ba 100644 --- a/shaketune/measurement/motorsconfigparser.py +++ b/shaketune/measurement/motorsconfigparser.py @@ -4,8 +4,7 @@ # from the Klipper configuration and the TMC registers # Written by Frix_x#0161 # -import re -from typing import Any, Dict, List, Optional, Tuple +from typing import Any, Dict, List, Optional TRINAMIC_DRIVERS = ['tmc2130', 'tmc2208', 'tmc2209', 'tmc2240', 'tmc2660', 'tmc5160'] MOTORS = ['stepper_x', 'stepper_y', 'stepper_x1', 'stepper_y1', 'stepper_z', 'stepper_z1', 'stepper_z2', 'stepper_z3'] @@ -17,25 +16,20 @@ class Motor: self.name: str = name self._registers: Dict[str, Dict[str, Any]] = {} self._config: Dict[str, Any] = {} - self._driver: Tuple[str, Any] = ('', None) - - def set_driver(self, driver_name: str, tmc_object: Any) -> None: - self._driver = (driver_name, tmc_object) - - def get_driver(self) -> Tuple[str, Any]: - return self._driver def set_register(self, register: str, value_dict: dict) -> None: + # First we filter out entries with a value of 0 to avoid having too much uneeded data + value_dict = {k: v for k, v in value_dict.items() if v != 0} + # Special parsing for CHOPCONF to extract meaningful values if register == 'CHOPCONF': - # Add intpol=0 if missing from the register dump - if 'intpol=' not in value_dict: + # Add intpol=0 if missing from the register dump to force printing it as it's important + if 'intpol' not in value_dict: value_dict['intpol'] = '0' - # Simplify the microstep resolution format + # Remove the microsteps entry as the format here is not easy to read and + # it's already read in the correct format directly from the Klipper config if 'mres' in value_dict: - mres_match = re.search(r'(\d+)usteps', value_dict['mres']) - if mres_match: - value_dict['mres'] = mres_match.group(1) + del value_dict['mres'] # Special parsing for CHOPCONF to avoid pwm_ before each values if register == 'PWMCONF': @@ -46,7 +40,7 @@ class Motor: new_value_dict[key] = val value_dict = new_value_dict - # Then fill the registers while merging all the thresholds into the same THRS virtual register + # Then gets merged all the thresholds into the same THRS virtual register if register in ['TPWMTHRS', 'TCOOLTHRS']: existing_thrs = self._registers.get('THRS', {}) merged_values = {**existing_thrs, **value_dict} @@ -110,35 +104,42 @@ class Motor: class MotorsConfigParser: - def __init__(self, printer, motors: List[str] = MOTORS, drivers: List[str] = TRINAMIC_DRIVERS): + def __init__(self, config, motors: List[str] = MOTORS, drivers: List[str] = TRINAMIC_DRIVERS): + self._printer = config.get_printer() + self._motors: List[Motor] = [] - self._printer = printer for motor_name in motors: for driver in drivers: - tmc_object = printer.lookup_object(f'{driver} {motor_name}', None) + tmc_object = self._printer.lookup_object(f'{driver} {motor_name}', None) if tmc_object is None: continue motor = self._create_motor(motor_name, driver, tmc_object) self._motors.append(motor) + pconfig = self._printer.lookup_object('configfile') + self.kinematics = pconfig.status_raw_config['printer']['kinematics'] + # Create a Motor object with the given name, driver and TMC object # and fill it with the relevant configuration and registers def _create_motor(self, motor_name: str, driver: str, tmc_object: Any) -> Motor: motor = Motor(motor_name) - motor.set_driver(driver.upper(), tmc_object) + motor.set_config('tmc', driver) self._parse_klipper_config(motor, tmc_object) self._parse_tmc_registers(motor, tmc_object) return motor - def _parse_klipper_config(self, motor: Motor, tmc: Any) -> None: + def _parse_klipper_config(self, motor: Motor, tmc_object: Any) -> None: # The TMCCommandHelper isn't a direct member of the TMC object... but we can still get it this way - tmc_cmdhelper = tmc.get_status.__self__ + tmc_cmdhelper = tmc_object.get_status.__self__ motor_currents = tmc_cmdhelper.current_helper.get_current() motor.set_config('run_current', motor_currents[0]) motor.set_config('hold_current', motor_currents[1]) + pconfig = self._printer.lookup_object('configfile') + motor.set_config('microsteps', int(pconfig.status_raw_config[motor.name]['microsteps'])) + autotune_object = self._printer.lookup_object(f'autotune_tmc {motor.name}', None) if autotune_object is not None: motor.set_config('autotune_enabled', True) @@ -147,24 +148,21 @@ class MotorsConfigParser: else: motor.set_config('autotune_enabled', False) - def _parse_tmc_registers(self, motor: Motor, tmc: Any) -> None: + def _parse_tmc_registers(self, motor: Motor, tmc_object: Any) -> None: # The TMCCommandHelper isn't a direct member of the TMC object... but we can still get it this way - tmc_cmdhelper = tmc.get_status.__self__ + tmc_cmdhelper = tmc_object.get_status.__self__ for register in RELEVANT_TMC_REGISTERS: - # value = tmc_cmdhelper.read_register(register) - # motor.set_register(register, value) - val = tmc_cmdhelper.fields.registers.get(register) if (val is not None) and (register not in tmc_cmdhelper.read_registers): # write-only register - fields_string = self._extract_register_values(register, val) + fields_string = self._extract_register_values(tmc_cmdhelper, register, val) elif register in tmc_cmdhelper.read_registers: # readable register val = tmc_cmdhelper.mcu_tmc.get_register(register) if tmc_cmdhelper.read_translate is not None: register, val = tmc_cmdhelper.read_translate(register, val) - fields_string = self._extract_register_values(register, val) + fields_string = self._extract_register_values(tmc_cmdhelper, register, val) motor.set_register(register, fields_string) @@ -173,7 +171,7 @@ class MotorsConfigParser: reg_fields = tmc_cmdhelper.fields.all_fields.get(register, {}) reg_fields = sorted([(mask, name) for name, mask in reg_fields.items()]) fields = {} - for mask, field_name in reg_fields: + for _, field_name in reg_fields: field_value = tmc_cmdhelper.fields.get_field(field_name, val, register) fields[field_name] = field_value return fields @@ -181,7 +179,7 @@ class MotorsConfigParser: # Find and return the motor by its name def get_motor(self, motor_name: str) -> Optional[Motor]: for motor in self._motors: - if motor._name == motor_name: + if motor.name == motor_name: return motor return None diff --git a/shaketune/measurement/static_freq.py b/shaketune/measurement/static_freq.py index 7bc41b1..1dbf188 100644 --- a/shaketune/measurement/static_freq.py +++ b/shaketune/measurement/static_freq.py @@ -1,11 +1,11 @@ #!/usr/bin/env python3 +from ..helpers.common_func import AXIS_CONFIG from ..helpers.console_output import ConsoleOutput -from . import AXIS_CONFIG from .resonance_test import vibrate_axis -def excitate_axis_at_freq(gcmd, gcode, printer) -> None: +def excitate_axis_at_freq(gcmd, config) -> None: freq = gcmd.get_int('FREQUENCY', default=25, minval=1) duration = gcmd.get_int('DURATION', default=10, minval=1) accel_per_hz = gcmd.get_float('ACCEL_PER_HZ', default=None) @@ -19,9 +19,11 @@ def excitate_axis_at_freq(gcmd, gcode, printer) -> None: ConsoleOutput.print(f'Excitating {axis.upper()} axis at {freq}Hz for {duration} seconds') - systime = printer.get_reactor().monotonic() + printer = config.get_printer() + gcode = printer.lookup_object('gcode') toolhead = printer.lookup_object('toolhead') res_tester = printer.lookup_object('resonance_tester') + systime = printer.get_reactor().monotonic() if accel_per_hz is None: accel_per_hz = res_tester.test.accel_per_hz diff --git a/shaketune/measurement/vibrations_profile.py b/shaketune/measurement/vibrations_profile.py index f580d9f..31e748d 100644 --- a/shaketune/measurement/vibrations_profile.py +++ b/shaketune/measurement/vibrations_profile.py @@ -11,7 +11,7 @@ from .motorsconfigparser import MotorsConfigParser MIN_SPEED = 2 # mm/s -def create_vibrations_profile(gcmd, gcode, printer, st_thread: ShakeTuneThread) -> None: +def create_vibrations_profile(gcmd, config, st_thread: ShakeTuneThread) -> None: size = gcmd.get_float('SIZE', default=100.0, minval=50.0) z_height = gcmd.get_float('Z_HEIGHT', default=20.0) max_speed = gcmd.get_float('MAX_SPEED', default=200.0, minval=10.0) @@ -23,29 +23,30 @@ def create_vibrations_profile(gcmd, gcode, printer, st_thread: ShakeTuneThread) if (size / (max_speed / 60)) < 0.25: gcmd.error('The size of the movement is too small for the given speed! Increase SIZE or decrease MAX_SPEED!') - # Check that input shaper is already configured + printer = config.get_printer() + gcode = printer.lookup_object('gcode') + toolhead = printer.lookup_object('toolhead') input_shaper = printer.lookup_object('input_shaper', None) + systime = printer.get_reactor().monotonic() + + # Check that input shaper is already configured if input_shaper is None: gcmd.error('Input shaper is not configured! Please run the shaper calibration macro first.') - # TODO: Add the kinematics check to define the main_angles - # but this needs to retrieve it from the printer configuration - # {% if kinematics == "cartesian" %} - # # Cartesian motors are on X and Y axis directly - # RESPOND MSG="Cartesian kinematics mode" - # {% set main_angles = [0, 90] %} - # {% elif kinematics == "corexy" %} - # # CoreXY motors are on A and B axis (45 and 135 degrees) - # RESPOND MSG="CoreXY kinematics mode" - # {% set main_angles = [45, 135] %} - # {% else %} - # { action_raise_error("Only Cartesian and CoreXY kinematics are supported at the moment for the vibrations measurement tool!") } - # {% endif %} - kinematics = 'cartesian' - main_angles = [0, 90] + motors_config_parser = MotorsConfigParser(config, motors=['stepper_x', 'stepper_y']) + + if motors_config_parser.kinematics == 'cartesian' or motors_config_parser.kinematics == 'corexz': + # Cartesian motors are on X and Y axis directly, same for CoreXZ + main_angles = [0, 90] + elif motors_config_parser.kinematics == 'corexy': + # CoreXY motors are on A and B axis (45 and 135 degrees) + main_angles = [45, 135] + else: + gcmd.error( + 'Only Cartesian and CoreXY kinematics are supported at the moment for the vibrations measurement tool!' + ) + ConsoleOutput.print(f'{motors_config_parser.kinematics.upper()} kinematics mode') - 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'] @@ -66,6 +67,7 @@ def create_vibrations_profile(gcmd, gcode, printer, st_thread: ShakeTuneThread) nb_speed_samples = int((max_speed - MIN_SPEED) / speed_increment + 1) for curr_angle in main_angles: + ConsoleOutput.print(f'-> Measuring angle: {curr_angle} degrees...') radian_angle = math.radians(curr_angle) # Find the best accelerometer chip for the current angle if not specified @@ -86,6 +88,7 @@ def create_vibrations_profile(gcmd, gcode, printer, st_thread: ShakeTuneThread) # Sweep the speed range to record the vibrations at different speeds for curr_speed_sample in range(nb_speed_samples): curr_speed = MIN_SPEED + curr_speed_sample * speed_increment + ConsoleOutput.print(f'Current speed: {curr_speed} mm/s') # Reduce the segments length for the lower speed range (0-100mm/s). The minimum length is 1/3 of the SIZE and is gradually increased # to the nominal SIZE at 100mm/s. No further size changes are made above this speed. The goal is to ensure that the print head moves @@ -126,12 +129,9 @@ def create_vibrations_profile(gcmd, gcode, printer, st_thread: ShakeTuneThread) ) toolhead.wait_moves() - # Get the motors and TMC configurations from Klipper - motors_config_parser = MotorsConfigParser(printer, motors=['stepper_x', 'stepper_y']) - # Run post-processing ConsoleOutput.print('Machine vibrations profile generation...') ConsoleOutput.print('This may take some time (5-8min)') creator = st_thread.get_graph_creator() - creator.configure(kinematics, accel, motors_config_parser) + creator.configure(motors_config_parser.kinematics, accel, motors_config_parser) st_thread.run() diff --git a/shaketune/post_processing/analyze_axesmap.py b/shaketune/post_processing/analyze_axesmap.py index 1a818d9..4c094a3 100644 --- a/shaketune/post_processing/analyze_axesmap.py +++ b/shaketune/post_processing/analyze_axesmap.py @@ -109,7 +109,8 @@ def axesmap_calibration(lognames, accel=None): axes_map = ','.join([f'{spike[0][0]}{spike[1]}' for spike in spikes_sorted]) # alignment_error, sensitivity_error = compute_errors(filtered_data, spikes_sorted, accel, NUM_POINTS) - results = f'Detected axes_map:\n {axes_map}\n' + results = f'Be aware that this macro is experimental and has been known to sometimes produce incorrect results. Use it with caution and always check the results!\n' + results += f'Detected axes_map:\n {axes_map}\n' # TODO: work on this function that is currently not giving good results... # results += "Accelerometer angle deviation:\n" diff --git a/shaketune/post_processing/graph_creator.py b/shaketune/post_processing/graph_creator.py index d8af166..9316bcb 100644 --- a/shaketune/post_processing/graph_creator.py +++ b/shaketune/post_processing/graph_creator.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import abc +import re import shutil import tarfile from datetime import datetime @@ -189,6 +190,7 @@ class VibrationsGraphCreator(GraphCreator): 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: @@ -197,7 +199,7 @@ class VibrationsGraphCreator(GraphCreator): lognames = self._move_and_prepare_files( glob_pattern='shaketune-vib_*.csv', min_files_required=None, - custom_name_func=lambda f: f.name, + 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], diff --git a/shaketune/post_processing/graph_vibrations.py b/shaketune/post_processing/graph_vibrations.py index 05bcf7e..48c32a9 100644 --- a/shaketune/post_processing/graph_vibrations.py +++ b/shaketune/post_processing/graph_vibrations.py @@ -564,23 +564,23 @@ def plot_motor_config_txt(fig, motors, differences): motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')] distance = 0.12 - if motors[0].get_property('autotune_enabled'): - distance = 0.24 + if motors[0].get_config('autotune_enabled'): + distance = 0.27 config_blocks = [ - f"| {lbl}: {mot.get_property('motor').upper()} on {mot.get_property('tmc').upper()} @ {mot.get_property('voltage')}V {mot.get_property('run_current')}A" + f"| {lbl}: {mot.get_config('motor').upper()} on {mot.get_config('tmc').upper()} @ {mot.get_config('voltage'):0.1f}V {mot.get_config('run_current'):0.2f}A - {mot.get_config('microsteps')}usteps" for mot, lbl in motor_details ] config_blocks.append('| TMC Autotune enabled') else: config_blocks = [ - f"| {lbl}: {mot.get_property('tmc').upper()} @ {mot.get_property('run_current')}A" + f"| {lbl}: {mot.get_config('tmc').upper()} @ {mot.get_config('run_current'):0.2f}A - {mot.get_config('microsteps')}usteps" for mot, lbl in motor_details ] config_blocks.append('| TMC Autotune not detected') for idx, block in enumerate(config_blocks): fig.text( - 0.40, 0.990 - 0.015 * idx, block, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'] + 0.41, 0.990 - 0.015 * idx, block, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'] ) tmc_registers = motors[0].get_registers() @@ -589,7 +589,7 @@ def plot_motor_config_txt(fig, motors, differences): settings_str = ' '.join(f'{k}={v}' for k, v in settings.items()) tmc_block = f'| {register.upper()}: {settings_str}' fig.text( - 0.40 + distance, + 0.41 + distance, 0.990 - 0.015 * idx, tmc_block, ha='left', @@ -601,7 +601,7 @@ def plot_motor_config_txt(fig, motors, differences): if differences is not None: differences_text = f'| Y motor diff: {differences}' fig.text( - 0.40 + distance, + 0.41 + distance, 0.990 - 0.015 * (idx + 1), differences_text, ha='left', diff --git a/shaketune/shaketune.py b/shaketune/shaketune.py index 6999bd2..f7d058b 100644 --- a/shaketune/shaketune.py +++ b/shaketune/shaketune.py @@ -18,8 +18,9 @@ from .shaketune_thread import ShakeTuneThread class ShakeTune: def __init__(self, config) -> None: + self._pconfig = config self._printer = config.get_printer() - self._gcode = self._printer.lookup_object('gcode') + gcode = self._printer.lookup_object('gcode') res_tester = self._printer.lookup_object('resonance_tester') if res_tester is None: @@ -34,29 +35,29 @@ class ShakeTune: 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) + ConsoleOutput.register_output_callback(gcode.respond_info) - self._gcode.register_command( + 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( + gcode.register_command( 'AXES_MAP_CALIBRATION', self.cmd_AXES_MAP_CALIBRATION, desc=self.cmd_AXES_MAP_CALIBRATION_help, ) - self._gcode.register_command( + gcode.register_command( 'COMPARE_BELTS_RESPONSES', self.cmd_COMPARE_BELTS_RESPONSES, desc=self.cmd_COMPARE_BELTS_RESPONSES_help, ) - self._gcode.register_command( + gcode.register_command( 'AXES_SHAPER_CALIBRATION', self.cmd_AXES_SHAPER_CALIBRATION, desc=self.cmd_AXES_SHAPER_CALIBRATION_help, ) - self._gcode.register_command( + gcode.register_command( 'CREATE_VIBRATIONS_PROFILE', self.cmd_CREATE_VIBRATIONS_PROFILE, desc=self.cmd_CREATE_VIBRATIONS_PROFILE_help, @@ -68,7 +69,7 @@ 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._gcode, self._printer) + excitate_axis_at_freq(gcmd, self._pconfig) 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' @@ -76,7 +77,7 @@ class ShakeTune: 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) + axes_map_calibration(gcmd, self._pconfig, st_thread) cmd_COMPARE_BELTS_RESPONSES_help = 'Perform a custom half-axis test to analyze and compare the frequency profiles of individual belts on CoreXY printers' @@ -84,7 +85,7 @@ class ShakeTune: 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) + compare_belts_responses(gcmd, self._pconfig, 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' @@ -94,7 +95,7 @@ class ShakeTune: 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) + axes_shaper_calibration(gcmd, self._pconfig, st_thread) cmd_CREATE_VIBRATIONS_PROFILE_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' @@ -102,4 +103,4 @@ class ShakeTune: ConsoleOutput.print(f'Shake&Tune version: {ShakeTuneConfig.get_git_version()}') vibration_profile_creator = VibrationsGraphCreator(self._config) st_thread = ShakeTuneThread(self._config, vibration_profile_creator, self._printer.get_reactor(), self.timeout) - create_vibrations_profile(gcmd, self._gcode, self._printer, st_thread) + create_vibrations_profile(gcmd, self._pconfig, st_thread)