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

Deep Data Analysis: Как мы ловили аномалии в SCADA и собирали все грабли подряд

AIPythonLLMMachine LearningYandexGPT

Путь от а давайте попробуем Isolation Forest до production-модуля в SCADA.AI v3.2.5 с реальным кодом, реальными багами и реальными ночными дебагами

для тех, кто не любит лонгриды

Мы сделали модуль, который находит аномалии в гигабайтах SCADA-данных там, где обычные алёрты молчат. Провалы, дрейфы, странные корреляции между датчиками — всё это оставалось незамеченным, пока мы не добавили ретроспективный анализ.

Внутри:

  • Isolation Forest (потому что он быстрый и не требует предположений о распределении)
  • Классификация на 4 типа: spike, dip, drift, noise
  • Min-max downsampling, чтобы 100K точек рендерились за секунды
  • Multi-tag корреляционный анализ с тепловой картой
  • Конфигуратор на 4 вкладки
  • ChartModal с zoom/pan/download

А ещё мы собрали 5 эпичных багов. Каждый из них ломал отображение аномалий. Ниже — полный разбор с реальным кодом из нашего репозитория.


Часть 1. Проблема, которую никто не замечал

Представьте: у вас промышленная SCADA-система. Сотни датчиков. Температура, давление, расход, вибрация — всё пишется в PostgreSQL каждую секунду. Оператор смотрит на мнемосхему. Зелёные лампочки. Всё ок.

А в данных уже есть аномалии:

  • Кратковременный провал напряжения 23-го числа в 14:32 (не триггернул аварию)
  • Дрейф датчика температуры, который за неделю ушёл на 3°C от калибровки
  • Пики на датчике давления, которые система считает “шумом”

Через неделю оборудование выходит из строя. Инженеры начинают копать постфактум: “Так, смотрите, вот же, 23-го в 14:32 был провал! А 24-го начался дрейф! Если бы мы заметили раньше — предотвратили бы аварию!”

Классический подход SCADA:

IF температура > 100°C THEN алёрт
IF давление < 2 bar THEN алёрт

Жёсткие пороги. Работают только для очевидных аварий.

Наш подход:

  • Возьми исторические данные за 7/30/90 дней
  • Найди все отклонения от нормального поведения
  • Классифицируй их по типам
  • Покажи корреляции между параметрами
  • Дай инженеру инструменты для анализа

Это ретроспективный анализ. Он не заменяет алёрты реального времени. Он их дополняет. Позволяет найти иголку в стоге сена.


Часть 2. Архитектура: модульность как образ жизни

Первая версия была монолитом — один файл на 800 строк. Всё работало, но поддерживать было больно. Разнесли по модулям:

backend/modules/deep_analysis/
├── api.py                    REST endpoints (FastAPI)
├── analyzers/
│   ├── anomalies.py         Isolation Forest + классификация
│   ├── correlations.py      Матрица корреляций Пирсона
│   └── stats.py             Базовая статистика
├── collectors/
│   ├── data_fetcher.py      Получение данных из PostgreSQL
│   └── tag_resolver.py      Резолвер тегов
├── history/
│   └── storage.py           Хранение истории анализов
└── visualizers/
    └── chart_specs.py       Генерация Chart.js спецификаций

Почему именно так?

  1. Separation of concerns — каждый модуль делает одну вещь. anomalies.py ничего не знает про графики. chart_specs.py ничего не знает про БД.

  2. Testability — можно тестировать анализаторы без БД, визуализаторы без данных.

  3. Extensibility — добавили новый алгоритм детекции? Просто новый файл в analyzers/.

  4. Performance — можно оптимизировать узкие места независимо. Downsampling тормозит? Оптимизируем chart_specs.py, не трогая остальное.

Поток данных:

Пользователь выбирает теги → API → data_fetcher → выравнивание на 5-мин grid
→ Isolation Forest → классификация → корреляции → Chart.js specs → UI

Каждый этап изолирован. Если что-то сломалось — локализуем быстро.


Часть 3. Математика под капотом

