Как мы учили SCADA.AI слышать ритм: сезонный анализ временных рядов
История о том, как превратить хаотичный график в симфонию, и какие грабли пришлось собрать по пути
Зачем вообще искать “ритм” в данных?
Представьте, что вы смотрите на график температуры в серверной за месяц. Видите кучу зигзагов вверх-вниз. Что это?
- Случайный шум? Возможно.
- Аномалии? Может быть.
- Или это просто сервера греются днём и остывают ночью?
Вот именно для ответа на этот вопрос мы и делали сезонный анализ — модуль, который автоматически находит повторяющиеся паттерны в данных и говорит: “Эй, тут есть чёткий суточный цикл!” или “Смотри, тут недельная периодичность!”
Почему это важно:
- Обнаружение аномалий: если значение отклоняется от типичного паттерна — это подозрительно
- Прогнозирование: зная сезонность, можно предсказать будущие значения
- Диагностика: если раньше был чёткий суточный цикл, а теперь его нет — что-то сломалось
Математика: как разложить сигнал на “ноты”
Задача 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 слышать ритм в данных:
- FFT + автокорреляция находят доминирующие периоды
- Декомпозиция разделяет ряд на тренд + сезонность + остаток
- Типичный паттерн показывает усреднённый профиль цикла
- Chart.js визуализирует всё это красиво
Результат: теперь система может сказать “тут чёткий суточный цикл с амплитудой 217 ppm” вместо “тут какая-то кривая”.
Грабли: Svelte 5 runes mode, Chart.js plugins, лишние скобки, плоские паттерны. Но мы их все собрали!
Версия: 3.2.8 (закоммичено и запушено)
P.S. Если ваш временной ряд похож на синусоиду — поздравляю, у вас идеальная сезонность. Если на белый шум — возможно, стоит проверить датчики. 😉