基于ESP32与ADXL345的Marlin共振补偿(Input Shaping)全自动校准系统1.0.0

引言

3D打印机在高速打印时,运动部件的惯性激发的机械共振会严重影响打印质量——表面出现“涟漪纹”(VFA,即垂直表面伪影),甚至导致层错位。Marlin固件从 2.0 版本开始引入 M593 命令,允许用户为 X/Y 轴设置共振频率,通过主动调整运动规划来抑制共振。但问题的关键在于:如何准确测得这组频率?

传统的做法是使用“共振补偿校准”插件(如 Klipper 的 shaper_calibrate),但需要额外的硬件(如加速度计)和复杂接线。本文介绍一套基于 ESP32 的便携式共振检测装置,它通过 ADXL345 加速度计采集振动信号,自动执行扫频 G 代码,利用 FFT 分析找到共振峰,并将结果直接写入 Marlin 的 EEPROM 或发送 M593 指令。更重要的是,它自带 Web 界面,支持实时波形显示、参数调整、G 代码生成,甚至可以一键完成“扫描 → 分析 → 写入”的全流程。


系统架构

硬件组成

  • 主控:ESP32 (支持 WiFi/蓝牙,双核,大容量 Flash)
  • 加速度计:ADXL345 (I2C 接口,可测 ±16g,采样率 400Hz)
  • 显示屏:0.96 寸 OLED (SSD1306, 128×64,可选)
  • 供电:通过 USB 或 3.3V 供电
  • 连接方式:WiFi STA 或 AP 模式,与打印机同网络通信(HTTP 协议)

软件框架

传感器驱动:ADXL345 的初始化与数据读取,通过 I²C 总线(GPIO21/22)与 ESP32 通信。

FFT 分析:使用 arduinoFFT 库,对 256 点 采样(400Hz 采样率)做 1024 点 FFT(零填充),频率分辨率约 0.39Hz。峰值检测结合了 SNR(信噪比)、Q 因子和相对幅值的综合置信度评分,确保结果可靠。

运动检测:计算加速度矢量的 RMS 偏差,判断打印机是否处于运动状态。阈值可通过 Web 界面调整,并支持自动校准。

状态机:管理校准流程的三段式推进:

  • INIT_WAITING:等待 INIT.GCO 执行完毕,打印机归位并移动到起始位置
  • SWEEPING:SWEEP.GCO 扫频执行中,持续采集 FFT 数据
  • FINISH_WAITING:FINISH.GCO 归位完成后,计算共振频率

G 代码生成与上传:

  • INIT.GCO:归位 → 移动到热床中心 → 抬升 Z 轴 → 切换到相对坐标
  • SWEEP.GCO:纯扫频运动(不含归位指令,避免与 INIT/FINISH 重复)
  • FINISH.GCO:恢复绝对坐标 → 归位 → 关闭电机

三个文件通过 HTTP 分步上传至打印机 SD 卡,然后按顺序启动打印。

Web 服务器:提供响应式控制面板(兼容手机/PC),使用 Chart.js 实时绘制加速度波形,支持一键校准、G 代码生成下载、噪声基底记录等完整功能。

参数存储:

  • EEPROM:保存共振频率、WiFi 凭据、打印机 IP、运动阈值、热床尺寸、幅度阈值
  • SPIFFS:保存 X/Y 轴的噪声基底数据(用于频谱抑制,提升信噪比)

核心原理

1. 扫频信号生成

为了激励打印机结构,我们让某个轴(X 或 Y)做正弦往复运动,等效频率由 速度 和 移动距离 决定:

f = v / (2 * travel * 60)

其中 v 是 G1 指令中的 F 参数(mm/min),travel 是单次移动距离(mm)。通过从起始频率到结束频率逐步增加速度,生成一段扫频 G 代码。每个频率点执行若干完整周期,并在每个频率点后插入 G4 P800 暂停 800ms,等待机械运动彻底停止。

我们通过改变运动速度来改变往复运动的频率。以下代码生成一段从 startFreq 到 endFreq 的扫频序列,每个频率点执行 CYCLES_PER_FREQ 个完整周期,并插入 800ms 暂停:

String generateSweepOnlyGCode(String axis, float &startFreq, float &endFreq, 
                              float stepFreq, float &travel, float maxSpeed) {
    // 根据频率和行程计算速度 (mm/min)
    float v_min = 60 * travel * 2 * startFreq;
    float v_max = 60 * travel * 2 * endFreq;
    float v_step = 60 * travel * 2 * stepFreq;

    String gcode;
    gcode += "G91\n";   // 相对坐标

    for (float v = v_min; v <= v_max; v += v_step) {
        float freq = v / (60 * travel * 2);
        for (int cycle = 0; cycle < CYCLES_PER_FREQ; cycle++) {
            gcode += "G1 " + axis + String(travel) + " F" + String(v) + " ; ~" + String(freq, 2) + " Hz\n";
            gcode += "G1 " + axis + String(-travel) + " F" + String(v) + " ; ~" + String(freq, 2) + " Hz\n";
        }
        gcode += "G4 P800 ; pause for motion stop\n";   // 等待运动停止
    }
    return gcode;
}

2. 加速度信号采样与 FFT

在扫频过程中,ESP32 以 200Hz 采样率连续读取 ADXL345 的 X/Y 轴加速度。当检测到运动停止(RMS 低于阈值)且稳定 800ms 后,对最近的 128 个样本进行 FFT 分析。

void processFrequencySampling() {
    if (!freqSamplingActive) return;
    unsigned long now = micros();
    if (now - lastFreqSampleTime < FREQ_SAMPLE_INTERVAL_US) return; // 5ms 间隔
    lastFreqSampleTime = now;

    sensors_event_t e;
    accel.getEvent(&e);

    if (testAxis == "X") {
        freqDataX[freqSampleCount] = e.acceleration.x;
        freqDataY[freqSampleCount] = 0;
    } else if (testAxis == "Y") {
        freqDataX[freqSampleCount] = 0;
        freqDataY[freqSampleCount] = e.acceleration.y;
    } else { // XY
        freqDataX[freqSampleCount] = e.acceleration.x;
        freqDataY[freqSampleCount] = e.acceleration.y;
    }
    freqSampleCount++;

    if (freqSampleCount >= FFT_SAMPLES) {  // FFT_SAMPLES = 128
        freqSamplingActive = false;
        // 接下来调用 analyzeFrequencyWithNoiseFloor 进行 FFT 分析
        // ...
    }
}

3. FFT 与峰值提取

对 128 个时域样本做零填充至 1024 点,加汉明窗,计算幅度谱。然后:

  • 抑制噪声基底(如有预记录的噪声基底,则从频谱中减去)
  • 寻找前 10 个极大值点(避开直流分量),计算每个峰的置信度(综合考虑信噪比、Q 因子、相对幅值)
  • 选取置信度最高的频率作为该频率点的共振频率
float analyzeFrequencyWithNoiseFloor(float *data, int len, float samplingFreq, 
                                     float &confidence, float &altFreq, 
                                     const String& axis, float &peakAmplitude) {
    const int FFT_SIZE = 1024;
    const int HALF_FFT = FFT_SIZE / 2;

    // 去直流
    float mean = 0;
    for (int i = 0; i < len; i++) mean += data[i];
    mean /= len;
    for (int i = 0; i < len; i++) {
        fftReal[i] = data[i] - mean;
        fftImag[i] = 0.0;
    }
    for (int i = len; i < FFT_SIZE; i++) {
        fftReal[i] = 0.0;
        fftImag[i] = 0.0;
    }

    // 加汉明窗 + FFT
    FFT.windowing(fftReal, FFT_SIZE, FFT_WIN_TYP_HAMMING, FFT_FORWARD);
    FFT.compute(fftReal, fftImag, FFT_SIZE, FFT_FORWARD);
    FFT.complexToMagnitude(fftReal, fftImag, FFT_SIZE);

    // 计算功率谱密度 (PSD)
    float psd[HALF_FFT];
    float maxMag = 0;
    for (int i = 1; i < HALF_FFT; i++) {
        psd[i] = fftReal[i] * fftReal[i];
        if (psd[i] > maxMag) maxMag = psd[i];
    }

    // 噪声基底抑制(如果有)
    if (noiseFloorEnabled) {
        float* floor = (axis == "X") ? noiseFloorX : noiseFloorY;
        bool valid = (axis == "X") ? noiseFloorValidX : noiseFloorValidY;
        if (valid) {
            for (int i = 1; i < HALF_FFT && i < FFT_SAMPLES; i++) {
                psd[i] = max(0.0f, psd[i] - floor[i]);
            }
        }
    }

    // 峰值检测(找极大值)
    struct Peak { int idx; float mag; };
    Peak peaks[20];
    int peakCount = 0;
    for (int i = 3; i < HALF_FFT - 3; i++) {
        if (psd[i] > psd[i-1] && psd[i] > psd[i+1]) {
            // 合并邻近峰值
            // ...
        }
    }

    // 对每个峰值计算置信度(SNR、Q因子、相对幅值)
    float bestFreq = 0, bestConfidence = 0;
    for (int p = 0; p < peakCount && p < 10; p++) {
        int idx = peaks[p].idx;
        float freq = idx * samplingFreq / FFT_SIZE;
        if (freq < 5.0 || freq > 100.0) continue;

        float snr = calculateSNR(psd, HALF_FFT, idx, 5);
        float qFactor = calculateQFactor(psd, idx, samplingFreq, FFT_SIZE);
        float confidence_score = min(snr / 10.0f, 1.0f) * 0.5f +
                                 min(qFactor / 5.0f, 1.0f) * 0.3f +
                                 min(peaks[p].mag / maxMag, 1.0f) * 0.2f;
        if (confidence_score > bestConfidence) {
            bestFreq = freq;
            bestConfidence = confidence_score;
        }
    }
    confidence = bestConfidence;
    return (confidence > 0.5f) ? bestFreq : 0;
}

