RK4 для систем: обобщение

Тот же RK4, что и для скаляра, — нужно лишь научить его складывать списки.

RK4 для систем — метод Рунге-Кутты 4-го порядка, в котором промежуточные наклоны k1, k2, k3, k4 являются векторами, а арифметика над ними выполняется покомпонентно.

В одном из прошлых разделов мы вывели классический RK4 для скалярного уравнения. Его суть — за один шаг h собрать четыре оценки наклона (в начале, дважды в середине и в конце интервала) и взять их взвешенное среднее с весами 1, 2, 2, 1. Сейчас мы покажем поразительный факт: чтобы применить тот же метод к системе из любого числа уравнений, не нужно менять ни одной формулы. Меняется только тип данных: вместо чисел в формулах живут векторы. А значит, всё, что нам потребуется добавить — две крошечные функции для арифметики над векторами.

k1..k4 как векторы

Напомним скалярные формулы RK4 и поставим рядом их векторный аналог. Слева Y — число, справа Y — список:

k1 = F(t,       Y)
k2 = F(t + h/2, Y + (h/2)*k1)
k3 = F(t + h/2, Y + (h/2)*k2)
k4 = F(t + h,   Y + h*k3)
Y_new = Y + (h/6)*(k1 + 2*k2 + 2*k3 + k4)

Формулы буква в букву те же. Но теперь k1, k2, k3, k4 — это векторы наклонов: F возвращает список, значит каждый k — список. Выражения вроде Y + (h/2)*k1 надо понимать покомпонентно: умножить каждый элемент k1 на h/2 и поэлементно прибавить к Y. Чистый Python не умеет складывать списки знаком +, поэтому мы заведём две вспомогательные функции, которые и составляют весь «секрет» обобщения:

vadd(a, b)   # поэлементная сумма двух векторов: [a0+b0, a1+b1, ...]
smul(s, v)   # умножение вектора на скаляр s: [s*v0, s*v1, ...]

Как работает под капотом

Соберём полноценный движок rk4_system(F, t0, Y0, h, n). Он копирует начальный вектор (чтобы не испортить аргумент вызывающего), делает n шагов, на каждом считает четыре векторных наклона, комбинирует их через vadd и smul и накапливает результат. Проверять будем на осцилляторе [y' = v, v' = -y] со стартом Y0 = [1, 0] — это начальное условие соответствует точному решению y = cos(t), v = -sin(t). Заодно посчитаем «энергию» E = y^2 + v^2: для точного решения это cos^2 + sin^2 = 1 в любой момент, и хороший метод обязан держать её почти постоянной.

import math

# --- арифметика над векторами (списками) ---
def vadd(a, b):
    return [ai + bi for ai, bi in zip(a, b)]

def smul(s, v):
    return [s * vi for vi in v]

# --- универсальный RK4 для систем ---
def rk4_system(F, t0, Y0, h, n):
    t = t0
    Y = Y0[:]                       # копия, чтобы не портить аргумент
    rows = [(t, Y[:])]
    for _ in range(n):
        k1 = F(t, Y)
        k2 = F(t + h/2, vadd(Y, smul(h/2, k1)))
        k3 = F(t + h/2, vadd(Y, smul(h/2, k2)))
        k4 = F(t + h,   vadd(Y, smul(h,   k3)))
        # Y += (h/6)*(k1 + 2*k2 + 2*k3 + k4)
        s = vadd(vadd(k1, smul(2, k2)), vadd(smul(2, k3), k4))
        Y = vadd(Y, smul(h/6, s))
        t = t + h
        rows.append((t, Y[:]))
    return rows

# Осциллятор: Y = [y, v], F = [v, -y], решение y=cos t, v=-sin t
def F(t, Y):
    y, v = Y
    return [v, -y]

rows = rk4_system(F, 0.0, [1.0, 0.0], 0.1, 30)
for i in range(0, 31, 5):
    t, Y = rows[i]
    y, v = Y
    E = y*y + v*v
    print(f"t={t:4.1f}  y={y:8.5f}  cos={math.cos(t):8.5f}  E={E:.6f}")

Вывод:

t= 0.0  y= 1.00000  cos= 1.00000  E=1.000000
t= 0.5  y= 0.87758  cos= 0.87758  E=1.000000
t= 1.0  y= 0.54030  cos= 0.54030  E=1.000000
t= 1.5  y= 0.07074  cos= 0.07074  E=1.000000
t= 2.0  y=-0.41615  cos=-0.41615  E=1.000000
t= 2.5  y=-0.80114  cos=-0.80114  E=1.000000
t= 3.0  y=-0.98999  cos=-0.98999  E=1.000000

