This guide reflects the complete working outcome achieved: measurements succeed, .stdata is produced, PNGs are generated,
and the dotted shaper response curves (ZV/MZV/EI/2HUMP/3HUMP) render correctly.
.stdata is produced, but PNGs are not created.
Community-discovered initial workaround: change the call to shaper_calibration.process_accelerometer_data(...) to pass
(None, data) instead of just (data).
The root of the “no PNGs” failure is a function signature mismatch between the installed Klipper code and Shake&Tune’s call site.
On some recent Klipper versions, process_accelerometer_data expects two parameters (e.g., an axis/context parameter plus data),
while Shake&Tune calls it with one.
Passing None as the first parameter (i.e., process_accelerometer_data(None, data)) is a pragmatic way to satisfy the new signature
without changing measurement inputs.
You must modify the Shake&Tune computation code where it calls:
shaper_calibration.process_accelerometer_data(data).
Replace:
shaper_calibration.process_accelerometer_data(data)
With:
shaper_calibration.process_accelerometer_data(None, data)
After PNG generation is restored, many users still see incorrect dotted curves: ZV/MZV/EI/2HUMP/3HUMP appear as straight or sloped lines. This happens when the plotter uses a monotonic weighting/attenuation array as if it were the true shaper transfer/response curve, or when it uses a mismatched frequency axis.
shaper_plotter.py below prefers true response/transfer curve keys (such as tf/transfer/response)
and falls back safely to Klipper shaper objects only if needed. It also trims X/Y to equal lengths.
Install this by replacing the live shaper_plotter.py file on your system with the full file provided below.
Find candidates:
find /home -type f -name shaper_plotter.py 2>/dev/null
If multiple exist, use a sentinel to prove which one is actually loaded by your runtime:
sudo sed -i '1i\with open("/tmp/SHAPER_PLOTTER_LOADED","w",encoding="utf-8") as f: f.write(__file__+"\\n")\' /path/to/shaper_plotter.py
Restart to reload:
sudo systemctl restart klipper sudo systemctl restart moonraker
Run Shake&Tune once, then confirm the loaded path:
ls -l /tmp/SHAPER_PLOTTER_LOADED cat /tmp/SHAPER_PLOTTER_LOADED
cat /tmp/SHAPER_PLOTTER_LOADED is the file you replace with the full shaper_plotter.py in this guide.
Remove the sentinel afterward.
Find the Shake&Tune computation file that contains the call site:
grep -R "process_accelerometer_data(" -n /home 2>/dev/null | head -n 50
Open the file that shows the call with a single argument and apply Change 1 (replace (data) with (None, data)).
/home tree is large. Restrict search to likely folders (examples):
/home/pi/klipper, /home/pi/klippy-env, /home/pi/klipper_extras, or the directory where Shake&Tune lives.
.stdata files are created.
Paste this entire file into the live shaper_plotter.py path you confirmed via the sentinel method. This file contains no sentinel and no debug writers.
# Shake&Tune: 3D printer analysis tools
#
# Copyright (C) 2024 Félix Boisselier
# Licensed under the GNU General Public License v3.0 (GPL-3.0)
#
# File: shaper_plotter.py
# Description: Plotter for input shaper calibration graphs
from datetime import datetime
from typing import Any, Dict
import matplotlib
import matplotlib.pyplot as plt
from matplotlib.figure import Figure
from ..base_models import PlotterStrategy
from ..computation_results import ShaperResult
from ..plotting_utils import AxesConfiguration, PlottingConstants, SpectrogramHelper
# -----------------------------
# Safe plotting helper
# -----------------------------
def _safe_plot(*args, **kwargs):
"""
Flexible wrapper around matplotlib Axes.plot that tolerates:
- _safe_plot(ax, y, ...)
- _safe_plot(ax, x, y, ...)
- _safe_plot(y, ...)
- _safe_plot(x, y, ...)
and always trims x/y to the same length.
"""
import numpy as np
import matplotlib.pyplot as plt
if not args:
return
first = args[0]
# Case 1: first argument is an Axes-like object (has .plot)
if hasattr(first, "plot"):
ax = first
remaining = args[1:]
else:
# Case 2: no axes passed – fall back to current axes
ax = plt.gca()
remaining = args
if not remaining:
return
# Handle "plot(y, ...)" form
if len(remaining) == 1:
y = np.asarray(remaining[0])
x = np.arange(y.shape[0])
extra = ()
else:
# Handle "plot(x, y, ...)" form
x = np.asarray(remaining[0])
y = np.asarray(remaining[1])
extra = remaining[2:]
n = min(x.shape[0], y.shape[0])
x = x[:n]
y = y[:n]
ax.plot(x, y, *extra, **kwargs)
class ShaperPlotter(PlotterStrategy):
"""Plotter for input shaper calibration graphs"""
def plot(self, result: ShaperResult) -> Figure:
"""Create input shaper calibration graph"""
data = result.get_plot_data()
fig = plt.figure(figsize=(15, 11.6))
gs = fig.add_gridspec(
2,
2,
height_ratios=[4, 3],
width_ratios=[5, 4],
bottom=0.050,
top=0.890,
left=0.048,
right=0.966,
hspace=0.169,
wspace=0.150,
)
ax_1 = fig.add_subplot(gs[0, 0])
ax_2 = fig.add_subplot(gs[1, 0])
ax_3 = fig.add_subplot(gs[1, 1])
# Titles / logo / version
self._add_titles(fig, data)
self.add_logo(fig, position=[0.001, 0.924, 0.075, 0.075])
self.add_version_text(fig, data["st_version"], position=(0.995, 0.985))
# Frequency profile + spectrogram
self._plot_frequency_profile(ax_1, data)
self._plot_spectrogram(ax_2, data)
# Remove ax_3 for now (TODO: re-add vibrations vs acceleration curves in next release)
ax_3.remove()
# Table + recommendations
self._add_shaper_table(fig, data)
self._add_recommendations(fig, data)
return fig
# -----------------------------
# Titles
# -----------------------------
def _add_titles(self, fig: Figure, data: Dict[str, Any]) -> None:
"""Add title lines to the figure"""
try:
filename_parts = data["measurements"][0]["name"].split("_")
dt = datetime.strptime(f"{filename_parts[2]} {filename_parts[3]}", "%Y%m%d %H%M%S")
title_line2 = dt.strftime("%x %X") + " -- " + filename_parts[1].upper() + " axis"
if data["compat"]:
title_line3 = "| Older Klipper version detected, damping ratio"
title_line4 = "| and SCV are not used for filter recommendations!"
else:
max_smoothing_string = (
f"default (={data['max_smoothing_computed']:0.3f})"
if data["max_smoothing"] is None
else f"{data['max_smoothing']:0.3f}"
)
title_line3 = f"| Square corner velocity: {data['scv']} mm/s"
title_line4 = f"| Allowed smoothing: {max_smoothing_string}"
except Exception:
title_line2 = data["measurements"][0]["name"]
title_line3 = ""
title_line4 = ""
mode, _, _, accel_per_hz, _, sweeping_accel, sweeping_period = data["test_params"]
title_line5 = f"| Mode: {mode}"
title_line5 += f" -- ApH: {accel_per_hz}" if accel_per_hz is not None else ""
if mode == "SWEEPING":
title_line5 += f" [sweeping period: {sweeping_period} s - accel: {sweeping_accel} mm/s²]"
title_lines = [
{
"x": 0.065,
"y": 0.965,
"text": "INPUT SHAPER CALIBRATION TOOL",
"fontsize": 20,
"color": PlottingConstants.KLIPPAIN_COLORS["purple"],
"weight": "bold",
},
{"x": 0.065, "y": 0.957, "va": "top", "text": title_line2},
{"x": 0.481, "y": 0.990, "va": "top", "fontsize": 11, "text": title_line5},
{"x": 0.480, "y": 0.970, "va": "top", "fontsize": 14, "text": title_line3},
{"x": 0.480, "y": 0.949, "va": "top", "fontsize": 14, "text": title_line4},
]
self.add_title(fig, title_lines)
# -----------------------------
# Frequency profile
# -----------------------------
def _plot_frequency_profile(self, ax, data: Dict[str, Any]) -> None:
"""Plot frequency profile with PSDs and shapers"""
import numpy as np
calibration_data = data["calibration_data"]
# PSD frequency axis (for PSD arrays)
psd_freqs = calibration_data.freqs
psd = calibration_data.psd_sum
px = calibration_data.psd_x
py = calibration_data.psd_y
pz = calibration_data.psd_z
max_freq = float(data["max_freq"])
# Plot PSDs (primary axis)
_safe_plot(ax, psd_freqs, psd, label="X+Y+Z", color="purple", zorder=5)
_safe_plot(ax, psd_freqs, px, label="X", color="red")
_safe_plot(ax, psd_freqs, py, label="Y", color="green")
_safe_plot(ax, psd_freqs, pz, label="Z", color="blue")
ax.set_xlim([0, max_freq])
ax.set_ylim([0, data["max_scale"] if data["max_scale"] is not None else psd.max() * 1.05])
# Secondary axis for shaper response curves (0..1)
ax_2 = ax.twinx()
ax_2.set_ylim(0.0, 1.02)
ax_2.set_xlim([0, max_freq])
ax_2.set_zorder(ax.get_zorder() + 1)
ax_2.patch.set_visible(False)
ax_2.yaxis.set_visible(True)
ax_2.set_ylabel("Shaper response (0–1)")
# -----------------------------
# Dotted shaper curves (response/transfer)
# -----------------------------
# Prefer true response/transfer curves when present in shaper_table_data; do not treat generic
# monotonic weighting arrays as "response".
preferred_curve_keys = (
"response",
"shaper_response",
"response_vals",
"response_curve",
"freq_response",
"transfer",
"tf",
)
preferred_x_keys = ("freqs", "frequencies", "f", "x")
dotted_plotted = False
table_shapers = (data.get("shaper_table_data", {}) or {}).get("shapers", []) or []
for sh in table_shapers:
sh_type = sh.get("type", None)
if not sh_type:
continue
curve = None
for k in preferred_curve_keys:
if k in sh:
arr = np.asarray(sh.get(k))
if arr.ndim == 1 and arr.size > 1:
curve = arr
break
if curve is None:
continue
x = None
for fk in preferred_x_keys:
if fk in sh:
xa = np.asarray(sh.get(fk))
if xa.ndim == 1 and xa.size > 1:
x = xa
break
if x is None:
x = psd_freqs
n = min(len(x), len(curve))
if n <= 1:
continue
_safe_plot(
ax_2,
x[:n],
curve[:n],
label=str(sh_type).upper(),
linestyle="dotted",
linewidth=2.0,
alpha=1.0,
)
dotted_plotted = True
# Fallback: Klipper-provided shaper objects
if not dotted_plotted:
shapers = data.get("shapers", []) or []
for sh in shapers:
vals = getattr(sh, "vals", None)
name = getattr(sh, "name", None)
sh_freqs = getattr(sh, "freqs", None)
if vals is None or name is None:
continue
vals = np.asarray(vals)
if vals.ndim != 1 or vals.size <= 1:
continue
if sh_freqs is not None:
sh_freqs = np.asarray(sh_freqs)
if sh_freqs.ndim != 1 or sh_freqs.size <= 1:
sh_freqs = None
if sh_freqs is None:
x = np.linspace(0.0, max_freq, vals.shape[0])
else:
x = sh_freqs
n = min(len(x), len(vals))
if n <= 1:
continue
_safe_plot(
ax_2,
x[:n],
vals[:n],
label=str(name).upper(),
linestyle="dotted",
linewidth=2.0,
alpha=1.0,
)
# Draw shaper filtered PSDs (primary axis) using shaper_table_data shaper "vals"
shaper_choices = data["shaper_choices"]
for sh in data["shaper_table_data"]["shapers"]:
n = min(len(psd_freqs), len(psd), len(sh["vals"]))
if sh["type"] == shaper_choices[0]:
_safe_plot(
ax,
psd_freqs[:n],
(psd[:n] * sh["vals"][:n]),
label=f"With {shaper_choices[0]} applied",
color="cyan",
)
if len(shaper_choices) > 1 and sh["type"] == shaper_choices[1]:
_safe_plot(
ax,
psd_freqs[:n],
(psd[:n] * sh["vals"][:n]),
label=f"With {shaper_choices[1]} applied",
color="lime",
)
# Draw detected peaks (primary axis)
peaks = data["peaks"]
peaks_freqs = data["peaks_freqs"]
peaks_threshold = data["peaks_threshold"]
valid_peaks = [p for p in peaks if 0 <= p < len(psd)]
x = peaks_freqs[: len(valid_peaks)]
y = psd[valid_peaks]
n = min(len(x), len(y))
_safe_plot(ax, x[:n], y[:n], "x", color="black", markersize=8)
for idx, peak in enumerate(peaks):
if not (0 <= peak < len(psd)):
continue
fontcolor = "red" if psd[peak] > peaks_threshold[1] else "black"
fontweight = "bold" if psd[peak] > peaks_threshold[1] else "normal"
ax.annotate(
f"{idx + 1}",
(psd_freqs[peak], psd[peak]),
textcoords="offset points",
xytext=(8, 5),
ha="left",
fontsize=13,
color=fontcolor,
weight=fontweight,
)
# Threshold lines + regions (primary axis)
ax.axhline(y=peaks_threshold[0], color="black", linestyle="--", linewidth=0.5)
ax.axhline(y=peaks_threshold[1], color="black", linestyle="--", linewidth=0.5)
ax.fill_between(psd_freqs, 0, peaks_threshold[0], color="green", alpha=0.15, label="Relax Region")
ax.fill_between(
psd_freqs, peaks_threshold[0], peaks_threshold[1], color="orange", alpha=0.2, label="Warning Region"
)
fontP = AxesConfiguration.configure_axes(
ax,
xlabel="Frequency (Hz)",
ylabel="Power spectral density",
title=f"Axis Frequency Profile (ω0={data['fr']:.1f}Hz, ζ={data['zeta']:.3f})",
sci_axes="y",
legend=True,
)
ax_2.legend(loc="upper right", prop=fontP)
# -----------------------------
# Spectrogram
# -----------------------------
def _plot_spectrogram(self, ax, data: Dict[str, Any]) -> None:
"""Plot time-frequency spectrogram"""
SpectrogramHelper.plot_spectrogram(
ax,
data["pdata"],
data["t"],
data["bins"],
data["max_freq"],
percentile_filter=PlottingConstants.SPECTROGRAM_LOW_PERCENTILE_FILTER,
)
for idx, peak in enumerate(data["peaks_freqs"]):
ax.axvline(peak, color="cyan", linestyle="dotted", linewidth=1)
ax.annotate(
f"Peak {idx + 1} ({peak:.1f} Hz)",
(peak, data["bins"][-1] * 0.9),
textcoords="data",
color="cyan",
rotation=90,
fontsize=10,
verticalalignment="top",
horizontalalignment="right",
)
ax.set_xlim([0.0, data["max_freq"]])
AxesConfiguration.configure_axes(
ax, xlabel="Frequency (Hz)", ylabel="Time (s)", title="Time-Frequency Spectrogram", grid=False
)
# -----------------------------
# Table
# -----------------------------
def _add_shaper_table(self, fig: Figure, data: Dict[str, Any]) -> None:
"""Add shaper parameters table"""
columns = ["Type", "Frequency", "Vibrations", "Smoothing", "Max Accel"]
table_data = [
[
shaper["type"].upper(),
f"{shaper['frequency']:.1f} Hz",
f"{shaper['vibrations'] * 100:.1f} %",
f"{shaper['smoothing']:.3f}",
f"{round(shaper['max_accel'] / 10) * 10:.0f}",
]
for shaper in data["shaper_table_data"]["shapers"]
]
table = plt.table(cellText=table_data, colLabels=columns, bbox=[1.100, 0.535, 0.830, 0.240], cellLoc="center")
table.auto_set_font_size(False)
table.set_fontsize(10)
table.auto_set_column_width([0, 1, 2, 3, 4])
table.set_zorder(100)
bold_font = matplotlib.font_manager.FontProperties(weight="bold")
for key, cell in table.get_celld().items():
row, col = key
cell.set_text_props(ha="center", va="center")
if col == 0:
cell.get_text().set_fontproperties(bold_font)
cell.get_text().set_color(PlottingConstants.KLIPPAIN_COLORS["dark_purple"])
if row == 0:
cell.get_text().set_fontproperties(bold_font)
cell.get_text().set_color(PlottingConstants.KLIPPAIN_COLORS["dark_orange"])
# -----------------------------
# Recommendations
# -----------------------------
def _add_recommendations(self, fig: Figure, data: Dict[str, Any]) -> None:
"""Add filter recommendations and damping ratio"""
fig.text(
0.575,
0.897,
"Recommended filters:",
fontsize=15,
fontweight="bold",
color=PlottingConstants.KLIPPAIN_COLORS["dark_purple"],
)
recommendations = data["shaper_table_data"]["recommendations"]
for idx, rec in enumerate(recommendations):
fig.text(0.580, 0.867 - idx * 0.025, rec, fontsize=14, color=PlottingConstants.KLIPPAIN_COLORS["purple"])
new_idx = len(recommendations)
fig.text(
0.580,
0.867 - new_idx * 0.025,
f" -> Estimated damping ratio (ζ): {data['shaper_table_data']['damping_ratio']:.3f}",
fontsize=14,
color=PlottingConstants.KLIPPAIN_COLORS["purple"],
)
After you confirm graphs are correct, remove any sentinel you used:
rm -f /tmp/SHAPER_PLOTTER_LOADED