Bu, konunun uzun ve teknik rehberi (ESP-IDF, MicroPython, rclcpp gibi alternatiflerle). Adım adım yol için Porti görevlerine dön.

Ana rota · Hafta 4 · 10–22 saat

Denge modu

Sarhoş tekeri sök: IMU, filtre, kaskad PID, LQR

Aynı robot, sarhoş teker sökülü. İki tekerlek üstünde durmak, robotikteki klasik ters sarkaç problemi: PID yanlışsa robot düşer. IMU'dan eğimi gürültüsüz çıkarmayı, kaskad kontrolü ve tam durum geri beslemesini (LQR) burada gerçekten öğreniyorsun. Her adım önce simülasyonda, sonra elinin altındaki robotta.

Günün sonunda elinde
  • MPU6050'yi kütüphanesiz okuyorsun; gyro bias'ı kalibre ediliyor
  • Üç açı tahmini yan yana: sadece gyro, complementary, Kalman
  • Sadece açı PD'nin neden yetmediğini simde ve gerçekte gördün
  • Kaskad kontrolcüyle robot ayakta duruyor, itince toparlıyor
  • Kendi ölçülerinden hesaplanmış LQR kazancı, aynı robotta karşılaştırma
  • Gerçek itme testi ile simülasyonu üst üste koyan bir sim-to-real notu
4.1

IMU'yu oku ve kalibre et

MPU6050 200 Hz'de okunuyor; eksenlerin robotun öne eğilmesine göre işaretlerini biliyorsun, gyro bias'ı açılışta ölçülüyor.

Yap
Kod
include/mpu6050.h
// include/mpu6050.h — kütüphanesiz, register seviyesinde MPU6050
#pragma once
#include <Arduino.h>
#include <Wire.h>

struct ImuSample {
  float ax, ay, az;   // m/s²
  float gx, gy, gz;   // rad/s (bias çıkarılmış)
};

class Mpu6050 {
 public:
  bool begin(int sda, int scl, uint8_t addr = 0x68) {
    addr_ = addr;
    Wire.begin(sda, scl, 400000);           // 400 kHz: 14 bayt ~0.4 ms
    if (read8(0x75) != 0x68) return false;  // WHO_AM_I
    write8(0x6B, 0x01);   // uyandır, saat kaynağı: gyro X PLL (daha kararlı)
    write8(0x1A, 0x03);   // DLPF ~44 Hz: motor titreşimini keser, ~5 ms gecikme ekler
    write8(0x19, 0x04);   // örnekleme 1 kHz / (1+4) = 200 Hz
    write8(0x1B, 0x08);   // gyro ±500 °/s → 65.5 LSB/(°/s)
    write8(0x1C, 0x00);   // ivme ±2 g → 16384 LSB/g
    return true;
  }

  // Robot hareketsizken çağır: gyro sıfır kaymasını ölç.
  void calibrateGyro(int n = 500) {
    float sx = 0, sy = 0, sz = 0;
    for (int i = 0; i < n; i++) {
      ImuSample s= readRaw();
      sx = s.gx; sy = s.gy; sz = s.gz;
      delay(2);
    }
    bias_[0]= sx / n; bias_[1]= sy / n; bias_[2]= sz / n;
  }

  ImuSample read() {
    ImuSample s= readRaw();
    s.gx -= bias_[0]; s.gy -= bias_[1]; s.gz -= bias_[2];
    return s;
  }

 private:
  ImuSample readRaw() {
    uint8_t b[14];
    Wire.beginTransmission(addr_);
    Wire.write(0x3B);
    Wire.endTransmission(false);
    Wire.requestFrom(addr_, (uint8_t)14);
    for (int i= 0; i < 14 && Wire.available(); i++) b[i]= Wire.read();
    auto w= [&](int i) { return static_cast<int16_t>((b[i] << 8) | b[i + 1]); };
    const float A= 9.80665f / 16384.0f;
    const float G= (PI / 180.0f) / 65.5f;
    return {w(0) * A, w(2) * A, w(4) * A, w(8) * G, w(10) * G, w(12) * G};
  }
  uint8_t read8(uint8_t reg) {
    Wire.beginTransmission(addr_);
    Wire.write(reg);
    Wire.endTransmission(false);
    Wire.requestFrom(addr_, (uint8_t)1);
    return Wire.available() ? Wire.read() : 0xFF;
  }
  void write8(uint8_t reg, uint8_t v) {
    Wire.beginTransmission(addr_);
    Wire.write(reg);
    Wire.write(v);
    Wire.endTransmission();
  }
  uint8_t addr_= 0x68;
  float bias_[3]= {0, 0, 0};
};
Kanıt: Eksen tablosu (öne eğilince hangi değer, hangi işaret) ve 60 s'de gyro integral kayması: önce / sonra (°).
Tuzaklar
  • MPU6050 modüllerinin çoğu 3.3V regülatörlü; yine de 3V3'ten besle, I2C hatları 3.3V kalsın.
  • Kabloları uzun ve motor kablolarına paralel çekme: I2C gürültüden kilitlenir.
  • IMU gövdeye oynak bağlıysa titreşim filtreden geçer ve dengeyi bozar. Sağlam vidala, gerekirse köpük bant.
