solver nastavuje stavy deye
Some checks failed
CI and deploy / migration-check (push) Failing after 14s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-04-20 08:33:56 +02:00
parent 6447666cee
commit 43b594c8d5
10 changed files with 219 additions and 70 deletions

View File

@@ -91,7 +91,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
- Pět hodnot `mode_code` v `ems.site_operating_mode`: **AUTO**, **SELF_SUSTAIN**, **CHARGE_CHEAP**, **PRESERVE**, **MANUAL**.
- Režim se načítá v `planning_engine._load_site_context()`; **dodatečné LP constraints** podle režimu jsou v **`solve_dispatch()`** (žádný export / limit importu / zákaz nabíjení nebo vybíjení baterie podle módu).
- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `control_exporter.get_deye_mode` a zapisují v `write_inverter_setpoints`.
- **Fyzické režimy Deye** (PASSIVE / SELL / CHARGE) se odvozují v `exporter_monolith.get_deye_mode` a zapisují v `write_inverter_setpoints`.
- **`lock_battery=True`** u `ControlSetpoints` (PRESERVE): registry **108/109 = 0** Deye baterii nepoužívá. Výjimka oproti obecnému pravidlu max A ve PASSIVE/SELL.
12. **`forecast_pv_run` a `forecast_pv_interval` se NESMÍ mazat** historické běhy zůstávají v DB pro tracking přesnosti (`forecast_accuracy`, `fn_fill_forecast_accuracy`).
@@ -106,7 +106,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st
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 **6264** (č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ů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Režimy:** `get_deye_mode`**SELL** jen při **\|battery_w\| ≥ \|grid_setpoint_w\|** a obou záporných (záměr výdeje baterie do sítě); **CHARGE** při `battery_w` > 500 a `grid_setpoint_w` > 200; jinak **PASSIVE**. **PASSIVE (AUTO):** reg. **108** i **109** na **max. proud z DB** (plný rozsah baterie); jemnější výkon drží **TOU** z plánu. **SELL:** 108=0, 109=max, **143** omezeno podle `|grid_setpoint_w|`; **142/178/145/TOU** jako v `write_inverter_setpoints`. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 6264**, bloky TOU **12** vs **36**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`docs/04-modules/operating-modes.md`**.
18. **Deye zápis registrů 60499:** pouze **FC 0x10** (`write_registers`), **nikdy** FC 0x06 pro tento rozsah; **`execute_modbus_commands`** slučuje souvislé adresy do jednoho FC 0x10. **Fyzický režim Deye** (`PASSIVE` / `CHARGE` / `SELL`): **výhradně** `get_deye_mode` v `exporter_monolith.py` (bez wattových prahů: **SELL** při `battery_w` < 0 a `grid_setpoint_w` < 0; **CHARGE** při obou > 0; jinak **PASSIVE**). **PASSIVE (ZERO, AUTO):** **108/109** dle `_deye_zero_export_amps_for_passive`; **TOU** z plánu. **SELL:** 108=0, 109=max, **178**=32 (peak shaving off), **143** omezeno podle `|grid_setpoint_w|`; **142/145/TOU** jako v `write_inverter_setpoints`. **PRESERVE:** `lock_battery` → 108/109=0. **Čas 6264**, bloky TOU **12** vs **36**, verify, Discord: beze změny oproti dřívějšímu chování — plný popis **`docs/04-modules/modbus-registers.md`** a **`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 1112 %, migrace V029 + komentář sloupce).

View File

@@ -32,7 +32,7 @@ DEYE_CLOCK_RESYNC_INTERVAL_HOURS = 24
# Deye LV baterie: převod výkon → proud pro registry 108/109 (viz docs/04-modules/modbus-registers.md)
BATT_VOLTAGE_V = 51.2
# Reg 143 ve SELL: strop exportu = min(site max, |grid_setpoint|), dolní podlahová konstanta kvůli nule.
# Reg 143 ve SELL: min(|grid_setpoint_w|, …) nesmí klesnout pod tuto podlahu (W) — kvůli chování firmware, ne mapování režimu.
REG143_SELL_CAP_MIN_W = 200
# Reg 178 pevné hodnoty (bit45); bez read-modify-write (kolize s Loxone / transaction ID)
@@ -340,6 +340,8 @@ class ControlSetpoints:
ev1_power_w: int
ev2_power_w: int
target_soc_pct: int | None = None
#: Explicitní fyzický režim z plánu (PASSIVE/SELL/CHARGE). Pokud je vyplněn, má přednost před detekcí ze znamének.
deye_physical_mode: str | None = None
#: Efektivní vykupní cena slotu (Kč/kWh z plánu); pro TOU řízení priorit baterie vs. přetok
effective_sell_price_czk_kwh: float | None = None
#: True = reg 108/109 na 0 (PRESERVE Deye baterii nepoužívá)
@@ -1220,6 +1222,8 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
hp_en = bool(pi["heat_pump_enabled"])
tgt = pi["battery_soc_target_pct"]
target_soc = int(round(float(tgt))) if tgt is not None else None
pm_raw = pi.get("deye_physical_mode")
pm: str | None = str(pm_raw).strip().upper() if pm_raw is not None else None
sell_raw = pi.get("effective_sell_price")
sell_f: float | None = float(sell_raw) if sell_raw is not None else None
return ControlSetpoints(
@@ -1232,6 +1236,7 @@ def _build_setpoints(mode: OperatingModeInfo, pi: asyncpg.Record | None) -> Cont
ev1_power_w=ev1_w,
ev2_power_w=ev2_w,
target_soc_pct=target_soc,
deye_physical_mode=pm,
effective_sell_price_czk_kwh=sell_f,
)
@@ -1373,31 +1378,52 @@ def _deye_passive_tou_battery_soc_pct(
return _clamp_deye_tou_soc_pct_hi(DEYE_TOU_SOC_PASSIVE_BATTERY_PRIORITY_PCT, hi=100)
def _deye_zero_export_amps_for_passive(
grid_w: int,
bat_w: int,
max_charge_a: int,
max_discharge_a: int,
) -> tuple[int, int]:
"""
PASSIVE (zero export k CT/zátěži, reg. 142 dle DB): výchozí plné 108/109.
- Export v plánu (grid_w < 0) a žádné plánované vybíjení (bat_w >= 0): **108 = 0** — nepřebírat
přebytek FVE do baterie, ať může jít přetok do sítě.
- Import v plánu (grid_w > 0) a žádné plánované nabíjení (bat_w <= 0): **109 = 0** — nevybíjet
baterii, odběr ze sítě.
"""
if grid_w < 0 and bat_w >= 0:
return 0, max_discharge_a
if grid_w > 0 and bat_w <= 0:
return max_charge_a, 0
return max_charge_a, max_discharge_a
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""
Fyzický režim Deye: SELL | CHARGE | PASSIVE.
Pravidlo držet jednoduché (viz ``docs/04-modules/operating-modes.md``):
Primárně explicitně z plánu (`setpoints.deye_physical_mode`), fallback jen ze znamének (viz
``docs/04-modules/operating-modes.md``):
- **SELL** — jen když plán explicitně počítá s **vybíjením baterie do sítě** ve smyslu:
záporný export z portu sítě a zároveň **|battery_w| ≥ |grid_setpoint_w|** (výdej z
baterie není menší než plánovaný čistý export). Pak Deye „selling first“ (reg. 142=0).
- **CHARGE** — ``battery_w`` > 0 **a** ``grid_setpoint_w`` > 0 (nabíjení ze sítě + import v plánu).
- **SELL** — ``grid_setpoint_w`` < 0 **a** ``battery_w`` < 0 (export + vybíjení baterie v plánu).
- **PASSIVE** (ZERO) — vše ostatní; reg. **108/109** dle ``_deye_zero_export_amps_for_passive``.
- **PASSIVE** — všude jinde: reg. **108** / **109** na **max. proud** invertoru; přetok FVE / chování
vůči zátěži drží **142** dle ``deye_zero_export_mode``, **TOU výkon** z plánu, **145** solar sell.
- **CHARGE** — nabíjení ze sítě (``battery_w`` > 500 a ``grid_setpoint_w`` > 200).
``battery_w=None`` (SELF_SUSTAIN) → bat_w 0 → PASSIVE zde; **108/109 max** stejně jako u běžného
PASSIVE v ``write_inverter_setpoints`` (viz ``self_sustain_local_use`` pro TOU SOC).
``battery_w=None`` (SELF_SUSTAIN) → bat_w 0 → typicky PASSIVE; v ``write_inverter_setpoints`` má
SELF_SUSTAIN vlastní větev (108/109 max).
"""
pm = (setpoints.deye_physical_mode or "").strip().upper()
if pm in {"PASSIVE", "SELL", "CHARGE"}:
return pm
grid_w = int(setpoints.grid_setpoint_w or 0)
bat_w = 0 if setpoints.battery_w is None else int(setpoints.battery_w)
if bat_w > 500 and grid_w > 200:
if bat_w > 0 and grid_w > 0:
return "CHARGE"
if grid_w < 0 and bat_w < 0 and abs(bat_w) >= abs(grid_w):
if grid_w < 0 and bat_w < 0:
return "SELL"
return "PASSIVE"
@@ -1475,18 +1501,19 @@ async def write_inverter_setpoints(
charge_a = int(inv.max_charge_a)
discharge_a = int(inv.max_discharge_a)
else:
# PASSIVE (AUTO): plný strop 108/109 — stejná idea jako SELF_SUSTAIN.
# Dříve škálování podle |battery_w| z LP usekávalo fyzický výkon baterie (např. 23 A při
# ~1,2 kW plánu) a velká akumulace pak neuměla rychle doplnit síť při nárazové zátěži.
# Ekonomiku a směr toku drží TOU časové body (výkon W / SOC %) + režim 142/178, ne reg. 108/109.
charge_a = int(inv.max_charge_a)
discharge_a = int(inv.max_discharge_a)
# PASSIVE (ZERO): výchozí plné 108/109; u přetoku FVE do sítě nebo importu bez baterie viz helper.
charge_a, discharge_a = _deye_zero_export_amps_for_passive(
grid_w,
bat_w,
int(inv.max_charge_a),
int(inv.max_discharge_a),
)
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
if deye_mode == "SELL" and grid_w < -200:
if deye_mode == "SELL" and grid_w < 0:
export_limit = min(export_lim, max(REG143_SELL_CAP_MIN_W, abs(grid_w)))
reg178_val = REG178_SELL if deye_mode == "SELL" else REG178_PASSIVE
@@ -1875,15 +1902,15 @@ async def export_setpoints(site_id: int, db: asyncpg.Connection) -> None:
if mode.mode_code == "CHARGE_CHEAP":
max_ch = await _fetch_max_charge_power_w(site_id, db)
# Kladný grid_setpoint_w > 200 → fyzický CHARGE (nabíjení ze sítě), viz get_deye_mode
grid_for_charge = max(300, max_ch)
# Oba setpointy kladné → get_deye_mode CHARGE; min. 1 W, aby režim nebyl PASSIVE při nulové DB.
pw = max(1, int(max_ch))
sp_now = ControlSetpoints(
battery_w=max_ch,
battery_w=pw,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=grid_for_charge,
grid_setpoint_w=pw,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=None,

View File

@@ -184,6 +184,9 @@ class DispatchResult:
battery_setpoint_w: int # kladné = nabíjení, záporné = vybíjení
battery_soc_target: float # % SoC na konci intervalu
grid_setpoint_w: int # kladné = import, záporné = export
#: Explicitní fyzický režim Deye pro control exporter (PASSIVE / SELL / CHARGE).
#: Cíl: odstranit heuristiky z exporteru a nést záměr přímo v plánu.
deye_physical_mode: str
ev1_setpoint_w: Optional[int]
ev2_setpoint_w: Optional[int]
ev1_via_bat_w: int
@@ -541,6 +544,14 @@ def solve_dispatch(
grid_w = round(pulp.value(gi[t]) - pulp.value(ge[t]))
soc_pct = round(pulp.value(soc[t]) / battery.usable_capacity_wh * 100, 1)
# Primární klasifikace fyzického režimu pro Deye: explicitně do plánu (Variant A).
# Default PASSIVE; SELL při export+vybíjení; CHARGE při import+nabíjení.
deye_mode = "PASSIVE"
if batt_w < 0 and grid_w < 0:
deye_mode = "SELL"
elif batt_w > 0 and grid_w > 0:
deye_mode = "CHARGE"
cost = (
pulp.value(gi[t]) * slots[t].buy_price * INTERVAL_H / 1000
- pulp.value(ge[t]) * slots[t].sell_price * INTERVAL_H / 1000
@@ -551,6 +562,7 @@ def solve_dispatch(
battery_setpoint_w = batt_w,
battery_soc_target = soc_pct,
grid_setpoint_w = grid_w,
deye_physical_mode = deye_mode,
ev1_setpoint_w = round(pulp.value(ev_direct[0][t]) + pulp.value(ev_via_bat[0][t]))
if slots[t].ev1_connected else None,
ev2_setpoint_w = round(pulp.value(ev_direct[1][t]) + pulp.value(ev_via_bat[1][t]))
@@ -982,6 +994,7 @@ async def _save_planning_run(
"battery_setpoint_w": r.battery_setpoint_w,
"battery_soc_target_pct": r.battery_soc_target,
"grid_setpoint_w": r.grid_setpoint_w,
"deye_physical_mode": r.deye_physical_mode,
"ev1_setpoint_w": r.ev1_setpoint_w,
"ev2_setpoint_w": r.ev2_setpoint_w,
"ev1_via_bat_w": r.ev1_via_bat_w,

View File

@@ -11,6 +11,7 @@ from services.control.exporter_monolith import (
_deye_reg178_verify_with_double_read,
_deye_tou_params,
_deye_tou_power_verify_match,
_deye_zero_export_amps_for_passive,
deye_reg_triggers_self_sustain_after_verify_exhaust,
get_deye_mode,
)
@@ -62,7 +63,7 @@ class ModbusVerifyPolicyTests(unittest.TestCase):
class DeyeTouParamsTests(unittest.TestCase):
def test_sell_uses_reserve_soc(self) -> None:
"""SELL: plánovaný výdej baterie alesvěň tak velký jako plánovaný export (|bat| ≥ |grid|)."""
"""SELL: záporný grid_setpoint_w i battery_w → selling first; TOU SOC = reserve."""
sp = ControlSetpoints(
battery_w=-8000,
grid_export_limit=8000,
@@ -79,8 +80,23 @@ class DeyeTouParamsTests(unittest.TestCase):
self.assertFalse(g)
self.assertEqual(s, 20)
def test_pv_led_export_with_small_battery_is_passive(self) -> None:
"""Regrese site 25A 17:30: |bat| < |grid| → PASSIVE (FVE přetok, ne „vylít baterku“)."""
def test_explicit_deye_physical_mode_from_plan_overrides_detection(self) -> None:
sp = ControlSetpoints(
battery_w=-8000,
grid_export_limit=8000,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=-8000,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=50,
deye_physical_mode="PASSIVE",
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
def test_pv_led_export_with_small_battery_is_sell(self) -> None:
"""Obě záporné → SELL (bez porovnání |bat| vs |grid|)."""
sp = ControlSetpoints(
battery_w=-733,
grid_export_limit=1294,
@@ -92,10 +108,10 @@ class DeyeTouParamsTests(unittest.TestCase):
ev2_power_w=0,
target_soc_pct=50,
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
self.assertEqual(get_deye_mode(sp), "SELL")
def test_large_export_small_battery_is_passive(self) -> None:
"""Export v plánu větší než výdej z baterie → PASSIVE."""
def test_large_export_small_battery_is_sell(self) -> None:
"""I když |bat| < |grid| — stále SELL při obou záporných setpointech."""
sp = ControlSetpoints(
battery_w=-1500,
grid_export_limit=8000,
@@ -107,7 +123,7 @@ class DeyeTouParamsTests(unittest.TestCase):
ev2_power_w=0,
target_soc_pct=50,
)
self.assertEqual(get_deye_mode(sp), "PASSIVE")
self.assertEqual(get_deye_mode(sp), "SELL")
def test_passive_uses_min_soc(self) -> None:
sp = ControlSetpoints(
@@ -181,6 +197,36 @@ class DeyeTouParamsTests(unittest.TestCase):
self.assertTrue(g)
self.assertEqual(s, 95)
def test_charge_any_positive_pair_without_w_threshold(self) -> None:
sp = ControlSetpoints(
battery_w=50,
grid_export_limit=0,
ev1_current_a=0,
ev2_current_a=0,
heat_pump_enable=False,
grid_setpoint_w=80,
ev1_power_w=0,
ev2_power_w=0,
target_soc_pct=50,
)
self.assertEqual(get_deye_mode(sp), "CHARGE")
def test_zero_export_amps_fve_overflow(self) -> None:
c, d = _deye_zero_export_amps_for_passive(-1000, 0, 100, 90)
self.assertEqual(c, 0)
self.assertEqual(d, 90)
def test_zero_export_amps_import_hold_discharge(self) -> None:
c, d = _deye_zero_export_amps_for_passive(500, 0, 100, 90)
self.assertEqual(c, 100)
self.assertEqual(d, 0)
def test_zero_export_amps_full_when_discharge_with_export(self) -> None:
"""Export + plánované vybíjení → plné proudy (SELL řeší režim 142 zvlášť)."""
c, d = _deye_zero_export_amps_for_passive(-2000, -500, 100, 90)
self.assertEqual(c, 100)
self.assertEqual(d, 90)
def test_self_sustain_tou_stays_min_soc_even_if_sell_negative(self) -> None:
"""SELF_SUSTAIN: nízké TOU (min_soc), ne 100 % z negativní vykupní — LP se nepoužívá."""
sp = ControlSetpoints(

View File

@@ -0,0 +1,10 @@
-- Explicitní fyzický režim Deye přímo v plánu (Variant A):
-- PASSIVE / SELL / CHARGE. Exporter pak nemusí heuristicky mapovat z wattů.
ALTER TABLE ems.planning_interval
ADD COLUMN IF NOT EXISTS deye_physical_mode TEXT;
COMMENT ON COLUMN ems.planning_interval.deye_physical_mode IS
'Explicitní fyzický režim Deye pro tento slot (PASSIVE / SELL / CHARGE).
Zdroj: planning_engine.solve_dispatch() (záměr slotu), použití: control exporter (get_deye_mode).';

View File

@@ -50,6 +50,7 @@ begin
run_id, interval_start,
battery_setpoint_w, battery_soc_target_pct,
grid_setpoint_w,
deye_physical_mode,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
@@ -64,6 +65,7 @@ begin
(r.value->>'battery_setpoint_w')::int,
(r.value->>'battery_soc_target_pct')::numeric,
(r.value->>'grid_setpoint_w')::int,
nullif(trim(r.value->>'deye_physical_mode'), ''),
nullif(r.value->>'ev1_setpoint_w', '')::int,
nullif(r.value->>'ev2_setpoint_w', '')::int,
coalesce((r.value->>'ev1_via_bat_w')::int, 0),
@@ -86,6 +88,7 @@ begin
run_id, interval_start,
battery_setpoint_w, battery_soc_target_pct,
grid_setpoint_w,
deye_physical_mode,
ev1_setpoint_w, ev2_setpoint_w, ev1_via_bat_w, ev2_via_bat_w,
heat_pump_enabled, heat_pump_setpoint_w,
pv_a_curtailed_w, expected_cost_czk,
@@ -97,6 +100,7 @@ begin
(r.value->>'battery_setpoint_w')::int,
(r.value->>'battery_soc_target_pct')::numeric,
(r.value->>'grid_setpoint_w')::int,
nullif(trim(r.value->>'deye_physical_mode'), ''),
nullif(r.value->>'ev1_setpoint_w', '')::int,
nullif(r.value->>'ev2_setpoint_w', '')::int,
coalesce((r.value->>'ev1_via_bat_w')::int, 0),

View File

@@ -111,30 +111,30 @@ def apply_overrides(plan, overrides) -> Setpoints:
**Princip:** držet mapování plán → Deye **jednoduché**; detail a zdůvodnění v [`operating-modes.md`](operating-modes.md) (sekce *Keep it simple*).
### Fyzický režim (`get_deye_mode`)
### Fyzický režim (`get_deye_mode` v `exporter_monolith.py`)
| Fyzický režim | Podmínka z `ControlSetpoints` |
|---|---|
| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 **a** **\|battery_w\| ≥ \|grid_setpoint_w\|** — plán počítá s výdejem z baterie do sítě alesvěň tak velkým jako plánovaný čistý export. |
| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 (nabíjení ze sítě) |
| **PASSIVE** | vše ostatní |
| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 |
| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 |
| **PASSIVE (ZERO)** | vše ostatní — reg. **108/109** dle `_deye_zero_export_amps_for_passive` (viz `operating-modes.md`) |
**PASSIVE** (AUTO, včetně FVE přetoku do sítě): reg. **108** i **109** na **maximum z DB** (plný proudový rozsah baterie); jemnější výkon drží **TOU časové body** z plánu. Reg. **145** = 1 (solar sell), reg. **142** = `deye_zero_export_mode`.
**PASSIVE** (AUTO, ZERO): výchozí **108** i **109** = maximum z DB; u exportu bez vybíjení **108 = 0**, u importu bez nabíjení **109 = 0** (`_deye_zero_export_amps_for_passive`). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **145** (solar sell): v kódu vždy **1** — význam přepínače a rozdíl vůči neřízeným FVE polím je v [`operating-modes.md`](operating-modes.md) (sekce *ZERO a zakázaný export*).
**SELF_SUSTAIN** (záložní režim po Modbus mismatch apod.) zůstává **PASSIVE** z hlediska `get_deye_mode`; **108/109** jsou stejně **max z DB** jako u AUTO PASSIVE. Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** se drží na **`min_soc_percent`** (typicky 12 %) a `battery_w=None`, aby střídač prioritizoval lokální buffer při zero-export, ne ekonomiku LP.
**SELF_SUSTAIN** zůstává **PASSIVE** v `get_deye_mode`; **108/109** jsou vždy **max z DB** (bez variant ZERO). Rozdíl je **`self_sustain_local_use=True`**: **TOU SOC** = **`min_soc_percent`**, `battery_w=None`.
### Klíčové registry podle typu slotu
| Registr | Charge | Pass-through / PASSIVE | SELL (battery-led) | Self-consumption |
| Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption |
|---|---|---|---|---|
| **108** (charge A) | škálo dle `battery_w` | **max z DB** | **0** | **max z DB** |
| **109** (discharge A) | **0** | **max z DB** | **max z DB** | **max z DB** |
| **108** (charge A) | škálo dle `battery_w` | max / **0** (FVE přetok) | **0** | dle varianty |
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) | **max z DB** | dle varianty |
| **142** (limit control) | `deye_zero_export_mode` | `deye_zero_export_mode` | **0** (selling first) | `deye_zero_export_mode` |
| **143** (export cap) | max z DB | max z DB | `min(max_site, max(200, \|grid_setpoint_w\|))` | max z DB |
| **145** (solar sell) | 1 | 1 | 1 | 1 |
| **178** (peak shaving) | 48 | 48 | **32** | 48 |
Sloupce **Pass-through / PASSIVE** (AUTO) a **Self-consumption** (typicky SELF_SUSTAIN / záloha) mají u **108/109** stejně **max z DB**; liší se hlavně **TOU SOC** a `battery_w` (viz výše).
U **AUTO PASSIVE** závisí **108/109** na znaménkách plánu (viz `operating-modes.md`). **SELF_SUSTAIN** drží oba **max z DB**; liší se **TOU SOC** a `battery_w`.
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).

View File

@@ -12,19 +12,19 @@ EMS zapisuje řídící hodnoty přes journal (`modbus_command`) a **`write_regi
| Reg | Název | Rozsah | Jednotka | Použití v EMS |
|-----|-------|--------|----------|---------------|
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). Ve **PASSIVE** (AUTO) `write_inverter_setpoints` zapisuje **vždy plný strop** — škálování podle malého `battery_w` z LP se **nepoužívá** (TOU výkon drží jemnější signál). Režim **CHARGE** stále odvádí proud z plánovaného výkonu přes `battery_watts_to_amps`. |
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Obdobně jako 108; ve **PASSIVE** plný strop, **SELL** plný vybíjecí proud, **CHARGE** typicky 0. |
| 108 | Max charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Strop **A** z DB (`COALESCE(deye_register_max_charge_a, odvod z kW)` + clamp **350 A**). Ve **PASSIVE** (AUTO) podle `_deye_zero_export_amps_for_passive`: výchozí **max**, u exportu v plánu bez vybíjení **0**. **CHARGE:** proud z `battery_w` přes `battery_watts_to_amps`. **SELL:** **0**. |
| 109 | Max discharge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Ve **PASSIVE** (AUTO): výchozí **max**, u importu bez nabíjení **0**; **SELL** max vybíjení; **CHARGE** typicky **0**. |
| 128 | Grid charge current | 0 … **max dle modelu** (manuál Deye) | 1 A | Nabíjení ze sítě. Firmware automaticky sníží reálný proud tak, aby `load + battery_charge` nepřekročil velikost jističe — proto LP v `planning_engine.py` plánuje `gi[t]`**do `max_import_power_w + BMS_max_charge`**, aby uměl využít cenově nejlepší 15min okna pro nabíjení na plný BMS strop (viz `planning.md` sekce „Plánovací strop gi[t] vs. fyzický jistič"). Fyzické dodržení jističe drží reg 128 + firmware. |
| 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 (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ě. |
| 145 | Solar sell | 0/1 | — | **0** = disabled (přebytek FVE na **straně měniče** se nesmí vést do sítě — curtailment vůči síti), **1** = enabled. Platí jen pro **FVE pod kontrolou Deye** (`controllable = true`); druhá pole (např. **pv-b** u home-01) EMS tímto registerem neřídí. EMS dnes **vždy zapisuje 1**; při 108 = 0 a 145 = 1 přebytky z řiditelného stringu typicky tečou do sítě (viz pass-through níže). |
| 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`, bit45 = **10**) v režimu **SELL**; **48** (`0b00110000`, bit45 = **11**) v **PASSIVE** a **CHARGE**. |
| 190 | GEN peak shaving | 016000 | 1 W | Peak shaving na GEN portu |
| 191 | Grid peak shaving power | 016000 | 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ř. 6264, 148159, 166177, 108109, 141143, 145 podle toho, co je ve frontě). Pořadí v journalu: **6264** (čas, viz níže), **time points 148177** (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.
`exporter_monolith.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ř. 6264, 148159, 166177, 108109, 141143, 145 podle toho, co je ve frontě). Pořadí v journalu: **6264** (čas, viz níže), **time points 148177** (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)
@@ -44,19 +44,19 @@ EMS **nezapisuje** read-modify-write (paralelní čtení jinými klienty může
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`)
### Detekce fyzického režimu (`get_deye_mode` v `exporter_monolith.py`)
Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivní plán / politika EMS), ne z telemetrie.
Vychází z **`grid_setpoint_w`** a **`battery_w`** z `ControlSetpoints` (aktivní plán / politika EMS), ne z telemetrie. **Bez wattových prahů** — jen znaménka.
| Režim | Podmínka |
|-------|----------|
| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 **a** **\|battery_w\| ≥ \|grid_setpoint_w\|** (výdej z baterie alesvěň tak velký jako plánovaný export) |
| **CHARGE** | `battery_w` > 500 **a** `grid_setpoint_w` > 200 |
| **SELL** | `grid_setpoint_w` < 0 **a** `battery_w` < 0 |
| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 |
| **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.
Režim **CHARGE_CHEAP** nastaví oba setpointy na stejný kladný výkon (min. 1 W), aby byl výsledek **CHARGE**.
**Důležité:** **SELL** jen pro záměr **vylít baterku do sítě** (viz `operating-modes.md`, *Keep it simple*). FVE přetok / malý doplněk z baterie vůči většímu exportu zůstává **PASSIVE** (reg. **108/109** škálované podle plánu).
**PASSIVE (ZERO):** reg. **108/109** podle `_deye_zero_export_amps_for_passive` — při exportu v plánu bez vybíjení je **108 = 0** (přetok FVE); při importu bez nabíjení je **109 = 0** (držet baterii). Jinak oba max (AUTO). Detail: `operating-modes.md`.
### Provozní režim EMS SELF_SUSTAIN
@@ -70,12 +70,12 @@ Z hlediska `get_deye_mode` je **SELF_SUSTAIN** stále **PASSIVE** (`battery_w` z
Solver předvybírá sloty pro nabíjení a export-vybíjení (`_select_charge_slots`, `_select_discharge_export_slots`). Nabíjení: vždy povoleno v slotech s PV-surplus; zbytek rozpočtu (`charge_slot_buffer × (soc_max current_soc) PV přínos`) doplněn nejlevnějšími sloty podle **`buy_price`** (nákupní cena ze sítě). Export-vybíjení: top-N slotů podle nejvyšší **`sell_price`**. Výsledné setpointy pak určují typ slotu:
| | **Charge** | **Pass-through** | **Battery→grid (SELL)** | **Self-consumption** |
| | **Charge** | **Pass-through / FVE přetok** | **Battery→grid (SELL)** | **Self-consumption** |
|---|---|---|---|---|
| **Kdy** | Solver: `bat_w > 0` | Solver: typicky export z FVE; `\|bat\| < \|grid\|` při exportu | `grid_w < 0`, `bat_w < 0`, `\|bat\| ≥ \|grid\|` | Noc / PV < spotřeba |
| **Deye mode** | PASSIVE | PASSIVE | SELL | PASSIVE |
| **108** charge A | škálo dle `bat_w` | škálo / **0** | **0** | **0** |
| **109** discharge A | **0** | škálo dle `\|bat_w\|` | **max** | škálo dle `\|bat_w\|` |
| **Kdy** | `bat_w > 0`, `grid_w > 0` | typicky `grid_w < 0`, `bat_w ≥ 0` | `grid_w < 0`, `bat_w < 0` | import, `bat_w ≤ 0` či mix |
| **Deye mode** | CHARGE | PASSIVE | SELL | PASSIVE |
| **108** charge A | dle `bat_w` | **0** při exportu bez vybíjení | **0** | max nebo **0** dle varianty |
| **109** discharge A | **0** | max | **max** | **0** při importu bez nabíjení, jinak 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) |

View File

@@ -2,9 +2,10 @@
## Keep it simple
- **Méně heuristik a pevných wattových práhů v řízení** — každá magická konstanta je místo, kde se rodí neshody s plánem a ekonomikou.
- **SELL na Deye** používej jen tam, kde produktově opravdu chceme režim „**vylít baterii do sítě**“ (selling first). Vše ostatní patří do **PASSIVE**: **108/109** dávají **plný proudový rozsah** baterie, směr toku a ekonomiku drží **142** + **TOU** z plánu a instalace.
- Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`) a typické chybě „plán říká kW, měnič jede na MW“.
- **Žádné wattové prahy pro výběr SELL / CHARGE** — mapování z MILP setpointů je čistě ze **znamének** `battery_setpoint_w` a `grid_setpoint_w` (viz `get_deye_mode` v `exporter_monolith.py`).
- **ZERO (PASSIVE)** = zero export k CT/zátěži (**142** = `deye_zero_export_mode`), s **plnými proudy 108/109** jen ve výchozím stavu; pro přetok FVE do sítě nebo odběr ze sítě bez vybíjení baterie se jeden z proudů **vynuluje** (`_deye_zero_export_amps_for_passive`).
- **SELL** = plánovaný export **i** plánované vybíjení (oba záporné) → **142** = selling first, **178** = vypnutý grid peak shaving (32); po návratu do ZERO/CHARGE zase **178** = 48.
- Novou logiku vždy ověřit proti **reálnému řádku plánu** (audit / `planning_interval`).
## Přehled
@@ -16,17 +17,55 @@
| PRESERVE | no_charge, no_discharge | PASSIVE | lock (0/0) |
| MANUAL | solver neběží | EMS nezapisuje | — |
Implementace: omezení LP v `planning_engine.solve_dispatch()` podle `mode_code` z `ems.site_operating_mode`; zápis Deye v `control_exporter.write_inverter_setpoints()` (včetně `lock_battery` u PRESERVE).
Implementace:
## Fyzické režimy Deye (výstup control_exporteru)
- **EMS provozní režim** (`AUTO`, `SELF_SUSTAIN`, …): jediný zdroj v DB `ems.site_operating_mode.mode_code` + větev v `_build_setpoints` / `export_setpoints` (např. `CHARGE_CHEAP` přepíše setpointy před zápisem — stále jedna funkce exportu).
- **Deye fyzický režim** (`PASSIVE` / `CHARGE` / `SELL`): jediný zdroj **`get_deye_mode`** (`exporter_monolith.py`); zápis v `write_inverter_setpoints()`.
- Omezení LP v `planning_engine.solve_dispatch()` podle `mode_code`; zápis Deye včetně `lock_battery` u PRESERVE.
Detekce v `get_deye_mode` (`battery_w` = `battery_setpoint_w` z plánu, záporné = vybíjení; `grid_setpoint_w` záporné = export do sítě):
### Odkud jsou `battery_setpoint_w` a `grid_setpoint_w` (AUTO)
- **CHARGE:** `battery_w` > 500 **a** `grid_setpoint_w` > 200 → nabíjení ze sítě; reg. **142** dle CHARGE větve v exporteru, **178** = 48.
- **SELL:** `grid_setpoint_w` < 0 **a** `battery_w` < 0 **a** `|battery_w| ≥ |grid_setpoint_w|` → záměr **vybíjet baterii do sítě** (selling first); reg. **142** = 0, **178** = 32, **108** = 0, **109** = max, reg. **143** omezen podle `|grid_setpoint_w|` (viz `control_exporter.py`).
- **PASSIVE:** vše ostatní — **reg. 108** a **109** na **plný strop** z konfigurace střídače (jako SELF_SUSTAIN); jemné řízení výkonu/SOC jde přes **TOU časové body** z plánu, **142** = `deye_zero_export_mode`, **145** = 1, **178** = 48.
Nejde o samostatný „tip“ z predikce FVE, který by exporter náhodně přetáhl do SELL nebo CHARGE.
**SELF_SUSTAIN:** `battery_w = None` ⇒ v `get_deye_mode` jako 0 ⇒ typicky **PASSIVE**; v `write_inverter_setpoints` při `self_sustain_local_use=True`**108 i 109 = max**, reg. **142** = zero export dle DB, TOU SOC = **`min_soc_percent`**. **PRESERVE:** `lock_battery`**108 = 0**, **109 = 0**.
1. **Zdroj dat:** pro režim **AUTO** exporter načte aktivní řádek **`ems.planning_interval`** pro aktuální 15min slot (`_fetch_plan_row_for_slot_offset``_build_setpoints` v `exporter_monolith.py`).
2. **Kdo je naplnil:** sloupce pocházejí z výstupu **`planning_engine.solve_dispatch()`** — MILP nad bilanční rovnicí za slot (základní značky v kódu: `gi[t]` ≥ 0 import ze sítě, `ge[t]` ≥ 0 export ze sítě, `bc[t]` / `bd[t]` nabíjení / vybíjení baterie). Uložené hodnoty odpovídají **`grid_setpoint_w = round(gi[t] - ge[t])`** a **`battery_setpoint_w = round(bc[t] - bd[t])`** (viz sestavení `DispatchResult` a zápis plánu).
3. **Fyzika u elektroměru:** v jednom slotu model pracuje s **čistým** tokem ze sítě jako rozdílem `gi` a `ge`; predikce PV a spotřeba vstupují do **bilance a omezení** řešiče, ne jako náhradní logika mapování na Deye.
4. **Role `get_deye_mode`:** pouze **přeloží** už hotový plán na kombinaci registrů (PASSIVE / CHARGE / SELL). Očekávání provozu (např. kdy přesně má být výdej baterie do sítě vs. přetok FVE) má držet **LP a výběr slotů** (`allow_charge`, `allow_discharge_export`, …), ne dodatečné wattové heuristiky v exporteru.
## Fyzické režimy Deye (výstup control exporteru)
**Jediné místo** pro klasifikaci **Deye** `PASSIVE` | `CHARGE` | `SELL` z MILP setpointů je **`get_deye_mode`** v `exporter_monolith.py`.
Značení: `battery_w` = `battery_setpoint_w` (kladné = nabíjení, záporné = vybíjení); `grid_setpoint_w` (kladné = import, záporné = export).
| Režim | Podmínka z plánu | 108 / 109 (zkráceně) | 142 | 178 |
|--------|------------------|----------------------|-----|-----|
| **CHARGE** | `battery_w` > 0 **a** `grid_setpoint_w` > 0 | dle plánu nabíjení / 0 vybíjení | větev CHARGE | 48 |
| **SELL** | `battery_w` < 0 **a** `grid_setpoint_w` < 0 | 0 nabíjení / max vybíjení | 0 (selling first) | **32** (peak shaving off) |
| **PASSIVE (ZERO)** | vše ostatní | viz tabulka ZERO níže | `deye_zero_export_mode` | 48 |
### ZERO: výchozí a dvě varianty proudu (reg. 108 / 109)
Všechny řádky předpokládají **142** = zero export (ne SELL).
| Situace | Podmínka (plán) | Reg. 108 (max charge A) | Reg. 109 (max discharge A) |
|---------|-----------------|-------------------------|----------------------------|
| Výchozí | ostatní případy PASSIVE | max | max |
| Přetok FVE do sítě | `grid_setpoint_w` < 0 **a** `battery_w` ≥ 0 | **0** | max |
| Držet baterii, brát ze sítě | `grid_setpoint_w` > 0 **a** `battery_w` ≤ 0 | max | **0** |
Nabíjení ze sítě s vysokým cílovým SoC v TOU řeší větev **CHARGE** (grid charge v time pointech), ne tato tabulka.
### ZERO a „zakázaný export FVE do sítě“ (jen řiditelná pole)
**Reg. 145 (solar sell)** na Deye je přepínač typu **enabled / disabled** pro **přebytek FVE na straně měniče** (počítá se vůči režimu **142** zero export a stavu **108** — viz `modbus-registers.md`, pass-through krok za krokem).
- **Pouze to, co EMS umí přes Deye Modbus ovlivnit** — typicky **FVE pole řízené střídačem** (`asset_pv_array.controllable = true`, u referenční lokality **home-01** např. string za Deye).
- **Pole mimo tento kanál** (např. **pv-b** u **home-01**, `controllable = false`, často samostatný ongrid GEN) **reg. 145 neovládá**; jejich výkon jde do plánovače jako `pv_b_forecast_w`, ale curtailment / solar sell logika Deye se jich netýká.
**Implementace dnes:** exporter vždy zapisuje **145 = 1** (solar sell enabled). Tvrdé vypnutí přebytku řiditelného FVE do sítě přes **145 = 0** z politik (`no_export`, `BLOCK_EXPORT`, …) je v plánu — viz **`docs/05-todo.md`** (sekce *Deye řízení rozšíření*).
**SELF_SUSTAIN:** `battery_w = None` ⇒ v `get_deye_mode` jako 0 ⇒ **PASSIVE**; v `write_inverter_setpoints` při `self_sustain_local_use=True`**108 i 109 = max** (bez variant ZERO výše), reg. **142** dle DB, TOU SOC = **`min_soc_percent`**. **PRESERVE:** `lock_battery`**108 = 0**, **109 = 0**.
## EMS politiky (nejsou fyzické stavy Deye)

View File

@@ -82,13 +82,23 @@ Potřebné pro reálný, stabilní provoz; lze část EMS otestovat bez nich (na
---
## Deye řízení rozšíření
| Popis | Kde | Kdo |
|-------|-----|-----|
| **Reg. 145 (solar sell)** z politiky: při `no_export` / `BLOCK_EXPORT` (a obdobně) zapisovat **145 = 0**, aby šlo tvrdě zakázat přetok **řiditelného** FVE na Deye (`asset_pv_array.controllable = true`); dnes exporter vždy **1**. Vazba na instalaci: `docs/04-modules/operating-modes.md` (ZERO a reg. 145). | `exporter_monolith.write_inverter_setpoints` (+ vstupy z `InverterConfig` / `site_grid_connection`) | programátor |
| **Testy reg. 145** vůči journalu (`ems.modbus_command`): očekávaná hodnota při zákazu exportu vs. běžný provoz. | `backend/tests/`; `docs/04-modules/modbus-command-journal.md` | programátor |
| **Dvě FVE pole:** UI / provozní poznámka, že **145 = 0** neomezuje **pv-b** (ongrid); celkový export lokality může z pole B dál „unikat“. | `docs/04-modules/operating-modes.md`; `planning.md` (pv_a / pv_b) | majitel + programátor |
---
## Fáze 2 rozšíření
| Popis | Kde | Kdo |
|-------|-----|-----|
| **Tesla API:** Tessie vs přímé API. | `docs/04-modules/ev-charging.md` ř. 280 | majitel + programátor |
| **UI** pro deadline a target SoC před odjezdem. | `docs/04-modules/ev-charging.md` ř. 283 | programátor |
| **Notifikace** při nesplnitelném deadline nabíjení. | `docs/04-modules/ev-charging.md` ř. 284; `docs/04-modules/operating-modes.md` ř. 132 (stale heartbeat) | programátor |
| **Notifikace** při nesplnitelném deadline nabíjení. | `docs/04-modules/ev-charging.md` ř. 284; `docs/04-modules/operating-modes.md` (sekce *Otevřené body* stale heartbeat) | programátor |
| Ověřit **round-trip účinnost** baterie a **odhad SoC Zoe** z energie session na reálných datech. | `docs/04-modules/ev-charging.md` ř. 282, 285 | programátor |
| **Kalibrace COP** modelu TČ na 46 týdnů historie. | `docs/04-modules/heat-pump.md` ř. 105 | programátor |
| **pvlib** vs jednoduchý model FVE; **Solcast** jako alternativa k Open-Meteo. | `docs/04-modules/forecast.md` ř. 178, 180; `docs/06-open-questions.md` ř. 34 | programátor |