diff --git a/.idea/data_source_mapping.xml b/.idea/data_source_mapping.xml
index 89ec326..5256de3 100644
--- a/.idea/data_source_mapping.xml
+++ b/.idea/data_source_mapping.xml
@@ -7,8 +7,14 @@
+
+
+
+
+
+
diff --git a/db/migration/V072__pv_array_telemetry_group_and_sources.sql b/db/migration/V072__pv_array_telemetry_group_and_sources.sql
new file mode 100644
index 0000000..6a8c91b
--- /dev/null
+++ b/db/migration/V072__pv_array_telemetry_group_and_sources.sql
@@ -0,0 +1,52 @@
+-- =============================================================
+-- V072 – asset_pv_array.telemetry_group + rozšíření telemetry_source
+--
+-- Cíl:
+-- - umožnit mapování PV pole → měřicí kanál (pv1/pv2/pv_strings/pv_total/gen_port),
+-- - umožnit sdílené měření pro více polí (telemetry_group) a následnou alokaci (v routines).
+-- =============================================================
+
+alter table ems.asset_pv_array
+ add column if not exists telemetry_group text;
+
+comment on column ems.asset_pv_array.telemetry_source is
+'Který sloupec v telemetry_inverter odpovídá tomuto poli.
+ gen_port = gen_port_power_w (AC-coupled pole na GEN portu),
+ pv1 = pv1_power_w (DC string 1 / MPPT1),
+ pv2 = pv2_power_w (DC string 2 / MPPT2),
+ pv_strings = pv1_power_w + pv2_power_w (souhrn DC stringů, pokud nejde rozlišit),
+ pv_total = pv_power_w (souhrnné PV, pokud nejde rozlišit).
+ NULL = pole nemá přímou telemetrii (fallback na forecast).';
+
+comment on column ems.asset_pv_array.telemetry_group is
+'Volitelná skupina pro sdílené měření: pokud více pv_array sdílí jeden telemetrický kanál (např. GEN port rozdělený do více orientací),
+pak mají shodné (site_id, telemetry_source, telemetry_group) a routines alokují actual proporčně podle forecastu.';
+
+-- --- Seed / upgrade stávajících referenčních lokalit ---
+
+-- home-01: dvě GEN pole sdílí jeden GEN port → stejné telemetry_group
+update ems.asset_pv_array
+set telemetry_source = 'gen_port',
+ telemetry_group = 'gen_port_1'
+where site_id = (select id from ems.site where code = 'home-01')
+ and code in ('pv-b', 'pv-b-flat');
+
+-- BA81: stringy mapujeme na PV1/PV2, mikroinvertory sdílí GEN port (alokace podle forecastu).
+update ems.asset_pv_array
+set telemetry_source = 'pv1',
+ telemetry_group = null
+where site_id = (select id from ems.site where code = 'BA81')
+ and code = 'pv-str-1';
+
+update ems.asset_pv_array
+set telemetry_source = 'pv2',
+ telemetry_group = null
+where site_id = (select id from ems.site where code = 'BA81')
+ and code = 'pv-str-2';
+
+update ems.asset_pv_array
+set telemetry_source = 'gen_port',
+ telemetry_group = 'gen_port_1'
+where site_id = (select id from ems.site where code = 'BA81')
+ and code in ('pv-mi-1', 'pv-mi-2');
+
diff --git a/db/migration/V073__pv_telemetry_source_def_fk.sql b/db/migration/V073__pv_telemetry_source_def_fk.sql
new file mode 100644
index 0000000..da92b1a
--- /dev/null
+++ b/db/migration/V073__pv_telemetry_source_def_fk.sql
@@ -0,0 +1,56 @@
+-- =============================================================
+-- V073 – číselník PV telemetrie + FK na asset_pv_array.telemetry_source
+--
+-- Cíl: referenční integrita pro telemetry_source (povolené kódy),
+-- aby se zabránilo překlepům a nekonzistentním datům.
+-- =============================================================
+
+create table if not exists ems.pv_telemetry_source_def (
+ code text primary key,
+ description text not null,
+ telemetry_inverter_expr text null,
+ active boolean not null default true
+);
+
+comment on table ems.pv_telemetry_source_def is
+ 'Číselník zdrojů PV telemetrie (kanálů) pro asset_pv_array.telemetry_source.';
+
+comment on column ems.pv_telemetry_source_def.code is
+ 'Stabilní kód zdroje telemetrie (FK z asset_pv_array.telemetry_source).';
+
+comment on column ems.pv_telemetry_source_def.telemetry_inverter_expr is
+ 'Volitelně: lidsky čitelný výraz, jak se kanál počítá z telemetry_inverter (informativní; runtime logika je v routines).';
+
+insert into ems.pv_telemetry_source_def (code, description, telemetry_inverter_expr) values
+ ('gen_port', 'AC-coupled výroba na GEN portu (souhrn).', 'gen_port_power_w'),
+ ('pv1', 'DC string/MPPT 1 (samostatně).', 'pv1_power_w'),
+ ('pv2', 'DC string/MPPT 2 (samostatně).', 'pv2_power_w'),
+ ('pv_strings', 'Součet DC stringů (pv1+pv2).', 'pv1_power_w + pv2_power_w'),
+ ('pv_total', 'Souhrnná PV výroba (pokud nelze rozlišit).','pv_power_w')
+on conflict (code) do update
+set description = excluded.description,
+ telemetry_inverter_expr = excluded.telemetry_inverter_expr,
+ active = true;
+
+-- FK (idempotentně): NULL povolen (pole bez přímé telemetrie / fallback na forecast).
+do $$
+begin
+ if not exists (
+ select 1
+ from pg_constraint c
+ join pg_class t on t.oid = c.conrelid
+ join pg_namespace n on n.oid = t.relnamespace
+ where n.nspname = 'ems'
+ and t.relname = 'asset_pv_array'
+ and c.conname = 'asset_pv_array_telemetry_source_fk'
+ ) then
+ alter table ems.asset_pv_array
+ add constraint asset_pv_array_telemetry_source_fk
+ foreign key (telemetry_source)
+ references ems.pv_telemetry_source_def(code)
+ on update cascade
+ on delete restrict;
+ end if;
+end;
+$$;
+
diff --git a/db/routines/R__019_fn_fill_audit_interval.sql b/db/routines/R__019_fn_fill_audit_interval.sql
index 66305c4..d0f961b 100644
--- a/db/routines/R__019_fn_fill_audit_interval.sql
+++ b/db/routines/R__019_fn_fill_audit_interval.sql
@@ -188,6 +188,8 @@ BEGIN
SELECT AVG(
CASE r_bonus.telemetry_source
WHEN 'gen_port' THEN ti.gen_port_power_w
+ WHEN 'pv1' THEN ti.pv1_power_w
+ WHEN 'pv2' THEN ti.pv2_power_w
WHEN 'pv_strings' THEN COALESCE(ti.pv1_power_w, 0)
+ COALESCE(ti.pv2_power_w, 0)
WHEN 'pv_total' THEN ti.pv_power_w
diff --git a/db/routines/R__022_fn_fill_forecast_accuracy.sql b/db/routines/R__022_fn_fill_forecast_accuracy.sql
index 8880963..bf74222 100644
--- a/db/routines/R__022_fn_fill_forecast_accuracy.sql
+++ b/db/routines/R__022_fn_fill_forecast_accuracy.sql
@@ -50,7 +50,9 @@ BEGIN
v.learning_exclude_reason
FROM ems.forecast_pv_interval fpi
JOIN ems.forecast_pv_run fpr ON fpr.id = fpi.run_id
- JOIN ems.asset_pv_array pa ON pa.id = fpr.pv_array_id
+ JOIN ems.asset_pv_array pa
+ ON pa.id = fpr.pv_array_id
+ AND pa.site_id = fpr.site_id
LEFT JOIN ems.site_pv_forecast_calibration cal
ON cal.site_id = fpr.site_id
LEFT JOIN LATERAL (
@@ -115,16 +117,47 @@ BEGIN
(flags.is_curtailed_learning_slot OR flags.is_telemetry_derated_slot) AS exclude_actual_for_learning
) v ON true
LEFT JOIN LATERAL (
- SELECT AVG(
+ WITH base AS (
+ SELECT AVG(
+ CASE coalesce(pa.telemetry_source, '')
+ WHEN 'pv1' THEN ti.pv1_power_w::NUMERIC
+ WHEN 'pv2' THEN ti.pv2_power_w::NUMERIC
+ WHEN 'pv_strings' THEN (COALESCE(ti.pv1_power_w, 0) + COALESCE(ti.pv2_power_w, 0))::NUMERIC
+ WHEN 'pv_total' THEN ti.pv_power_w::NUMERIC
+ WHEN 'gen_port' THEN ti.gen_port_power_w::NUMERIC
+ ELSE NULL
+ END
+ ) AS avg_actual_w
+ FROM ems.telemetry_inverter ti
+ WHERE ti.site_id = fpr.site_id
+ AND ti.measured_at >= fpi.interval_start
+ AND ti.measured_at < fpi.interval_start + INTERVAL '15 minutes'
+ ),
+ grp AS (
+ -- Pokud více pv_array sdílí stejné měření (např. GEN port rozdělený do více orientací),
+ -- rozdělíme actual proporčně podle forecastu v daném slotu.
+ SELECT
+ sum(fpi2.power_w)::numeric AS forecast_group_w
+ FROM ems.forecast_pv_interval fpi2
+ JOIN ems.forecast_pv_run fpr2 ON fpr2.id = fpi2.run_id
+ JOIN ems.asset_pv_array pa2
+ ON pa2.id = fpi2.pv_array_id
+ AND pa2.site_id = fpr2.site_id
+ WHERE pa.telemetry_group IS NOT NULL
+ AND pa2.site_id = fpr.site_id
+ AND pa2.telemetry_group = pa.telemetry_group
+ AND pa2.telemetry_source = pa.telemetry_source
+ AND fpi2.interval_start = fpi.interval_start
+ AND fpr2.id = fpr.id
+ )
+ SELECT
CASE
- WHEN pa.controllable = false THEN ti.gen_port_power_w::NUMERIC
- ELSE (COALESCE(ti.pv1_power_w, 0) + COALESCE(ti.pv2_power_w, 0))::NUMERIC
- END
- ) AS avg_actual_w
- FROM ems.telemetry_inverter ti
- WHERE ti.site_id = fpr.site_id
- AND ti.measured_at >= fpi.interval_start
- AND ti.measured_at < fpi.interval_start + INTERVAL '15 minutes'
+ WHEN pa.telemetry_group IS NULL THEN (SELECT avg_actual_w FROM base)
+ WHEN (SELECT forecast_group_w FROM grp) IS NULL THEN NULL
+ WHEN (SELECT forecast_group_w FROM grp) <= 0 THEN NULL
+ WHEN (SELECT avg_actual_w FROM base) IS NULL THEN NULL
+ ELSE (SELECT avg_actual_w FROM base) * (fpi.power_w::numeric / (SELECT forecast_group_w FROM grp))
+ END AS avg_actual_w
) slot ON true
WHERE fpr.site_id = p_site_id
AND fpr.status = 'ok'
diff --git a/db/routines/R__043_fn_pv_forecast_correction_factor.sql b/db/routines/R__043_fn_pv_forecast_correction_factor.sql
index 761ed36..17720e4 100644
--- a/db/routines/R__043_fn_pv_forecast_correction_factor.sql
+++ b/db/routines/R__043_fn_pv_forecast_correction_factor.sql
@@ -25,23 +25,30 @@ begin
and ti.measured_at >= p_window_start
and ti.measured_at < p_window_end;
+ with pv_arrays as (
+ select apa.id as pv_array_id
+ from ems.asset_pv_array apa
+ where apa.site_id = p_site_id
+ ),
+ latest_run as (
+ select distinct on (fpr.pv_array_id)
+ fpr.pv_array_id,
+ fpr.id as run_id
+ from pv_arrays pa
+ join ems.forecast_pv_run fpr
+ on fpr.pv_array_id = pa.pv_array_id
+ and fpr.site_id = p_site_id
+ where fpr.status = 'ok'
+ and fpr.created_at <= p_window_start
+ order by fpr.pv_array_id, fpr.created_at desc
+ )
select coalesce(sum(fpi.power_w) * 0.25 / 1000.0, 0)
into v_forecast
from ems.forecast_pv_interval fpi
- join ems.forecast_pv_run fpr on fpr.id = fpi.run_id
- where fpr.site_id = p_site_id
- and fpi.interval_start >= p_window_start
+ join latest_run lr on lr.run_id = fpi.run_id
+ where fpi.interval_start >= p_window_start
and fpi.interval_start < p_window_end
- and fpr.status = 'ok'
- and fpr.id = (
- select fpr2.id
- from ems.forecast_pv_run fpr2
- where fpr2.site_id = p_site_id
- and fpr2.status = 'ok'
- and fpr2.created_at <= p_window_start
- order by fpr2.created_at desc
- limit 1
- );
+ and fpi.pv_array_id = lr.pv_array_id;
if v_forecast < 0.1 or coalesce(v_actual, 0) < 0.05 then
return jsonb_build_object(