← Назад к блогу

Как мы учили SCADA.AI слышать ритм: сезонный анализ временных рядов

AIPythonYandexGPTLLM

История о том, как превратить хаотичный график в симфонию, и какие грабли пришлось собрать по пути

Зачем вообще искать “ритм” в данных?

Представьте, что вы смотрите на график температуры в серверной за месяц. Видите кучу зигзагов вверх-вниз. Что это?

  • Случайный шум? Возможно.
  • Аномалии? Может быть.
  • Или это просто сервера греются днём и остывают ночью?

Вот именно для ответа на этот вопрос мы и делали сезонный анализ — модуль, который автоматически находит повторяющиеся паттерны в данных и говорит: “Эй, тут есть чёткий суточный цикл!” или “Смотри, тут недельная периодичность!”

Почему это важно:

  • Обнаружение аномалий: если значение отклоняется от типичного паттерна — это подозрительно
  • Прогнозирование: зная сезонность, можно предсказать будущие значения
  • Диагностика: если раньше был чёткий суточный цикл, а теперь его нет — что-то сломалось

Математика: как разложить сигнал на “ноты”

Задача 1: Найти доминирующие периоды

У нас есть временной ряд

$x_0, x_1, ldots, x_{N-1}$ 

Например, 8581 точек CO₂ за 30 дней. Нужно найти, какие периоды (сутки? неделя? месяц?) в нём “звучат” громче всего.

Подход 1: Быстрое преобразование Фурье (FFT)

FFT — это как разобрать музыкальный аккорд на отдельные ноты. Математически:

$$X_k = sum_{n=0}^{N-1} x_n cdot e^{-i cdot 2pi k n / N}$$

Где:

  • $x_n$ — наше значение в момент $n$
  • $X_k$ — комплексная амплитуда частоты $k$
  • $|X_k|^2$ — мощность этой частоты (насколько она “громкая”)

Простыми словами: FFT показывает, какие частоты (и соответствующие им периоды) присутствуют в сигнале.

Подход 2: Автокорреляция (ACF)

Автокорреляция отвечает на вопрос: “Похож ли сигнал сам на себя, если сдвинуть его на $T$ точек?”

$$R(\tau) = rac{1}{N-\tau} sum_{t=0}^{N-\tau-1} (x_t - ar{x})(x_{t+\tau} - ar{x})$$

Если $R(T)$ большое — значит, сигнал повторяется каждые $T$ точек.

Пример: для суточного цикла при 5-минутном интервале $T = 288$ точек (24 часа × 12 измерений/час).

Composite Confidence Score

Мы объединили оба метода в единую оценку уверенности:

$$\text{confidence}(T) = 0.5 cdot hat{P}_T + 0.3 cdot \rho(T) + 0.2 cdot \text{sharpness}(T)$$

Где:

- $hat{P}_T$ — нормализованная мощность FFT
- $\rho(T)$ — нормализованная автокорреляция
- $\text{sharpness}(T)$ — насколько "острый" пик (не размазанный)

Результат: топ-5 периодов с оценкой уверенности в процентах.


Задача 2: Декомпозиция ряда (тренд + сезонность + остаток)

Теперь, когда мы знаем период $T$, хотим разделить ряд на три компоненты:

$$x_t = T_t + S_t + R_t$$

Шаг 1: Оценка тренда

Используем скользящее среднее с окном $T$:

$$T_t = rac{1}{T} sum_{j=-T/2}^{T/2} x_{t+j}$$

Проблема: на краях ряда (первые и последние $T/2$ точек) данных не хватает.

Решение: pandas rolling(window=T, center=True) + fillna(method='bfill').fillna(method='ffill').

Шаг 2: Detrending

Вычитаем тренд:

$$D_t = x_t - T_t$$

Теперь $D_t$ — это “чистый” сигнал без долгосрочного изменения.

Шаг 3: Сезонная компонента

Для каждой фазы

$p in [0, T-1]$ 

усредняем все значения с этой фазой:

