dalsi uprava vypoctu delty (ignorujeme orezane vyroby)
This commit is contained in:
@@ -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). |
|
||||
|
||||
@@ -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,
|
||||
|
||||
12
db/migration/V058__telemetry_inverter_derating_flags.sql
Normal file
12
db/migration/V058__telemetry_inverter_derating_flags.sql
Normal file
@@ -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.';
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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<SiteConfigur
|
||||
return data
|
||||
}
|
||||
|
||||
/** PATCH /sites/{id}/configuration/pv-forecast-calibration — pouze uvedená pole. */
|
||||
export type PvForecastCalibrationPatchPayload = {
|
||||
delta_learn_min_ts?: string
|
||||
pv_curtailment_policy_effective_from?: string | null
|
||||
top_n_days?: number | null
|
||||
non_top_day_factor?: number | null
|
||||
day_weight_gamma?: number | null
|
||||
half_life_days?: number | null
|
||||
threshold_w?: number | null
|
||||
}
|
||||
|
||||
export async function patchPvForecastCalibration(
|
||||
siteId: number,
|
||||
payload: PvForecastCalibrationPatchPayload,
|
||||
): Promise<SitePvForecastCalibrationRow> {
|
||||
const { data } = await client.patch<SitePvForecastCalibrationRow>(
|
||||
`/sites/${siteId}/configuration/pv-forecast-calibration`,
|
||||
payload,
|
||||
)
|
||||
return data as SitePvForecastCalibrationRow
|
||||
}
|
||||
|
||||
export type SiteNotificationsResponse = {
|
||||
notifications: Notification[]
|
||||
}
|
||||
|
||||
@@ -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<string, unknown> | null
|
||||
@@ -46,5 +59,7 @@ export type SiteConfigurationResponse = {
|
||||
heat_pumps: Record<string, unknown>[]
|
||||
operating_mode: Record<string, unknown> | null
|
||||
active_overrides: Record<string, unknown>[]
|
||||
/** Kalibrace PV delty; null pokud v DB chybí řádek (měl by existovat po V057). */
|
||||
pv_forecast_calibration?: SitePvForecastCalibrationRow | null
|
||||
operational: SiteConfigurationOperational
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user