181 lines
5.9 KiB
Python
181 lines
5.9 KiB
Python
"""OTE CZ DAM spot price import (15min slots, shared market table)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import logging
|
|
from datetime import date, datetime, timedelta, timezone
|
|
from typing import Any
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import httpx
|
|
|
|
from app.config import get_settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
MARKET_SOURCE = "OTE_CZ"
|
|
|
|
|
|
async def import_ote_prices(
|
|
site_id: int,
|
|
db,
|
|
target_date: date | None = None,
|
|
) -> tuple[int, str, float]:
|
|
"""
|
|
Stáhne DAM ceny OTE pro zvolený den (nebo „zítřek“ v TZ lokality), uloží 96 slotů (15 min).
|
|
|
|
Schéma DB: ``ems.market_interval_price`` má PK ``(market_source, interval_start)``;
|
|
ceny v ``buy_raw_price_czk_kwh`` / ``sell_raw_price_czk_kwh`` (pro OTE stejné).
|
|
|
|
Returns:
|
|
``(počet_slotů, datum_YMD, první_cena_kč_kwh)``. Počet 96 při úspěchu, -1 při chybě.
|
|
První cena je cena prvního 15min slotu dne; při chybě 0.0.
|
|
Datum je prázdný řetězec jen pokud site neexistuje nebo je neplatná timezone.
|
|
"""
|
|
row = await db.fetchrow(
|
|
"SELECT timezone FROM ems.site WHERE id = $1",
|
|
site_id,
|
|
)
|
|
if row is None:
|
|
logger.error("import_ote_prices: site id=%s nenalezen", site_id)
|
|
return -1, "", 0.0
|
|
|
|
tz_name: str = row["timezone"] or "Europe/Prague"
|
|
try:
|
|
site_tz = ZoneInfo(tz_name)
|
|
except Exception as e:
|
|
logger.error("import_ote_prices: neplatná timezone %r: %s", tz_name, e)
|
|
return -1, "", 0.0
|
|
|
|
if target_date is not None:
|
|
target_day = target_date
|
|
else:
|
|
now_local = datetime.now(site_tz)
|
|
target_day = (now_local + timedelta(days=1)).date()
|
|
date_str = target_day.isoformat()
|
|
|
|
cet = ZoneInfo("Europe/Prague")
|
|
now_cet = datetime.now(cet)
|
|
tomorrow_cet = (now_cet + timedelta(days=1)).date()
|
|
if target_day == tomorrow_cet:
|
|
cutoff = now_cet.replace(hour=13, minute=30, second=0, microsecond=0)
|
|
if now_cet < cutoff:
|
|
logger.warning(
|
|
"OTE prices for tomorrow may not be available yet (before 13:30 CET)"
|
|
)
|
|
|
|
settings = get_settings()
|
|
base_url = settings.ote_api_url.rstrip("/")
|
|
url = f"{base_url}?date={date_str}"
|
|
eur_czk = float(settings.eur_czk_rate)
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=30.0) as client:
|
|
resp = await client.get(url)
|
|
resp.raise_for_status()
|
|
body = resp.json()
|
|
except httpx.TimeoutException:
|
|
logger.warning("import_ote_prices: timeout při GET %s", url)
|
|
return -1, date_str, 0.0
|
|
except httpx.HTTPStatusError as e:
|
|
logger.warning(
|
|
"import_ote_prices: HTTP %s při GET %s: %s",
|
|
e.response.status_code,
|
|
url,
|
|
e.response.text[:500],
|
|
)
|
|
return -1, date_str, 0.0
|
|
except httpx.HTTPError as e:
|
|
logger.warning("import_ote_prices: HTTP chyba při GET %s: %s", url, e)
|
|
return -1, date_str, 0.0
|
|
except Exception as e:
|
|
logger.warning("import_ote_prices: neočekávaná chyba při stahování: %s", e)
|
|
return -1, date_str, 0.0
|
|
|
|
hourly_eur_mwh: dict[int, float] | None = None
|
|
try:
|
|
points: list[dict[str, Any]] = body["data"]["dataLine"][0]["point"]
|
|
hourly_eur_mwh = {}
|
|
for p in points:
|
|
x = int(p["x"])
|
|
y = float(p["y"])
|
|
hourly_eur_mwh[x] = y
|
|
except (KeyError, TypeError, ValueError, IndexError):
|
|
snippet = json.dumps(body, ensure_ascii=False)[:500]
|
|
logger.error("import_ote_prices: neočekádaná struktura OTE, začátek: %s", snippet)
|
|
return -1, date_str, 0.0
|
|
|
|
if len(hourly_eur_mwh) != 24 or set(hourly_eur_mwh.keys()) != set(range(1, 25)):
|
|
logger.error(
|
|
"import_ote_prices: očekáváno 24 bodů x=1..24, dostáno klíče %s",
|
|
sorted(hourly_eur_mwh.keys()),
|
|
)
|
|
return -1, date_str, 0.0
|
|
|
|
slots: list[tuple[datetime, datetime, float]] = []
|
|
for h in range(24):
|
|
x = h + 1
|
|
eur_mwh = hourly_eur_mwh[x]
|
|
price_czk_kwh = eur_mwh * eur_czk / 1000.0
|
|
for minute in (0, 15, 30, 45):
|
|
interval_start_local = datetime(
|
|
target_day.year,
|
|
target_day.month,
|
|
target_day.day,
|
|
h,
|
|
minute,
|
|
tzinfo=site_tz,
|
|
)
|
|
interval_start_utc = interval_start_local.astimezone(timezone.utc)
|
|
interval_end_utc = interval_start_utc + timedelta(minutes=15)
|
|
slots.append((interval_start_utc, interval_end_utc, price_czk_kwh))
|
|
|
|
for interval_start_utc, interval_end_utc, price in slots:
|
|
await db.execute(
|
|
"""
|
|
INSERT INTO ems.market_interval_price (
|
|
market_source,
|
|
interval_start,
|
|
interval_end,
|
|
buy_raw_price_czk_kwh,
|
|
sell_raw_price_czk_kwh,
|
|
currency,
|
|
imported_at
|
|
)
|
|
VALUES ($1, $2, $3, $4, $5, 'CZK', now())
|
|
ON CONFLICT (market_source, interval_start)
|
|
DO UPDATE SET
|
|
interval_end = EXCLUDED.interval_end,
|
|
buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh,
|
|
sell_raw_price_czk_kwh = EXCLUDED.sell_raw_price_czk_kwh,
|
|
imported_at = now()
|
|
""",
|
|
MARKET_SOURCE,
|
|
interval_start_utc,
|
|
interval_end_utc,
|
|
price,
|
|
price,
|
|
)
|
|
|
|
first_price = float(slots[0][2]) if slots else 0.0
|
|
return len(slots), date_str, first_price
|
|
|
|
|
|
if __name__ == "__main__":
|
|
import asyncio
|
|
import os
|
|
|
|
import asyncpg
|
|
from dotenv import load_dotenv
|
|
|
|
load_dotenv()
|
|
|
|
async def test():
|
|
conn = await asyncpg.connect(os.getenv("DATABASE_URL"))
|
|
n, d, fp = await import_ote_prices(1, conn)
|
|
print(f"Uloženo {n} slotů pro {d}, první cena {fp}")
|
|
await conn.close()
|
|
|
|
asyncio.run(test())
|