$$S_p = rac{1}{K_p} sum_{k=0}^{K_p-1} D_{p + k cdot T}$$

Где $K_p$ — количество наблюдений в фазе $p$.

Пример для 7-дневного ряда с периодом 288 точек (сутки):

- Фаза 0 (полночь): $x_0, x_{288}, x_{576}, ldots, x_{1728}$ — всего 7 значений
- $S_0 = \text{среднее}(x_0, x_{288}, ldots, x_{1728})$

Центрирование: чтобы сумма по периоду была ≈ 0:

$$S'_p = S_p - rac{1}{T} sum_{p=0}^{T-1} S_p$$

Шаг 4: Остаток

$$R_t = x_t - T_t - S'_t$$

Variance Explained

Каждая компонента оценивается через долю объяснённой дисперсии:

$$\text{Var}_{\text{trend}} = rac{\text{Var}(T)}{\text{Var}(X)} cdot 100%$$

Пример результата:

  • Тренд: 1.5% (почти нет долгосрочного изменения)
  • Сезонность: 87.1% (подавляющая часть — циклы)
  • Остаток: 11.2% (шум)

Вывод: ряд высоко периодичен!


Задача 3: Типичный паттерн

Финальный штрих — показать усреднённый профиль одного цикла:

$$\text{pattern}[p] = rac{1}{K_p} sum_{k=0}^{K_p-1} x_{p + k cdot T}$$

Важно: здесь используем исходный ряд (не детрендированный), чтобы паттерн сохранял абсолютные значения.

Результат: массив из $T$ точек, где каждая точка — это среднее значение в данную фазу цикла по всем наблюдениям.

Пример: для суточного цикла получаем 288 точек — типичный профиль CO₂ за сутки:

  • Ночь: 426 ppm (минимум, низкая активность)
  • День: 643 ppm (пик, рабочие часы)
  • Размах: 217 ppm

Фронтенд: как это показать красиво

Helper функции

// Равномерное сэмплирование длинных паттернов
function samplePattern(pattern: number[], maxPoints = 200): number[] {
  if (pattern.length <= maxPoints) return pattern
  const step = pattern.length / maxPoints
  return Array.from({length: maxPoints}, (_, i) => 
    pattern[Math.floor(i * step)]
  )
}

// Безопасное вычисление min/max/range
function getPatternStats(pattern: number[]) {
  const valid = pattern.filter(v => v !== null)
  if (valid.length === 0) return {min: 0, max: 0, range: 0}
  const min = Math.min(...valid)
  const max = Math.max(...valid)
  return {min, max, range: max - min}
}

// Автоматическое определение периода
function formatPeriod(period: number): string {
  // 5-мин sampling rate: 12 точек/час, 288/день, 2016/неделя
  if (period >= 270 && period <= 310) return '~24ч (сутки)'
  if (period >= 2000 && period <= 2040) return '~7 дней (неделя)'
  // ... и так далее
}

Chart.js вместо flex-баров

Первая попытка: показать паттерн как набор вертикальных баров (как гистограмма).

Проблема: для 294 точек с flex-1 и gap-0.5 бары сжимаются до 0px → плоская линия.

Решение: Chart.js Line chart с fill: true:

<Line data={patternData} options={timeSeriesOptions} />

Результат: чёткая линия с волнами, zoom/pan через chartjs-plugin-zoom, tooltips при hover.


Грабли, по которым мы прошлись

Грабли #1: Svelte 5 Runes Mode

Ошибка: const_tag_invalid_placement

