Files
ems/docs/03-data-model.md
Dusan Vojacek 02f0ab66e4
Some checks failed
CI and deploy / migration-check (pull_request) Failing after 27s
CI and deploy / deploy (pull_request) Has been skipped
gpt5.5 - odladeni dokumentace dle kodu
2026-05-02 19:17:04 +02:00

18 KiB
Raw Blame History

EMS Platform Data Model

Principy

  • Vše je vztaženo ke site (lokalitě)
  • Raw tržní data jsou sdílená, efektivní ceny se dopočítávají per site
  • Telemetrie a plány mají vždy site_id + interval_start
  • TimescaleDB hypertable pro časové série (telemetrie, ceny, plány)
  • Primární časová granularita: 15 minut
  • Čtení a doménová logika z DB preferuj ems.fn_* a ems.vw_* (SQL-first; detail a výjimky v CLAUDE.md → sekce SQL-first).

Konfigurace lokalit

site

Základní entita. Jedna instalace = jeden objekt.

CREATE TABLE site (
    id          SERIAL PRIMARY KEY,
    code        TEXT UNIQUE NOT NULL,        -- např. 'home-01'
    name        TEXT NOT NULL,
    timezone    TEXT NOT NULL DEFAULT 'Europe/Prague',
    latitude    NUMERIC(9,6),                -- Open-Meteo / pvlib
    longitude   NUMERIC(9,6),                -- Open-Meteo / pvlib
    active      BOOLEAN NOT NULL DEFAULT true,
    notes       TEXT,
    created_at  TIMESTAMPTZ DEFAULT now()
);

site_endpoint

Komunikační endpointy každá lokalita může mít více.

CREATE TABLE site_endpoint (
    id              SERIAL PRIMARY KEY,
    site_id         INT REFERENCES site(id),
    endpoint_type   TEXT NOT NULL,   -- 'modbus_tcp', 'loxone_http', 'teltonika_api'
    host            TEXT NOT NULL,
    port            INT,
    protocol        TEXT,            -- 'modbus_tcp', 'http', 'https'
    unit_id         INT,             -- Modbus Unit ID pro modbus_tcp
    auth_reference  TEXT,            -- odkaz na secret / env proměnnou
    enabled         BOOLEAN DEFAULT true,
    notes           TEXT
);

site_market_config

Obchodní konfigurace s maržemi. Platnost od-do umožňuje historické sledování změn.

CREATE TABLE site_market_config (
    id                   SERIAL PRIMARY KEY,
    site_id              INT REFERENCES site(id),
    purchase_pricing_mode TEXT NOT NULL DEFAULT 'spot',  -- 'spot', 'fixed', 'hybrid'
    sale_pricing_mode     TEXT NOT NULL DEFAULT 'spot',
    buy_margin_fixed_czk  NUMERIC(10,4) DEFAULT 0,       -- Kč/kWh
    buy_margin_percent    NUMERIC(6,4) DEFAULT 0,
    sell_margin_fixed_czk NUMERIC(10,4) DEFAULT 0,       -- Kč/kWh (záporná = srážka)
    sell_margin_percent   NUMERIC(6,4) DEFAULT 0,
    currency              TEXT NOT NULL DEFAULT 'CZK',
    valid_from            TIMESTAMPTZ NOT NULL,
    valid_to              TIMESTAMPTZ,                   -- NULL = aktuálně platný
    notes                 TEXT
);

site_grid_connection

Síťová omezení lokality.

Migrace V074 přidává block_export_on_negative_sell (boolean, default false): v LP při záporné efektivní prodejní ceně tvrdě grid_export == 0. Použít u lokalit typu KV1 (fixní nákup, bez neriťitelného přetoku pole B); u home-01 obvykle nechat false, aby řešení zůstalo proveditelné při přebytku z pole B.

CREATE TABLE site_grid_connection (
    id                    SERIAL PRIMARY KEY,
    site_id               INT REFERENCES site(id) UNIQUE,
    max_import_power_w    INT NOT NULL,
    max_export_power_w    INT NOT NULL DEFAULT 0,
    no_export             BOOLEAN DEFAULT false,
    reserved_capacity_w   INT DEFAULT 0,
    notes                 TEXT
);

Aktiva

asset_inverter

