sync reference days
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-02 14:05:09 +02:00
parent 343f2f9847
commit 5ca5eab1d8
12 changed files with 192 additions and 203 deletions

View File

@@ -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`.
---

View File

@@ -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).';

View File

@@ -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).';

View File

@@ -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.';

View File

@@ -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;

View File

@@ -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í

View File

@@ -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`.

View File

@@ -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í.
---

View File

@@ -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)

View File

@@ -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

View File

@@ -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."

View File

@@ -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."