4. 幅频曲线与最终共振频率

扫频过程产生一系列离散频率点及对应的振幅(FFT 峰值幅值)。最后,使用二次插值(抛物线拟合)在最大振幅点附近插值,得到更精确的共振频率。

5. 写入打印机

得到频率后,自动发送 M593 X Fxx.x(或 Y)命令给打印机,同时存入 EEPROM 以便断电记忆。

运动检测与状态机

运动检测(非阻塞)

计算加速度矢量 RMS 偏差,判断是否运动:

void updateMotionState() {
    if (!resonanceTesting) { isMoving = false; return; }
    if (!motionDetectionEnabled) { isMoving = true; lastMotionTime = millis(); return; }

    const int samples = MOTION_SAMPLES_TOTAL;  // 20
    float diffs[samples];
    float sum = 0;
    for (int i = 0; i < samples; i++) {
        sensors_event_t e;
        accel.getEvent(&e);
        float total = sqrt(e.acceleration.x*e.acceleration.x + 
                           e.acceleration.y*e.acceleration.y + 
                           e.acceleration.z*e.acceleration.z);
        diffs[i] = total - GRAVITY;
        sum += diffs[i];
        delayMicroseconds(100);  // 100us 间隔,总体约 2ms
    }
    float mean = sum / samples;
    float sqSum = 0;
    for (int i = 0; i < samples; i++) {
        float dev = diffs[i] - mean;
        sqSum += dev * dev;
    }
    float rms = sqrt(sqSum / samples);
    bool nowMoving = (rms > motionThreshold);
    if (nowMoving) lastMotionTime = millis();
    isMoving = nowMoving;
}

校准状态机(三段式)

在 loop() 中,我们根据当前相位推进流程:

// 在 loop() 中
if (resonanceTesting && calibrationMode) {
    switch (calibPhase) {
        case INIT_WAITING:
            if (!isMoving) {
                if (phaseStableStart == 0) phaseStableStart = millis();
                if (millis() - phaseStableStart > STABLE_DELAY_MS) {
                    // 启动扫频文件
                    if (startPrint("SWEEP.GCO")) {
                        calibPhase = SWEEPING;
                        phaseStartTime = millis();
                        phaseStableStart = 0;
                    }
                }
            } else {
                phaseStableStart = 0;
            }
            break;

        case SWEEPING:
            if (!isMoving) {
                if (phaseStableStart == 0) phaseStableStart = millis();
                else {
                    unsigned long stableTime = millis() - phaseStableStart;
                    // 稳定时间达到频率间隔阈值,记录该频率点
                    if (stableTime >= PER_FREQ_STABLE_DELAY && !freqEndProcessed) {
                        // 记录 lastFx/lastFy 和 lastAmpX/lastAmpY 到 calib 数组
                        // 清空历史平均,准备下一频率
                        freqEndProcessed = true;
                    }
                    // 若稳定时间超过扫频结束超时,则启动结束文件
                    if (stableTime >= SWEEP_END_TIMEOUT) {
                        if (startPrint("FINISH.GCO")) {
                            calibPhase = FINISH_WAITING;
                            phaseStartTime = millis();
                        }
                    }
                }
            } else {
                phaseStableStart = 0;
                freqEndProcessed = false;
            }
            break;

        case FINISH_WAITING:
            // 类似,等待归位完成
            break;

        case RESULT:
            // 计算共振频率并发送 M593
            break;
    }
}

噪声基底抑制(提升信噪比)

记录静态噪声时,我们多次做 FFT 并平均功率谱,保存到 SPIFFS:

