Перейти к контенту
← Назад

Зачем нужен регулятор

Тестовая статья

Любая роботизированная система пытается добиться заданного значения (позиции, скорости, угла, температуры и т.д.) несмотря на помехи: трение, нагрузка, задержки датчиков.
Регулятор — это алгоритм, который по ошибке e = setpoint − measured вычисляет управляющее воздействие на привод/мотор.

Три кирпичика: P, I, D

Комбинируя, получаем PI (часто для скорости/потока) и PID (универсальный для большинства задач слежения).

Как выбрать тип

Базовая настройка (стартовая евристика)

  1. Поставьте I = 0, D = 0, увеличивайте P, пока система не начнёт быстро, но без больших колебаний выходить к заданию.
  2. Добавьте I небольшими шагами, чтобы убрать остаточную ошибку. Если появляются «качели» — уменьшите I.
  3. Если наблюдается перерегулирование — добавьте D (или чуть уменьшите P).
  4. Введите ограничения: по выходу, по интегратору (anti-windup), по скорости изменения выхода (slew-rate).

Практично: вести логи ошибок/выхода, смотреть переходный процесс (перерегулирование, время установления), фиксировать удачные наборы коэффициентов.

Частые ошибки

Пример PID на Python (дискретный, с anti-windup)

class PID:
    def __init__(self, kp=1.0, ki=0.0, kd=0.0, dt=0.01,
                 out_min=None, out_max=None,
                 i_min=None, i_max=None,
                 d_limit=None):
        self.kp, self.ki, self.kd = kp, ki, kd
        self.dt = dt
        self.out_min, self.out_max = out_min, out_max
        self.i_min, self.i_max = i_min, i_max
        self.d_limit = d_limit
        self.integral = 0.0
        self.prev_error = 0.0
        self.initialized = False

    def reset(self):
        self.integral = 0.0
        self.prev_error = 0.0
        self.initialized = False

    def clamp(self, v, lo, hi):
        if lo is not None and v < lo: v = lo
        if hi is not None and v > hi: v = hi
        return v

    def __call__(self, setpoint, measured):
        e = setpoint - measured

        # P
        p = self.kp * e

        # I (anti-windup через ограничение интегратора)
        self.integral += e * self.dt
        self.integral = self.clamp(self.integral, self.i_min, self.i_max)
        i = self.ki * self.integral

        # D (по ошибке; при первом шаге D=0)
        if not self.initialized:
            d_raw = 0.0
            self.initialized = True
        else:
            d_raw = (e - self.prev_error) / self.dt
        if self.d_limit is not None:
            d_raw = self.clamp(d_raw, -self.d_limit, self.d_limit)
        d = self.kd * d_raw

        u = p + i + d
        u = self.clamp(u, self.out_min, self.out_max)

        self.prev_error = e
        return u

# Пример использования:
# pid = PID(kp=1.2, ki=0.6, kd=0.05, dt=0.01, out_min=-100, out_max=100, i_min=-10, i_max=10, d_limit=200)
# while True:
#     u = pid(setpoint, measured_value)
#     actuate(u)
// Copyright (c) 2026 ЦПМК по информатике, Юрий Дементьев
// Licensed under the MIT License.
// https://robot.mipt.ru/
// Пример работы с I2C OLED дисплеем 128*64 пикселя на базе контроллера SSD1306
// подключение:
// VCC -  3.3В или 5В
// GND - GND (0 В)
// SCL - SCL (A5 Arduino UNO) 
// SDA - SDA (A4 Arduino UNO)
// в дисплее есть встроенная подтяжка для I2C


#include <Wire.h>  // Стандартная библиотека работы с I2C

#define OLED_ADDR 0x3C  // Стандартный адрес I2C для SSD1306