CREATE TABLE asset_inverter (
    id                    SERIAL PRIMARY KEY,
    site_id               INT REFERENCES site(id),
    code                  TEXT NOT NULL,
    manufacturer          TEXT,                -- 'Deye'
    model                 TEXT,                -- 'SUN-20K-SG01LP1-EU'
    endpoint_id           INT REFERENCES site_endpoint(id),
    max_charge_power_w    INT,
    max_discharge_power_w INT,
    max_export_power_w    INT,
    controllable          BOOLEAN DEFAULT true,  -- false = ongridový autonomní
    notes                 TEXT
);

asset_battery

CREATE TABLE asset_battery (
    id                       SERIAL PRIMARY KEY,
    site_id                  INT REFERENCES site(id),
    inverter_id              INT REFERENCES asset_inverter(id),
    code                     TEXT NOT NULL,
    usable_capacity_wh       INT NOT NULL,         -- 64000
    min_soc_percent          NUMERIC(5,2) DEFAULT 10,   -- provozní spodní mez LP + clamp; u paralelních packů často 1112
    reserve_soc_percent      NUMERIC(5,2) DEFAULT 20,   -- ekonomická podlaha; MILP export (ge≥1 W) drží soc[t] ≥ tato rezerva (arb_base_wh)
    max_soc_percent          NUMERIC(5,2) DEFAULT 95,
    charge_efficiency        NUMERIC(5,4) DEFAULT 0.95,
    discharge_efficiency     NUMERIC(5,4) DEFAULT 0.95,
    degradation_cost_czk_kwh NUMERIC(8,4) DEFAULT 0.5  -- náklad na cyklus
    -- pozdější migrace přidávají plánovací tunables:
    -- charge_slot_buffer, discharge_slot_buffer,
    -- planner_max_soc_percent, planner_discharge_floor_percent,
    -- planner_extreme_buy_threshold_czk_kwh,
    -- planner_terminal_soc_value_factor
);

asset_pv_array

Každé FVE pole zvlášť důležité pro predikci (azimut, sklon). Zelený bonus (dotace za vyrobenou elektřinu) se váže na úroveň pole, ne na celou lokalitu: které pole má bonus, jaká je sazba Kč/kWh a platnost, určují sloupce green_bonus_*. Výpočet příjmu za interval probíhá funkcí ems.fn_green_bonus_revenue z výroby v Wh; není součástí efektivní prodejní ceny ze sítě.

Deye reg 340 (max solar power, W): strop pro řiditelné DC pole A na hybridu počítá ems.fn_inverter_pv_a_max_w(inverter_id) jako součet nominal_power_wp řádků s controllable = true vázaných na daný invertor. Zápis z EMS je povolen jen na lokalitách se zeleným bonusem na PV poli (ems.fn_site_has_active_green_bonus_pv(site_id) — aktivní asset_pv_array.green_bonus_* v kalendářním dni Europe/Prague); jinak EMS reg 340 nemění (invertor zůstane na poslední hodnotě).

CREATE TABLE asset_pv_array (
    id                  SERIAL PRIMARY KEY,
    site_id             INT REFERENCES site(id),
    inverter_id         INT REFERENCES asset_inverter(id),
    code                TEXT NOT NULL,
    name                TEXT,
    nominal_power_wp    INT NOT NULL,         -- 10000
    azimuth_deg         NUMERIC(6,2),         -- kompasově/pvlib: 0=N, 90=E, 180=S, 270=W
    tilt_deg            NUMERIC(5,2),
    module_count        INT,
    shading_factor      NUMERIC(4,3) DEFAULT 1.0,
    controllable        BOOLEAN DEFAULT false, -- ongridový = false
    -- zelený bonus (NULL = pole bez bonusu)
    green_bonus_czk_kwh    NUMERIC(10,6),     -- sazba Kč/kWh za vyrobenou kWh
    green_bonus_valid_from DATE,              -- platnost od (včetně)
    green_bonus_valid_to   DATE,              -- platnost do (exclusive), NULL = dosud
    green_bonus_meter_code TEXT,              -- EAN / číslo zeleného elektroměru (audit)
    notes               TEXT
);

asset_ev_charger

EV nabíječky. Na první instalaci jsou 2× Teltonika.

