Initial commit

Made-with: Cursor
This commit is contained in:
Dusan Vojacek
2026-03-20 13:27:37 +01:00
commit 8b4af663d8
77 changed files with 13337 additions and 0 deletions

View File

@@ -0,0 +1,137 @@
-- =============================================================
-- R__fn_cop_estimate.sql
-- EMS Platform odhad COP tepelného čerpadla dle venkovní teploty
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_cop_estimate(
p_heat_pump_id INT,
p_outdoor_temp_c NUMERIC
)
RETURNS NUMERIC(4,2)
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_cop_rated NUMERIC;
v_cop_ref_temp NUMERIC;
v_cop_estimated NUMERIC;
BEGIN
-- Načíst referenční COP a teplotu z konfigurace čerpadla
SELECT cop_rated, cop_temp_reference_c
INTO v_cop_rated, v_cop_ref_temp
FROM ems.asset_heat_pump
WHERE id = p_heat_pump_id;
IF v_cop_rated IS NULL OR v_cop_ref_temp IS NULL THEN
-- Fallback: obecný odhad pro vzduch-voda TČ bez konfigurace
-- Zdroj: přibližná lineární závislost COP na venkovní teplotě
-- COP ≈ 2.0 při -10°C, COP ≈ 4.5 při +15°C
v_cop_estimated := 2.0 + (p_outdoor_temp_c + 10.0) * (4.5 - 2.0) / 25.0;
ELSE
-- Lineární interpolace od referenčního bodu
-- COP klesá přibližně o 0.10 na každý stupeň poklesu venkovní teploty
-- Toto je zjednodušený model zpřesnit dle skutečných dat z tepelky
v_cop_estimated := v_cop_rated + (p_outdoor_temp_c - v_cop_ref_temp) * 0.10;
END IF;
-- Omezit na rozumné hodnoty (COP vzduch-voda reálně 1.56.0)
v_cop_estimated := GREATEST(1.5, LEAST(6.0, v_cop_estimated));
RETURN ROUND(v_cop_estimated, 2);
END;
$$;
COMMENT ON FUNCTION ems.fn_cop_estimate(INT, NUMERIC) IS
'Odhadne COP tepelného čerpadla pro danou venkovní teplotu.
Používá lineární model od referenčního bodu (cop_rated při cop_temp_reference_c).
Výstup slouží k rozhodnutí zda je výhodné spustit TČ v daném intervalu.
Přesnost modelu zlepšit kalibrací na historická data (cop_actual z telemetrie).
Výsledek omezen na rozsah 1.56.0.';
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION ems.fn_heat_pump_cost_per_kwh_heat(
p_heat_pump_id INT,
p_outdoor_temp_c NUMERIC,
p_buy_price_czk_kwh NUMERIC
)
RETURNS NUMERIC(8,4)
LANGUAGE sql
STABLE
AS $$
-- Cena za 1 kWh tepla = cena elektřiny / COP
-- Čím vyšší COP, tím levnější teplo
SELECT ROUND(
p_buy_price_czk_kwh / NULLIF(ems.fn_cop_estimate(p_heat_pump_id, p_outdoor_temp_c), 0),
4
);
$$;
COMMENT ON FUNCTION ems.fn_heat_pump_cost_per_kwh_heat(INT, NUMERIC, NUMERIC) IS
'Vypočte efektivní cenu za 1 kWh dodaného tepla v Kč.
Vstup: ID tepelného čerpadla, venkovní teplota, nákupní cena elektřiny.
Výstup slouží k porovnání výhodnosti ohřevu v různých časových intervalech.
Nižší hodnota = výhodnější čas pro provoz TČ.';
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION ems.fn_heat_pump_should_run(
p_heat_pump_id INT,
p_interval_start TIMESTAMPTZ,
p_outdoor_temp_c NUMERIC,
p_tuv_tank_temp_c NUMERIC,
p_buy_price_czk_kwh NUMERIC,
p_max_cost_threshold NUMERIC DEFAULT 3.0 -- Kč/kWh tepla maximální akceptovatelná cena
)
RETURNS BOOLEAN
LANGUAGE plpgsql
STABLE
AS $$
DECLARE
v_hp ems.asset_heat_pump%ROWTYPE;
v_cost_per_kwh NUMERIC;
v_override BOOLEAN;
BEGIN
SELECT * INTO v_hp FROM ems.asset_heat_pump WHERE id = p_heat_pump_id;
-- Kontrola override (blokování TČ)
SELECT EXISTS(
SELECT 1 FROM ems.site_override
WHERE site_id = v_hp.site_id
AND override_type = 'block_heat_pump'
AND valid_from <= p_interval_start
AND (valid_to IS NULL OR valid_to > p_interval_start)
) INTO v_override;
IF v_override THEN
RETURN false;
END IF;
-- Povinný ohřev: teplota pod minimem
IF p_tuv_tank_temp_c IS NOT NULL AND p_tuv_tank_temp_c < v_hp.tuv_min_temp_c THEN
RETURN true;
END IF;
-- Zásobník je plný
IF p_tuv_tank_temp_c IS NOT NULL AND p_tuv_tank_temp_c >= v_hp.tuv_max_temp_c THEN
RETURN false;
END IF;
-- Ekonomické rozhodnutí: spustit pokud cena tepla je pod prahem
v_cost_per_kwh := ems.fn_heat_pump_cost_per_kwh_heat(
p_heat_pump_id, p_outdoor_temp_c, p_buy_price_czk_kwh
);
RETURN v_cost_per_kwh <= p_max_cost_threshold;
END;
$$;
COMMENT ON FUNCTION ems.fn_heat_pump_should_run(INT, TIMESTAMPTZ, NUMERIC, NUMERIC, NUMERIC, NUMERIC) IS
'Rozhodne zda má tepelné čerpadlo v daném intervalu běžet.
Logika priorit:
1. Pokud existuje override block_heat_pump → false.
2. Pokud teplota zásobníku pod tuv_min_temp_c → true (povinný ohřev).
3. Pokud zásobník nad tuv_max_temp_c → false.
4. Jinak ekonomické rozhodnutí: spustit pokud cena tepla ≤ p_max_cost_threshold Kč/kWh.
Výhodné časy jsou přes poledne v chladných měsících (vyšší venkovní teplota = lepší COP).';