Daha derin
  • ICM-42688 gibi daha yeni bir IMU'nun gürültü yoğunluğunu datasheet'ten karşılaştır; ne kazanırsın?
4.2

Eğimi tahmin et: gyro, ivme, complementary, Kalman

Aynı anda dört tahmin yazdırılıyor. Hangisinin neden kaydığını, hangisinin neden titrediğini grafikte görüyorsun ve α'yı bilinçli seçtin.

Önce simülasyonda
Dene:
  • Varsayılan "sadece gyro": robot bir süre duruyor, sonra tahmin kaydıkça düşüyor.
  • "Sadece ivmeölçer": tahmin her hızlanmada sapıyor; robot titreyerek düşüyor.
  • Complementary seç: ayakta kalıyor. α'yı 0.9'a indir, sonra 0.999'a çıkar; farkı izle.
  • Kalman'ı dene: gyro bias'ı kendi öğreniyor.
Simülasyon yükleniyor…
Yap
Kod
include/tilt.h
// include/tilt.h — eğim tahmini: complementary ve Kalman, yan yana
#pragma once
#include <math.h>

// Hangi eksen "öne eğilme"? IMU'yu nasıl monte ettiğine bağlı.
// Robotu elle öne eğ: değeri pozitif artan eksenleri seç.
//   pitch_acc = atan2(ax, az)   (x ileri, z yukarı montajda)
//   pitch_rate = -gy            (sağ el kuralı: y sola bakar)

class Complementary {
 public:
  explicit Complementary(float alpha = 0.98f) : a_(alpha) {}
  float update(float accAngle, float gyroRate, float dt) {
    if (!init_) { angle_ = accAngle; init_ = true; }
    angle_ = a_ * (angle_ + gyroRate * dt) + (1 - a_) * accAngle;
    return angle_;
  }
  void alpha(float a) { a_ = a; }
 private:
  float a_, angle_ = 0;
  bool init_ = false;
};

// 2 durumlu Kalman: [açı, gyro bias]. Bias'ı kendi kendine öğrenir.
class TiltKalman {
 public:
  float qAngle = 0.001f, qBias = 0.003f, rMeasure = 0.03f;
  float update(float accAngle, float gyroRate, float dt) {
    rate_ = gyroRate - bias_;
    angle_ += dt * rate_;
    P_[0][0] += dt * (dt * P_[1][1] - P_[0][1] - P_[1][0] + qAngle);
    P_[0][1] -= dt * P_[1][1];
    P_[1][0] -= dt * P_[1][1];
    P_[1][1] += qBias * dt;
    const float S = P_[0][0] + rMeasure;
    const float K0 = P_[0][0] / S, K1 = P_[1][0] / S;
    const float y = accAngle - angle_;
    angle_ += K0 * y;
    bias_ += K1 * y;
    const float p00 = P_[0][0], p01 = P_[0][1];
    P_[0][0] -= K0 * p00;
    P_[0][1] -= K0 * p01;
    P_[1][0] -= K1 * p00;
    P_[1][1] -= K1 * p01;
    return angle_;
  }
  float rate() const { return rate_; }   // bias'ı düşülmüş açısal hız
  float bias() const { return bias_; }
 private:
  float angle_ = 0, bias_ = 0, rate_ = 0;
  float P_[2][2] = {{0, 0}, {0, 0}};
};
Kanıt: Dört tahminin aynı grafikte gösterildiği bir çizim ve seçtiğin α ile gerekçesi.
Tuzaklar
  • dt'yi sabit varsayma; micros() farkıyla ölç. Döngü kayarsa gyro integrali de kayar.
