Deep Data Analysis: Как мы ловили аномалии в SCADA и собирали все грабли подряд
Путь от а давайте попробуем 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 спецификаций Почему именно так?
Separation of concerns — каждый модуль делает одну вещь.
anomalies.pyничего не знает про графики.chart_specs.pyничего не знает про БД.Testability — можно тестировать анализаторы без БД, визуализаторы без данных.
Extensibility — добавили новый алгоритм детекции? Просто новый файл в
analyzers/.Performance — можно оптимизировать узкие места независимо. Downsampling тормозит? Оптимизируем
chart_specs.py, не трогая остальное.
Поток данных:
Пользователь выбирает теги → API → data_fetcher → выравнивание на 5-мин grid
→ Isolation Forest → классификация → корреляции → Chart.js specs → UI Каждый этап изолирован. Если что-то сломалось — локализуем быстро.
Часть 3. Математика под капотом
3.1 Isolation Forest: изоляция вместо кластеризации
Большинство алгоритмов детекции аномалий работают так:
- Построй модель “нормального” поведения
- Всё что далеко от модели — аномалия
Isolation Forest работает наоборот. Гениальная в своей простоте идея: аномалии легче изолировать.
Алгоритм строит набор случайных деревьев решений. Для каждого дерева:
- Берём подвыборку (обычно 256 точек)
- Рекурсивно выбираем случайный признак и случайное значение разделения
- Разделяем данные пока не изолируем каждую точку
Аномальные точки изолируются быстрее — они далеко от основной массы, поэтому их легче “отрезать” случайным разбиением.
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,600 | 6x | 50 ms |
| 50,000 | ~1,600 | 31x | 60 ms |
| 130,000 | ~1,600 | 81x | 70 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 Где применяем:
- Labels для оси X:
labels = [format_ts_label(ts) for ts in ds_timestamps] - Маппинг timestamp → index:
ts_to_index = {}
for idx, ts in enumerate(ds_timestamps):
ts_key = format_ts_label(ts)
ts_to_index[ts_key] = idx - Поиск аномалий по 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:
- Храните в UTC (в БД)
- Передавайте с timezone info (ISO 8601 с
Zили+05:00) - Конвертируйте в локальное время только на клиенте
Мы нарушили все три. И получили 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.
Начните с малого:
- Один тег
- Один алгоритм (Isolation Forest — отличный старт)
- Одна визуализация
А потом итеративно развивайте. Добавляйте классификацию. Multi-tag. Корреляции. Downsampling.
Главное — не пытайтесь сделать всё сразу. И собирайте грабли — они учат лучше любых книг.
Удачи в анализе!