Robô autoequilibrado com ESP32: três malhas de controlo

Este robô acabou de saltar de uma balança de casa de banho e manteve-se de pé. Cinco dias antes, nem sequer conseguia ficar no mesmo sítio. Entretanto, nada mudou no seu hardware: a mesma estrutura, os mesmos motores, o mesmo sensor. Só mudou o programa. Uma malha de controlo passou a duas, depois a três, e cada nova malha corrigiu o que a anterior não conseguia fazer.

O primeiro de dois saltos da balança de casa de banho, a 8 de novembro: sobe pela tábua, passa a borda, aterra com força e continua a equilibrar-se.

É um robô de duas rodas que se equilibra sozinho: uma estrutura de três andares impressa em 3D, montada sobre dois motorredutores DC, uma placa ESP32 e um sensor de movimento. Construímo-lo para comparar formas de manter um robô de pé com o mesmo hardware, a começar pela clássica, o PID. Este artigo acompanha os dez dias entre o primeiro teste e o salto, pela ordem em que aconteceram: o que tem lá dentro, como é que o robô sabe qual é o lado de cima, as três malhas uma a uma e o que nos ensinou conduzi-lo por Wi-Fi. Os números vêm dos registos do próprio robô.

O robô de pé na mesa de uma impressora 3D Creality preta: uma estrutura laranja de três andares com uma bateria de células 18650 no topo, um pequeno ecrã aceso e fios ao meio, dois drivers de motor com dissipadores pretos em baixo e duas rodas pretas com jantes azuis.
O robô em cima da impressora que lhe imprimiu a estrutura.

O robô

A estrutura é feita de três andares impressos, empilhados uns sobre os outros, cada um deles uma peça separada. Desenhámo-la no Fusion 360 juntamente com as peças compradas, para que os motores e os drivers já tivessem o seu lugar antes de se imprimir fosse o que fosse.

O modelo do Fusion 360 a separar-se nas suas peças: três andares impressos, dois drivers de motor, dois motores e o módulo de 5 V. O suporte preto segura o robô na bancada e não faz parte dele.

De baixo para cima: os motores estão pendurados por baixo do andar inferior, nos suportes que vinham no kit. O primeiro andar tem os dois drivers de motor, com os dissipadores virados para baixo. O segundo andar é o cérebro: a placa ESP32 com o seu pequeno ecrã, montada numa placa perfurada, o sensor de movimento e um buzzer. O andar de cima leva uma bateria de três células com a sua placa de proteção, um interruptor e um módulo redutor de 5 V. O corpo mede 164 × 128 × 60 mm, 235 mm de largura com as rodas, e o robô completo pesa 1025 g.

PeçaO que usámosPara que serve
ControladorLILYGO TTGO T-Display: ESP32, dois núcleos a 240 MHz, ecrã de 1,14″Um núcleo executa a malha de controlo, o outro trata do Wi-Fi; o ecrã mostra os ganhos
Sensor de movimentoMPU-6500 numa placa MPU-9250/6500/9255, I²C a 400 kHzGiroscópio e acelerómetro, 500 amostras por segundo
Drivers de motor2 × módulo BTS7960 (HW-039, IBT-2)Uma ponte H por motor, PWM a 8 kHz
Motores2 × JGB37-520, 12 V, 333 rpm, caixa redutora metálica de engrenagens retas, encoder de efeito Hall1320 impulsos do encoder por volta da roda, 0,16 mm de deslocamento por impulso
Rodas67 mm, do kit dos motoresCerca de 210 mm de deslocamento por volta
BateriaBateria 3S de células 18650, 11,1 V, 12,6 V carregada, com BMS e interruptorAlimenta diretamente os motores; o interruptor corta tudo
Alimentação de 5 VMódulo redutor LM2596Alimenta a placa a partir da bateria
BuzzerBuzzer ativoTrês bipes quando o robô se arma
Diagrama de blocos num painel escuro ao lado do robô no seu suporte: a bateria de 12 V alimenta o LM2596, que gera 5 V para o ESP32; um divisor permite ao ESP32 ler a bateria pelo ADC; o MPU-6500 comunica com ele por I2C; o ESP32 envia PWM a 8 kHz a dois drivers BTS7960, que acionam dois motores; os encoders devolvem a distância percorrida pelas rodas.
Como está ligado, num fotograma do nosso vídeo. A bateria alimenta diretamente os motores, o conversor alimenta a placa, e a seta que regressa dos encoders é a razão de ser de toda esta montagem.

