目录
1 前言
2 选题分析
3 前置电路设计
4 理论分析
4.1 离散傅里叶变换基础
4.2 采样点数和采样频率的确定
4.3 频谱泄露与窗函数
4.4 失真度计算公式
5 代码分享
5.1 采样相关代码
5.2 FFT变换代码
5.3 求失真度
5.4 其他
5.5 成品
6 结尾
延期三个月的电赛终于在11.4-11.7进行了,如今各赛区的成绩也都出来了,很遗憾只拿了个省二,不过没关系,就当是大四枯燥生活的一点小插曲啦。虽然自己做得并不算很好,毕竟好像这道题很多满分的大佬,但还是决定记录和分享一下自己的思路,对比赛做一个复盘,为整个比赛画上一个圆满的句号。
题目:设计一个失真度分析仪,只取前五次谐波作近似计算,要能够在屏幕上显示失真度、归一化幅值和一个周期的波形,并能将结果无线传输至手机显示。
选题原因:因为暑假准备的时候刚好用过ARM官方的DSP库,对FFT运算也有了一些了解,觉得A题应该挺好做的,于是就选了A题。(毕竟其它题也不会(狗头
思路:思路应该也比较清晰,用adc采集数据作FFT分析,得到各次谐波的幅值并带入公式计算即可得到失真度THD。
难点:前置放大电路设计、无线发送、显示这些都不难,难点应该是在如何调Ti板子的ADC和DMA以及如何对FFT频谱泄露进行处理以提高测量精度。
实现情况与吐槽:手头上只有一个Tiva的版子,搞了很久终于调通了ADC,但是DMA一直弄不好,最后只好用中断的方式去读ADC。因为中断耗时比较久,采样率根本达不到标称的1Msps,实际上采样速率设置到600Ksps就会出现数据混乱的情况了,所以我们的作品太高速率的信号是测不准的。于是把精力都花费在了应对频谱泄露上,最后在我们频率范围内(即最高谐波频率不高于300K)的信号都能测得比较准,题目要求误差小于3%我们能达到1%以内。可惜实际测评时只测了三组数据,频率分别是1K、50K、100K,这些频率点可能不会出现频谱泄露的情况因此我们的优点也展示不出来,而且只有三组数据未免也太少了吧,拉不开差距,我们只能测两组所以拿了省二也不出乎意料,唉。如果能够捉对重点,尝试着去调DMA或者弄一个更好的板子比如tm4c1294或者msp432e系列就好了。
采样前首先需要将信号放大到一个合适的范围以增加采样精度,放大电路如下图所示。
最开始部分为一个100Hz的高通滤波器用于滤除输入信号的直流成分,因为如果带着直流分量直接放大,输出结果有可能超出ADC测量范围。实际测评中信号并没有出现直流分量,所以这个滤波器是可以去掉的。
其后是一个基础的正向放大器,由于最大输入信号峰峰值为600mV,而量程是3.3V,所以最大只能放大个5倍,由于手里的电容电阻大小限制,最放大倍数是
差点忘记说了,由于是失真度测量,所以选用的运放本身最好是低失真的的,我们手头上有准备THS3001发现它参数还不错,以及增益带宽积和压摆率都足够于是就用了。
由于MCU片内ADC无法测量负电压,因而放大后需要将信号电压抬升到ADC的测量范围内,我们用的是分压式的电压抬升电路,这个电路要求后置电路(也就是adc)的输入电阻足够大,不然由于负载效应的存在可能影响抬升的大小。这里两个电阻对5V进行分压,理论上抬升1.56V,实际抬升到1.54V,误差不大。
离散傅立叶变换(DFT)是将时域信号转换到频域分析的一种重要方法,而FFT是DFT的快速算法。N点FFT的运算结果为N点复数,设采样频率为Fs ,则第n点处所表示的频率Fn为:
Fn = (n-1) * Fs
记FFT结果的第n个的幅值为An,那么它对应频率的时域幅值大小Bn满足:
Bn = An / N,当n=1时
Bn = An * 2 / N,当n>1时
也就是说,n为1即该分量为直流分量时,实际时域幅值为FFT幅值的1/N倍,而n不为1时,实际时域幅值为FFT幅值的2/N倍,下面看一个仿真:
>> N = 1024; % 1024点
>> Fs = 1000; % 采样率1000Hz
>> T = 1/Fs;
>> t = (0:N-1)*T;
>> x = 10 + 15 * sin(2*pi*100*t) + 8 * sin(2*pi*200*t); % 直流、100Hz、200Hz
>> stem(abs(fft(x)))
结果如下:
由FFT结果知道信号有三个频率点,分别是
(1-1)*1000/1024 = 0Hz
(103-1)*1000/1024 = 99.6Hz
(206-1)*1000/1024 = 200.2Hz
这与我们输入信号的频率是符合的。再来看幅值,根据之前提到的转换方式,上述三个频率对应的时域幅值是
10280/1024=10.03
5823/512=11.37
3835/512=7.48
除了第二个频率计算出来的幅值11.37跟实际幅值15相比误差较大,其他跟我们输入信号的幅值是符合的。
文章后面会陆续分析频率误差和幅值误差产生的原因。
在上面的仿真结果中,我们由结果计算出来的频率与实际频率有一点的误差,这是因为栅栏效应的存在。
要明白栅栏效应,就要先知道频谱分辨率,频谱分辨率是两根谱线之间的间隔,假设采样频率为Fs,采样间隔为Ts ,总采样时间为T0,采样点数为N ,那么频谱分辨率为:
对于上面的仿真例子,频谱分辨率为1000/1024 = 0.9765Hz,即两根谱线之间间隔0.9765Hz,100和200都不是这个数的整个倍,因而在谱线中我们不能直接看到这两个频率点,而是看到它附近的 99.6Hz和200.2Hz。
可能上面这个例子误差并不大,那么再举个夸张点的例子,假如频谱分辨率是40Hz,实际信号分量有400Hz和420Hz,那么实际上我们可能只能看到400Hz的那根谱线,420Hz的谱线处于400和440之间,我们是看不到的,那么我们分析时可能就会忽略这个分量。
一根根的谱线就像一条条栅栏,我们看不到栅栏之间究竟有什么,这就是栅栏效应。为了能够看到更多东西,我们就得拉近栅栏之间的间隔,即提高频谱分辨率。
如何提高频谱分辨率:
1.由频谱分辨率的公式可知,采样点数越多,总采样时间越长,频谱分辨率越高;但采样点数越多,样本点存储和FFT运算会消耗更多的RAM空间。
2.另外一个影响频谱分辨率的因素是采样速率Fs,Fs越小,总采样时间越长,频谱分辨率越高;但是因为采样定理的存在,Fs要大于原信号频率的两倍,一般至少要大于原信号频率的五倍以上才能比较好的还原信号。
结论:
增加点数和减小采样速率都有其弊端,我们综合考量之下,选择采样点数为2048。而采样速率使用的是一种动态调整的方式,先用最快的速率采样一次分析得出大致的基波频率,再根据这个基波频率,在满足采样定理的情况下减小采样速率,用这个速率再采样一次并作FFT分析,此时分辨率高,结果也会比较准确。
另外还想提一下,一开始只想到要根据基波频率调整采样率才会准,但没想到究竟怎么测基波频率,考虑过作一个F/V转换电路,或者将信号整形成方波测脉冲宽度来着,但由于能力和模块有限放弃了,后来才恍然大雾,用极快的采样速率采一次得到的频率虽然不准但是知道个大致的范围去调整也ok呀,于是就成了。
还是回到一开始的仿真例子,明明输入信号只有三个分量,看结果图却发现那三个频率附近还有好多根谱线,这是不是说这些谱线对应的频率也存在?并不是这样的。
信号往往是无限长的,而我们只能截取其中一部分来进行频谱分析。当进行非周期截断时,频谱中除了本来该有的主瓣之外,还会出现本不该有的旁瓣,这种现象就是频谱泄露。频谱泄露会把能量泄漏到附近的频率上,影响我们的分析,因而需要减少频谱泄露。
在上面的例子中,信号频率为100Hz,而采样率是1000Hz,即一个周期采10个点,采1024个点总共是102.4个周期,因而是把信号非周期截断了,于是就出现了频谱泄露。
现在我们把采样率改成1280看看同一个信号的傅里叶变换结果如何:
>> Fs = 1280; % 采样率1280Hz
>> T = 1/Fs;
>> N = 1024; % 1024点
>> t = (0:N-1)*T;
>> x = 10 + 15 * sin(2*pi*100*t) + 8 * sin(2*pi*200*t); % 直流,100Hz,200Hz
>> stem(abs(fft(x)))
WOW~ ⊙o⊙,非常干净的谱线,这是因为采1024个点刚好用了80个周期,是周期截断的,这时候很明显就没有所谓的频谱泄露了。
用这个结果再去分析下这个信号,因为点数没有增加,频谱分辨率没变,能得到的信号频率还是有点误差,但计算得到的幅值变成了10、15、8,简直和原信号一模一样!
那么,就有这么一种思路,我们用某种方法准确得到信号周期,然后计算出一个合适的采样率使得可以对信号周期截断,然后就能得到准确的结果啦!这种方法叫做同步采样。
但是,前面说过了fft的方式只能得到大致的基波周期范围,就算能准确知道信号周期,但是单片机的定时器也不一定能准确地设定采样间隔,比如算出来的计数器初值是个小数那可怎么办。同步采样可以用硬件电路的方式实现,但是在数字系统中还是较难的,那还没有别的办法减少频谱泄露呢。有!那就是用加窗。
窗函数有多种,如矩形窗(就是不加窗)、平顶窗、海宁窗、汉明窗、三角窗等。加窗简单地说就是用采样得到的数据乘上一个加权的系数,根据信号理论,原信号乘上一个函数在频域上表现为卷积,以矩形窗(不加窗)为例,就是原先几根干净的谱线和sinc函数卷起来了,所以频谱上就出现了不该有额谱线,即就出现了频谱泄露。频谱的泄露程度表现在旁瓣与主瓣的相对大小,而矩形窗的旁瓣是很大的,所以泄露得比较严重,因此我们要选用一个旁瓣小的窗函数。
百度跟我们说,平顶窗适用于对幅值精度要求高的情景,实际上我们也仿真试过不同的窗函数,发现百度说得对。那么看看加了平顶窗是咋样:
>> N = 1024; % 1024点
>> Fs = 1000; % 采样率1000Hz
>> T = 1/Fs;
>> t = (0:N-1)*T;
>> x = 10 + 15 * sin(2*pi*100*t) + 8 * sin(2*pi*200*t); % 直流、100Hz、200Hz
>> w = flattopwin(length(x)); % 生成N点平顶窗序列
>> stem(abs(fft(x.*w'))) % 原函数和窗函数点乘后作fft并画图
唔,看起来好像是比第一幅图好点了,至少没有那么大范围的扩散了。可是怎么好像幅值大的谱线却增多了?这是因为窗函数的主瓣宽度和旁瓣幅度是一对矛盾的点,矩形窗主瓣窄而旁瓣幅度大,平顶窗的旁瓣幅度较小因而主瓣就会变宽,所以加了平顶窗后幅值较大的谱线(主瓣)就增多了,但是没关系,在这个场合我们我们更看重的是幅值精度,所以让我们看看幅值算出来怎么样就好了。按照老样子,很容易算出三个频率对应的时域幅值为:
2205/1024=2.15
1654/512=3.23
882.3/512=1.72
emmm,这也错得太离谱了......但是先别急,因为这是加窗后的信号的幅值,如果要求原信号的幅值,还得乘上一个幅值修正系数K,这个K我也查不到是多少,所以刚开始我也很困惑这个平顶窗究竟要怎么用。现在我们不妨换个思路来看:
频率 | 直流 | 100Hz | 200Hz |
周期截断 | 10/10 = 1 | 15/15 = 1 | 8/8 = 1 |
矩形窗 | 10.03/10 = 1.03 | 11.37/15 = 0.758 | 7.48/8 = 0.935 |
平顶窗 | 2.15/10 = 0.215 | 3.23/15 = 0.2153 | 1.72/8 = 0.215 |
上表中,我们用算出来的幅值和实际幅值相除,发现平顶窗的得数都是0.215左右,而矩形窗的三个得数相差较大,所以说的确存在一个常数K可以使得我们算出的幅值和实际幅值有一个完美的对应,这个K应该是0.215的倒数即4.651左右。那么再算一次幅值,
2205/1024*4.651=10.015
1654/512*4.651=15.024
882.3/512*4.651=8.014
跟实际相差已经不大了。大家也可以去试试别的窗,我们那会也试了很久的,这里就懒得再做一次来贴代码贴图片了.......
总之,我们使用了平顶窗来减少频谱泄露,得到了准确的幅值,计算出来的失真度也是比较准的(可是并不能加分....
直接贴公式:
那么就很简单了哈,把算出来的幅值带进去就好了。但是,真的有必要把具体幅值算出来吗?观察这个公式,发现如果每个幅值都乘上同一个系数,结果不变。所以为了偷懒,为了省时省事,我们adc采样得到的结果不转为实际电压,直接加窗做FFT变换,变换的结果也不用换成实际的幅值,直接带进公式算就ok了。当然,这么做还得注意下,别数值溢出了。
用的是中断读数的方式,DMA实在调不通,大家可试试。
代码里主要是adc和定时器的一些配置,每计数结束一次后触发一次采样,转换完触发中断然后在中断里读取数据。adc的中断函数记得要自己加到启动文件的中断向量表里面,不然进不去中断就会自己卡死。
顺便提一下,由于算出来的定时器计数值可能不是整数,实际采样率会跟设定的有点偏差,所以后面算频谱分辨率时最好用实际的采样率,即:
F0 = SysCtlClockGet() / (SysCtlClockGet() / SampleRate) / N
void ConfigureADC(void)
{
// 一些时钟和引脚配置
MAP_SysCtlPeripheralEnable(SYSCTL_PERIPH_ADC0);
MAP_SysCtlPeripheralEnable(SYSCTL_PERIPH_GPIOE);
MAP_GPIOPinTypeADC(GPIO_PORTE_BASE, GPIO_PIN_0); //PE0 ADC0 Channel3
ADCClockConfigSet(ADC0_BASE, ADC_CLOCK_SRC_PIOSC | ADC_CLOCK_RATE_FULL, 1);
//先失能这个采样序列组和中断
IntDisable(INT_ADC0SS3);
ADCIntDisable(ADC0_BASE, 3);
ADCSequenceDisable(ADC0_BASE, 3);
//配置为定时器触发,使用采样序列组3,序列中只有一个ADC0 Channel3
ADCSequenceConfigure(ADC0_BASE, 3, ADC_TRIGGER_TIMER, 0);
ADCSequenceStepConfigure(ADC0_BASE, 3, 0, ADC_CTL_CH3 | ADC_CTL_END | ADC_CTL_IE );
}
void ConfigureTIMER(void)
{
//配置计数器时钟,位数,并且设置为可以触发ADC
MAP_SysCtlPeripheralEnable(SYSCTL_PERIPH_TIMER0);
TimerConfigure(TIMER0_BASE, TIMER_CFG_SPLIT_PAIR | TIMER_CFG_A_PERIODIC);
TimerControlTrigger(TIMER0_BASE, TIMER_A, true);
IntMasterEnable();
}
void StartSampling(int SampleRate)
{
// 根据采样率算出加载到计数器的值,启动ADC然后启动计数器,计数完就会触发一次采样
TimerLoadSet(TIMER0_BASE, TIMER_A, (SysCtlClockGet()/SampleRate) - 1);
IntEnable(INT_ADC0SS3);
ADCIntEnable(ADC0_BASE, 3);
ADCSequenceEnable(ADC0_BASE, 3);
TimerEnable(TIMER0_BASE, TIMER_A);
}
void StopSampling()
{
// 停止计数,失能ADC及中断
TimerDisable(TIMER0_BASE, TIMER_A);
IntDisable(INT_ADC0SS3);
ADCIntDisable(ADC0_BASE, 3);
ADCSequenceDisable(ADC0_BASE, 3);
}
// 这个函数要自己加到中断向量表里
void ADCSeq3Handler(void)
{
// 转换完毕会进入中断,读取数据后存到数组里
ADCSequenceDataGet(ADC0_BASE,3,SampleTemp);
adcValues[SampleNum++] = SampleTemp[0];
if(SampleNum>=N)
{
StopSampling();
SampleisFinish = 1;
SampleNum = 0;
}
ADCIntClear(ADC0_BASE, 3);
}
在keil里面很方便调用ARM官方的DSP库,如果在ccs里面好像还得自己编译源码比较麻烦,所以俺用的是keil哈。
函数里面的第一步是求均值,目的是去除主流分量。观察第三个仿真结果,因为加了平顶窗主瓣变宽,所以零频率附近也出现了几条幅值很大的竖线,为了不影响接下来找基频,把每个采样值减去均值就可以去除直流分量。为什么这么做就能去除直流分量?因为直流分量就是采样值的均值,如果采样值都减去了均值,那么新序列的均值就成了0,直流也就成了0,而其它频率幅值不变。
然后是加窗,接着初始化FFT结构体,用的是rfft函数,即实数傅里叶变换,这个函数内部将2048点实数FFT转换成1024点的复数FFT去计算,占用的空间以及速度都少一些。为什么可以这么转换呢,其实在程佩青老师的数字信号处理一书中都有有相关性质的证明和例题,在这里我们直接调用函数即可。
//利用ARM库写的fft函数
void my_fft(uint16_t *adcValues,float *fftResults,uint8_t type)
{
//求均值,去除直流分量
float adcMean = 0;
for(int i = 0; i < N; i++)
adcMean += adcValues[i];
adcMean = adcMean / N;
//电压转换(其实不转换也可,所以这里没转),然后是加窗
// 1 不加 2 汉宁 3 平顶
float voltage[N];
if(type == 1)
for(int i = 0; i < N; i++)
voltage[i] = ((adcValues[i]&0x0FFF) - adcMean);
else if(type == 2)
for(int i = 0; i < N; i++)
voltage[i] = ((adcValues[i]&0x0FFF) - adcMean) * 0.5f * ( 1 - cos(2*PI*i/(N-1)) );
else if(type == 3)
for(int i = 0; i < N; i++)
voltage[i] = ((adcValues[i]&0x0FFF) - adcMean) * ((1 - 1.93f * cos(2*PI*i/(N-1))) + 1.29f * cos(4*PI*i/(N-1)) - 0.388f * cos(6*PI*i/(N-1)) + 0.0322 * cos(8*PI*i/(N-1))) /4.634f;
// 初始化FFT结构体
arm_rfft_fast_instance_f32 S;
arm_rfft_fast_init_f32(&S,N);
// 调用fft函数得到结果
// 结果将会按X[0]、X[N/2]、X[1]实部、X[1]虚部、X[2]实部、X[2]虚部...X[N/2-1]虚部的格式存进结果数组中,共N个值
float output[N];
arm_rfft_fast_f32(&S,voltage,output,0);
//求模值,只求了N/2个模值,这是因为由实数fft的性质就可知道后面的点跟前面的点是对称的
arm_cmplx_mag_f32(output,fftResults,N/2);
}
好了,当得到fft结果的模值后,就可以进行下一步运算了。
因为题目规定失真度不大于50%,而FFT之前我们又已经把直流成分去了,因此幅值最大对应的频率肯定是基频。一开始用最大的采样率采样一次,得到一个大致的基频,然后根据基频调小采样速率再采样一次,这样做可以提高频谱分辨率。第二次采样结果作一次FFT,就可以求失真度了。
在C语言里,索引从0开始算,因而某点的频率直接就是索引乘上频谱分辨率,那么是不是说第n次谐波的索引就是基波索引的n倍?这不一定。举个例子,假如分辨率是24Hz,1KHz对应的索引就应该是41.66,实际最大值的索引取42,5KHz对应的索引208.33,实际取208,42和208相差5倍多2,这种误差是由于取整造成的,41.66取到了42,多了0.38,放大五倍就多了1.9。那如果1K的频率索引是41.49,实际取41,少了0.49,5倍就会造成少了2.45。
根据上面一段话的分析,第n次谐波的索引不一定是基波索引的n倍,但是一定在n倍附近的左右两个点范围内,因此在代码中会找到n倍本身以及左右两个点共5个点中的最大值作为该次谐波的模值。
当然也可以不用以上这么复杂,去掉直流分量后的FFT模值排序,取最大的五个来算也行。
求失真度的关键代码如下:
my_fft(adcValues,fftResults,3);
un[0] = fftResults[0];
arm_max_f32(fftResults,N/2,&max,&max_idx); //找出基波下标
for(int i = 1; i < 6; i++) //找出谐波幅值
arm_max_f32(&fftResults[max_idx*i-2],5,&un[i],NULL); //这里要考虑数组溢出,我还没改
arm_sqrt_f32(un[2]*un[2]+un[3]*un[3]+un[4]*un[4]+un[5]*un[5],&THD[0]);
THD[0] = THD[0] / un[1] * 100.0f; //计算THD
for(int i = 0;i < 5; i++)
un_rate[i] = un[i+1] / un[1];
F0 = SysCtlClockGet() / (SysCtlClockGet()/SampleRate) / Nf ;
Freq = max_idx * F0 / 1000.0f; //计算基波频率
oled显示的代码就不附了,改IO引脚,移植就完事。
波形显示就是把点取出来,描点连线就完事。呃,当然,因为采样率不足的原因,基频高的时候可能一个周期也采集不了几个点,直接连线会很丑,因此如果能正确分析出谐波成分,频率和幅值都知道了,就可以自己把时域表达式写出来然后画图......
无线传输这边用的经典的HC-05,AT模式配置好就能透传了,手机APP用的LazyDog大神的蓝牙调试器,具体请看:蓝牙调试器-划时代无线调试器_XLazyDog的博客-CSDN博客_蓝牙调试器蓝牙调试器 这篇文章的受众是本专科院校有理想的青年或已经踏入社会的电子工程师们。本文章旨在介绍一款在Android设备上通过使用蓝牙功能实现无线调试的应用。一、蓝牙调试器介绍 此蓝牙调试器历时一个多月开发完成,其基于安卓设备,通过安卓设备的蓝牙通信功能实现单片机的无线调试。编写这款软件的目的主要是为了盈利, 嗯,当然是为了广大的单片机开发爱好者,拯救他们于繁琐的调试步骤...https://blog.csdn.net/XLazyDog/article/details/99584735 我们只需要将这个app的stm32例程中的初始化函数和串口发送函数换成tm4c的,然后按照顺序要求打包发送就可以了,如下:
void initValuePack(int baudrate)
{
ROM_SysCtlPeripheralEnable(SYSCTL_PERIPH_GPIOA);
ROM_SysCtlPeripheralEnable(SYSCTL_PERIPH_UART0);
ROM_GPIOPinConfigure(GPIO_PA0_U0RX);
ROM_GPIOPinConfigure(GPIO_PA1_U0TX);
ROM_GPIOPinTypeUART(GPIO_PORTA_BASE,GPIO_PIN_0|GPIO_PIN_1);
ROM_UARTClockSourceSet(UART0_BASE,UART_CLOCK_PIOSC);
ROM_UARTConfigSetExpClk(UART0_BASE,16000000,baudrate,(UART_CONFIG_WLEN_8|UART_CONFIG_STOP_ONE|UART_CONFIG_PAR_NONE));
}
void sendBuffer(unsigned char *p,unsigned short length)
{
for(int i=0;i
连着写了两天下午,总算是完工了,本来只想稍微分享下自己的思路,又怕自己说不清楚,所以写的有点啰啰嗦嗦,反而快成了一个教程......虽然国赛还没进行,但我的电赛生涯算是告一段落了,很感谢四天三夜小伙伴们的陪伴,这将是我本科生涯最难忘的回忆之一。
今天是11月20日,上次看完一本书还是上次,上次看完一本书还是一个月前,比赛结束也一直在焦虑地等结果也没有学习,是时候该重新开始了,转码去了嘿嘿.....