Files
ems/backend/services/planning/forecast.py
Dusan Vojacek dcbb5de98c Fáze 1.3+1.4: extrakce forecast korekce a DB vrstvy plánovače
- services/planning/forecast.py: compute_correction_factor, apply_forecast_correction
- services/planning/db_io.py: _ev_session_from_json, _load_site_context,
  _load_previous_plan_charge_commitment_prev_w, _load_slots, _build_slot_inputs,
  _save_planning_run, _save_failed_planning_run
- .claude/settings.json: projektový allowlist (autonomní běhy bez promptů)

Fasáda beze změny chování; golden 5/5, baseline faily beze změny.
planning_engine.py: 6345 → 5717 řádků.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-11 12:39:55 +02:00

98 lines
3.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# backend/services/planning/forecast.py
#
# EMS plánovač korekce FVE forecastu podle skutečné výroby
# (Fáze 1 dekompozice, čistý přesun z planning_engine.py).
import json
import logging
from dataclasses import replace
from datetime import datetime, timedelta
from typing import Optional
from services.planning.constants import (
CORRECTION_DECAY_SLOTS,
CORRECTION_MAX_CLAMP,
CORRECTION_MIN_CLAMP,
CORRECTION_WINDOW_H,
)
from services.planning.types import PlanningSlot, _parse_json_dt
logger = logging.getLogger(__name__)
async def compute_correction_factor(
site_id: int,
now: datetime,
db,
window_h: float = CORRECTION_WINDOW_H,
) -> tuple[float, dict]:
"""
Spočítá korekční faktor FVE forecastu z posledních window_h hodin.
Vrátí (factor, log_data) kde factor je v rozsahu [CORRECTION_MIN_CLAMP, CORRECTION_MAX_CLAMP].
factor = 1.0 pokud není dostatek dat nebo je rozdíl zanedbatelný.
"""
window_start = now - timedelta(hours=window_h)
raw = await db.fetchval(
"""
select ems.fn_pv_forecast_correction_factor(
$1::int, $2::timestamptz, $3::timestamptz,
$4::numeric, $5::numeric
)
""",
site_id,
window_start,
now,
CORRECTION_MIN_CLAMP,
CORRECTION_MAX_CLAMP,
)
j = raw if isinstance(raw, dict) else json.loads(raw)
factor = float(j.get("correction_factor", 1.0))
# JSON z DB má často ISO řetězce; asyncpg u $2/$3 vyžaduje datetime
ws = _parse_json_dt(j.get("window_start")) or window_start
we = _parse_json_dt(j.get("window_end")) or now
log_data = {
"window_start": ws,
"window_end": we,
"actual_pv_wh": j.get("actual_pv_wh"),
"forecast_pv_wh": j.get("forecast_pv_wh"),
"correction_factor": factor,
"reason": j.get("reason", "ok"),
}
if j.get("raw_factor") is not None:
log_data["raw_factor"] = j["raw_factor"]
return factor, log_data
def apply_forecast_correction(
slots: list[PlanningSlot],
now: datetime,
factor: float,
decay_slots: int = CORRECTION_DECAY_SLOTS,
) -> list[PlanningSlot]:
"""
Aplikuje korekční faktor na FVE forecast zbývajících slotů.
Korekce se lineárně utlumuje: na 1. slotu plná korekce,
na decay_slots-tém slotu žádná korekce.
Příklad: factor=0.85, slot 0 → pv_a *= 0.85, slot 8 → pv_a *= 0.925, slot 16+ → žádná korekce
"""
corrected = []
for i, slot in enumerate(slots):
if factor == 1.0 or i >= decay_slots:
corrected.append(slot)
continue
# Lineární útlum: weight klesá od 1.0 (slot 0) do 0.0 (slot decay_slots)
weight = 1.0 - (i / decay_slots)
effective_factor = 1.0 + (factor - 1.0) * weight
corrected.append(
replace(
slot,
pv_a_forecast_w=max(0, int(slot.pv_a_forecast_w * effective_factor)),
pv_b_forecast_w=max(0, int(slot.pv_b_forecast_w * effective_factor)),
)
)
return corrected