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

@@ -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,214 @@
#########################################
###### 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

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