3.1 Isolation Forest: изоляция вместо кластеризации

Большинство алгоритмов детекции аномалий работают так:

  1. Построй модель “нормального” поведения
  2. Всё что далеко от модели — аномалия

Isolation Forest работает наоборот. Гениальная в своей простоте идея: аномалии легче изолировать.

Алгоритм строит набор случайных деревьев решений. Для каждого дерева:

  1. Берём подвыборку (обычно 256 точек)
  2. Рекурсивно выбираем случайный признак и случайное значение разделения
  3. Разделяем данные пока не изолируем каждую точку

Аномальные точки изолируются быстрее — они далеко от основной массы, поэтому их легче “отрезать” случайным разбиением.

Anomaly score:

s(x, n) = 2^(-E(h(x)) / c(n))

Где:

  • E(h(x)) — средняя глубина изоляции точки x по всем деревьям
  • c(n) — нормализующий фактор (средняя глубина в BST размера n)
  • n — размер выборки

Интерпретация:

  • s → 1: точка — аномалия (легко изолируется)
  • s ≈ 0.5: нормальная точка
  • s → 0: очень нормальная точка (трудно изолировать)

Почему Isolation Forest, а не что-то ещё?

Рассматривали варианты:

  • Z-score — слишком простой, не ловит сложные паттерны
  • DBSCAN — требует подбора eps, медленно на больших данных
  • Auto-encoder — требует обучения, сложно интерпретировать
  • One-class SVM — медленный, O(n²)

Isolation Forest выиграл по всем пунктам:

  • Быстрый: O(n log n)
  • Не требует предположений о распределении
  • Устойчив к выбросам в обучающей выборке
  • Работает “из коробки” с дефолтными параметрами
  • Хорошо масштабируется

3.2 Классификация аномалий: 4 типа

Isolation Forest говорит “это аномалия”. Но инженеру нужно больше: какая именно? Пик? Провал? Дрейф? Шум?

Для каждой аномальной точки анализируем окно W (обычно ±5 точек):

W = [x(t-w), ..., x(t-1), x(t), x(t+1), ..., x(t+w)]

Spike (Пик) — резкий кратковременный всплеск:

def is_spike(value, window, threshold=2.0):
    mean_val = np.mean(window)
    std_val = np.std(window)
    return (value > mean_val + threshold * std_val and
            abs(value - window[0]) > threshold * std_val and
            abs(value - window[-1]) > threshold * std_val)

Физический смысл: помеха датчика, кратковременный всплеск, электрический шум.

Dip (Провал) — резкое кратковременное падение:

def is_dip(value, window, threshold=2.0):
    mean_val = np.mean(window)
    std_val = np.std(window)
    return (value < mean_val - threshold * std_val and
            abs(value - window[0]) > threshold * std_val and
            abs(value - window[-1]) > threshold * std_val)

Физический смысл: обрыв сигнала, кратковременное отключение, потеря связи.

Drift (Дрейф) — постепенное отклонение от базовой линии:

def is_drift(value, baseline, threshold=2.0):
    std_val = np.std(baseline)
    return abs(value - np.mean(baseline)) > threshold * std_val

Физический смысл: износ оборудования, уход калибровки, температурная компенсация.

Noise (Шум) — всё что не попало под другие категории.

Реальный код из anomalies.py:

def classify_anomaly_type(value, window_values, threshold=2.0):
    """Классифицирует аномалию по локальному контексту."""
    if not window_values or len(window_values) < 3:
        return "noise"

    mean_val = np.mean(window_values)
    std_val = np.std(window_values)

    if std_val < 1e-6:  почти константа
        return "noise"

    Spike: резкий всплеск
    if value > mean_val + threshold * std_val:
        if (abs(value - window_values[0]) > threshold * std_val and
            abs(value - window_values[-1]) > threshold * std_val):
            return "spike"

    Dip: резкий провал
    if value < mean_val - threshold * std_val:
        if (abs(value - window_values[0]) > threshold * std_val and
            abs(value - window_values[-1]) > threshold * std_val):
            return "dip"

    Drift: постепенное отклонение
    half = len(window_values) // 2
    baseline = np.mean(window_values[:half])
    if abs(value - baseline) > threshold * std_val:
        return "drift"

    return "noise"

