第十六届全国大学生智能汽车竞赛电磁越野组参赛技术总结

时间:2024-03-06 07:01:36

写在前面

本文的代码工程文件已经在github开源,地址:电磁越野组开源工程代码
同时,我也想说明一下写这篇博文的目的以及希望达到的效果。通过半年的准备时间,从最开始的自信满满、勇往直前,再到最后的黯然离场。一路走来,智能车带给我的不仅仅是各类硬件、软件知识,更多的还是性格上的磨练。我的感受就是:做车,要耐得住独自调车的寂寞,耐得住调参带来的折磨。更要耐得住研究底层的痛苦。
本次开源的工程,是基于龙邱开发库,加上我们自己编写的控制算法,从无到有。这也是我们能够开源的原因。

智能车是起点,而不是终点
选择将自己的工程文件开源,希望我们帮助到的车友,能够在完赛以后,将自己的工程也开源出来,供大家一起学习,一起进步。

最后再谈谈我希望这篇博文能达到的一个效果:
一是提醒想要参加智能车竞赛的同学,这个比赛准备周期长,通常再半年到一年之间,因此需要你有坚定的决心和信念。
二是将我们做车的历程分享出来,让做车新手们避开我们曾经踩过的坑,对整个比赛有一个整体的把控。
三是希望得到做车大佬的指正,十六届电磁越野的短前瞻让选手不得不放弃传统算法,但我们由于种种原因,没能成功用上机器学习,因此希望大神能够帮我们指正工程里传统算法的错误和不足,万分感谢!

前期准备

这一阶段相当重要,也特别容易埋雷,这一阶段没能解决的问题将会成为之后永远的痛点。

熟悉开发库

每年智能车竞赛,龙邱和逐飞都会发布自家今年的开发库。借助他们的开发库,能够让你更快进入做车的状态。例如屏幕显示、串口、时钟等模块都已经帮你封装到函数中。因此在比赛初期一定要先熟悉开发库,这些调试函数将会一直贯穿整个竞赛准备阶段。

机械结构

都说“基础不牢,地动山摇”,机械结构作为智能车的基础部分,常被大家所忽略。拿今年电磁越野所使用的L车模距离,仅机械结构的改装都可以分为平台搭建、减震改造、转向改造、防水改造等(详情可以查看卓晴老师的这篇博文:L车改装浅析及性能测试)。我希望传达的信息就是,机械结构的改造非常重要,在我心中机械结构和硬件的重要性是大于软件的。如果智能车的机械结构或者硬件出了问题,软件再怎么优异也都于事无补。
在我们前期的准备工作中,没有对机械结构引起足够的重视,这也导致我们在后期准备对机械结构非常头疼。我认为,一个优异的机械结构,需要满足方便、稳定、安全的特征。这里的方便指的是硬件的更换要方便,基本没有哪个队伍的硬件能做到一步到位,更多需要进行迭代改进。因此在设计车模的机械结构的时候,要考虑到该结构是否有利于后期的更换、维护。稳定则是指车模结构是否稳定,电磁越野组有它的特殊性,在比赛过程中,可能有沙坑、路肩、水坑等元素,所以在机械结构上考虑这些元素的应对方案也是非常重要的。路肩会比较高,在安装干簧管模块时需要格外注意,将接口朝上防止,否则可能会卡在路肩处。如果担心有水坑,防水工作也是必不可少的。同时减震也需要进行调整测试,不是所有车模拿到手中减震都是最好状态。另外,安全指对硬件做好防止短路的措施,切忌两块板重叠防止,这样会引起短路等不良后果。
方向朝下的干簧管
▲我们接口向下的干簧管

技术报告

在比赛初期,查阅前几年的技术报告是快速入手智能车竞赛的一条捷径。在全国大学生智能车官网可以获取到技术报告。通过阅读优秀参赛队伍的技术报告,能够帮助新手确定做车的大方向。值得一提的是,技术报告内容比较笼统,只能提供大致的竞赛思路,而不能指望它帮你解决现实问题。所以在调车陷入僵局后翻阅技术报告往往是最高效的使用方法。
请添加图片描述
▲AI电磁组部分技术报告

中期搭建

调参是一门艺术,难度在于如何权衡各种性能。

