# 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.

```sql
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.

```sql
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.

```sql
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.

```sql
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`

```sql
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`

```sql
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 11–12
    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
    -- V077: planner_daytime_charge_target_enabled, planner_night_baseload_buffer_percent,
    -- planner_daytime_charge_price_quantile, planner_charge_commitment_penalty_czk_kwh
);
```

### `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 `ems.fn_inverter_pv_a_max_w(inverter_id)` = `asset_inverter.deye_reg340_max_solar_w` (seed home-01 **32 000**, ostatní Deye **65 000**), fallback `max_dc_input_w`, pak součet Wp řiditelných polí; funkce vrací **0** bez řiditelného PV A. Spodní limit zápisu: `deye_reg340_min_solar_w` (home-01 **400**, jinde **0**). Zápis jen se zeleným bonusem (`fn_site_has_active_green_bonus_pv`).

```sql
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.

```sql
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`.

```sql
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.

```sql
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á ×`(1−p/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:

```sql
-- 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.

```sql
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_anon` má `SELECT` 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.

```sql
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).

```sql
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.

```sql
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.

```sql
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,  -- po V077: JSON z planning_engine (masks, soc_bounds, objective_terms, …)
    notes           TEXT
);
```

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

```sql
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.

```sql
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.

```sql
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.

```sql
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()
);
```
