弹簧振子阻尼振动测量实验
作品简介
本项目基于ESP32‑C5主控板搭配URM09超声波传感器,实现弹簧振子阻尼振动位移采集、自动峰值识别、阻尼系数β最小二乘拟合,网页端同时展示实测振动曲线、理论指数衰减包络曲线与振动峰值标记点,用来探究空气阻尼下弹簧振子衰减规律,验证经典线性阻尼振动模型。
相关原理知识介绍
阻尼振动是振动系统受到阻力作用,机械能不断损耗、振幅随时间逐步衰减的运动。弱阻尼(欠阻尼)条件下,经典线性阻尼振动振幅满足指数衰减公式:

A₀:初始振幅,振动第一个峰值相对于平衡位置的位移
β:阻尼系数(单位 s⁻¹),表征系统能量衰减快慢,β越大振动衰减越快
t:时间
本实验利用URM09超声波传感器非接触式测量弹簧振子竖直方向距离,ESP32‑C5定时采集位移数据;通过峰值检测算法自动提取每一次振动极大值,再对提取出来的峰值序列做最小二乘拟合求解阻尼系数β;最后利用得到的β反向绘制理论指数衰减包络线,将实测振动波形和理论衰减包络放在同一张图表对比,直观观察真实振动和线性阻尼理论之间的偏差,可进一步探究:不同初始释放振幅下,空气阻尼系数β是否保持不变,检验经典线性阻尼模型适用边界。
耗材清单:
ESP32‑C5开发板
DFRobot URM09(SEN0307)重力超声波传感器
弹簧振子实验装置(可自制,模型原理图在之后附上)
5V供电电源、杜邦线
接线说明:
URM09 VCC → 5V
URM09 GND → GND
URM09 AOUT → ESP32‑C5 ADC引脚4
代码介绍
开发环境
Arduino IDE,ESP32核心库,依赖WiFi.h 、 WebServer.h 、 ArduinoJson.h
流程图