Два вывода из таблицы. Во-первых, столбцы y и cos совпадают до пятого знака — RK4 воспроизводит точное решение синуса с великолепной точностью при крупном шаге h = 0.1. Во-вторых, энергия E = y^2 + v^2 держится равной 1.000000 на всём интервале. Это не случайность: RK4 4-го порядка вносит настолько малую ошибку за шаг, что величины, которые физически должны сохраняться, почти не дрейфуют на коротких и средних дистанциях. Проверка сохраняющейся величины — лучший способ убедиться, что движок для системы написан верно: если энергия «поплыла», ищите ошибку в порядке компонент или в формуле комбинации.

Почему это работает в любой размерности

Заметьте, что в коде rk4_system нигде не написано, что вектор двумерный. vadd и smul работают со списком любой длины, F вызывается как чёрный ящик. Тот же самый движок без единого изменения проинтегрирует систему из трёх уравнений (как SIR-модель), из четырёх (точка на плоскости) или из шести (орбита в пространстве). Мы написали движок один раз — и дальше меняется только функция F. Это и есть выигрыш от перехода к векторному мышлению.

Почему компоненты обновляются синхронно

Здесь скрыта тонкость, в которую легко упереться. Возникает соблазн «упростить» код: пройтись по компонентам вектора по очереди и обновить каждую своим маленьким RK4, как если бы это были независимые скалярные уравнения. Так делать нельзя. В системе уравнения связаны: производная одной компоненты зависит от значений других (в осцилляторе y' зависит от v, а v' зависит от y). Если обновить сначала y, а потом считать наклон для v уже по новому y, мы смешаем два разных момента времени и разрушим связанность системы. Поэтому все наклоны k1, k2, k3, k4 вычисляются от одного и того же вектора состояния, и лишь в самом конце шага весь вектор Y обновляется одним движением. Синхронность — не стилистическая прихоть, а условие корректности: только так метод «видит» систему как единое целое, а не как набор разрозненных уравнений.

Эта же синхронность объясняет, почему движок так легко масштабируется. Вектор состояния может включать не два, а десятки и сотни величин: в химической кинетике каждая компонента — концентрация одного вещества в реакции, в многотельной задаче (звёзды, спутники) — координаты и скорости каждого тела. Размерность вектора растёт, но логика шага не меняется ни на букву: те же четыре оценки наклона, та же взвешенная комбинация. Именно благодаря тому, что vadd и smul работают над списком любой длины, переход от двух уравнений к двумстам не требует ни строчки новой математики — лишь более длинного вектора и более содержательной функции F.

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

  • Y = Y0 вместо Y = Y0[:]. Без копии вы будете изменять список вызывающего, и повторный запуск с тем же Y0 даст мусор. Всегда копируйте начальное состояние.
  • Складывание списков через +. В Python [1,2] + [3,4] даёт [1,2,3,4] (конкатенацию!), а не [4,6]. Поэлементную сумму делает только vadd. Это самая коварная ошибка обобщения.
  • Забыли множители 2 у k2 и k3. Веса в RK4 — это 1, 2, 2, 1. Потеряете двойку — порядок метода упадёт, и точность резко ухудшится.
  • Сохраняют ссылку на Y в истории. В rows нужно класть Y[:], копию. Иначе все строки истории будут указывать на один и тот же изменяемый список и покажут лишь финальное состояние.

Итоги:

  • RK4 для систем — те же формулы, но k1..k4 и Y становятся векторами.
  • Вся «магия обобщения» — две функции: vadd (поэлементная сумма) и smul (умножение на скаляр).
  • Движок rk4_system не зависит от размерности: меняется только F.
  • Проверка сохраняющейся величины (энергия y^2 + v^2 ≈ 1) — надёжный тест корректности.
  • Главная ловушка Python — + конкатенирует списки, а не складывает их поэлементно.
Проверьте себя
1. Чем k1, k2, k3, k4 отличаются в RK4 для систем по сравнению со скалярным случаем?
AИх становится восемь вместо четырёх
BОни становятся векторами (списками) той же длины, что и Y
CОни исчезают — метод упрощается
DОни становятся матрицами
2. Почему в чистом Python нельзя складывать векторы через выражение a + b?
APython вообще не умеет работать со списками
BОператор + для списков делает конкатенацию [a..., b...], а не поэлементную сумму
CСписки в Python неизменяемы
D+ работает, но округляет результат
3. Зачем для осциллятора Y0=[1,0] мы следим за величиной E = y^2 + v^2?
AЭто случайное число без смысла
BЭто аналог сохраняющейся энергии: для точного решения E≈1 всегда, и постоянство E подтверждает корректность метода
CЭто шаг интегрирования h
DЭто период колебаний