korkece fve predikce, grafy predikci
This commit is contained in:
@@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timedelta
|
from datetime import date, datetime, timedelta, timezone
|
||||||
from typing import Annotated, Any
|
from typing import Annotated, Any
|
||||||
|
|
||||||
import asyncpg
|
import asyncpg
|
||||||
@@ -522,3 +522,159 @@ async def get_site_forecast_pv_slots_range(
|
|||||||
if not isinstance(slots, list):
|
if not isinstance(slots, list):
|
||||||
slots = []
|
slots = []
|
||||||
return {"slots": slots}
|
return {"slots": slots}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{site_id}/forecast/pv-slots-corrected")
|
||||||
|
async def get_site_forecast_pv_slots_range_corrected(
|
||||||
|
site_id: int,
|
||||||
|
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
|
from_ts: datetime = Query(
|
||||||
|
...,
|
||||||
|
alias="from",
|
||||||
|
description="Začátek okna [from, to), typicky UTC zaokrouhlené na 15 min",
|
||||||
|
),
|
||||||
|
to_ts: datetime = Query(
|
||||||
|
...,
|
||||||
|
alias="to",
|
||||||
|
description="Konec polouzavřeného intervalu (max. cca 120 h za from)",
|
||||||
|
),
|
||||||
|
delta_from_ts: datetime | None = Query(
|
||||||
|
None,
|
||||||
|
alias="delta_from",
|
||||||
|
description="Začátek okna historie pro výpočet delta profilu (default: now-60d)",
|
||||||
|
),
|
||||||
|
delta_to_ts: datetime | None = Query(
|
||||||
|
None,
|
||||||
|
alias="delta_to",
|
||||||
|
description="Konec okna historie pro výpočet delta profilu (default: now)",
|
||||||
|
),
|
||||||
|
half_life_days: float = Query(
|
||||||
|
14,
|
||||||
|
ge=1,
|
||||||
|
le=90,
|
||||||
|
description="Half-life vážení (dny) pro delta profil",
|
||||||
|
),
|
||||||
|
threshold_w: int = Query(
|
||||||
|
150,
|
||||||
|
ge=0,
|
||||||
|
le=10_000,
|
||||||
|
description="Ignorovat sloty s nízkou výrobou (W) při odhadu profilu",
|
||||||
|
),
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
if to_ts <= from_ts:
|
||||||
|
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||||
|
if to_ts - from_ts > timedelta(hours=120):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Span between 'from' and 'to' must be at most 120 hours",
|
||||||
|
)
|
||||||
|
now = datetime.now(tz=timezone.utc)
|
||||||
|
delta_to = delta_to_ts or now
|
||||||
|
delta_from = delta_from_ts or (delta_to - timedelta(days=60))
|
||||||
|
async with db.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")
|
||||||
|
raw = await fetch_json(
|
||||||
|
conn,
|
||||||
|
"""
|
||||||
|
select ems.fn_forecast_pv_slots_range_corrected(
|
||||||
|
$1::int,
|
||||||
|
$2::timestamptz,
|
||||||
|
$3::timestamptz,
|
||||||
|
$4::timestamptz,
|
||||||
|
$5::timestamptz,
|
||||||
|
$6::numeric,
|
||||||
|
$7::int
|
||||||
|
)
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
from_ts,
|
||||||
|
to_ts,
|
||||||
|
delta_from,
|
||||||
|
delta_to,
|
||||||
|
half_life_days,
|
||||||
|
threshold_w,
|
||||||
|
)
|
||||||
|
slots = raw if isinstance(raw, list) else []
|
||||||
|
if not isinstance(slots, list):
|
||||||
|
slots = []
|
||||||
|
return {"slots": [s for s in slots if isinstance(s, dict)]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{site_id}/timeseries/telemetry-15m")
|
||||||
|
async def get_site_telemetry_15m_range(
|
||||||
|
site_id: int,
|
||||||
|
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
|
from_ts: datetime = Query(..., alias="from", description="Začátek okna [from, to)"),
|
||||||
|
to_ts: datetime = Query(..., alias="to", description="Konec okna [from, to)"),
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
if to_ts <= from_ts:
|
||||||
|
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||||
|
if to_ts - from_ts > timedelta(days=60):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Span between 'from' and 'to' must be at most 60 days",
|
||||||
|
)
|
||||||
|
async with db.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")
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
select
|
||||||
|
slot_start,
|
||||||
|
site_id,
|
||||||
|
avg_pv_w,
|
||||||
|
avg_load_w,
|
||||||
|
avg_grid_w,
|
||||||
|
avg_battery_w,
|
||||||
|
last_soc_pct,
|
||||||
|
sample_count
|
||||||
|
from ems.telemetry_inverter_15m
|
||||||
|
where site_id = $1
|
||||||
|
and slot_start >= $2::timestamptz
|
||||||
|
and slot_start < $3::timestamptz
|
||||||
|
order by slot_start asc
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
from_ts,
|
||||||
|
to_ts,
|
||||||
|
)
|
||||||
|
return {"slots": [record_to_dict(r) for r in rows]}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{site_id}/forecast/load-baseline-slots")
|
||||||
|
async def get_site_load_baseline_slots_range(
|
||||||
|
site_id: int,
|
||||||
|
db: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
|
from_ts: datetime = Query(..., alias="from", description="Začátek okna [from, to)"),
|
||||||
|
to_ts: datetime = Query(..., alias="to", description="Konec okna [from, to)"),
|
||||||
|
) -> dict[str, list[dict[str, Any]]]:
|
||||||
|
if to_ts <= from_ts:
|
||||||
|
raise HTTPException(status_code=422, detail="'to' must be after 'from'")
|
||||||
|
if to_ts - from_ts > timedelta(days=60):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=422,
|
||||||
|
detail="Span between 'from' and 'to' must be at most 60 days",
|
||||||
|
)
|
||||||
|
async with db.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")
|
||||||
|
rows = await conn.fetch(
|
||||||
|
"""
|
||||||
|
select interval_start, forecast_w, confidence_w
|
||||||
|
from ems.fn_get_baseline_forecast($1::int, $2::timestamptz, $3::timestamptz)
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
from_ts,
|
||||||
|
to_ts,
|
||||||
|
)
|
||||||
|
return {"slots": [record_to_dict(r) for r in rows]}
|
||||||
|
|||||||
121
db/routines/R__078_fn_pv_forecast_delta_profile.sql
Normal file
121
db/routines/R__078_fn_pv_forecast_delta_profile.sql
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- Profil systematické chyby PV forecastu po 15min slotu dne
|
||||||
|
-- (aditivní korekce: corrected = max(0, forecast - delta[slot]))
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
create or replace function ems.fn_pv_forecast_delta_profile(
|
||||||
|
p_site_id int,
|
||||||
|
p_data_from timestamptz,
|
||||||
|
p_data_to timestamptz default now(),
|
||||||
|
p_half_life_days numeric default 14,
|
||||||
|
p_threshold_w int default 150
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
with tz as (
|
||||||
|
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') as tz_name
|
||||||
|
from ems.site s
|
||||||
|
where s.id = p_site_id
|
||||||
|
),
|
||||||
|
-- Cutoff z analýzy DB (EMS Postgres): u site_id=2 (`home-01`) začíná být
|
||||||
|
-- `forecast_accuracy.actual_power_w` spolehlivě vyplněné pro celé kalendářní dny
|
||||||
|
-- od 2026-04-06 (Europe/Prague). Dřívší dny mají výrazně nižší podíl slotů s actual
|
||||||
|
-- (částečný backfill / výpadky) a zkreslují delta profil.
|
||||||
|
cutoff as (
|
||||||
|
select timestamptz '2026-04-05T22:00:00Z' as min_ts
|
||||||
|
),
|
||||||
|
bounds as (
|
||||||
|
select
|
||||||
|
greatest(p_data_from, p_data_to - interval '120 days', (select min_ts from cutoff)) as ts_from,
|
||||||
|
p_data_to as ts_to,
|
||||||
|
greatest(p_half_life_days, 1) as half_life_days,
|
||||||
|
greatest(p_threshold_w, 0) as threshold_w
|
||||||
|
),
|
||||||
|
-- vezmeme jeden „reprezentativní“ forecast z historie: pro každý interval_start a pv_array_id
|
||||||
|
-- vybereme nejnovější forecast (forecast_created_at) který je <= interval_start (lead_time >= 0)
|
||||||
|
best as (
|
||||||
|
select
|
||||||
|
fa.interval_start,
|
||||||
|
fa.pv_array_id,
|
||||||
|
fa.forecast_power_w,
|
||||||
|
fa.actual_power_w,
|
||||||
|
fa.forecast_created_at,
|
||||||
|
row_number() over (
|
||||||
|
partition by fa.interval_start, fa.pv_array_id
|
||||||
|
order by fa.forecast_created_at desc
|
||||||
|
) as rn
|
||||||
|
from ems.forecast_accuracy fa
|
||||||
|
cross join bounds b
|
||||||
|
where fa.site_id = p_site_id
|
||||||
|
and fa.interval_start >= b.ts_from
|
||||||
|
and fa.interval_start < b.ts_to
|
||||||
|
and fa.actual_power_w is not null
|
||||||
|
and fa.forecast_created_at <= fa.interval_start
|
||||||
|
),
|
||||||
|
slots as (
|
||||||
|
select
|
||||||
|
b.interval_start,
|
||||||
|
sum(b.forecast_power_w)::numeric as forecast_total_w,
|
||||||
|
sum(b.actual_power_w)::numeric as actual_total_w,
|
||||||
|
(
|
||||||
|
(extract(hour from (b.interval_start at time zone tz.tz_name))::int * 60)
|
||||||
|
+ extract(minute from (b.interval_start at time zone tz.tz_name))::int
|
||||||
|
) / 15 as slot_of_day,
|
||||||
|
extract(epoch from (now() - b.interval_start)) / 86400.0 as age_days
|
||||||
|
from best b
|
||||||
|
cross join tz
|
||||||
|
where b.rn = 1
|
||||||
|
group by b.interval_start, slot_of_day, tz.tz_name
|
||||||
|
),
|
||||||
|
filtered as (
|
||||||
|
select
|
||||||
|
s.slot_of_day,
|
||||||
|
(s.forecast_total_w - s.actual_total_w) as error_w,
|
||||||
|
exp(-s.age_days / nullif((select half_life_days from bounds), 0)) as w
|
||||||
|
from slots s
|
||||||
|
cross join bounds b
|
||||||
|
where s.slot_of_day between 0 and 95
|
||||||
|
and (s.actual_total_w > b.threshold_w or s.forecast_total_w > b.threshold_w)
|
||||||
|
),
|
||||||
|
agg as (
|
||||||
|
select
|
||||||
|
slot_of_day,
|
||||||
|
count(*) as sample_count,
|
||||||
|
sum(w) as w_sum,
|
||||||
|
case
|
||||||
|
when sum(w) > 0 then sum(error_w * w) / sum(w)
|
||||||
|
else null
|
||||||
|
end as delta_w
|
||||||
|
from filtered
|
||||||
|
group by slot_of_day
|
||||||
|
),
|
||||||
|
spine as (
|
||||||
|
select generate_series(0, 95) as slot_of_day
|
||||||
|
)
|
||||||
|
select jsonb_build_object(
|
||||||
|
'site_id', p_site_id,
|
||||||
|
'data_from', (select ts_from from bounds),
|
||||||
|
'data_to', (select ts_to from bounds),
|
||||||
|
'half_life_days', (select half_life_days from bounds),
|
||||||
|
'threshold_w', (select threshold_w from bounds),
|
||||||
|
'deltas',
|
||||||
|
coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'slot_of_day', sp.slot_of_day,
|
||||||
|
'delta_w', coalesce(round(a.delta_w)::int, 0),
|
||||||
|
'sample_count', coalesce(a.sample_count, 0)
|
||||||
|
)
|
||||||
|
order by sp.slot_of_day
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
)
|
||||||
|
from spine sp
|
||||||
|
left join agg a on a.slot_of_day = sp.slot_of_day;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_pv_forecast_delta_profile(int, timestamptz, timestamptz, numeric, int) is
|
||||||
|
'Aditivní delta profil chyby PV forecastu po 15min slotu dne (96 slotů). Zdroj: forecast_accuracy, vážení exp(-age/half_life_days). Vrací JSON {deltas:[{slot_of_day, delta_w, sample_count}], ...}. Interní minimální cutoff dat (2026-04-06 Europe/Prague) brání učení z nekonzistentní historie před kompletním plněním actual.';
|
||||||
123
db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql
Normal file
123
db/routines/R__079_fn_forecast_pv_slots_range_corrected.sql
Normal file
@@ -0,0 +1,123 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- PV forecast sloty (15min) + aditivně korigovaný forecast
|
||||||
|
-- corrected = max(0, forecast - delta_profile[slot_of_day])
|
||||||
|
-- ============================================================
|
||||||
|
|
||||||
|
create or replace function ems.fn_forecast_pv_slots_range_corrected(
|
||||||
|
p_site_id int,
|
||||||
|
p_from timestamptz,
|
||||||
|
p_to timestamptz,
|
||||||
|
p_delta_data_from timestamptz,
|
||||||
|
p_delta_data_to timestamptz default now(),
|
||||||
|
p_half_life_days numeric default 14,
|
||||||
|
p_threshold_w int default 150
|
||||||
|
)
|
||||||
|
returns jsonb
|
||||||
|
language sql
|
||||||
|
stable
|
||||||
|
as $fn$
|
||||||
|
with tz as (
|
||||||
|
select coalesce(nullif(trim(s.timezone), ''), 'Europe/Prague') as tz_name
|
||||||
|
from ems.site s
|
||||||
|
where s.id = p_site_id
|
||||||
|
),
|
||||||
|
bounds as (
|
||||||
|
select
|
||||||
|
p_from as ts_from,
|
||||||
|
case
|
||||||
|
when p_to <= p_from then p_from + interval '15 minutes'
|
||||||
|
when p_to > p_from + interval '120 hours' then p_from + interval '120 hours'
|
||||||
|
else p_to
|
||||||
|
end as ts_to
|
||||||
|
),
|
||||||
|
slot_spine as (
|
||||||
|
select gs as interval_start
|
||||||
|
from bounds b,
|
||||||
|
generate_series(
|
||||||
|
b.ts_from,
|
||||||
|
(b.ts_to - interval '15 minutes')::timestamptz,
|
||||||
|
interval '15 minutes'
|
||||||
|
) as gs
|
||||||
|
),
|
||||||
|
fc as (
|
||||||
|
select
|
||||||
|
u.interval_start,
|
||||||
|
coalesce(sum(u.power_w), 0)::bigint as pv_forecast_total_w
|
||||||
|
from (
|
||||||
|
select distinct on (fpi.interval_start, fpr.pv_array_id)
|
||||||
|
fpi.interval_start,
|
||||||
|
fpi.power_w
|
||||||
|
from ems.forecast_pv_interval fpi
|
||||||
|
join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
|
||||||
|
join ems.asset_pv_array apa
|
||||||
|
on apa.id = fpr.pv_array_id
|
||||||
|
and apa.site_id = fpr.site_id
|
||||||
|
cross join bounds b
|
||||||
|
where fpr.site_id = p_site_id
|
||||||
|
and fpr.status = 'ok'
|
||||||
|
and fpi.interval_start >= b.ts_from
|
||||||
|
and fpi.interval_start < b.ts_to
|
||||||
|
order by fpi.interval_start, fpr.pv_array_id, fpr.created_at desc
|
||||||
|
) u
|
||||||
|
group by u.interval_start
|
||||||
|
),
|
||||||
|
profile as (
|
||||||
|
select ems.fn_pv_forecast_delta_profile(
|
||||||
|
p_site_id,
|
||||||
|
p_delta_data_from,
|
||||||
|
p_delta_data_to,
|
||||||
|
p_half_life_days,
|
||||||
|
p_threshold_w
|
||||||
|
) as j
|
||||||
|
),
|
||||||
|
deltas as (
|
||||||
|
select
|
||||||
|
(x->>'slot_of_day')::int as slot_of_day,
|
||||||
|
(x->>'delta_w')::int as delta_w,
|
||||||
|
(x->>'sample_count')::int as sample_count
|
||||||
|
from profile p
|
||||||
|
cross join lateral jsonb_array_elements(p.j->'deltas') as x
|
||||||
|
)
|
||||||
|
select coalesce(
|
||||||
|
jsonb_agg(
|
||||||
|
jsonb_build_object(
|
||||||
|
'interval_start', s.interval_start,
|
||||||
|
'pv_forecast_total_w', coalesce(fc.pv_forecast_total_w, 0),
|
||||||
|
'pv_forecast_corrected_w',
|
||||||
|
greatest(
|
||||||
|
0,
|
||||||
|
coalesce(fc.pv_forecast_total_w, 0)::int
|
||||||
|
- coalesce(
|
||||||
|
(
|
||||||
|
select d.delta_w
|
||||||
|
from deltas d
|
||||||
|
cross join tz
|
||||||
|
where d.slot_of_day = (
|
||||||
|
(
|
||||||
|
(extract(hour from (s.interval_start at time zone tz.tz_name))::int * 60)
|
||||||
|
+ extract(minute from (s.interval_start at time zone tz.tz_name))::int
|
||||||
|
) / 15
|
||||||
|
)
|
||||||
|
),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
),
|
||||||
|
'slot_of_day',
|
||||||
|
(
|
||||||
|
(
|
||||||
|
(extract(hour from (s.interval_start at time zone tz.tz_name))::int * 60)
|
||||||
|
+ extract(minute from (s.interval_start at time zone tz.tz_name))::int
|
||||||
|
) / 15
|
||||||
|
)
|
||||||
|
)
|
||||||
|
order by s.interval_start
|
||||||
|
),
|
||||||
|
'[]'::jsonb
|
||||||
|
)
|
||||||
|
from slot_spine s
|
||||||
|
cross join tz
|
||||||
|
left join fc on fc.interval_start = s.interval_start;
|
||||||
|
$fn$;
|
||||||
|
|
||||||
|
comment on function ems.fn_forecast_pv_slots_range_corrected(int, timestamptz, timestamptz, timestamptz, timestamptz, numeric, int) is
|
||||||
|
'JSON pole {interval_start, pv_forecast_total_w, pv_forecast_corrected_w, slot_of_day} po 15 min pro [p_from, p_to). Korekce je aditivní delta profil z fn_pv_forecast_delta_profile.';
|
||||||
@@ -18,6 +18,7 @@ Shrnutí otevřených bodů z `docs/06-open-questions.md`, checklistů v modulec
|
|||||||
| **Telemetry – výroba FVE:** Registry 672/673/667 jsou **signed** W; `pv_power_w` = max(0,pv1)+max(0,pv2)+max(0,gen) (dashboard); sloupce pv1/pv2/gen ukládají signed pro audit. |
|
| **Telemetry – výroba FVE:** Registry 672/673/667 jsou **signed** W; `pv_power_w` = max(0,pv1)+max(0,pv2)+max(0,gen) (dashboard); sloupce pv1/pv2/gen ukládají signed pro audit. |
|
||||||
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
|
| **Ekonomika baterie:** snížení `reserve_soc_percent` na 10 % a `degradation_cost_czk_kwh` na 0.1500 (migrace `V026__battery_economics_tuning.sql`), úpravy objective pro ekonomicky konzistentnější nabíjení/vybíjení. |
|
||||||
| **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. |
|
| **Planning UI operátor akce:** trvale viditelné akce import/forecast/init plan, volba data OTE (dnes/zítra), zobrazení `pv_scarcity_factor` ve stavu plánu. |
|
||||||
|
| **PV delta profil – cutoff historie:** po analýze `ems.forecast_accuracy` pro `home-01` je minimální spolehlivý začátek učení **2026-04-06 (Europe/Prague)** (UTC `2026-04-05T22:00:00Z`); cutoff je zafixovaný v `db/routines/R__078_fn_pv_forecast_delta_profile.sql` (ignoruje starší data i při širším `p_data_from`). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { useWsLogErrorCount } from './hooks/useWsLogErrorCount'
|
|||||||
import { Dashboard } from './pages/Dashboard'
|
import { Dashboard } from './pages/Dashboard'
|
||||||
import Economics from './pages/Economics'
|
import Economics from './pages/Economics'
|
||||||
import EnergyFlows from './pages/EnergyFlows'
|
import EnergyFlows from './pages/EnergyFlows'
|
||||||
|
import ForecastVsActual from './pages/ForecastVsActual'
|
||||||
import { Logs } from './pages/Logs'
|
import { Logs } from './pages/Logs'
|
||||||
import Planning from './pages/Planning'
|
import Planning from './pages/Planning'
|
||||||
import SiteConfiguration from './pages/SiteConfiguration'
|
import SiteConfiguration from './pages/SiteConfiguration'
|
||||||
@@ -70,6 +71,9 @@ function AppLayout() {
|
|||||||
<NavLink to="/planning" className={tabClass}>
|
<NavLink to="/planning" className={tabClass}>
|
||||||
Plánování
|
Plánování
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
<NavLink to="/forecast-vs-actual" className={tabClass}>
|
||||||
|
Srovnání
|
||||||
|
</NavLink>
|
||||||
<NavLink to="/economics" className={tabClass}>
|
<NavLink to="/economics" className={tabClass}>
|
||||||
Ekonomika
|
Ekonomika
|
||||||
</NavLink>
|
</NavLink>
|
||||||
@@ -111,6 +115,7 @@ export default function App() {
|
|||||||
<Route element={<AppLayout />}>
|
<Route element={<AppLayout />}>
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="planning" element={<Planning />} />
|
<Route path="planning" element={<Planning />} />
|
||||||
|
<Route path="forecast-vs-actual" element={<ForecastVsActual />} />
|
||||||
<Route path="economics" element={<Economics />} />
|
<Route path="economics" element={<Economics />} />
|
||||||
<Route path="energy-flows" element={<EnergyFlows />} />
|
<Route path="energy-flows" element={<EnergyFlows />} />
|
||||||
<Route path="site-config" element={<SiteConfiguration />} />
|
<Route path="site-config" element={<SiteConfiguration />} />
|
||||||
|
|||||||
@@ -117,6 +117,74 @@ export async function getForecastPvSlotsRange(
|
|||||||
return Array.isArray(data?.slots) ? data.slots : []
|
return Array.isArray(data?.slots) ? data.slots : []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type ForecastPvSlotCorrectedRow = {
|
||||||
|
interval_start: string
|
||||||
|
pv_forecast_total_w?: number | null
|
||||||
|
pv_forecast_corrected_w?: number | null
|
||||||
|
slot_of_day?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ForecastPvSlotsCorrectedParams = {
|
||||||
|
delta_from?: string
|
||||||
|
delta_to?: string
|
||||||
|
half_life_days?: number
|
||||||
|
threshold_w?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getForecastPvSlotsRangeCorrected(
|
||||||
|
siteId: number,
|
||||||
|
fromIso: string,
|
||||||
|
toIso: string,
|
||||||
|
params?: ForecastPvSlotsCorrectedParams,
|
||||||
|
): Promise<ForecastPvSlotCorrectedRow[]> {
|
||||||
|
const { data } = await client.get<{ slots?: ForecastPvSlotCorrectedRow[] }>(
|
||||||
|
`/sites/${siteId}/forecast/pv-slots-corrected`,
|
||||||
|
{ params: { from: fromIso, to: toIso, ...params }, timeout: 45_000 },
|
||||||
|
)
|
||||||
|
return Array.isArray(data?.slots) ? data.slots : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export type Telemetry15mRow = {
|
||||||
|
slot_start: string
|
||||||
|
site_id: number
|
||||||
|
avg_pv_w?: number | null
|
||||||
|
avg_load_w?: number | null
|
||||||
|
avg_grid_w?: number | null
|
||||||
|
avg_battery_w?: number | null
|
||||||
|
last_soc_pct?: number | null
|
||||||
|
sample_count?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getTelemetry15mRange(
|
||||||
|
siteId: number,
|
||||||
|
fromIso: string,
|
||||||
|
toIso: string,
|
||||||
|
): Promise<Telemetry15mRow[]> {
|
||||||
|
const { data } = await client.get<{ slots?: Telemetry15mRow[] }>(`/sites/${siteId}/timeseries/telemetry-15m`, {
|
||||||
|
params: { from: fromIso, to: toIso },
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
return Array.isArray(data?.slots) ? data.slots : []
|
||||||
|
}
|
||||||
|
|
||||||
|
export type BaselineLoadSlotRow = {
|
||||||
|
interval_start: string
|
||||||
|
forecast_w: number
|
||||||
|
confidence_w?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getBaselineLoadSlotsRange(
|
||||||
|
siteId: number,
|
||||||
|
fromIso: string,
|
||||||
|
toIso: string,
|
||||||
|
): Promise<BaselineLoadSlotRow[]> {
|
||||||
|
const { data } = await client.get<{ slots?: BaselineLoadSlotRow[] }>(
|
||||||
|
`/sites/${siteId}/forecast/load-baseline-slots`,
|
||||||
|
{ params: { from: fromIso, to: toIso }, timeout: 60_000 },
|
||||||
|
)
|
||||||
|
return Array.isArray(data?.slots) ? data.slots : []
|
||||||
|
}
|
||||||
|
|
||||||
/** GET /api/v1/sites/{id}/prices?date=YYYY-MM-DD */
|
/** GET /api/v1/sites/{id}/prices?date=YYYY-MM-DD */
|
||||||
export type SiteEffectivePriceRowDto = {
|
export type SiteEffectivePriceRowDto = {
|
||||||
site_id: number
|
site_id: number
|
||||||
|
|||||||
@@ -31,11 +31,18 @@ function sumW(a: number | null, b: number | null): number | null {
|
|||||||
return (a ?? 0) + (b ?? 0)
|
return (a ?? 0) + (b ?? 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
export type EnergyLegendItem = { key: string; label: string; color: string; dashed?: boolean }
|
export type EnergyLegendItem = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
color: string
|
||||||
|
dashed?: boolean
|
||||||
|
dashStyle?: 'dashed' | 'dotted'
|
||||||
|
}
|
||||||
|
|
||||||
export const ENERGY_LEGEND: EnergyLegendItem[] = [
|
export const ENERGY_LEGEND: EnergyLegendItem[] = [
|
||||||
{ key: 'fve_real', label: 'FVE skutečnost', color: COL.fve },
|
{ key: 'fve_real', label: 'FVE skutečnost', color: COL.fve },
|
||||||
{ key: 'fve_pred', label: 'FVE předpověď', color: COL.fve, dashed: true },
|
{ key: 'fve_pred', label: 'FVE předpověď', color: COL.fve, dashed: true },
|
||||||
|
{ key: 'fve_corr', label: 'FVE korigovaná', color: COL.fve, dashed: true, dashStyle: 'dotted' },
|
||||||
{ key: 'baz_real', label: 'Spotřeba skutečnost', color: COL.baz },
|
{ key: 'baz_real', label: 'Spotřeba skutečnost', color: COL.baz },
|
||||||
{ key: 'baz_pred', label: 'Spotřeba předpověď', color: COL.baz, dashed: true },
|
{ key: 'baz_pred', label: 'Spotřeba předpověď', color: COL.baz, dashed: true },
|
||||||
{ key: 'ev', label: 'EV plán', color: COL.ev },
|
{ key: 'ev', label: 'EV plán', color: COL.ev },
|
||||||
@@ -93,6 +100,9 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
|||||||
const series = useMemo(() => {
|
const series = useMemo(() => {
|
||||||
const fveReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.pv_power_w) : null))
|
const fveReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.pv_power_w) : null))
|
||||||
const fvePred = slots.map((s) => kwFromW(sumW(s.pv_a_forecast_w, s.pv_b_forecast_w)))
|
const fvePred = slots.map((s) => kwFromW(sumW(s.pv_a_forecast_w, s.pv_b_forecast_w)))
|
||||||
|
const fveCorr = slots.map((s) =>
|
||||||
|
kwFromW(s.pv_forecast_corrected_w ?? sumW(s.pv_a_forecast_w, s.pv_b_forecast_w)),
|
||||||
|
)
|
||||||
const bazReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.load_power_w) : null))
|
const bazReal = slots.map((s, i) => (i <= nowIndex ? kwFromW(s.load_power_w) : null))
|
||||||
const bazPred = slots.map((s) => kwFromW(s.load_baseline_w))
|
const bazPred = slots.map((s) => kwFromW(s.load_baseline_w))
|
||||||
const ev = slots.map((s) => kwFromW(sumW(s.ev1_setpoint_w, s.ev2_setpoint_w)))
|
const ev = slots.map((s) => kwFromW(sumW(s.ev1_setpoint_w, s.ev2_setpoint_w)))
|
||||||
@@ -105,7 +115,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
|||||||
)
|
)
|
||||||
const buy = slots.map((s) => (s.buy_price == null ? null : s.buy_price))
|
const buy = slots.map((s) => (s.buy_price == null ? null : s.buy_price))
|
||||||
const sell = slots.map((s) => (s.sell_price == null ? null : s.sell_price))
|
const sell = slots.map((s) => (s.sell_price == null ? null : s.sell_price))
|
||||||
return { fveReal, fvePred, bazReal, bazPred, ev, tc, bat, sit, buy, sell }
|
return { fveReal, fvePred, fveCorr, bazReal, bazPred, ev, tc, bat, sit, buy, sell }
|
||||||
}, [slots, nowIndex])
|
}, [slots, nowIndex])
|
||||||
|
|
||||||
const bgPlugin = useMemo(
|
const bgPlugin = useMemo(
|
||||||
@@ -126,6 +136,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
|||||||
opts: {
|
opts: {
|
||||||
fill?: boolean | 'origin'
|
fill?: boolean | 'origin'
|
||||||
dashed?: boolean
|
dashed?: boolean
|
||||||
|
dash?: number[]
|
||||||
yAxisID?: string
|
yAxisID?: string
|
||||||
order: number
|
order: number
|
||||||
borderWidth?: number
|
borderWidth?: number
|
||||||
@@ -137,7 +148,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
|||||||
backgroundColor:
|
backgroundColor:
|
||||||
opts.fill === true ? `${color}33` : opts.fill === 'origin' ? `${color}40` : undefined,
|
opts.fill === true ? `${color}33` : opts.fill === 'origin' ? `${color}40` : undefined,
|
||||||
fill: opts.fill ?? false,
|
fill: opts.fill ?? false,
|
||||||
borderDash: opts.dashed ? [5, 4] : undefined,
|
borderDash: opts.dash ?? (opts.dashed ? [5, 4] : undefined),
|
||||||
borderWidth: opts.borderWidth ?? (opts.dashed ? 1 : 1.2),
|
borderWidth: opts.borderWidth ?? (opts.dashed ? 1 : 1.2),
|
||||||
pointRadius: 0,
|
pointRadius: 0,
|
||||||
hitRadius: 6,
|
hitRadius: 6,
|
||||||
@@ -161,6 +172,12 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
|||||||
mkDs('fve_real', 'FVE ■', series.fveReal, COL.fve, { fill: true, order: 7 }),
|
mkDs('fve_real', 'FVE ■', series.fveReal, COL.fve, { fill: true, order: 7 }),
|
||||||
mkDs('baz_pred', 'Spotřeba ···', series.bazPred, COL.baz, { dashed: true, order: 8 }),
|
mkDs('baz_pred', 'Spotřeba ···', series.bazPred, COL.baz, { dashed: true, order: 8 }),
|
||||||
mkDs('fve_pred', 'FVE ···', series.fvePred, COL.fve, { dashed: true, order: 9 }),
|
mkDs('fve_pred', 'FVE ···', series.fvePred, COL.fve, { dashed: true, order: 9 }),
|
||||||
|
mkDs('fve_corr', 'FVE (korig.)', series.fveCorr, COL.fve, {
|
||||||
|
dashed: true,
|
||||||
|
dash: [2, 3],
|
||||||
|
order: 9,
|
||||||
|
borderWidth: 1,
|
||||||
|
}),
|
||||||
mkDs('buy_price', 'Nákup', series.buy, COL.buy, {
|
mkDs('buy_price', 'Nákup', series.buy, COL.buy, {
|
||||||
dashed: true,
|
dashed: true,
|
||||||
yAxisID: 'y1',
|
yAxisID: 'y1',
|
||||||
@@ -267,6 +284,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
|||||||
s.fveReal,
|
s.fveReal,
|
||||||
s.bazPred,
|
s.bazPred,
|
||||||
s.fvePred,
|
s.fvePred,
|
||||||
|
s.fveCorr,
|
||||||
s.buy,
|
s.buy,
|
||||||
s.sell,
|
s.sell,
|
||||||
]
|
]
|
||||||
@@ -290,6 +308,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
|||||||
'fve_real',
|
'fve_real',
|
||||||
'baz_pred',
|
'baz_pred',
|
||||||
'fve_pred',
|
'fve_pred',
|
||||||
|
'fve_corr',
|
||||||
'buy_price',
|
'buy_price',
|
||||||
'sell_price',
|
'sell_price',
|
||||||
] as const
|
] as const
|
||||||
@@ -326,7 +345,7 @@ export function EnergyChart({ slots, nowIndex, hidden, onToggle, onChartArea }:
|
|||||||
className="h-2.5 w-4 shrink-0 rounded-sm border border-white/10"
|
className="h-2.5 w-4 shrink-0 rounded-sm border border-white/10"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: off ? 'transparent' : item.color,
|
backgroundColor: off ? 'transparent' : item.color,
|
||||||
borderStyle: item.dashed ? 'dashed' : 'solid',
|
borderStyle: item.dashStyle === 'dotted' ? 'dotted' : item.dashed ? 'dashed' : 'solid',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
{item.label}
|
{item.label}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
getCurrentPlan,
|
getCurrentPlan,
|
||||||
getSiteForecastPv,
|
getSiteForecastPv,
|
||||||
getSitePrices,
|
getSitePrices,
|
||||||
|
getForecastPvSlotsRangeCorrected,
|
||||||
type SiteEffectivePriceRowDto,
|
type SiteEffectivePriceRowDto,
|
||||||
} from '../api/backend'
|
} from '../api/backend'
|
||||||
import { getJson } from '../api/postgrest'
|
import { getJson } from '../api/postgrest'
|
||||||
@@ -279,6 +280,20 @@ export function useDashboardData(siteId: number | null) {
|
|||||||
if (!fc) continue
|
if (!fc) continue
|
||||||
addForecastToByStart(fc, forecastBySlot)
|
addForecastToByStart(fc, forecastBySlot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const windowFromIso = new Date(windowStart).toISOString()
|
||||||
|
const windowToIso = new Date(windowStart + TOTAL_SLOTS * SLOT_MS).toISOString()
|
||||||
|
const correctedSlots = await getForecastPvSlotsRangeCorrected(siteId, windowFromIso, windowToIso).catch(
|
||||||
|
() => [] as Awaited<ReturnType<typeof getForecastPvSlotsRangeCorrected>>,
|
||||||
|
)
|
||||||
|
const correctedBySlot = new Map<string, number>()
|
||||||
|
for (const r of correctedSlots) {
|
||||||
|
const t = new Date(r.interval_start).getTime()
|
||||||
|
if (!Number.isFinite(t)) continue
|
||||||
|
const v = r.pv_forecast_corrected_w
|
||||||
|
if (v == null) continue
|
||||||
|
correctedBySlot.set(slotTimeKey(t), Number(v))
|
||||||
|
}
|
||||||
for (const ymd of weekDates) {
|
for (const ymd of weekDates) {
|
||||||
const fc = forecastByYmd.get(ymd) ?? null
|
const fc = forecastByYmd.get(ymd) ?? null
|
||||||
if (!fc) {
|
if (!fc) {
|
||||||
@@ -364,6 +379,10 @@ export function useDashboardData(siteId: number | null) {
|
|||||||
base.pv_a_forecast_w = fc.a
|
base.pv_a_forecast_w = fc.a
|
||||||
base.pv_b_forecast_w = fc.b
|
base.pv_b_forecast_w = fc.b
|
||||||
}
|
}
|
||||||
|
const corr = correctedBySlot.get(k)
|
||||||
|
if (corr != null) {
|
||||||
|
base.pv_forecast_corrected_w = corr
|
||||||
|
}
|
||||||
|
|
||||||
const pi = planBySlot.get(k)
|
const pi = planBySlot.get(k)
|
||||||
if (pi) mergeInterval(base, pi)
|
if (pi) mergeInterval(base, pi)
|
||||||
|
|||||||
300
frontend/src/pages/ForecastVsActual.tsx
Normal file
300
frontend/src/pages/ForecastVsActual.tsx
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
|
import {
|
||||||
|
CartesianGrid,
|
||||||
|
Legend,
|
||||||
|
Line,
|
||||||
|
LineChart,
|
||||||
|
ResponsiveContainer,
|
||||||
|
Tooltip,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
} from 'recharts'
|
||||||
|
|
||||||
|
import {
|
||||||
|
getBaselineLoadSlotsRange,
|
||||||
|
getForecastPvSlotsRangeCorrected,
|
||||||
|
getTelemetry15mRange,
|
||||||
|
type BaselineLoadSlotRow,
|
||||||
|
type ForecastPvSlotCorrectedRow,
|
||||||
|
type Telemetry15mRow,
|
||||||
|
} from '../api/backend'
|
||||||
|
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||||
|
import { instantPragueDay } from '../lib/pragueDate'
|
||||||
|
|
||||||
|
type MetricKey = 'pv' | 'load' | 'grid'
|
||||||
|
|
||||||
|
type Point = {
|
||||||
|
k: string
|
||||||
|
timeLabel: string
|
||||||
|
actual_kw: number | null
|
||||||
|
forecast_kw: number | null
|
||||||
|
corrected_kw: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
function kwFromW(w: number | null | undefined): number | null {
|
||||||
|
if (w == null || Number.isNaN(Number(w))) return null
|
||||||
|
return Number(w) / 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDayLabel(ymd: string): string {
|
||||||
|
return new Date(ymd + 'T12:00:00Z').toLocaleDateString('cs-CZ', {
|
||||||
|
weekday: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'numeric',
|
||||||
|
timeZone: 'Europe/Prague',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function DayChart({
|
||||||
|
title,
|
||||||
|
points,
|
||||||
|
showForecast,
|
||||||
|
showCorrected,
|
||||||
|
}: {
|
||||||
|
title: string
|
||||||
|
points: Point[]
|
||||||
|
showForecast: boolean
|
||||||
|
showCorrected: boolean
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="h-[240px] w-full rounded-xl border border-slate-800 bg-slate-900/40 p-2 pt-4">
|
||||||
|
<div className="px-2 pb-2 text-xs font-semibold uppercase tracking-wide text-slate-500">{title}</div>
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={points} margin={{ top: 8, right: 16, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#334155" opacity={0.6} />
|
||||||
|
<XAxis dataKey="timeLabel" tick={{ fill: '#94a3b8', fontSize: 11 }} interval={7} />
|
||||||
|
<YAxis
|
||||||
|
tick={{ fill: '#94a3b8', fontSize: 11 }}
|
||||||
|
label={{ value: 'kW', angle: -90, position: 'insideLeft', fill: '#64748b', fontSize: 11 }}
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: '#0f172a',
|
||||||
|
border: '1px solid #1e293b',
|
||||||
|
borderRadius: '8px',
|
||||||
|
}}
|
||||||
|
labelStyle={{ color: '#e2e8f0' }}
|
||||||
|
/>
|
||||||
|
<Legend wrapperStyle={{ fontSize: 12 }} />
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="actual_kw"
|
||||||
|
name="Skutečnost"
|
||||||
|
stroke="#e2e8f0"
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
|
{showForecast ? (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="forecast_kw"
|
||||||
|
name="Předpověď"
|
||||||
|
stroke="#ef9f27"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
strokeDasharray="5 4"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{showCorrected ? (
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="corrected_kw"
|
||||||
|
name="Korigovaná"
|
||||||
|
stroke="#ef9f27"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
dot={false}
|
||||||
|
connectNulls
|
||||||
|
strokeDasharray="2 3"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ForecastVsActual() {
|
||||||
|
const { site: siteRow, ready: siteReady, error: siteErr } = useSiteStatus()
|
||||||
|
const siteId = siteRow?.site_id ?? null
|
||||||
|
|
||||||
|
const [metric, setMetric] = useState<MetricKey>('pv')
|
||||||
|
const [days, setDays] = useState(20)
|
||||||
|
const [ready, setReady] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const [telemetry, setTelemetry] = useState<Telemetry15mRow[]>([])
|
||||||
|
const [pvSlots, setPvSlots] = useState<ForecastPvSlotCorrectedRow[]>([])
|
||||||
|
const [baselineSlots, setBaselineSlots] = useState<BaselineLoadSlotRow[]>([])
|
||||||
|
|
||||||
|
const load = useCallback(async () => {
|
||||||
|
if (siteId == null) {
|
||||||
|
setTelemetry([])
|
||||||
|
setPvSlots([])
|
||||||
|
setBaselineSlots([])
|
||||||
|
setError(null)
|
||||||
|
setReady(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const to = new Date()
|
||||||
|
const from = new Date(to.getTime() - days * 24 * 60 * 60 * 1000)
|
||||||
|
const fromIso = from.toISOString()
|
||||||
|
const toIso = to.toISOString()
|
||||||
|
try {
|
||||||
|
const [tel, pv, base] = await Promise.all([
|
||||||
|
getTelemetry15mRange(siteId, fromIso, toIso),
|
||||||
|
getForecastPvSlotsRangeCorrected(siteId, fromIso, toIso),
|
||||||
|
getBaselineLoadSlotsRange(siteId, fromIso, toIso),
|
||||||
|
])
|
||||||
|
setTelemetry(tel)
|
||||||
|
setPvSlots(pv)
|
||||||
|
setBaselineSlots(base)
|
||||||
|
setError(null)
|
||||||
|
} catch (e) {
|
||||||
|
setTelemetry([])
|
||||||
|
setPvSlots([])
|
||||||
|
setBaselineSlots([])
|
||||||
|
setError(e instanceof Error ? e.message : 'Chyba načítání dat')
|
||||||
|
} finally {
|
||||||
|
setReady(true)
|
||||||
|
}
|
||||||
|
}, [siteId, days])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load()
|
||||||
|
}, [load])
|
||||||
|
|
||||||
|
const byInterval = useMemo(() => {
|
||||||
|
const map = new Map<string, { tel?: Telemetry15mRow; pv?: ForecastPvSlotCorrectedRow; base?: BaselineLoadSlotRow }>()
|
||||||
|
for (const r of telemetry) map.set(r.slot_start, { ...(map.get(r.slot_start) ?? {}), tel: r })
|
||||||
|
for (const r of pvSlots) map.set(r.interval_start, { ...(map.get(r.interval_start) ?? {}), pv: r })
|
||||||
|
for (const r of baselineSlots) map.set(r.interval_start, { ...(map.get(r.interval_start) ?? {}), base: r })
|
||||||
|
return map
|
||||||
|
}, [telemetry, pvSlots, baselineSlots])
|
||||||
|
|
||||||
|
const daysGrouped = useMemo(() => {
|
||||||
|
const byDay = new Map<string, Point[]>()
|
||||||
|
const keys = [...byInterval.keys()].sort((a, b) => new Date(a).getTime() - new Date(b).getTime())
|
||||||
|
for (const iso of keys) {
|
||||||
|
const item = byInterval.get(iso)
|
||||||
|
if (!item?.tel) continue
|
||||||
|
const d = new Date(iso)
|
||||||
|
const day = instantPragueDay(iso)
|
||||||
|
const timeLabel = d.toLocaleTimeString('cs-CZ', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
timeZone: 'Europe/Prague',
|
||||||
|
})
|
||||||
|
const k = iso
|
||||||
|
|
||||||
|
let actual_kw: number | null = null
|
||||||
|
let forecast_kw: number | null = null
|
||||||
|
let corrected_kw: number | null = null
|
||||||
|
|
||||||
|
if (metric === 'pv') {
|
||||||
|
actual_kw = kwFromW(item.tel.avg_pv_w)
|
||||||
|
forecast_kw = kwFromW(item.pv?.pv_forecast_total_w ?? null)
|
||||||
|
corrected_kw = kwFromW(item.pv?.pv_forecast_corrected_w ?? null)
|
||||||
|
} else if (metric === 'load') {
|
||||||
|
actual_kw = kwFromW(item.tel.avg_load_w)
|
||||||
|
forecast_kw = kwFromW(item.base?.forecast_w ?? null)
|
||||||
|
corrected_kw = null
|
||||||
|
} else if (metric === 'grid') {
|
||||||
|
actual_kw = kwFromW(item.tel.avg_grid_w)
|
||||||
|
forecast_kw = null
|
||||||
|
corrected_kw = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const arr = byDay.get(day) ?? []
|
||||||
|
arr.push({ k, timeLabel, actual_kw, forecast_kw, corrected_kw })
|
||||||
|
byDay.set(day, arr)
|
||||||
|
}
|
||||||
|
return [...byDay.entries()]
|
||||||
|
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||||
|
.map(([day, points]) => ({ day, points }))
|
||||||
|
}, [byInterval, metric])
|
||||||
|
|
||||||
|
const title = metric === 'pv' ? 'FVE (výroba)' : metric === 'load' ? 'Spotřeba (bazál)' : 'Síť (signed)'
|
||||||
|
const showForecast = metric === 'pv' || metric === 'load'
|
||||||
|
const showCorrected = metric === 'pv'
|
||||||
|
|
||||||
|
const tabClass = (on: boolean) =>
|
||||||
|
`rounded-lg px-3 py-2 text-sm font-medium transition ${on ? 'bg-slate-800 text-white' : 'text-slate-400 hover:bg-slate-900 hover:text-slate-200'}`
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-950 p-4 text-slate-100 md:p-8">
|
||||||
|
<div className="mx-auto max-w-7xl space-y-5">
|
||||||
|
<header className="border-b border-slate-800/80 pb-5">
|
||||||
|
<h1 className="text-2xl font-bold tracking-tight text-white">Srovnání predikce vs skutečnost</h1>
|
||||||
|
<p className="mt-1 text-sm text-slate-400">Posledních {days} dní (po dnech, 15min sloty)</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{!siteReady ? (
|
||||||
|
<p className="text-sm text-slate-500">Načítám lokalitu…</p>
|
||||||
|
) : siteErr ? (
|
||||||
|
<p className="text-sm text-red-200">{siteErr}</p>
|
||||||
|
) : siteId == null ? (
|
||||||
|
<p className="text-sm text-slate-500">Žádná vybraná lokalita.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<button type="button" className={tabClass(metric === 'pv')} onClick={() => setMetric('pv')}>
|
||||||
|
FVE
|
||||||
|
</button>
|
||||||
|
<button type="button" className={tabClass(metric === 'load')} onClick={() => setMetric('load')}>
|
||||||
|
Spotřeba
|
||||||
|
</button>
|
||||||
|
<button type="button" className={tabClass(metric === 'grid')} onClick={() => setMetric('grid')}>
|
||||||
|
Síť
|
||||||
|
</button>
|
||||||
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
<label className="text-xs text-slate-400">
|
||||||
|
Dní:{' '}
|
||||||
|
<input
|
||||||
|
className="ml-1 w-20 rounded-md border border-slate-700 bg-slate-900 px-2 py-1 text-slate-100"
|
||||||
|
type="number"
|
||||||
|
min={3}
|
||||||
|
max={60}
|
||||||
|
value={days}
|
||||||
|
onChange={(e) => setDays(Math.max(3, Math.min(60, Number(e.target.value) || 20)))}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void load()}
|
||||||
|
className="rounded-lg bg-slate-800 px-3 py-2 text-sm font-semibold text-slate-100 hover:bg-slate-700"
|
||||||
|
>
|
||||||
|
Obnovit
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<div className="rounded-xl border border-red-500/40 bg-red-950/40 px-4 py-3 text-sm text-red-200" role="alert">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{!ready ? (
|
||||||
|
<p className="text-sm text-slate-500">Načítám data…</p>
|
||||||
|
) : daysGrouped.length === 0 ? (
|
||||||
|
<p className="text-sm text-slate-500">Žádná data pro zvolený rozsah.</p>
|
||||||
|
) : (
|
||||||
|
<section className="space-y-4">
|
||||||
|
{daysGrouped.map(({ day, points }) => (
|
||||||
|
<DayChart
|
||||||
|
key={day}
|
||||||
|
title={`${fmtDayLabel(day)} · ${title}`}
|
||||||
|
points={points}
|
||||||
|
showForecast={showForecast}
|
||||||
|
showCorrected={showCorrected}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -14,6 +14,8 @@ export type SlotData = {
|
|||||||
gen_port_power_w: number | null
|
gen_port_power_w: number | null
|
||||||
pv_a_forecast_w: number | null
|
pv_a_forecast_w: number | null
|
||||||
pv_b_forecast_w: number | null
|
pv_b_forecast_w: number | null
|
||||||
|
/** Korigovaný součet FVE forecastu (W). */
|
||||||
|
pv_forecast_corrected_w?: number | null
|
||||||
load_baseline_w: number | null
|
load_baseline_w: number | null
|
||||||
ev1_setpoint_w: number | null
|
ev1_setpoint_w: number | null
|
||||||
ev2_setpoint_w: number | null
|
ev2_setpoint_w: number | null
|
||||||
|
|||||||
Reference in New Issue
Block a user