From 8e517f2ca3d8a8af8fbce41288c213c94217d10d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sun, 5 May 2024 15:50:59 +0200 Subject: [PATCH 01/13] updated requirement to fix S&T on older Python version --- requirements.txt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/requirements.txt b/requirements.txt index 52e0c94..db89e34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,4 +1,7 @@ GitPython==3.1.40 -matplotlib==3.8.2 -numpy==1.26.2 -scipy==1.11.4 +matplotlib==3.8.2 ; python_version >= '3.9' +matplotlib==3.3.4 ; python_version < '3.9' +numpy==1.26.2 ; python_version >= '3.9' +numpy==1.19.5 ; python_version < '3.9' +scipy==1.11.4 ; python_version >= '3.9' +scipy==1.7.3 ; python_version < '3.9' From 8fff10ada2955cd7a7d7dec0fc07d1298d6c96a3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Mon, 6 May 2024 11:55:38 +0200 Subject: [PATCH 02/13] some documentation and tuning workflow --- docs/README.md | 44 +++++++++++++++++++++++++++++++++++-- docs/macros/belts_tuning.md | 8 +++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/docs/README.md b/docs/README.md index d0f8f87..d76488c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -2,11 +2,51 @@ ![](./banner_long.png) + +When perfecting 3D prints and tuning your printer, there is all that resonance testing stuff that Shake&Tune will try to help you with. But keep in mind that it's part of a complete process, and Shake&Tune alone won't magically make your printer print at lightning speed. Also, when using the tools, **it's important to get back to the original need: good prints**. + +While there are some ideal goals described in this documentation, you need to understand that it's not always possible to achieve the ideal resonance graphs due to a variety of factors unique to each printer, such as precision of the assembly, quality and brand of components, components wear, etc. Even a different accelerometer can give different results. But that's not a problem; the primary goal is to produce clean and satisfactory prints. If your test prints look good and meet your standards, even if the response curves aren't perfect, you're on the right track. **Trust your printer and your print results more than chasing ideal graphs!** If it's satisfactory, there's no need for further adjustments. + + ## Resonance testing -First, check out the **[input shaping and tuning generalities](./is_tuning_generalities.md)** documentation to understand how it all works and what to look for when taking these measurements. +First, check out the **[input shaping and tuning generalities](./is_tuning_generalities.md)** documentation to understand how it all works and what to look for when taking these measurements. A standard tuning workflow might look something like this: -Then look at the documentation for each type of graph by clicking on them below tu run the tests and better understand your results to tune your machine! +```mermaid +flowchart LR + start([Start]) --> tensionBelts[Tension your\nbelts as best\n as possible] + checkmotion --> tensionBelts + tensionBelts --> SnT_Belts[Run Shake&Tune\nbelts comparison tool] + SnT_Belts --> goodbelts{Check the documentation\nDoes belts comparison profiles\nlook decent?} + goodbelts --> |YES| SnT_IS[Run Shake&Tune\naxis input shaper tool] + goodbelts --> |NO| checkmotion[Fix your mechanical assembly\nand your motion system] + SnT_IS --> goodIS{Check the documentation\nDoes axis profiles and\n input shapers look decent?} + goodIS --> |YES| SnT_Vibrations[Run Shake&Tune\nvibration profile tool] + goodIS--> |NO| checkmotion + SnT_Vibrations --> goodvibs{Check the documentation\nAre the graphs OK?\nSet the speeds in\nyour slicer profile} + goodvibs --> |YES| pressureAdvance[Tune your\npressure advance] + goodvibs --> |NO| checkTMC[Dig into TMC drivers\ntuning if you want to] + goodvibs --> |NO| checkmotion + checkTMC --> SnT_Vibrations + pressureAdvance --> extrusionMultiplier[Tune your\nextrusion multiplier] + extrusionMultiplier --> testPrint[Do a test print] + testPrint --> printGood{Is the print good?} + printGood --> |YES| unicorn{want to chase unicorns} + printGood --> |NO -> Underextrusion / Overextrusion| extrusionMultiplier + printGood --> |NO -> Corner humps and no ghosting| pressureAdvance + printGood --> |NO -> Visible VFAs| SnT_Vibrations + printGood --> |NO -> Ghosting, ringing, resonance| SnT_IS + unicorn --> |NO| done + unicorn --> |YES| SnT_Belts + + classDef default fill:#70088C,stroke:#150140,stroke-width:4px,color:#ffffff; + classDef questions fill:#FF8D32,stroke:#F24130,stroke-width:4px,color:#ffffff; + classDef startstop fill:#F2055C,stroke:#150140,stroke-width:3px; + class start,done startstop; + class goodbelts,goodIS,goodvibs,printGood,unicorn questions; +``` + +You can access the documentation for each type of graph by clicking on them below. | [Belt response comparison](./macros/belts_tuning.md) | [Axis input shaper graphs](./macros/axis_tuning.md) | [Vibrations profile](./macros/vibrations_profile.md) | |:----------------:|:------------:|:---------------------:| diff --git a/docs/macros/belts_tuning.md b/docs/macros/belts_tuning.md index 225ed00..0ce42c0 100644 --- a/docs/macros/belts_tuning.md +++ b/docs/macros/belts_tuning.md @@ -1,11 +1,11 @@ # Belt relative difference measurements -The `COMPARE_BELTS_RESPONSES` macro is dedicated for CoreXY machines where it can help you to diagnose belt path problems by measuring and plotting the differences between their behavior. It will also help you tension your belts at the same tension. +The `COMPARE_BELTS_RESPONSES` macro is dedicated for CoreXY machines where it can help you to diagnose belt path problems by measuring and plotting the differences between their behavior. It will also help you tension your belts at the same tension. Using it on Cartesian printers doesn't really make sense, as it's normal to have different responses in that case. ## Usage -**Before starting, ensure that the belts are properly tensioned**. For example, you can follow the [Voron belt tensioning documentation](https://docs.vorondesign.com/tuning/secondary_printer_tuning.html#belt-tension). This is crucial: you need a good starting point to then iterate from it! +**Before starting, ensure that the belts are properly pre-tensioned**. For example, you can follow the [Voron belt tensioning documentation](https://docs.vorondesign.com/tuning/secondary_printer_tuning.html#belt-tension). This is crucial: you need a good starting point to then iterate from it! Then, call the `COMPARE_BELTS_RESPONSES` macro and look for the graphs in the results folder. Here are the parameters available: @@ -33,6 +33,10 @@ Aside from the actual belt tension, the resonant frequency/amplitude of the curv **If these three parameters are met, there is no way that the curves could be different** or you can be sure that there is an underlying problem in at least one of the belt paths. Also, if the belt graphs have low amplitude curves (no distinct peaks) and a lot of noise, you will probably also have poor input shaper graphs. So before you continue, ensure that you have good belt graphs or fix your belt paths. Start by checking the belt tension, bearings, gantry screws, alignment of the belts on the idlers, and so on. + > **Note**: + > + > If you are using this tool to check or adjust the tension after installing new belts, you will need to measure again after a few hours of printing, as the tension can change slightly as the belts stretch and settle to their final tension. Usually 24 hours should be sufficient. + ## Advanced explanation on why 1 or 2 peaks From e1a7681a4ad839d1571cf135a9ead05462be5bb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Mon, 6 May 2024 11:56:31 +0200 Subject: [PATCH 03/13] typo in doc --- docs/README.md | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/docs/README.md b/docs/README.md index d76488c..9d3fca6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -7,12 +7,26 @@ When perfecting 3D prints and tuning your printer, there is all that resonance t While there are some ideal goals described in this documentation, you need to understand that it's not always possible to achieve the ideal resonance graphs due to a variety of factors unique to each printer, such as precision of the assembly, quality and brand of components, components wear, etc. Even a different accelerometer can give different results. But that's not a problem; the primary goal is to produce clean and satisfactory prints. If your test prints look good and meet your standards, even if the response curves aren't perfect, you're on the right track. **Trust your printer and your print results more than chasing ideal graphs!** If it's satisfactory, there's no need for further adjustments. +First, you might want to check out the **[input shaping and tuning generalities](./is_tuning_generalities.md)** documentation to understand how it all works and what to look for when taking these measurements. + ## Resonance testing -First, check out the **[input shaping and tuning generalities](./is_tuning_generalities.md)** documentation to understand how it all works and what to look for when taking these measurements. A standard tuning workflow might look something like this: +A standard tuning workflow might look something like this: ```mermaid +%%{ + init: { + 'theme': 'base', + 'themeVariables': { + 'lineColor': '#232323', + 'primaryTextColor': '#F2055C', + 'secondaryColor': '#D3D3D3', + 'tertiaryColor': '#FFFFFF' + } + } +}%% + flowchart LR start([Start]) --> tensionBelts[Tension your\nbelts as best\n as possible] checkmotion --> tensionBelts @@ -46,7 +60,7 @@ flowchart LR class goodbelts,goodIS,goodvibs,printGood,unicorn questions; ``` -You can access the documentation for each type of graph by clicking on them below. +You can access the documentation for each graph type by clicking on it in the table below. | [Belt response comparison](./macros/belts_tuning.md) | [Axis input shaper graphs](./macros/axis_tuning.md) | [Vibrations profile](./macros/vibrations_profile.md) | |:----------------:|:------------:|:---------------------:| From 303ed7060c7a94667ae46006482ae99759a379e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Mon, 6 May 2024 13:43:51 +0200 Subject: [PATCH 04/13] fixed darkmode for tuning workflow mermaid --- docs/README.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/README.md b/docs/README.md index 9d3fca6..714db9d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -27,7 +27,9 @@ A standard tuning workflow might look something like this: } }%% -flowchart LR +flowchart TB + subgraph Tuning Workflow + direction LR start([Start]) --> tensionBelts[Tension your\nbelts as best\n as possible] checkmotion --> tensionBelts tensionBelts --> SnT_Belts[Run Shake&Tune\nbelts comparison tool] @@ -52,12 +54,14 @@ flowchart LR printGood --> |NO -> Ghosting, ringing, resonance| SnT_IS unicorn --> |NO| done unicorn --> |YES| SnT_Belts + end - classDef default fill:#70088C,stroke:#150140,stroke-width:4px,color:#ffffff; + classDef standard fill:#70088C,stroke:#150140,stroke-width:4px,color:#ffffff; classDef questions fill:#FF8D32,stroke:#F24130,stroke-width:4px,color:#ffffff; - classDef startstop fill:#F2055C,stroke:#150140,stroke-width:3px; + classDef startstop fill:#F2055C,stroke:#150140,stroke-width:3px,color:#ffffff; class start,done startstop; class goodbelts,goodIS,goodvibs,printGood,unicorn questions; + class tensionBelts,checkmotion,SnT_Belts,SnT_IS,SnT_Vibrations,pressureAdvance,extrusionMultiplier,testPrint,checkTMC standard; ``` You can access the documentation for each graph type by clicking on it in the table below. From 3a0c0c4173683bf258a3a47cc188561784639b2c Mon Sep 17 00:00:00 2001 From: Oz Elentok Date: Thu, 9 May 2024 00:02:23 +0300 Subject: [PATCH 05/13] Run ShakeTune as an in-process Klipper module (#100) * feat: Run ShakeTune as an in-process Klipper module * feat: install shaketune dependencies to klipper venv * refactor: replace print_with_c_locale with klipper console output with stdout fallback --- K-ShakeTune/K-SnT_axes_map.cfg | 2 +- K-ShakeTune/K-SnT_axis.cfg | 4 +- K-ShakeTune/K-SnT_belts.cfg | 2 +- K-ShakeTune/K-SnT_vibrations.cfg | 2 +- K-ShakeTune/shaketune_cmd.cfg | 6 - README.md | 4 +- install.sh | 27 ++--- moonraker.conf | 2 +- src/is_workflow.py => shaketune/__init__.py | 111 +++++++++++++----- shaketune/__main__.py | 10 ++ .../graph_creators}/__init__.py | 0 .../graph_creators/analyze_axesmap.py | 4 +- .../graph_creators/graph_belts.py | 13 +- .../graph_creators/graph_shaper.py | 19 ++- .../graph_creators/graph_vibrations.py | 27 ++--- .../graph_creators/klippain.png | Bin .../helpers/__init__.py | 0 {src => shaketune}/helpers/common_func.py | 11 +- shaketune/helpers/console_output.py | 24 ++++ {src => shaketune}/helpers/filemanager.py | 0 {src => shaketune}/helpers/motorlogparser.py | 0 src/helpers/locale_utils.py | 34 ------ 22 files changed, 169 insertions(+), 133 deletions(-) delete mode 100644 K-ShakeTune/shaketune_cmd.cfg rename src/is_workflow.py => shaketune/__init__.py (82%) mode change 100755 => 100644 create mode 100644 shaketune/__main__.py rename {src/helpers => shaketune/graph_creators}/__init__.py (100%) rename {src => shaketune}/graph_creators/analyze_axesmap.py (98%) rename {src => shaketune}/graph_creators/graph_belts.py (98%) rename {src => shaketune}/graph_creators/graph_shaper.py (97%) rename {src => shaketune}/graph_creators/graph_vibrations.py (98%) rename {src => shaketune}/graph_creators/klippain.png (100%) rename src/graph_creators/__init.py__ => shaketune/helpers/__init__.py (100%) rename {src => shaketune}/helpers/common_func.py (97%) create mode 100644 shaketune/helpers/console_output.py rename {src => shaketune}/helpers/filemanager.py (100%) rename {src => shaketune}/helpers/motorlogparser.py (100%) delete mode 100644 src/helpers/locale_utils.py 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) From d9060fed3b43b626d43316640f366a164ec11be7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Thu, 9 May 2024 12:26:43 +0200 Subject: [PATCH 06/13] cleaning old Shake&Tune venv and configs --- install.sh | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/install.sh b/install.sh index bf280b8..7d59e13 100755 --- a/install.sh +++ b/install.sh @@ -5,6 +5,7 @@ MOONRAKER_CONFIG="${HOME}/printer_data/config/moonraker.conf" KLIPPER_PATH="${HOME}/klipper" KLIPPER_VENV_PATH="${HOME}/klippy-env" +OLD_K_SHAKETUNE_VENV="${HOME}/klippain_shaketune-env" K_SHAKETUNE_PATH="${HOME}/klippain_shaketune" set -eu @@ -81,6 +82,11 @@ function setup_venv { exit -1 fi + if [ -d "${OLD_K_SHAKETUNE_VENV}" ]; then + echo "[INFO] Old K-Shake&Tune virtual environement found, cleaning it!" + rm -rf "${OLD_K_SHAKETUNE_VENV}" + fi + source "${KLIPPER_VENV_PATH}/bin/activate" echo "[SETUP] Installing/Updating K-Shake&Tune dependencies..." pip install --upgrade pip @@ -90,14 +96,18 @@ function setup_venv { } function link_extension { - echo "[INSTALL] Linking scripts to your config directory..." + # Reusing the old linking extension function to cleanup and remove the macros for older S&T versions if [ -d "${HOME}/klippain_config" ] && [ -f "${USER_CONFIG_PATH}/.VERSION" ]; then - 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 + if [ -d "${USER_CONFIG_PATH}/scripts/K-ShakeTune" ]; then + echo "[INFO] Old K-Shake&Tune macro folder found, cleaning it!" + rm -d "${USER_CONFIG_PATH}/scripts/K-ShakeTune" + fi 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 + if [ -d "${USER_CONFIG_PATH}/K-ShakeTune" ]; then + echo "[INFO] Old K-Shake&Tune macro folder found, cleaning it!" + rm -d "${USER_CONFIG_PATH}/K-ShakeTune" + fi fi } 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 07/13] 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 08/13] 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 09/13] 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 10/13] 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 11/13] 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 12/13] 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) From 9f4da8b80d01cdfc0558934145997ed990b5bff4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Boisselier?= Date: Sun, 19 May 2024 11:36:27 +0200 Subject: [PATCH 13/13] added accel_per_hertz to the graphs --- shaketune/measurement/axes_input_shaper.py | 2 +- shaketune/measurement/belts_comparison.py | 4 +++ shaketune/post_processing/graph_belts.py | 5 ++-- shaketune/post_processing/graph_creator.py | 8 +++++- shaketune/post_processing/graph_shaper.py | 31 ++++++++++++++++------ 5 files changed, 38 insertions(+), 12 deletions(-) diff --git a/shaketune/measurement/axes_input_shaper.py b/shaketune/measurement/axes_input_shaper.py index 48b4d64..87d7734 100644 --- a/shaketune/measurement/axes_input_shaper.py +++ b/shaketune/measurement/axes_input_shaper.py @@ -60,7 +60,7 @@ def axes_shaper_calibration(gcmd, config, st_thread: ShakeTuneThread) -> None: # Configure the graph creator creator = st_thread.get_graph_creator() - creator.configure(scv, max_sm) + creator.configure(scv, max_sm, accel_per_hz) # set the needed acceleration values for the test toolhead_info = toolhead.get_status(systime) diff --git a/shaketune/measurement/belts_comparison.py b/shaketune/measurement/belts_comparison.py index e0d312d..047967d 100644 --- a/shaketune/measurement/belts_comparison.py +++ b/shaketune/measurement/belts_comparison.py @@ -56,6 +56,10 @@ def compare_belts_responses(gcmd, config, st_thread: ShakeTuneThread) -> None: toolhead.manual_move(point, feedrate_travel) + # Configure the graph creator + creator = st_thread.get_graph_creator() + creator.configure(accel_per_hz) + # set the needed acceleration values for the test toolhead_info = toolhead.get_status(systime) old_accel = toolhead_info['max_accel'] diff --git a/shaketune/post_processing/graph_belts.py b/shaketune/post_processing/graph_belts.py index 16858b1..ff6b506 100644 --- a/shaketune/post_processing/graph_belts.py +++ b/shaketune/post_processing/graph_belts.py @@ -452,7 +452,7 @@ def compute_signal_data(data, max_freq): ###################################################################### -def belts_calibration(lognames, klipperdir='~/klipper', max_freq=200.0, st_version=None): +def belts_calibration(lognames, klipperdir='~/klipper', max_freq=200.0, accel_per_hz=None, st_version='unknown'): global shaper_calibrate shaper_calibrate = setup_klipper_import(klipperdir) @@ -538,6 +538,7 @@ def main(): opts = optparse.OptionParser(usage) opts.add_option('-o', '--output', type='string', dest='output', default=None, help='filename of output graph') opts.add_option('-f', '--max_freq', type='float', default=200.0, help='maximum frequency to graph') + opts.add_option('--accel_per_hz', type='float', default=None, help='accel_per_hz used during the measurement') opts.add_option( '-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory' ) @@ -547,7 +548,7 @@ def main(): if options.output is None: opts.error('You must specify an output file.png to use the script (option -o)') - fig = belts_calibration(args, options.klipperdir, options.max_freq) + fig = belts_calibration(args, options.klipperdir, options.max_freq, options.accel_per_hz, 'unknown') fig.savefig(options.output, dpi=150) diff --git a/shaketune/post_processing/graph_creator.py b/shaketune/post_processing/graph_creator.py index 9316bcb..305eeb5 100644 --- a/shaketune/post_processing/graph_creator.py +++ b/shaketune/post_processing/graph_creator.py @@ -96,6 +96,9 @@ class BeltsGraphCreator(GraphCreator): self._setup_folder('belts') + def configure(self, accel_per_hz: float = None) -> None: + self._accel_per_hz = accel_per_hz + def create_graph(self) -> None: lognames = self._move_and_prepare_files( glob_pattern='shaketune-belt_*.csv', @@ -105,6 +108,7 @@ class BeltsGraphCreator(GraphCreator): fig = belts_calibration( lognames=[str(path) for path in lognames], klipperdir=str(self._config.klipper_folder), + accel_per_hz=self._accel_per_hz, st_version=self._version, ) self._save_figure_and_cleanup(fig, lognames) @@ -134,9 +138,10 @@ class ShaperGraphCreator(GraphCreator): self._setup_folder('shaper') - def configure(self, scv: float, max_smoothing: float = None) -> None: + def configure(self, scv: float, max_smoothing: float = None, accel_per_hz: float = None) -> None: self._scv = scv self._max_smoothing = max_smoothing + self._accel_per_hz = accel_per_hz def create_graph(self) -> None: if not self._scv: @@ -152,6 +157,7 @@ class ShaperGraphCreator(GraphCreator): klipperdir=str(self._config.klipper_folder), max_smoothing=self._max_smoothing, scv=self._scv, + accel_per_hz=self._accel_per_hz, st_version=self._version, ) self._save_figure_and_cleanup(fig, lognames, lognames[0].stem.split('_')[-1]) diff --git a/shaketune/post_processing/graph_shaper.py b/shaketune/post_processing/graph_shaper.py index ca89901..741a3af 100644 --- a/shaketune/post_processing/graph_shaper.py +++ b/shaketune/post_processing/graph_shaper.py @@ -294,7 +294,15 @@ 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): +def shaper_calibration( + lognames, + klipperdir='~/klipper', + max_smoothing=None, + scv=5.0, + max_freq=200.0, + accel_per_hz=None, + st_version='unknown', +): global shaper_calibrate shaper_calibrate = setup_klipper_import(klipperdir) @@ -359,19 +367,23 @@ def shaper_calibration(lognames, klipperdir='~/klipper', max_smoothing=None, scv dt = datetime.strptime(f'{filename_parts[1]} {filename_parts[2]}', '%Y%m%d %H%M%S') title_line2 = dt.strftime('%x %X') + ' -- ' + filename_parts[3].upper().split('.')[0] + ' axis' if compat: - title_line3 = '| Compatibility mode with older Klipper,' - title_line4 = '| and no custom S&T parameters are used!' + title_line3 = '| Older Klipper version detected, damping ratio' + title_line4 = '| and SCV are not used for filter recommendations!' + title_line5 = f'| Accel per Hz used: {accel_per_hz}' if accel_per_hz is not None else '' else: - title_line3 = '| Square corner velocity: ' + str(scv) + 'mm/s' - title_line4 = '| Max allowed smoothing: ' + str(max_smoothing) + title_line3 = f'| Square corner velocity: {scv}mm/s' + title_line4 = f'| Max allowed smoothing: {max_smoothing}' + title_line5 = f'| Accel per Hz used: {accel_per_hz}' if accel_per_hz is not None else '' except Exception: ConsoleOutput.print('Warning: CSV filename look to be different than expected (%s)' % (lognames[0])) title_line2 = lognames[0].split('/')[-1] title_line3 = '' title_line4 = '' + title_line5 = '' fig.text(0.12, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) - fig.text(0.58, 0.960, title_line3, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) - fig.text(0.58, 0.946, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.58, 0.965, title_line3, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.58, 0.951, title_line4, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) + fig.text(0.58, 0.919, title_line5, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple']) # Plot the graphs plot_freq_response( @@ -401,6 +413,7 @@ def main(): opts.add_option( '--scv', '--square_corner_velocity', type='float', dest='scv', default=5.0, help='square corner velocity' ) + opts.add_option('--accel_per_hz', type='float', default=None, help='accel_per_hz used during the measurement') opts.add_option( '-k', '--klipper_dir', type='string', dest='klipperdir', default='~/klipper', help='main klipper directory' ) @@ -412,7 +425,9 @@ def main(): if options.max_smoothing is not None and options.max_smoothing < 0.05: opts.error('Too small max_smoothing specified (must be at least 0.05)') - fig = shaper_calibration(args, options.klipperdir, options.max_smoothing, options.scv, options.max_freq) + fig = shaper_calibration( + args, options.klipperdir, options.max_smoothing, options.scv, options.max_freq, options.accel_per_hz, 'unknown' + ) fig.savefig(options.output, dpi=150)