diff --git a/K-ShakeTune/K-SnT_vibrations.cfg b/K-ShakeTune/K-SnT_vibrations.cfg index 947be5f..a0a9ddd 100644 --- a/K-ShakeTune/K-SnT_vibrations.cfg +++ b/K-ShakeTune/K-SnT_vibrations.cfg @@ -122,7 +122,7 @@ gcode: # 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/3 + 2/3 * (curr_speed / 60) / 100 %} + {% set segment_length_multiplier = 1/5 + 4/5 * (curr_speed / 60) / 100 %} {% else %} {% set segment_length_multiplier = 1 %} {% endif %} @@ -156,12 +156,59 @@ gcode: {% endfor %} {% endfor %} - - RESPOND MSG="Machine vibrations profile generation..." - RESPOND MSG="This may take some time (3-5min)" - RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type vibrations --accel {accel|int} --kinematics {kinematics} --chip_name {accel_chip} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}" - # 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)" + RUN_SHELL_COMMAND CMD=shaketune PARAMS="--type vibrations --accel {accel|int} --kinematics {kinematics} {% if metadata %}--metadata {metadata}{% endif %} --chip_name {accel_chip} {% if keep_csv %}--keep_csv{% endif %} --keep_results {keep_results}" + RESTORE_GCODE_STATE NAME=CREATE_VIBRATIONS_PROFILE diff --git a/src/graph_creators/graph_vibrations.py b/src/graph_creators/graph_vibrations.py index d4d4173..c33b359 100644 --- a/src/graph_creators/graph_vibrations.py +++ b/src/graph_creators/graph_vibrations.py @@ -560,6 +560,57 @@ def plot_vibration_spectrogram(ax, angles, speeds, spectrogram_data, peaks): return +def plot_motor_config_txt(fig, motors, differences): + motor_details = [(motors[0], 'X motor'), (motors[1], 'Y motor')] + + distance = 0.12 + if motors[0].get_property('autotune_enabled'): + distance = 0.24 + config_blocks = [ + f"| {lbl}: {mot.get_property('motor').upper()} on {mot.get_property('tmc').upper()} @ {mot.get_property('voltage')}V {mot.get_property('run_current')}A" + for mot, lbl in motor_details + ] + config_blocks.append('| TMC Autotune enabled') + else: + config_blocks = [ + f"| {lbl}: {mot.get_property('tmc').upper()} @ {mot.get_property('run_current')}A" + for mot, lbl in motor_details + ] + config_blocks.append('| TMC Autotune not detected') + + for idx, block in enumerate(config_blocks): + fig.text( + 0.40, 0.990 - 0.015 * idx, block, ha='left', va='top', fontsize=10, color=KLIPPAIN_COLORS['dark_purple'] + ) + + tmc_registers = motors[0].get_registers() + idx = -1 + for idx, (register, settings) in enumerate(tmc_registers.items()): + settings_str = ' '.join(f'{k}={v}' for k, v in settings.items()) + tmc_block = f'| {register.upper()}: {settings_str}' + fig.text( + 0.40 + distance, + 0.990 - 0.015 * idx, + tmc_block, + ha='left', + va='top', + fontsize=10, + color=KLIPPAIN_COLORS['dark_purple'], + ) + + if differences is not None: + differences_text = f'| Y motor diff: {differences}' + fig.text( + 0.40 + distance, + 0.990 - 0.015 * (idx + 1), + differences_text, + ha='left', + va='top', + fontsize=10, + color=KLIPPAIN_COLORS['dark_purple'], + ) + + ###################################################################### # Startup and main routines ###################################################################### @@ -577,7 +628,7 @@ def extract_angle_and_speed(logname): def vibrations_profile( - lognames, klipperdir='~/klipper', kinematics='cartesian', accel=None, max_freq=1000.0, st_version=None + lognames, klipperdir='~/klipper', kinematics='cartesian', accel=None, max_freq=1000.0, st_version=None, motors=None ): set_locale() global shaper_calibrate @@ -710,6 +761,13 @@ def vibrations_profile( title_line2 = lognames[0].split('/')[-1] fig.text(0.060, 0.957, title_line2, ha='left', va='top', fontsize=16, color=KLIPPAIN_COLORS['dark_purple']) + # Add the motors infos to the top of the graph + if motors is not None and len(motors) == 2: + differences = motors[0].compare_to(motors[1]) + plot_motor_config_txt(fig, motors, differences) + if differences is not None and kinematics == 'corexy': + print_with_c_locale(f'Warning: motors have different TMC configurations!\n{differences}') + # Plot the graphs plot_angle_profile_polar(ax1, all_angles, all_angles_energy, good_angles, symmetry_factor) plot_vibration_spectrogram_polar(ax4, all_angles, all_speeds, spectrogram_data) diff --git a/src/helpers/motorlogparser.py b/src/helpers/motorlogparser.py new file mode 100644 index 0000000..4e6e743 --- /dev/null +++ b/src/helpers/motorlogparser.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 + +# Classes to parse the Klipper log and parse the TMC dump to extract the relevant information +# Written by Frix_x#0161 # + +import re +from pathlib import Path +from typing import Any, Dict, List, Optional, Union + + +class Motor: + def __init__(self, name: str): + self._name: str = name + self._registers: Dict[str, Dict[str, Any]] = {} + self._properties: Dict[str, Any] = {} + + def set_register(self, register: str, value: Any) -> None: + # Special parsing for CHOPCONF to extract meaningful values + if register == 'CHOPCONF': + # Add intpol=0 if missing from the register dump + if 'intpol=' not in value: + value += ' intpol=0' + # Simplify the microstep resolution format + mres_match = re.search(r'mres=\d+\((\d+)usteps\)', value) + if mres_match: + value = re.sub(r'mres=\d+\(\d+usteps\)', f'mres={mres_match.group(1)}', value) + + # Special parsing for CHOPCONF to avoid pwm_ before each values + if register == 'PWMCONF': + parts = value.split() + new_parts = [] + for part in parts: + key, val = part.split('=', 1) + if key.startswith('pwm_'): + key = key[4:] + new_parts.append(f'{key}={val}') + value = ' '.join(new_parts) + + # General cleaning to remove extraneous labels and colons and parse the whole into Motor _registers + cleaned_values = re.sub(r'\b\w+:\s+\S+\s+', '', value) + + # Then fill the registers while merging all the thresholds into the same THRS virtual register + if register in ['TPWMTHRS', 'TCOOLTHRS']: + existing_thrs = self._registers.get('THRS', {}) + new_values = self._parse_register_values(cleaned_values) + merged_values = {**existing_thrs, **new_values} + self._registers['THRS'] = merged_values + else: + self._registers[register] = self._parse_register_values(cleaned_values) + + def _parse_register_values(self, register_string: str) -> Dict[str, Any]: + parsed = {} + parts = register_string.split() + for part in parts: + if '=' in part: + k, v = part.split('=', 1) + parsed[k] = v + return parsed + + def get_register(self, register: str) -> Optional[Dict[str, Any]]: + return self._registers.get(register) + + def get_registers(self) -> Dict[str, Dict[str, Any]]: + return self._registers + + def set_property(self, property: str, value: Any) -> None: + self._properties[property] = value + + def get_property(self, property: str) -> Optional[Any]: + return self._properties.get(property) + + def __str__(self): + return f'Stepper: {self._name}\nKlipper config: {self._properties}\nTMC Registers: {self._registers}' + + # Return the other motor properties and registers that are different from the current motor + def compare_to(self, other: 'Motor') -> Optional[Dict[str, Dict[str, Any]]]: + differences = {'properties': {}, 'registers': {}} + + # Compare properties + all_keys = self._properties.keys() | other._properties.keys() + for key in all_keys: + val1 = self._properties.get(key) + val2 = other._properties.get(key) + if val1 != val2: + differences['properties'][key] = val2 + + # Compare registers + all_keys = self._registers.keys() | other._registers.keys() + for key in all_keys: + reg1 = self._registers.get(key, {}) + reg2 = other._registers.get(key, {}) + if reg1 != reg2: + reg_diffs = {} + sub_keys = reg1.keys() | reg2.keys() + for sub_key in sub_keys: + reg_val1 = reg1.get(sub_key) + reg_val2 = reg2.get(sub_key) + if reg_val1 != reg_val2: + reg_diffs[sub_key] = reg_val2 + if reg_diffs: + differences['registers'][key] = reg_diffs + + # Clean up: remove empty sections if there are no differences + if not differences['properties']: + del differences['properties'] + if not differences['registers']: + del differences['registers'] + + if not differences: + return None + + return differences + + +class MotorLogParser: + _section_pattern: str = r'DUMP_TMC stepper_(x|y)' + _register_patterns: Dict[str, str] = { + 'CHOPCONF': r'CHOPCONF:\s+\S+\s+(.*)', + 'PWMCONF': r'PWMCONF:\s+\S+\s+(.*)', + 'COOLCONF': r'COOLCONF:\s+(.*)', + 'TPWMTHRS': r'TPWMTHRS:\s+\S+\s+(.*)', + 'TCOOLTHRS': r'TCOOLTHRS:\s+\S+\s+(.*)', + } + + def __init__(self, filepath: Path, config_string: Optional[str] = None): + self._filepath = filepath + + self._motors: List[Motor] = [] + self._config = self._parse_config(config_string) if config_string else {} + + self._parse_registers() + + def _parse_config(self, config_string: str) -> Dict[str, Any]: + config = {} + entries = config_string.split('|') + for entry in entries: + if entry: + key, value = entry.split(':') + config[key.strip()] = self._convert_value(value.strip()) + return config + + def _convert_value(self, value: str) -> Union[int, float, bool, str]: + if value.isdigit(): + return int(value) + try: + return float(value) + except ValueError: + if value.lower() in ['true', 'false']: + return value.lower() == 'true' + return value + + def _parse_registers(self) -> None: + with open(self._filepath, 'r') as file: + log_content = file.read() + + sections = re.split(self._section_pattern, log_content) + + # Detect only the latest dumps from the log (to ignore potential previous and outdated dumps) + last_sections: Dict[str, int] = {} + for i in range(1, len(sections), 2): + stepper_name = 'stepper_' + sections[i].strip() + last_sections[stepper_name] = i + + for stepper_name, index in last_sections.items(): + content = sections[index + 1] + motor = Motor(stepper_name) + + # Apply general properties from config string + for key, value in self._config.items(): + if stepper_name in key: + prop_key = key.replace(stepper_name + '_', '') + motor.set_property(prop_key, value) + elif 'autotune' in key: + motor.set_property(key, value) + + # Parse TMC registers + for key, pattern in self._register_patterns.items(): + match = re.search(pattern, content) + if match: + values = match.group(1).strip() + motor.set_register(key, values) + + self._motors.append(motor) + + # Find and return the motor by its name + def get_motor(self, motor_name: str) -> Optional[Motor]: + for motor in self._motors: + if motor._name == motor_name: + return motor + return None + + # Get all the motor list at once + def get_motors(self) -> List[Motor]: + return self._motors + + +# # Usage example: +# config_string = "stepper_x_tmc:tmc2240|stepper_x_run_current:0.9|stepper_x_hold_current:0.9|stepper_y_tmc:tmc2240|stepper_y_run_current:0.9|stepper_y_hold_current:0.9|autotune_enabled:True|stepper_x_motor:ldo-35sth48-1684ah|stepper_x_voltage:|stepper_y_motor:ldo-35sth48-1684ah|stepper_y_voltage:|" +# parser = MotorLogParser('/path/to/your/logfile.log', config_string) + +# stepper_x = parser.get_motor('stepper_x') +# stepper_y = parser.get_motor('stepper_y') + +# print(stepper_x) +# print(stepper_y) diff --git a/src/is_workflow.py b/src/is_workflow.py index 06aa40b..5847fda 100755 --- a/src/is_workflow.py +++ b/src/is_workflow.py @@ -26,10 +26,12 @@ from src.graph_creators.graph_belts import belts_calibration from src.graph_creators.graph_shaper import shaper_calibration from src.graph_creators.graph_vibrations import vibrations_profile from src.helpers.locale_utils import print_with_c_locale +from src.helpers.motorlogparser import MotorLogParser 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'} @@ -102,6 +104,13 @@ class Config: 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', @@ -279,14 +288,18 @@ class VibrationsGraphCreator(GraphCreator): self._kinematics = None self._accel = None self._chip_name = None + self._motors = None self._setup_folder('vibrations') - def configure(self, kinematics: str, accel: float, chip_name: str) -> None: + 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: @@ -308,6 +321,7 @@ class VibrationsGraphCreator(GraphCreator): kinematics=self._kinematics, accel=self._accel, st_version=self._version, + motors=self._motors, ) self._save_figure_and_cleanup(fig, lognames) @@ -369,7 +383,7 @@ def main(): '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), + lambda gc: gc.configure(options.kinematics, options.accel_used, options.chip_name, options.metadata), ), 'axesmap': (AxesMapFinder, None), }