diff --git a/CLAUDE.md b/CLAUDE.md index 16a2987..78d0a28 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,7 +83,7 @@ Multi-site Energy Management System: optimalizuje FVE, baterii a flexibilní zá 17. **Modbus zápis = journal.** Každý zápis do zařízení přes control exporter se loguje do `ems.modbus_command`. **Verifikační job** běží každé **2 minuty** a ověřuje nedávno zápis (`written` → čtení registru). Při **mismatch** po max. **3** pokusech o zápis → u běžných registrů přepnutí na **SELF_SUSTAIN** (`run_fn_set_mode_with_discord` → `fn_set_mode`, `activated_by` = `system:mismatch`) + **Discord** při skutečné změně režimu. **Výjimka:** souvislý blok Deye **62–64** (čas) → po 3 neúspěšných ověřeních **bez** změny režimu, kritický **Discord** (`notify_modbus_clock_verify_exhausted`). **Obecně:** při jakékoli změně `mode_code` z Pythonu (`POST /api/v1/sites/{id}/mode`, mismatch → SELF_SUSTAIN, `fn_expire_modes`) lze Discord zapnout přes `DISCORD_WEBHOOK_URL`. Detail: `docs/04-modules/modbus-command-journal.md`. -18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). V **PASSIVE** a **SELL** jsou reg **108** / **109** obvykle na **maximum z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). Omezování pod maximum jinak brání Deye reagovat na nepředvídatelnou spotřebu a přebytky FVE. **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (0 = selling first jen ve **SELL**, jinak 1) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního syncu; `deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` po **úspěšném zápisu** 62–64 a znovu po **úspěšné toleranční verifikaci**; při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verify **vždy** čte 62–64 najednou — **reg 64 nesmí** do striktní větve; toleranční odchylka až **120 s**; po 3 neúspěších u hodin **bez** SELF_SUSTAIN (jen Discord). **SELL:** `grid_setpoint_w` < −200. **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně `battery_w=None` u SELF_SUSTAIN → plné limity 108/109). Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`. +18. **Deye zápis registrů 60–499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzické režimy střídače jsou tři:** **PASSIVE**, **SELL**, **CHARGE** (mapování z plánu / politik EMS v `control_exporter.get_deye_mode`). Reg **108** (charge A) se řídí záměrem solveru: **max** při nabíjení (`bat_w > 0`), **0** jinak (pass-through, self-consumption). Reg **109** (discharge A) vždy **max z DB** (**výjimka PRESERVE:** `lock_battery=True` → **0 / 0**). **Řízení:** time points – blok **1** = začátek **aktuálního** 15min slotu + plán pro tento slot, blok **2** = začátek **následujícího** slotu + plán pro něj (`current_slot_hhmm` / `next_slot_hhmm`); bloky **3–6** neaktivní **2355** (ne 23:59 kvůli firmware), zápis **nejednou častěji než 1× denně** (Europe/Prague) + při změně podpisu (`deye_tou_inactive_signature`: `HHMM|min_soc|reserve_soc|tp_discharge_w`, V028 meta + V029 komentář); **reg 166+** u TP: **SELL** = `reserve_soc_percent`, **PASSIVE** / řádky **3–6** = `min_soc_percent`. **108** / **109** / **141** (0) / **142** (`deye_zero_export_mode` z DB: 1 = to load / 2 = to CT v non-SELL; 0 = selling first ve **SELL**) / **178** (pevně **32** ve **SELL**, **48** v **PASSIVE** a **CHARGE** – bez read-modify-write) / **143** (export limit W z DB) / **145** (solar sell, vždy **1** = enabled) z **aktuálního** setpointu. **Reg 191** EMS **nezapisuje**. **Čas 62–64:** před zařazením do fronty **čtení** 62–64; zápis jen při driftu **> 60 s**, nebo **NULL** `deye_last_system_time_sync_at`, nebo uplynulých **24 h** od posledního syncu; `deye_last_system_time_sync_at` / `deye_last_system_time_sync_minute` po **úspěšném zápisu** 62–64 a znovu po **úspěšné toleranční verifikaci**; při chybě čtení se čas zapisuje; reg **64** se zapisuje s **sekundami 0**; verify **vždy** čte 62–64 najednou — **reg 64 nesmí** do striktní větve; toleranční odchylka až **120 s**; po 3 neúspěších u hodin **bez** SELF_SUSTAIN (jen Discord). **SELL:** `battery_w` < −500 a `grid_setpoint_w` < −200 (aktivní vybíjení baterie pro export). **CHARGE:** `battery_w` > 500 a `grid_setpoint_w` > 200. **PASSIVE:** ostatní (včetně pass-through s reg 108=0, self-consumption, `battery_w=None` u SELF_SUSTAIN). **Čtyři typy slotů:** Charge (108=max), Pass-through (108=0, PV→síť), Discharge-export (SELL, 142=0), Self-consumption (108=0, noc). Reg 109 vždy max kromě PRESERVE. Detail: `docs/04-modules/modbus-registers.md`, režimy: `docs/04-modules/operating-modes.md`. 19. **Baterie – export v LP:** V `solve_dispatch` binárka `z_export[t]`: pokud `grid_export` v daném slotu **≥ 1** W, platí koncové `soc[t] ≥ arb_base_wh` (ekonomická rezerva z DB, ne časová řada `arb_floor_series`). Bez exportu může plán jít k `min_soc_percent` (provozní podlaha; u paralelních packů často 11–12 %, migrace V029 + komentář sloupce). diff --git a/backend/services/control_exporter.py b/backend/services/control_exporter.py index e70bcb4..b475c01 100644 --- a/backend/services/control_exporter.py +++ b/backend/services/control_exporter.py @@ -62,8 +62,9 @@ DEYE_REGISTER_NAMES: dict[int, str] = { 108: "max_charge_a (max nabíjecí proud baterie)", 109: "max_discharge_a (max vybíjecí proud baterie)", 141: "energy_mode (0, EMS nemění)", - 142: "limit_control (0=selling first, 1=zero export built-in CT)", + 142: "limit_control (0=selling first, 1=zero export to load, 2=zero export to CT)", 143: "export_limit_w (max export do sítě)", + 145: "solar_sell (0=disabled, 1=enabled)", 178: "grid_peak_shaving_switch (SELL=32 bit4-5=10, PASSIVE/CHARGE=48 bit4-5=11)", 148: "time_point_1_time", 149: "time_point_2_time", @@ -152,6 +153,7 @@ class InverterConfig: deye_last_system_time_sync_at: datetime | None = None deye_last_tou_inactive_write_prague_date: date | None = None deye_tou_inactive_signature: str | None = None + deye_zero_export_mode: int = 1 def _prague_minute_start_utc() -> datetime: @@ -972,6 +974,7 @@ async def _load_inverter_config( ai.deye_tou_inactive_signature, ai.deye_register_max_charge_a, ai.deye_register_max_discharge_a, + COALESCE(ai.deye_zero_export_mode, 1) AS deye_zero_export_mode, LEAST( COALESCE(ab.bms_max_charge_w, ai.max_battery_charge_w), ai.max_battery_charge_w @@ -1047,6 +1050,7 @@ async def _load_inverter_config( "deye_last_tou_inactive_write_prague_date" ], deye_tou_inactive_signature=row["deye_tou_inactive_signature"], + deye_zero_export_mode=int(row["deye_zero_export_mode"]), ) @@ -1266,15 +1270,15 @@ def _deye_tou_reserve_soc_pct(inv: InverterConfig) -> int: def get_deye_mode(setpoints: ControlSetpoints) -> str: """ Fyzický režim Deye: SELL | CHARGE | PASSIVE. - Solver: záporný grid_setpoint_w = export; kladný výrazný + nabíjení = CHARGE ze sítě. - battery_w=None (SELF_SUSTAIN) → bat_w považuj za 0 → typicky PASSIVE při grid_setpoint_w=0. + + SELL only when battery actively discharges for grid export (bat_w < -500 + AND grid_w < -200). Pass-through (PV → grid, battery idle) stays PASSIVE + with reg 108 = 0 + reg 145 = 1 (solar sell). + battery_w=None (SELF_SUSTAIN) → bat_w considered 0 → PASSIVE. """ grid_w = int(setpoints.grid_setpoint_w or 0) - if setpoints.battery_w is None: - bat_w = 0 - else: - bat_w = int(setpoints.battery_w) - if grid_w < -200: + bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w) + if bat_w < -500 and grid_w < -200: return "SELL" if bat_w > 500 and grid_w > 200: return "CHARGE" @@ -1339,18 +1343,20 @@ async def write_inverter_setpoints( deye_mode = get_deye_mode(setpoints_now) + bat_w = int(raw_bat) if raw_bat is not None else 0 if setpoints_now.lock_battery: charge_a = 0 discharge_a = 0 elif deye_mode == "CHARGE": - battery_w = int(raw_bat) if raw_bat is not None else 0 - charge_a = battery_watts_to_amps(battery_w, eff_ca) + charge_a = battery_watts_to_amps(bat_w, eff_ca) discharge_a = 0 else: - charge_a = int(eff_ca) + charge_a = int(eff_ca) if bat_w > 0 else 0 discharge_a = int(eff_da) - selling_mode = 0 if deye_mode == "SELL" else 1 + zero_exp_mode = int(inv.deye_zero_export_mode or 1) + selling_mode = 0 if deye_mode == "SELL" else zero_exp_mode + solar_sell = 1 export_limit = export_lim reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE @@ -1358,8 +1364,7 @@ async def write_inverter_setpoints( f"[control] site={site_id} fyzický režim Deye: {deye_mode} | " f"battery_w={raw_bat!r} grid_w={grid_w} | " f"charge_a={charge_a} discharge_a={discharge_a} | " - f"reg142={'0=SELL' if deye_mode == 'SELL' else '1=ZERO_EXP'} " - f"reg178={reg178_val}" + f"reg142={selling_mode} reg145={solar_sell} reg178={reg178_val}" ) now_cet, time_rows = _deye_system_time_register_rows() @@ -1430,20 +1435,23 @@ async def write_inverter_setpoints( (108, "", charge_a), (109, "", discharge_a), (141, "energy_mode (0)", 0), - (142, "limit_control (0=selling, 1=zero_export)", selling_mode), - (178, "grid_peak_shaving_switch", reg178_val), + (142, "limit_control", selling_mode), (143, "", export_limit), + (145, "solar_sell", solar_sell), + (178, "grid_peak_shaving_switch", reg178_val), ] ) logger.info( - "[control] %s: deye_mode=%s charge=%sA discharge=%sA limit_control=%s export=%sW " - "time_point1=%s time_point2=%s soc_telemetry=%s%% (batt=%r grid=%sW)", + "[control] %s: deye_mode=%s charge=%sA discharge=%sA " + "reg142=%s reg145=%s export=%sW " + "tp1=%s tp2=%s soc=%s%% (batt=%r grid=%sW)", inv.code, deye_mode, charge_a, discharge_a, selling_mode, + solar_sell, export_limit, hh_cur, hh_nxt, @@ -1541,7 +1549,7 @@ async def write_inverter_setpoints( async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict[str, Any]: """ - Živé čtení holding registrů Deye 108, 109, 141, 142, 143, 178, 191 (stejné TCP spojení jako telemetrie/export). + Živé čtení holding registrů Deye 108, 109, 141–145, 178, 191 (stejné TCP spojení jako telemetrie/export). Vše pod jedním mutexem + sdružené FC3 bloky — mezi jednotlivými read_register dřív telemetrie střídavě brala lock a RS485 brány házely cizí transaction_id / I/O timeouty. """ @@ -1555,11 +1563,12 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict try: async with client.batch(uid) as mb: b108 = await mb.read_holding_registers(108, 2) - b141 = await mb.read_holding_registers(141, 3) + b141 = await mb.read_holding_registers(141, 5) r178 = await mb.read_holding_registers(178, 1) r191 = await mb.read_holding_registers(191, 1) r108, r109 = b108[0], b108[1] r141, r142, r143 = b141[0], b141[1], b141[2] + r145 = b141[4] r178 = r178[0] r191 = r191[0] except Exception: @@ -1572,6 +1581,7 @@ async def read_deye_registers_live(site_id: int, db: asyncpg.Connection) -> dict "reg141_energy_mode": int(r141), "reg142_limit_control": int(r142), "reg143_export_limit_w": int(r143), + "reg145_solar_sell": int(r145), "reg178_peak_shaving_switch": int(r178), "reg191_peak_shaving_w": int(r191), "read_at": read_at.isoformat(), diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 0a185d2..4aa6f53 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -149,6 +149,93 @@ def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]: return (loc.weekday() + 1) % 7, loc.hour +# ============================================================ +# Slot pre-selection (anti-micro-cycling) +# ============================================================ + +def _select_charge_slots( + slots: list["PlanningSlot"], + battery, + current_soc_wh: float, +) -> set[int]: + """ + Pre-select which slots are eligible for battery charging. + Only the X cheapest sell-price PV-surplus slots are selected, + enough to fill the battery with a configurable buffer. + Returns set of slot indices. Empty set = no restriction. + """ + charge_buf = float(getattr(battery, "charge_slot_buffer", 0) or 0) + if charge_buf <= 0: + return set(range(len(slots))) + + energy_to_fill = float(battery.soc_max_wh) - float(current_soc_wh) + if energy_to_fill <= 0: + return set() + + candidates: list[tuple[int, float, float]] = [] + for t, s in enumerate(slots): + pv_surplus = max(0, s.pv_a_forecast_w + s.pv_b_forecast_w - s.load_baseline_w) + if pv_surplus <= 0: + continue + charge_w = min(float(battery.max_charge_power_w), float(pv_surplus)) + charge_wh = charge_w * float(battery.charge_efficiency) * INTERVAL_H + candidates.append((t, float(s.sell_price), charge_wh)) + + candidates.sort(key=lambda x: x[1]) + + selected: set[int] = set() + cumulative = 0.0 + target = energy_to_fill * charge_buf + for t, _price, wh in candidates: + if cumulative >= target: + break + selected.add(t) + cumulative += wh + + if cumulative < energy_to_fill: + selected = set(c[0] for c in candidates) + + return selected + + +def _select_discharge_export_slots( + slots: list["PlanningSlot"], + battery, +) -> set[int]: + """ + Pre-select which slots may use battery energy for grid export. + Only the Y most expensive sell-price slots are selected, + enough to empty the exportable portion of the battery with a buffer. + Returns set of slot indices. Empty set = no restriction. + """ + discharge_buf = float(getattr(battery, "discharge_slot_buffer", 0) or 0) + if discharge_buf <= 0: + return set(range(len(slots))) + + exportable = float(battery.soc_max_wh) - float(battery.min_soc_wh) + if exportable <= 0: + return set() + + candidates = [(t, float(s.sell_price)) for t, s in enumerate(slots)] + candidates.sort(key=lambda x: x[1], reverse=True) + + energy_per_slot = ( + float(battery.max_discharge_power_w) + * float(battery.discharge_efficiency) + * INTERVAL_H + ) + target = exportable * discharge_buf + selected: set[int] = set() + cumulative = 0.0 + for t, _price in candidates: + if cumulative >= target: + break + selected.add(t) + cumulative += energy_per_slot + + return selected + + # ============================================================ # Datové třídy (lze nahradit pydantic modely) # ============================================================ @@ -448,11 +535,24 @@ def solve_dispatch( if price_failsafe_active: for t in range(T): - # Fail-safe aplikujeme po slotech: v predikovaných cenách zakážeme pouze export. - # Baterie se má dál normálně používat pro interní spotřebu (nabíjení/vybíjení do domu). if slots[t].is_predicted_price: prob += ge[t] == 0 + # Slot pre-selection: omezení nabíjení a discharge-exportu na vybrané sloty + if om == "AUTO": + charge_slots = _select_charge_slots(slots, battery, current_soc_wh) + discharge_export_slots = _select_discharge_export_slots(slots, battery) + for t in range(T): + if t not in charge_slots: + prob += bc[t] == 0 + + if t not in discharge_export_slots: + s = slots[t] + ev_total_t = pulp.lpSum( + ev_direct[e][t] + ev_via_bat[e][t] for e in range(EV) + ) + prob += bd[t] <= s.load_baseline_w + ev_total_t + hp[t] + # Deadline constraints pro EV for e, session in enumerate(ev_sessions): if session and session.target_deadline and session.energy_needed_wh > 0: @@ -795,6 +895,8 @@ async def _load_site_context(site_id: int, db): ab.charge_efficiency, ab.discharge_efficiency, ab.degradation_cost_czk_kwh, + ab.charge_slot_buffer, + ab.discharge_slot_buffer, LEAST( COALESCE(ai.max_battery_charge_w, ai.max_charge_power_w), COALESCE( @@ -856,6 +958,8 @@ async def _load_site_context(site_id: int, db): degradation_cost_czk_kwh=float(brow["degradation_cost_czk_kwh"]), max_charge_power_w=ec_i, max_discharge_power_w=ed_i, + charge_slot_buffer=float(brow["charge_slot_buffer"]) if brow["charge_slot_buffer"] is not None else 0, + discharge_slot_buffer=float(brow["discharge_slot_buffer"]) if brow["discharge_slot_buffer"] is not None else 0, ) hrow = await db.fetchrow( diff --git a/db/migration/V046__battery_slot_selection_and_registers.sql b/db/migration/V046__battery_slot_selection_and_registers.sql new file mode 100644 index 0000000..7564501 --- /dev/null +++ b/db/migration/V046__battery_slot_selection_and_registers.sql @@ -0,0 +1,40 @@ +-- V046: Battery slot selection buffers + Deye zero-export mode + solar sell register +-- +-- Solver: slot pre-selection eliminates battery micro-cycling. +-- Registers: reg 142 (zero export mode) per-inverter, reg 145 (solar sell) newly managed. + +-- ============================================================ +-- 1. Slot selection buffers on asset_battery +-- ============================================================ + +ALTER TABLE ems.asset_battery + ADD COLUMN IF NOT EXISTS charge_slot_buffer NUMERIC(3,1) DEFAULT 1.3, + ADD COLUMN IF NOT EXISTS discharge_slot_buffer NUMERIC(3,1) DEFAULT 1.5; + +COMMENT ON COLUMN ems.asset_battery.charge_slot_buffer IS + 'Buffer multiplier for charge slot count over minimum to fill battery (1.0 = exact, 1.3 = 30 % extra). NULL = no slot selection.'; +COMMENT ON COLUMN ems.asset_battery.discharge_slot_buffer IS + 'Buffer multiplier for discharge-export slot count over minimum to empty battery (1.0 = exact, 1.5 = 50 % extra). NULL = no slot selection.'; + +-- ============================================================ +-- 2. Deye zero-export mode on asset_inverter +-- ============================================================ + +ALTER TABLE ems.asset_inverter + ADD COLUMN IF NOT EXISTS deye_zero_export_mode SMALLINT DEFAULT 1; + +COMMENT ON COLUMN ems.asset_inverter.deye_zero_export_mode IS + 'Deye reg 142 value for non-SELL modes: 1 = zero export to load (no CT), 2 = zero export to CT. Depends on physical installation.'; + +-- ============================================================ +-- 3. Per-site seed values +-- ============================================================ + +-- BA81 (site_id=3, inverter_id=5): CT installed, bump degradation cost +UPDATE ems.asset_inverter SET deye_zero_export_mode = 2 WHERE id = 5; +UPDATE ems.asset_battery SET degradation_cost_czk_kwh = 1.00 WHERE site_id = 3; + +-- KV1 (site_id=4, inverter_id=7): CT installed +UPDATE ems.asset_inverter SET deye_zero_export_mode = 2 WHERE id = 7; + +-- home-01 (site_id=2, inverter_id=3): no CT — default 1 is correct diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index cffb51e..0452fad 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -109,6 +109,30 @@ def apply_overrides(plan, overrides) -> Setpoints: ## Zápis do Deye (Modbus) +### Fyzický režim (`get_deye_mode`) + +Solver rozlišuje **čtyři typy slotů**: **Charge**, **Pass-through**, **Discharge-export**, **Self-consumption**. Na úrovni Deye se mapují na tři fyzické režimy: + +| Fyzický režim | Podmínka z `ControlSetpoints` | +|---|---| +| **SELL** | `battery_w` < −500 **a** `grid_setpoint_w` < −200 (záměrné vybíjení baterie do sítě) | +| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 (nabíjení ze sítě) | +| **PASSIVE** | vše ostatní (pass-through, self-consumption, SELF_SUSTAIN) | + +**Pass-through** (PV → síť, baterie idle) zůstává **PASSIVE** — fyzicky se realizuje nastavením reg 108 = 0 (zákaz nabíjení) + reg 145 = 1 (solar sell), takže PV přebytky tečou do sítě. + +### Klíčové registry podle typu slotu + +| Registr | Charge | Pass-through | Discharge-export | Self-consumption | +|---|---|---|---|---| +| **108** (charge A) | max z DB | **0** | 0 | **0** | +| **109** (discharge A) | max | max | max | max | +| **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` | +| **145** (solar sell) | 1 | 1 | 1 | 1 | +| **178** (peak shaving) | 48 | 48 | **32** | 48 | + +Hodnota `deye_zero_export_mode` (1 = zero export to load, 2 = zero export to CT) pochází z `asset_inverter.deye_zero_export_mode` a závisí na fyzické instalaci (přítomnost CT). Detail v [`modbus-registers.md`](modbus-registers.md). + **TOU (time points, reg. 166+):** SOC závisí na fyzickém režimu z `get_deye_mode` — **SELL** zapisuje ekonomickou rezervu (`reserve_soc_percent`), **PASSIVE** a neaktivní řádky **3–6** provozní minimum (`min_soc_percent`). Viz [`modbus-registers.md`](modbus-registers.md). ```python diff --git a/docs/04-modules/modbus-registers.md b/docs/04-modules/modbus-registers.md index 9620cf8..df55030 100644 --- a/docs/04-modules/modbus-registers.md +++ b/docs/04-modules/modbus-registers.md @@ -17,13 +17,14 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi | 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě | | 130 | Grid charge enable | 0/1 | — | 1 = povolit nabíjení ze sítě | | 141 | Energy mgmt mode | bitmask | — | EMS vždy **0** (neměnit jinak) | -| 142 | Limit control | 0/1/2 | — | **0** = selling first, **1** = zero export (built-in CT); EMS přepíná export vs. idle/nabíjení | +| 142 | Limit control (System work mode) | 0/1/2 | — | **0** = selling first, **1** = zero export to load, **2** = zero export to CT. Hodnota v non-SELL režimech pochází z `asset_inverter.deye_zero_export_mode` (závisí na instalaci – viz tabulka níže). V režimu SELL vždy **0**. | +| 145 | Solar sell | 0/1 | — | **0** = disabled (omezí FVE aby neexportoval), **1** = enabled. EMS vždy zapisuje **1**. Při reg 108 = 0 (baterie se nenabíjí) a solar sell = 1 přebytky FVE tečou do sítě. | | 143 | Export limit W | závisí na typu (SUN-20K až ~13 500) | 1 W | Max export do sítě; hodnota z `site_grid_connection.max_export_power_w` | | 178 | Grid peak shaving switch | bitmask | — | EMS zapisuje **pevnou** hodnotu (bez read-modify-write kvůli kolizím s paralelním čtením z Loxone): **32** (`0b00100000`, bit4–5 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit4–5 = **11**) v **PASSIVE** a **CHARGE**. | | 190 | GEN peak shaving | 0–16000 | 1 W | Peak shaving na GEN portu | | 191 | Grid peak shaving power | 0–16000 | 1 W | **EMS NEZAPISUJE** – nastavit **manuálně v SolarmanApp**. Hodnota určuje výkon peak shavingu v **W**. | -`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal; jeden řádek na registr) a **`execute_modbus_commands`** odesílá **souvislé bloky jedním FC 0x10** (např. 62–64, 148–159, 166–177, 108–109, 141–142 podle toho, co je ve frontě). Pořadí v journalu: **62–64** (čas, viz níže), **time points 148–177** (jen řádky zařazené do daného běhu), **108, 109, 141, 142, 178, 143**. Popisné názvy v DB bere `DEYE_REGISTER_NAMES`. **Reg 191** EMS nezapisuje. +`control_exporter.write_inverter_setpoints` zapisuje přes **`modbus_command`** (journal; jeden řádek na registr) a **`execute_modbus_commands`** odesílá **souvislé bloky jedním FC 0x10** (např. 62–64, 148–159, 166–177, 108–109, 141–143, 145 podle toho, co je ve frontě). Pořadí v journalu: **62–64** (čas, viz níže), **time points 148–177** (jen řádky zařazené do daného běhu), **108, 109, 141, 142, 143, 145, 178**. Popisné názvy v DB bere `DEYE_REGISTER_NAMES`. **Reg 191** EMS nezapisuje. ### Reg 191 (výkon grid peak shaving) @@ -41,18 +42,7 @@ EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může ## Klíčové registry podle fyzického režimu Deye -Provozní režimy EMS (AUTO, SELF_SUSTAIN, SELL, …) se mapují na **tři fyzické režimy** střídače: **PASSIVE**, **SELL**, **CHARGE**. Ostatní je politika solveru / EMS, ne samostatný „režim“ invertoru. - -| Reg | PASSIVE | SELL | CHARGE | -|-----|---------|------|--------| -| 142 | 1 (zero export to load) | 0 (selling first) | 1 | -| 108 | `max_charge_a` z DB | `max_charge_a` z DB | `battery_watts_to_amps(battery_w, max_charge_a)` | -| 109 | `max_discharge_a` z DB | `max_discharge_a` z DB | 0 | -| 178 | 48 | 32 | 48 | -| 143 | max export W z DB | max export W z DB | max export W z DB | -| 141 | 0 | 0 | 0 | - -**Důležité:** V **PASSIVE** i **SELL** jsou registry **108** a **109** vždy na **plném limitu z DB**. Deye si tok energie reguluje sám; snížení 108/109 pod maximum brání reakci na nepředvídatelnou spotřebu nebo přebytky FVE. +Provozní režimy EMS (AUTO, SELF_SUSTAIN, SELL, …) se mapují na **tři fyzické režimy** střídače: **PASSIVE**, **SELL**, **CHARGE**. Solver navíc rozlišuje **čtyři typy slotů** – každý typ určuje specifickou kombinaci registrů. ### Detekce fyzického režimu (`get_deye_mode` v `control_exporter.py`) @@ -60,12 +50,50 @@ Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivn | Režim | Podmínka | |-------|----------| -| **SELL** | `grid_setpoint_w` < −200 | +| **SELL** | `battery_w` < −500 **a** `grid_setpoint_w` < −200 (aktivní vybíjení baterie pro export) | | **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 | -| **PASSIVE** | vše ostatní (včetně SELF_SUSTAIN, IDLE, …) | +| **PASSIVE** | vše ostatní (včetně pass-through, self-consumption, SELF_SUSTAIN, IDLE, …) | Režim **CHARGE_CHEAP** v EMS nastaví `grid_setpoint_w` tak, aby platila podmínka importu (> 200 W), jinak by fyzicky zůstal PASSIVE. +**Důležité:** SELL se aktivuje **pouze** při záměrném vybíjení baterie do sítě (`bat_w < −500`). Pass-through (PV → síť, baterie idle) zůstává v PASSIVE s reg 108 = 0. + +### Čtyři typy slotů a mapování na registry + +Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_slots`, `_select_discharge_export_slots`). Výsledné setpointy pak určují typ slotu: + +| | **Charge** | **Pass-through** | **Discharge-export** | **Self-consumption** | +|---|---|---|---|---| +| **Kdy** | Solver: `bat_w > 0` | Solver: `bat_w == 0`, PV > spotřeba | Solver: `bat_w < −500`, `grid_w < −200` | Noc / PV < spotřeba | +| **Deye mode** | PASSIVE | PASSIVE | SELL | PASSIVE | +| **108** charge A | **max** (z DB) | **0** | 0 | **0** | +| **109** discharge A | max | **max** | **max** | **max** | +| **142** limit control | `deye_zero_export_mode` (1 nebo 2) | `deye_zero_export_mode` (1 nebo 2) | **0** (selling first) | `deye_zero_export_mode` (1 nebo 2) | +| **145** solar sell | **1** (enabled) | **1** (enabled) | **1** (enabled) | **1** (enabled) | +| **178** peak shaving | 48 (PASSIVE) | 48 (PASSIVE) | **32** (SELL) | 48 (PASSIVE) | +| **143** export limit | max export W z DB | max export W z DB | max export W z DB | max export W z DB | +| **141** energy mode | 0 | 0 | 0 | 0 | +| **TOU SOC** | min_soc_pct | min_soc_pct | reserve_soc_pct | min_soc_pct | + +**Jak funguje pass-through fyzicky:** + +1. Reg 108 = 0 → baterie se fyzicky nemůže nabíjet (Deye ji považuje za „plnou") +2. Reg 142 = 1/2 → zero export mode (Deye nebude aktivně prodávat z baterie) +3. Reg 145 = 1 → solar sell enabled: protože baterie je „plná" (108 = 0), PV přebytky tečou do sítě +4. Reg 109 = max → pokud spotřeba překročí FVE, baterie může vybíjet (ochrana self-consumption) + +### `deye_zero_export_mode` per inverter + +Hodnota registru 142 v non-SELL režimech závisí na fyzické instalaci. Uložena v `asset_inverter.deye_zero_export_mode`: + +| Site | Inverter ID | `deye_zero_export_mode` | Důvod | +|---|---|---|---| +| home-01 (id=2) | 3 | **1** (zero export to load) | Nemá CT | +| BA81 (id=3) | 5 | **2** (zero export to CT) | CT osazeno | +| KV1 (id=4) | 7 | **2** (zero export to CT) | CT osazeno | + +**Varování:** Záměna způsobí chybné měření – pokud site nemá CT a nastaví se „to CT" (2), střídač nevidí skutečný odběr. Naopak pokud má CT ale nastaví se „to load" (1), zátěže mimo load port (např. wallbox) nebudou vidět. + Limity `max_charge_a` / `max_discharge_a` (odvozené z W a BMS) a volitelné stropy **`deye_register_max_charge_a` / `deye_register_max_discharge_a`** pocházejí z DB (`_load_inverter_config`, migrace **V044**). `max_export_power_w` / reg 143 také z DB. ## Time Points – řízení podle fyzického režimu @@ -80,7 +108,7 @@ Deye má 6 časových bloků. EMS přepisuje **bloky 1–2** (TOU index 0–1) p | 2 | **`next_slot_hhmm()`** – začátek **následujícího** 15min slotu | `planning_interval` pro **další** slot (`_fetch_plan_row_for_slot_offset(..., 1)`) | Přechod na další čtvrthodinu | viz tabulka níže | viz tabulka níže | | 3–6 | **23:55** (2355) | — | Neaktivní (pasivní profil); ne 23:59 — firmware Deye často 2359 neuloží → verify mismatch | **`min_soc_percent`** (DB) | NE | -**Registry 108 / 109 / 142 / 178 / 143** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 1–2 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`). +**Registry 108 / 109 / 141 / 142 / 143 / 145 / 178** odpovídají **aktuálnímu** plánu (okamžitý výstup; `setpoints_now` v `write_inverter_setpoints`). TOU řádky 1–2 doplňují stejnou logiku pro časové segmenty (`_deye_tou_params`). Příklad v 14:18: blok 1 má čas **1415**, blok 2 čas **1430** – mezi 14:15 a 14:29 je aktivní segment z bloku 1 (sladěný s plánem pro 14:15–14:30), po 14:30 blok 2 (plán 14:30–14:45). Po dalším exportu se oba časy posunou (např. 14:30 / 14:45). @@ -161,6 +189,7 @@ async def check(): for name, reg in [ ('Limit control', 142), + ('Solar sell', 145), ('Peak sw (bit4-5)', 178), ('Export limit', 143), ('Discharge A', 109), @@ -181,7 +210,7 @@ docker compose exec db psql -U ems_user -d ems -c " SELECT register_name, value_to_write, status, created_at AT TIME ZONE 'Europe/Prague' AS cas FROM ems.modbus_command - WHERE site_id=2 AND register IN (108, 109, 142) + WHERE site_id=2 AND register IN (108, 109, 142, 145) ORDER BY created_at DESC LIMIT 9;" ``` diff --git a/scripts/analysis/battery_sizing_screen.py b/scripts/analysis/battery_sizing_screen.py index 8910bb1..c64fbe9 100644 --- a/scripts/analysis/battery_sizing_screen.py +++ b/scripts/analysis/battery_sizing_screen.py @@ -13,22 +13,27 @@ Připojení k DB (deploy / Docker): - Nebo ``DATABASE_URL`` / ``postgresql://USER:PASS@HOST:5432/ems`` (na hostu HOST=127.0.0.1 nebo EMS_DB_BIND, ne ``db`` — to je jen uvnitř Docker sítě). -Příklad: - python3 scripts/analysis/battery_sizing_screen.py \\ - --db \\ +Příklad (syntetická FVE, flat nákup): + python3 scripts/analysis/battery_sizing_screen.py --db \\ --date-from 2024-04-01 --date-to 2026-04-01 \\ - --battery-kwh 12.5 32 48 \\ - --load-kw 1.2 \\ + --battery-kwh 12.5 32 48 --load-kw 1.2 \\ --pv-daily-kwh-summer 55 --pv-daily-kwh-winter 12 \\ - --sell-margin-fixed -0.02 \\ - --buy-vat-kwh 4.443 \\ - --capex-per-kwh 9000 + --sell-margin-fixed -0.02 --buy-vat-kwh 4.443 --capex-per-kwh 9000 + +Příklad (PVGIS měsíční E_d + NT/VT): + python3 scripts/analysis/battery_sizing_screen.py --db \\ + --pvgis-csv pole_A.csv --pvgis-csv pole_B.csv \\ + --buy-nt-kwh 5.25 --buy-vt-surcharge-kwh 2.0 --nt-from-hour 22 --nt-to-hour 6 \\ + ... (ostatní jako výše) Vyžaduje: pip install pulp (volitelně psycopg2 pro --db). -Omezení modelu: syntetický denní tvar FVE (kalibruj --pv-daily-kwh-* podle měření); -mikroinvertory / GEN nejsou; zelený bonus není v účelové funkci; nákup je jedna flat -sazba vč. DPH (reálné NT/VT přes HDO přidej později). Výsledek = screening, ne nabídka. +Omezení modelu: FVE buď syntetický denní tvar (--pv-daily-kwh-*), nebo součet měsíčních +E_d z PVGIS CSV (--pvgis-csv, opakovat pro více orientací); denní energie = E_d měsíce +× normalizovaný tvar (stejný profil každý den v měsíci). Nákup: buď flat (--buy-vat-kwh), +nebo NT/VT podle hodin Europe/Prague: --buy-nt-kwh, VT = NT + --buy-vt-surcharge-kwh, +okno NT --nt-from-hour až --nt-to-hour (přes půlnoc, pokud from > to). Mikroinvertory / GEN +nejsou; zelený bonus není v účelové funkci. Výsledek = screening, ne nabídka. """ from __future__ import annotations @@ -40,7 +45,7 @@ import sys from dataclasses import dataclass from datetime import date, datetime, timedelta from pathlib import Path -from typing import Iterable, Sequence +from typing import Iterable, Sequence, Mapping try: import pulp @@ -93,6 +98,72 @@ def daily_pv_wh(d: date, summer_kwh: float, winter_kwh: float, shape: Sequence[f return [base * 1000.0 * sh for sh in shape] +def load_pvgis_monthly_ed_kwh(path: Path) -> dict[int, float]: + """Z PVGIS CSV (Fixed angle) načte E_d [kWh/d] pro měsíce 1–12.""" + text = path.read_text(encoding="utf-8", errors="replace").splitlines() + start: int | None = None + for i, line in enumerate(text): + if line.strip().startswith("Fixed angle"): + start = i + 2 + break + if start is None: + raise ValueError(f"PVGIS: řádek 'Fixed angle' nenalezen: {path}") + out: dict[int, float] = {} + for line in text[start:]: + cells = [c.strip() for c in line.split("\t") if c.strip() != ""] + if not cells: + continue + if cells[0] == "Year": + break + try: + month = int(cells[0]) + except ValueError: + continue + if not (1 <= month <= 12): + continue + out[month] = float(cells[1].replace(",", ".")) + if len(out) != 12: + raise ValueError(f"PVGIS: očekáváno 12 měsíců E_d v {path}, mám {sorted(out.keys())}") + return out + + +def merge_pvgis_monthly_ed_kwh(paths: Sequence[Path]) -> dict[int, float]: + """Sečte E_d jednotlivých polí (např. dvě orientace).""" + total = {m: 0.0 for m in range(1, 13)} + for p in paths: + part = load_pvgis_monthly_ed_kwh(Path(p)) + for m in range(1, 13): + total[m] += part[m] + return total + + +def daily_pv_wh_monthly(d: date, monthly_ed_kwh: Mapping[int, float], shape: Sequence[float]) -> list[float]: + kwh = float(monthly_ed_kwh[d.month]) + return [kwh * 1000.0 * sh for sh in shape] + + +def buy_prices_96_nt_vt( + nt_kwh: float, + vt_kwh: float, + nt_from_hour: int, + nt_to_hour: int, +) -> list[float]: + """ + 96 cen nákupu [Kč/kWh] podle začátku 15min slotu (hodina 0–23, Europe/Prague). + Pokud nt_from_hour > nt_to_hour: NT pro hodiny >= from nebo < to (přes půlnoc). + Jinak NT pro from <= h < to. + """ + out: list[float] = [] + for t in range(SLOTS_PER_DAY): + h = t // 4 + if nt_from_hour > nt_to_hour: + is_nt = h >= nt_from_hour or h < nt_to_hour + else: + is_nt = nt_from_hour <= h < nt_to_hour + out.append(nt_kwh if is_nt else vt_kwh) + return out + + def daily_load_wh(load_kw: float) -> list[float]: e_per_slot = load_kw * 1000.0 * DT_H return [e_per_slot] * SLOTS_PER_DAY @@ -221,7 +292,7 @@ def solve_one_day( pv_wh: Sequence[float], load_wh: Sequence[float], p_sell: Sequence[float], - p_buy_flat: float, + p_buy: Sequence[float], e_usable_wh: float, p_batt_w: float, site: SiteLimits, @@ -262,7 +333,7 @@ def solve_one_day( soc[t + 1] == soc[t] + site.eta_charge * ch[t] - dis[t] / site.eta_discharge ), f"socdyn_{t}" - obj.append(p_sell[t] * gexp[t] / 1000.0 - p_buy_flat * gimp[t] / 1000.0) + obj.append(p_sell[t] * gexp[t] / 1000.0 - p_buy[t] * gimp[t] / 1000.0) prob += pulp.lpSum(obj) @@ -285,15 +356,23 @@ def simulate_year( site: SiteLimits, sell_margin_fixed: float, sell_margin_pct: float, - buy_vat_kwh: float, + buy_flat_kwh: float, + buy_prices_96: Sequence[float] | None, summer_kwh: float, winter_kwh: float, load_kw: float, shape: Sequence[float], + monthly_ed_kwh: Mapping[int, float] | None, ) -> dict[str, float]: e_wh = usable_kwh * 1000.0 p_batt = batt_power_cap_w(usable_kwh, site) load_wh = daily_load_wh(load_kw) + if buy_prices_96 is not None: + if len(buy_prices_96) != SLOTS_PER_DAY: + raise ValueError("buy_prices_96 musí mít 96 hodnot") + p_buy_day: Sequence[float] = buy_prices_96 + else: + p_buy_day = [buy_flat_kwh] * SLOTS_PER_DAY cash_total = 0.0 curt_total = 0.0 dis_total = 0.0 @@ -304,9 +383,12 @@ def simulate_year( continue raw = px_day[d] p_sell = [effective_sell_kc_kwh(x, sell_margin_fixed, sell_margin_pct) for x in raw] - pv_wh = daily_pv_wh(d, summer_kwh, winter_kwh, shape) + if monthly_ed_kwh is not None: + pv_wh = daily_pv_wh_monthly(d, monthly_ed_kwh, shape) + else: + pv_wh = daily_pv_wh(d, summer_kwh, winter_kwh, shape) cash, soc_state, curt, dis = solve_one_day( - pv_wh, load_wh, p_sell, buy_vat_kwh, e_wh, p_batt, site, soc_state + pv_wh, load_wh, p_sell, p_buy_day, e_wh, p_batt, site, soc_state ) cash_total += cash curt_total += curt @@ -346,7 +428,33 @@ def main() -> None: ap.add_argument("--pv-daily-kwh-winter", type=float, default=10.0) ap.add_argument("--sell-margin-fixed", type=float, default=-0.02) ap.add_argument("--sell-margin-pct", type=float, default=0.0) - ap.add_argument("--buy-vat-kwh", type=float, default=4.443, help="Efektivní nákup Kč/kWh vč. DPH (flat screening)") + ap.add_argument( + "--buy-vat-kwh", + type=float, + default=4.443, + help="Flat nákup Kč/kWh (když není --buy-nt-kwh)", + ) + ap.add_argument( + "--buy-nt-kwh", + type=float, + default=None, + help="NT cena Kč/kWh; VT = NT + --buy-vt-surcharge-kwh; okno --nt-from-hour / --nt-to-hour (Europe/Prague)", + ) + ap.add_argument( + "--buy-vt-surcharge-kwh", + type=float, + default=0.0, + help="Příplatek VT oproti NT (jako buy_fixed_vt_surcharge v EMS)", + ) + ap.add_argument("--nt-from-hour", type=int, default=22, help="Začátek NT (hodina 0–23)") + ap.add_argument("--nt-to-hour", type=int, default=6, help="Konec NT: první hodina VT (0–23); přes půlnoc pokud from > to") + ap.add_argument( + "--pvgis-csv", + action="append", + default=[], + metavar="PATH", + help="PVGIS měsíční E_d (Fixed angle); opakovat pro více polí/orientací, energie se sečte", + ) ap.add_argument("--max-export-w", type=float, default=16_000.0) ap.add_argument("--max-import-w", type=float, default=17_000.0) ap.add_argument("--inv-batt-max-w", type=float, default=12_000.0) @@ -379,6 +487,23 @@ def main() -> None: c_rate=args.c_rate, ) + monthly_ed: dict[int, float] | None = None + if args.pvgis_csv: + monthly_ed = merge_pvgis_monthly_ed_kwh([Path(p) for p in args.pvgis_csv]) + + if args.buy_nt_kwh is not None: + vt = args.buy_nt_kwh + args.buy_vt_surcharge_kwh + buy_prices_96 = buy_prices_96_nt_vt( + args.buy_nt_kwh, + vt, + args.nt_from_hour, + args.nt_to_hour, + ) + buy_flat = args.buy_vat_kwh + else: + buy_prices_96 = None + buy_flat = args.buy_vat_kwh + day_list = [d0 + timedelta(days=i) for i in range((d1 - d0).days)] results = [] @@ -390,19 +515,40 @@ def main() -> None: site, args.sell_margin_fixed, args.sell_margin_pct, - args.buy_vat_kwh, + buy_flat, + buy_prices_96, args.pv_daily_kwh_summer, args.pv_daily_kwh_winter, args.load_kw, shape, + monthly_ed, ) results.append((kwh, r)) baseline_kwh = min(args.battery_kwh) base = dict(results)[baseline_kwh] - print("Parametry: prodej = OTE + sell_margin_fixed (+ %), nákup = flat buy_vat_kwh") - print(f" FVE tvar = syntetický den, léto {args.pv_daily_kwh_summer} kWh/d, zima {args.pv_daily_kwh_winter} kWh/d, load {args.load_kw} kW") + print("Parametry: prodej = OTE + sell_margin_fixed (+ %)") + if buy_prices_96 is not None: + vt_show = args.buy_nt_kwh + args.buy_vt_surcharge_kwh + print( + f" Nákup = NT/VT: NT {args.buy_nt_kwh} Kč/kWh, VT {vt_show} Kč/kWh " + f"(okno NT {args.nt_from_hour:02d}–{args.nt_to_hour:02d} h lokální)" + ) + else: + print(f" Nákup = flat {args.buy_vat_kwh} Kč/kWh") + if monthly_ed is not None: + edv = [monthly_ed[m] for m in range(1, 13)] + print( + f" FVE = PVGIS měsíční E_d (součet {len(args.pvgis_csv)} souborů), " + f"rozsah {min(edv):.1f}–{max(edv):.1f} kWh/d, denní tvar = syntetika" + ) + else: + print( + f" FVE = syntetický den, léto {args.pv_daily_kwh_summer} kWh/d, " + f"zima {args.pv_daily_kwh_winter} kWh/d" + ) + print(f" Load (konstanta) {args.load_kw} kW") print(f" Limity: export {args.max_export_w} W, import {args.max_import_w} W, P_batt = min({args.c_rate}*E_kWh, {args.inv_batt_max_w} W)") print() diff --git a/scripts/analysis/data/kv2/PVdata_49.241_17.472_SA3_crystSi_8kWp_14_15deg_-76deg.csv b/scripts/analysis/data/kv2/PVdata_49.241_17.472_SA3_crystSi_8kWp_14_15deg_-76deg.csv new file mode 100644 index 0000000..ba9cc3b --- /dev/null +++ b/scripts/analysis/data/kv2/PVdata_49.241_17.472_SA3_crystSi_8kWp_14_15deg_-76deg.csv @@ -0,0 +1,34 @@ +Latitude (decimal degrees): 49.241 +Longitude (decimal degrees): 17.472 +Radiation database: PVGIS-SARAH3 +Nominal power of the PV system (c-Si) (kWp): 8.0 +System losses(%): 14.0 +Fixed slope of modules (deg.): 15 +Orientation (azimuth) of modules (deg.): -76 + +Fixed angle +Month E_d E_m H(i)_d H(i)_m SD_m +1 6.51 201.76 1.01 31.39 40.32 +2 11.5 322.13 1.73 48.35 56.66 +3 20.7 641.58 3.11 96.47 92.53 +4 30.11 903.3 4.66 139.66 102.82 +5 33.5 1038.44 5.27 163.47 146.81 +6 37.65 1129.53 6.06 181.91 112.87 +7 35.78 1109.15 5.86 181.56 109.26 +8 31.19 967.04 5.07 157.2 88.94 +9 24.0 719.86 3.8 113.88 77.04 +10 14.63 453.54 2.29 70.9 71.15 +11 7.34 220.22 1.17 35.16 28.46 +12 4.93 152.73 0.81 25.12 22.46 +Year 21.53 654.94 3.41 103.76 23.94 + AOI loss (%) Spectral effects (%) Temperature and low irradiance loss (%) Combined loss (%) +Fixed angle: -3.8 1.38 -5.92 -21.1 + +E_d: Average daily energy production from the given system (kWh/d) +E_m: Average monthly energy production from the given system (kWh/mo) +H(i)_d: Average daily sum of global irradiation per square meter received by the modules of the given system (kWh/m2/d) +H(i)_m: Average monthly sum of global irradiation per square meter received by the modules of the given system (kWh/m2/mo) +SD_m: Standard deviation of the monthly energy production due to year-to-year variation (kWh) + + +PVGIS (c) European Union, 2001-2026 diff --git a/scripts/analysis/data/kv2/PVdata_49.241_17.474_SA3_crystSi_8kWp_14_15deg_104deg.csv b/scripts/analysis/data/kv2/PVdata_49.241_17.474_SA3_crystSi_8kWp_14_15deg_104deg.csv new file mode 100644 index 0000000..6e68131 --- /dev/null +++ b/scripts/analysis/data/kv2/PVdata_49.241_17.474_SA3_crystSi_8kWp_14_15deg_104deg.csv @@ -0,0 +1,34 @@ +Latitude (decimal degrees): 49.241 +Longitude (decimal degrees): 17.474 +Radiation database: PVGIS-SARAH3 +Nominal power of the PV system (c-Si) (kWp): 8.0 +System losses(%): 14.0 +Fixed slope of modules (deg.): 15 +Orientation (azimuth) of modules (deg.): 104 + +Fixed angle +Month E_d E_m H(i)_d H(i)_m SD_m +1 5.17 160.32 0.85 26.26 23.55 +2 9.83 275.32 1.52 42.62 39.6 +3 18.58 575.98 2.83 87.86 74.67 +4 28.37 850.97 4.41 132.31 91.17 +5 32.39 1004.0 5.11 158.3 131.56 +6 36.78 1103.33 5.93 177.9 103.53 +7 34.87 1081.07 5.71 177.13 96.37 +8 29.5 914.54 4.81 149.23 71.24 +9 22.01 660.26 3.51 105.44 74.33 +10 12.87 398.83 2.06 63.77 60.0 +11 6.1 183.02 1.02 30.51 20.03 +12 3.84 118.91 0.67 20.83 12.24 +Year 20.07 610.54 3.21 97.68 18.01 + AOI loss (%) Spectral effects (%) Temperature and low irradiance loss (%) Combined loss (%) +Fixed angle: -4.35 1.34 -6.28 -21.87 + +E_d: Average daily energy production from the given system (kWh/d) +E_m: Average monthly energy production from the given system (kWh/mo) +H(i)_d: Average daily sum of global irradiation per square meter received by the modules of the given system (kWh/m2/d) +H(i)_m: Average monthly sum of global irradiation per square meter received by the modules of the given system (kWh/m2/mo) +SD_m: Standard deviation of the monthly energy production due to year-to-year variation (kWh) + + +PVGIS (c) European Union, 2001-2026 diff --git a/scripts/analysis/ote_arbitrage_proxy.sql b/scripts/analysis/ote_arbitrage_proxy.sql new file mode 100644 index 0000000..4758d80 --- /dev/null +++ b/scripts/analysis/ote_arbitrage_proxy.sql @@ -0,0 +1,104 @@ +-- ============================================================= +-- Hrubý odhad „kolik by přineslo přesunout ~30 kWh/den“ z levných +-- slotů (OTE sell < práh) do večerních/ranních špiček. +-- +-- Zjednodušení: +-- • Kalendářní den v Europe/Prague. +-- • Bereme jen dny, kde existuje aspoň jeden 15min slot s sell < :cheap_thr. +-- • „Levná“ strana: průměrná OTE sell ve všech slotech toho dne s sell < :cheap_thr. +-- • „Drahá“ strana: okno večer+ráno (18:00–24:00 a 0:00–8:00), seřadíme sloty +-- podle ceny DESC, vyhodíme :drop_top nejvyšších (malá baterie je už „sežere“), +-- vezmeme dalších :take_slot 15min intervalů → :take_slot × 0,25 h × 12 kW = 30 kWh +-- při 12 kW a take_slot = 10. +-- • Hrubý přínos (Kč/den) ≈ :shift_kwh * (avg_peak - avg_cheap); bez účinnosti baterie. +-- +-- Uprav parametry v nejníže (date_trunc rozsah, práhy, počty slotů). +-- Spuštění: psql -v ON_ERROR_STOP=1 -f scripts/analysis/ote_arbitrage_proxy.sql +-- ============================================================= + +WITH params AS ( + SELECT + 0.3::numeric AS cheap_thr, + 0::int AS drop_top, -- vynechat N nejdražších 15min ve večer+ráno okně + 12::int AS take_slot, -- dalších N slotů = 2,5 h při 15 min + 30::numeric AS shift_kwh -- objem energie pro hrubý spread (volitelně = take_slot * 0.25 * 12) +), +slots AS ( + SELECT + interval_start, + (interval_start AT TIME ZONE 'Europe/Prague')::date AS d, + (interval_start AT TIME ZONE 'Europe/Prague')::time AS t, + sell_raw_price_czk_kwh::numeric AS sell + FROM ems.market_interval_price + WHERE market_source = 'OTE_CZ' + AND interval_start >= TIMESTAMPTZ '2025-04-01 Europe/Prague' + AND interval_start < TIMESTAMPTZ '2026-04-01 Europe/Prague' +), +days_cheap AS ( + SELECT s.d + FROM slots s + GROUP BY s.d + HAVING MIN(s.sell) < (SELECT cheap_thr FROM params) +), +cheap_side AS ( + SELECT + s.d, + AVG(s.sell) AS avg_cheap + FROM slots s + INNER JOIN days_cheap dc ON dc.d = s.d + WHERE s.sell < (SELECT cheap_thr FROM params) + GROUP BY s.d +), +evening_morning AS ( + SELECT + s.d, + s.sell, + s.t + FROM slots s + INNER JOIN days_cheap dc ON dc.d = s.d + WHERE s.t >= TIME '18:00' + OR s.t < TIME '08:00' +), +ranked AS ( + SELECT + em.d, + em.sell, + ROW_NUMBER() OVER (PARTITION BY em.d ORDER BY em.sell DESC, em.t) AS rn + FROM evening_morning em +), +peak_pick AS ( + SELECT + r.d, + r.sell + FROM ranked r + WHERE r.rn > (SELECT drop_top FROM params) + AND r.rn <= (SELECT drop_top + take_slot FROM params) +), +peak_side AS ( + SELECT d, AVG(sell) AS avg_peak + FROM peak_pick + GROUP BY d +), +per_day AS ( + SELECT + c.d, + c.avg_cheap, + p.avg_peak, + (SELECT shift_kwh FROM params) * (p.avg_peak - c.avg_cheap) AS rough_kc_day + FROM cheap_side c + INNER JOIN peak_side p ON p.d = c.d +), +period AS ( + SELECT + (TIMESTAMPTZ '2026-04-01 Europe/Prague' - TIMESTAMPTZ '2024-04-01 Europe/Prague') + AS len +) +SELECT + COUNT(*)::int AS days_qualifying, + ROUND(SUM(pd.rough_kc_day)::numeric, 2) AS spread_kc_sum_period, + ROUND(AVG(pd.rough_kc_day)::numeric, 4) AS spread_kc_avg_per_qualifying_day, + ROUND( + (SUM(pd.rough_kc_day) / NULLIF(EXTRACT(EPOCH FROM (SELECT len FROM period)) / 86400.0, 0) * 365.0)::numeric, + 2 + ) AS spread_kc_naive_per_solar_year +FROM per_day pd;