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

Как мы превратили SCADA.AI из набора данных в полноценный BI-инструмент с интерактивной визуализацией

AIPythonYandexGPTArchitectureLLMSCADASvelte

как за одну сессию разработки мы прошли путь от простого 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 линии на каждом графике:

  1. Данные — реальные измерения
  2. Тренд — линейная регрессия
  3. MA-7 — 7-дневная скользящая средняя
  4. Прогноз — экстраполяция на 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() чтобы получить «снимок» состояния. Но функции нельзя клонировать — они не сериализуемы.

Попытки решения:

  1. $state.raw() — говорит Svelte «не делай это реактивным»

    • [-] Не помогло — warning всё равно появлялся
  2. Вынести 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:

  1. Тренды — интерактивные графики с 4 линиями
  2. Проблемы — топ-5 с раскрытием деталей (компоненты impact, нормы)
  3. Рекомендации — топ-5 с обоснованием расчёта
  4. Прогноз — переключатель 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 (качественный анализ). Для длинных — математическая экстраполяция (быстро, детерминированно).