Daha derin
  • Madgwick ya da Mahony filtresini dene (3B yönelim); iki tekerlekli robota fazla mı?
  • Kalman'ın Q ve R değerlerini simülasyonda değiştir; aynı şeyi complementary'nin α'sıyla eşleştirebilir misin?
4.3

Sadece açı PD: neden yetmez?

Yalnız eğime bakan bir PD ile robotu dengede tutmaya çalıştın ve neden kaçarak düştüğünü açıklayabiliyorsun.

Önce simülasyonda
Dene:
  • Kaldır ve izle: robot birkaç saniye duruyor ama hızlanarak kaçıyor ve düşüyor.
  • Kp ve Kd'yi ne kadar artırırsan artır, sonuç değişmiyor; yalnız ne kadar sürdüğü değişiyor.
  • Sebep back-EMF: robot hızlandıkça aynı PWM daha az tork verir, açı döngüsü bunu görmez.
Simülasyon yükleniyor…
Yap
Kanıt: Düşüşün videosu ve simülasyondaki grafikle karşılaştırma: aynı "kaçma" davranışı mı?
Tuzaklar
  • Motor yönü yanlışsa robot eğildiği yönün tersine gider ve anında düşer. İlk testi tekeri havada yap.
  • Gücü ilk açtığında robot fırlayabilir: kodda 5°'den dik ve 0.5 s sabit tutulmadan devreye girmiyor.
4.4

Kaskad: hız döngüsü ekle

Robot iki tekerlek üstünde kendi başına duruyor, hafifçe itince toparlıyor, /cmd_vel ile ileri gidiyor.

Önce simülasyonda
Dene:
  • İt: robot sendeliyor ve toparlıyor. PWM grafiğinde doyum var mı?
  • Hedef hızı 0.3 m/s yap: robot önce geri gidip öne eğiliyor (minimum olmayan faz).
  • Ağırlık merkezini 0.05 m'ye indir: kısa gövde çok daha zor. 0.25 m'ye çıkar.
  • Kontrol frekansını 50 Hz'e, gecikmeyi 20 ms'ye çek: nerede bozuluyor?
  • Motoru 100 RPM'e çevir: güçlü ama yavaş motorla neden zorlanıyor?
Simülasyon yükleniyor…
Yap
Kod
src/balance.cpp
// src/balance.cpp — adım 4.4: kaskad denge kontrolcüsü (dış: hız PI, iç: açı PD)
// 200 Hz, 1. çekirdekte. Robot dik tutulunca devreye girer, 40°'yi geçerse keser.
#include <Arduino.h>
#include "drive.h"     // Motor, QuadEncoder (3. hafta)
#include "mpu6050.h"
#include "pins.h"
#include "tilt.h"

struct Gains {
  float kp = 5.0f, kd = 0.5f;        // PWM / rad, PWM / (rad/s)  — simülasyondaki varsayılanlar
  float kv = 0.5f, kiv = 0.2f;       // rad / (m/s), rad / m
  float trim = 0.0f;                 // rad: ağırlık merkezi tam üstte değilse dengedeki açı
};

Gains gains;
volatile float g_vRef = 0, g_wRef = 0;   // m/s, rad/s — /cmd_vel'den
constexpr float WHEEL_R = 0.0325f, CPR = 1400.0f, TRACK = 0.175f;
constexpr float CUTOFF = 40.0f * DEG_TO_RAD;
constexpr int HZ = 200;

Motor left(PIN_PWMA, PIN_AIN1, PIN_AIN2), right(PIN_PWMB, PIN_BIN1, PIN_BIN2);
QuadEncoder encL(PIN_ENC_L_A, PIN_ENC_L_B), encR(PIN_ENC_R_A, PIN_ENC_R_B);
Mpu6050 imu;
Complementary tilt(0.98f);

enum class Mode { Disarmed, Arming, Balancing, Fallen };
volatile Mode mode = Mode::Disarmed;