Dois pormenores da transmissão determinaram tudo o que veio depois. O primeiro é o atraso. O sensor entrega uma nova amostra a cada 2 ms, e o comando que dela resulta chega aos drivers em menos de um milissegundo, mas uma roda precisa de cerca de um décimo de segundo para responder a uma mudança de comando. O segundo é a caixa redutora: tem folga, por isso, depois de cada inversão de sentido, o motor gira durante um instante antes de a roda se mexer.

E os dois motores não são gémeos. Em imagens filmadas ao nível do chão, exatamente o mesmo comando PWM fez girar a roda mais afastada 18 mm e a mais próxima 8 mm. O comando era idêntico; porque é que a resposta não o é, não apurámos. Deixada a si própria, esta diferença faz o robô rodar lentamente no mesmo sítio enquanto se equilibra, e uma pequena correção de rumo, descrita na secção sobre a condução, trata disso.

Qual é o lado de cima

Tudo o que um robô de equilíbrio faz depende de um único número: a sua inclinação. O MPU-6500 oferece duas formas de a obter, e nenhuma funciona sozinha.

O giroscópio mede a rapidez com que o corpo roda. Ao somar as suas leituras a cada 2 ms, obtém-se um ângulo suave e rápido, mas que deriva: sem correção do desvio próprio do giroscópio, medimos cerca de 15° por minuto. O acelerómetro sabe onde está a gravidade, por isso o seu ângulo não pode derivar, mas também sente cada solavanco. Com os motores a trabalhar, o seu ângulo saltava de um lado para o outro numa faixa de 18,7°.

Um filtro complementar fica com a metade boa de cada um. A cada passo, o giroscópio faz avançar o ângulo, e depois o ângulo é puxado em direção à leitura do acelerómetro por uma parte ínfima da diferença, um milésimo. Dito de outra forma: 99,9 % giroscópio, 0,1 % acelerómetro, o que dá uma constante de tempo de dois segundos. Na bancada, o ângulo filtrado manteve-se dentro de seis centésimos de grau.

Painel do nosso vídeo ao lado do robô no seu suporte. Em cima: o ângulo só do giroscópio ao longo de 39 segundos, uma linha reta a descer a cerca de −15° por minuto. Ao meio: o ângulo do acelerómetro, uma faixa ruidosa com 18,7° de largura. Em baixo: as duas fórmulas do filtro, τ = 2 s, e a amplitude filtrada de 0,06°.
Sozinho, o giroscópio deriva; sozinho, o acelerómetro treme; o filtro mantém o ângulo dentro de 0,06°.

No firmware, tudo se resume a umas poucas linhas. A placa do sensor está montada ao alto, sobre uma aresta, e inclinada 1,4° para o lado, por isso, antes de mais nada, o código separa a velocidade de arfagem, de que o filtro precisa, da velocidade de guinada, que o código das viragens usa mais tarde:

// Leaning forward, the forward axis dips below the horizontal:
// its reading is -sin(theta).
att.thetaAcc = atan2f(-(float)s.accel[1], (float)s.accel[0])
               * DEG_PER_RAD;

// The board is rolled by 1.4 deg: take pitch and yaw rates apart.
const float gx = s.gyro[0] / GYRO_LSB_PER_DPS - att.gyroBiasDps[0];
const float gz = s.gyro[2] / GYRO_LSB_PER_DPS - att.gyroBiasDps[2];
att.omegaPitch = gz * rollCos - gx * rollSin;
att.omegaYaw = gx * rollCos + gz * rollSin;

// Complementary filter: the gyroscope carries the angle from
// sample to sample, the accelerometer pulls it back to gravity.
// FILTER_K = dt / (tau + dt) = 0.002 / 2.002, about 0.1 %.
att.theta = wrapDeg(att.theta + att.omegaPitch * IMU_DT);
att.theta = wrapDeg(att.theta
                    + FILTER_K * wrapDeg(att.thetaAcc - att.theta));

O passo segue o relógio do próprio sensor. Um temporizador de hardware acorda a tarefa de controlo pouco antes de cada amostra esperada, a tarefa lê-a e executa o controlador inteiro, e o ecrã e o registo do ensaio esperam a sua vez com uma prioridade mais baixa. O Wi-Fi vive no outro núcleo. Este esquema vem do nosso balanceador de hélice anterior, construído com a mesma placa e o mesmo sensor.

Primeira malha: o ângulo

A primeira experiência usa uma única malha, que só olha para o ângulo. É um controlador PID executado 500 vezes por segundo, uma vez por cada amostra do sensor, e a sua saída é a potência para os dois motores, em percentagem:

A referência não é exatamente zero. O centro de massa não fica perfeitamente por cima do eixo, por isso o robô equilibra-se em θ_ref = 0,3°.

float pidStep(float theta, float omegaPitch, float thetaRef,
              const Params &p, float dt, float *raw) {
    const float error = theta - thetaRef;
    const float summed = integral + p.ki * error * dt;
    integral = constrain(summed, -p.uMax, p.uMax);
    const float u = p.kp * error + integral + p.kd * omegaPitch;
    *raw = u;
    return constrain(u, -p.uMax, p.uMax);
}

O primeiro ensaio no chão falhou de uma forma que a bancada não podia ter mostrado. No ar, todas as verificações tinham passado: as rodas giravam, os encoders contavam, o sensor lia a inclinação. No chão, as rodas andavam para o lado oposto ao da inclinação, em vez de irem para debaixo do corpo, e o robô caiu. O ângulo estava certo; o que estava trocado era a «frente» dos motores e dos encoders. Com ambos invertidos na configuração, o robô pôs-se de pé e, com estes ganhos, mais tarde manteve-se de pé durante mais de dois minutos, com um desvio-padrão do ângulo de 0,2°.

Uma malha, 3 de novembro. Um empurrão, e o robô afasta-se a rolar com o corpo ainda direito e depois fica onde parou.

É aqui que uma única malha chega ao limite. Durante esse empurrão, o corpo nunca se inclinou mais de meio grau, e o robô rolou 27 cm e ficou lá. Para uma malha de ângulo, isso é um sucesso, porque o ângulo está bem. Ela não consegue distinguir entre estar parado e rolar a velocidade constante: no nosso chão, manter uma velocidade v exige cerca de 4,5 % de potência mais 78 % por cada metro por segundo, e o integral limita-se a guardar a potência que o empurrão lhe deixou. Para ela, qualquer velocidade parece equilíbrio.

Segunda malha: velocidade

A segunda malha vigia as rodas. É executada 100 vezes por segundo, lê os dois encoders e compara a velocidade do robô com a velocidade que ele devia ter, por agora zero. Não toca de todo nos motores: a sua saída é o ângulo que a primeira malha deve manter. A rolar para a frente? Inclina-se um pouco para trás, e a malha de ângulo, ao perseguir o novo ângulo, trava o robô. É um controlador PI, com um integral limitado aos mesmos poucos graus que a saída:

float pidSpeed(float vRef, float v, const Params &p, float dt,
               bool hold) {
    const float error = vRef - v;
    const float summed = hold ? speedIntegral
                              : speedIntegral + p.kvi * error * dt;
    speedIntegral = constrain(summed, -p.thetaMax, p.thetaMax);
    return constrain(p.kv * error + speedIntegral,
                     -p.thetaMax, p.thetaMax);
}

A primeira tentativa tremia cinco vezes por segundo. O seu ganho vinha de um modelo que supunha uma malha de ângulo três vezes mais fraca do que a real: kv = 10 foi calculado para kp = 10, e o robô funciona com kp = 30. O modelo também ignorava o décimo de segundo que as rodas levam a responder. O remédio não foi um ganho mais pequeno, mas uma medição mais calma: a velocidade passou a ser uma média ao longo de 200 ms em vez de 40 ms, e uma média de 200 ms, por acaso, anula por completo uma oscilação de 5 Hz.

Depois, o robô começou a fugir aos poucos. Com uma malha de velocidade puramente proporcional, kvi = 0, oito ensaios em dez terminaram a mais de 10 cm do sítio onde tinham começado. Um pequeno integral, kvi = 1, resolveu o problema: nos onze ensaios seguintes, nenhum se afastou mais de 2,7 cm.

Painel do nosso vídeo ao lado do robô num chão de madeira: a malha de velocidade, PI a 100 Hz, por cima da malha de ângulo, PID a 500 Hz. Dois gráficos da posição ao longo de 36 segundos: com kvi 0, a maioria dos ensaios foge, 8 em 10 para lá de 10 cm; com kvi 1, todos os ensaios ficam planos, 0 em 11, no máximo 2,7 cm.
Sem o integral da velocidade, o robô foge aos poucos; com ele, fica no lugar.

E então fez uma coisa que ninguém lhe tinha pedido: empurrado, voltou.

Duas malhas, 5 de novembro. Empurrado, o robô rola 37,8 cm, detém-se e regressa, até 2 cm do sítio onde estava. Ainda não há malha de posição.