滤波归一

滤波与归一化是为了让智能车在信号强度不同的赛场也能发挥出相同的实力,同时消除误差信号。各种滤波方案的比较可见AD采集滤波算法对比

中值滤波

float ForServoMID(float i,float j,float k)
{
    float tmp;
    if (i > j)
    {
        tmp = i; i = j; j = tmp;
    }
    if (k > j)
        tmp = j;
    else if(k > i)
        tmp = k;
    else
        tmp = i;
    return tmp;
}

▲三个值采样后取中值

均值滤波

    // 每一个电感值进行5次采样
    for(num = 0;num < 10;num++)
    {
        My_ReadADC();  //读取电感值到a数组
        for(i = 0;i < 6;i++)
        {
            MID[i] = ForServoMID(a[i][0],a[i][1],a[i][2]);  //取三次采样的中值
            sum[i] += MID[i];
        }
    }
    // 10次采样求平均
    for(i = 0;i < 6;i++)
    {
        AD_VAL[i] = sum[i]/10;  //求5次采样的平均值

        if(AD_VAL[i] < AD_val_min[i])
            AD_VAL[i]=AD_val_min[i];
    }

▲采样10次求均值

归一化

ad_VAL[i]=100*(AD_VAL[i] - AD_val_min[i])/(AD_val_max[i]-AD_val_min[i]);    //归一化处理采集数据

传统pd控制(位置式)

舵机控制方面,我们首先使用了最传统的pd控制,pd控制的原理比较简单,但调参却很复杂,尤其是对于机动性不强,转弯半径大的L车模来讲,找到合适的Kp和Kd更是难上加难,根据电磁越野规则指出,场地的弯道角度在90°~180°之间,这意味着如何找到兼并直道稳定性以及弯道机动性的Kp、Kd成为了传统位置式pd控制的主要难点,在大量时间调参和尝试使用分段式之后,我们便放弃了传统pd控制的方法,进而改为最后使用的模糊pd方案。注意,在上控制之前,需要先对舵机中值以及打角极限占空比进行校准,否则有损坏舵机的风险。

int PID_Servo_Contrl(int set,int NowPoint)
{
    int iError,output;             //iError:误差
    s_pid.SetPoint=set;              //设置目标值

    iError = s_pid.SetPoint - NowPoint;      //当前误差  设定的目标值和实际值的偏差

    output = s_pid.P * iError               //增量计算
              + s_pid.D * (iError - s_pid.LastError);

    /*存储误差  用于下次计算*/
    s_pid.PrevError = s_pid.LastError;
    s_pid.LastError = iError;

    return output;                         //返回位置值
}

▲算法主体

// PD控制+限幅
s_duty = Servo_Center_Mid + PID_Servo_Contrl(Servo_Center_Mid,input_error);
s_duty = constrain_int(s_duty,820,1610);  // 限幅

▲函数调用

后期改进

差比和差

差比和差拥有比差比和更好的鲁棒性,误差更小的优势。我们也有尝试过使用差比和差算法,但由于四个参数难以确定最终放弃此方案。想要使用差比和差的伙伴可以看卓晴老师的这篇文章:差比和差算法

/*************************************************************************
*  函数名称:void aricomsumope()
*  功能说明:差比和差运算(由于四个系数难以确定,因此弃用差比和差算法)
*  参数说明:acso_abcp的值需要后期调整得到
*  函数返回:无
*  修改时间:2021年4月29日
*  备    注:差比和差算法,差比和差算法相较差比和算法拥有更加优秀的鲁棒性
*************************************************************************/
void aricomsumope()
{
    const float acso_a = 0.4;
    const float acso_b = 0.6;
    const float acso_c = 0.6;
    const float acso_p = 1;      //比例系数p
    last_val = curr_val;
    curr_val =  acso_p*((acso_a*(ad_VAL[4] - ad_VAL[1])+acso_b*(ad_VAL[5] - ad_VAL[0]))/(acso_a*(ad_VAL[4] + ad_VAL[1])+acso_c*(my_absolute(ad_VAL[5]-ad_VAL[0]))));
    delta_val = curr_val - last_val;
    fuzzy_txt1[0] = curr_val;
}

▲差比和差中难以确定的四个系数

