弹簧振子阻尼振动测量实验

2026-10-103

作品简介

本项目基于ESP32‑C5主控板搭配URM09超声波传感器,实现弹簧振子阻尼振动位移采集、自动峰值识别、阻尼系数β最小二乘拟合,网页端同时展示实测振动曲线、理论指数衰减包络曲线与振动峰值标记点,用来探究空气阻尼下弹簧振子衰减规律,验证经典线性阻尼振动模型。

 

相关原理知识介绍

阻尼振动是振动系统受到阻力作用,机械能不断损耗、振幅随时间逐步衰减的运动。弱阻尼(欠阻尼)条件下,经典线性阻尼振动振幅满足指数衰减公式:

弹簧振子阻尼振动测量实验_image_1.webp

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

 

流程图

弹簧振子阻尼振动测量实验_image_2.webp

 

第一步:头文件、宏定义与全局缓冲区

 

引入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进入实验网页

弹簧振子阻尼振动测量实验_image_3.webp
​3. 点击「开始15秒采样」,立刻释放弹簧振子,等待15秒自动结束采集

弹簧振子阻尼振动测量实验_image_4.webp
​4. 点击「拉取本次采样数据」,网页加载三条曲线,读取阻尼系数β与峰值数量

弹簧振子阻尼振动测量实验_image_5.webp
​5. 如果识别出来峰值过多(噪声误判),调大  PEAK_MIN_AMP ;峰值太少识别不到,减小这个数值

 

最后网页下翻会出现知识介绍

弹簧振子阻尼振动测量实验_image_6.webp

 

外观参考模型

弹簧振子阻尼振动测量实验_image_7.webp

 

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

创作许可协议

本项目采用 CC BY(署名) 进行许可。

评论(0)
- 没有更多了 -