CREATE TABLE asset_ev_charger (
    id                SERIAL PRIMARY KEY,
    site_id           INT REFERENCES site(id),
    code              TEXT NOT NULL,
    manufacturer      TEXT,                  -- 'Teltonika'
    model             TEXT,                  -- 'TeltoCharge'
    endpoint_id       INT REFERENCES site_endpoint(id),
    max_power_w       INT NOT NULL,          -- 22000
    min_power_w       INT DEFAULT 1380,      -- min pro jednofázové nabíjení
    phases            INT DEFAULT 3,
    connector_count   INT DEFAULT 1,
    schedulable       BOOLEAN DEFAULT true,
    notes             TEXT
);

asset_heat_pump

Tepelné čerpadlo / TUV. Aktuální implementace má samostatnou tabulku místo historického generického asset_flexible_device.

CREATE TABLE asset_heat_pump (
    id                    SERIAL PRIMARY KEY,
    site_id               INT REFERENCES site(id),
    code                  TEXT NOT NULL,
    manufacturer          TEXT,
    model                 TEXT,
    endpoint_id           INT REFERENCES site_endpoint(id),
    rated_heating_power_w INT NOT NULL,
    cop_rated             NUMERIC(4,2),
    cop_temp_reference_c  NUMERIC(5,2),
    min_run_duration_min  INT NOT NULL DEFAULT 30,
    min_stop_duration_min INT NOT NULL DEFAULT 15,
    tuv_tank_volume_l     INT,
    tuv_min_temp_c        NUMERIC(5,2) NOT NULL DEFAULT 45,
    tuv_max_temp_c        NUMERIC(5,2) NOT NULL DEFAULT 60,
    tuv_target_temp_c     NUMERIC(5,2) NOT NULL DEFAULT 55,
    tuv_temp_sensor_ref   TEXT,
    schedulable           BOOLEAN NOT NULL DEFAULT true,
    notes                 TEXT
);

Tržní data

market_interval_price

Raw spotové ceny OTE CZ. Bez vazby na lokalitu sdílené. Backend stahuje OTE jednou za naplánovaný import (data platí pro všechny site); efektivní ceny per site jen přes site_market_config a view. TimescaleDB hypertable.

CREATE TABLE market_interval_price (
    market_source   TEXT NOT NULL DEFAULT 'OTE_CZ',
    interval_start  TIMESTAMPTZ NOT NULL,
    interval_end    TIMESTAMPTZ NOT NULL,
    buy_raw_price_czk_kwh  NUMERIC(10,6),   -- nákupní raw cena Kč/kWh
    sell_raw_price_czk_kwh NUMERIC(10,6),   -- prodejní raw cena (ref. cena)
    currency        TEXT DEFAULT 'CZK',
    imported_at     TIMESTAMPTZ DEFAULT now(),
    PRIMARY KEY (market_source, interval_start)
);
-- SELECT create_hypertable('market_interval_price', 'interval_start');

View: vw_site_effective_price (ems)

Efektivní ceny per site — neukládá se; nákupní cena přes ems.fn_effective_buy_price(site_id, interval_start) (zahrnuje HDO NT/VT, distribuci, systémové služby, OTE poplatek, marži a DPH podle tarifu). Pro spot je procentní marže na buy_raw asymetrická: kladná raw ×(1+p/100), záporná ×(1p/100); viz docs/04-modules/market-prices.md a repeatable db/routines/R__011_fn_effective_price.sql.

Prodejní strana nadále ems.fn_effective_sell_price: raw + prodejní marže (bez DPH). Zjednodušený vzorec bez distribuce jen pro ilustraci prodeje:

-- pouze analogie k sell (nákup v produkci vždy přes funkci výše):
mip.sell_raw_price_czk_kwh
    + smc.sell_margin_fixed_czk
    + (mip.sell_raw_price_czk_kwh * smc.sell_margin_percent / 100)

Telemetrie

telemetry_inverter

Raw měření ze střídače Deye (Modbus). 1min granularita, hypertable.

CREATE TABLE telemetry_inverter (
    site_id             INT REFERENCES site(id),
    inverter_id         INT REFERENCES asset_inverter(id),
    measured_at         TIMESTAMPTZ NOT NULL,
    -- výroba
    pv_power_w          INT,           -- celkový výkon FVE (oba stringy)
    -- baterie
    battery_soc_percent NUMERIC(5,2),
    battery_power_w     INT,           -- kladné = nabíjení, záporné = vybíjení
    battery_voltage_v   NUMERIC(7,3),
    -- síť
    grid_power_w        INT,           -- kladné = import, záporné = export
    grid_voltage_v      NUMERIC(7,3),
    -- spotřeba
    load_power_w        INT,           -- celková spotřeba objektu
    -- teploty
    inverter_temp_c     NUMERIC(5,2),
    -- provozní stav
    operating_mode      TEXT,          -- raw hodnota z Modbus registru
    fault_code          INT,
    PRIMARY KEY (inverter_id, measured_at)
);
-- SELECT create_hypertable('telemetry_inverter', 'measured_at');