差比和

由于差比和差系数难以确定,最终我们还是用回经典的差比和算法。可能有的人会思考,为什么要使用差比和与差比和差?因为这两个算法都可以减小误差,提高鲁棒性,并且可以将多个电感的值按照不同规则灵活地结合为一个值。而在元素识别中,使用单一电感滤波归一后的值的风险是很大的。

     //水平电感差比和
    curr_val =  (my_sqrt(ad_VAL[3]) - my_sqrt(ad_VAL[2] ))/ (my_sqrt(ad_VAL[3]) + my_sqrt(ad_VAL[2])+ my_sqrt(elect_M));
    //竖直电感差比和
    up_curr_val =(my_sqrt(ad_VAL[5] ) - my_sqrt(ad_VAL[0])) / (my_sqrt(ad_VAL[5]) + my_sqrt(ad_VAL[0]) + my_sqrt(elect_M));

▲其中elect_M是中间两个电感的平均值

模糊pd控制

首先分享两篇将自适应模糊的好文:自适应模糊 c语言实现模糊pid
为什么使用模糊pd控制?因为使用传统pd控制时,一是参数难以确定,二是直道弯道难以兼顾。而基于调整模糊规则表的模糊pd控制,在调试参数期间,有更明显、更直观的反馈。你可以将某一时刻调用的模糊规则表坐标发送出来,如此一来,参数大了还是小了,规则表中的定位都一目了然。
在实际调试过程中,我们发现,模糊pid同样无法摆脱直线稳定性和转弯机动性的矛盾冲突。如果追求直线的稳定则必然牺牲弯道的机动性。

    if(fabs(curr_val) < fabs(up_curr_val))
    {//两边电感差比和大,则忽略中间电感
        curr_val = up_curr_val;
        delta_val = up_delta_val;
    }

▲最初的电感调用方案

    if(ad_VAL[0] > 15 || ad_VAL[5] > 20)
    {//两边电感差比和大,则忽略中间电感值
        if(fabs(curr_val) < fabs(up_curr_val) || (ad_VAL[0] > 80 || ad_VAL[5] > 80))
        {
//          UART_PutStr (UART0, "ininin");
          curr_val = up_curr_val;
          delta_val = up_delta_val;
        }
    }

▲针对直道抖动改进的电感调用方案

改进电感调用方案之后,在直道上调用的规则表更加稳定,更改直道规则表经过测试也不会减弱对于弯道的控制。但问题是此种方案对电感的切换调用不够平滑,将会导致由弯道进入直道难以快速稳定车身。我也曾设想使用权重配比实现电感选择,但此想法并未实施验证,有时间的小伙伴可以试试。

int PFF[4] = {0,11,20,28};    //PFF[3]为电感最大值,其余值等值增加即可
int DFF[4] = {0,7,13,19};      //DFF[3]为电感变化量的最大值,其余值等值增加即可
int UFF[7] = {0,180,350,500,600,680,720};     //舵机打角过程占空比
int rule[7][7]={                                                                                                                   
            {-6,-6,-6,-6,-5,-5,-4,},                    
            {-6,-6,-6,-6,-4,-2,-1,},                   
            {-6,-6,-3,-1, 0, 1, 1,},                      
            {-1,-1,-2, 0, 4, 5, 6,},                     
            {-2,-2, 0, 1, 6, 6, 6,},                     
            { 1, 1, 5, 6, 6, 6, 6,},                     
            { 4, 5, 5, 5, 6, 6, 6} };                    
            