void saveNoiseFloorToSPIFFS(const String& axis) {
    File f = SPIFFS.open((axis == "X") ? "/noise_x.bin" : "/noise_y.bin", FILE_WRITE);
    if (!f) return;
    float* data = (axis == "X") ? noiseFloorX : noiseFloorY;
    f.write((uint8_t*)data, FFT_SAMPLES * sizeof(float));
    f.close();
}

void processNoiseCalibration() {
    // 采集 FFT_SAMPLES 个样本,做 FFT,累加功率谱到 accum
    // 当 noiseSampleCount 达到 NOISE_FFT_TOTAL 后,平均并保存
}

Web 接口与数据交互

后端暴露了多个 REST API,以下是数据返回格式(/data 端点):

void handleData() {
    StaticJsonDocument<256> doc;
    doc["freqX"] = currentFreqX;
    doc["freqY"] = currentFreqY;
    doc["moving"] = isMoving;
    doc["testing"] = resonanceTesting;
    doc["calibration"] = calibrationMode;
    doc["phaseProgress"] = phaseProgress;   // 0~100
    // 相位字符串
    switch (calibPhase) {
        case INIT_WAITING:   doc["calibPhase"] = "INIT_WAITING"; break;
        case SWEEPING:       doc["calibPhase"] = "SWEEPING"; break;
        // ...
    }
    sendJson(doc);
}

校准结果计算(幅频曲线插值)

在扫频结束后,我们从记录的所有 (频率, 幅值) 点中找出最大幅值,并做二次插值得到精确共振频率:

float calculateResonanceFreq(float* freqs, float* amps, int count) {
    if (count < 3) return 0;
    int maxIdx = 0;
    for (int i = 1; i < count; i++) {
        if (amps[i] > amps[maxIdx]) maxIdx = i;
    }
    // 二次插值
    if (maxIdx > 0 && maxIdx < count - 1) {
        float a = amps[maxIdx-1], b = amps[maxIdx], c = amps[maxIdx+1];
        float denom = a - 2*b + c;
        if (fabs(denom) > 1e-6) {
            float offset = (a - c) / (2 * denom);
            return freqs[maxIdx] + offset * (freqs[maxIdx+1] - freqs[maxIdx]);
        }
    }
    return freqs[maxIdx];
}

使用指南

硬件连接

  • ADXL345:VCC→3.3V,GND→GND,SDA→GPIO21,SCL→GPIO22。
  • OLED(可选):SDA→GPIO21,SCL→GPIO22(与加速度计共享 I²C 总线)。
  • 将 ESP32 靠近打印机,最好固定在热床上(或专用支架上)。

初次上电

  • ESP32 默认开启 AP 模式,SSID 为 ResonanceMeasurer,密码 12345678。
  • 用手机/电脑连接该 WiFi,浏览器访问 192.168.4.1。
  • 在页面中设置打印机 IP(打印机需在同一局域网且已开启 WiFi 模块,端口 80)。
  • 点击“连接”测试通信,自动读取固件信息(需支持 [ESP800] 命令)。
  • 输入热床尺寸和安全 Z 高度(保存后生效)。

执行校准

  • 选择测试轴(X 或 Y),设置扫频参数(建议起始 5~10Hz,结束 80~100Hz,步进 2Hz,移动距离 10~20mm,最大速度根据打印机能力)。
  • 点击“一键校准”,系统自动完成全部流程。
  • 校准过程中可观察实时频率和进度条,结束后自动发送 M593,频率结果会保存到 EEPROM。

其他功能

  • 测试传感器:只打开加速度计监测,不驱动打印机,适合手动检查振动。
  • 手动发送 G 代码:在文本框中输入任意 G 代码,可直接发送给打印机(用于调试)。
  • 噪声基底记录:在打印机完全静止时点击“记录噪声基底”,可提高后续分析的抗干扰能力。
  • 幅度阈值校准:自动设定信号有效的最小峰峰值,避免噪声被误判为共振。

总结

这套 ESP32 共振补偿校准仪,将复杂的振动检测与 Marlin 校准流程封装成简便的一键操作。它不仅具备专业仪器的精度,还拥有友好的 Web 界面和丰富的辅助功能,让普通创客也能轻松完成高速打印的共振调校。代码全部开源,可根据自身打印机型号微调参数,是 3D 打印爱好者提升打印品质的得力工具。


完整的项目代码可在 [TishonL/Marlin-Auto-InputShaper-ESP32: 一键自动校准 3D 打印机共振频率,基于 Marlin + ESP32 + ADXL345 实现 Input Shaping 自动调谐] 获取,欢迎 star 和 issue。

暂无评论

发送评论 编辑评论


上一篇