{#if condition}
  <div>
    {@const ve = data.value}  <!-- ОШИБКА! -->
    {ve}
  </div>
{/if}

Причина: В Svelte 5 {@const} должен быть непосредственным ребёнком {#if}, {#each}, или <Component>. Нельзя вкладывать его в HTML элементы.

Решение:

{#if condition}
  {@const ve = data.value}  <!-- ПРАВИЛЬНО -->
  <div>
    {ve}
  </div>
{/if}

Вывод: Runes mode строже, но логичнее. Нужно привыкнуть.


Грабли #2: Chart.js Filler Plugin

Ошибка: Tried to use the 'fill' option without the 'Filler' plugin enabled

Причина: fill: true требует зарегистрированный Filler plugin.

Решение:

import { Filler } from 'chart.js'

ChartJS.register(
  CategoryScale, LinearScale, PointElement, LineElement,
  Title, Tooltip, Legend, Filler  // <-- добавили Filler
)

Грабли #3: Лишние закрывающие скобки

Ошибка: element_invalid_closing_tag

<div>
  {@const patternData = {...}}
  <Line data={patternData} />
</div>
{/if}}}  <!-- ОШИБКА: три } вместо двух -->

Причина: При генерации кода добавились лишние }.

Решение: Внимательно считать скобки. {@const ...} закрывается }}, {/if} — это отдельный тег.


Грабли #4: Плоский паттерн

Проблема: паттерн отображался как прямая линия.

Причина: pattern.slice(0, 48) показывал только первые 48 точек из 294 → это ночные часы, когда CO₂ стабилен.

Решение: использовать Chart.js, который масштабирует все точки через viewBox.


Грабли #5: UnboundLocalError в Python

Ошибка: UnboundLocalError: cannot access local variable 'seasonal_analysis'

Причина: В multi-tag ветке seasonal_analysis использовался, но не вычислялся.

Решение:

# Для multi-tag: вычисляем seasonal для каждого тега
seasonal_analysis = {}
for tag_name in request.tags:
    tag_data = data['tags'].get(tag_name, {})
    aligned_values = tag_data.get('aligned_values', [])
    
    if len(aligned_values) >= 50:
        periods = detect_dominant_periods(aligned_values)
        decomp = decompose_seasonal(aligned_values, periods[0]['period'])
        pattern = get_seasonal_pattern(aligned_values, periods[0]['period'])
        
        seasonal_analysis[tag_name] = {
            "periods": periods,
            "decomposition": decomp,
            "pattern": pattern,
        }

Результат

Что получилось

[x] Backend:

  • Модуль seasonal.py с FFT + автокорреляцией + декомпозицией
  • Интеграция в API для single-tag и multi-tag
  • Фикс numpy сериализации и trend estimation

[x] Frontend:

  • Helper функции: samplePattern, getPatternStats, formatPeriod
  • Chart.js Line chart с zoom/pan/tooltips
  • Кнопки управления: (+) (-) сброс PNG fullscreen
  • Filler plugin для заливки
  • Единый стиль для всех графиков

Пример работы

Вход: KITCHEN2-CO2, период 7 дней, 5-мин интервал

Результат:

  • Доминирующие периоды:
    • 288 точек (~24ч) — уверенность 72%
    • 2016 точек (~7 дней) — уверенность 87%
  • Декомпозиция:
    • Тренд: 1.5%
    • Сезонность: 87.1% ← очень периодичный ряд!
    • Остаток: 11.2%
  • Типичный паттерн:
    • Мин: 426 ppm (ночь)
    • Макс: 643 ppm (день)
    • Размах: 217 ppm

Интерпретация: CO₂ на кухне имеет чёткий суточный цикл — ночью минимум (нет людей), днём пик (готовка). Недельный цикл тоже есть, но слабее.


Вывод

Мы научили SCADA.AI слышать ритм в данных:

  1. FFT + автокорреляция находят доминирующие периоды
  2. Декомпозиция разделяет ряд на тренд + сезонность + остаток
  3. Типичный паттерн показывает усреднённый профиль цикла
  4. Chart.js визуализирует всё это красиво

Результат: теперь система может сказать “тут чёткий суточный цикл с амплитудой 217 ppm” вместо “тут какая-то кривая”.

Грабли: Svelte 5 runes mode, Chart.js plugins, лишние скобки, плоские паттерны. Но мы их все собрали!

Версия: 3.2.8 (закоммичено и запушено)


P.S. Если ваш временной ряд похож на синусоиду — поздравляю, у вас идеальная сезонность. Если на белый шум — возможно, стоит проверить датчики. 😉