A razão está no integral. O integral da velocidade é simplesmente a distância percorrida, por isso, para manter a zero a velocidade de um robô deslocado, o integral da velocidade acaba por guardar uma pequena inclinação, cerca de um grau por cada metro de afastamento do ponto inicial. É uma mola escondida e fraca a puxar o robô de volta ao ponto de partida. Fraca é a palavra certa: o robô volta para uma faixa com cerca de 30 cm de largura, e não para um ponto, e neste take dois dos seus pontos de repouso ficaram a 34 cm um do outro. O nosso palpite é que, dentro da faixa, a inclinação é demasiado pequena para fazer a caixa redutora vencer a folga, mas é um palpite, não uma medição. O take acima foi feito com kv = 5; a etapa foi encerrada com kv = 7.

Terceira malha: posição

A terceira malha lembra-se de onde o robô partiu, e marcámos esse sítio com um barquinho impresso. Vinte vezes por segundo, a malha mede a distância ao ponto de partida e pede à malha de velocidade uma velocidade de regresso. O seu ganho é kx = 1,5, por isso, a dez centímetros do ponto de partida, pediria 15 cm/s, mas nunca pede mais de 10 cm/s. A posição define a velocidade, a velocidade define o ângulo, e o ângulo define os motores.

O controlo de qualidade chega para inspecionar o ponto de referência. O robô continua de pé, e o barquinho é aprovado.

A distância vem dos encoders, corrigida pela inclinação: quando o corpo se inclina, as rodas rolam por baixo dele, mesmo que o robô, no seu todo, fique no mesmo sítio. Eis a cascata tal como é executada dentro do passo de 500 Hz, um pouco encurtada: o ramo que permite ao PC conduzir o robô ficou de fora.

// Every step runs at 500 Hz: position every 25th, speed every 5th.
if (positionPhase == 0) {
    vRefNow = pidPosition(xHold + params.xRef, xNow, params);
}
positionPhase = (positionPhase + 1) % POSITION_EVERY;

if (speedPhase == 0) {
    // Hold the speed integral while a hand carries the robot away.
    const bool limited = fabsf(vRefNow) >= params.vMax;
    const bool carried = (vRefNow > 0) ? (vNow < -AWAY_M_S)
                                       : (vNow > AWAY_M_S);
    thetaOffset = pidSpeed(vRefNow, vNow, params,
                           SPEED_EVERY * IMU_DT, limited && carried);
}
speedPhase = (speedPhase + 1) % SPEED_EVERY;

thetaRefNow = params.thetaRef + thetaOffset;
percent = pidStep(a.theta, a.omegaPitch, thetaRefNow, params,
                  IMU_DT, &uCtl);

A linha «carried» tem uma história por trás. Na primeira versão, depois de um empurrão, o robô regressava a uma velocidade de 20 a 46 cm/s e ultrapassava o seu sítio em 10 a 24 cm. O integral da velocidade tinha estado a acumular-se durante todo o tempo em que a mão empurrava, e lembrava-se da mão. Congelar o integral sempre que a malha de posição pedia a velocidade máxima reduziu a ultrapassagem para cerca de 5 cm, mas então os empurrões grandes passaram a terminar 12 a 27 cm antes do ponto de partida. A regra que ficou só o congela enquanto a malha pede a velocidade máxima de regresso e o robô continua a afastar-se do ponto de partida a mais de 1 cm/s, ou seja, enquanto uma mão o está a levar. A partir daí, depois de empurrões de 68 e 80 cm, o robô regressou a menos de 3 cm do seu sítio, sem ultrapassagem.

O próprio kx foi escolhido ao deslocar o ponto de referência por Wi-Fi e ver o robô segui-lo, 36 deslocamentos para cada valor:

kx11,52
Tempo até ficar a menos de 3 cm, mediana8,1 s4,8 s4,6 s
Maior ultrapassagem2,6 cm3,0 cm5,1 cm
Paragens pelo caminho37518
Com 1, o robô arrastava-se até ao ponto de partida aos solavancos; com 2, ultrapassava-o; 1,5 fica entre os dois. Uma paragem é uma pausa de meio segundo a dois segundos no regresso ao ponto de partida: o atrito segura o robô até o integral da velocidade acumular inclinação suficiente para o voltar a pôr em movimento.
Três malhas, 7 de novembro. Empurrado 40,6 cm, o robô volta a ficar a menos de 3 cm do barquinho em 6,1 segundos.