// Упрощенный шрифт ASCII (символы 32-126)
// PROGMEM заставляет компилятор оставить данные во Flash-памяти и не копировать их в SRAM при старте программы.
// байты тут - столбцы при выводе символа на экран, символ можно увидеть повернув голову, например 0b01011111 превратится в !  
const uint8_t font5x7[][5] PROGMEM = {
  { 0b00000000,
    0b00000000,
    0b00000000,
    0b00000000,
    0b00000000 },  // space
  { 0b00000000,
    0b00000000,
    0b01011111,
    0b00000000,
    0b00000000 },  // !
  { 0b00000000,
    0b00000111,
    0b00000000,
    0b00000111,
    0b00000000 },  // "
  { 0b00010100,
    0b01111111,
    0b00010100,
    0b01111111,
    0b00010100 },  // #
  { 0b00100100,
    0b00101010,
    0b01111111,
    0b00101010,
    0b00010010 },  // $
  { 0b00100011,
    0b00010011,
    0b00001000,
    0b01100100,
    0b01100010 },  // %
  { 0b00110110,
    0b01001001,
    0b01010101,
    0b00100010,
    0b01010000 },  // &
  { 0b00000000,
    0b00000101,
    0b00000011,
    0b00000000,
    0b00000000 },  // '
  { 0b00000000,
    0b00011100,
    0b00100010,
    0b01000001,
    0b00000000 },  // (
  { 0b00000000,
    0b01000001,
    0b00100010,
    0b00011100,
    0b00000000 },  // )
  { 0b00010100,
    0b00001000,
    0b00111110,
    0b00001000,
    0b00010100 },  // *
  { 0b00001000,
    0b00001000,
    0b00111110,
    0b00001000,
    0b00001000 },  // +
  { 0b00000000,
    0b01010000,
    0b00110000,
    0b00000000,
    0b00000000 },  // ,
  { 0b00001000,
    0b00001000,
    0b00001000,
    0b00001000,
    0b00001000 },  // -
  { 0b00000000,
    0b01100000,
    0b01100000,
    0b00000000,
    0b00000000 },  // .
  { 0b00100000,
    0b00010000,
    0b00001000,
    0b00000100,
    0b00000010 },  // /
  { 0b00111110,
    0b01010001,
    0b01001001,
    0b01000101,
    0b00111110 },  // 0
  { 0b00000000,
    0b01000010,
    0b01111111,
    0b01000000,
    0b00000000 },  // 1
  { 0b01000010,
    0b01100001,
    0b01010001,
    0b01001001,
    0b01000110 },  // 2
  { 0b00100001,
    0b01000001,
    0b01000101,
    0b01001011,
    0b00110001 },  // 3
  { 0b00011000,
    0b00010100,
    0b00010010,
    0b01111111,
    0b00010000 },  // 4
  { 0b00100111,
    0b01000101,
    0b01000101,
    0b01000101,
    0b00111001 },  // 5
  { 0b00111100,
    0b01001010,
    0b01001001,
    0b01001001,
    0b00110000 },  // 6
  { 0b00000001,
    0b01110001,
    0b00001001,
    0b00000101,
    0b00000011 },  // 7
  { 0b00110110,
    0b01001001,
    0b01001001,
    0b01001001,
    0b00110110 },  // 8
  { 0b00000110,
    0b01001001,
    0b01001001,
    0b00101001,
    0b00011110 },  // 9
  { 0b00000000,
    0b00110110,
    0b00110110,
    0b00000000,
    0b00000000 },  // :
  { 0b00000000,
    0b01010110,
    0b00110110,
    0b00000000,
    0b00000000 },  // ;
  { 0b00001000,
    0b00010100,
    0b00100010,
    0b01000001,
    0b00000000 },  // <
  { 0b00010100,
    0b00010100,
    0b00010100,
    0b00010100,
    0b00010100 },  // =
  { 0b00000000,
    0b01000001,
    0b00100010,
    0b00010100,
    0b00001000 },  // >
  { 0b00000010,
    0b00000001,
    0b01010001,
    0b00001001,
    0b00000110 },  // ?
  { 0b00110010,
    0b01001001,
    0b01111001,
    0b01000001,
    0b00111110 },  // @
  { 0b01111110,
    0b00010001,
    0b00010001,
    0b00010001,
    0b01111110 },  // A
  { 0b01111111,
    0b01001001,
    0b01001001,
    0b01001001,
    0b00110110 },  // B
  { 0b00111110,
    0b01000001,
    0b01000001,
    0b01000001,
    0b00100010 },  // C
  { 0b01111111,
    0b01000001,
    0b01000001,
    0b00100010,
    0b00011100 },  // D
  { 0b01111111,
    0b01001001,
    0b01001001,
    0b01001001,
    0b01000001 },  // E
  { 0b01111111,
    0b00001001,
    0b00001001,
    0b00001001,
    0b00000001 },  // F
  { 0b00111110,
    0b01000001,
    0b01001001,
    0b01001001,
    0b01111010 },  // G
  { 0b01111111,
    0b00001000,
    0b00001000,
    0b00001000,
    0b01111111 },  // H
  { 0b00000000,
    0b01000001,
    0b01111111,
    0b01000001,
    0b00000000 },  // I
  { 0b00100000,
    0b01000000,
    0b01000001,
    0b00111111,
    0b00000001 },  // J
  { 0b01111111,
    0b00001000,
    0b00010100,
    0b00100010,
    0b01000001 },  // K
  { 0b01111111,
    0b01000000,
    0b01000000,
    0b01000000,
    0b01000000 },  // L
  { 0b01111111,
    0b00000010,
    0b00001100,
    0b00000010,
    0b01111111 },  // M
  { 0b01111111,
    0b00000100,
    0b00001000,
    0b00010000,
    0b01111111 },  // N
  { 0b00111110,
    0b01000001,
    0b01000001,
    0b01000001,
    0b00111110 },  // O
  { 0b01111111,
    0b00001001,
    0b00001001,
    0b00001001,
    0b00000110 },  // P
  { 0b00111110,
    0b01000001,
    0b01010001,
    0b00100001,
    0b01011110 },  // Q
  { 0b01111111,
    0b00001001,
    0b00011001,
    0b00101001,
    0b01000110 },  // R
  { 0b01000110,
    0b01001001,
    0b01001001,
    0b01001001,
    0b00110001 },  // S
  { 0b00000001,
    0b00000001,
    0b01111111,
    0b00000001,
    0b00000001 },  // T
  { 0b00111111,
    0b01000000,
    0b01000000,
    0b01000000,
    0b00111111 },  // U
  { 0b00011111,
    0b00100000,
    0b01000000,
    0b00100000,
    0b00011111 },  // V
  { 0b00111111,
    0b01000000,
    0b00111000,
    0b01000000,
    0b00111111 },  // W
  { 0b01100011,
    0b00010100,
    0b00001000,
    0b00010100,
    0b01100011 },  // X
  { 0b00000111,
    0b00001000,
    0b01110000,
    0b00001000,
    0b00000111 },  // Y
  { 0b01100001,
    0b01010001,
    0b01001001,
    0b01000101,
    0b01000011 }  // Z
};