第一步:头文件、宏定义与全局缓冲区
引入WiFi、网页服务器、JSON库;定义WiFi参数、ADC引脚、采样时长、峰值识别阈值;开辟数组用来存放原始采样值、滤波后位移、采样时间戳,同时定义结构体存储识别出来的振动峰值信息。
#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>
// ===================== 配置区 =====================
const char* WIFI_SSID = "你的WiFi名字";
const char* WIFI_PASS = "你的WiFi密码";
#define URM09_ADC_PIN 4
#define SAMPLE_RATE_MS 20
#define SAMPLE_WINDOW 750 // 15s / 20ms =750点
#define PEAK_MIN_DISTANCE 15
#define PEAK_MIN_AMP 8
// URM09 SEN0307 参数
#define URM09_VOLT_MIN 0.25
#define URM09_VOLT_MAX 2.75
#define URM09_RANGE_MIN_MM 200
#define URM09_RANGE_MAX_MM 5000
// ==================================================
WebServer server(80);
float rawBuf[SAMPLE_WINDOW];
float filterBuf[SAMPLE_WINDOW];
unsigned long timeBuf[SAMPLE_WINDOW];
int sampleIndex = 0;
// 采样状态机
enum SampleState {
STATE_IDLE, // 空闲等待触发
STATE_SAMPLING, // 正在15秒采样
STATE_FINISHED // 采集完成,已经算出峰值与β
};
SampleState sampleState = STATE_IDLE;
unsigned long sampleStartMs = 0;
const unsigned long SAMPLE_DURATION_MS = 15000;
// 峰值结构体
struct PeakPoint {
unsigned long t;
float amplitude;
int index;
};
#define MAX_PEAK_COUNT 50
PeakPoint peakList[MAX_PEAK_COUNT];
int peakCount = 0;
float betaResult = 0.0f;
float A0 = 0.0f;
bool calcReady = false;
第二步:传感器读取与滑动平均滤波函数
readURM09MM :读取ADC原始值,转换成电压,映射得到超声波距离(单位mm);
slidingFilter :5点滑动平均滤波,滤除超声波高频噪声,平滑位移曲线,降低虚假峰值出现概率。
float readURM09MM(void)
{
int adcRaw = analogRead(URM09_ADC_PIN);
float adcVolt = adcRaw * 3.3f / 4095.0f;
float ratio = constrain((adcVolt - URM09_VOLT_MIN) / (URM09_VOLT_MAX - URM09_VOLT_MIN), 0.0f,1.0f);
float distMM = URM09_RANGE_MIN_MM + ratio * (URM09_RANGE_MAX_MM - URM09_RANGE_MIN_MM);
return distMM;
}
float slidingFilter(float newVal)
{
static float fBuf[5];
static int fPtr = 0;
fBuf[fPtr] = newVal;
fPtr = (fPtr + 1) % 5;
float sum = 0;
for(int i=0;i<5;i++) sum += fBuf[i];
return sum / 5.0f;
}
第三步:峰值检测函数 detectPeaks
遍历全部采样点,比较当前点左右一段距离内数值,判断是否为局部极大值;再减去整条曲线平均值得到相对于平衡位置的振幅,只有振幅大于阈值 PEAK_MIN_AMP 才判定为有效振动峰值,存入峰值数组。
void detectPeaks(void)
{
peakCount = 0;
for(int i=PEAK_MIN_DISTANCE; i < SAMPLE_WINDOW - PEAK_MIN_DISTANCE; i++)
{
float curr = filterBuf[i];
bool isPeak = true;
for(int j = i - PEAK_MIN_DISTANCE; j < i; j++)
{
if(filterBuf[j] > curr) {isPeak = false; break;}
}
for(int j = i + 1; j < i + PEAK_MIN_DISTANCE; j++)
{
if(filterBuf[j] > curr) {isPeak = false; break;}
}
if(isPeak)
{
float meanVal = 0;
for(int k=0;k<SAMPLE_WINDOW;k++) meanVal += filterBuf[k];
meanVal /= SAMPLE_WINDOW;
float amp = abs(curr - meanVal);
if(amp > PEAK_MIN_AMP && peakCount < MAX_PEAK_COUNT)
{
peakList[peakCount].t = timeBuf[i];
peakList[peakCount].amplitude = amp;
peakList[peakCount].index = i;
peakCount++;
}
}
}
}
第四步:最小二乘拟合阻尼系数 fitBeta
利用提取出来的有效峰值序列,对 ln(A) 和 t 做线性最小二乘拟合,求出斜率k,阻尼系数 β=-k,同时记录第一个峰值作为初始振幅A0,用于后续生成理论衰减曲线。
void fitBeta(void)
{
A0 = 0;
if(peakCount < 4) { betaResult = 0; calcReady = true; return; }
A0 = peakList[0].amplitude;
float sumT = 0, sumY = 0, sumTT = 0, sumTY = 0;
int validN = 0;
unsigned long t0 = peakList[0].t;
for(int i=0;i<peakCount;i++)
{
if(peakList[i].amplitude <= 0) continue;
float tSec = (peakList[i].t - t0)/1000.0f;
float y = log(peakList[i].amplitude);
sumT += tSec;
sumY += y;
sumTT += tSec * tSec;
sumTY += tSec * tSec * y;
validN++;
}
if(validN <4) { betaResult =0; calcReady = true; return; }
float denom = validN * sumTT - sumT * sumT;
if(fabs(denom) < 1e-6) { betaResult =0; calcReady = true; return; }
float k = (validN * sumTY - sumT * sumY) / denom;
betaResult = -k;
calcReady = true;
}
第五步:网页接口函数(三个API)
1. startSample :网页点击「开始15秒采样」触发,清空缓冲区,切换状态到STATE_SAMPLING,正式启动采集
2. getSampleStatus :网页每500ms轮询这个接口,获取当前状态、β、峰值数量,用来更新页面文字状态
3. pullData :点击拉取数据时调用,把实测曲线、峰值点、根据β算出的理论指数衰减包络全部打包为JSON返回前端Chart渲染
void startSample(void)
{
if(sampleState == STATE_SAMPLING)
{
server.send(400, "text/plain", "ERR: Sampling running");
return;
}
sampleIndex = 0;
memset(rawBuf,0,sizeof(rawBuf));
memset(filterBuf,0,sizeof(filterBuf));
memset(timeBuf,0,sizeof(timeBuf));
peakCount = 0;
betaResult = 0;
A0 = 0;
calcReady = false;
sampleStartMs = millis();
sampleState = STATE_SAMPLING;
Serial.println("[INFO] Start 15 seconds sampling");
server.send(200, "text/plain", "OK");
}
void getSampleStatus(void)
{
StaticJsonDocument<256> doc;
doc["state"] = sampleState;
doc["beta"] = betaResult;
doc["A0"] = A0;
doc["peakCount"] = peakCount;
String out;
serializeJson(doc, out);
server.send(200, "application/json", out);
}
void pullData(void)
{
if(sampleState != STATE_FINISHED)
{
server.send(400, "text/plain", "ERR: No finished sample data");
return;
}
StaticJsonDocument<12288> doc;
JsonArray labels = doc.createNestedArray("labels");
JsonArray filterData = doc.createNestedArray("filterData");
JsonArray peakData = doc.createNestedArray("peakData");
JsonArray theoryData = doc.createNestedArray("theoryData");
doc["beta"] = betaResult;
doc["A0"] = A0;
doc["peakCount"] = peakCount;
float meanVal = 0;
for(int i=0;i<SAMPLE_WINDOW;i++) meanVal += filterBuf[i];
meanVal /= SAMPLE_WINDOW;
int startIdx = (peakCount > 0) ? peakList[0].index : 0;
float dtSec = SAMPLE_RATE_MS / 1000.0f;
for(int i=0;i<SAMPLE_WINDOW;i++)
{
labels.add(i);
filterData.add(filterBuf[i]);
peakData.add(null);
if(i >= startIdx && A0 > 1e-3 && betaResult > 1e-6)
{
float t = (i - startIdx) * dtSec;
float theoryAmp = A0 * exp(-betaResult * t);
theoryData.add(meanVal + theoryAmp);
}else{
theoryData.add(null);
}
}
for(int p=0;p<peakCount;p++)
{
int idx = peakList[p].index;
if(idx >=0 && idx < SAMPLE_WINDOW)
{
peakData[idx] = filterBuf[idx];
}
}
String out;
serializeJson(doc, out);
server.send(200, "application/json", out);
}
第六步:内置前端网页 getWebPage
将完整HTML页面写在C++字符串里面,ESP32收到浏览器访问根路径 / 直接返回网页;页面包含两个按钮、状态提示、β和峰值数量展示,基于Chart.js绘制三条曲线(蓝色实测、绿色虚线理论包络、红色圆点峰值),同时展示实验原理文字说明。
String getWebPage(void)
{
String html = R"HTML(
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>弹簧振子阻尼 ESP32-C5|实测+理论衰减对比</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
*{box-sizing:border-box;font-family:system-ui, sans-serif;}
body{margin:12px;background:#f8f8f8;}
.card{background:white;border-radius:8px;padding:16px;margin-bottom:12px;box-shadow:0 2px 8px #00000018;}
.chartContainer{height:420px;}
.infoRow{display:flex;gap:12px;flex-wrap:wrap;align-items:center;}
.infoItem{padding:10px;background:#f0f7ff;border-radius:6px;flex:1;min-width:140px;}
.knowledge{background:#fffbe8;border:1px solid #ffe89c;}
button{padding:10px 18px;color:white;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
#btnStart{background:#16a34a;}
#btnPull{background:#2563eb;}
#statusText{font-size:18px;font-weight:bold;}
h3{margin-top:0;}
</style>
</head>
<body>
<div class="card infoRow">
<button id="btnStart" onclick="sendStart()">▶️ 开始15秒采样</button>
<button id="btnPull" onclick="sendPull()">📥 拉取本次采样数据</button>
<div id="statusText">状态:空闲,等待开始采样</div>
<div class="infoItem"><b>阻尼系数 β:</b><span id="betaVal">0.000</span> s⁻¹</div>
<div class="infoItem"><b>检出峰值数量:</b><span id="peakNum">0</span></div>
</div>
<div class="card">
<h3>📈 蓝色=实测滤波位移|绿色虚线=理论指数衰减包络|红色圆点=振动峰值</h3>
<div class="chartContainer">
<canvas id="myChart"></canvas>
</div>
</div>
<div class="card knowledge">
<h3>📖 相关知识点</h3>
<p>弹簧振子阻尼振动模型:A(t)=A₀·e^(-β t),β即阻尼系数;A₀为初始振幅,绿色虚线是基于本次拟合β得到的理想指数衰减包络线,用来和实测振动峰值对比,观察实际振动与线性阻尼理论之间的偏差。本实验自变量为初始释放振幅,探究空气阻尼条件下β是否随振幅改变,检验经典线性阻尼模型的适用边界。</p>
<p>流程:点击按钮启动 → 连续15s采集URM09超声波距离 → 滑动平均滤波降噪 → 峰值提取识别振动波峰 → 最小二乘拟合指数衰减曲线得到阻尼β → 生成理论衰减包络曲线 → 手动拉取数据在网页可视化展示实测曲线、理论曲线与峰值特殊点。</p>
</div>
<script>
const ctx = document.getElementById('myChart').getContext('2d');
const chart = new Chart(ctx, {
type:'line',
data:{
labels:[],
datasets:[
{label:"实测滤波位移(mm)", data:[], borderColor:"#2563eb", fill:false, tension:0.2},
{label:"理论指数衰减包络", data:[], borderColor:"#22a850", borderDash:[5,4], fill:false, tension:0.2},
{label:"振动峰值点", data:[], pointRadius:6, pointHoverRadius:8, pointBackgroundColor:"#dc2626", showLine:false}
]
},
options:{responsive:true, maintainAspectRatio:false, scales:{x:{title:{display:true,text:"采样序号"}},y:{title:{display:true,text:"距离(mm)"}}}}
});
let isSampling = false;
function pollStatus(){
fetch("/status").then(r=>r.json()).then(json=>{
const state = json.state;
const beta = json.beta;
const pc = json.peakCount;
document.getElementById("betaVal").innerText = beta.toFixed(4);
document.getElementById("peakNum").innerText = pc;
if(state === 0){
document.getElementById("statusText").innerText = "状态:空闲,等待开始采样";
isSampling = false;
}else if(state ===1){
document.getElementById("statusText").innerText = "🔴 状态:正在15秒采样,请保持振子运动";
isSampling = true;
}else if(state ===2){
document.getElementById("statusText").innerText = "🟢 状态:采样完成,请点击拉取本次采样数据";
isSampling = false;
}
}).catch(e=>console.error(e));
}
function sendStart(){
if(isSampling) return alert("正在采样,请勿重复触发");
fetch("/start").then(r=>{
if(r.ok){
alert("✅ 已启动15秒采样,请立刻释放弹簧振子");
}else{
alert("❌ 启动采样失败");
}
}).catch(err=>alert("网络异常"));
}
function sendPull(){
fetch("/pulldata").then(r=>{
if(!r.ok) return alert("❌ 没有可用采样数据,请先完成一轮15s采样");
return r.json();
}).then(json=>{
chart.data.labels = json.labels;
chart.data.datasets[0].data = json.filterData;
chart.data.datasets[1].data = json.theoryData;
chart.data.datasets[2].data = json.peakData;
chart.update();
document.getElementById("betaVal").innerText = json.beta.toFixed(4);
document.getElementById("peakNum").innerText = json.peakCount;
alert("✅ 实测曲线+理论衰减曲线已更新");
}).catch(err=>alert("拉取失败"));
}
setInterval(pollStatus, 500);
</script>
</body>
</html>
)HTML";
return html;
}
第七步:WiFi连接、setup初始化、loop主循环
wifiConnect :ESP32上电连接指定WiFi,串口打印分配得到的IP地址;
setup :初始化串口、ADC引脚,注册网页路由(根路径、/start、/status、/pulldata),启动WebServer;
loop :持续处理网页请求;当状态处于STATE_SAMPLING,按照20ms间隔采集URM09数据存入缓冲区;15秒时间到后自动切换STATE_FINISHED,执行峰值检测和阻尼系数拟合。
void wifiConnect(void)
{
Serial.printf("Connecting WiFi: %s\r\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
Serial.println("\r\nWiFi connected");
Serial.print("ESP32-C5 IP Address: ");
Serial.println(WiFi.localIP());
}
void setup(void)
{
Serial.begin(115200);
pinMode(URM09_ADC_PIN, INPUT);
wifiConnect();
server.on("/", [](){server.send(200, "text/html", getWebPage());});
server.on("/start", startSample);
server.on("/status", getSampleStatus);
server.on("/pulldata", pullData);
server.begin();
Serial.println("Web server ready, with theory curve");
}
void loop(void)
{
server.handleClient();
static unsigned long lastSampleTick = 0;
unsigned long now = millis();
if(sampleState == STATE_SAMPLING)
{
if(now - sampleStartMs >= SAMPLE_DURATION_MS)
{
sampleState = STATE_FINISHED;
Serial.println("[INFO] 15s sampling finished, start calculate peaks and beta");
detectPeaks();
fitBeta();
Serial.printf("[INFO] calculation done, peakCount=%d, beta=%.4f, A0=%.2f\r\n", peakCount, betaResult, A0);
return;
}
if(now - lastSampleTick >= SAMPLE_RATE_MS && sampleIndex < SAMPLE_WINDOW)
{
lastSampleTick = now;
float rawVal = readURM09MM();
float filVal = slidingFilter(rawVal);
rawBuf[sampleIndex] = rawVal;
filterBuf[sampleIndex] = filVal;
timeBuf[sampleIndex] = now;
sampleIndex++;
}
}
}
完整全部代码
/*
ESP32-C5 弹簧振子阻尼|15秒单次采样 + 手动拉取 + 叠加理论衰减曲线
传感器:DFRobot URM09 SEN0307 重力超声波(模拟AOUT输出)
功能:点击开始15s采样 → 采集结束自动算峰值、β、理论衰减曲线 → 点击拉取渲染三条曲线
曲线说明:蓝色=实测滤波位移,绿色虚线=理论指数衰减包络,红色圆点=振动峰值
*/
#include <WiFi.h>
#include <WebServer.h>
#include <ArduinoJson.h>
// ===================== 配置区 =====================
const char* WIFI_SSID = "你的WiFi名字";
const char* WIFI_PASS = "你的WiFi密码";
#define URM09_ADC_PIN 4
#define SAMPLE_RATE_MS 20
#define SAMPLE_WINDOW 750 // 15s / 20ms =750点
#define PEAK_MIN_DISTANCE 15
#define PEAK_MIN_AMP 8
// URM09 SEN0307 参数
#define URM09_VOLT_MIN 0.25
#define URM09_VOLT_MAX 2.75
#define URM09_RANGE_MIN_MM 200
#define URM09_RANGE_MAX_MM 5000
// ==================================================
WebServer server(80);
float rawBuf[SAMPLE_WINDOW];
float filterBuf[SAMPLE_WINDOW];
unsigned long timeBuf[SAMPLE_WINDOW];
int sampleIndex = 0;
// 采样状态机
enum SampleState {
STATE_IDLE, // 空闲等待触发
STATE_SAMPLING, // 正在15秒采样
STATE_FINISHED // 15s采集完成,已算出峰值和β
};
SampleState sampleState = STATE_IDLE;
unsigned long sampleStartMs = 0;
const unsigned long SAMPLE_DURATION_MS = 15000; // 15秒采样时长
struct PeakPoint {
unsigned long t;
float amplitude;
int index;
};
#define MAX_PEAK_COUNT 50
PeakPoint peakList[MAX_PEAK_COUNT];
int peakCount = 0;
float betaResult = 0.0f;
float A0 = 0.0f; // 第一个峰值振幅,用于生成理论衰减曲线
bool calcReady = false;
// URM09读取距离mm
float readURM09MM(void)
{
int adcRaw = analogRead(URM09_ADC_PIN);
float adcVolt = adcRaw * 3.3f / 4095.0f;
float ratio = constrain((adcVolt - URM09_VOLT_MIN) / (URM09_VOLT_MAX - URM09_VOLT_MIN), 0.0f,1.0f);
float distMM = URM09_RANGE_MIN_MM + ratio * (URM09_RANGE_MAX_MM - URM09_RANGE_MIN_MM);
return distMM;
}
// 滑动平均滤波,窗口5
float slidingFilter(float newVal)
{
static float fBuf[5];
static int fPtr = 0;
fBuf[fPtr] = newVal;
fPtr = (fPtr + 1) % 5;
float sum = 0;
for(int i=0;i<5;i++) sum += fBuf[i];
return sum / 5.0f;
}
// 峰值提取,只在采集结束运行一次
void detectPeaks(void)
{
peakCount = 0;
for(int i=PEAK_MIN_DISTANCE; i < SAMPLE_WINDOW - PEAK_MIN_DISTANCE; i++)
{
float curr = filterBuf[i];
bool isPeak = true;
for(int j = i - PEAK_MIN_DISTANCE; j < i; j++)
{
if(filterBuf[j] > curr) {isPeak = false; break;}
}
for(int j = i + 1; j < i + PEAK_MIN_DISTANCE; j++)
{
if(filterBuf[j] > curr) {isPeak = false; break;}
}
if(isPeak)
{
float meanVal = 0;
for(int k=0;k<SAMPLE_WINDOW;k++) meanVal += filterBuf[k];
meanVal /= SAMPLE_WINDOW;
float amp = abs(curr - meanVal);
if(amp > PEAK_MIN_AMP && peakCount < MAX_PEAK_COUNT)
{
peakList[peakCount].t = timeBuf[i];
peakList[peakCount].amplitude = amp;
peakList[peakCount].index = i;
peakCount++;
}
}
}
}
// 最小二乘拟合β,采集结束运行一次
void fitBeta(void)
{
A0 = 0;
if(peakCount < 4) { betaResult = 0; calcReady = true; return; }
// 取第一个有效峰值作为初始振幅A0
A0 = peakList[0].amplitude;
float sumT = 0, sumY = 0, sumTT = 0, sumTY = 0;
int validN = 0;
unsigned long t0 = peakList[0].t;
for(int i=0;i<peakCount;i++)
{
if(peakList[i].amplitude <= 0) continue;
float tSec = (peakList[i].t - t0)/1000.0f;
float y = log(peakList[i].amplitude);
sumT += tSec;
sumY += y;
sumTT += tSec * tSec;
sumTY += tSec * tSec * y;
validN++;
}
if(validN <4) { betaResult =0; calcReady = true; return; }
float denom = validN * sumTT - sumT * sumT;
if(fabs(denom) < 1e-6) { betaResult =0; calcReady = true; return; }
float k = (validN * sumTY - sumT * sumY) / denom;
betaResult = -k;
calcReady = true;
}
// 开始15s采样接口,网页触发
void startSample(void)
{
if(sampleState == STATE_SAMPLING)
{
server.send(400, "text/plain", "ERR: Sampling running");
return;
}
// 清空缓冲区
sampleIndex = 0;
memset(rawBuf,0,sizeof(rawBuf));
memset(filterBuf,0,sizeof(filterBuf));
memset(timeBuf,0,sizeof(timeBuf));
peakCount = 0;
betaResult = 0;
A0 = 0;
calcReady = false;
sampleStartMs = millis();
sampleState = STATE_SAMPLING;
Serial.println("[INFO] Start 15 seconds sampling");
server.send(200, "text/plain", "OK");
}
// 获取当前状态接口(网页轮询)
void getSampleStatus(void)
{
StaticJsonDocument<256> doc;
doc["state"] = sampleState;
doc["beta"] = betaResult;
doc["A0"] = A0;
doc["peakCount"] = peakCount;
String out;
serializeJson(doc, out);
server.send(200, "application/json", out);
}
// 拉取完整15s数据接口
void pullData(void)
{
if(sampleState != STATE_FINISHED)
{
server.send(400, "text/plain", "ERR: No finished sample data");
return;
}
StaticJsonDocument<12288> doc;
JsonArray labels = doc.createNestedArray("labels");
JsonArray filterData = doc.createNestedArray("filterData");
JsonArray peakData = doc.createNestedArray("peakData");
JsonArray theoryData = doc.createNestedArray("theoryData");
doc["beta"] = betaResult;
doc["A0"] = A0;
doc["peakCount"] = peakCount;
// 先求振动平衡位置均值
float meanVal = 0;
for(int i=0;i<SAMPLE_WINDOW;i++) meanVal += filterBuf[i];
meanVal /= SAMPLE_WINDOW;
// 找到第一个峰值的采样序号,作为理论曲线起始点
int startIdx = (peakCount > 0) ? peakList[0].index : 0;
float dtSec = SAMPLE_RATE_MS / 1000.0f;
for(int i=0;i<SAMPLE_WINDOW;i++)
{
labels.add(i);
filterData.add(filterBuf[i]);
peakData.add(null);
// 理论衰减曲线,以平衡位置为中心向上包络
if(i >= startIdx && A0 > 1e-3 && betaResult > 1e-6)
{
float t = (i - startIdx) * dtSec;
float theoryAmp = A0 * exp(-betaResult * t);
theoryData.add(meanVal + theoryAmp);
}else{
theoryData.add(null);
}
}
for(int p=0;p<peakCount;p++)
{
int idx = peakList[p].index;
if(idx >=0 && idx < SAMPLE_WINDOW)
{
peakData[idx] = filterBuf[idx];
}
}
String out;
serializeJson(doc, out);
server.send(200, "application/json", out);
}
String getWebPage(void)
{
String html = R"HTML(
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>弹簧振子阻尼 ESP32-C5|实测+理论衰减对比</title>
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
<style>
*{box-sizing:border-box;font-family:system-ui, sans-serif;}
body{margin:12px;background:#f8f8f8;}
.card{background:white;border-radius:8px;padding:16px;margin-bottom:12px;box-shadow:0 2px 8px #00000018;}
.chartContainer{height:420px;}
.infoRow{display:flex;gap:12px;flex-wrap:wrap;align-items:center;}
.infoItem{padding:10px;background:#f0f7ff;border-radius:6px;flex:1;min-width:140px;}
.knowledge{background:#fffbe8;border:1px solid #ffe89c;}
button{padding:10px 18px;color:white;border:none;border-radius:6px;font-size:16px;cursor:pointer;}
#btnStart{background:#16a34a;}
#btnPull{background:#2563eb;}
#statusText{font-size:18px;font-weight:bold;}
h3{margin-top:0;}
</style>
</head>
<body>
<div class="card infoRow">
<button id="btnStart" onclick="sendStart()">▶️ 开始15秒采样</button>
<button id="btnPull" onclick="sendPull()">📥 拉取本次采样数据</button>
<div id="statusText">状态:空闲,等待开始采样</div>
<div class="infoItem"><b>阻尼系数 β:</b><span id="betaVal">0.000</span> s⁻¹</div>
<div class="infoItem"><b>检出峰值数量:</b><span id="peakNum">0</span></div>
</div>
<div class="card">
<h3>📈 蓝色=实测滤波位移|绿色虚线=理论指数衰减包络|红色圆点=振动峰值</h3>
<div class="chartContainer">
<canvas id="myChart"></canvas>
</div>
</div>
<div class="card knowledge">
<h3>📖 相关知识点</h3>
<p>弹簧振子阻尼振动模型:A(t)=A₀·e^(-β t),β即阻尼系数;A₀为初始振幅,绿色虚线是基于本次拟合β得到的理想指数衰减包络线,用来和实测振动峰值对比,观察实际振动与线性阻尼理论之间的偏差。本实验自变量为初始释放振幅,探究空气阻尼条件下β是否随振幅改变,检验经典线性阻尼模型的适用边界。</p>
<p>流程:点击按钮启动 → 连续15s采集URM09超声波距离 → 滑动平均滤波降噪 → 峰值提取识别振动波峰 → 最小二乘拟合指数衰减曲线得到阻尼β → 生成理论衰减包络曲线 → 手动拉取数据在网页可视化展示实测曲线、理论曲线与峰值特殊点。</p>
</div>
<script>
const ctx = document.getElementById('myChart').getContext('2d');
const chart = new Chart(ctx, {
type:'line',
data:{
labels:[],
datasets:[
{label:"实测滤波位移(mm)", data:[], borderColor:"#2563eb", fill:false, tension:0.2},
{label:"理论指数衰减包络", data:[], borderColor:"#22a850", borderDash:[5,4], fill:false, tension:0.2},
{label:"振动峰值点", data:[], pointRadius:6, pointHoverRadius:8, pointBackgroundColor:"#dc2626", showLine:false}
]
},
options:{responsive:true, maintainAspectRatio:false, scales:{x:{title:{display:true,text:"采样序号"}},y:{title:{display:true,text:"距离(mm)"}}}}
});
let isSampling = false;
function pollStatus(){
fetch("/status").then(r=>r.json()).then(json=>{
const state = json.state;
const beta = json.beta;
const pc = json.peakCount;
document.getElementById("betaVal").innerText = beta.toFixed(4);
document.getElementById("peakNum").innerText = pc;
if(state === 0){
document.getElementById("statusText").innerText = "状态:空闲,等待开始采样";
isSampling = false;
}else if(state ===1){
document.getElementById("statusText").innerText = "🔴 状态:正在15秒采样,请保持振子运动";
isSampling = true;
}else if(state ===2){
document.getElementById("statusText").innerText = "🟢 状态:采样完成,请点击拉取本次采样数据";
isSampling = false;
}
}).catch(e=>console.error(e));
}
function sendStart(){
if(isSampling) return alert("正在采样,请勿重复触发");
fetch("/start").then(r=>{
if(r.ok){
alert("✅ 已启动15秒采样,请立刻释放弹簧振子");
}else{
alert("❌ 启动采样失败");
}
}).catch(err=>alert("网络异常"));
}
function sendPull(){
fetch("/pulldata").then(r=>{
if(!r.ok) return alert("❌ 没有可用采样数据,请先完成一轮15s采样");
return r.json();
}).then(json=>{
chart.data.labels = json.labels;
chart.data.datasets[0].data = json.filterData;
chart.data.datasets[1].data = json.theoryData;
chart.data.datasets[2].data = json.peakData;
chart.update();
document.getElementById("betaVal").innerText = json.beta.toFixed(4);
document.getElementById("peakNum").innerText = json.peakCount;
alert("✅ 实测曲线+理论衰减曲线已更新");
}).catch(err=>alert("拉取失败"));
}
setInterval(pollStatus, 500);
</script>
</body>
</html>
)HTML";
return html;
}
void wifiConnect(void)
{
Serial.printf("Connecting WiFi: %s\r\n", WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(WIFI_SSID, WIFI_PASS);
while (WiFi.status() != WL_CONNECTED)
{
delay(500);
Serial.print(".");
}
Serial.println("\r\nWiFi connected");
Serial.print("ESP32-C5 IP Address: ");
Serial.println(WiFi.localIP());
}
void setup(void)
{
Serial.begin(115200);
pinMode(URM09_ADC_PIN, INPUT);
wifiConnect();
server.on("/", [](){server.send(200, "text/html", getWebPage());});
server.on("/start", startSample);
server.on("/status", getSampleStatus);
server.on("/pulldata", pullData);
server.begin();
Serial.println("Web server ready, with theory curve");
}
void loop(void)
{
server.handleClient();
static unsigned long lastSampleTick = 0;
unsigned long now = millis();
if(sampleState == STATE_SAMPLING)
{
if(now - sampleStartMs >= SAMPLE_DURATION_MS)
{
sampleState = STATE_FINISHED;
Serial.println("[INFO] 15s sampling finished, start calculate peaks and beta");
detectPeaks();
fitBeta();
Serial.printf("[INFO] calculation done, peakCount=%d, beta=%.4f, A0=%.2f\r\n", peakCount, betaResult, A0);
return;
}
if(now - lastSampleTick >= SAMPLE_RATE_MS && sampleIndex < SAMPLE_WINDOW)
{
lastSampleTick = now;
float rawVal = readURM09MM();
float filVal = slidingFilter(rawVal);
rawBuf[sampleIndex] = rawVal;
filterBuf[sampleIndex] = filVal;
timeBuf[sampleIndex] = now;
sampleIndex++;
}
}
}
使用说明与调试提示
1. 修改代码开头WiFi名称和密码,选择开发板 ESP32-C5 ,上传
2. 打开串口监视器,查看ESP获取到的局域网IP,浏览器访问这个IP进入实验网页

3. 点击「开始15秒采样」,立刻释放弹簧振子,等待15秒自动结束采集

4. 点击「拉取本次采样数据」,网页加载三条曲线,读取阻尼系数β与峰值数量

5. 如果识别出来峰值过多(噪声误判),调大 PEAK_MIN_AMP ;峰值太少识别不到,减小这个数值
最后网页下翻会出现知识介绍

外观参考模型

相比传统手工秒表记录、纸笔计算的方式,本装置可以自动、连续采集振动全过程数据,减少人为读数带来的误差,把抽象的阻尼振动物理现象转化为可量化、可回看的曲线数据,适合作为中学物理简谐运动与阻尼振动的数字化实验教具。