Continuous aggregates a views (výkon střídače pro UI)

Z telemetry_inverter počítá TimescaleDB materializované agregáty (viz db/migration/V011__indexes_and_aggregates.sql, V039__telemetry_inverter_15m_aggregate.sql):

  • telemetry_inverter_hourly hodinové průměry + LAST(battery_soc_percent, measured_at); čtení přes view vw_telemetry_hourly_7d.
  • telemetry_inverter_15m čtvrthodinové bucket odpovídající 15min slotům EMS; čtení přes vw_telemetry_15m_7d (definice v db/views/R__071_vw_telemetry_15m_7d.sql, repeatable).

PostgREST role ems_anonSELECT na tyto views (ne na samotné CA); u view nad CA je security_invoker = false, stejně jako u vw_telemetry_hourly_7d (viz db/views/R__072_z_postgrest_ems_anon_grants.sql).

telemetry_ev_charger

Stav EV nabíječek.

CREATE TABLE telemetry_ev_charger (
    site_id         INT REFERENCES site(id),
    charger_id      INT REFERENCES asset_ev_charger(id),
    measured_at     TIMESTAMPTZ NOT NULL,
    connector_id    INT DEFAULT 1,
    status          TEXT,              -- 'available', 'charging', 'faulted', ...
    power_w         INT,
    energy_kwh      NUMERIC(10,3),    -- kumulativní
    current_a       NUMERIC(7,3),
    voltage_v       NUMERIC(7,3),
    session_id      TEXT,
    PRIMARY KEY (charger_id, connector_id, measured_at)
);
-- SELECT create_hypertable('telemetry_ev_charger', 'measured_at');

Predikce výroby

forecast_pv_run

Každý běh predikce je jeden záznam (kdy se spustil, jaký model, jaké pole).

CREATE TABLE forecast_pv_run (
    id              SERIAL PRIMARY KEY,
    site_id         INT REFERENCES site(id),
    pv_array_id     INT REFERENCES asset_pv_array(id),  -- NULL = celá lokalita
    forecast_source TEXT NOT NULL,       -- 'open_meteo', 'solcast', 'manual'
    model_params    JSONB,               -- parametry modelu
    horizon_start   TIMESTAMPTZ NOT NULL,
    horizon_end     TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ DEFAULT now(),
    status          TEXT DEFAULT 'ok'    -- 'ok', 'partial', 'failed'
);

forecast_pv_interval

Samotná predikovaná data, 15min granularita.

CREATE TABLE forecast_pv_interval (
    run_id          INT REFERENCES forecast_pv_run(id),
    pv_array_id     INT REFERENCES asset_pv_array(id),
    interval_start  TIMESTAMPTZ NOT NULL,
    power_w         INT NOT NULL,        -- predikovaný výkon
    irradiance_wm2  NUMERIC(8,2),        -- GHI ze weather service
    temp_c          NUMERIC(5,2),
    PRIMARY KEY (run_id, pv_array_id, interval_start)
);
-- SELECT create_hypertable('forecast_pv_interval', 'interval_start');

Kalibrace delty PV (per site)

  • site_pv_forecast_calibration (V057+, rozšířeno V076) parametry učení aditivní korekce výkonu PV z forecast_accuracy při každém výpočtu fn_pv_forecast_delta_profile (např. half_life_days, top_n_days; V076: reference_day_weight_mult).
  • site_pv_forecast_reference_day (V076) kalendářní datum ve smyslu lokality (interval_start AT TIME ZONE site.timezone)::date; tyto dny dostanou násobek váhy vzorků v fn_pv_forecast_delta_profile, aby zpětné „hezky svítící“ reference silněji vtáhly δ profil bez mazání řádků forecast_pv_interval.

Plánování

planning_run

Jeden plánovací běh per site.