Não volta exatamente ao barquinho. Detém-se dois ou três centímetros antes dele ou depois dele, retido pelo mesmo atrito que deixava o robô de duas malhas assentar em qualquer ponto da sua faixa. Um empurrão de 50 cm no sentido contrário levou 8,6 segundos a ser desfeito. Com as três etapas lado a lado, o progresso salta à vista:

O mesmo tipo de empurrão com uma, duas e três malhas.
MalhasO que vigiaDepois de um empurrão
1Ângulo, 500 HzAfasta-se 27 cm a rolar e fica lá
2Ângulo e velocidade, 100 HzVolta para uma faixa com cerca de 30 cm de largura
3Ângulo, velocidade e posição, 20 HzVolta a menos de 3 cm do seu lugar

Condução por Wi-Fi

Quando o robô já consegue manter-se num ponto, conduzi-lo é uma questão de mover esse ponto. O ESP32 liga-se à rede Wi-Fi de casa, e uma pequena ponte em Python no PC serve uma página web com setas: teclado, comando de jogos ou comando no ecrã. A ponte reencaminha a ordem mais recente para o robô por UDP vinte vezes por segundo, e o robô responde de imediato a cada ordem com a sua telemetria: ângulo, velocidade, posição, rumo, potência, tensão da bateria e intensidade do sinal. Enquanto um dos controlos está premido, uma velocidade de deslocação que varia em rampa suave toma o lugar da saída da malha de posição; quando é largado, a malha de posição mantém o robô no sítio onde parou.

Para virar, foi necessária mais uma malha, fora da cascata. Uma malha de rumo compara a diferença entre os encoders das duas rodas com uma referência que se desloca à velocidade de viragem pedida e, depois, soma a sua correção a uma roda e subtrai-a à outra. A mesma malha impede o robô de virar sozinho por causa dos motores desiguais. A regra é primeiro o equilíbrio: a viragem só recebe a potência que a malha de ângulo deixa de sobra.

// Balance first: the turn gets what is left of the limit, so the
// mean of the two wheels stays the angle loop's command.
static void driveApart(float percent, float turn, float limit) {
    drivePercent = percent;
    const float room = max(limit - fabsf(percent), 0.0f);
    uTurnNow = constrain(turn, -room, room);
    motorDrive(Wheel::Left, percent - uTurnNow);
    motorDrive(Wheel::Right, percent + uTurnNow);
}
A virar no mesmo sítio, comandado a partir da página web, sem deixar de se equilibrar.

Os limites de trabalho são 0,35 m/s e 75° por segundo. O último ensaio de 8 de novembro durou 150 segundos: 28 metros de condução com picos até 0,57 m/s, dois saltos da balança e nenhuma queda. Na primeira aterragem, o corpo inclinou-se por um instante até +7°, e o robô manteve-se de pé.

Nem todos os saltos correram tão bem. Mais cedo, nesse mesmo dia, ao aterrar depois de saltar de uma pequena rampa, o robô inclinou-se 9° para a frente. Para recuperar o equilíbrio, arrancou a cerca de um metro por segundo e foi direito à cama. Para o robô se inclinar para trás e travar, as rodas têm primeiro de passar à frente do corpo, e o comando de potência já estava no seu limite de 85 %: àquela velocidade, não sobrava nada para as levar até lá. Entretanto, o limite foi aumentado para 100 %, o que ainda não experimentámos no chão.

O que o impede de se magoar

Veja a montagem completa

O vídeo conta a mesma história em oito minutos e meio, com os registos desenhados por cima das imagens. O leitor só se liga ao YouTube quando carrega nele.

O que vem a seguir

Dez dias, três malhas, um robô que se mantém de pé. A cascata PID funciona, mas é uma pilha de peças afinadas à mão, e cada peça foi acrescentada para corrigir o que a anterior não conseguia fazer.

A seguir, queremos substituir as três malhas por um único controlador LQR, que calcula os seus ganhos a partir de um modelo do robô, em vez de ser uma pessoa a afiná-los um a um. Depois disso, queremos experimentar uma pequena rede neuronal a correr no próprio ESP32. O robô já guarda registos dos seus ensaios, em parte para que essa rede tenha com que aprender. Nenhum dos dois existe ainda. Quando existirem, vão enfrentar os mesmos empurrões que o PID enfrentou aqui, e vamos publicar os números.

O robô de pé num chão de madeira ao lado de uma roseira cor-de-rosa num vaso cor-de-rosa vivo, com o ecrã aceso e LEDs verdes a brilhar por baixo dos motores.
Entre experiências.