void sendCmd(uint8_t command) {  // отправка команд на дисплей
  Wire.beginTransmission(OLED_ADDR);
  Wire.write(0x80);  // Байт управления: следующая посылка — команда
  Wire.write(command);
  Wire.endTransmission();
}

void setCursor(uint8_t page, uint8_t col) {  // задаем место печати символа
  // в контроллере SSD1306 память 128x64 пикселя разделена на 8 страниц (строк) по 128 столбцов
  sendCmd(0xB0 + page);        // Установка страницы (строки)
  sendCmd(col & 0x0F);         // Столбец (младшие 4 бита)
  sendCmd(0x10 | (col >> 4));  // Столбец (старшие 4 бита)
}

void clear(uint8_t page, uint8_t col) {  // функция очистки с заданного места и до конца строки
  setCursor(page, col);
  Wire.beginTransmission(OLED_ADDR);
  Wire.write(0x40); // следующий байт (или поток байтов) будет данными пикселей, которые отобразятся на экране
  for (int i = col; i < 128; i++) {
    Wire.write(0);
    if (i > 0 && i % 16 == 0) { // Каждые 16 байт перезапускаем передачу (защита буфера Wire)
      Wire.endTransmission();
      Wire.beginTransmission(OLED_ADDR);
      Wire.write(0x40);
    }
  }
  Wire.endTransmission();
}

void clear(uint8_t page) { // очистка строки
  clear(page, 0);
}

void clear() {  // очистка всего экрана
  for (int p = 0; p < 8; p++) {
    clear(p);
  }
}

