Klipper plugin refactoring with embedded macros

This commit is contained in:
Félix Boisselier
2024-05-09 16:08:47 +02:00
parent d9060fed3b
commit 30a1910513
21 changed files with 762 additions and 644 deletions

View File

@@ -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

View File

@@ -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 %}

View File

@@ -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}"

View File

@@ -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

View File

@@ -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 <args>
source ~/klippain_shaketune-env/bin/activate
cd ~/klippain_shaketune
python -m src.is_workflow "$@"
deactivate

View File

@@ -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

View File

@@ -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:

View File

@@ -1,10 +0,0 @@
from . import Config, create_graph
def main() -> None:
options = Config.parse_arguments()
create_graph(options)
if __name__ == '__main__':
main()

View File

@@ -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

View File

@@ -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()

View File

@@ -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)

View File

@@ -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),
# ),
# }

View File

@@ -0,0 +1,2 @@
#!/usr/bin/env python3

View File

@@ -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()

View File

@@ -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()

View File

@@ -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()

View File

@@ -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)}'
)

86
shaketune/shaketune.py Normal file
View File

@@ -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)

View File

@@ -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)

View File

@@ -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)!'
)