Перейти к содержимому

Сейчас: внедряю системы ИИ-мониторинга в Steady Control

Daniil Dushin

Energy Saver

Запускает бойлер, зарядку электромобиля и прачечную отеля в самые дешёвые часы рынка электроэнергии на сутки вперёд и сообщает в Telegram, сколько это экономит.

Роль
Личный проект — проектирование, код, тесты
Год
2026
Категория
Инфраструктура · Боты
Стек
Python 3.12, asyncio, FastAPI, Jinja2
Обложка Energy Saver — почасовые цены на электроэнергию, самые дешёвые часы выделены

Проблема

По статье 11 Директивы (ЕС) 2019/944 страны ЕС обязаны дать каждому потребителю со смарт-счётчиком возможность заключить договор с динамической ценой на электроэнергию. По такому договору стоимость самой энергии в счёте следует за рынком на сутки вперёд, а с 1 октября 2025 года на этом рынке своя цена у каждых 15 минут. В обычный день октября 2026 года цена в Германии менялась от 7 до 29 центов за кВт·ч.

У отеля или кафе есть нагрузки, которым всё равно, когда работать: бойлер с большим баком, сушильная машина, электромобиль, который стоит всю ночь, насос бассейна. Если перенести их на дешёвые часы, получится экономия без нового оборудования, но никто на ресепшене не будет каждый день после обеда читать график цен.

Решение

  • Реальные цены берутся из Energy-Charts API от Fraunhofer ISE (без токена) и кэшируются в SQLite; ENTSO-E Transparency Platform — необязательный второй источник. Поддерживаются и почасовые, и 15-минутные цены.
  • Каждая нагрузка описана в devices.yaml: мощность, время работы в сутки, разрешённое окно (22:00-07:00), крайний срок окончания, один блок или работа частями с минимальной длиной блока.
  • Планировщик выбирает самые дешёвые слоты: скользящее окно для одного блока, k самых дешёвых слотов для работы частями и небольшое динамическое программирование, когда у каждого блока есть минимальная длина. Ограничение мощности объекта не даёт нагрузкам слишком сильно накладываться.
  • Каждый план сравнивается с наивным расписанием, где каждое устройство включается в момент открытия окна. Деньги считаются в Decimal, поэтому экономия в евро точная.
  • Каждый день после обеда сообщение в Telegram даёт план на завтра со временем запусков и ожидаемой экономией. Веб-страница показывает график цен и план по каждому устройству.
  • Умными розетками Shelly можно управлять через их локальный HTTP API. По умолчанию — пробный режим: запрос пишется в лог, а не отправляется.

Скриншоты

План на четверг 22 октября 2026 года — 8,39 евро по плану против 15,86 евро, график цен по 15 минут с выделенными дешёвыми слотами ночью и днём и по строке на каждое устройство
Страница плана в демо-режиме на реальных немецких ценах на сутки вперёд за 22 октября 2026 года.
План на воскресенье 25 октября 2026 года, день из 25 часов, — с пометкой о переводе часов, почти нулевыми ценами с ночи до второй половины дня и запусками в повторяющемся часе с пометками CEST и CET
День перевода часов. Запуски в повторяющемся часе подписаны CEST и CET.

Технологии

  • Python 3.12
  • asyncio
  • FastAPI
  • Jinja2
  • SVG
  • aiogram 3
  • SQLite
  • httpx
  • pytest

Все цены приводятся к одной сетке по 15 минут: почасовая цена становится четырьмя одинаковыми слотами, так что планировщику не нужно знать, какие данные пришли. График — встроенный SVG, который рисуется на сервере, по оси x идёт реальное время, поэтому день из 25 часов шире. Демо работает без интернета на 36 днях реальных цен, которые я один раз скачал и положил в проект вместе с источником и лицензией (CC BY 4.0, Bundesnetzagentur | SMARD.de).

Фрагмент кода

Окна заданы в местном времени, цены — в UTC. Окно, которое переходит через полночь, относится к тому дню, в котором заканчивается, поэтому ночной запуск бойлера планируется вместе с ценами на завтра. Каждое время по часам переводится в UTC в свой собственный день, и одно и то же окно 22:00-07:00 длится 8 часов, когда часы переводят вперёд, и 10 часов, когда назад:

python
def _local(day: date, minute: int, tz: ZoneInfo) -> datetime:
    day = day + timedelta(days=minute // 1440)
    minute %= 1440
    local = datetime.combine(day, time(minute // 60, minute % 60), tzinfo=tz)
    return local.astimezone(UTC)


def window_bounds(device: Device, day: date, tz: ZoneInfo) -> tuple[datetime, datetime]:
    spec = device.spec  # start_day_offset is -1 when the window crosses midnight
    start_day = day + timedelta(days=spec.start_day_offset)
    return _local(start_day, spec.start_minute, tz), _local(start_day, spec.end_minute, tz)

Баг, который я нашёл и исправил

«-0.00 €» в день с отрицательными ценами

Что сломалось

Первый запуск демо напечатал план на завтра с -0.00 € instead of 0.29 € для бойлера и Expected cost -0.00 € для всего дня. На странице минимальная цена была -0.0.

Причина

25 октября 2026 года было ветреное воскресенье: цена в Германии с 02:00 до 16:00 стояла на 0,00 или −0,01 EUR/MWh. Планировщик правильно выбрал слоты по −0,01, то есть бойлеру ещё заплатили бы 0,00003 €. Decimal.quantize сохраняет знак у числа, которое округлилось до нуля, и форматирование float делает то же самое.

Исправление

Одна функция округления для денег и одна для центов за кВт·ч: если значение округлилось до нуля, знак убирается, а настоящая отрицательная сумма по-прежнему печатается с минусом. Тест использует реальную стоимость одного из этих слотов.

До
def money(value: Decimal | None) -> str:
    if value is None:
        return "—"
    return f"{value.quantize(CENT, ROUND_HALF_UP):.2f} €"
После
def cents(value: Decimal) -> Decimal:
    rounded = value.quantize(CENT, ROUND_HALF_UP)
    return abs(rounded) if rounded == 0 else rounded


def money(value: Decimal | None) -> str:
    return "—" if value is None else f"{cents(value):.2f} €"

Результат

  • 108 автоматических тестов, в том числе на реальных ценах за оба дня перевода часов (почасовых и 15-минутных), на переходе с почасовых цен на 15-минутные и с проверкой динамического программирования полным перебором.
  • Бэктест на 30 днях реальных немецких цен (с 26 сентября по 25 октября 2026 года) с пятью примерными устройствами: 4,88 € экономии в день, 29,6 % от стоимости энергии по спотовой цене, 146,47 € за месяц.
  • Демо работает без интернета и без ключей; бэктест занимает несколько секунд.