diff --git a/K-ShakeTune/K-SnT_axes_map.cfg b/K-ShakeTune/K-SnT_axes_map.cfg index 0be4b5d..d175e41 100644 --- a/K-ShakeTune/K-SnT_axes_map.cfg +++ b/K-ShakeTune/K-SnT_axes_map.cfg @@ -52,7 +52,7 @@ gcode: ACCELEROMETER_MEASURE CHIP={accel_chip} NAME=axemap RESPOND MSG="Analysis of the movements..." - RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type axesmap --accel {accel|int} --chip_name {accel_chip}" + 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} diff --git a/K-ShakeTune/K-SnT_axis.cfg b/K-ShakeTune/K-SnT_axis.cfg index 9cdb0a2..dadf02f 100644 --- a/K-ShakeTune/K-SnT_axis.cfg +++ b/K-ShakeTune/K-SnT_axis.cfg @@ -41,7 +41,7 @@ gcode: RESPOND MSG="X axis frequency profile generation..." RESPOND MSG="This may take some time (1-3min)" - RUN_SHELL_COMMAND CMD=shaketune 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}" + 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 %} @@ -50,5 +50,5 @@ gcode: RESPOND MSG="Y axis frequency profile generation..." RESPOND MSG="This may take some time (1-3min)" - RUN_SHELL_COMMAND CMD=shaketune 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}" + 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 index cd4987c..0c5a86a 100644 --- a/K-ShakeTune/K-SnT_belts.cfg +++ b/K-ShakeTune/K-SnT_belts.cfg @@ -20,4 +20,4 @@ gcode: RESPOND MSG="Belts comparative frequency profile generation..." RESPOND MSG="This may take some time (3-5min)" - RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type belts {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}" + SHAKETUNE_POSTPROCESS PARAMS="--type belts {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}" diff --git a/K-ShakeTune/K-SnT_vibrations.cfg b/K-ShakeTune/K-SnT_vibrations.cfg index a0a9ddd..d6ebacd 100644 --- a/K-ShakeTune/K-SnT_vibrations.cfg +++ b/K-ShakeTune/K-SnT_vibrations.cfg @@ -209,6 +209,6 @@ gcode: RESPOND MSG="Machine vibrations profile generation..." RESPOND MSG="This may take some time (3-5min)" - RUN_SHELL_COMMAND CMD=shaketune 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}" + 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/K-ShakeTune/shaketune_cmd.cfg b/K-ShakeTune/shaketune_cmd.cfg deleted file mode 100644 index 8891eda..0000000 --- a/K-ShakeTune/shaketune_cmd.cfg +++ /dev/null @@ -1,6 +0,0 @@ -[gcode_shell_command shaketune] -command: ~/printer_data/config/K-ShakeTune/shaketune.sh -timeout: 600.0 -verbose: True - -[respond] diff --git a/README.md b/README.md index 0fa696c..16eeb20 100644 --- a/README.md +++ b/README.md @@ -17,9 +17,6 @@ Check out the **[detailed documentation of the Shake&Tune module here](./docs/RE |:----------------:|:------------:|:---------------------:| | [](./docs/macros/belts_tuning.md) | [](./docs/macros/axis_tuning.md) | [](./docs/macros/vibrations_profile.md) | - > **Note**: - > - > Be aware that Shake&Tune uses the [Gcode shell command plugin](https://github.com/dw-0/kiauh/blob/master/docs/gcode_shell_command.md) under the hood to call the Python scripts that generate the graphs. While my scripts should be safe, the Gcode shell command plugin also has great potential for abuse if not used carefully for other purposes, since it opens shell access from Klipper. ## Installation @@ -31,6 +28,7 @@ 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] ``` diff --git a/install.sh b/install.sh index 7e57a13..bf280b8 100755 --- a/install.sh +++ b/install.sh @@ -3,9 +3,9 @@ USER_CONFIG_PATH="${HOME}/printer_data/config" MOONRAKER_CONFIG="${HOME}/printer_data/config/moonraker.conf" KLIPPER_PATH="${HOME}/klipper" +KLIPPER_VENV_PATH="${HOME}/klippy-env" K_SHAKETUNE_PATH="${HOME}/klippain_shaketune" -K_SHAKETUNE_VENV_PATH="${HOME}/klippain_shaketune-env" set -eu export LC_ALL=C @@ -39,7 +39,7 @@ function is_package_installed { } function install_package_requirements { - packages=("python3-venv" "libopenblas-dev" "libatlas-base-dev") + packages=("libopenblas-dev" "libatlas-base-dev") packages_to_install="" for package in "${packages[@]}"; do @@ -76,14 +76,12 @@ function check_download { } function setup_venv { - if [ ! -d "${K_SHAKETUNE_VENV_PATH}" ]; then - echo "[SETUP] Creating Python virtual environment..." - python3 -m venv "${K_SHAKETUNE_VENV_PATH}" - else - echo "[SETUP] Virtual environment already exists. Continuing..." + if [ ! -d "${KLIPPER_VENV_PATH}" ]; then + echo "[ERROR] Klipper's Python virtual environment not found!" + exit -1 fi - source "${K_SHAKETUNE_VENV_PATH}/bin/activate" + source "${KLIPPER_VENV_PATH}/bin/activate" echo "[SETUP] Installing/Updating K-Shake&Tune dependencies..." pip install --upgrade pip pip install -r "${K_SHAKETUNE_PATH}/requirements.txt" @@ -98,16 +96,17 @@ function link_extension { echo "[INSTALL] Klippain full installation found! Linking module to the script folder of Klippain" ln -frsn ${K_SHAKETUNE_PATH}/K-ShakeTune ${USER_CONFIG_PATH}/scripts/K-ShakeTune else + echo "[INSTALL] Klippain not found! Linking module to the config folder of Klipper" ln -frsn ${K_SHAKETUNE_PATH}/K-ShakeTune ${USER_CONFIG_PATH}/K-ShakeTune fi } -function link_gcodeshellcommandpy { - if [ ! -f "${KLIPPER_PATH}/klippy/extras/gcode_shell_command.py" ]; then - echo "[INSTALL] Downloading gcode_shell_command.py Klipper extension needed for this module" - wget -P ${KLIPPER_PATH}/klippy/extras https://raw.githubusercontent.com/Frix-x/klippain/main/scripts/gcode_shell_command.py +function link_module { + if [ ! -d "${KLIPPER_PATH}/klippy/extras/shaketune" ]; then + echo "[INSTALL] Linking Shake&Tune module to Klipper extras" + ln -frsn ${K_SHAKETUNE_PATH}/shaketune ${KLIPPER_PATH}/klippy/extras/shaketune else - printf "[INSTALL] gcode_shell_command.py Klipper extension is already installed. Continuing...\n\n" + printf "[INSTALL] Klippain Shake&Tune Klipper module is already installed. Continuing...\n\n" fi } @@ -140,7 +139,7 @@ preflight_checks check_download setup_venv link_extension +link_module add_updater -link_gcodeshellcommandpy restart_klipper restart_moonraker diff --git a/moonraker.conf b/moonraker.conf index 83c6acb..24a2552 100644 --- a/moonraker.conf +++ b/moonraker.conf @@ -4,7 +4,7 @@ type: git_repo origin: https://github.com/Frix-x/klippain-shaketune.git path: ~/klippain_shaketune -virtualenv: ~/klippain_shaketune-env +virtualenv: ~/klippy-env requirements: requirements.txt system_dependencies: system-dependencies.json primary_branch: main diff --git a/src/is_workflow.py b/shaketune/__init__.py old mode 100755 new mode 100644 similarity index 82% rename from src/is_workflow.py rename to shaketune/__init__.py index 5ae1370..f167f4b --- a/src/is_workflow.py +++ b/shaketune/__init__.py @@ -5,29 +5,30 @@ ############################################ # Written by Frix_x#0161 # -# This script is designed to be used with gcode_shell_commands directly from Klipper +# This script is designed to be run from inside Klipper Console # Use the provided Shake&Tune macros instead! 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, Optional +from typing import Callable, List, Optional -from git import GitCommandError, Repo from matplotlib.figure import Figure -import src.helpers.filemanager as fm -from src.graph_creators.analyze_axesmap import axesmap_calibration -from src.graph_creators.graph_belts import belts_calibration -from src.graph_creators.graph_shaper import shaper_calibration -from src.graph_creators.graph_vibrations import vibrations_profile -from src.helpers.locale_utils import print_with_c_locale -from src.helpers.motorlogparser import MotorLogParser +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: @@ -43,6 +44,8 @@ class Config: @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() @@ -54,11 +57,11 @@ class Config: version = repo.head.commit.hexsha[:7] # If no tag is found, use the simplified commit SHA instead return version except Exception as e: - print_with_c_locale(f'Warning: unable to retrieve Shake&Tune version number: {e}') + ConsoleOutput.print(f'Warning: unable to retrieve Shake&Tune version number: {e}') return 'unknown' @staticmethod - def parse_arguments() -> argparse.Namespace: + def parse_arguments(params: Optional[List] = None) -> argparse.Namespace: parser = argparse.ArgumentParser(description='Shake&Tune graphs generation script') parser.add_argument( '-t', @@ -131,7 +134,7 @@ class Config: 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() + return parser.parse_args(params) class GraphCreator(abc.ABC): @@ -341,16 +344,21 @@ class VibrationsGraphCreator(GraphCreator): tar_file.unlink(missing_ok=True) -class AxesMapFinder: - def __init__(self, accel: float, chip_name: str): - self._accel = accel - self._chip_name = chip_name +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')) @@ -371,14 +379,21 @@ class AxesMapFinder: with result_filename.open('w') as f: f.write(results) + ConsoleOutput.print(f'Detected axes_map: {results}') -def main(): - options = Config.parse_arguments() + 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()] ) - print_with_c_locale(f'Shake&Tune version: {Config.get_git_version()}') + ConsoleOutput.print(f'Shake&Tune version: {Config.get_git_version()}') graph_creators = { 'belts': (BeltsGraphCreator, None), @@ -387,12 +402,12 @@ def main(): VibrationsGraphCreator, lambda gc: gc.configure(options.kinematics, options.accel_used, options.chip_name, options.metadata), ), - 'axesmap': (AxesMapFinder, None), + 'axesmap': (AxesMapFinder, lambda gc: gc.configure(options.accel_used, options.chip_name)), } creator_info = graph_creators.get(options.type) if not creator_info: - print_with_c_locale('Error: invalid graph type specified!') + ConsoleOutput.print('Error: invalid graph type specified!') return # Instantiate the graph creator @@ -407,20 +422,54 @@ def main(): try: graph_creator.create_graph() except FileNotFoundError as e: - print_with_c_locale(f'FileNotFound error: {e}') + ConsoleOutput.print(f'FileNotFound error: {e}') return except TimeoutError as e: - print_with_c_locale(f'Timeout error: {e}') + ConsoleOutput.print(f'Timeout error: {e}') return except Exception as e: - print_with_c_locale(f'Error while generating the graphs: {e}') - traceback.print_exc() + ConsoleOutput.print(f'Error while generating the graphs: {e}\n{traceback.print_exc()}') return - print_with_c_locale(f'{options.type} graphs created successfully!') + ConsoleOutput.print(f'{options.type} graphs created successfully!') graph_creator.clean_old_files(options.keep_results) - print_with_c_locale(f'Cleaned output folder to keep only the last {options.keep_results} results!') + ConsoleOutput.print(f'Cleaned output folder to keep only the last {options.keep_results} results!') -if __name__ == '__main__': - main() +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 + + +def load_config(config) -> ShakeTune: + return ShakeTune(config) diff --git a/shaketune/__main__.py b/shaketune/__main__.py new file mode 100644 index 0000000..6fa9e52 --- /dev/null +++ b/shaketune/__main__.py @@ -0,0 +1,10 @@ +from . import Config, create_graph + + +def main() -> None: + options = Config.parse_arguments() + create_graph(options) + + +if __name__ == '__main__': + main() diff --git a/src/helpers/__init__.py b/shaketune/graph_creators/__init__.py similarity index 100% rename from src/helpers/__init__.py rename to shaketune/graph_creators/__init__.py diff --git a/src/graph_creators/analyze_axesmap.py b/shaketune/graph_creators/analyze_axesmap.py similarity index 98% rename from src/graph_creators/analyze_axesmap.py rename to shaketune/graph_creators/analyze_axesmap.py index 9376cfc..1a818d9 100644 --- a/src/graph_creators/analyze_axesmap.py +++ b/shaketune/graph_creators/analyze_axesmap.py @@ -10,7 +10,7 @@ import optparse import numpy as np from scipy.signal import butter, filtfilt -from ..helpers.locale_utils import print_with_c_locale +from ..helpers.console_output import ConsoleOutput NUM_POINTS = 500 @@ -143,7 +143,7 @@ def main(): opts.error('Invalid acceleration value. It should be a numeric value.') results = axesmap_calibration(args, accel_value) - print_with_c_locale(results) + ConsoleOutput.print(results) if options.output is not None: with open(options.output, 'w') as f: diff --git a/src/graph_creators/graph_belts.py b/shaketune/graph_creators/graph_belts.py similarity index 98% rename from src/graph_creators/graph_belts.py rename to shaketune/graph_creators/graph_belts.py index ed4fd38..16858b1 100644 --- a/src/graph_creators/graph_belts.py +++ b/shaketune/graph_creators/graph_belts.py @@ -27,7 +27,7 @@ from ..helpers.common_func import ( parse_log, setup_klipper_import, ) -from ..helpers.locale_utils import print_with_c_locale, set_locale +from ..helpers.console_output import ConsoleOutput ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' # For paired peaks names @@ -200,7 +200,7 @@ def plot_compare_frequency(ax, lognames, signal1, signal2, similarity_factor, ma signal1_belt += ' (axis 1, 1)' signal2_belt += ' (axis 1,-1)' else: - print_with_c_locale( + ConsoleOutput.print( "Warning: belts doesn't seem to have the correct name A and B (extracted from the filename.csv)" ) @@ -453,7 +453,6 @@ def compute_signal_data(data, max_freq): def belts_calibration(lognames, klipperdir='~/klipper', max_freq=200.0, st_version=None): - set_locale() global shaper_calibrate shaper_calibrate = setup_klipper_import(klipperdir) @@ -479,13 +478,13 @@ def belts_calibration(lognames, klipperdir='~/klipper', max_freq=200.0, st_versi similarity_factor = compute_curve_similarity_factor( signal1.freqs, signal1.psd, signal2.freqs, signal2.psd, CURVE_SIMILARITY_SIGMOID_K ) - print_with_c_locale(f'Belts estimated similarity: {similarity_factor:.1f}%') + ConsoleOutput.print(f'Belts estimated similarity: {similarity_factor:.1f}%') # Compute the MHI value from the differential spectrogram sum of gradient, salted with the similarity factor and the number of # unpaired peaks from the belts frequency profile. Be careful, this value is highly opinionated and is pretty experimental! mhi, textual_mhi = compute_mhi( combined_sum, similarity_factor, len(signal1.unpaired_peaks) + len(signal2.unpaired_peaks) ) - print_with_c_locale(f'[experimental] Mechanical Health Indicator: {textual_mhi.lower()} ({mhi:.1f}%)') + ConsoleOutput.print(f'[experimental] Mechanical Health Indicator: {textual_mhi.lower()} ({mhi:.1f}%)') # Create graph layout fig, (ax1, ax2) = plt.subplots( @@ -513,9 +512,7 @@ def belts_calibration(lognames, klipperdir='~/klipper', max_freq=200.0, st_versi dt = datetime.strptime(f"{filename.split('_')[1]} {filename.split('_')[2]}", '%Y%m%d %H%M%S') title_line2 = dt.strftime('%x %X') except Exception: - print_with_c_locale( - 'Warning: CSV filenames look to be different than expected (%s , %s)' % (lognames[0], lognames[1]) - ) + ConsoleOutput.print(f'Warning: CSV filenames look to be different than expected: {lognames}') title_line2 = lognames[0].split('/')[-1] + ' / ' + lognames[1].split('/')[-1] fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) diff --git a/src/graph_creators/graph_shaper.py b/shaketune/graph_creators/graph_shaper.py similarity index 97% rename from src/graph_creators/graph_shaper.py rename to shaketune/graph_creators/graph_shaper.py index 9b851c1..ca89901 100644 --- a/src/graph_creators/graph_shaper.py +++ b/shaketune/graph_creators/graph_shaper.py @@ -27,7 +27,7 @@ from ..helpers.common_func import ( parse_log, setup_klipper_import, ) -from ..helpers.locale_utils import print_with_c_locale, set_locale +from ..helpers.console_output import ConsoleOutput PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_EFFECT_THRESHOLD = 0.12 @@ -72,19 +72,19 @@ def calibrate_shaper(datas, max_smoothing, scv, max_freq): max_smoothing=max_smoothing, test_damping_ratios=None, max_freq=max_freq, - logger=print_with_c_locale, + logger=ConsoleOutput.print, ) except TypeError: - print_with_c_locale( + ConsoleOutput.print( '[WARNING] You seem to be using an older version of Klipper that is not compatible with all the latest Shake&Tune features!' ) - print_with_c_locale( + ConsoleOutput.print( 'Shake&Tune now runs in compatibility mode: be aware that the results may be slightly off, since the real damping ratio cannot be used to create the filter recommendations' ) compat = True - shaper, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, print_with_c_locale) + shaper, all_shapers = helper.find_best_shaper(calibration_data, max_smoothing, ConsoleOutput.print) - print_with_c_locale( + ConsoleOutput.print( '\n-> Recommended shaper is %s @ %.1f Hz (when using a square corner velocity of %.1f and a damping ratio of %.3f)' % (shaper.name.upper(), shaper.freq, scv, zeta) ) @@ -295,14 +295,13 @@ def plot_spectrogram(ax, t, bins, pdata, peaks, max_freq): def shaper_calibration(lognames, klipperdir='~/klipper', max_smoothing=None, scv=5.0, max_freq=200.0, st_version=None): - set_locale() global shaper_calibrate shaper_calibrate = setup_klipper_import(klipperdir) # Parse data from the log files while ignoring CSV in the wrong format datas = [data for data in (parse_log(fn) for fn in lognames) if data is not None] if len(datas) > 1: - print_with_c_locale('Warning: incorrect number of .csv files detected. Only the first one will be used!') + ConsoleOutput.print('Warning: incorrect number of .csv files detected. Only the first one will be used!') # Compute shapers, PSD outputs and spectrogram performance_shaper, shapers, calibration_data, fr, zeta, compat = calibrate_shaper( @@ -329,7 +328,7 @@ def shaper_calibration(lognames, klipperdir='~/klipper', max_smoothing=None, scv # Print the peaks info in the console peak_freqs_formated = ['{:.1f}'.format(f) for f in peaks_freqs] num_peaks_above_effect_threshold = np.sum(calibration_data.psd_sum[peaks] > peaks_threshold[1]) - print_with_c_locale( + ConsoleOutput.print( '\nPeaks detected on the graph: %d @ %s Hz (%d above effect threshold)' % (num_peaks, ', '.join(map(str, peak_freqs_formated)), num_peaks_above_effect_threshold) ) @@ -366,7 +365,7 @@ def shaper_calibration(lognames, klipperdir='~/klipper', max_smoothing=None, scv title_line3 = '| Square corner velocity: ' + str(scv) + 'mm/s' title_line4 = '| Max allowed smoothing: ' + str(max_smoothing) except Exception: - print_with_c_locale('Warning: CSV filename look to be different than expected (%s)' % (lognames[0])) + ConsoleOutput.print('Warning: CSV filename look to be different than expected (%s)' % (lognames[0])) title_line2 = lognames[0].split('/')[-1] title_line3 = '' title_line4 = '' diff --git a/src/graph_creators/graph_vibrations.py b/shaketune/graph_creators/graph_vibrations.py similarity index 98% rename from src/graph_creators/graph_vibrations.py rename to shaketune/graph_creators/graph_vibrations.py index 8462839..05bcf7e 100644 --- a/src/graph_creators/graph_vibrations.py +++ b/shaketune/graph_creators/graph_vibrations.py @@ -28,7 +28,7 @@ from ..helpers.common_func import ( parse_log, setup_klipper_import, ) -from ..helpers.locale_utils import print_with_c_locale, set_locale +from ..helpers.console_output import ConsoleOutput PEAKS_DETECTION_THRESHOLD = 0.05 PEAKS_RELATIVE_HEIGHT_THRESHOLD = 0.04 @@ -453,19 +453,19 @@ def plot_motor_profiles(ax, freqs, main_angles, motor_profiles, global_motor_pro # Then add the motor resonance peak to the graph and print some infos about it motor_fr, motor_zeta, motor_res_idx, lowfreq_max = compute_mechanical_parameters(global_motor_profile, freqs, 30) if lowfreq_max: - print_with_c_locale( + ConsoleOutput.print( '[WARNING] There are a lot of low frequency vibrations that can alter the readings. This is probably due to the test being performed at too high an acceleration!' ) - print_with_c_locale( + ConsoleOutput.print( 'Try lowering the ACCEL value and/or increasing the SIZE value before restarting the macro to ensure that only constant speeds are being recorded and that the dynamic behavior of the machine is not affecting the measurements' ) if motor_zeta is not None: - print_with_c_locale( + ConsoleOutput.print( 'Motors have a main resonant frequency at %.1fHz with an estimated damping ratio of %.3f' % (motor_fr, motor_zeta) ) else: - print_with_c_locale( + ConsoleOutput.print( 'Motors have a main resonant frequency at %.1fHz but it was impossible to estimate a damping ratio.' % (motor_fr) ) @@ -634,7 +634,6 @@ def extract_angle_and_speed(logname): def vibrations_profile( lognames, klipperdir='~/klipper', kinematics='cartesian', accel=None, max_freq=1000.0, st_version=None, motors=None ): - set_locale() global shaper_calibrate shaper_calibrate = setup_klipper_import(klipperdir) @@ -686,7 +685,7 @@ def vibrations_profile( # symmetry_factor = compute_symmetry_analysis(all_angles, all_angles_energy) symmetry_factor = compute_symmetry_analysis(all_angles, spectrogram_data, main_angles) - print_with_c_locale(f'Machine estimated vibration symmetry: {symmetry_factor:.1f}%') + ConsoleOutput.print(f'Machine estimated vibration symmetry: {symmetry_factor:.1f}%') # Analyze low variance ranges of vibration energy across all angles for each speed to identify clean speeds # and highlight them. Also find the peaks to identify speeds to avoid due to high resonances @@ -699,7 +698,7 @@ def vibrations_profile( 10, ) formated_peaks_speeds = ['{:.1f}'.format(pspeed) for pspeed in peaks_speeds] - print_with_c_locale( + ConsoleOutput.print( 'Vibrations peaks detected: %d @ %s mm/s (avoid setting a speed near these values in your slicer print profile)' % (num_peaks, ', '.join(map(str, formated_peaks_speeds))) ) @@ -713,16 +712,16 @@ def vibrations_profile( good_speeds = filter_and_split_ranges(all_speeds, good_speeds, peak_speed_indices, deletion_range) # Add some logging about the good speeds found - print_with_c_locale(f'Lowest vibrations speeds ({len(good_speeds)} ranges sorted from best to worse):') + ConsoleOutput.print(f'Lowest vibrations speeds ({len(good_speeds)} ranges sorted from best to worse):') for idx, (start, end, _) in enumerate(good_speeds): - print_with_c_locale(f'{idx+1}: {all_speeds[start]:.1f} to {all_speeds[end]:.1f} mm/s') + ConsoleOutput.print(f'{idx+1}: {all_speeds[start]:.1f} to {all_speeds[end]:.1f} mm/s') # Angle low energy valleys identification (good angles ranges) and print them to the console good_angles = identify_low_energy_zones(all_angles_energy, ANGLES_VALLEY_DETECTION_THRESHOLD) if good_angles is not None: - print_with_c_locale(f'Lowest vibrations angles ({len(good_angles)} ranges sorted from best to worse):') + ConsoleOutput.print(f'Lowest vibrations angles ({len(good_angles)} ranges sorted from best to worse):') for idx, (start, end, energy) in enumerate(good_angles): - print_with_c_locale( + ConsoleOutput.print( f'{idx+1}: {all_angles[start]:.1f}° to {all_angles[end]:.1f}° (mean vibrations energy: {energy:.2f}% of max)' ) @@ -763,7 +762,7 @@ def vibrations_profile( if accel is not None: title_line2 += ' at ' + str(accel) + ' mm/s² -- ' + kinematics.upper() + ' kinematics' except Exception: - print_with_c_locale('Warning: CSV filenames appear to be different than expected (%s)' % (lognames[0])) + ConsoleOutput.print('Warning: CSV filenames appear to be different than expected (%s)' % (lognames[0])) title_line2 = lognames[0].split('/')[-1] fig.text(0.060, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) @@ -772,7 +771,7 @@ def vibrations_profile( differences = motors[0].compare_to(motors[1]) plot_motor_config_txt(fig, motors, differences) if differences is not None and kinematics == 'corexy': - print_with_c_locale(f'Warning: motors have different TMC configurations!\n{differences}') + ConsoleOutput.print(f'Warning: motors have different TMC configurations!\n{differences}') # Plot the graphs plot_angle_profile_polar(ax1, all_angles, all_angles_energy, good_angles, symmetry_factor) diff --git a/src/graph_creators/klippain.png b/shaketune/graph_creators/klippain.png similarity index 100% rename from src/graph_creators/klippain.png rename to shaketune/graph_creators/klippain.png diff --git a/src/graph_creators/__init.py__ b/shaketune/helpers/__init__.py similarity index 100% rename from src/graph_creators/__init.py__ rename to shaketune/helpers/__init__.py diff --git a/src/helpers/common_func.py b/shaketune/helpers/common_func.py similarity index 97% rename from src/helpers/common_func.py rename to shaketune/helpers/common_func.py index 49831a5..56edff5 100644 --- a/src/helpers/common_func.py +++ b/shaketune/helpers/common_func.py @@ -10,8 +10,8 @@ from importlib import import_module from pathlib import Path import numpy as np -from git import GitCommandError, Repo from scipy.signal import spectrogram +from .console_output import ConsoleOutput def parse_log(logname): @@ -23,7 +23,7 @@ def parse_log(logname): # Check for a PSD file generated by Klipper and raise a warning if cleaned_line.startswith('#freq,psd_x,psd_y,psd_z,psd_xyz'): - print( + ConsoleOutput.print( 'Warning: %s does not contain raw accelerometer data. ' 'Please use the official Klipper script to process it instead. ' 'It will be ignored by Shake&Tune!' % (logname,) @@ -36,7 +36,7 @@ def parse_log(logname): break if not header: - print( + ConsoleOutput.print( 'Warning: file %s has an incorrect header and will be ignored by Shake&Tune!\n' "Expected '#time,accel_x,accel_y,accel_z', but got '%s'." % (logname, header.strip()) ) @@ -45,7 +45,7 @@ def parse_log(logname): # If we have the correct raw data header, proceed to load the data data = np.loadtxt(logname, comments='#', delimiter=',', skiprows=1) if data.ndim == 1 or data.shape[1] != 4: - print( + ConsoleOutput.print( 'Warning: %s does not have the correct data format; expected 4 columns. ' 'It will be ignored by Shake&Tune!' % (logname,) ) @@ -54,7 +54,7 @@ def parse_log(logname): return data except Exception as err: - print(f'Error while reading {logname}: {err}. It will be ignored by Shake&Tune!') + ConsoleOutput.print(f'Error while reading {logname}: {err}. It will be ignored by Shake&Tune!') return None @@ -69,6 +69,7 @@ def get_git_version(): try: # 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/shaketune/helpers/console_output.py b/shaketune/helpers/console_output.py new file mode 100644 index 0000000..c8c72d7 --- /dev/null +++ b/shaketune/helpers/console_output.py @@ -0,0 +1,24 @@ +import io +from typing import Callable, Optional + + +class ConsoleOutput: + """ + Print output to stdout or to an alternative like the Klipper console through a callback + """ + + _output_func: Optional[Callable[[str], None]] = None + + @classmethod + def register_output_callback(cls, output_func: Optional[Callable[[str], None]]): + cls._output_func = output_func + + @classmethod + def print(cls, *args, **kwargs): + if not cls._output_func: + print(*args, **kwargs) + return + + with io.StringIO() as mem_output: + print(*args, file=mem_output, **kwargs) + cls._output_func(mem_output.getvalue()) diff --git a/src/helpers/filemanager.py b/shaketune/helpers/filemanager.py similarity index 100% rename from src/helpers/filemanager.py rename to shaketune/helpers/filemanager.py diff --git a/src/helpers/motorlogparser.py b/shaketune/helpers/motorlogparser.py similarity index 100% rename from src/helpers/motorlogparser.py rename to shaketune/helpers/motorlogparser.py diff --git a/src/helpers/locale_utils.py b/src/helpers/locale_utils.py deleted file mode 100644 index 611ecbd..0000000 --- a/src/helpers/locale_utils.py +++ /dev/null @@ -1,34 +0,0 @@ -#!/usr/bin/env python3 - -# Special utility functions to manage locale settings and printing -# Written by Frix_x#0161 # - - -import locale - - -# Set the best locale for time and date formating (generation of the titles) -def set_locale(): - try: - current_locale = locale.getlocale(locale.LC_TIME) - if current_locale is None or current_locale[0] is None: - locale.setlocale(locale.LC_TIME, 'C') - except locale.Error: - locale.setlocale(locale.LC_TIME, 'C') - - -# Print function to avoid problem in Klipper console (that doesn't support special characters) due to locale settings -def print_with_c_locale(*args, **kwargs): - try: - original_locale = locale.getlocale() - locale.setlocale(locale.LC_ALL, 'C') - except locale.Error as e: - print( - 'Warning: Failed to set a basic locale. Special characters may not display correctly in Klipper console:', e - ) - finally: - print(*args, **kwargs) # Proceed with printing regardless of locale setting success - try: - locale.setlocale(locale.LC_ALL, original_locale) - except locale.Error as e: - print('Warning: Failed to restore the original locale setting:', e)