fix letni /zimni cas OTE
This commit is contained in:
@@ -46,7 +46,7 @@ from services.notification_service import (
|
|||||||
notify_operating_mode_changed,
|
notify_operating_mode_changed,
|
||||||
run_fn_set_mode_with_discord,
|
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 services.telemetry_collector import run_telemetry_loop_wrapper
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -317,7 +317,7 @@ async def lifespan(app: FastAPI):
|
|||||||
|
|
||||||
for day in (today, tomorrow):
|
for day in (today, tomorrow):
|
||||||
slots = await _count_ote_slots_for_day(conn, day)
|
slots = await _count_ote_slots_for_day(conn, day)
|
||||||
if slots >= 96:
|
if ote_prague_day_slots_look_complete(slots):
|
||||||
continue
|
continue
|
||||||
n, imported_day, _, err = await import_ote_prices(
|
n, imported_day, _, err = await import_ote_prices(
|
||||||
conn, site_id=None, target_date=day
|
conn, site_id=None, target_date=day
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ Lokálně (venv s backend/requirements.txt):
|
|||||||
Volby:
|
Volby:
|
||||||
--days 730 posledních N kalendářních dní (Europe/Prague), výchozí 730 ≈ 2 roky
|
--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)
|
--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
|
--dry-run jen vypsat chybějící dny, bez HTTP
|
||||||
--delay SEC pauza mezi dny (výchozí 0.35)
|
--delay SEC pauza mezi dny (výchozí 0.35)
|
||||||
--refresh-predictions po skončení zavolat fn_predict_negative_price_windows pro aktivní site
|
--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 app.config import get_settings # noqa: E402
|
||||||
from services.price_importer import ( # noqa: E402
|
from services.price_importer import ( # noqa: E402
|
||||||
OTE_EXPECTED_SLOTS,
|
OTE_FULL_DAY_SLOT_COUNTS,
|
||||||
backfill_ote_prices,
|
backfill_ote_prices,
|
||||||
count_ote_slots_prague_day,
|
count_ote_slots_prague_day,
|
||||||
|
ote_prague_day_slots_look_complete,
|
||||||
)
|
)
|
||||||
|
|
||||||
PRAGUE = ZoneInfo("Europe/Prague")
|
PRAGUE = ZoneInfo("Europe/Prague")
|
||||||
@@ -82,7 +83,7 @@ async def _dry_run_missing(
|
|||||||
if d > today_prague:
|
if d > today_prague:
|
||||||
break
|
break
|
||||||
n = await count_ote_slots_prague_day(conn, d)
|
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)
|
out.append(d)
|
||||||
d += timedelta(days=1)
|
d += timedelta(days=1)
|
||||||
return out
|
return out
|
||||||
@@ -136,9 +137,9 @@ async def main_async(args: argparse.Namespace) -> int:
|
|||||||
if args.dry_run:
|
if args.dry_run:
|
||||||
missing = await _dry_run_missing(conn, start, end, today_prague)
|
missing = await _dry_run_missing(conn, start, end, today_prague)
|
||||||
logging.info(
|
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),
|
len(missing),
|
||||||
OTE_EXPECTED_SLOTS,
|
sorted(OTE_FULL_DAY_SLOT_COUNTS),
|
||||||
)
|
)
|
||||||
for md in missing[:50]:
|
for md in missing[:50]:
|
||||||
n = await count_ote_slots_prague_day(conn, md)
|
n = await count_ote_slots_prague_day(conn, md)
|
||||||
@@ -198,7 +199,7 @@ def main() -> None:
|
|||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--force",
|
"--force",
|
||||||
action="store_true",
|
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("--dry-run", action="store_true", help="Jen vypsat chybějící dny")
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
|
|||||||
@@ -14,7 +14,16 @@ from app.config import get_settings
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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 = (
|
OTE_URL = (
|
||||||
"https://www.ote-cr.cz/cs/kratkodobe-trhy/elektrina/denni-trh/"
|
"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:
|
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(
|
return int(
|
||||||
await conn.fetchval(
|
await conn.fetchval(
|
||||||
"""
|
"""
|
||||||
@@ -150,12 +159,12 @@ async def import_ote_prices_for_day(
|
|||||||
target_day,
|
target_day,
|
||||||
)
|
)
|
||||||
n_imported = await count_ote_slots_prague_day(conn, 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(
|
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,
|
n_imported,
|
||||||
OTE_EXPECTED_SLOTS,
|
|
||||||
day_str,
|
day_str,
|
||||||
|
sorted(OTE_FULL_DAY_SLOT_COUNTS),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"OTE import OK: %s slotů (upsert) pro %s, první cena %.4f Kč/kWh",
|
"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.
|
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).
|
pause_between_days_s: krátká pauza mezi HTTP požadavky (ohleduplnost k OTE).
|
||||||
"""
|
"""
|
||||||
stats = OteBackfillStats(start_date=start_date, end_date=end_date)
|
stats = OteBackfillStats(start_date=start_date, end_date=end_date)
|
||||||
@@ -208,7 +217,7 @@ async def backfill_ote_prices(
|
|||||||
d += timedelta(days=1)
|
d += timedelta(days=1)
|
||||||
continue
|
continue
|
||||||
slots = await count_ote_slots_prague_day(conn, d)
|
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
|
stats.days_skipped_complete += 1
|
||||||
d += timedelta(days=1)
|
d += timedelta(days=1)
|
||||||
continue
|
continue
|
||||||
@@ -301,7 +310,7 @@ async def import_ote_prices(
|
|||||||
""",
|
""",
|
||||||
target_day,
|
target_day,
|
||||||
)
|
)
|
||||||
incomplete = (n_imported or 0) < OTE_EXPECTED_SLOTS
|
incomplete = not ote_prague_day_slots_look_complete(n_imported or 0)
|
||||||
if incomplete:
|
if incomplete:
|
||||||
now_p = datetime.now(ZoneInfo("Europe/Prague"))
|
now_p = datetime.now(ZoneInfo("Europe/Prague"))
|
||||||
tomorrow_p = (now_p + timedelta(days=1)).date()
|
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)
|
and (now_p.hour, now_p.minute) < (14, 30)
|
||||||
):
|
):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"OTE: jen %s/%s slotů pro %s",
|
"OTE: %s slotů pro %s (plný den = jedna z %s)",
|
||||||
n_imported,
|
n_imported,
|
||||||
OTE_EXPECTED_SLOTS,
|
|
||||||
date_str,
|
date_str,
|
||||||
|
sorted(OTE_FULL_DAY_SLOT_COUNTS),
|
||||||
)
|
)
|
||||||
logger.info(
|
logger.info(
|
||||||
"OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh",
|
"OTE import OK: %s slotů pro %s, první cena %.4f Kč/kWh",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ AS $$
|
|||||||
DECLARE
|
DECLARE
|
||||||
v_date_text text;
|
v_date_text text;
|
||||||
v_market_date date;
|
v_market_date date;
|
||||||
|
v_anchor timestamptz;
|
||||||
v_dl jsonb;
|
v_dl jsonb;
|
||||||
v_npts int;
|
v_npts int;
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -39,6 +40,10 @@ BEGIN
|
|||||||
in_payload #>> '{graph,title}';
|
in_payload #>> '{graph,title}';
|
||||||
END IF;
|
END IF;
|
||||||
v_market_date := to_date(v_date_text, 'DD.MM.YYYY');
|
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_*),
|
-- 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".
|
-- starší / jiná odpověď = jen 24 hodinových bodů s tooltip "Cena".
|
||||||
@@ -60,7 +65,15 @@ BEGIN
|
|||||||
SELECT t.dl INTO v_dl
|
SELECT t.dl INTO v_dl
|
||||||
FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl)
|
FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl)
|
||||||
WHERE dl ->> 'tooltip' = 'Cena'
|
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;
|
LIMIT 1;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
@@ -68,7 +81,7 @@ BEGIN
|
|||||||
SELECT t.dl INTO v_dl
|
SELECT t.dl INTO v_dl
|
||||||
FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl)
|
FROM jsonb_array_elements(in_payload #> '{data,dataLine}') AS t(dl)
|
||||||
WHERE dl ->> 'tooltip' = 'flash_chart_01_y_15m_price_tooltip'
|
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;
|
LIMIT 1;
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
@@ -83,17 +96,13 @@ BEGIN
|
|||||||
v_npts := jsonb_array_length(v_dl -> 'point');
|
v_npts := jsonb_array_length(v_dl -> 'point');
|
||||||
|
|
||||||
IF v_npts >= 90 THEN
|
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
|
RETURN QUERY
|
||||||
SELECT s.interval_start, s.interval_end, s.raw_price_czk_kwh
|
SELECT s.interval_start, s.interval_end, s.raw_price_czk_kwh
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
((v_market_date::timestamp
|
(v_anchor + ((block_no - 1) * INTERVAL '15 minutes')) AS interval_start,
|
||||||
+ ((block_no - 1) * INTERVAL '15 minutes'))
|
(v_anchor + (block_no * INTERVAL '15 minutes')) AS interval_end,
|
||||||
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,
|
|
||||||
ROUND(
|
ROUND(
|
||||||
(price_eur_mwh * in_czk_per_eur / 1000.0)::numeric, 6
|
(price_eur_mwh * in_czk_per_eur / 1000.0)::numeric, 6
|
||||||
)::numeric(10,6) AS raw_price_czk_kwh,
|
)::numeric(10,6) AS raw_price_czk_kwh,
|
||||||
@@ -106,19 +115,17 @@ BEGIN
|
|||||||
) pts
|
) pts
|
||||||
) AS s
|
) AS s
|
||||||
ORDER BY s.block_no;
|
ORDER BY s.block_no;
|
||||||
ELSE
|
ELSIF v_npts BETWEEN 2 AND 28 THEN
|
||||||
-- x = 1..24 = hodiny dne; každou hodinovou cenu rozvineme na 4× 15min slot
|
-- x = 1..H = hodiny (ne nutně celý den); každou hodinovou cenu → 4× 15min slot
|
||||||
RETURN QUERY
|
RETURN QUERY
|
||||||
SELECT s.interval_start, s.interval_end, s.raw_price_czk_kwh
|
SELECT s.interval_start, s.interval_end, s.raw_price_czk_kwh
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
SELECT
|
||||||
((v_market_date::timestamp
|
(v_anchor + (((hour_no - 1) * 4 + qix) * INTERVAL '15 minutes'))
|
||||||
+ (((hour_no - 1) * 4 + qix) * INTERVAL '15 minutes'))
|
AS interval_start,
|
||||||
AT TIME ZONE 'Europe/Prague') AS interval_start,
|
(v_anchor + (((hour_no - 1) * 4 + qix) * INTERVAL '15 minutes')
|
||||||
((v_market_date::timestamp
|
|
||||||
+ (((hour_no - 1) * 4 + qix) * INTERVAL '15 minutes')
|
|
||||||
+ INTERVAL '15 minutes')
|
+ INTERVAL '15 minutes')
|
||||||
AT TIME ZONE 'Europe/Prague') AS interval_end,
|
AS interval_end,
|
||||||
ROUND(
|
ROUND(
|
||||||
(price_eur_mwh * in_czk_per_eur / 1000.0)::numeric, 6
|
(price_eur_mwh * in_czk_per_eur / 1000.0)::numeric, 6
|
||||||
)::numeric(10,6) AS raw_price_czk_kwh,
|
)::numeric(10,6) AS raw_price_czk_kwh,
|
||||||
@@ -130,6 +137,11 @@ BEGIN
|
|||||||
CROSS JOIN (VALUES (0), (1), (2), (3)) AS q(qix)
|
CROSS JOIN (VALUES (0), (1), (2), (3)) AS q(qix)
|
||||||
) AS s
|
) AS s
|
||||||
ORDER BY s.sort_key;
|
ORDER BY s.sort_key;
|
||||||
|
ELSE
|
||||||
|
RAISE EXCEPTION
|
||||||
|
'OTE price series: unexpected point count % (tooltip %)',
|
||||||
|
v_npts,
|
||||||
|
v_dl ->> 'tooltip';
|
||||||
END IF;
|
END IF;
|
||||||
|
|
||||||
IF NOT FOUND THEN
|
IF NOT FOUND THEN
|
||||||
@@ -140,10 +152,9 @@ $$;
|
|||||||
|
|
||||||
COMMENT ON FUNCTION ems.fn_ote_parse_15m_price_json(jsonb, numeric) IS
|
COMMENT ON FUNCTION ems.fn_ote_parse_15m_price_json(jsonb, numeric) IS
|
||||||
'Parsuje raw JSON z OTE @@chart-data?time_resolution=PT15M.
|
'Parsuje raw JSON z OTE @@chart-data?time_resolution=PT15M.
|
||||||
Datum extrahuje z graph.title (DD.MM.YYYY).
|
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: (1) flash_chart_01_y_15m_price_tooltip s ≥90 body, (2) flash_chart_01_y_60m_price_tooltip s ≥90,
|
Výběr série: flash_15m ≥90, flash_60m ≥90, Cena ≥90, Cena 2–28 h, flash_15m 2–28 h.
|
||||||
(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. Typicky 96 řádků, 92 při zkráceném dni (letní čas).
|
||||||
EUR/MWh → Kč/kWh přes kurz. Výstup: řádky po 15 min (typicky 96).
|
|
||||||
Testování přímo v DB:
|
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;';
|
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,
|
currency,
|
||||||
imported_at
|
imported_at
|
||||||
)
|
)
|
||||||
SELECT
|
SELECT DISTINCT ON (p.interval_start)
|
||||||
'OTE_CZ',
|
'OTE_CZ',
|
||||||
p.interval_start,
|
p.interval_start,
|
||||||
p.interval_end,
|
p.interval_end,
|
||||||
@@ -176,6 +187,7 @@ BEGIN
|
|||||||
'CZK',
|
'CZK',
|
||||||
now()
|
now()
|
||||||
FROM ems.fn_ote_parse_15m_price_json(in_payload, in_czk_per_eur) AS p
|
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
|
ON CONFLICT (market_source, interval_start) DO UPDATE SET
|
||||||
interval_end = EXCLUDED.interval_end,
|
interval_end = EXCLUDED.interval_end,
|
||||||
buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh,
|
buy_raw_price_czk_kwh = EXCLUDED.buy_raw_price_czk_kwh,
|
||||||
|
|||||||
Reference in New Issue
Block a user