diff --git a/backend/services/planning/__init__.py b/backend/services/planning/__init__.py new file mode 100644 index 0000000..6a44ec4 --- /dev/null +++ b/backend/services/planning/__init__.py @@ -0,0 +1 @@ +"""EMS plánovač – moduly (Fáze 1 dekompozice planning_engine.py).""" diff --git a/backend/services/planning/constants.py b/backend/services/planning/constants.py new file mode 100644 index 0000000..8be569a --- /dev/null +++ b/backend/services/planning/constants.py @@ -0,0 +1,113 @@ +# backend/services/planning/constants.py +# +# EMS plánovač – konstanty (Fáze 1 dekompozice, čistý přesun z planning_engine.py). +# POZOR: ekonomické penalty/váhy jsou kandidáti na přesun do DB ve Fázi 2 +# (CLAUDE.md pravidlo 16: žádný skrytý faktor v Pythonu). + +from zoneinfo import ZoneInfo + +# ============================================================ +# Konstanty +# ============================================================ + +# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end). +_DAILY_FALLBACK_HORIZON_HOURS = 1.0 +# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh). +INTERVAL_H = 0.25 # 15 minut v hodinách +CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A +SOLVER_TIME_LIMIT = 10 # sekund +# MILP: významný export ge (W) ⇒ koncové soc[t] ≥ podlaha; mimo arbitrážní relax je to arb_base_wh +# (rezerva z DB). Při relaxaci spodku před extrémně záporným buy je podlaha soc_panel_min[t] +# (planner floor), jinak by šlo jen do zátěže a nešlo by „vypustit do sítě“ před levným nákupem. +GE_MIN_EXPORT_W = 1.0 +# Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh). +ACQUISITION_TWO_PASS_EPS_KWH = 0.05 +# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek). +LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05 +# Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než +# tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor — +# priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem. +DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8 +# Měkká kotva: chceme být u planner floor už v posledním slotu před prvním sell < 0. +# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila +# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0. +PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20 +PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 +# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail). +PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0 +# Curtailment při sell<0 + allow_charge: nesmí být téměř zdarma oproti nabíjení (BA81). +NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0 +# Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif). +NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8 +# Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail). +NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35 +# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek. +NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0 +# Fáze sell<0 (v32): ASAP na prep_soc %, tail rampa na soc_max. +NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.85 +NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH = 60.0 +# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž). +EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12 +NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0 +NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0 +PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 +PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 +PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 +PLANNER_BUILD_TAG = "2026-06-06-home01-strict-late-replan-v5" +SOLVER_RELAX_STEPS: tuple[str, ...] = ( + "strict", + "relaxed_expensive_import", + "relaxed_neg_buy_charge", + "relaxed_neg_prep_hold_only", + "relaxed_neg_prep_window", + "neg_sell_phases_fallback", + "relaxed_pos_sell_ge_block", + "relaxed_solver_masks", +) +# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). +DAWN_LOW_PV_NO_CURTAIL_W = 1500 +# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu). +NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0 +# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). +NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0 +# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat). +NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0 +# Kotva: SoC na konci večera D−1 a těsně před 1. sell<0 ráno D ≤ reserve_soc. +NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH = 400.0 +NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.0 +# Terminal SoC shadow price: effective_factor = base × (1 − w_neg); w_neg roste s blízkostí a záporností buy<0. +TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS = int(36 / INTERVAL_H) +TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK = 1.0 +TERMINAL_NEG_BUY_MAGNITUDE_FLOOR = 0.25 +TERMINAL_NEG_BUY_WEIGHT_CAP = 0.95 +# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl. +PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15 +PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0 +PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 55.0 +PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH = 90.0 +POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30 +PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25 +PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 +EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05 +# Rolling replan: držet evening_push_ts při malé změně peak sell / SoC. +EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH = 0.5 +EVENING_PUSH_HYSTERESIS_SOC_PCT = 5.0 +# Noční výprodej baterie: večer (≥17h) + ráno do východu FVE (0–5h Prague), jedna špička přes půlnoc. +NIGHT_EXPORT_EVENING_START_HOUR = 17 +NIGHT_EXPORT_MORNING_END_HOUR = 5 +NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W = 500.0 +# Převáží terminal SoC shadow price při krátkém večerním horizontu (home-01). +EVENING_PUSH_Z_EXPORT_BONUS_CZK = 2500.0 +# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B). +PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0 +CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru +CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru +CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru +# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast +CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0 +# Dynamická ekonomická podlaha (MILP w_arb): lookahead FVE energie v dalších slotech +ARB_LOOKAHEAD_SLOTS = 32 # 8 h při INTERVAL_H=0.25 +ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..1) + +_PRAGUE_TZ = ZoneInfo("Europe/Prague") + diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 1a278dc..adf8d67 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -24,111 +24,65 @@ from app.config import get_settings logger = logging.getLogger(__name__) -# ============================================================ -# Konstanty -# ============================================================ - -# Když DB vrátí NULL (skoro žádná OTE data), denní plán použije krátký fallback (soulad s min hodinami ve fn_planning_horizon_end). -_DAILY_FALLBACK_HORIZON_HOURS = 1.0 -# Shadow cena zbytkové energie na konci horizontu: - (avg_buy * FACTOR / 1000) * soc[T-1] (Kč; soc v Wh). -INTERVAL_H = 0.25 # 15 minut v hodinách -CURTAILMENT_PENALTY = 0.001 # Kč/Wh – malá penalizace za omezení FVE pole A -SOLVER_TIME_LIMIT = 10 # sekund -# MILP: významný export ge (W) ⇒ koncové soc[t] ≥ podlaha; mimo arbitrážní relax je to arb_base_wh -# (rezerva z DB). Při relaxaci spodku před extrémně záporným buy je podlaha soc_panel_min[t] -# (planner floor), jinak by šlo jen do zátěže a nešlo by „vypustit do sítě“ před levným nákupem. -GE_MIN_EXPORT_W = 1.0 -# Dvouprůchodové solve: stop když acquisition z pass1 vs pass2 se liší méně než (Kč/kWh). -ACQUISITION_TWO_PASS_EPS_KWH = 0.05 -# Load-first (Deye): PV nejdřív pokryje load+EV+TČ; bc_pv/ge_pv jen z pv_sp (přebytek). -LOAD_FIRST_INCENTIVE_CZK_KWH = 0.05 -# Dokud je kotva pro hluboký dump (první sell < 0 v horizontu, jinak první extrémní buy) dál než -# tento počet 15min slotů, držíme plánovací spodek na rezervě (arb_base_wh) místo planner floor — -# priorita: beze „ztráty na prodeji“ (sell >= 0) držet buffer, hluboký vývoz až těsně před záporným prodejem. -DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS = 8 -# Měkká kotva: chceme být u planner floor už v posledním slotu před prvním sell < 0. -# Penalizace je v Kč/Wh (např. 0.20 = 200 Kč/kWh). Musí být dost velká, aby přebila -# bezpečnostní SoC buffer + terminal shadow cenu a solver skutečně „dovylil“ před sell<0. -PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH = 0.20 -PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 -# Měkký tlak: v okně sell<0 + block_export využít PV přebytek do baterie (ne curtail). -PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0 -# Curtailment při sell<0 + allow_charge: nesmí být téměř zdarma oproti nabíjení (BA81). -NEG_SELL_CURTAIL_PENALTY_CZK_KWH = 1.0 -# Odměna v objective za FVE→baterie při sell<0 (doplňuje shortfall; BA81 fixed tarif). -NEG_SELL_PV_CHARGE_REWARD_CZK_KWH = 0.8 -# Měkký tlak: v okně sell<0 dobít na soc_max (ne zastavit na ~94 % kvůli curtail). -NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH = 0.35 -# Jen ventil nekontrolovatelného pole B při plné baterii a sell<0 (spot); ne celý PV přebytek. -NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH = 4.0 -# Fáze sell<0 (v32): ASAP na prep_soc %, tail rampa na soc_max. -NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.85 -NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH = 60.0 -# Výboj baterie při sell<0 jen těsně před extrémně záporným buy (round-trip arbitráž). -EXTREME_BUY_DUMP_PREWINDOW_SLOTS = 12 -NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH = 80.0 -NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH = 100.0 -PRE_NEG_CHARGE_PENALTY_CZK_KWH = 400.0 -PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 -PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH = 1.0 -PLANNER_BUILD_TAG = "2026-06-06-home01-strict-late-replan-v5" -SOLVER_RELAX_STEPS: tuple[str, ...] = ( - "strict", - "relaxed_expensive_import", - "relaxed_neg_buy_charge", - "relaxed_neg_prep_hold_only", - "relaxed_neg_prep_window", - "neg_sell_phases_fallback", - "relaxed_pos_sell_ge_block", - "relaxed_solver_masks", +from services.planning.constants import ( + ACQUISITION_TWO_PASS_EPS_KWH, + SOLVER_RELAX_STEPS, + ARB_FLOOR_E_REF_FRAC, + ARB_LOOKAHEAD_SLOTS, + CORRECTION_DECAY_SLOTS, + CORRECTION_MAX_CLAMP, + CORRECTION_MIN_CLAMP, + CORRECTION_WINDOW_H, + CURTAILMENT_PENALTY, + DAWN_LOW_PV_NO_CURTAIL_W, + DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS, + EVENING_PEAK_SELL_EPS_CZK_KWH, + EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH, + EVENING_PUSH_HYSTERESIS_SOC_PCT, + EVENING_PUSH_Z_EXPORT_BONUS_CZK, + EXTREME_BUY_DUMP_PREWINDOW_SLOTS, + GE_MIN_EXPORT_W, + INTERVAL_H, + LOAD_FIRST_INCENTIVE_CZK_KWH, + NEG_BUY_CHARGE_SHORTFALL_PENALTY_CZK_KWH, + NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH, + NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH, + NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH, + NEG_SELL_BAT_DUMP_SHORTFALL_PENALTY_CZK_KWH, + NEG_SELL_CURTAIL_PENALTY_CZK_KWH, + NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH, + NEG_SELL_PREP_HOLD_BCPV_PENALTY_CZK_KWH, + NEG_SELL_PREP_SOC_SHORTFALL_PENALTY_CZK_PER_WH, + NEG_SELL_PV_B_VENT_PENALTY_CZK_KWH, + NEG_SELL_PV_CHARGE_REWARD_CZK_KWH, + NEG_SELL_SOC_UNDERFILL_PENALTY_CZK_PER_WH, + NIGHT_EXPORT_EVENING_START_HOUR, + NIGHT_EXPORT_MORNING_END_HOUR, + NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W, + NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH, + PEAK_EXPORT_SHORTFALL_PENALTY_CZK_KWH, + PLANNER_BUILD_TAG, + POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH, + PRENEG_SELL_SOC_ANCHOR_SLACK_PENALTY_CZK_PER_WH, + PRE_NEG_BATT_EXPORT_MIN_SELL_CZK_KWH, + PRE_NEG_BATT_EXPORT_SHORTFALL_PENALTY_CZK_KWH, + PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH, + PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH, + PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH, + PRE_NEG_CHARGE_PENALTY_CZK_KWH, + PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH, + PRE_NEG_PV_EXPORT_FORECAST_MARGIN, + PRE_NEG_PV_EXPORT_MIN_NEEDED_WH, + PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH, + PV_CHARGE_SHORTFALL_PENALTY_CZK_KWH, + SOLVER_TIME_LIMIT, + TERMINAL_NEG_BUY_MAGNITUDE_FLOOR, + TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK, + TERMINAL_NEG_BUY_WEIGHT_CAP, + TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS, + _DAILY_FALLBACK_HORIZON_HOURS, + _PRAGUE_TZ, ) -# Ranní slabá FVE: neaplikovat pv_store ge_pv=0 (jinak curtail při sell < večerní peak). -DAWN_LOW_PV_NO_CURTAIL_W = 1500 -# Mimo evening_push: preferovat bd pro dům místo gi, když buy >> acq (účinná cena importu). -NIGHT_SELF_CONSUME_IMPORT_SURCHARGE_CZK_KWH = 4.0 -# Po t_detach v prep: necpát PV do bat (měkké; tvrdý hold přes soc_target z rampy). -NEG_SELL_POST_DETACH_BCPV_DISCOURAGE_CZK_KWH = 250.0 -# Večer před neg dnem: výboj do sítě (měkký shortfall na ge_bat). -NEG_EVENING_PREP_DISCHARGE_SHORTFALL_PENALTY_CZK_KWH = 120.0 -# Kotva: SoC na konci večera D−1 a těsně před 1. sell<0 ráno D ≤ reserve_soc. -NEG_EVENING_RESERVE_SOC_MAX_SLACK_WH = 400.0 -NEG_EVENING_RESERVE_SOC_SLACK_PENALTY_CZK_PER_WH = 55.0 -# Terminal SoC shadow price: effective_factor = base × (1 − w_neg); w_neg roste s blízkostí a záporností buy<0. -TERMINAL_NEG_BUY_WEIGHT_HORIZON_SLOTS = int(36 / INTERVAL_H) -TERMINAL_NEG_BUY_MAGNITUDE_REF_CZK = 1.0 -TERMINAL_NEG_BUY_MAGNITUDE_FLOOR = 0.25 -TERMINAL_NEG_BUY_WEIGHT_CAP = 0.95 -# Před prvním sell<0: export FVE jen pokud predikce v sell<0 okně pokryje dobítí na prep cíl. -PRE_NEG_PV_EXPORT_FORECAST_MARGIN = 1.15 -PRE_NEG_PV_EXPORT_MIN_NEEDED_WH = 2500.0 -PRE_NEG_PV_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 55.0 -PRE_NEG_PV_BCPV_DISCOURAGE_CZK_KWH = 90.0 -POS_SELL_PRE_NEG_SOC_SHORTFALL_PENALTY_CZK_PER_WH = 0.30 -PRE_NEG_BUY_SOC_CEILING_SLACK_PENALTY_CZK_PER_WH = 0.25 -PRE_NEG_BUY_EMPTY_EXPORT_SHORTFALL_PENALTY_CZK_KWH = 80.0 -EVENING_PEAK_SELL_EPS_CZK_KWH = 0.05 -# Rolling replan: držet evening_push_ts při malé změně peak sell / SoC. -EVENING_PUSH_HYSTERESIS_SELL_PEAK_DELTA_CZK_KWH = 0.5 -EVENING_PUSH_HYSTERESIS_SOC_PCT = 5.0 -# Noční výprodej baterie: večer (≥17h) + ráno do východu FVE (0–5h Prague), jedna špička přes půlnoc. -NIGHT_EXPORT_EVENING_START_HOUR = 17 -NIGHT_EXPORT_MORNING_END_HOUR = 5 -NIGHT_EXPORT_PV_SUNRISE_SURPLUS_W = 500.0 -# Převáží terminal SoC shadow price při krátkém večerním horizontu (home-01). -EVENING_PUSH_Z_EXPORT_BONUS_CZK = 2500.0 -# buy<0: preferovat import před PV A→bat (měkké; tvrdé bc_pv=0 láme bilanci s polem B). -PRE_NEG_BUY_PV_CHARGE_PENALTY_CZK_KWH = 250.0 -CORRECTION_WINDOW_H = 1 # hodina zpět pro výpočet korekčního faktoru -CORRECTION_MIN_CLAMP = 0.5 # spodní limit korekčního faktoru -CORRECTION_MAX_CLAMP = 1.5 # horní limit korekčního faktoru -# Útlum korekce: čím dál od aktuálního času, tím méně korigujeme forecast -CORRECTION_DECAY_SLOTS = 16 # po 16 slotech (4h) klesne korekce na 0 -# Dynamická ekonomická podlaha (MILP w_arb): lookahead FVE energie v dalších slotech -ARB_LOOKAHEAD_SLOTS = 32 # 8 h při INTERVAL_H=0.25 -ARB_FLOOR_E_REF_FRAC = 0.5 # má scale Wh = tato frakce usable_capacity (0..1) - -_PRAGUE_TZ = ZoneInfo("Europe/Prague") - class PlannerSolverError(RuntimeError): """Solver selhal po vyčerpání retry řetězce (typicky Infeasible)."""