float Vag_pid(float currval,float deltaval)
{
    float U;                //偏差,偏差微分以及输出值的精确量
    float PF[2], DF[2], UF[4];//偏差,偏差微分以及输出值的隶属度
    int Pn, Dn, Un[4];        //Un对应表中四个调用元素
    float temp1, temp2;

    fuzzy_txt1[0] = currval;
    fuzzy_txt1[1] = deltaval;
    /*隶属度的确定*/
    /*根据PD的指定语言值获得有效隶属度*/
    if (currval > -PFF[3] && currval < PFF[3])
    {
        if (currval <= -PFF[2])
        {
            Pn = -2;
            PF[0] = FMAX * ((float)(-PFF[2] - currval) / (PFF[3] - PFF[2]));
        }
        else if (currval <= -PFF[1])
        {
            Pn = -1;
            PF[0] = FMAX * ((float)(-PFF[1] - currval) / (PFF[2] - PFF[1]));
        }
        else if (currval <= PFF[0])
        {
            Pn = 0;
            PF[0] = FMAX * ((float)(-PFF[0] - currval) / (PFF[1] - PFF[0]));
        }
        else if (currval <= PFF[1])
        {
            Pn = 1; PF[0] = FMAX * ((float)(PFF[1] - currval) / (PFF[1] - PFF[0]));
        }
        else if (currval <= PFF[2])
        {
            Pn = 2; PF[0] = FMAX * ((float)(PFF[2] - currval) / (PFF[2] - PFF[1]));
        }
        else if (currval <= PFF[3])
        {
            Pn = 3; PF[0] = FMAX * ((float)(PFF[3] - currval) / (PFF[3] - PFF[2]));
        }
    }
    else if (currval <= -PFF[3])
    {
        Pn = -2; PF[0] = FMAX;
    }
    else if (currval >= PFF[3])
    {
        Pn = 3; PF[0] = 0;
    }
    PF[1] = FMAX - PF[0];

    if (deltaval > -DFF[3] && deltaval < DFF[3])
    {
        if (deltaval <= -DFF[2])
        {
            Dn = -2; DF[0] = FMAX * ((float)(-DFF[2] - deltaval) / (DFF[3] - DFF[2]));
        }
        else if (deltaval <= -DFF[1])
        {
            Dn = -1;
            DF[0] = FMAX * ((float)(-DFF[1] - deltaval) / (DFF[2] - DFF[1]));
        }
        else if (deltaval <= DFF[0])
        {
            Dn = 0;
            DF[0] = FMAX * ((float)(-DFF[0] - deltaval) / (DFF[1] - DFF[0]));
        }
        else if (deltaval <= DFF[1])
        {
            Dn = 1;
            DF[0] = FMAX * ((float)(DFF[1] - deltaval) / (DFF[1] - DFF[0]));
        }
        else if (deltaval <= DFF[2])
        {
            Dn = 2; DF[0] = FMAX * ((float)(DFF[2] - deltaval) / (DFF[2] - DFF[1]));
        }
        else if (deltaval <= DFF[3])
        {
            Dn = 3; DF[0] = FMAX * ((float)(DFF[3] - deltaval) / (DFF[3] - DFF[2]));
        }
    }
    else if (deltaval <= -DFF[3])
    {
        Dn = -2;
        DF[0] = FMAX;
    }
    else if (deltaval >= DFF[3])
    {
        Dn = 3;
        DF[0] = 0;
    }
    DF[1] = FMAX - DF[0];

    /*使用误差范围优化后的规则表rule[7][7]*/
    /*输出值使用13个隶属函数,中心值由UFF[7]指定*/
    /*一般都是四个规则有效*/
    fuzzy_txt[0]=Pn - 1 + 3;
    fuzzy_txt[1]=Dn - 1 + 3;
    fuzzy_txt[2]=Pn + 3;
    fuzzy_txt[3]=Dn - 1 + 3;
    fuzzy_txt[4]=Pn - 1 + 3;
    fuzzy_txt[5]=Dn + 3;
    fuzzy_txt[6]=Pn + 3;
    fuzzy_txt[7]=Dn + 3;
    // 拿到规则表中的四个值
    Un[0] = rule[Pn - 1 + 3][Dn - 1 + 3];
    Un[1] = rule[Pn + 3][Dn - 1 + 3];
    Un[2] = rule[Pn - 1 + 3][Dn + 3];
    Un[3] = rule[Pn + 3][Dn + 3];

    if (PF[0] <= DF[0]) UF[0] = PF[0];
    else    UF[0] = DF[0];
    if (PF[1] <= DF[0]) UF[1] = PF[1];
    else    UF[1] = DF[0];
    if (PF[0] <= DF[1]) UF[2] = PF[0];
    else    UF[2] = DF[1];
    if (PF[1] <= DF[1]) UF[3] = PF[1];
    else    UF[3] = DF[1];

    /*同隶属函数输出语言值求大*/
    if (Un[0] == Un[1])
    {
        if (UF[0] > UF[1])  UF[1] = 0;
        else    UF[0] = 0;
    }
    if (Un[0] == Un[2])
    {
        if (UF[0] > UF[2])  UF[2] = 0;
        else    UF[0] = 0;
    }
    if (Un[0] == Un[3])
    {
        if (UF[0] > UF[3])  UF[3] = 0;
        else    UF[0] = 0;
    }
    if (Un[1] == Un[2])
    {
        if (UF[1] > UF[2])  UF[2] = 0;
        else    UF[1] = 0;
    }
    if (Un[1] == Un[3])
    {
        if (UF[1] > UF[3])  UF[3] = 0;
        else    UF[1] = 0;
    }
    if (Un[2] == Un[3])
    {
        if (UF[2] > UF[3])  UF[3] = 0;
        else    UF[2] = 0;
    }
//
    /*重心法反模糊*/
    /*Un[]原值为输出隶属函数标号,转换为隶属函数值*/
    if (Un[0] >= 0) Un[0] = UFF[Un[0]];
    else            Un[0] = -UFF[-Un[0]];
    if (Un[1] >= 0) Un[1] = UFF[Un[1]];
    else            Un[1] = -UFF[-Un[1]];
    if (Un[2] >= 0) Un[2] = UFF[Un[2]];
    else            Un[2] = -UFF[-Un[2]];
    if (Un[3] >= 0) Un[3] = UFF[Un[3]];
    else            Un[3] = -UFF[-Un[3]];

    temp1 = UF[0] * Un[0] + UF[1] * Un[1] + UF[2] * Un[2] + UF[3] * Un[3];
    temp2 = UF[0] + UF[1] + UF[2] + UF[3];
    U = temp1 / temp2;



//    sendFuzzyData();
    return U;
}

