diff --git a/CLAUDE.md b/CLAUDE.md index 7917a7c..13277c5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,7 +98,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st 13. **Endpoint `GET …/forecast/pv`** vrací `DISTINCT ON (interval_start, pv_array_id)` seřazené podle nejnovějšího `forecast_pv_run.created_at`, aby UI nemělo duplikáty slotů; plná historie běhů zůstává v tabulkách. -13a. **PV delta kalibrace:** `GET …/forecast/pv-delta-profile` vrací JSON z `fn_pv_forecast_delta_profile`; `GET …/configuration` obsahuje `pv_forecast_calibration` z `ems.site_pv_forecast_calibration`; `PATCH …/configuration/pv-forecast-calibration` mění cutoff / policy / přepsání parametrů delty. Telemetrie `telemetry_inverter.is_export_limited` / `pv_derating_flags` (V058) řídí vyloučení slotu z učení v `fn_fill_forecast_accuracy` (`telemetry_derating`); `telemetry_collector` je plní čtením Deye reg **145** a **179** při poll střídače. +13a. **PV delta kalibrace:** `GET …/forecast/pv-delta-profile` vrací JSON z `fn_pv_forecast_delta_profile`; `GET …/configuration` obsahuje `pv_forecast_calibration` z `ems.site_pv_forecast_calibration`; `PATCH …/configuration/pv-forecast-calibration` mění cutoff / policy / přepsání parametrů delty. **Referenční dny** špičkové produkce zpětně: tabulka **`ems.site_pv_forecast_reference_day`** (V076) + volitelně sloupec **`reference_day_weight_mult`** v kalibraci — v `fn_pv_forecast_delta_profile` zvednou váhu řádků `forecast_accuracy` těchto kalendářních dní (datum ve `site.timezone` jako u slotů); doplňovat lze **`ems.fn_pv_forecast_sync_reference_days`**. Provozní mazání uložené predikce za den (hranice **Europe/Prague**, ne TZ site): **`ems.fn_delete_forecast_pv_prague_calendar_day`**. Telemetrie `telemetry_inverter.is_export_limited` / `pv_derating_flags` (V058) řídí vyloučení slotu z učení v `fn_fill_forecast_accuracy` (`telemetry_derating`); `telemetry_collector` je plní čtením Deye reg **145** a **179** při poll střídače. 14. **Příchod a odjezd EV** detekuje `telemetry_collector` z telemetrie nabíječky: přechod `available` → `preparing` / `charging` (resp. jakýkoli stav ≠ `available`) znamená příjezd; přechod na `available` uzavře `ev_session`. Tabulka `ev_arrival_stats` se při příjezdu doplňuje přes `fn_update_ev_arrival_stats` a **nemá se mazat** (dlouhodobá historická statistika). @@ -159,7 +159,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st | `signal_state` | Poslední požadovaná / odeslaná / ověřená hodnota na cíli (idempotence). | | `cutoff_switch_log` | Log přepnutí cut-off přepínačů (mikroinvertory); edge trigger, důvod a cena. | -**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`. +**View / funkce (nejsou tabulky):** `vw_site_effective_price`, `vw_site_directory`, `vw_modbus_last_verified`, `vw_asset_inverter_modbus_poll`, `vw_asset_ev_charger_modbus_poll`, `vw_asset_heat_pump_modbus_poll`, `vw_latest_telemetry`, `vw_telemetry_hourly_7d`, `vw_telemetry_15m_7d` (15min agregát pro dashboard sloty; repeatable `R__071_vw_telemetry_15m_7d.sql`), `vw_audit_summary`, `vw_operating_mode`, `vw_forecast_accuracy_by_lead_time`, `vw_forecast_accuracy_daily`; `fn_effective_price`, `fn_green_bonus_revenue`, `fn_cop_estimate`, `fn_fill_audit_interval`, `fn_fill_forecast_accuracy`, `fn_delete_forecast_pv_prague_calendar_day`, `fn_pv_forecast_sync_reference_days`, `fn_set_mode`, `fn_expire_modes` (vrací řádky přepnutí pro Discord), `fn_restore_previous_mode`, `fn_update_ev_arrival_stats`, `fn_ev_expected_arrival`, `fn_update_baseline_stats`, `fn_rebuild_consumption_baseline_stats`, `fn_get_baseline_forecast`, `fn_update_market_price_stats`, `fn_update_tuv_usage_stats`, `fn_get_predicted_price`, dále read-modely: `fn_site_configuration`, `fn_site_full_status`, `fn_site_notifications_context`, `fn_plan_current_bundle`, `fn_planning_run_horizon`, `fn_planning_future_price_days`, `fn_economics_daily_month`, `fn_economics_monthly_chart`, `fn_economics_lock_day`, `fn_economics_unlock_day`, `fn_energy_flows_daily_month`, `fn_energy_flows_intervals_day`, `fn_forecast_pv_split`, `fn_ev_sessions_active`, `fn_ev_session_apply_patch`, `fn_ev_arrival_prediction_bundle`, `fn_ev_session_transition`, `fn_negative_price_predictions`, `fn_latest_ote_day_stats`, `fn_ote_day_slot_stats_prague`, `fn_ote_list_missing_days`, `fn_site_effective_prices_day_prague`, `fn_modbus_journal_list`, `fn_modbus_written_command_ids`, `fn_modbus_commands_by_ids`, `fn_inverter_modbus_caps_patch`, `fn_set_mode_with_context`, `fn_fill_audit_for_site_window`, plánování: `fn_load_planning_slots_full`, `fn_last_effective_ote`, `fn_planning_horizon_end`, `fn_planning_site_context`, `fn_pv_forecast_correction_factor`, `fn_planning_run_commit`, `fn_planning_slot_boundary_prague`, `fn_planning_interval_at_offset`, `fn_telemetry_inverter_sample`, `fn_telemetry_ev_charger_sample`, `fn_telemetry_heat_pump_sample`, `fn_battery_cycle_audit`, Deye helpery: `fn_deye_pack_system_time`, `fn_deye_clock_drift_sec`, `fn_deye_time_point_regs`, `fn_deye_tou_inactive_signature`, `fn_modbus_last_verified_map`, `fn_inverter_pv_a_max_w`, `fn_site_has_active_green_bonus_pv`. --- diff --git a/db/migration/V076__pv_forecast_reference_day.sql b/db/migration/V076__pv_forecast_reference_day.sql new file mode 100644 index 0000000..ef7f679 --- /dev/null +++ b/db/migration/V076__pv_forecast_reference_day.sql @@ -0,0 +1,21 @@ +-- Kalendářní dny lokality označené jako referenční pro učení delty PV forecastu (dobrá obloha). + +create table ems.site_pv_forecast_reference_day ( + site_id int not null references ems.site (id) on delete cascade, + day_local date not null, + notes text null, + created_at timestamptz not null default now(), + primary key (site_id, day_local) +); + +comment on table ems.site_pv_forecast_reference_day is +'Dny v kalendáři lokality podle jejího site.timezone (typicky datum ve zdi Europe/Prague), kterým se v ems.fn_pv_forecast_delta_profile zvýší váha řádků forecast_accuracy při počítání delta profilu.'; + +comment on column ems.site_pv_forecast_reference_day.day_local is +'Kalendářní datum v časové zóně lokality; porovnává se na (interval_start AT TIME ZONE site.timezone)::date ze slotů.'; + +alter table ems.site_pv_forecast_calibration + add column if not exists reference_day_weight_mult numeric null; + +comment on column ems.site_pv_forecast_calibration.reference_day_weight_mult is +'Násobitel váhy učícího vzorku pro všechny sloty jejichž den spadá do site_pv_forecast_reference_day; NULL použije default v fn_pv_forecast_delta_profile (aktuálně 3).'; 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 cb7197e..cdf8b50 100644 --- a/db/routines/R__078_fn_pv_forecast_delta_profile.sql +++ b/db/routines/R__078_fn_pv_forecast_delta_profile.sql @@ -19,14 +19,16 @@ LANGUAGE sql STABLE SET work_mem = '64MB' AS $fn$ - WITH eff AS ( + WITH eff AS ( SELECT coalesce(cal.delta_learn_min_ts, timestamptz '2026-04-11T22:00:00Z') AS delta_learn_min_ts, coalesce(cal.half_life_days, p_half_life_days) AS half_life_days, coalesce(cal.threshold_w, p_threshold_w) AS threshold_w, coalesce(cal.top_n_days, p_top_n_days) AS top_n_days, coalesce(cal.non_top_day_factor, p_non_top_day_factor) AS non_top_day_factor, - coalesce(cal.day_weight_gamma, p_day_weight_gamma) AS day_weight_gamma + coalesce(cal.day_weight_gamma, p_day_weight_gamma) AS day_weight_gamma, + greatest(1::numeric, coalesce(cal.reference_day_weight_mult, 3::numeric)) + AS reference_day_w_mult FROM ems.site s LEFT JOIN ems.site_pv_forecast_calibration cal ON cal.site_id = s.id WHERE s.id = p_site_id @@ -170,6 +172,11 @@ AS $fn$ ) AS rn FROM day_stats ds ), + ref_wall AS ( + SELECT d.day_local + FROM ems.site_pv_forecast_reference_day d + WHERE d.site_id = p_site_id + ), filtered AS ( SELECT s.pv_array_id, @@ -194,7 +201,12 @@ AS $fn$ ), greatest(0.25, least(coalesce(e.day_weight_gamma, 1.0), 8.0)) ) - ) AS w + ) + * CASE + WHEN EXISTS (SELECT 1 FROM ref_wall rw WHERE rw.day_local = s.day_local) + THEN e.reference_day_w_mult + ELSE 1::numeric + END AS w FROM slots s CROSS JOIN bounds b CROSS JOIN eff e @@ -281,4 +293,6 @@ AS $fn$ $fn$; COMMENT ON FUNCTION ems.fn_pv_forecast_delta_profile IS - 'Aditivní delta profil PV forecastu po 15min slotu dne (96 slotů) per pv_array_id v `deltas_by_array`; `deltas` je součet delt přes pole (kompatibilita). Zdroj: forecast_accuracy s learning_eligible, cutoff a numerické defaulty z ems.site_pv_forecast_calibration (NULL sloupce = parametry volání).'; + 'Aditivní delta profil PV forecastu po 15min slotu dne (96 slotů) per pv_array_id v deltas_by_array; deltas je součet delt přes pole.' + ' Zdroj forecast_accuracy learning_eligible, cutoff kalibrace, váhy dnů.' + ' Dny z ems.site_pv_forecast_reference_day (den = interval ve site.timezone) mají násobenou váhu (site_pv_forecast_calibration.reference_day_weight_mult nebo default 3).'; diff --git a/db/routines/R__086_fn_forecast_pv_prague_day_ops.sql b/db/routines/R__086_fn_forecast_pv_prague_day_ops.sql new file mode 100644 index 0000000..1d1ab62 --- /dev/null +++ b/db/routines/R__086_fn_forecast_pv_prague_day_ops.sql @@ -0,0 +1,120 @@ +-- Operace nad PV forecastem v DB a správa referenčních dnů kalibrace (náhrada dřívějších .sh nástrojů). + +create or replace function ems.fn_delete_forecast_pv_prague_calendar_day( + p_day date, + p_site_id int default null +) +returns table ( + targets_interval_rows bigint, + deleted_forecast_accuracy_rows bigint, + deleted_forecast_pv_interval_rows bigint, + deleted_empty_forecast_pv_run_rows bigint +) +language plpgsql +volatile +as $fn$ +declare + v_ts_start timestamptz; + v_ts_end timestamptz; + v_tgt interval_rows bigint; + v_acc bigint; + v_iv bigint; + v_run bigint; +begin + v_ts_start := (p_day::text || ' 00:00:00')::timestamp at time zone 'Europe/Prague'; + v_ts_end := ((p_day + 1)::text || ' 00:00:00')::timestamp at time zone 'Europe/Prague'; + + drop table if exists _ems_wipe_pv_forecast_targets; + + create temporary table _ems_wipe_pv_forecast_targets ( + run_id int not null, + pv_array_id int not null, + interval_start timestamptz not null + ) on commit drop; + + insert into _ems_wipe_pv_forecast_targets (run_id, pv_array_id, interval_start) + select fi.run_id, fi.pv_array_id, fi.interval_start + from ems.forecast_pv_interval fi + inner join ems.forecast_pv_run r on r.id = fi.run_id + where fi.interval_start >= v_ts_start + and fi.interval_start < v_ts_end + and (p_site_id is null or r.site_id = p_site_id); + + get diagnostics v_tgt = row_count; + + delete from ems.forecast_accuracy fa + using (select distinct run_id, interval_start from _ems_wipe_pv_forecast_targets) t + where fa.run_id = t.run_id + and fa.interval_start = t.interval_start; + + get diagnostics v_acc = row_count; + + delete from ems.forecast_pv_interval fi + using _ems_wipe_pv_forecast_targets t + where fi.run_id = t.run_id + and fi.pv_array_id = t.pv_array_id + and fi.interval_start = t.interval_start; + + get diagnostics v_iv = row_count; + + delete from ems.forecast_pv_run fr + where fr.id in (select distinct run_id from _ems_wipe_pv_forecast_targets) + and not exists ( + select 1 from ems.forecast_pv_interval x where x.run_id = fr.id + ); + + get diagnostics v_run = row_count; + + targets_interval_rows := v_tgt; + deleted_forecast_accuracy_rows := v_acc; + deleted_forecast_pv_interval_rows := v_iv; + deleted_empty_forecast_pv_run_rows := v_run; + return next; +end; +$fn$; + +comment on function ems.fn_delete_forecast_pv_prague_calendar_day is +'Maze forecast_pv_interval (a navázané forecast_accuracy) pro řádky podle začátku intervalu ' + 'na daný kalendářní den hranovaný půlnocí Europe/Prague — ne podle TZ lokality.' + ' p_site_id NULL = všechny lokality. Prázdné forecast_pv_run v mazané množině smaže návazně.' + ' Destruktivní vůči historii přesnosti; preferuj jen provozní re-import forecastu.'; + + +create or replace function ems.fn_pv_forecast_sync_reference_days( + p_site_id int, + p_days_local date[], + p_replace_existing boolean default false +) +returns int +language plpgsql +volatile +as $fn$ +declare + v_after int; +begin + if not exists (select 1 from ems.site s where s.id = p_site_id) then + raise exception using + message = format('site_id %s neexistuje v ems.site', p_site_id), + errcode = 'P0001'; + end if; + + if p_replace_existing then + delete from ems.site_pv_forecast_reference_day d where d.site_id = p_site_id; + end if; + + insert into ems.site_pv_forecast_reference_day (site_id, day_local) + select p_site_id, d::date + from unnest(p_days_local) as u(d) + where d is not null + on conflict (site_id, day_local) do nothing; + + select count(*)::int into v_after from ems.site_pv_forecast_reference_day d where d.site_id = p_site_id; + return v_after; +end; +$fn$; + +comment on function ems.fn_pv_forecast_sync_reference_days is +'Zapíše kalendářní dny (datum ve zdi site.timezone lokality při použití s fn_pv_forecast_delta_profile) jako referenční. ' + 'p_replace_existing true smaže předchozí záznamy dané lokality; false jen doplňuje unnest bez přepsání. ' + 'Vrací počet řádků v site_pv_forecast_reference_day po operaci.'; + diff --git a/db/views/R__072_z_postgrest_ems_anon_grants.sql b/db/views/R__072_z_postgrest_ems_anon_grants.sql index 1bdc5e3..adf6b19 100644 --- a/db/views/R__072_z_postgrest_ems_anon_grants.sql +++ b/db/views/R__072_z_postgrest_ems_anon_grants.sql @@ -29,6 +29,7 @@ GRANT SELECT ON ems.vw_telemetry_hourly_7d TO ems_anon; GRANT SELECT ON ems.vw_telemetry_15m_7d TO ems_anon; GRANT SELECT ON ems.forecast_accuracy TO ems_anon; GRANT SELECT ON ems.site_pv_forecast_calibration TO ems_anon; +GRANT SELECT ON ems.site_pv_forecast_reference_day TO ems_anon; GRANT SELECT ON ems.vw_forecast_accuracy_by_lead_time TO ems_anon; GRANT SELECT ON ems.vw_forecast_accuracy_daily TO ems_anon; GRANT SELECT ON ems.consumption_baseline_stats TO ems_anon; diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 445427d..3ccc01e 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -323,6 +323,11 @@ CREATE TABLE forecast_pv_interval ( -- SELECT create_hypertable('forecast_pv_interval', 'interval_start'); ``` +### Kalibrace delty PV (per site) + +- **`site_pv_forecast_calibration`** (V057+, rozšířeno V076) – parametry učení aditivní korekce výkonu PV z `forecast_accuracy` při každém výpočtu `fn_pv_forecast_delta_profile` (např. `half_life_days`, `top_n_days`; **V076**: `reference_day_weight_mult`). +- **`site_pv_forecast_reference_day`** (**V076**) – kalendářní datum ve smyslu lokality `(interval_start AT TIME ZONE site.timezone)::date`; tyto dny dostanou násobek váhy vzorků v `fn_pv_forecast_delta_profile`, aby zpětné „hezky svítící“ reference silněji vtáhly δ profil bez mazání řádků `forecast_pv_interval`. + --- ## Plánování diff --git a/docs/04-modules/consumption.md b/docs/04-modules/consumption.md index a002497..9e0df7f 100644 --- a/docs/04-modules/consumption.md +++ b/docs/04-modules/consumption.md @@ -44,7 +44,7 @@ bazální_w = load_power_w - ev_power_w - heat_pump_power_w **Solver (`planning_engine._load_slots`):** pro každý 15min interval efektivní ceny bere **`avg_power_w` z `consumption_baseline_stats`** podle DOW+hodiny slotu, jinak **500 W** – nečte `consumption_baseline_interval`. Stejná hodnota se ukládá do **`planning_interval.load_baseline_w`** při každém běhu plánovače (přehled v UI / PostgREST). Odchylka vs. skutečnost: tabulka **`baseline_load_forecast_accuracy`**, plněno po auditu. -**Operace: přepočet bez EMA „ocasu“:** denní job volá `fn_update_baseline_stats`, které při updatu bucketu míchá **70 % starý + 30 % nový** průměr. Je-li profil zaseklý, smaž statistiky a znovu načti z telemetrie — kanonické API je **`ems.fn_rebuild_consumption_baseline_stats(p_site_id, p_lookback_days)` v `db/routines/R__085_fn_rebuild_consumption_baseline_stats.sql`**: při **`p_site_id IS NULL`** maže celou `consumption_baseline_stats` a přepíná všechny řádky z `ems.site`; při konkrétním `site_id` jen řádky dané lokality. **Příklad (psql / MCP):** `select * from ems.fn_rebuild_consumption_baseline_stats(2, 30);` jedna lokality; **`select * from ems.fn_rebuild_consumption_baseline_stats(null::int, 30);`** všechny lokality *(první argument je site_id — ne zaměnit s počtem dnů).* Tenký wrapper: **`scripts/rebuild_consumption_baseline_stats.sh`**. Špatná měření (EV/TČ) funkce sama neopraví. +**Operace: přepočet bez EMA „ocasu“:** denní job volá `fn_update_baseline_stats`, které při updatu bucketu míchá **70 % starý + 30 % nový** průměr. Je-li profil zaseklý, smaž statistiky a znovu načti z telemetrie — kanonické API je **`ems.fn_rebuild_consumption_baseline_stats(p_site_id, p_lookback_days)` v `db/routines/R__085_fn_rebuild_consumption_baseline_stats.sql`**: při **`p_site_id IS NULL`** maže celou `consumption_baseline_stats` a přepíná všechny řádky z `ems.site`; při konkrétním `site_id` jen řádky dané lokality. **Příklad (psql / MCP):** `select * from ems.fn_rebuild_consumption_baseline_stats(2, 30);` jedna lokality; **`select * from ems.fn_rebuild_consumption_baseline_stats(null::int, 30);`** všechny lokality *(první argument je site_id — ne zaměnit s počtem dnů).* Špatná měření (EV/TČ) funkce sama neopraví. > **Poznámka:** TUV jako samostatný odečet zůstává otevřený bod, pokud není měřen zvlášť; aktuálně je TČ zahrnut v `heat_pump_power_w`. diff --git a/docs/04-modules/forecast.md b/docs/04-modules/forecast.md index 1c8ba87..c771d01 100644 --- a/docs/04-modules/forecast.md +++ b/docs/04-modules/forecast.md @@ -171,15 +171,31 @@ Viz `03-data-model.md`: --- -## Jednorázové smazání PV forecastu za den (provoz) +## Operace SQL: mazání řádků PV forecastu za den (provozní výjimka) -Projekt standardně **nemazá** `forecast_pv_interval` / `forecast_pv_run`, aby zůstala historie pro `forecast_accuracy` a učení delty. Pokud potřebuješ záměrně smazat řádky FVE predikce za **jeden kalendářní den v `Europe/Prague`** (a znovu naplnit službou forecastu), použij: +Projekt standardně **nemá mazat** `forecast_pv_interval` / `forecast_pv_run`, aby zůstala historie pro přesnost. Když **záměrně** promāžeš den (např. před regenerací výstupu předpovědi), použij **`ems.fn_delete_forecast_pv_prague_calendar_day(p_day date, p_site_id int DEFAULT NULL)`** (`db/routines/R__086_fn_forecast_pv_prague_day_ops.sql`). Hranice dne jsou **`Europe/Prague` půlnoc** *(ne timezone lokality)*; `p_site_id NULL` = všechny lokality. -`scripts/wipe_pv_forecast_prague_day.sh` (volitelně `SITE_ID`, `DRY_RUN=1`; vyžaduje `DATABASE_URL` nebo PG env). +Příklad: `select * from ems.fn_delete_forecast_pv_prague_calendar_day('2026-05-02'::date, 2);` -Skript maže v pořadí: `forecast_accuracy` (odpovídající páry `run_id`/`interval_start`), `forecast_pv_interval` (PK řádků ve zvoleném dni), pak `forecast_pv_run`, které po smazání už nemají žádné intervaly — **pouze pokud** jejich `id` bylo v mazaném výběru (`IN` z cílové množiny), aby se neodstraňovaly náhodné orphan běhy z jiných událostí. +Odstranění jde přes páry **`forecast_accuracy` → řádek `forecast_pv_interval`→ prázdné `forecast_pv_run`**, které měly jen interval v mazané množině (*stejně jako dříve skript*). -**Není to** mazání statistiky bazální spotřeby (`consumption_baseline_*`); pokud tě trápí load v plánovači, řeš výpočet/`fn_update_baseline_stats`, ne tento skript. +Na **bazální spotřebu** (`consumption_baseline_stats`) to nesahá → **`ems.fn_rebuild_consumption_baseline_stats`** v `R__085`. + +--- + +## Referenční dny při učení delty („hezky svítily“ zpětně) + +Profil **`ems.fn_pv_forecast_delta_profile`** se **nemerguje jako samostatný soubor** — při každém načítání (`fn_load_planning_slots_full` / API) znovu agreguje chybu z **`forecast_accuracy`** v okně (lookback/exponenta `half_life`, rank top dnů odvozený od energie a hladkosti dnů). + +**„Zapošto“ k existující logice**: + +1. Ověř, že máš `forecast_accuracy` pro ty dny (po skutečnosti slotů z `actual_power_w` z telemetrie) — obvykle díky `fn_fill_forecast_accuracy`. +2. Založ řádek v **`ems.site_pv_forecast_reference_day(site_id, day_local, notes)`**. **`day_local`** musí sedět na **`(interval_start AT TIME ZONE site.timezone)::date`** slovní hodiny lokality *(typicky datum v Praze jako u home-01 `Europe/Prague`)*. +3. *(Volitelně)* nastav **`site_pv_forecast_calibration.reference_day_weight_mult`** *(NULL = výchozí násobitel **3**, minimum v kódu 1).* Ostatní dny berou jako dosud jejich váhy `(top_n, non_top_day_factor, decay…)` současně — **„referenční den“ je multiplikátor navíc**, nesamostatný paralelní model. + +Hromadně: **`ems.fn_pv_forecast_sync_reference_days(site_id, p_days_local date[], p_replace_existing bool default false)`** — nahrazením **true** nejdřív vymaže dřívější řádky reference pro site, pak doplní `unnest`; vrací celkový počet pinů lokality po operaci. + +**Co to nedělá:** nepřepisuje zpětně uložené `forecast_pv_interval`; mění jen to, jak moc vstupuje ten den do **aktuálních** δ slotů používaných v plánění. --- diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 9c491a9..963e030 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -32,7 +32,7 @@ - **nebo** **`ems.site_grid_connection.block_export_on_negative_sell`** (migrace **V074**, default **false**) — bez GEN registrů na Deye; vhodné např. pro **KV1** (fixní nákup, bez nutnosti vést výkon neriťitelného pole B do sítě). **home-01** nech **false**, jinak může být horizont při přebytku z pole B a plné/nedostupné baterii **infeasible** (solver export potřebuje jako fyzikální ventil tam, kde FVE B nelze štípnout ani odpojit modelem bez GEN řádku). - **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit. - **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu. -- **Kalibrace PV forecastu (delta profil):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …). `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`). `ems.fn_pv_forecast_delta_profile` vrací `deltas_by_array` i součtové `deltas`; `ems.fn_load_planning_slots_full` aplikuje stejnou **per-pole** korekci jako UI (`fn_forecast_pv_slots_range_corrected`); pokud v JSON profilu chybí `deltas_by_array`, použije se souhrnné `deltas` rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON). +- **Kalibrace PV forecastu (delta profil):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …; **V076** navíc `reference_day_weight_mult` pro „připnuté“ dny níže). **`ems.site_pv_forecast_reference_day`** (**V076**) umožňuje zvýšit váhu konkrétních kalendářních dnů (datum ve `site.timezone` jako u časování slotů) při agregaci δ z `forecast_accuracy` (`fn_pv_forecast_delta_profile`); hromadný zápis **`ems.fn_pv_forecast_sync_reference_days`**, detail **`docs/04-modules/forecast.md`**. `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`). `ems.fn_pv_forecast_delta_profile` vrací `deltas_by_array` i součtové `deltas`; `ems.fn_load_planning_slots_full` aplikuje stejnou **per-pole** korekci jako UI (`fn_forecast_pv_slots_range_corrected`); pokud v JSON profilu chybí `deltas_by_array`, použije se souhrnné `deltas` rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON). Solver optimalizuje celý horizont (typicky do konce známých OTE dat, strop z `fn_planning_horizon_end`) najednou, čímž přirozeně zvládá: - pohled dopředu (ráno ví že přes poledne bude záporná cena → prodává z baterie) diff --git a/docs/07-mcp-postgres-ems.md b/docs/07-mcp-postgres-ems.md index f89037e..e57a217 100644 --- a/docs/07-mcp-postgres-ems.md +++ b/docs/07-mcp-postgres-ems.md @@ -61,6 +61,8 @@ limit 10; select ems.fn_plan_explain_bundle(2, 6); ``` +Měnící funkce (**`ems.fn_delete_forecast_pv_prague_calendar_day`**, **`ems.fn_rebuild_consumption_baseline_stats`**, …) MCP přes **`query` neprovede**, pokud má server jen read-only práva na DB — použij psql aplikačním účtem. + --- ## 5. Odkud to vychází v repozitáři diff --git a/scripts/rebuild_consumption_baseline_stats.sh b/scripts/rebuild_consumption_baseline_stats.sh deleted file mode 100755 index 484b6fd..0000000 --- a/scripts/rebuild_consumption_baseline_stats.sh +++ /dev/null @@ -1,64 +0,0 @@ -#!/usr/bin/env bash -# Tenký wrapper nad ems.fn_rebuild_consumption_baseline_stats (kanonické API je v PostgreSQL). -# -# Použití: -# export DATABASE_URL='postgres://…/ems' -# LOOKBACK_DAYS=14 ./scripts/rebuild_consumption_baseline_stats.sh 2 # jedna lokality (site.id) -# -# ./scripts/rebuild_consumption_baseline_stats.sh --all # celá tabulka stats + všichni sites -# -# MCP / psql přímo (doporučeno v SQL-first režimu): -# select * from ems.fn_rebuild_consumption_baseline_stats(2, 30); -# select * from ems.fn_rebuild_consumption_baseline_stats(null::int, 30); -# -# DRY_RUN=1 … — jen řádek count (žádné mazání). - -set -euo pipefail - -if [[ -z "${DATABASE_URL:-}" ]] && [[ -z "${PGHOST:-}" ]]; then - echo "Nastav DATABASE_URL nebo PG proměnné." >&2 - exit 1 -fi - -LOOKBACK="${LOOKBACK_DAYS:-30}" -if ! [[ "$LOOKBACK" =~ ^[0-9]+$ ]] || [[ "$LOOKBACK" -lt 1 ]]; then - echo "LOOKBACK_DAYS musí být kladné celé číslo." >&2 - exit 1 -fi - -PSQL=(psql -v ON_ERROR_STOP=1) -if [[ -n "${DATABASE_URL:-}" ]]; then - PSQL+=("$DATABASE_URL") -else - PSQL+=("${PGDATABASE:-ems}") -fi - -MODE="${1:?Chybí argument: site_id (číslo) nebo --all}" -DRY="${DRY_RUN:-0}" - -if [[ "$MODE" == "--all" ]]; then - if [[ "$DRY" == "1" ]] || [[ "$DRY" == "true" ]]; then - echo "DRY_RUN: consumption_baseline_stats řádků celkem, sites:" - "${PSQL[@]}" -c " - select count(*) as stats_rows from ems.consumption_baseline_stats; - select count(*) as sites from ems.site; - " - exit 0 - fi - echo "Volám fn_rebuild_consumption_baseline_stats(null, ${LOOKBACK}) …" - "${PSQL[@]}" -c "select * from ems.fn_rebuild_consumption_baseline_stats(null::int, ${LOOKBACK}::int);" -else - SITE_ID="$MODE" - if ! [[ "$SITE_ID" =~ ^[0-9]+$ ]]; then - echo "První argument: site_id (číslo) nebo --all." >&2 - exit 1 - fi - if [[ "$DRY" == "1" ]] || [[ "$DRY" == "true" ]]; then - "${PSQL[@]}" -c "select count(*) from ems.consumption_baseline_stats where site_id = ${SITE_ID}::int;" - exit 0 - fi - echo "Volám fn_rebuild_consumption_baseline_stats(${SITE_ID}, ${LOOKBACK}) …" - "${PSQL[@]}" -c "select * from ems.fn_rebuild_consumption_baseline_stats(${SITE_ID}::int, ${LOOKBACK}::int);" -fi - -echo "Hotovo. Spusť rolling/denní plán nebo počkej na scheduler." diff --git a/scripts/wipe_pv_forecast_prague_day.sh b/scripts/wipe_pv_forecast_prague_day.sh deleted file mode 100755 index 470e206..0000000 --- a/scripts/wipe_pv_forecast_prague_day.sh +++ /dev/null @@ -1,126 +0,0 @@ -#!/usr/bin/env bash -# Odstraní predikované PV intervaly (a navázaný tracking přesnosti) pro jeden kalendářní den v Europe/Prague. -# -# VAROVÁNÍ (provozní / datová kontinuita) -# ──────────────────────────────────────── -# - Řád v repozitáři je držet historické běhy FVE forecastu pro analýzu a učení delty (@see docs/04-modules/forecast.md). -# - Používej jen když vědomě potřebuješ „načisto“ vygenerovat nový forecast (forecast service). -# - Fyzický breaker řeší měnič/Deye — skript jen čistí databázi od uloženého PV forecastu. -# -# Nenahrazuje mazání/load baseline spotřeby (`consumption_baseline_stats` / její výpočet). -# -# Použití: -# export DATABASE_URL='postgres://…/ems' -# ./scripts/wipe_pv_forecast_prague_day.sh # dnešní den (Europe/Prague), všechny lokality -# ./scripts/wipe_pv_forecast_prague_day.sh 2026-05-02 # konkrétní datum YYYY-MM-DD -# ./scripts/wipe_pv_forecast_prague_day.sh 2026-05-02 2 # jen site.id = 2 (např. home-01) -# -# Šedý režim (jen počty, žádné mazání): -# DRY_RUN=1 ./scripts/wipe_pv_forecast_prague_day.sh 2026-05-02 2 - -set -euo pipefail - -DAY="${1:-$(TZ=Europe/Prague date +%Y-%m-%d)}" -SITE_ID="${2:-}" - -if ! [[ "$DAY" =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then - echo "Datum musí být YYYY-MM-DD, dostal jsem: $DAY" >&2 - exit 1 -fi - -if [[ -z "${DATABASE_URL:-}" ]] && [[ -z "${PGHOST:-}" ]]; then - echo "Nastav DATABASE_URL nebo standardní PG proměnné (PGHOST, PGUSER, PGDATABASE, …)." >&2 - exit 1 -fi - -PSQL=(psql -v ON_ERROR_STOP=1) -if [[ -n "${DATABASE_URL:-}" ]]; then - PSQL+=("$DATABASE_URL") -else - PSQL+=("${PGDATABASE:-ems}") -fi - -SITE_CLAUSE="" -if [[ -n "$SITE_ID" ]]; then - if ! [[ "$SITE_ID" =~ ^[0-9]+$ ]]; then - echo "Druhý argument musí být číslo site_id." >&2 - exit 1 - fi - SITE_CLAUSE="AND r.site_id = ${SITE_ID}::int" -fi - -DRY="${DRY_RUN:-0}" - -if [[ "$DRY" == "1" ]] || [[ "$DRY" == "true" ]]; then - echo "DRY_RUN: den $DAY (Europe/Prague)${SITE_ID:+ site_id=$SITE_ID}" - "${PSQL[@]}" -c " - with bounds as ( - select - ('${DAY}'::date::text || ' 00:00:00')::timestamp at time zone 'Europe/Prague' as ts_start, - (('${DAY}'::date + 1)::text || ' 00:00:00')::timestamp at time zone 'Europe/Prague' as ts_end - ), - targets as ( - select fi.run_id, fi.pv_array_id, fi.interval_start - from ems.forecast_pv_interval fi - inner join ems.forecast_pv_run r on r.id = fi.run_id - cross join bounds b - where fi.interval_start >= b.ts_start - and fi.interval_start < b.ts_end - ${SITE_CLAUSE} - ) - select count(*) as interval_rows_to_delete, count(distinct run_id) as distinct_run_ids - from targets; -" - exit 0 -fi - -echo "Mažu PV forecast intervaly pro den $DAY (Europe/Prague)${SITE_ID:+ site_id=$SITE_ID} …" - -"${PSQL[@]}" -c " -begin; - -create temporary table _ems_wipe_pv_forecast_targets ( - run_id int not null, - pv_array_id int not null, - interval_start timestamptz not null -) on commit drop; - -with bounds as ( - select - ('${DAY}'::date::text || ' 00:00:00')::timestamp at time zone 'Europe/Prague' as ts_start, - (('${DAY}'::date + 1)::text || ' 00:00:00')::timestamp at time zone 'Europe/Prague' as ts_end -) -insert into _ems_wipe_pv_forecast_targets (run_id, pv_array_id, interval_start) -select fi.run_id, fi.pv_array_id, fi.interval_start -from ems.forecast_pv_interval fi -inner join ems.forecast_pv_run r on r.id = fi.run_id -cross join bounds b -where fi.interval_start >= b.ts_start - and fi.interval_start < b.ts_end - ${SITE_CLAUSE}; - -delete from ems.forecast_accuracy fa -using (select distinct run_id, interval_start from _ems_wipe_pv_forecast_targets) t -where fa.run_id = t.run_id - and fa.interval_start = t.interval_start; - -delete from ems.forecast_pv_interval fi -using _ems_wipe_pv_forecast_targets t -where fi.run_id = t.run_id - and fi.pv_array_id = t.pv_array_id - and fi.interval_start = t.interval_start; - -delete from ems.forecast_pv_run fr -where fr.id in (select distinct run_id from _ems_wipe_pv_forecast_targets) - and not exists ( - select 1 from ems.forecast_pv_interval x where x.run_id = fr.id - ); - -select - (select count(*) from _ems_wipe_pv_forecast_targets) as targets_interval_rows, - (select count(distinct run_id) from _ems_wipe_pv_forecast_targets) as distinct_run_ids_touched; - -commit; -" - -echo "Hotovo. Spusť forecast job / službu v backendu (Open-Meteo běh), aby se řádky ${DAY} doplnily znovu."