3.3 Корреляционный анализ

Для multi-tag анализа строим матрицу корреляций Пирсона:

r(X, Y) = Σ((xᵢ - x̄)(yᵢ - ȳ)) / √(Σ(xᵢ - x̄)² · Σ(yᵢ - ȳ)²)

Пример из реального кейса:

  • Температура реактора и давление: r = 0.85 (сильная корреляция)
  • Температура и вибрация: r = 0.12 (нет корреляции)

Что это даёт? Если температура растёт, а давление нет — это аномалия! Даже если оба значения в пределах нормы.

Реальный код из correlations.py:

def calculate_correlation_matrix(tags_data: dict) -> dict:
    tags = list(tags_data.keys())
    n = len(tags)
    matrix = [[0.0] * n for _ in range(n)]

    for i in range(n):
        for j in range(n):
            if i == j:
                matrix[i][j] = 1.0
                continue

            values_i = tags_data[tags[i]]['aligned_values']
            values_j = tags_data[tags[j]]['aligned_values']

            Только пары где оба значения не None
            pairs = [(v_i, v_j) for v_i, v_j in zip(values_i, values_j)
                     if v_i is not None and v_j is not None]

            if len(pairs) < 10:
                continue

            x = np.array([p[0] for p in pairs])
            y = np.array([p[1] for p in pairs])
            corr = np.corrcoef(x, y)[0, 1]
            matrix[i][j] = float(corr) if not np.isnan(corr) else 0.0

    return {"tags": tags, "matrix": matrix}

Часть 4. Downsampling — как отрендерить 100K точек в браузере

Это, пожалуй, самая интересная техническая часть. И самая недооценённая.

Проблема

Возьмём типичный кейс: анализ тега за 90 дней с 5-минутным интервалом.

90 дней × 24 часа × 12 точек/час = 25,920 точек

Если интервал 1 минута — уже 129,600 точек.

Chart.js может их отрендерить. Но:

  • Первые 2-3 секунды браузер “думает”
  • Zoom/pan лагает
  • Потребление памяти — сотни мегабайт
  • На мобильных устройствах вообще не работает

Нужен downsampling. Но какой?

Наивный подход: average (усреднение)

def average_downsample(values, target_points):
    bucket_size = len(values) / target_points
    result = []
    for i in range(target_points):
        start = int(i * bucket_size)
        end = int((i + 1) * bucket_size)
        bucket = values[start:end]
        result.append(np.mean(bucket))
    return result

Проблема: теряем экстремумы!

Bucket: [10, 12, 100, 11, 13]
Среднее: 29.2

Пик 100 потерян! Для детекции аномалий это катастрофа. Именно пики и провалы — самые интересные точки.

Решение: Min-Max Downsampling

Идея: для каждого bucket’а сохранять и min, и max значения.

def downsample_time_series(values: list, timestamps: list,
                         target_points: int = 800) -> tuple:
    """
    Downsample временной ряд с сохранением экстремумов (пиков и провалов).

    Алгоритм min-max downsampling:
    1. Делим диапазон на N bucket'ов
    2. Для каждого bucket находим min и max значения с их timestamps
    3. Добавляем обе точки в порядке их следования во времени
    4. Это сохраняет пики/провалы, которые теряются при усреднении

    Результат: ~2× больше точек чем target_points, но все экстремумы сохранены.
    """
    if len(values) <= target_points:
        return values, timestamps

    bucket_size = len(values) / target_points
    ds_values = []
    ds_timestamps = []

    for i in range(target_points):
        start_idx = int(i * bucket_size)
        end_idx = int((i + 1) * bucket_size)

        bucket_values = values[start_idx:end_idx]
        bucket_timestamps = timestamps[start_idx:end_idx]

        Находим все валидные точки в bucket'е
        valid_points = []
        for j, (v, t) in enumerate(zip(bucket_values, bucket_timestamps)):
            if v is not None and t is not None:
                valid_points.append((start_idx + j, v, t))

        if not valid_points:
            continue

        Находим min и max в bucket'е
        min_point = min(valid_points, key=lambda x: x[1])
        max_point = max(valid_points, key=lambda x: x[1])

        КРИТИЧНО: добавляем в хронологическом порядке!
        if min_point[0] <= max_point[0]:
            ds_values.append(min_point[1])
            ds_timestamps.append(min_point[2])
            if min_point[0] != max_point[0]:
                ds_values.append(max_point[1])
                ds_timestamps.append(max_point[2])
        else:
            ds_values.append(max_point[1])
            ds_timestamps.append(max_point[2])
            ds_values.append(min_point[1])
            ds_timestamps.append(min_point[2])

    return ds_values, ds_timestamps