▲模糊pid算法,取自github
其中PFF为差比和的结果,DFF为差比和结果的变化值。在使用此算法时,只需要先校准PFF和DFF的大小,然后校准规则表即可。

电感排布方案

主流的智能车电磁电感排布方案主要有两种:
普通电感排布
▲我们使用的版本

45°电感排布
▲两颗45°电感版本
相比普通的版本,两颗45°电感的版本对环岛、有更好的拟合性,使用第二种方案,在入环和出环的过程中可以取地更加平滑的效果。一些队伍在入环出环使用开环控制(比如我们)的风险是比较大的,而且随着代码量与电压的变化,出入环的距离(编码器)常常不是稳定的。但我们并没有选择第二种方案的原因在于我们的硬件放在竞赛后期来做,运放和电感并没有经过很精确的测量,灵敏度不够对称,在测试后发现该方案并没有取得很好的效果。

电机减速控制

 // 弯道
	if(ad_VAL[0] > 15 || ad_VAL[5] > 30 && (ad_VAL[2]+ad_VAL[3] >= 10))
	{
	    if(fjc_duty >= 550 && fjc_duty <= 580)
	    {
	
	        if(set_speed >= 3000)
	        {
	            back_speed = 1;
	        }
	        else
	            set_speed = 2900;
	    }
	    if(fjc_duty >= 1730 && fjc_duty <= 1850)
	    {
	        if(set_speed >= 3000)
	        {
	            back_speed = 800;
	        }
	        else
	            set_speed = 2700;
	    }
	
	    else if(fjc_duty > 1600 && fjc_duty <= 1740)
	    {
	        set_speed = (unsigned int)(-1.33*fjc_duty + 5133);
	    }
	    /*else if(fjc_duty >= 570 && fjc_duty <= 729)
	    {
	        set_speed = (unsigned int)(2.5*fjc_duty + 1175);
	    }*/
	    else
	        set_speed = 3000;
	}

▲针对短前瞻的减速方案
由于前瞻太短,当电机速度较快,舵机无法很好地反应。因此我们使用面向占空比的减速策略。刚开始我们简单地认为舵机占空比与电机占空比呈正相关,但经过多次观察测试后发现。其实舵机占空比与电机占空比应该呈负相关。这是因为,一旦舵机打角已经完成,直接加速即可过弯,减速环节应该放在舵机打角的前期过程当中,否则会导致智能车停滞不前。同时,减速策略还基于电机占空比,当电机占空比较大时,便给出反应更大的减速。