void balanceTask(void*) {
  TickType_t wake = xTaskGetTickCount();
  const float dt = 1.0f / HZ;
  int32_t lastL = encL.count(), lastR = encR.count();
  float v = 0, integ = 0, thRef = 0;
  uint32_t uprightSince = 0;
  for (;;) {
    vTaskDelayUntil(&wake, pdMS_TO_TICKS(1000 / HZ));
    ImuSample s = imu.read();
    const float rate = -s.gy;
    const float th = tilt.update(atan2f(s.ax, s.az), rate, dt) - gains.trim;

    int32_t l = encL.count(), r = encR.count();
    const float vL = (l - lastL) / CPR * 2 * PI * WHEEL_R / dt;
    const float vR = (r - lastR) / CPR * 2 * PI * WHEEL_R / dt;
    lastL = l;
    lastR = r;
    v += 0.2f * ((vL + vR) / 2 - v);   // tekerlek hızı gürültülü: hafif filtre

    switch (mode) {
      case Mode::Disarmed:
      case Mode::Fallen:
        left.coast(); right.coast();
        if (fabsf(th) < 5 * DEG_TO_RAD) {
          if (!uprightSince) uprightSince= millis();
          if (millis() - uprightSince > 500) { mode = Mode::Arming; integ = 0; }
        } else uprightSince = 0;
        continue;
      case Mode::Arming:
        mode = Mode::Balancing;
        [[fallthrough]];
      case Mode::Balancing:
        if (fabsf(th) > CUTOFF) { mode = Mode::Fallen; uprightSince = 0; continue; }
        break;
    }

    // dış döngü: hız hatası → istenen eğim (öne gitmek için öne eğil)
    const float e = g_vRef - v;
    integ = constrain(integ + e * dt, -2.0f, 2.0f);
    thRef = constrain(gains.kv * e + gains.kiv * integ, -0.25f, 0.25f);
    // iç döngü: eğim hatası + açısal hız → PWM
    const float u = gains.kp * (th - thRef) + gains.kd * rate;
    // dönüş: iki tekere zıt ofset, dengeyi bozmadan
    const float turn = constrain(g_wRef * 0.08f, -0.3f, 0.3f);
    left.set(constrain(u - turn, -1.0f, 1.0f));
    right.set(constrain(u + turn, -1.0f, 1.0f));
  }
}

void setupBalance() {
  imu.begin(PIN_SDA, PIN_SCL);
  imu.calibrateGyro();      // açılışta robotu yatır ya da sabit tut
  xTaskCreatePinnedToCore(balanceTask, "bal", 4096, nullptr, 6, nullptr, 1);
}
Kanıt: 30 saniye kendi başına duran, bir kez itilip toparlayan robotun videosu ve o anın eğim/PWM grafiği.
Tuzaklar
  • Pil gerilimi düştükçe aynı PWM daha az tork verir: dolu pille ayarladığın robot boşalınca titrer. Pil gerilimini ölçüp PWM'i ölçeklemek iyi bir sonraki adım.
  • Redüktör boşluğu (backlash) küçük salınımlara yol açar; Kd'yi biraz düşürmek ya da ölü bölge eklemek yardımcı olur.
  • Motorları ani yön değiştirirken TB6612 ısınır: 1.2 A sürekli akım sınırına dikkat.
Daha derin
  • Pil gerilimi kompanzasyonu: ADC ile pil gerilimini oku, u_gerçek = u · V_nominal / V_ölçülen.
  • Dönüşü ayrı bir yaw PID'i ile kontrol et (gyro z ile), sabit ofset yerine.
4.5

LQR: kazancı modelden hesapla

Robotunun ölçülerinden doğrusal model kurdun, LQR kazancını hesapladın ve aynı robotta kaskad ile karşılaştırdın.

Önce simülasyonda
Dene:
  • Q eğim'i artır: robot daha dik durur ama PWM daha çok oynar.
  • R'yi büyüt: daha tembel, daha az doyum. Küçült: agresif.
  • Q konum'u 0 yap: robot hızını tutar ama yerinden kayar. Neden?
  • Kazanç vektörünün başlıktaki işaretlerine bak: hız ve konum geri beslemesi kaskaddaki dış döngünün karşılığı.
Simülasyon yükleniyor…
Yap
Kod
tools/lqr_gain.py
#!/usr/bin/env python3
"""lqr_gain.py — kendi robotunun ölçülerinden LQR kazancı.

  pip install numpy scipy control
Değerleri ölç: gövde kütlesi (tekerlek ve motorlar hariç), ağırlık merkezinin
aks üstündeki yüksekliği (mekanik sayfası verir ya da robotu bir kenarda
dengeleyerek bul), motorun stall torku ve boşta hızı (1. haftadaki step testinden).
Model simülasyondakiyle aynı: τ = 2·τs·(u − ω/ω0), u = PWM (−1..1).
"""
import numpy as np
from scipy.linalg import solve_discrete_are