Результат для того же bucket’а:

Bucket: [10, 12, 100, 11, 13]
Min-max: [10, 100] — оба экстремума сохранены!

Ключевой момент: хронологический порядок. Если min идёт раньше max — добавляем min первым. Иначе — max первым. Это сохраняет форму графика.

Производительность

Тесты на реальных данных:

Исходных точекПосле min-maxСжатиеВремя рендера
10,000~1,6006x50 ms
50,000~1,60031x60 ms
130,000~1,60081x70 ms

Без downsampling рендер 130K точек занимал 3-4 секунды. С downsampling — 70 ms.

Грабли #1: Разный downsampling для разных тегов

Это был самый коварный баг в multi-tag анализе.

Симптом: два тега на одном графике. Один заканчивается 20-го числа, другой — 26-го. Графики “съезжают” друг относительно друга.

Код, который вызывал проблему:

БЫЛО (неправильно):
for tag_name, tag_data in tags_data.items():
    ds_values, ds_timestamps = downsample_time_series(
        tag_data['aligned_values'],
        common_timestamps,
        max_points
    )
    Каждый тег имеет свои ds_timestamps!

Корень проблемы: min-max downsampling выбирает разные точки для разных значений, даже если timestamps одинаковые.

Пример для одного bucket’а с timestamps [t1, t2, t3, t4, t5]:

Тег A (температура): [20, 22, 21, 23, 20]
  min=20 в t1, max=23 в t4
  → ds_timestamps_A = [t1, t4]
Тег B (давление): [5, 8, 10, 7, 6]
  min=5 в t1, max=10 в t3
  → ds_timestamps_B = [t1, t3]

Разные ds_timestamps для разных тегов! График тега A использует labels [t1, t4], график тега B — [t1, t3]. Они “едут”.

Решение: один downsampling для всех тегов.

СТАЛО (правильно):
ОДИН downsampling для получения ds_timestamps
_, ds_timestamps = downsample_time_series(
    first_tag_values, common_timestamps, max_points
)
Все теги используют те же ds_timestamps
for tag_name, tag_data in tags_data.items():
    ds_values, _ = downsample_time_series(
        tag_data['aligned_values'],
        common_timestamps,
        max_points
    )
    ds_timestamps ОДИНАКОВЫЕ для всех тегов

Теперь все теги используют одну временную сетку. Графики синхронизированы.

Урок: при работе с несколькими временными рядами всегда используйте общую временную сетку для downsampling. Иначе получите рассинхрон.


Часть 5. Timezone Hell — как аномалии уехали на 11 часов

Это был самый эпичный баг. Мы потратили на него почти целый день.

Симптом

Пользователь открывает график. Видит точку провала. Наводит курсор:

Реальный провал: 28.05.2026 02:18
На графике:      27.05.2026 14:58
Разница:         -11 часов 20 минут

WTF?! Откуда 11 часов 20 минут?!

Расследование

Сначала думали: проблема в Chart.js. Может, он как-то странно интерпретирует timestamps. Проверили — нет, Chart.js работает корректно.

