Как мы превратили SCADA.AI из набора данных в полноценный BI-инструмент с интерактивной визуализацией
как за одну сессию разработки мы прошли путь от простого health-модуля до полноценного движка аналитики с Chart.js графиками, прогнозами на 365 дней и интерактивным zoom/pan. По дороге наступили на все грабли Svelte 5 reactivity, Chart.js API и линейной регрессии — и нашли элегантные решения для каждой проблемы.
Откуда мы стартовали
В начале сессии у нас был SCADA.AI v3.1.0 — система с индексом здоровья здания, модулями энергоучёта и AI-анализом логов.
Что было:
- Индекс здоровья (health_score) с детализацией расчёта
- Учёт электроэнергии, воды, тепла
- Анализ системных логов через YandexGPT
Чего не хватало:
- [-] Трендов и прогнозов
- [-] Визуализации данных
- [-] Корреляций между параметрами
- [-] Интерактивных графиков
Оператор видел цифры («температура 24.5°C»), но не понимал куда это движется и что с этим делать.
Цель: превратить SCADA.AI в BI-инструмент, где оператор может увидеть тренд, получить прогноз, масштабировать график колёсиком мыши и скачать PNG для отчёта.
Архитектура: что мы построили
Backend: модуль analytics
modules/analytics/
├── collectors/
│ └── history.py # SQL-запросы к TimescaleDB
├── analyzers/
│ ├── trends.py # Линейная регрессия (slope, R²)
│ ├── correlations.py # Pearson + временной лаг
│ └── aggregators.py # Impact score для проблем
├── llm/
│ └── analyzer.py # YandexGPT + deterministic fallback
└── norms.py # Нормативные диапазоны Ключевые метрики:
- Тренды:
slope_per_day(изменение за день),r_squared(качество модели) - Корреляции: Pearson (r ∈ [-1, 1]) с лагом ±24 часа
- Impact:
deviation + trend + anomalies + outliers - Прогнозы: экстраполяция на 7/30/90/365 дней
Frontend: Chart.js + svelte-chartjs
<script lang="ts">
import { Line } from 'svelte-chartjs'
import { Chart as ChartJS } from 'chart.js'
import zoomPlugin from 'chartjs-plugin-zoom'
ChartJS.register(/* scales, elements */ zoomPlugin)
</script>
<Line data={chartData} options={chartOptions} /> 4 линии на каждом графике:
- Данные — реальные измерения
- Тренд — линейная регрессия
- MA-7 — 7-дневная скользящая средняя
- Прогноз — экстраполяция на 30% вперёд
Грабли #1: Svelte 5 reactivity vs Chart.js callbacks
Проблема
Chart.js позволяет кастомизировать форматирование через callbacks:
const chartOptions = {
scales: {
y: {
ticks: {
callback: (value) => `${value} °C`
}
}
},
plugins: {
tooltip: {
callbacks: {
label: (context) => `${context.parsed.y.toFixed(2)} °C`
}
}
}
} Но в Svelte 5 runes mode это вызывало state_snapshot_uncloneable warning:
[svelte] state_snapshot_uncloneable
The following properties cannot be cloned with `$state.snapshot`:
- <value>.options.scales.y.ticks.callback
- <value>.plugins.tooltip.callbacks.label Почему так?
Svelte 5 пытается сделать chartOptions реактивным через $state. Для этого он вызывает $state.snapshot() чтобы получить «снимок» состояния. Но функции нельзя клонировать — они не сериализуемы.
Попытки решения:
$state.raw()— говорит Svelte «не делай это реактивным»- [-] Не помогло — warning всё равно появлялся
Вынести callbacks наружу
- [-] Не помогло — Chart.js всё равно пытался их клонировать
Решение: Убрать все callbacks
Мы просто удалили все callbacks из chartOptions:
const chartOptions = {
scales: {
y: {
suggestedMin: yRange?.min,
suggestedMax: yRange?.max,
ticks: { font: { size: 9 } }
// callback убран
}
}
} Почему это работает:
- Chart.js сам отформатирует числа дефолтным способом
- Для большинства случаев дефолт достаточно хорош
- Если нужен кастомный формат — используем
unitв props
Вывод: Иногда лучшее решение — отказаться от кастомизации и использовать дефолты.
Грабли #2: Как получить доступ к Chart instance?
Проблема
Нам нужны были кнопки управления графиком:
- Zoom In / Zoom Out
- Reset Zoom
- Download PNG
Для этого нужен доступ к Chart.js instance:
function zoomIn() {
chartInstance.zoom(1.2)
}
function downloadPNG() {
const base64 = chartInstance.toBase64Image('image/png', 1.0)
// сохраняем файл
} Первая попытка: bind:this
<script>
let chartComponent: any = null
onMount(() => {
chartInstance = chartComponent.chart // undefined!
})
</script>
<Line bind:this={chartComponent} ... /> Результат: chartComponent = {$destroy, $on, $set} — объект Svelte-компонента, но без Chart instance.
Вторая попытка: use:ref action
<Line use:ref={setChartRef} ... /> Ошибка компиляции:
[plugin:vite-plugin-svelte:compile]
This type of directive is not valid on components В Svelte 5 actions (use:) работают только на DOM-элементах, не на компонентах.
Решение: Chart.getChart(canvas)
Chart.js хранит все instances в глобальном реестре. Метод Chart.getChart(canvas) возвращает instance по canvas элементу:
<script>
const chartId = `chart-${Math.random().toString(36).slice(2, 9)}`
let chartInstance: ChartJS | null = null
onMount(() => {
setTimeout(() => {
const container = document.getElementById(chartId)
const canvas = container.querySelector('canvas')
chartInstance = ChartJS.getChart(canvas) || null
}, 150)
})
</script>
<div id={chartId} class="h-[200px]">
<Line data={chartData} options={chartOptions} />
</div> Почему это работает:
- Chart.js регистрирует каждый instance в глобальном реестре при создании
Chart.getChart(canvas)— стандартный метод Chart.js (не зависит от обёртки)- Работает с любой обёрткой: svelte-chartjs, react-chartjs-2, vue-chartjs
setTimeout(150ms): Даём время svelte-chartjs полностью отрендерить canvas.
Грабли #3: Масштабирование оси Y (−400 до +300)
Проблема
При экстраполяции тренда на 30% вперёд прогноз выходил за разумные пределы:
Температура: 24.5°C → через 30 дней: 87.5°C (CRITICAL)
График масштабировался: ось Y от -400 до +300 Почему так?
Линейная экстраполяция не знает физических границ параметров. Chart.js автоматически масштабирует ось Y чтобы показать все данные (включая нереалистичные прогнозы).
Первая попытка: жёсткие min/max
scales: {
y: { min: 0, max: 50 }
} Проблема: Chart.js обрезает данные за пределами min/max. Если реальное значение 26.4°C (выше 25), график покажет 25°C — неправильные данные.
Решение: suggestedMin/suggestedMax + clip()
1. Мягкие пределы через suggestedMin/suggestedMax:
scales: {
y: {
suggestedMin: yRange?.min,
suggestedMax: yRange?.max
}
} Разница:
min/max— жёсткие ограничения (данные обрезаются)suggestedMin/suggestedMax— мягкие рекомендации (Chart.js масштабирует вокруг, но показывает все данные)
2. Clip функция для прогноза:
function clip(value: number): number {
if (!yRange) return value
return Math.max(yRange.min, Math.min(yRange.max, value))
}
// При генерации прогноза:
const rawValue = slope * forecastDays + intercept
forecastValues.push(clip(rawValue)) 3. Фиксированные пределы по физическим границам:
const yRanges = {
temperature: { min: 0, max: 50 }, // °C
humidity: { min: 0, max: 100 }, // %
co2: { min: 300, max: 2000 }, // ppm
pressure: { min: 700, max: 800 }, // мм рт. ст.
voc: { min: 0, max: 1 }, // мг/м³
} Результат:
- График масштабируется вокруг разумных пределов
- Прогноз обрезается физическими границами
- Реальные данные (даже выбросы) отображаются корректно
Грабли #4: Неправильная математика линейной регрессии
Проблема
Изначально мы считали тренд так:
const n = values.length
const slope = trend.slope_per_day * (n / 30) // НЕПРАВИЛЬНО
const trendValues = values.map((_, i) => slope * i + intercept) Почему это неправильно:
- Мы умножали
slope_per_dayна количество точек, а не на дни - Если точек 164 за 7 дней — slope был в 23 раза больше реального
- Все графики показывали «линейный рост вверх»
Решение: Правильная формула на основе дней
const firstDate = new Date(data[0].timestamp)
const daysArray = data.map(d => {
const date = new Date(d.timestamp)
return (date.getTime() - firstDate.getTime()) / 86400000 // дни
})
const avgDays = daysArray.reduce((a, b) => a + b, 0) / n
const avgValue = values.reduce((a, b) => a + b, 0) / n
const intercept = avgValue - trend.slope_per_day * avgDays
// Правильная формула: y = slope_per_day * days + intercept
const trendValues = daysArray.map(days =>
trend.slope_per_day * days + intercept
) Почему это правильно:
slope_per_dayиз backend — это изменение значения за день- Мы умножаем на реальное количество дней от начала
- Для прогноза:
forecastDays = lastDays + (i / 24)— добавляем часы в днях
Вывод: Всегда проверяйте единицы измерения в формулах. slope_per_day должен умножаться на дни, а не на индексы.
Грабли #5: Цветовые конфликты (MA-7 сливался с данными)
Проблема
Изначально MA-7 (скользящая средняя) был фиолетовым #8b5cf6:
datasets.push({
label: 'MA-7 (скользящая средняя)',
data: maValues,
borderColor: '#8b5cf6'
}) Но VOC имеет цвет #f59e0b (оранжевый), а данные humidity — #3b82f6 (синий). Фиолетовый MA-7 сливался с обоими.
Решение: Нейтральный серый
borderColor: '#9ca3af' // светло-серый (Tailwind gray-400) Почему это работает:
- Хорошо читается на светлой теме (белый фон)
- Хорошо читается на тёмной теме (тёмно-серый фон)
- Не конфликтует ни с одним цветом данных
- Визуально отличается от тренда (тренд пунктирный, MA-7 сплошная)
Вывод: Для вспомогательных линий используйте нейтральные цвета (серый, чёрный), а для данных — яркие насыщенные.
Грабли #6: Downsampling больших данных
Проблема
Для периода 90 дней с hourly агрегацией получалось ~2160 точек. Chart.js начинал тормозить, а график превращался в кашу.
Первая попытка: data_points[:200] — берём первые 200 точек.
Проблема: Брались первые точки (март-апрель), а не последние (май-июнь). Графики показывали старые данные.
Решение: Адаптивный downsampling
MAX_POINTS = 500
if len(data_points) <= MAX_POINTS:
raw_data = data_points
else:
# Берём каждую N-ю точку
step = len(data_points) / MAX_POINTS
raw_data = [data_points[int(i * step)] for i in range(MAX_POINTS)]
# ВСЕГДА добавляем последнюю точку (актуальность)
raw_data.append(data_points[-1]) Почему это работает:
- Равномерное распределение точек по всему периоду
- Всегда включаем последнюю точку — оператор видит актуальные данные
- 500 точек — оптимальный баланс между детализацией и производительностью
Грабли #7: Периоды прогноза (7/30/90/365 дней)
Проблема
Изначально backend возвращал прогноз только на 7 и 30 дней (через LLM). Для 90 и 365 дней прогноз отсутствовал.
Решение: Экстраполяция + LLM
function getForecastText(days: number): string {
if (!data?.forecast) return 'Нет данных'
if (days <= 7) return data.forecast['7_days'] || 'Нет данных'
if (days <= 30) return data.forecast['30_days'] || 'Нет данных'
// Для 90 и 365 дней экстраполируем на основе трендов
const parts: string[] = []
for (const param of ['temperature', 'humidity', 'co2', 'pressure', 'voc']) {
const trend = data.trends?.[param]
if (trend?.slope_per_day && trend.r_squared > 0.2) {
const change = trend.slope_per_day * days
const projected = (trend.avg || 0) + change
parts.push(`${param}: ${projected.toFixed(1)} (${change > 0 ? '+' : ''}${change.toFixed(1)})`)
}
}
return parts.length ? parts.join(', ') : 'Недостаточно данных для прогноза'
} Почему это работает:
- Для коротких периодов (7/30) — качественный прогноз от LLM
- Для длинных периодов (90/365) — математическая экстраполяция
- Учитываем только тренды с R² > 0.2 (отсеиваем шум)
- Предупреждаем пользователя: «Точность снижается с увеличением горизонта»
Что получилось в итоге
UI аналитики
4 вкладки AnalyticsPanel:
- Тренды — интерактивные графики с 4 линиями
- Проблемы — топ-5 с раскрытием деталей (компоненты impact, нормы)
- Рекомендации — топ-5 с обоснованием расчёта
- Прогноз — переключатель 7/30/90/365 дней
Интерактивность
- Zoom: колёсико мыши, кнопки +/-, pinch (touch)
- Pan: перетаскивание мышью
- Reset: сброс масштаба
- Export: PNG через
chart.toBase64Image()
Команда в чате
Пользователь: "покажи аналитику"
→ Открывается AnalyticsPanel со всеми 4 вкладками Триггеры: «аналитик», «тренд», «прогноз», «рекомендац», «корреляц», «analytics»
Главные уроки
1. Иногда дефолты лучше кастомизации
Не пытайтесь переопределить всё. Chart.js callbacks создают проблемы с Svelte reactivity — дефолтное форматирование работает нормально в 90% случаев.
2. Используйте стандартные API библиотек
Chart.getChart(canvas) — стандартный метод Chart.js. Он работает с любой обёрткой и не зависит от внутренней реализации svelte-chartjs.
3. Проверяйте единицы измерения в формулах
slope_per_day должен умножаться на дни, а не на индексы. Всегда спрашивайте: «В каких единицах эта величина?»
4. Мягкие ограничения лучше жёстких
suggestedMin/suggestedMax позволяют Chart.js показывать все данные, но масштабировать вокруг разумных пределов. min/max обрезают данные.
5. Downsampling — это искусство
Берите не первые точки, а равномерно распределённые. И ВСЕГДА включайте последнюю — оператор должен видеть актуальные данные.
6. Нейтральные цвета для вспомогательных линий
Серый для MA-7 и тренда, яркие цвета для данных. Так оператор сразу видит что главное, а что вспомогательное.
7. Комбинируйте LLM и математику
Для коротких прогнозов — LLM (качественный анализ). Для длинных — математическая экстраполяция (быстро, детерминированно).