g = 9.81
r = 0.0325          # tekerlek yarıçapı (m)
mp = 0.70           # gövde (kg)
mc = 0.25           # tekerlek + motor (+ tekerlek ataleti / r²)
l = 0.12            # ağırlık merkezi, aksın üstünde (m)
taus = 0.18         # motor başına stall torku (N·m) — 300 RPM JGA25-370 için tahmin
w0 = 31.4           # boşta açısal hız (rad/s) = 300 RPM
fc = 200            # kontrol frekansı (Hz)

M, ml = mc + mp, mp * l
J = mp * l**2 + mp * (2 * l) ** 2 / 12
a, b = 2 * taus, 2 * taus / (r * w0)
det = M * J - ml**2

# durum: [x, ẋ, θ, θ̇], giriş: u (PWM)
A = np.array([
    [0, 1, 0, 0],
    [0, -b * (J / r + ml) / det, -ml * mp * g * l / det, 0],
    [0, 0, 0, 1],
    [0, b * (ml / r + M) / det, M * mp * g * l / det, 0],
])
B = np.array([[0], [a * (J / r + ml) / det], [0], [-a * (ml / r + M) / det]])

dt = 1 / fc
Ad, Bd = np.eye(4) + A * dt, B * dt          # Euler ayrıklaştırma (dt küçük)
Q = np.diag([1.0, 1.0, 10.0, 0.1])           # konum, hız, eğim, açısal hız
R = np.array([[1.0]])                        # PWM kullanmanın bedeli

P = solve_discrete_are(Ad, Bd, Q, R)
K = np.linalg.solve(R + Bd.T @ P @ Bd, Bd.T @ P @ Ad)
print("açık döngü kutupları:", np.round(np.linalg.eigvals(A), 2))
print("kapalı döngü kutupları:", np.round(np.abs(np.linalg.eigvals(Ad - Bd @ K)), 4), "(hepsi < 1 olmalı)")
print("K =", np.round(K.ravel(), 3), "  →  u = -K · [x - x_ref, v - v_ref, θ, θ̇]")
Kanıt: Aynı itme için kaskad ve LQR'ın eğim grafikleri yan yana; en büyük eğim ve toparlanma süresi.
Tuzaklar
  • Model yanlışsa (örn. ağırlık merkezi) LQR kazancı da yanlış olur. Simülasyonda l'yi %30 değiştirip dayanıklılığı dene.
  • x ve ẋ tekerlek odometry'sinden gelir; patinajda yanılır. Halı yerine sert zeminde test et.
Daha derin
  • Gürültü varken LQR yerine LQG (LQR + Kalman) kullan: durum tahmini ve kontrolü birlikte tasarla.
  • Tedrake'in Underactuated Robotics notlarındaki cart-pole bölümünü oku: aynı problem, enerji tabanlı swing-up dahil.
4.6

Gerçek ile simülasyonu üst üste koy

Aynı itmeyi gerçek robota ve simülasyona uyguladın; parametreleri gerçek grafiğe oturana kadar ayarladın. İlk sim-to-real notun hazır.

Önce simülasyonda
Dene:
  • Gerçek robottaki kazançları simülasyona gir.
  • Gerçek grafikle eşleşene kadar ağırlık merkezi, motor ve gecikmeyi ayarla.
Simülasyon yükleniyor…
Yap
Kanıt: Gerçek ve simülasyon eğrisi aynı grafikte, uyuşmayan yerlerin kısa açıklamasıyla.
Daha derin
  • System identification: motor sabitlerini ve gecikmeyi bir optimizasyonla (scipy.optimize) kayıtlı veriye oturt.

Kavramlar

Ters sarkaçAğırlık merkezi desteğin üstünde: kendi başına kararsız. Tek çare, desteği ağırlığın altına sürmek.
Complementary filtreGyro kısa vadede doğru ama kayar; ivmeölçer uzun vadede doğru ama gürültülü. İkisini frekansa göre harmanla.
Kalman filtresiTahmin ve ölçümü belirsizliklerine göre ağırlıklandırır; gyro bias'ını da tahmin edebilir.
Kaskad kontrolDış döngü (hız) iç döngüye (eğim) hedef verir. İç döngü çok daha hızlı olmalı.
Minimum olmayan fazÖne gitmek için önce tekeri geri çekip gövdeyi öne eğmen gerekir. Dengeleyen robotun en şaşırtıcı davranışı.
LQRDoğrusal modelden, durum hatası ile kontrol çabası arasındaki dengeyi en iyileyen kazanç matrisi.
Back-EMFDönen motor gerilim üretir; hız arttıkça aynı PWM daha az tork verir. Bu yüzden yalnız açı döngüsü kaçar.

Kaynaklar