Потом думали: проблема в БД. Может, timestamps сохраняются с ошибкой. Проверили SQL-запрос — нет, timestamps правильные.

Потом заметили паттерн: сдвиг всегда один и тот же — 11 часов 20 минут. Не случайный. Всегда одинаковый.

Эврика! 11 часов 20 минут — это разница между UTC и локальным временем (Asia/Yekaterinburg, UTC+5) плюс какое-то смещение.

Корень проблемы

Timestamps из PostgreSQL приходят как naive datetime (без timezone info):

timestamp = datetime(2026, 5, 28, 2, 18)  naive!

Когда мы форматируем их в строку для Chart.js:

ts.strftime("%Y-%m-%d %H:%M")  "2026-05-28 02:18"

Chart.js получает строку без timezone. Интерпретирует как UTC. Браузер конвертирует в локальное время пользователя.

Если сервер в UTC, а пользователь в Екатеринбурге (UTC+5):

  • Сервер: “2026-05-28 02:18” (думает что это UTC)
  • Браузер: “2026-05-28 07:18” (добавил 5 часов)

Но у нас получилось 11ч 20м, а не 5ч. Значит, проблема ещё где-то.

Оказалось, PostgreSQL в нашем случае возвращал timestamps в локальном времени сервера (Екатеринбург), но без timezone info. А Chart.js интерпретировал их как UTC. Итого: -5 часов (потеряли Екатеринбург) - 6 часов (ещё какая-то конвертация) = -11 часов.

Решение

Явное применение timezone из конфига.

from config.settings import settings
import pytz
Получаем таймзону из конфига (один раз при старте)
LOCAL_TZ = pytz.timezone(settings.timezone)  "Asia/Yekaterinburg"
def apply_timezone(ts):
    """Применяет локальную таймзону к timestamp."""
    if isinstance(ts, datetime):
        if ts.tzinfo is None:
            Naive datetime → локализуем в нашей таймзоне
            return LOCAL_TZ.localize(ts)
        else:
            Aware datetime → конвертируем в нашу таймзону
            return ts.astimezone(LOCAL_TZ)
    return ts
def format_ts_label(ts) -> str:
    """Форматирует timestamp с учётом таймзоны."""
    if isinstance(ts, datetime):
        return apply_timezone(ts).strftime("%Y-%m-%d %H:%M")
    ts_str = str(ts).replace('T', ' ')
    return ts_str[:16] if len(ts_str) > 16 else ts_str

Где применяем:

  1. Labels для оси X:
labels = [format_ts_label(ts) for ts in ds_timestamps]
  1. Маппинг timestamp → index:
ts_to_index = {}
for idx, ts in enumerate(ds_timestamps):
    ts_key = format_ts_label(ts)
    ts_to_index[ts_key] = idx
  1. Поиск аномалий по timestamp:
for val, orig_ts in anomaly_points:
    ts_key = format_ts_label(orig_ts)
    if ts_key in ts_to_index:
        ds_idx = ts_to_index[ts_key]
        type_data[ds_idx] = val

Результат: аномалии точно на своих местах.

Урок

Всегда явно указывайте timezone. Никогда не полагайтесь на “оно как-нибудь само”. В распределённых системах (сервер в одном TZ, клиент в другом) naive datetime — это мина замедленного действия.

Три правила работы с timestamps:

  1. Храните в UTC (в БД)
  2. Передавайте с timezone info (ISO 8601 с Z или +05:00)
  3. Конвертируйте в локальное время только на клиенте

Мы нарушили все три. И получили 11 часов сдвига.


Часть 6. Индексы vs Timestamps — как аномалии уехали в случайные места

Симптом

В multi-tag анализе точки аномалий отображались в случайных местах графика. Не синхронизированы с реальным графиком. Шумы отображались на точках провала, провалы — где-то в середине графика.

Полный хаос.

Расследование

Логи показывали: аномалии детектируются правильно. anomaly_indices корректные. anomaly_values правильные. Но отображаются не там.

Начали копать в api.py и нашли корень зла:

БЫЛО (строка 232-237 в api.py):
for tag_name in request.tags:
    tag_data = data['tags'].get(tag_name, {})
    aligned_values = tag_data.get('aligned_values', [])
    Фильтруем None значения
    valid_values = [v for v in aligned_values if v is not None]
    if len(valid_values) >= 10:
        tag_anomalies = detect_anomalies_isolation_forest(
            valid_values,
            list(range(len(valid_values))),  ← ВОТ ОНА!
            contamination=adaptive_contamination,
            classify_types=True
        )

list(range(len(valid_values))) — это индексы! [0, 1, 2, 3, ...]

Второй аргумент detect_anomalies_isolation_forest — это timestamps. Но мы передавали туда индексы!

Почему это ломало отображение

detect_anomalies_isolation_forest возвращает:

{
    "anomaly_indices": [142, 287, 534, ...],
    "anomaly_timestamps": [0, 1, 2, ...],  ← индексы, не datetime!
    "anomaly_values": [45.2, 38.7, 52.1, ...],
    "anomaly_types": ["spike", "dip", "drift", ...]
}

Потом create_time_series_spec пытается найти аномалию по timestamp:

for val, orig_ts in anomaly_points:
    ts_key = format_ts_label(orig_ts)  "1970-01-01 05:00" (timestamp 0 = epoch!)
    if ts_key in ts_to_index:
        ds_idx = ts_to_index[ts_key]
        type_data[ds_idx] = val

orig_ts — это число 0, 1, 2… Когда мы пытаемся его форматировать как datetime, Python интерпретирует его как Unix timestamp (секунды с epoch).

datetime.fromtimestamp(0)  1970-01-01 00:00:00 UTC
datetime.fromtimestamp(142)  1970-01-01 00:02:22 UTC

Все “timestamps” оказываются 1 января 1970 года! Естественно, в ts_to_index (где реальные timestamps 2026 года) их нет. Fallback-логика ищет ближайший — и находит что-то случайное.

Отсюда и “хаотичное отображение”.

Решение

Передавать реальные timestamps из common_timestamps:

СТАЛО:
for tag_name in request.tags:
    tag_data = data['tags'].get(tag_name, {})
    aligned_values = tag_data.get('aligned_values', [])

    Фильтруем None значения И соответствующие timestamps
    valid_values = []
    valid_timestamps = []
    for idx, v in enumerate(aligned_values):
        if v is not None:
            valid_values.append(v)
            if idx < len(data['common_timestamps']):
                valid_timestamps.append(data['common_timestamps'][idx])
            else:
                valid_timestamps.append(idx)  fallback, но уже с предупреждением

    if len(valid_values) >= 10:
        tag_anomalies = detect_anomalies_isolation_forest(
            valid_values,
            valid_timestamps,  ← РЕАЛЬНЫЕ datetime объекты!
            contamination=adaptive_contamination,
            classify_types=True
        )

Теперь anomaly_timestamps содержат реальные datetime объекты:

{
    "anomaly_indices": [142, 287, 534, ...],
    "anomaly_timestamps": [
        datetime(2026, 5, 28, 2, 18),
        datetime(2026, 5, 28, 14, 35),
        datetime(2026, 5, 29, 8, 12),
        ...
    ],
    ...
}

И create_time_series_spec находит их точно:

ts_key = format_ts_label(orig_ts)  "2026-05-28 02:18"
if ts_key in ts_to_index:  ✓ точное совпадение!
    ds_idx = ts_to_index[ts_key]
    type_data[ds_idx] = val

Результат: аномалии точно на своих местах.

Урок

При работе с временными рядами используйте timestamps, а не индексы.

Индексы — это просто числа. Они теряют контекст времени. Timestamps — это семантическая информация. Они несут смысл.

Когда передаёте данные между функциями, всегда спрашивайте себя: “Эта функция ожидает индекс или timestamp?”

Если ожидает timestamp — передавайте timestamp. Даже если внутри алгоритму всё равно (Isolation Forest, например, индексы тоже переварит). Потому что где-то дальше по цепочке может быть функция, которой timestamp критически важен.