void printStr(uint8_t page, uint8_t col, const char* s) {  // печать на экран
  setCursor(page, col);
  while (*s && col < 122) {  // Условие: пока есть символы И есть место на экране
    char c = *s;
    if (c >= 'a' && c <= 'z') c -= 32;  // Если строчная буква (a-z), превращаем в заглавную (A-Z)
    int fontIndex = c - 32;
    if (fontIndex < 0 || fontIndex > 58) fontIndex = '?' - 32;  // Если символ вне диапазона шрифта заменяем на знак вопроса

    Wire.beginTransmission(OLED_ADDR);  // Печать символа
    Wire.write(0x40);  // следующий байт (или поток байтов) будет данными пикселей, которые отобразятся на экране
    for (int i = 0; i < 5; i++) {
      Wire.write(pgm_read_byte(&(font5x7[fontIndex][i])));  // побайтно считываем символы из таблицы
    }
    Wire.write(0x00);  // Межсимвольный интервал 1 столбец
    Wire.endTransmission();

    s++;
    col += 6;  // Сдвигаемся на 5 столюцов буквы + 1 пробел
  }
  clear(page, col); // стираем остаток строки
}

void printStr(uint8_t page, const char* s) {  // печать с начала строки
  printStr(page, 0, s);
}

void drawProgressBar(uint8_t page, int percent) { // рисование ползунка

  setCursor(page, 13);  // начало X=13 окончание X=115 ширина 100+2
  Wire.beginTransmission(OLED_ADDR);
  Wire.write(0x40);
  Wire.write(0b01111110);  // Левый край
  for (int i = 0; i < 100; i++) {
    // Каждые 16 байт перезапускаем передачу (защита буфера Wire)
    if (i > 0 && i % 16 == 0) {
      Wire.endTransmission();
      Wire.beginTransmission(OLED_ADDR);
      Wire.write(0x40);
    }

    if (i < percent) Wire.write(0b01111110);  // рисуем заполненную область
    else Wire.write(0b01000010);              // или только границу
  }

  Wire.write(0b01111110);  // Правый край
  Wire.endTransmission();
}


void setup() {
  Wire.begin();
  Wire.setClock(400000);
  // Инициализация SSD1306
  uint8_t init[] = { 0xAE, 0xD5, 0x80, 0xA8, 0x3F, 0xD3, 0x00, 0x40, 0x8D, 0x14, 0xA1, 0xC8, 0xDA, 0x12, 0x81, 0xCF, 0xD9, 0xF1, 0xDB, 0x40, 0xA4, 0xA6, 0xAF };
  for (uint8_t i = 0; i < sizeof(init); i++) sendCmd(init[i]);

  clear();
  printStr(0, "https://robot.mipt.ru/");
}

void loop() {
  // выведем на экран напряжение на A0
  int adcValue = analogRead(A0);              // Читаем значение 10 бит (0 - 1023)
  float voltage = adcValue * (5.0 / 1023.0);  // Переводим в вольты

  char buf[22];   // Тут формируем строку. На экране поместится максимум 22 символа в строке
  char fBuf[10];  // Временный буфер для дробного числа (snprintf в ардуино их не выводит)

  // Конвертируем float (voltage) в строку vBuf
  dtostrf(voltage, 4, 2, fBuf);   // (число, общая ширина 4, знаков после запятой 2, куда писать)

  // Формируем итоговую строку в buf
  // %d - для целого числа АЦП, %s - для строки с вольтами
  snprintf(buf, sizeof(buf), "A0: %d, %s V", adcValue, fBuf);
  printStr(3, buf);                   // Выводим строку на экран
  drawProgressBar(5, voltage * 20);  // рисуем ползунок

  // напечатаем время с момента запуска микроконтроллера
  unsigned long currentMillis = millis();

  int h = (currentMillis / 3600000) % 24; // часы
  int m = (currentMillis / 60000) % 60;  // минуты
  int s = (currentMillis / 1000) % 60;  // секунды
  int ms_hundreds = (currentMillis % 1000) / 10;  // Сотые доли (альтернативный способ вывода дробных чисел)

  // Формируем строку времени: ЧЧ:ММ:СС.сс

  snprintf(buf, sizeof(buf), "UP: %02dH %02dM %02d.%02dS", h, m, s, ms_hundreds);
  printStr(7, buf);

  delay(100); 
}

Где ставить PID в роботе

Мини-чек-лист перед отдачей робота


Если нужно — добавлю сюда графики переходного процесса (как изображения) или разнесу текст на два уровня: «базовый» и «углублённый» с математикой. Также могу подготовить отдельную версию для страницы-раздела (list) и карточку для ленты с коротким excerpt.