x
This commit is contained in:
180
backend/services/price_importer.py
Normal file
180
backend/services/price_importer.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""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())
|
||||
Reference in New Issue
Block a user