From 1dfab8c7a1789fdeedb20ef7f5623d9658ffa450 Mon Sep 17 00:00:00 2001 From: Dusan Vojacek Date: Wed, 22 Apr 2026 22:42:12 +0200 Subject: [PATCH] dalsi uprava vypoctu delty (ignorujeme orezane vyroby) --- CLAUDE.md | 5 +- backend/app/routers/site_configuration.py | 86 ++++++++++++++++++- ...058__telemetry_inverter_derating_flags.sql | 12 +++ .../R__022_fn_fill_forecast_accuracy.sql | 26 ++++-- db/routines/R__046_fn_site_configuration.sql | 6 ++ db/views/R__058_vw_latest_telemetry.sql | 4 +- docs/04-modules/forecast.md | 1 + docs/04-modules/planning.md | 2 +- docs/05-todo.md | 2 +- frontend/src/api/backend.ts | 27 +++++- frontend/src/types/siteConfiguration.ts | 15 ++++ 11 files changed, 174 insertions(+), 12 deletions(-) create mode 100644 db/migration/V058__telemetry_inverter_derating_flags.sql diff --git a/CLAUDE.md b/CLAUDE.md index 2215486..0959492 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -98,6 +98,8 @@ 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`). + 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). 15. **Bazální spotřeba** = `load_power_w` minus řízené zátěže (součet EV z `telemetry_ev_charger`, TČ z `telemetry_heat_pump`). Tabulka `consumption_baseline_stats` se plní denně (APScheduler 00:30) přes `fn_update_baseline_stats`. **Solver** načítá průměr bazálu z `consumption_baseline_stats` (DOW + hodina v Europe/Prague), ne z `consumption_baseline_interval`. @@ -132,12 +134,13 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st | `asset_heat_pump` | TČ (výkon, COP ref, limity běhu, TUV parametry). | | `asset_vehicle` | Vozidlo (kapacita, max AC výkon, default target SoC/deadline). | | `market_interval_price` | Raw spot OTE (15min), bez marží. | -| `telemetry_inverter` | 1min telemetrie střídače (Timescale). | +| `telemetry_inverter` | 1min telemetrie střídače (Timescale); volitelně `is_export_limited`, `pv_derating_flags` pro vyloučení slotu z učení delty. | | `telemetry_ev_charger` | 1min telemetrie nabíječky (Timescale). | | `telemetry_heat_pump` | 1min telemetrie TČ (Timescale). | | `forecast_pv_run` | Metadata běhu predikce FVE. | | `forecast_pv_interval` | Predikovaný výkon FVE po 15min (Timescale). | | `forecast_accuracy` | Řádky přesnosti predikce vs telemetrie po 15min (per run); doplňuje `fn_fill_forecast_accuracy`. | +| `site_pv_forecast_calibration` | Per site: cutoff učení delty, policy škrcení, přepsání parametrů `fn_pv_forecast_delta_profile`. | | `forecast_weather_interval` | Počasí 15min pro site (Timescale). | | `forecast_correction_log` | Log korekcí forecastu vs skutečnost při rolling replanu. | | `planning_run` | Jeden běh plánovače (daily/rolling/manual, stav, parametry solveru). | diff --git a/backend/app/routers/site_configuration.py b/backend/app/routers/site_configuration.py index f44f9de..7b8d208 100644 --- a/backend/app/routers/site_configuration.py +++ b/backend/app/routers/site_configuration.py @@ -8,7 +8,7 @@ from typing import Annotated, Any import asyncpg from fastapi import APIRouter, Depends, HTTPException -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field from app.db_json import fetch_json from app.deps import get_pg_pool @@ -16,6 +16,20 @@ from app.deps import get_pg_pool router = APIRouter(prefix="/sites/{site_id}", tags=["sites"]) +class PvForecastCalibrationPatch(BaseModel): + """Částečná úprava `ems.site_pv_forecast_calibration`. Vynechané klíče = beze změny.""" + + model_config = ConfigDict(extra="forbid") + + delta_learn_min_ts: datetime | None = None + pv_curtailment_policy_effective_from: datetime | None = None + top_n_days: int | None = Field(default=None, ge=0, le=31) + non_top_day_factor: float | None = Field(default=None, ge=0, le=1) + day_weight_gamma: float | None = Field(default=None, ge=0.25, le=8) + half_life_days: float | None = Field(default=None, ge=1, le=90) + threshold_w: int | None = Field(default=None, ge=0, le=10_000) + + class InverterModbusCurrentCapsBody(BaseModel): """Tvrdý strop proudu pro zápis Deye reg 108/109 (A); NULL ve JSONu = smaž strop v DB.""" @@ -77,6 +91,76 @@ async def get_site_configuration( return raw +@router.patch("/configuration/pv-forecast-calibration") +async def patch_pv_forecast_calibration( + site_id: int, + body: PvForecastCalibrationPatch, + pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)], +) -> dict[str, Any]: + """Aktualizace kalibrace PV delty (`ems.site_pv_forecast_calibration`).""" + updates = body.model_dump(exclude_unset=True) + if not updates: + raise HTTPException(status_code=400, detail="No fields to update") + if updates.get("delta_learn_min_ts") is None and "delta_learn_min_ts" in updates: + raise HTTPException( + status_code=422, + detail="delta_learn_min_ts cannot be null (column is NOT NULL)", + ) + + allowed = { + "delta_learn_min_ts", + "pv_curtailment_policy_effective_from", + "top_n_days", + "non_top_day_factor", + "day_weight_gamma", + "half_life_days", + "threshold_w", + } + bad = set(updates) - allowed + if bad: + raise HTTPException(status_code=400, detail=f"Unknown fields: {sorted(bad)}") + + cols = list(updates.keys()) + set_parts: list[str] = [] + args: list[Any] = [site_id] + for i, col in enumerate(cols, start=2): + set_parts.append(f"{col} = ${i}") + args.append(updates[col]) + set_sql = ", ".join(set_parts) + ", updated_at = now()" + + async with pool.acquire() as conn: + site_ok = await conn.fetchval( + "SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id + ) + if not site_ok: + raise HTTPException(status_code=404, detail="Site not found") + n = await conn.execute( + f""" + UPDATE ems.site_pv_forecast_calibration + SET {set_sql} + WHERE site_id = $1 + """, + *args, + ) + if n == "UPDATE 0": + raise HTTPException( + status_code=404, + detail="PV forecast calibration row missing; run migration V057", + ) + row = await conn.fetchrow( + """ + SELECT to_jsonb(c.*) AS j + FROM ems.site_pv_forecast_calibration c + WHERE c.site_id = $1 + """, + site_id, + ) + raw = row["j"] if row else {} + if not isinstance(raw, dict): + raw = json.loads(raw) + return raw + + @router.patch("/inverters/{inverter_id}/modbus-current-caps") async def patch_inverter_modbus_current_caps( site_id: int, diff --git a/db/migration/V058__telemetry_inverter_derating_flags.sql b/db/migration/V058__telemetry_inverter_derating_flags.sql new file mode 100644 index 0000000..33ae361 --- /dev/null +++ b/db/migration/V058__telemetry_inverter_derating_flags.sql @@ -0,0 +1,12 @@ +-- Volitelné flagy pro vyloučení „škrcených“ slotů z učení PV delty (fáze 2 plánu kalibrace). +-- Plní collector podle režimu / registrů (145/179 apod.); dokud NULL, R__022 je ignoruje. + +ALTER TABLE ems.telemetry_inverter + ADD COLUMN IF NOT EXISTS is_export_limited boolean NULL, + ADD COLUMN IF NOT EXISTS pv_derating_flags int NULL; + +COMMENT ON COLUMN ems.telemetry_inverter.is_export_limited IS +'TRUE = interval indikuje omezení exportu / odpojení GEN (např. cut-off mikroinvertorů); fn_fill_forecast_accuracy může vyloučit slot z učení.'; + +COMMENT ON COLUMN ems.telemetry_inverter.pv_derating_flags IS +'Bitová maska nebo enum z režimu střídače (derating); <> 0 může vést k vyloučení slotu z učení delty.'; diff --git a/db/routines/R__022_fn_fill_forecast_accuracy.sql b/db/routines/R__022_fn_fill_forecast_accuracy.sql index a99d346..8880963 100644 --- a/db/routines/R__022_fn_fill_forecast_accuracy.sql +++ b/db/routines/R__022_fn_fill_forecast_accuracy.sql @@ -27,16 +27,16 @@ BEGIN / 3600.0, 2 ) AS lead_time_hours, CASE - WHEN v.is_curtailed_learning_slot THEN NULL + WHEN v.exclude_actual_for_learning THEN NULL ELSE slot.avg_actual_w::INT END AS actual_power_w, now() AS actual_filled_at, CASE - WHEN v.is_curtailed_learning_slot THEN NULL + WHEN v.exclude_actual_for_learning THEN NULL ELSE fpi.power_w - COALESCE(slot.avg_actual_w::INT, 0) END AS error_w, CASE - WHEN v.is_curtailed_learning_slot THEN NULL + WHEN v.exclude_actual_for_learning THEN NULL WHEN slot.avg_actual_w IS NOT NULL AND slot.avg_actual_w > 0 THEN ROUND( @@ -85,21 +85,34 @@ BEGIN AND l.new_state IS FALSE ) ) - ) AS is_curtailed_learning_slot + ) AS is_curtailed_learning_slot, + EXISTS ( + SELECT 1 + FROM ems.telemetry_inverter ti_d + WHERE ti_d.site_id = fpr.site_id + AND ti_d.measured_at >= fpi.interval_start + AND ti_d.measured_at < fpi.interval_start + INTERVAL '15 minutes' + AND ( + coalesce(ti_d.is_export_limited, false) IS TRUE + OR (ti_d.pv_derating_flags IS NOT NULL AND ti_d.pv_derating_flags <> 0) + ) + ) AS is_telemetry_derated_slot ) flags ON true LEFT JOIN LATERAL ( SELECT CASE WHEN flags.before_learn_cutoff THEN false WHEN flags.is_curtailed_learning_slot THEN false + WHEN flags.is_telemetry_derated_slot THEN false ELSE true END AS learning_eligible, CASE WHEN flags.before_learn_cutoff THEN 'before_delta_learn_min' + WHEN flags.is_telemetry_derated_slot THEN 'telemetry_derating' WHEN flags.is_curtailed_learning_slot THEN 'curtailment_or_export_cutoff' ELSE NULL END AS learning_exclude_reason, - flags.is_curtailed_learning_slot + (flags.is_curtailed_learning_slot OR flags.is_telemetry_derated_slot) AS exclude_actual_for_learning ) v ON true LEFT JOIN LATERAL ( SELECT AVG( @@ -133,7 +146,8 @@ $$; COMMENT ON FUNCTION ems.fn_fill_forecast_accuracy(INT, INT) IS 'Doplní skutečné hodnoty výroby do forecast_accuracy z telemetrie. learning_eligible / learning_exclude_reason: před delta_learn_min_ts (kalibrace site) se nepočítá do učení delty; -po pv_curtailment_policy_effective_from sloty s curtailment / gen cutoff / cutoff_switch_log (export off) mají NULL actual a jsou vyloučeny z učení. +po pv_curtailment_policy_effective_from sloty s curtailment / gen cutoff / cutoff_switch_log (export off) mají NULL actual a jsou vyloučeny z učení; +telemetrie: is_export_limited nebo pv_derating_flags <> 0 v okně slotu → stejné vyloučení (telemetry_derating). Volat každých 15 minut (spolu s audit_filler) pro inkrementální plnění. p_lookback_hours: kolik hodin zpět zpracovat (default 48h pro catch-up). Pro první backfill: SELECT ems.fn_fill_forecast_accuracy(2, 8760) -- 1 rok'; diff --git a/db/routines/R__046_fn_site_configuration.sql b/db/routines/R__046_fn_site_configuration.sql index 987c527..853c83c 100644 --- a/db/routines/R__046_fn_site_configuration.sql +++ b/db/routines/R__046_fn_site_configuration.sql @@ -234,6 +234,12 @@ as $fn$ ), '[]'::jsonb ), + 'pv_forecast_calibration', + ( + select to_jsonb(c.*) + from ems.site_pv_forecast_calibration c + where c.site_id = p_site_id + ), 'operational', jsonb_build_object( 'heartbeat_last_seen', diff --git a/db/views/R__058_vw_latest_telemetry.sql b/db/views/R__058_vw_latest_telemetry.sql index a56c448..b5528ba 100644 --- a/db/views/R__058_vw_latest_telemetry.sql +++ b/db/views/R__058_vw_latest_telemetry.sql @@ -27,7 +27,9 @@ SELECT DISTINCT ON (t.inverter_id) t.gen_port_power_w, t.batt_charge_today_wh, t.batt_discharge_today_wh, - t.run_state + t.run_state, + t.is_export_limited, + t.pv_derating_flags FROM ems.telemetry_inverter t JOIN ems.asset_inverter inv ON inv.id = t.inverter_id ORDER BY t.inverter_id, t.measured_at DESC; diff --git a/docs/04-modules/forecast.md b/docs/04-modules/forecast.md index 0c81881..e5c4288 100644 --- a/docs/04-modules/forecast.md +++ b/docs/04-modules/forecast.md @@ -99,6 +99,7 @@ def calculate_pv_power( - Default je `7` dní. - Endpoint `GET /api/v1/sites/{site_id}/forecast/pv?date=YYYY-MM-DD` vrací vždy poslední `ok` run per `(interval_start, pv_array_id)` (`DISTINCT ON`), takže UI nevidí duplikáty z historických běhů. - **Kalibrace delty:** `GET /api/v1/sites/{site_id}/forecast/pv-delta-profile?from=…&to=…` vrací JSON z `ems.fn_pv_forecast_delta_profile` (`deltas`, `deltas_by_array`, `delta_learn_min_ts` z `ems.site_pv_forecast_calibration`). Volitelné query parametry: `half_life_days`, `threshold_w`, `top_n_days`, `non_top_day_factor`, `day_weight_gamma` (NULL u numerických přepsání = hodnota z kalibrační tabulky / default funkce). +- **Úprava kalibrace z API:** `PATCH /api/v1/sites/{site_id}/configuration/pv-forecast-calibration` s JSON tělem (částečný update); odpověď je aktuální řádek kalibrace. Souhrn konfigurace v `GET …/configuration` obsahuje klíč `pv_forecast_calibration`. --- diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 44bdefc..25df299 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -27,7 +27,7 @@ - horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu). - **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). `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`, …). `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/05-todo.md b/docs/05-todo.md index efc10fd..e817b18 100644 --- a/docs/05-todo.md +++ b/docs/05-todo.md @@ -26,7 +26,7 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec | Popis | Kde | Kdo | |-------|-----|-----| -| Telemetrické flagy derating (místo heuristiky z `planning_interval`), volitelné rozšíření API o korigovaný výkon per `pv_array_id` v grafu. | `db/routines/R__022_fn_fill_forecast_accuracy.sql`, collector | programátor | +| Sloupce `telemetry_inverter.is_export_limited` / `pv_derating_flags` jsou v DB (V058) a `fn_fill_forecast_accuracy` je respektuje; **doplnit zápis z collectoru** (režim / reg 145/179). Volitelné rozšíření API o korigovaný výkon per `pv_array_id` v grafu. | `telemetry_collector`, `db/routines/R__022_fn_fill_forecast_accuracy.sql` | programátor | --- diff --git a/frontend/src/api/backend.ts b/frontend/src/api/backend.ts index 1b079d6..78053de 100644 --- a/frontend/src/api/backend.ts +++ b/frontend/src/api/backend.ts @@ -1,7 +1,10 @@ import axios, { type AxiosInstance } from 'axios' import type { FullStatusResponse } from '../types/fullStatus' -import type { SiteConfigurationResponse } from '../types/siteConfiguration' +import type { + SiteConfigurationResponse, + SitePvForecastCalibrationRow, +} from '../types/siteConfiguration' import type { Notification } from '../types/dashboard' import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan' @@ -59,6 +62,28 @@ export async function getSiteConfiguration(siteId: number): Promise { + const { data } = await client.patch( + `/sites/${siteId}/configuration/pv-forecast-calibration`, + payload, + ) + return data as SitePvForecastCalibrationRow +} + export type SiteNotificationsResponse = { notifications: Notification[] } diff --git a/frontend/src/types/siteConfiguration.ts b/frontend/src/types/siteConfiguration.ts index 3614ec4..6400441 100644 --- a/frontend/src/types/siteConfiguration.ts +++ b/frontend/src/types/siteConfiguration.ts @@ -32,6 +32,19 @@ export type SiteConfigurationOperational = { active_plan_created_at: string | null } +/** Řádek `ems.site_pv_forecast_calibration` (GET /configuration → `pv_forecast_calibration`). */ +export type SitePvForecastCalibrationRow = { + site_id: number + delta_learn_min_ts: string + pv_curtailment_policy_effective_from?: string | null + top_n_days?: number | null + non_top_day_factor?: number | string | null + day_weight_gamma?: number | string | null + half_life_days?: number | string | null + threshold_w?: number | null + updated_at?: string +} + export type SiteConfigurationResponse = { site: SiteConfigurationSite grid_connection: Record | null @@ -46,5 +59,7 @@ export type SiteConfigurationResponse = { heat_pumps: Record[] operating_mode: Record | null active_overrides: Record[] + /** Kalibrace PV delty; null pokud v DB chybí řádek (měl by existovat po V057). */ + pv_forecast_calibration?: SitePvForecastCalibrationRow | null operational: SiteConfigurationOperational }