Klipper plugin refactoring with embedded macros
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
from . import Config, create_graph
|
||||
|
||||
|
||||
def main() -> None:
|
||||
options = Config.parse_arguments()
|
||||
create_graph(options)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
main()
|
||||
@@ -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
|
||||
|
||||
276
shaketune/graph_creators/graph_creator.py
Normal file
276
shaketune/graph_creators/graph_creator.py
Normal 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()
|
||||
@@ -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)
|
||||
|
||||
214
shaketune/macros/K-SnT_vibrations.cfg
Normal file
214
shaketune/macros/K-SnT_vibrations.cfg
Normal 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
|
||||
16
shaketune/macros/__init__.py
Normal file
16
shaketune/macros/__init__.py
Normal 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),
|
||||
# ),
|
||||
# }
|
||||
2
shaketune/macros/accelerometer.py
Normal file
2
shaketune/macros/accelerometer.py
Normal file
@@ -0,0 +1,2 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
35
shaketune/macros/axes_input_shaper.py
Normal file
35
shaketune/macros/axes_input_shaper.py
Normal 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()
|
||||
83
shaketune/macros/axes_map.py
Normal file
83
shaketune/macros/axes_map.py
Normal 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()
|
||||
28
shaketune/macros/belts_comparison.py
Normal file
28
shaketune/macros/belts_comparison.py
Normal 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()
|
||||
22
shaketune/macros/static_freq.py
Normal file
22
shaketune/macros/static_freq.py
Normal 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
86
shaketune/shaketune.py
Normal 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)
|
||||
131
shaketune/shaketune_config.py
Normal file
131
shaketune/shaketune_config.py
Normal 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)
|
||||
66
shaketune/shaketune_thread.py
Normal file
66
shaketune/shaketune_thread.py
Normal 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)!'
|
||||
)
|
||||
Reference in New Issue
Block a user