Shake&Tune on latest Klipper: Fix “PNGs not created” + prevent follow-on plotting breakage + fix sloped dotted shaper curves

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.

Primary goal: fix missing PNGs
Secondary goal: fix incorrect dotted response curves
Method: two code changes + safe plotting semantics

Table of contents

  1. Problem statement
  2. Why PNG generation breaks on latest Klipper
  3. Change 1: computation fix (process_accelerometer_data signature mismatch)
  4. Change 2: plotter fix (prevent logical shaper-curve misplot)
  5. How to locate the live files on your system
  6. Verification checklist
  7. Full working shaper_plotter.py (copy/paste)

1) Problem statement

Known issue on latest Klipper + Shake&Tune: measurements complete and .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).

That one-line workaround can create new plotting/runtime issues in some installs (e.g., follow-on graph generation failures or incorrect dotted curves). The final working result requires that workaround plus a plotter fix that preserves correct data semantics.

2) Why PNG generation breaks on latest Klipper

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.

This guide implements that fix and then hardens plotting so PNG generation remains stable and the dotted shaper response curves are plotted from the correct curve data.

3) Change 1: computation fix (required to restore PNG generation)

You must modify the Shake&Tune computation code where it calls: shaper_calibration.process_accelerometer_data(data).

Exact change

Replace:

shaper_calibration.process_accelerometer_data(data)

With:

shaper_calibration.process_accelerometer_data(None, data)
The exact file path varies by install. Use the “Locate live files” section below to find the correct computation file on your system. Do not guess the path.

4) Change 2: plotter fix (prevents logical misplot and stabilizes output)

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.

The working 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.

5) How to locate the live files on your system

5.1 Locate the active shaper_plotter.py (plotter)

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
The path printed by cat /tmp/SHAPER_PLOTTER_LOADED is the file you replace with the full shaper_plotter.py in this guide. Remove the sentinel afterward.

5.2 Locate the computation file containing process_accelerometer_data(data)

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)).

If your grep appears to “hang,” your /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.

6) Verification checklist

If PNGs still do not generate after Change 1, you edited the wrong computation file. Re-run the grep search and confirm you changed the active call site.

7) Full working shaper_plotter.py (copy/paste)

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"],
        )

Post-install cleanup (recommended)

After you confirm graphs are correct, remove any sentinel you used:

rm -f /tmp/SHAPER_PLOTTER_LOADED