Files
klipper/scripts/calibrate_shaper.py
2025-11-21 17:48:56 -05:00

293 lines
12 KiB
Python
Executable File

#!/usr/bin/env python3
# Shaper auto-calibration script
#
# Copyright (C) 2020-2025 Dmitry Butyugin <dmbutyugin@google.com>
# Copyright (C) 2020 Kevin O'Connor <kevin@koconnor.net>
#
# This file may be distributed under the terms of the GNU GPLv3 license.
from __future__ import print_function
import csv, importlib, optparse, os, sys
from textwrap import wrap
import numpy as np, matplotlib
sys.path.append(os.path.join(os.path.dirname(os.path.realpath(__file__)),
'..', 'klippy'))
shaper_calibrate = importlib.import_module('.shaper_calibrate', 'extras')
MAX_TITLE_LENGTH=65
def parse_log(logname):
with open(logname) as f:
for header in f:
if not header.startswith('#'):
break
if not header.startswith('freq,'):
# Process raw accelerometer data
data = np.loadtxt(logname, comments='#', delimiter=',')
helper = shaper_calibrate.ShaperCalibrate(printer=None)
calibration_data = helper.process_accelerometer_data(logname, data)
calibration_data.normalize_to_frequencies()
return calibration_data
# Parse power spectral density data
data = np.genfromtxt(logname, dtype=np.float64, skip_header=1,
comments='#', delimiter=',', filling_values=0.)
if header.startswith('freq,psd_x,psd_y,psd_z,psd_xyz'):
calibration_data = shaper_calibrate.CalibrationData(
name=logname, freq_bins=data[:,0], psd_sum=data[:,4],
psd_x=data[:,1], psd_y=data[:,2], psd_z=data[:,3])
calibration_data.set_numpy(np)
else:
parsed_header = next(csv.reader([header], delimiter=','))
calibration_data = None
for i, dataset_name in enumerate(parsed_header[1:]):
if dataset_name == 'shapers:':
break
cdata = shaper_calibrate.CalibrationData(
name=dataset_name, freq_bins=data[:,0], psd_sum=data[:,i+1],
# Individual per-axis data is not stored
psd_x=None, psd_y=None, psd_z=None)
cdata.set_numpy(np)
if calibration_data is None:
calibration_data = cdata
else:
calibration_data.add_data(cdata)
# If input shapers are present in the CSV file, the frequency
# response is already normalized to input frequencies
if ',shapers:' not in header:
calibration_data.normalize_to_frequencies()
return calibration_data
######################################################################
# Shaper calibration
######################################################################
# Find the best shaper parameters
def calibrate_shaper(datas, csv_output, *, shapers, damping_ratio, scv,
shaper_freqs, max_smoothing, test_damping_ratios,
max_freq):
# Combine accelerometer data
calibration_data = datas[0]
for data in datas[1:]:
calibration_data.add_data(data)
print("Processing resonances from %s"
% ",".join(d.name for d in calibration_data.get_datasets()))
helper = shaper_calibrate.ShaperCalibrate(printer=None)
shaper, all_shapers = helper.find_best_shaper(
calibration_data, shapers=shapers, damping_ratio=damping_ratio,
scv=scv, shaper_freqs=shaper_freqs, max_smoothing=max_smoothing,
test_damping_ratios=test_damping_ratios, max_freq=max_freq,
logger=print)
if not shaper:
print("No recommended shaper, possibly invalid value for --shapers=%s" %
(','.join(shapers)))
return None, None, None
print("Recommended shaper is %s @ %.1f Hz" % (shaper.name, shaper.freq))
if csv_output is not None:
helper.save_calibration_data(
csv_output, calibration_data, all_shapers)
return shaper.name, all_shapers, calibration_data
######################################################################
# Plot frequency response and suggested input shapers
######################################################################
def plot_freq_response(calibration_data, shapers,
selected_shaper, max_freq):
selected_shaper_data = [s for s in shapers if s.name == selected_shaper][0]
max_freq_bin = selected_shaper_data.freq_bins.max()
if max_freq > max_freq_bin:
max_freq = max_freq_bin
fontP = matplotlib.font_manager.FontProperties()
fontP.set_size('x-small')
fig, ax = matplotlib.pyplot.subplots(figsize=(8, 5))
ax.set_xlabel('Frequency, Hz')
ax.set_xlim([0, max_freq])
ax.set_ylabel('Power spectral density')
datasets = calibration_data.get_datasets()
if len(datasets) == 1:
freqs = calibration_data.freq_bins
psd = calibration_data.psd_sum[freqs <= max_freq]
px = calibration_data.psd_x[freqs <= max_freq]
py = calibration_data.psd_y[freqs <= max_freq]
pz = calibration_data.psd_z[freqs <= max_freq]
freqs = freqs[freqs <= max_freq]
after_shaper = np.interp(selected_shaper_data.freq_bins, freqs, psd)
ax.plot(freqs, psd, label='X+Y+Z', color='purple')
ax.plot(freqs, px, label='X', color='red')
ax.plot(freqs, py, label='Y', color='green')
ax.plot(freqs, pz, label='Z', color='blue')
title = "Frequency response and shapers (%s)" % calibration_data.name
else:
after_shaper = np.zeros(shape=selected_shaper_data.freq_bins.shape)
for data in datasets:
freqs = data.freq_bins
psd = data.psd_sum[freqs <= max_freq]
freqs = freqs[freqs <= max_freq]
after_shaper = np.maximum(
after_shaper, np.interp(selected_shaper_data.freq_bins,
freqs, psd))
ax.plot(freqs, psd, label=data.name)
title = "Frequency responses and shapers"
after_shaper *= selected_shaper_data.vals
ax.set_title("\n".join(wrap(title, MAX_TITLE_LENGTH)))
ax.xaxis.set_minor_locator(matplotlib.ticker.MultipleLocator(5))
ax.yaxis.set_minor_locator(matplotlib.ticker.AutoMinorLocator())
ax.ticklabel_format(axis='y', style='scientific', scilimits=(0,0))
ax.grid(which='major', color='grey')
ax.grid(which='minor', color='lightgrey')
ax2 = ax.twinx()
ax2.set_ylabel('Shaper vibration reduction (ratio)')
best_shaper_vals = None
for shaper in shapers:
label = "%s (%.1f Hz, vibr=%.1f%%, sm~=%.2f, accel<=%.f)" % (
shaper.name.upper(), shaper.freq,
shaper.vibrs * 100., shaper.smoothing,
round(shaper.max_accel / 100.) * 100.)
linestyle = 'dotted'
if shaper.name == selected_shaper:
linestyle = 'dashdot'
ax2.plot(shaper.freq_bins, shaper.vals,
label=label, linestyle=linestyle)
ax.plot(selected_shaper_data.freq_bins, after_shaper,
label='After%sshaper' % ('\n' if len(datasets) == 1 else ' '),
color='cyan')
# A hack to add a human-readable shaper recommendation to legend
ax2.plot([], [], ' ',
label="Recommended shaper: %s" % (selected_shaper.upper()))
ax.legend(loc='upper left', prop=fontP)
ax2.legend(loc='upper right', prop=fontP)
fig.tight_layout()
return fig
######################################################################
# Startup
######################################################################
def setup_matplotlib(output_to_file):
global matplotlib
if output_to_file:
matplotlib.rcParams.update({'figure.autolayout': True})
matplotlib.use('Agg')
import matplotlib.pyplot, matplotlib.dates, matplotlib.font_manager
import matplotlib.ticker
def main():
# Parse command-line arguments
usage = "%prog [options] <logs>"
opts = optparse.OptionParser(usage)
opts.add_option("-o", "--output", type="string", dest="output",
default=None, help="filename of output graph")
opts.add_option("-c", "--csv", type="string", dest="csv",
default=None, help="filename of output csv file")
opts.add_option("-f", "--max_freq", type="float", default=None,
help="maximum frequency to plot")
opts.add_option("-s", "--max_smoothing", type="float", dest="max_smoothing",
default=None, help="maximum shaper smoothing to allow")
opts.add_option("--scv", "--square_corner_velocity", type="float",
dest="scv", default=5., help="square corner velocity")
opts.add_option("--shaper_freq", type="string", dest="shaper_freq",
default=None, help="shaper frequency(-ies) to test, " +
"either a comma-separated list of floats, or a range in " +
"the format [start]:end[:step]")
opts.add_option("--shapers", type="string", dest="shapers", default=None,
help="a comma-separated list of shapers to test")
opts.add_option("--damping_ratio", type="float", dest="damping_ratio",
default=None, help="shaper damping_ratio parameter")
opts.add_option("--test_damping_ratios", type="string",
dest="test_damping_ratios", default=None,
help="a comma-separated list of damping ratios to test " +
"input shaper for")
options, args = opts.parse_args()
if len(args) < 1:
opts.error("Incorrect number of arguments")
if options.max_smoothing is not None and options.max_smoothing < 0.05:
opts.error("Too small max_smoothing specified (must be at least 0.05)")
max_freq = options.max_freq
if options.shaper_freq is None:
shaper_freqs = []
elif options.shaper_freq.find(':') >= 0:
freq_start = None
freq_end = None
freq_step = None
try:
freqs_parsed = options.shaper_freq.partition(':')
if freqs_parsed[0]:
freq_start = float(freqs_parsed[0])
freqs_parsed = freqs_parsed[-1].partition(':')
freq_end = float(freqs_parsed[0])
if freq_start and freq_start > freq_end:
opts.error("Invalid --shaper_freq param: start range larger " +
"than its end")
if freqs_parsed[-1].find(':') >= 0:
opts.error("Invalid --shaper_freq param format")
if freqs_parsed[-1]:
freq_step = float(freqs_parsed[-1])
except ValueError:
opts.error("--shaper_freq param does not specify correct range " +
"in the format [start]:end[:step]")
shaper_freqs = (freq_start, freq_end, freq_step)
if max_freq is not None:
max_freq = max(max_freq, freq_end * 4./3.)
else:
try:
shaper_freqs = [float(s) for s in options.shaper_freq.split(',')]
except ValueError:
opts.error("invalid floating point value in --shaper_freq param")
if max_freq is not None:
max_freq = max(max_freq, max(shaper_freqs) * 4./3.)
if options.test_damping_ratios:
try:
test_damping_ratios = [float(s) for s in
options.test_damping_ratios.split(',')]
except ValueError:
opts.error("invalid floating point value in " +
"--test_damping_ratios param")
else:
test_damping_ratios = None
if options.shapers is None:
shapers = None
else:
shapers = options.shapers.lower().split(',')
# Parse data
datas = [parse_log(fn) for fn in args]
# Calibrate shaper and generate outputs
selected_shaper, shapers, calibration_data = calibrate_shaper(
datas, options.csv, shapers=shapers,
damping_ratio=options.damping_ratio,
scv=options.scv, shaper_freqs=shaper_freqs,
max_smoothing=options.max_smoothing,
test_damping_ratios=test_damping_ratios,
max_freq=max_freq)
if selected_shaper is None:
return
if max_freq is None:
max_freq = 0.
for data in calibration_data.get_datasets():
max_freq = max(max_freq, data.freq_bins.max())
if not options.csv or options.output:
# Draw graph
setup_matplotlib(options.output is not None)
fig = plot_freq_response(calibration_data, shapers,
selected_shaper, max_freq)
# Show graph
if options.output is None:
matplotlib.pyplot.show()
else:
fig.set_size_inches(8, 6)
fig.savefig(options.output)
if __name__ == '__main__':
main()