Часть 7. Другие грабли (коротко)

Грабли #3: Дублирующиеся функции

Симптом: multi-tag анализ не работал, хотя single-tag работал идеально.

Корень: в chart_specs.py было ДВЕ функции с одинаковым именем:

def create_multitag_time_series_spec(...):  ПЕРВАЯ (правильная)
    """Вызывает create_time_series_spec для каждого тега."""
    for tag_name, tag_data in tags_data.items():
        tag_spec = create_time_series_spec(...)
        all_datasets.extend(tag_spec['data']['datasets'])
... какой-то код ...
def create_multitag_time_series_spec(...):  ВТОРАЯ (неправильная)
    """Использует min-max downsampling с рассинхроном."""
    ...

Python использовал вторую (она переопределяет первую). Отсюда все баги.

Решение: git restore + аккуратный фикс. И проверка на дубликаты.

Урок: всегда проверяйте что в файле нет дублирующихся определений. Линтеры типа pylint или flake8 ловят такое.

Грабли #4: Устаревший bind синтаксис в Svelte

Симптом: кнопки zoom/download в ChartModal не работали.

Корень: в svelte-chartjs v4 для Svelte 5 правильный bind — bind:chart, а не bind:chartInstance.

<!-- БЫЛО (неправильно) -->
<Line bind:chartInstance={chartInstance} data={chartData} />
<!-- СТАЛО (правильно) -->
<Line bind:chart={chart} data={chartData} />

Урок: читайте changelog при обновлении библиотек. Breaking changes бывают в самых неожиданных местах.

Грабли #5: state_snapshot_uncloneable в Svelte 5

Симптом: warning в консоли:

[svelte] state_snapshot_uncloneable
The following properties cannot be cloned with `$state.snapshot`:
- <value>.options.plugins.tooltip.callbacks.label

Корень: Chart.js опции содержат функции (tooltip callbacks). Svelte 5 пытается их клонировать для реактивности. Функции не клонируются.