View File

@@ -0,0 +1,61 @@
-- =============================================================
-- R__fn_effective_price.sql
-- EMS Platform funkce pro výpočet efektivní ceny per site
-- Repeatable migration nasazuje se při každé změně
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_effective_buy_price(
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS NUMERIC(10,6)
LANGUAGE sql
STABLE
AS $$
SELECT
mip.buy_raw_price_czk_kwh
+ smc.buy_margin_fixed_czk
+ (mip.buy_raw_price_czk_kwh * smc.buy_margin_percent / 100.0)
FROM ems.market_interval_price mip
CROSS JOIN ems.site_market_config smc
WHERE mip.market_source = 'OTE_CZ'
AND mip.interval_start = p_interval_start
AND smc.site_id = p_site_id
AND smc.valid_from <= p_interval_start
AND (smc.valid_to IS NULL OR smc.valid_to > p_interval_start)
ORDER BY smc.valid_from DESC
LIMIT 1;
$$;
COMMENT ON FUNCTION ems.fn_effective_buy_price(INT, TIMESTAMPTZ) IS
'Vrátí efektivní nákupní cenu elektřiny v Kč/kWh pro danou lokalitu a 15min interval.
Přičítá fixní a procentní nákupní marži dle aktuálně platné site_market_config.';
-- ------------------------------------------------------------
CREATE OR REPLACE FUNCTION ems.fn_effective_sell_price(
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS NUMERIC(10,6)
LANGUAGE sql
STABLE
AS $$
SELECT
mip.sell_raw_price_czk_kwh
+ smc.sell_margin_fixed_czk
+ (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100.0)
FROM ems.market_interval_price mip
CROSS JOIN ems.site_market_config smc
WHERE mip.market_source = 'OTE_CZ'
AND mip.interval_start = p_interval_start
AND smc.site_id = p_site_id
AND smc.valid_from <= p_interval_start
AND (smc.valid_to IS NULL OR smc.valid_to > p_interval_start)
ORDER BY smc.valid_from DESC
LIMIT 1;
$$;
COMMENT ON FUNCTION ems.fn_effective_sell_price(INT, TIMESTAMPTZ) IS
'Vrátí efektivní prodejní cenu elektřiny v Kč/kWh pro danou lokalitu a 15min interval.
Aplikuje fixní a procentní prodejní marži (záporná marže = srážka z prodejní ceny).';

View File

@@ -0,0 +1,171 @@
-- =============================================================
-- R__fn_fill_audit_interval.sql
-- EMS Platform plnění audit_interval ze skutečné telemetrie
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_fill_audit_interval(
p_site_id INT,
p_interval_start TIMESTAMPTZ
)
RETURNS VOID
LANGUAGE plpgsql
AS $$
DECLARE
v_interval_end TIMESTAMPTZ := p_interval_start + INTERVAL '15 minutes';
v_run_id INT;
v_avg_pv_power_w INT;
v_avg_battery_power_w INT;
v_avg_grid_power_w INT;
v_avg_load_power_w INT;
v_last_soc NUMERIC(5,2);
v_sum_ev_power_w INT;
v_avg_hp_power_w INT;
v_plan ems.planning_interval%ROWTYPE;
v_buy_price NUMERIC;
v_sell_price NUMERIC;
v_actual_cost NUMERIC := NULL;
BEGIN
-- Najít aktivní plán pro tento interval
SELECT pi.* INTO v_plan
FROM ems.planning_interval pi
JOIN ems.planning_run pr ON pr.id = pi.run_id
WHERE pr.site_id = p_site_id
AND pi.interval_start = p_interval_start
AND pr.status IN ('active', 'superseded')
ORDER BY pr.created_at DESC
LIMIT 1;
v_run_id := v_plan.run_id;
-- Agregovat telemetrii střídače (průměr za 15min; agregace bez GROUP BY vrací vždy 1 řádek)
SELECT
AVG(pv_power_w)::INT,
AVG(battery_power_w)::INT,
AVG(grid_power_w)::INT,
AVG(load_power_w)::INT,
LAST(battery_soc_percent, measured_at)
INTO
v_avg_pv_power_w,
v_avg_battery_power_w,
v_avg_grid_power_w,
v_avg_load_power_w,
v_last_soc
FROM ems.telemetry_inverter
WHERE site_id = p_site_id
AND measured_at >= p_interval_start
AND measured_at < v_interval_end;
-- Agregovat EV nabíječky (součet průměrů po charger_id)
SELECT COALESCE(SUM(avg_power), 0)::INT
INTO v_sum_ev_power_w
FROM (
SELECT AVG(power_w) AS avg_power
FROM ems.telemetry_ev_charger
WHERE site_id = p_site_id
AND measured_at >= p_interval_start
AND measured_at < v_interval_end
GROUP BY charger_id
) sub;
-- Agregovat tepelné čerpadlo
SELECT AVG(power_w)::INT
INTO v_avg_hp_power_w
FROM ems.telemetry_heat_pump
WHERE site_id = p_site_id
AND measured_at >= p_interval_start
AND measured_at < v_interval_end;
-- Efektivní cena pro výpočet skutečných nákladů
v_buy_price := ems.fn_effective_buy_price(p_site_id, p_interval_start);
v_sell_price := ems.fn_effective_sell_price(p_site_id, p_interval_start);
-- Skutečné náklady (kladný grid = nákup, záporný = prodej)
IF v_avg_grid_power_w IS NOT NULL THEN
v_actual_cost := (v_avg_grid_power_w::NUMERIC / 1000.0 / 4.0)
* CASE WHEN v_avg_grid_power_w >= 0
THEN COALESCE(v_buy_price, 0)
ELSE COALESCE(v_sell_price, 0) END;
END IF;
-- Upsert do audit_interval
INSERT INTO ems.audit_interval (
site_id, interval_start, planning_run_id,
actual_pv_power_w, actual_battery_power_w,
actual_grid_power_w, actual_load_power_w,
actual_battery_soc_pct,
actual_ev_power_w,
actual_heat_pump_power_w,
actual_cost_czk,
deviation_grid_w,
deviation_cost_czk
) VALUES (
p_site_id, p_interval_start, v_run_id,
v_avg_pv_power_w,
v_avg_battery_power_w,
v_avg_grid_power_w,
v_avg_load_power_w,
v_last_soc,
v_sum_ev_power_w,
v_avg_hp_power_w,
ROUND(v_actual_cost, 4),
CASE WHEN v_plan.run_id IS NOT NULL
THEN v_avg_grid_power_w - v_plan.grid_setpoint_w
ELSE NULL END,
CASE WHEN v_plan.run_id IS NOT NULL
THEN ROUND(v_actual_cost - COALESCE(v_plan.expected_cost_czk, 0), 4)
ELSE NULL END
)
ON CONFLICT (site_id, interval_start) DO UPDATE SET
planning_run_id = EXCLUDED.planning_run_id,
actual_pv_power_w = EXCLUDED.actual_pv_power_w,
actual_battery_power_w = EXCLUDED.actual_battery_power_w,
actual_grid_power_w = EXCLUDED.actual_grid_power_w,
actual_load_power_w = EXCLUDED.actual_load_power_w,
actual_battery_soc_pct = EXCLUDED.actual_battery_soc_pct,
actual_ev_power_w = EXCLUDED.actual_ev_power_w,
actual_heat_pump_power_w = EXCLUDED.actual_heat_pump_power_w,
actual_cost_czk = EXCLUDED.actual_cost_czk,
deviation_grid_w = EXCLUDED.deviation_grid_w,
deviation_cost_czk = EXCLUDED.deviation_cost_czk;
END;
$$;
COMMENT ON FUNCTION ems.fn_fill_audit_interval(INT, TIMESTAMPTZ) IS
'Naplní nebo aktualizuje jeden řádek v audit_interval pro danou lokalitu a 15min interval.
Agreguje průměry z telemetrie (střídač, EV, TČ), porovná se skutečným plánem a spočítá odchylky.
Volat každých 15 minut pro interval který právě skončil.';
-- ============================================================
-- Hromadné plnění auditu za historické období
-- ============================================================
CREATE OR REPLACE FUNCTION ems.fn_fill_audit_range(
p_site_id INT,
p_from TIMESTAMPTZ,
p_to TIMESTAMPTZ
)
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_slot TIMESTAMPTZ;
v_count INT := 0;
BEGIN
v_slot := date_trunc('hour', p_from)
+ INTERVAL '15 min' * FLOOR(EXTRACT(MINUTE FROM p_from) / 15);
WHILE v_slot < p_to LOOP
PERFORM ems.fn_fill_audit_interval(p_site_id, v_slot);
v_slot := v_slot + INTERVAL '15 minutes';
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION ems.fn_fill_audit_range(INT, TIMESTAMPTZ, TIMESTAMPTZ) IS
'Hromadně naplní audit_interval pro celé historické období.
Volá fn_fill_audit_interval pro každý 15min slot v rozsahu p_fromp_to.
Vrátí počet zpracovaných intervalů. Použít pro backfill po výpadku nebo prvním nasazení.';

View File

@@ -0,0 +1,160 @@
-- =============================================================
-- R__fn_set_mode.sql
-- EMS Platform přepínání provozních režimů
-- Repeatable migration
-- =============================================================
CREATE OR REPLACE FUNCTION ems.fn_set_mode(
p_site_id INT,
p_mode_code TEXT,
p_activated_by TEXT DEFAULT 'system',
p_valid_until TIMESTAMPTZ DEFAULT NULL,
p_notes TEXT DEFAULT NULL
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_current_mode TEXT;
v_mode_exists BOOLEAN;
BEGIN
-- Ověřit že režim existuje
SELECT EXISTS(SELECT 1 FROM ems.operating_mode_def WHERE code = p_mode_code)
INTO v_mode_exists;
IF NOT v_mode_exists THEN
RAISE EXCEPTION 'Neznámý provozní režim: %', p_mode_code;
END IF;
-- Zjistit aktuální režim (pro log a previous_mode)
SELECT mode_code INTO v_current_mode
FROM ems.site_operating_mode
WHERE site_id = p_site_id;
-- Pokud se režim nemění, nic nedělat
IF v_current_mode = p_mode_code THEN
RETURN p_mode_code;
END IF;
-- Uzavřít předchozí záznam v logu
UPDATE ems.site_operating_mode_log
SET deactivated_at = now()
WHERE site_id = p_site_id
AND deactivated_at IS NULL;
-- Upsert aktivního režimu
INSERT INTO ems.site_operating_mode
(site_id, mode_code, activated_at, activated_by, valid_until, previous_mode, notes)
VALUES
(p_site_id, p_mode_code, now(), p_activated_by, p_valid_until, v_current_mode, p_notes)
ON CONFLICT (site_id) DO UPDATE SET
mode_code = EXCLUDED.mode_code,
activated_at = EXCLUDED.activated_at,
activated_by = EXCLUDED.activated_by,
valid_until = EXCLUDED.valid_until,
previous_mode = EXCLUDED.previous_mode,
notes = EXCLUDED.notes;
-- Přidat záznam do logu
INSERT INTO ems.site_operating_mode_log
(site_id, mode_code, activated_at, activated_by, notes)
VALUES
(p_site_id, p_mode_code, now(), p_activated_by, p_notes);
RETURN p_mode_code;
END;
$$;
COMMENT ON FUNCTION ems.fn_set_mode(INT, TEXT, TEXT, TIMESTAMPTZ, TEXT) IS
'Přepne provozní režim lokality. Atomicky aktualizuje site_operating_mode a zapíše do audit logu.
Ignoruje přepnutí na stejný režim. Vyhodí výjimku pro neznámý kód režimu.
Příklad: SELECT ems.fn_set_mode(1, ''SELF_SUSTAIN'', ''user:jan'', NULL, ''Odjezd na dovolenou'');';
-- ============================================================
CREATE OR REPLACE FUNCTION ems.fn_restore_previous_mode(
p_site_id INT,
p_activated_by TEXT DEFAULT 'system'
)
RETURNS TEXT
LANGUAGE plpgsql
AS $$
DECLARE
v_previous TEXT;
BEGIN
SELECT previous_mode INTO v_previous
FROM ems.site_operating_mode
WHERE site_id = p_site_id;
IF v_previous IS NULL THEN
-- Fallback na AUTO pokud není předchozí režim
v_previous := 'AUTO';
END IF;
RETURN ems.fn_set_mode(p_site_id, v_previous, p_activated_by, NULL, 'Obnova předchozího režimu');
END;
$$;
COMMENT ON FUNCTION ems.fn_restore_previous_mode(INT, TEXT) IS
'Přepne lokalitu zpět na předchozí provozní režim (uložený v previous_mode).
Pokud předchozí režim neexistuje, přepne na AUTO. Používat po skončení dočasného přepisu.';
-- ============================================================
CREATE OR REPLACE FUNCTION ems.fn_expire_modes()
RETURNS INT
LANGUAGE plpgsql
AS $$
DECLARE
v_count INT := 0;
v_rec RECORD;
BEGIN
-- Najít lokality kde vypršel valid_until a přepnout na AUTO
FOR v_rec IN
SELECT site_id, previous_mode
FROM ems.site_operating_mode
WHERE valid_until IS NOT NULL
AND valid_until <= now()
AND mode_code <> 'AUTO'
LOOP
PERFORM ems.fn_set_mode(
v_rec.site_id,
COALESCE(v_rec.previous_mode, 'AUTO'),
'system:expiry',
NULL,
'Automatické vypršení dočasného režimu'
);
v_count := v_count + 1;
END LOOP;
RETURN v_count;
END;
$$;
COMMENT ON FUNCTION ems.fn_expire_modes() IS
'Zkontroluje všechny lokality s dočasným režimem (valid_until IS NOT NULL) a přepne zpět ty s prosahlým časem.
Volat každou minutu jako scheduled task. Vrátí počet přepnutých lokalit.';
-- ============================================================
CREATE OR REPLACE FUNCTION ems.fn_update_heartbeat(
p_site_id INT,
p_status TEXT DEFAULT 'ok',
p_ems_version TEXT DEFAULT NULL
)
RETURNS VOID
LANGUAGE sql
AS $$
INSERT INTO ems.site_heartbeat (site_id, last_seen, status, ems_version)
VALUES (p_site_id, now(), p_status, p_ems_version)
ON CONFLICT (site_id) DO UPDATE SET
last_seen = now(),
status = EXCLUDED.status,
ems_version = COALESCE(EXCLUDED.ems_version, ems.site_heartbeat.ems_version);
$$;
COMMENT ON FUNCTION ems.fn_update_heartbeat(INT, TEXT, TEXT) IS
'Aktualizuje informační heartbeat záznam EMS pro danou lokalitu.
Volat každou minutu z backend service po úspěšném odeslání pulzu do Loxone.
Slouží pouze pro EMS dashboard Loxone watchdog nezávisí na této tabulce,
sleduje HTTP pulzy přímo a nezávisle na dostupnosti DB.';