电机pid

void Set_Speed(unsigned int tar_speed)
{
    unsigned int tmp_PWM =0;
    int tar_ENC = (int)(0.7676*tar_speed - 1048); //目标编码器的值
    static unsigned int out_ENC = 0;
    volatile  int tmp_ENC = 0;
    char txt[20],txt1[10];

    // 拿到当前编码器的值
    realpwm = (unsigned int)(1.2993*encValue5 + 1369.5);

   // 输入PID得到应该设置的占空比的值
   out_ENC += PID_MOTOR_Contrl(tar_ENC,encValue5);    //将目标的ENC和现在的ENC比较
   out_ENC = constrain_int(out_ENC,0,tar_ENC+20);
   tmp_PWM = (unsigned int)(1.2993*out_ENC + 1369.5);                 //将结果转化成PWM
   //设置速度
   tmp_PWM = constrain_int(tmp_PWM,0,5000);
   ATOM_PWM_SetDuty(ATOMPWM0,tmp_PWM, 12500);
   ATOM_PWM_SetDuty(ATOMPWM1,0, 12500);
}

电机pid的测试方法建议将智能车放到各种不同材质的场地,通过图形化上位机观察其电机pid的效果。

元素处理

十字

十字元素看起来简单,但实际情况会比理想情况更加复杂,智能车在进入十字之前可能就会被干扰左偏或右偏而偏离赛道,不能以很好的姿态接近十字。在测量中我们发现,在十字的大约5cm前,左右竖直电感往往会突变。在查阅资料后,我们总结出可能是因为磁场叠加造成此种不对称的问题。因此,过十字的秘诀就是快。只要速度够快,加上滤波的加持,十字便能够轻易过掉。

环岛

环岛比较简单,看这两篇文章就行:电磁五电感入环 电磁入环方案

复盘总结

在实际的比赛过程中,我们发现智能车在重大的木地板上跑不起来。由于我们调车一直是在瓷砖地上面调试。速度能达到2m左右,但是在西部赛上,我们才意识到电机pid有问题,这也直接导致我们的车在最后的一个十字冲不出去的结果。赛后我们分析,造成这种问题的原因有二:编码器和电机连接的机械机构问题和电机pid的参数整定问题。
一个良好的电机pid需要达到的要求就是,在不同电压下都能保持匀速前进。但在我们调试的过程中,电机速度的确是会随着压降而变慢的。当时我们已经注意到了这一现象,于是对编码器进行测试,我们发现编码器脉冲抖动比较厉害,究其原因应该是做机械机构时,将连接杆插入太紧,产生了摩擦,同时电机没能完全固定,跑车时电机会抖动,最后整定的电机pid参数也不过是自欺欺人罢了。就是因为我们在前期的机械结构不够用心,也就间接导致半年后的省赛出现了问题。所以我想向大家强调的就是,一定要在硬件和机械结构上下足功夫,软件是很难弥补硬件的短板的。
另外还有需要引起所以做电磁组智能车的队伍的注意的一点就是,一定要多花时间在电磁传感器上,多琢磨运放、电感。这里推荐大家用RC电桥来测量电感的参数。测量方法比较复杂,我们也没能实操,如果有时间,b站达尔闻一期又讲其测量方法,大家可以学习一下。

开源工程说明

IDE

由于英飞凌公司对中国的制裁,目前智能车使用的IDE是由英飞凌(中国)开发的ADS,烧录工具是Memtool。

工程说明

算法位置在GitHub仓库的“Smartcarelectromagnetism/My_test_official/src/AppSw/Tricore/User/” 目录中工程目录
FJC_ControlServo.c:舵机传统pd控制
CXJ_MOTOR.c:电机pid
FJC_NewCS.c:模糊pd及记忆算法
FJC_Servo_t.c:AD数据处理算法

参赛感悟

智能车竞赛非常考验学生的综合能力,包括硬件开发、软件开发的专业能力,同时也考验学生持之以恒的精神力。很多人在做比赛的过程中因为各种原因坚持不下来,选择弃赛。但我觉得,不管结果如何,只要你能在比赛周期内坚持下来,智能车都能给你很大的收获。