CREATE TABLE planning_run (
    id              SERIAL PRIMARY KEY,
    site_id         INT REFERENCES site(id),
    horizon_start   TIMESTAMPTZ NOT NULL,
    horizon_end     TIMESTAMPTZ NOT NULL,
    created_at      TIMESTAMPTZ DEFAULT now(),
    status          TEXT DEFAULT 'draft',   -- 'draft', 'approved', 'active', 'superseded'
    solver_params   JSONB,
    notes           TEXT
);

planning_interval

Výstup plánování jeden řádek = jeden 15min slot.

CREATE TABLE planning_interval (
    run_id                  INT REFERENCES planning_run(id),
    interval_start          TIMESTAMPTZ NOT NULL,
    -- baterie
    battery_setpoint_w      INT,         -- kladné = nabíjení, záporné = vybíjení
    battery_soc_target_pct  NUMERIC(5,2),
    -- grid
    grid_setpoint_w         INT,         -- kladné = import, záporné = export
    -- EV (agregát za všechny nabíječky na site)
    ev_charge_power_w       INT,
    -- TUV
    tuv_enabled             BOOLEAN,
    -- ekonomika
    expected_cost_czk       NUMERIC(10,4),
    effective_buy_price     NUMERIC(10,6),
    effective_sell_price    NUMERIC(10,6),
    -- + sloupce z migrací (curtailment, EV1/2, predicted price, vstupy solveru):
    -- load_baseline_w, pv_a_forecast_raw_w, pv_b_forecast_raw_w,
    -- pv_a_forecast_solver_w, pv_b_forecast_solver_w
    PRIMARY KEY (run_id, interval_start)
);

Tabulka baseline_load_forecast_accuracy (migrace V027+) ukládá zpětně plánovaný bazál vs. skutečný bazál z auditu; plní fn_fill_baseline_load_forecast_accuracy po fn_fill_audit_interval.


Audit

audit_interval

Skutečnost vs plán, 15min granularita. Plněno zpětně po dostupnosti dat.

CREATE TABLE audit_interval (
    site_id             INT REFERENCES site(id),
    interval_start      TIMESTAMPTZ NOT NULL,
    planning_run_id     INT REFERENCES planning_run(id),
    -- skutečnost (z telemetrie, průměr/agregát za 15min)
    actual_pv_power_w   INT,
    actual_battery_power_w INT,
    actual_grid_power_w INT,
    actual_load_power_w INT,
    actual_battery_soc  NUMERIC(5,2),
    -- odchylky
    deviation_grid_w    INT,            -- actual - planned
    actual_cost_czk     NUMERIC(10,4),
    pv_b_production_wh  NUMERIC(10,3),  -- výroba bonusových polí (Wh / interval), podklad pro bonus
    green_bonus_czk     NUMERIC(10,4),  -- příjem zeleného bonusu (fn_green_bonus_revenue), mimo actual_cost_czk
    PRIMARY KEY (site_id, interval_start)
);
-- SELECT create_hypertable('audit_interval', 'interval_start');

Spotřeba

consumption_baseline_interval

Bazální (neflexibilní) spotřeba historická a predikovaná, 15min.

CREATE TABLE consumption_baseline_interval (
    site_id         INT REFERENCES site(id),
    interval_start  TIMESTAMPTZ NOT NULL,
    data_type       TEXT NOT NULL,  -- 'actual', 'forecast'
    power_w         INT NOT NULL,
    source          TEXT,           -- 'measured', 'model_v1', ...
    PRIMARY KEY (site_id, data_type, interval_start)
);

Flexibilní spotřebiče

Flexibilní spotřeba se neukládá souhrnně odvozuje se ze součtu telemetry_ev_charger + telemetry_heat_pump / plánovaných setpointů. Plánovaná flexibilní spotřeba je součástí planning_interval.


Override

site_override

Manuální přepisy pro provozní stavy.

CREATE TABLE site_override (
    id              SERIAL PRIMARY KEY,
    site_id         INT REFERENCES site(id),
    override_type   TEXT NOT NULL,      -- 'force_charge', 'force_discharge', 'block_export', 'manual_setpoint'
    value_json      JSONB,              -- parametry přepisu
    valid_from      TIMESTAMPTZ NOT NULL,
    valid_to        TIMESTAMPTZ,
    reason          TEXT,
    created_by      TEXT,
    created_at      TIMESTAMPTZ DEFAULT now()
);