diff --git a/backend/app/main.py b/backend/app/main.py index 45ed8e0..a2b5f0a 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -46,7 +46,7 @@ from services.notification_service import ( notify_operating_mode_changed, run_fn_set_mode_with_discord, ) -from services.price_importer import import_ote_prices +from services.price_importer import import_ote_prices, ote_prague_day_slots_look_complete from services.telemetry_collector import run_telemetry_loop_wrapper from pydantic import BaseModel, Field @@ -317,7 +317,7 @@ async def lifespan(app: FastAPI): for day in (today, tomorrow): slots = await _count_ote_slots_for_day(conn, day) - if slots >= 96: + if ote_prague_day_slots_look_complete(slots): continue n, imported_day, _, err = await import_ote_prices( conn, site_id=None, target_date=day diff --git a/backend/scripts/backfill_ote_prices.py b/backend/scripts/backfill_ote_prices.py index b07e4ed..6c3a041 100644 --- a/backend/scripts/backfill_ote_prices.py +++ b/backend/scripts/backfill_ote_prices.py @@ -17,7 +17,7 @@ Lokálně (venv s backend/requirements.txt): Volby: --days 730 posledních N kalendářních dní (Europe/Prague), výchozí 730 ≈ 2 roky --from-date / --to-date pevný rozsah YYYY-MM-DD (má přednost před --days u konce rozsahu) - --force stáhnout znovu i dny, kde už je 96 slotů + --force stáhnout znovu i dny s plným počtem slotů OTE (92/96/100) --dry-run jen vypsat chybějící dny, bez HTTP --delay SEC pauza mezi dny (výchozí 0.35) --refresh-predictions po skončení zavolat fn_predict_negative_price_windows pro aktivní site @@ -57,9 +57,10 @@ except ModuleNotFoundError as e: from app.config import get_settings # noqa: E402 from services.price_importer import ( # noqa: E402 - OTE_EXPECTED_SLOTS, + OTE_FULL_DAY_SLOT_COUNTS, backfill_ote_prices, count_ote_slots_prague_day, + ote_prague_day_slots_look_complete, ) PRAGUE = ZoneInfo("Europe/Prague") @@ -82,7 +83,7 @@ async def _dry_run_missing( if d > today_prague: break n = await count_ote_slots_prague_day(conn, d) - if n < OTE_EXPECTED_SLOTS: + if not ote_prague_day_slots_look_complete(n): out.append(d) d += timedelta(days=1) return out @@ -136,9 +137,9 @@ async def main_async(args: argparse.Namespace) -> int: if args.dry_run: missing = await _dry_run_missing(conn, start, end, today_prague) logging.info( - "Dry-run: %s chybějících nebo neúplných dní (< %s slotů)", + "Dry-run: %s chybějících nebo neúplných dní (plný den = jedna z %s)", len(missing), - OTE_EXPECTED_SLOTS, + sorted(OTE_FULL_DAY_SLOT_COUNTS), ) for md in missing[:50]: n = await count_ote_slots_prague_day(conn, md) @@ -198,7 +199,7 @@ def main() -> None: parser.add_argument( "--force", action="store_true", - help="Stáhnout znovu i dny s plnými %s sloty" % OTE_EXPECTED_SLOTS, + help="Stáhnout znovu i dny s plným počtem slotů OTE (92/96/100)", ) parser.add_argument("--dry-run", action="store_true", help="Jen vypsat chybějící dny") parser.add_argument( diff --git a/backend/services/price_importer.py b/backend/services/price_importer.py index 9117dae..90e351a 100644 --- a/backend/services/price_importer.py +++ b/backend/services/price_importer.py @@ -14,7 +14,16 @@ from app.config import get_settings logger = logging.getLogger(__name__) -OTE_EXPECTED_SLOTS = 96 +# Běžný kalendářní den na DAM = 96 čtvrthodin; 92 při přechodu na letní čas, 100 na zimní. +OTE_TYPICAL_SLOTS = 96 +OTE_FULL_DAY_SLOT_COUNTS: frozenset[int] = frozenset({92, 96, 100}) +# Zpětná kompatibilita ve starších importech +OTE_EXPECTED_SLOTS = OTE_TYPICAL_SLOTS + + +def ote_prague_day_slots_look_complete(slot_count: int) -> bool: + """True, pokud počet řádků odpovídá celému obchodnímu dni OTE (včetně DST).""" + return slot_count in OTE_FULL_DAY_SLOT_COUNTS OTE_URL = ( "https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/" @@ -109,7 +118,7 @@ async def _apply_ote_json_to_db(conn, payload: dict) -> int: async def count_ote_slots_prague_day(conn, target_day: date) -> int: - """Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (96 očekáváno).""" + """Počet řádků OTE_CZ pro kalendářní den v Europe/Prague (plný den 92/96/100).""" return int( await conn.fetchval( """ @@ -150,12 +159,12 @@ async def import_ote_prices_for_day( target_day, ) n_imported = await count_ote_slots_prague_day(conn, target_day) - if n_imported < OTE_EXPECTED_SLOTS: + if not ote_prague_day_slots_look_complete(n_imported): logger.warning( - "OTE: jen %s/%s slotů pro %s (Europe/Prague)", + "OTE: %s slotů pro %s (plný den = jedna z %s; jinak neúplná data)", n_imported, - OTE_EXPECTED_SLOTS, day_str, + sorted(OTE_FULL_DAY_SLOT_COUNTS), ) logger.info( "OTE import OK: %s slotů (upsert) pro %s, první cena %.4f Kč/kWh", @@ -195,7 +204,7 @@ async def backfill_ote_prices( """ Projde rozsah [start_date, end_date] (kalendář Prague) a doplní chybějící dny z OTE. - only_missing: přeskočí dny, kde už je count >= OTE_EXPECTED_SLOTS. + only_missing: přeskočí dny, kde už je „plný“ počet slotů (92/96/100 dle OTE). pause_between_days_s: krátká pauza mezi HTTP požadavky (ohleduplnost k OTE). """ stats = OteBackfillStats(start_date=start_date, end_date=end_date) @@ -208,7 +217,7 @@ async def backfill_ote_prices( d += timedelta(days=1) continue slots = await count_ote_slots_prague_day(conn, d) - if only_missing and slots >= OTE_EXPECTED_SLOTS: + if only_missing and ote_prague_day_slots_look_complete(slots): stats.days_skipped_complete += 1 d += timedelta(days=1) continue @@ -301,7 +310,7 @@ async def import_ote_prices( """, target_day, ) - incomplete = (n_imported or 0) < OTE_EXPECTED_SLOTS + incomplete = not ote_prague_day_slots_look_complete(n_imported or 0) if incomplete: now_p = datetime.now(ZoneInfo("Europe/Prague")) tomorrow_p = (now_p + timedelta(days=1)).date() @@ -311,10 +320,10 @@ async def import_ote_prices( and (now_p.hour, now_p.minute) < (14, 30) ): logger.warning( - "OTE: jen %s/%s slotů pro %s", + "OTE: %s slotů pro %s (plný den = jedna z %s)", n_imported, - OTE_EXPECTED_SLOTS, date_str, + sorted(OTE_FULL_DAY_SLOT_COUNTS), ) logger.info( "OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh", diff --git a/db/routines/R__fn_ote_import.sql b/db/routines/R__fn_ote_import.sql index d471973..245ee9d 100644 --- a/db/routines/R__fn_ote_import.sql +++ b/db/routines/R__fn_ote_import.sql @@ -19,6 +19,7 @@ AS $$ DECLARE v_date_text text; v_market_date date; + v_anchor timestamptz; v_dl jsonb; v_npts int; BEGIN @@ -39,6 +40,10 @@ BEGIN in_payload #>> '{graph,title}'; END IF; v_market_date := to_date(v_date_text, 'DD.MM.YYYY'); + -- Začátek obchodního dne v Praze jako absolutní okamžik; pak +15 min v UTC. + -- Starý vzor (timestamp + interval) AT TIME ZONE při přechodu na letní čas + -- slučoval různé sloty → duplicitní interval_start a chyba ON CONFLICT. + v_anchor := v_market_date::timestamp AT TIME ZONE 'Europe/Prague'; -- OTE mění strukturu: novější data = 15min série (tooltip flash_chart_01_y_15m_*), -- starší / jiná odpověď = jen 24 hodinových bodů s tooltip "Cena". @@ -60,7 +65,15 @@ BEGIN SELECT t.dl INTO v_dl FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl) WHERE dl ->> 'tooltip' = 'Cena' - AND jsonb_array_length(dl -> 'point') BETWEEN 20 AND 28 + AND jsonb_array_length(dl -> 'point') >= 90 + LIMIT 1; + END IF; + + IF v_dl IS NULL THEN + SELECT t.dl INTO v_dl + FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl) + WHERE dl ->> 'tooltip' = 'Cena' + AND jsonb_array_length(dl -> 'point') BETWEEN 2 AND 28 LIMIT 1; END IF; @@ -68,7 +81,7 @@ BEGIN SELECT t.dl INTO v_dl FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl) WHERE dl ->> 'tooltip' = 'flash_chart_01_y_15m_price_tooltip' - AND jsonb_array_length(dl -> 'point') BETWEEN 20 AND 28 + AND jsonb_array_length(dl -> 'point') BETWEEN 2 AND 28 LIMIT 1; END IF; @@ -83,17 +96,13 @@ BEGIN v_npts := jsonb_array_length(v_dl -> 'point'); IF v_npts >= 90 THEN - -- x = 1..N = 15min bloky (typicky 96; 92/100 při přechodu letní/zimní čas) + -- x = 1..N = 15min bloky (96 běžně; 92 při zkráceném dni při přechodu na letní čas) RETURN QUERY SELECT s.interval_start, s.interval_end, s.raw_price_czk_kwh FROM ( SELECT - ((v_market_date::timestamp - + ((block_no - 1) * INTERVAL '15 minutes')) - AT TIME ZONE 'Europe/Prague') AS interval_start, - ((v_market_date::timestamp - + (block_no * INTERVAL '15 minutes')) - AT TIME ZONE 'Europe/Prague') AS interval_end, + (v_anchor + ((block_no - 1) * INTERVAL '15 minutes')) AS interval_start, + (v_anchor + (block_no * INTERVAL '15 minutes')) AS interval_end, ROUND( (price_eur_mwh * in_czk_per_eur / 1000.0)::numeric, 6 )::numeric(10,6) AS raw_price_czk_kwh, @@ -106,19 +115,17 @@ BEGIN ) pts ) AS s ORDER BY s.block_no; - ELSE - -- x = 1..24 = hodiny dne; každou hodinovou cenu rozvineme na 4× 15min slot + ELSIF v_npts BETWEEN 2 AND 28 THEN + -- x = 1..H = hodiny (ne nutně celý den); každou hodinovou cenu → 4× 15min slot RETURN QUERY SELECT s.interval_start, s.interval_end, s.raw_price_czk_kwh FROM ( SELECT - ((v_market_date::timestamp - + (((hour_no - 1) * 4 + qix) * INTERVAL '15 minutes')) - AT TIME ZONE 'Europe/Prague') AS interval_start, - ((v_market_date::timestamp - + (((hour_no - 1) * 4 + qix) * INTERVAL '15 minutes') + (v_anchor + (((hour_no - 1) * 4 + qix) * INTERVAL '15 minutes')) + AS interval_start, + (v_anchor + (((hour_no - 1) * 4 + qix) * INTERVAL '15 minutes') + INTERVAL '15 minutes') - AT TIME ZONE 'Europe/Prague') AS interval_end, + AS interval_end, ROUND( (price_eur_mwh * in_czk_per_eur / 1000.0)::numeric, 6 )::numeric(10,6) AS raw_price_czk_kwh, @@ -130,6 +137,11 @@ BEGIN CROSS JOIN (VALUES (0), (1), (2), (3)) AS q(qix) ) AS s ORDER BY s.sort_key; + ELSE + RAISE EXCEPTION + 'OTE price series: unexpected point count % (tooltip %)', + v_npts, + v_dl ->> 'tooltip'; END IF; IF NOT FOUND THEN @@ -140,10 +152,9 @@ $$; COMMENT ON FUNCTION ems.fn_ote_parse_15m_price_json(jsonb, numeric) IS 'Parsuje raw JSON z OTE @@chart-data?time_resolution=PT15M. -Datum extrahuje z graph.title (DD.MM.YYYY). -Výběr série: (1) flash_chart_01_y_15m_price_tooltip s ≥90 body, (2) flash_chart_01_y_60m_price_tooltip s ≥90, -(3) tooltip Cena s 20–28 body (hodinovka → 4× 15min stejná EUR/MWh), (4) legacy 15m tooltip s 20–28 body. -EUR/MWh → Kč/kWh přes kurz. Výstup: řádky po 15 min (typicky 96). +Datum z graph.title (DD.MM.YYYY). Sloty: kotva půlnoci Europe/Prague jako timestamptz + násobky 15 min (bez DST duplicit). +Výběr série: flash_15m ≥90, flash_60m ≥90, Cena ≥90, Cena 2–28 h, flash_15m 2–28 h. +EUR/MWh → Kč/kWh. Typicky 96 řádků, 92 při zkráceném dni (letní čas). Testování přímo v DB: SELECT * FROM ems.fn_ote_parse_15m_price_json(pg_read_file(''/tmp/ote.json'')::jsonb, 25.0) LIMIT 5;'; @@ -167,7 +178,7 @@ BEGIN currency, imported_at ) - SELECT + SELECT DISTINCT ON (p.interval_start) 'OTE_CZ', p.interval_start, p.interval_end, @@ -176,6 +187,7 @@ BEGIN 'CZK', now() FROM ems.fn_ote_parse_15m_price_json(in_payload, in_czk_per_eur) AS p + ORDER BY p.interval_start, p.interval_end DESC 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,