diff --git a/.cursor/rules/postgres-sql-drop-comment.mdc b/.cursor/rules/postgres-sql-drop-comment.mdc new file mode 100644 index 0000000..c24912d --- /dev/null +++ b/.cursor/rules/postgres-sql-drop-comment.mdc @@ -0,0 +1,12 @@ +--- +description: Postgres DROP/COMMENT ON FUNCTION bez seznamu argumentů (jedna funkce na jméno) +globs: db/**/*.sql +alwaysApply: false +--- + +# Postgres: `DROP FUNCTION` a `COMMENT ON FUNCTION` bez parametrů + +- U **`DROP FUNCTION`** (včetně schématu, např. `ems.fn_pv_forecast_delta_profile`) **nemusíme** uvádět signaturu argumentů, pokud platí předpoklad: **v DB existuje jen jedna funkce tohoto plného jména** (žádný jiný overload se stejným jménem). +- Stejně u **`COMMENT ON FUNCTION`** používej **`COMMENT ON FUNCTION ems.nazev_funkce IS '...'`** bez seznamu typů argumentů — za stejného předpokladu jedné funkce na jméno. + +**Důsledek:** při zavádění overloadů se stejným názvem je nutné signatury zase explicitně rozlišit, nebo přejmenovat / sloučit funkce tak, aby jméno bylo jednoznačné. diff --git a/backend/app/routers/sites.py b/backend/app/routers/sites.py index 85c96b2..84bf5e1 100644 --- a/backend/app/routers/sites.py +++ b/backend/app/routers/sites.py @@ -560,6 +560,24 @@ async def get_site_forecast_pv_slots_range_corrected( le=10_000, description="Ignorovat sloty s nízkou výrobou (W) při odhadu profilu", ), + top_n_days: int | None = Query( + None, + ge=1, + le=120, + description="Jen N nejlepších kalendářních dní (podle clear-ish skóre) pro delta profil; ostatní dny ztlumené", + ), + non_top_day_factor: float | None = Query( + None, + ge=0.0, + le=1.0, + description="Násobek váhy pro dny mimo top N (default 0.02)", + ), + day_weight_gamma: float | None = Query( + None, + ge=0.25, + le=8.0, + description="Exponent na denní váhu (>1 silněji preferuje jen velmi 'clear' dny)", + ), ) -> dict[str, list[dict[str, Any]]]: if to_ts <= from_ts: raise HTTPException(status_code=422, detail="'to' must be after 'from'") @@ -571,6 +589,8 @@ async def get_site_forecast_pv_slots_range_corrected( now = datetime.now(tz=timezone.utc) delta_to = delta_to_ts or now delta_from = delta_from_ts or (delta_to - timedelta(days=60)) + ntf = 0.02 if non_top_day_factor is None else float(non_top_day_factor) + dg = 1.0 if day_weight_gamma is None else float(day_weight_gamma) async with db.acquire() as conn: site_ok = await conn.fetchval( "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id @@ -587,7 +607,10 @@ async def get_site_forecast_pv_slots_range_corrected( $4::timestamptz, $5::timestamptz, $6::numeric, - $7::int + $7::int, + $8::int, + $9::numeric, + $10::numeric ) """, site_id, @@ -597,6 +620,9 @@ async def get_site_forecast_pv_slots_range_corrected( delta_to, half_life_days, threshold_w, + top_n_days, + ntf, + dg, ) slots = raw if isinstance(raw, list) else [] if not isinstance(slots, list): diff --git a/db/routines/R__078_fn_pv_forecast_delta_profile.sql b/db/routines/R__078_fn_pv_forecast_delta_profile.sql index 25be8d5..6af4f77 100644 --- a/db/routines/R__078_fn_pv_forecast_delta_profile.sql +++ b/db/routines/R__078_fn_pv_forecast_delta_profile.sql @@ -3,12 +3,17 @@ -- (aditivní korekce: corrected = max(0, forecast - delta[slot])) -- ============================================================ +drop function if exists ems.fn_pv_forecast_delta_profile; + create or replace function ems.fn_pv_forecast_delta_profile( p_site_id int, p_data_from timestamptz, p_data_to timestamptz default now(), p_half_life_days numeric default 14, - p_threshold_w int default 150 + p_threshold_w int default 150, + p_top_n_days int default null, + p_non_top_day_factor numeric default 0.02, + p_day_weight_gamma numeric default 1.0 ) returns jsonb language sql @@ -143,22 +148,45 @@ as $fn$ left join day_jump dj on dj.day_local = de.day_local left join day_med dm on dm.day_local = de.day_local ), + -- Volitelně: jen top N kalendářních dní podle (w_energy * w_smooth); zbytek ztlumit (bez hardcodu data). + day_rank as ( + select + ds.day_local, + row_number() over ( + order by + (coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35)) desc, + ds.day_local desc + ) as rn + from day_stats ds + ), filtered as ( select s.slot_of_day, (s.forecast_total_w - s.actual_total_w) as error_w, exp(-s.age_days / nullif((select half_life_days from bounds), 0)) + * ( + case + when p_top_n_days is null then 1::numeric + when p_top_n_days < 1 then 1::numeric + when dr.rn <= p_top_n_days then 1::numeric + else greatest(0::numeric, least(1::numeric, coalesce(p_non_top_day_factor, 0.02))) + end + ) * ( 0.05 + 0.95 - * greatest( - 0.0, - least(1.0, coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35)) + * power( + greatest( + 0.0, + least(1.0, coalesce(ds.w_energy, 0.35) * coalesce(ds.w_smooth, 0.35)) + ), + greatest(0.25, least(coalesce(p_day_weight_gamma, 1.0), 8.0)) ) ) as w from slots s cross join bounds b left join day_stats ds on ds.day_local = s.day_local + left join day_rank dr on dr.day_local = s.day_local where s.slot_of_day between 0 and 95 and (s.actual_total_w > b.threshold_w or s.forecast_total_w > b.threshold_w) ), @@ -200,5 +228,5 @@ as $fn$ left join agg a on a.slot_of_day = sp.slot_of_day; $fn$; -comment on function ems.fn_pv_forecast_delta_profile(int, timestamptz, timestamptz, numeric, int) is - 'Aditivní delta profil chyby PV forecastu po 15min slotu dne (96 slotů). Zdroj: forecast_accuracy, vážení exp(-age/half_life_days) * day_weight (preferuje „clear-ish“ dny: vyšší denní energie vs median v okně + nižší median skoků výkonu mezi 15min v daylight bandu). Vrací JSON {deltas:[{slot_of_day, delta_w, sample_count}], ...}. Interní minimální cutoff dat (2026-04-11 Europe/Prague) brání učení z nekonzistentní historie před kompletním plněním actual.'; +comment on function ems.fn_pv_forecast_delta_profile is + 'Aditivní delta profil chyby PV forecastu po 15min slotu dne (96 slotů). Zdroj: forecast_accuracy, vážení exp(-age/half_life_days) * day_weight (clear-ish dny) * volitelně top_n_days (jen N nejlepších kalendářních dní podle w_energy*w_smooth, ostatní ztlumené) * power(day_weight, day_weight_gamma). Vrací JSON {deltas:[{slot_of_day, delta_w, sample_count}], ...}. Cutoff dat od 2026-04-11 Europe/Prague.'; diff --git a/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql b/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql index 8f0932d..3975723 100644 --- a/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql +++ b/db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql @@ -3,6 +3,8 @@ -- corrected = max(0, forecast - delta_profile[slot_of_day]) -- ============================================================ +drop function if exists ems.fn_forecast_pv_slots_range_corrected; + create or replace function ems.fn_forecast_pv_slots_range_corrected( p_site_id int, p_from timestamptz, @@ -10,7 +12,10 @@ create or replace function ems.fn_forecast_pv_slots_range_corrected( p_delta_data_from timestamptz, p_delta_data_to timestamptz default now(), p_half_life_days numeric default 14, - p_threshold_w int default 150 + p_threshold_w int default 150, + p_top_n_days int default null, + p_non_top_day_factor numeric default 0.02, + p_day_weight_gamma numeric default 1.0 ) returns jsonb language sql @@ -67,7 +72,10 @@ as $fn$ p_delta_data_from, p_delta_data_to, p_half_life_days, - p_threshold_w + p_threshold_w, + p_top_n_days, + p_non_top_day_factor, + p_day_weight_gamma ) as j ), deltas as ( @@ -119,5 +127,5 @@ as $fn$ left join fc on fc.interval_start = s.interval_start; $fn$; -comment on function ems.fn_forecast_pv_slots_range_corrected(int, timestamptz, timestamptz, timestamptz, timestamptz, numeric, int) is - 'JSON pole {interval_start, pv_forecast_total_w, pv_forecast_corrected_w, slot_of_day} po 15 min pro [p_from, p_to). Korekce je aditivní delta profil z fn_pv_forecast_delta_profile. Horizont je omezený na max. 60 dní.'; +comment on function ems.fn_forecast_pv_slots_range_corrected is + 'JSON pole {interval_start, pv_forecast_total_w, pv_forecast_corrected_w, slot_of_day} po 15 min pro [p_from, p_to). Korekce je aditivní delta profil z fn_pv_forecast_delta_profile (top_n_days / non_top_day_factor / day_weight_gamma). Horizont je omezený na max. 60 dní.'; diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index 0febe6f..85bbe81 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -129,6 +129,12 @@ export type ForecastPvSlotsCorrectedParams = { delta_to?: string half_life_days?: number threshold_w?: number + /** Jen N nejlepších kalendářních dní pro výpočet delta profilu (backend → fn_pv_forecast_delta_profile). */ + top_n_days?: number + /** Násobek váhy pro dny mimo top N (0–1, default na serveru 0.02). */ + non_top_day_factor?: number + /** Exponent na denní váhu (default 1 = beze změny oproti předchozímu chování bez top_n). */ + day_weight_gamma?: number } export async function getForecastPvSlotsRangeCorrected(