Решение: это НЕ ошибка. Warning информационный. Всё работает. Можно подавить через {#svelte-ignore state_snapshot_uncloneable}.

Урок: Svelte 5 более строгий к реактивности. Некоторые warning’и можно игнорировать, если функциональность работает.


Часть 8. Конфигуратор: 4 вкладки настроек

Когда модуль заработал, встал вопрос: как его настраивать?

Первое решение — конфиг в settings.yaml. Но это неудобно:

  • Надо перезапускать backend после каждого изменения
  • Нет визуальной обратной связи
  • Нельзя экспериментировать

Сделали UI-конфигуратор на 4 вкладки.

Вкладка 1: Детекция аномалий

Contamination (0.01 - 0.5)

  • Доля ожидаемых аномалий в данных
  • Дефолт: 0.08 (8%)
  • Для чистых данных: 0.01-0.05
  • Для зашумлённых: 0.15-0.20

N Estimators (50 - 500)

  • Количество деревьев в Isolation Forest
  • Дефолт: 100
  • Больше = стабильнее, но медленнее
  • Меньше = быстрее, но вариативнее

Классификация типов (чекбокс)

  • Включить/выключить классификацию spike/dip/drift/noise

Вкладка 2: Окно анализа

Window Size (3 - 21)

  • Количество точек для локального контекста при классификации
  • Дефолт: 11 (±5 точек)

Spike Threshold (1.0 - 5.0 σ)

  • Минимальное отклонение для классификации как spike/dip
  • Дефолт: 2.0

Вкладка 3: Downsampling

Max Points (500 - 10000)

  • Максимальное количество точек на графике
  • Дефолт: 3000

Вкладка 4: Корреляции

Min Correlation (0.0 - 1.0)

  • Минимальный коэффициент для отображения
  • Дефолт: 0.3

Auto Select Pairs (чекбокс)

  • Автоматически строить scatter plot для самой сильной пары

Часть 9. Итоги: что получили в v3.2.5

Функциональность

  • Детекция аномалий (Isolation Forest)
  • Классификация на 4 типа (spike/dip/drift/noise)
  • Single-tag анализ
  • Multi-tag анализ с корреляциями
  • Min-max downsampling (130K → 3K точек)
  • Конфигуратор на 4 вкладки
  • ChartModal с zoom/pan/download
  • Тепловая карта корреляций
  • Scatter plot с линией регрессии
  • Полная документация (26 KB markdown)

Производительность

СценарийВремя
1 тег, 7 дней (~10K точек)2-3 сек
5 тегов, 7 дней (~50K точек)8-10 сек
1 тег, 90 дней (~130K точек)15-20 сек

UX

  • Интуитивный интерфейс
  • Цветовая кодировка аномалий
  • Интерактивные графики (zoom, pan, hover)
  • Экспорт в PNG
  • Сохранение истории анализов

Часть 10. Уроки, которые мы вынесли

Урок 1: Timezone — это важно

Всегда явно указывайте timezone. Никогда не полагайтесь на naive datetime. В распределённых системах это мина замедленного действия.

Урок 2: Timestamps > индексы

При работе с временными рядами используйте timestamps. Индексы теряют контекст. Когда передаёте данные между функциями — передавайте семантику, а не просто числа.

Урок 3: Общая временная сетка

Для multi-tag анализа всегда используйте общую сетку timestamps для downsampling. Иначе получите рассинхрон между графиками.

Урок 4: Проверяйте документацию библиотек

При обновлении версий (особенно major) всегда читайте changelog. Breaking changes бывают в самых неожиданных местах (bind:chart vs bind:chartInstance).

Урок 5: Тестируйте edge cases

Не только happy path. Тестируйте:

  • Пустые данные
  • Пропуски (None значения)
  • Дубликаты timestamps
  • Разные timezone
  • Большие объёмы данных

Урок 6: Логи — ваш лучший друг

Structured logging с structlog спасал нас много раз. Когда что-то ломается, хорошие логи позволяют локализовать проблему за минуты, а не за часы.

Урок 7: Простое решение часто лучше сложного

Когда мы пытались делать умный маппинг orig_to_ds_idx — ломалось. Когда сделали просто: “вызови create_time_series_spec для каждого тега” — заработало. KISS principle в действии.


Что дальше?

Планы на v3.3.0

  • Прогнозирование — предсказание будущих значений (Prophet, LSTM)
  • Auto-encoder — нейросетевой подход к детекции
  • Batch analysis — анализ сразу всех тегов системы
  • Alert integration — интеграция с системой алёртов реального времени

Долгосрочные цели

  • ML Pipeline — автоматическое обучение моделей на исторических данных
  • Anomaly explanation — объяснение почему точка помечена как аномалия (SHAP values, LIME)
  • Root cause analysis — автоматическое определение корневой причины
  • Multi-tenant — поддержка нескольких пользователей с разными данными

Заключение

Модуль Deep Data Analysis — это не просто “ещё одна фича” в SCADA.AI. Это смена парадигмы в работе с данными.

Было: реактивный подход. Ждём аварию. Потом анализируем.

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

Мы прошли путь от “а давайте попробуем Isolation Forest” до production-ready модуля за 5 итераций. Собрали все возможные грабли. Потратили кучу времени на баги, которые казались мистическими (11 часов сдвига! случайные точки!).

Но в итоге получили систему, которая реально помогает инженерам. Которая находит то, что не находят обычные алёрты.

И это только начало.


P.S. Если вы работаете с SCADA-системами и у вас есть гигабайты исторических данных, которые никто не анализирует — возможно, вам тоже нужен свой DDA.

Начните с малого:

  1. Один тег
  2. Один алгоритм (Isolation Forest — отличный старт)
  3. Одна визуализация

А потом итеративно развивайте. Добавляйте классификацию. Multi-tag. Корреляции. Downsampling.

Главное — не пытайтесь сделать всё сразу. И собирайте грабли — они учат лучше любых книг.

Удачи в анализе!