Зачем нужен регулятор
Тестовая статья
Любая роботизированная система пытается добиться заданного значения (позиции, скорости, угла, температуры и т.д.) несмотря на помехи: трение, нагрузка, задержки датчиков.
Регулятор — это алгоритм, который по ошибке e = setpoint − measured вычисляет управляющее воздействие на привод/мотор.
Три кирпичика: P, I, D
- P (пропорциональная часть): усилие пропорционально текущей ошибке.
Плюсы — быстро реагирует; минусы — остаётся стационарная ошибка. - I (интегральная часть): накапливает прошлую ошибку → убирает постоянное смещение.
Осторожно с wind-up (перенакопление). - D (дифференциальная часть): гасит колебания, реагируя на скорость изменения ошибки.
Чувствителен к шуму — иногда требуют фильтрацию.
Комбинируя, получаем PI (часто для скорости/потока) и PID (универсальный для большинства задач слежения).
Как выбрать тип
- P — где можно терпеть небольшое смещение (например, простая стабилизация).
- PI — когда важен нулевой статический промах (скорость ленты, температура).
- PID — когда система склонна к колебаниям/перерегулированиям и нужна «пожёстче» динамика (позиционирование осей, балансировка).
Базовая настройка (стартовая евристика)
- Поставьте I = 0, D = 0, увеличивайте P, пока система не начнёт быстро, но без больших колебаний выходить к заданию.
- Добавьте I небольшими шагами, чтобы убрать остаточную ошибку. Если появляются «качели» — уменьшите I.
- Если наблюдается перерегулирование — добавьте D (или чуть уменьшите P).
- Введите ограничения: по выходу, по интегратору (anti-windup), по скорости изменения выхода (slew-rate).
Практично: вести логи ошибок/выхода, смотреть переходный процесс (перерегулирование, время установления), фиксировать удачные наборы коэффициентов.
Частые ошибки
- Нет анти-виндапа: интегратор «разгоняется», привод уходит в насыщение — долгий возврат.
- Шумный D: дергает привод. Решение — ограничить диапазон D, применить фильтр на измерениях или на D-слагаемом.
- Слишком большой P: быстро, но неустойчиво; «пилит» около уставки.
- Слишком маленький I: остаётся смещение; слишком большой — «раскачка».
Пример 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 в роботе
- Контур тока/момента → быстрый PI (в драйвере мотора).
- Контур скорости → PI на основании измеренной скорости (энкодер/тахо).
- Контур позиции → PID поверх скорости или напрямую по позиции (часто каскад).
Мини-чек-лист перед отдачей робота
- Ограничены выходы и интегратор.
- Переходной процесс без «диких» выбросов.
- Параметры сохранены/логируются.
- Отработка на крайних уставках и при резких изменениях нагрузки.
Если нужно — добавлю сюда графики переходного процесса (как изображения) или разнесу текст на два уровня: «базовый» и «углублённый» с математикой. Также могу подготовить отдельную версию для страницы-раздела (list) и карточку для ленты с коротким excerpt.