diff --git a/CLAUDE.md b/CLAUDE.md index 7d594cf..a66b55a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -64,7 +64,7 @@ Když uživatel napíše **„použij MCP“** nebo potřebuje **aktuální řá 5. **FVE pole B (`controllable = false`, typicky ongrid GEN) – žádný curtailment.** Curtailment jen pole A (Deye). Solver smí omezovat jen `pv_a`; pole B může mít zelený bonus na `asset_pv_array` (`green_bonus_*`), audit `pv_b_production_wh` / `green_bonus_czk`. -6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint). +6. **Záporná prodejní cena → `grid_export == 0`** v LP (hard constraint kde zapnuté): buď **`deye_gen_microinverter_cutoff_enabled`** na `deye-main`, nebo **`ems.site_grid_connection.block_export_on_negative_sell`** (default false). **home-01** kvůli neriťitelnému PV B často **bez** druhého přepínače — přebytek pole B nesmí dělat PL infeasible; **KV1** (bez pole B / fixní nákup) migrace **V074** nastavuje `block_export_on_negative_sell = true`. 7. **Záporná nákupní cena → omezit import** na realistický horní strop (viz `solve_dispatch` v `planning_engine.py` – nesmí „nekonečný“ import). @@ -121,7 +121,7 @@ Projekt je **SQL-first**: doménová logika, agregace, joiny mezi tabulkami a st | `site` | Lokalita (časová zóna, GPS, aktivita). | | `site_endpoint` | Endpointy: Modbus, Loxone HTTP, atd. | | `site_market_config` | Marže, režimy cenění; časová platnost (zelený bonus není zde – viz `asset_pv_array`). | -| `site_grid_connection` | Limity import/export, no_export, rezervovaný výkon. | +| `site_grid_connection` | Limity import/export, **block_export_on_negative_sell** (LP při záporném sell), no_export, rezervovaný výkon. | | `site_override` | Manuální přepisy nad plánem (JSON + platnost). | | `site_operating_mode` | Aktuální provozní režim na site (1 řádek/site). | | `site_operating_mode_log` | Historie přepnutí režimů. | diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 5d164d3..eddb56b 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -687,10 +687,13 @@ def solve_dispatch( if s.sell_price < 0: prob += w_arb[t] == 0 prob += bd[t] <= pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) - # BA81 (GEN port microinverters): pokud máme k dispozici GEN cut-off, držíme skutečný - # BLOCK_EXPORT jako hard constraint: export do sítě v okně se záporným prodejem je zakázaný. - # Přebytek pak řeší curtail PV A / nabíjení / případně GEN cut-off (reg 178 bits0–1). - if z_gen_cutoff is not None: + # Tvrdý zákaz vývozu při záporné prodejní ceně, pokud: + # - site má GEN/MI cutoff model (binárky z_gen_cutoff — BA81), nebo + # - explicitně site_grid_connection.block_export_on_negative_sell (např. fixní nákup, bez pole B). + block_neg_sell_export = bool( + getattr(grid, "block_export_on_negative_sell", False) + ) + if z_gen_cutoff is not None or block_neg_sell_export: prob += ge[t] == 0 soc_prev_expr = current_soc_wh if t == 0 else soc[t - 1] @@ -1146,6 +1149,7 @@ async def _load_site_context(site_id: int, db): grid = SimpleNamespace( max_import_power_w=int(g["max_import_power_w"]), max_export_power_w=int(g["max_export_power_w"]), + block_export_on_negative_sell=bool(g.get("block_export_on_negative_sell") or False), deye_gen_microinverter_cutoff_enabled=bool(g.get("deye_gen_microinverter_cutoff_enabled") or False), ) diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index b069272..fae1c48 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -782,6 +782,63 @@ class PlanningDispatchMilpTests(unittest.TestCase): msg="with very negative buy price, solver may choose to exceed breaker (soft cap)", ) + def test_block_export_on_negative_sell_no_grid_export_pv_surplus(self) -> None: + """site_grid_connection.block_export_on_negative_sell → ge=0 při sell<0.""" + slots = [ + PlanningSlot( + interval_start=datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc), + buy_price=5.25, + sell_price=-0.5, + pv_a_forecast_w=7000, + pv_b_forecast_w=0, + load_baseline_w=500, + ev1_connected=False, + ev2_connected=False, + is_predicted_price=False, + allow_charge=True, + allow_discharge_export=False, + ) + ] + battery = _battery(uc_wh=20_000.0, arb_pct=15.0, max_pct=95.0) + hp = SimpleNamespace( + rated_heating_power_w=0, + tuv_min_temp_c=45.0, + tuv_target_temp_c=55.0, + ) + grid = SimpleNamespace( + max_import_power_w=17_000, + max_export_power_w=8000, + block_export_on_negative_sell=True, + ) + vehicles = [ + SimpleNamespace( + max_charge_power_w=0, + battery_capacity_kwh=1.0, + default_target_soc_pct=80.0, + ), + SimpleNamespace( + max_charge_power_w=0, + battery_capacity_kwh=1.0, + default_target_soc_pct=80.0, + ), + ] + soc0 = 0.34 * battery.usable_capacity_wh + results, _ms = solve_dispatch( + slots, + battery, + hp, + grid, + [None, None], + vehicles, + soc0, + 50.0, + tuv_delta_stats=None, + operating_mode="AUTO", + ) + self.assertEqual(len(results), 1) + self.assertGreaterEqual(results[0].grid_setpoint_w, 0, "no grid export") + self.assertGreater(results[0].battery_setpoint_w, 0, "surplus PV should charge") + class TerminalSocShadowTests(unittest.TestCase): """Terminal SoC shadow price v objective drží konec horizontu nad holým minimem.""" diff --git a/db/migration/V074__site_grid_block_export_negative_sell.sql b/db/migration/V074__site_grid_block_export_negative_sell.sql new file mode 100644 index 0000000..06889da --- /dev/null +++ b/db/migration/V074__site_grid_block_export_negative_sell.sql @@ -0,0 +1,13 @@ +-- Tvrdý zákaz grid exportu při záporné efektivní prodejní ceně v LP (odděleně od GEN cut-off přepínače na invertoru). + +alter table ems.site_grid_connection + add column if not exists block_export_on_negative_sell boolean not null default false; + +comment on column ems.site_grid_connection.block_export_on_negative_sell is + 'LP (solve_dispatch): při effective sell < 0 vynutit ge[t]=0. Nezávislé na deye_gen_microinverter_cutoff_enabled. Zapínat jen u lokalit bez nutnosti vést přebytek neriťitelného PV pole B do sítě (jinak hrozí infeasible); př. KV1 vs home-01.'; + +update ems.site_grid_connection sgc +set block_export_on_negative_sell = true +from ems.site s +where sgc.site_id = s.id + and s.code = 'KV1'; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index 69e7cf6..5d0c0a5 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -110,6 +110,7 @@ begin select jsonb_build_object( 'max_import_power_w', sgc.max_import_power_w, 'max_export_power_w', sgc.max_export_power_w, + 'block_export_on_negative_sell', coalesce(sgc.block_export_on_negative_sell, false), 'deye_gen_microinverter_cutoff_enabled', coalesce( ( select ai.deye_gen_microinverter_cutoff_enabled diff --git a/docs/03-data-model.md b/docs/03-data-model.md index f484b35..374e0ae 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -68,6 +68,8 @@ CREATE TABLE site_market_config ( ### `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, diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 034c59b..7dba4f7 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -27,6 +27,9 @@ - **Výběr exportních slotů (`allow_discharge_export`):** `ems.fn_load_planning_slots_full` omezuje, ve kterých slotech smí solver vybíjet baterii „nad rámec spotřeby“ pro export do sítě (anti-mikrocyklování). Aktuálně se sloty pro exportní vybíjení vybírají **globálně** podle `sell_price desc` přes celé okno (ne 50/50 AM/PM), aby solver neodkládal vybíjení do levnějších ranních slotů, pokud jsou dražší sloty už večer. - **Záporná nákupní cena:** - horní mez `grid_import` zahrnuje `load_baseline_w` + nabíjení/EV/TČ (bez nekonečného importu). +- **Záporná prodejní cena → tvrdý zákaz vývozu (`ge = 0`)** (`planning_engine.solve_dispatch`): platí ve slotu kde `sell_price < 0`, pokud lokality zapne některou z opcí — + - **`asset_inverter.deye_gen_microinverter_cutoff_enabled`** (`deye-main`) — spojeno s MILP binárkami GEN cut-off (BA81), + - **nebo** **`ems.site_grid_connection.block_export_on_negative_sell`** (migrace **V074**, default **false**) — bez GEN registrů na Deye; vhodné např. pro **KV1** (fixní nákup, bez nutnosti vést výkon neriťitelného pole B do sítě). **home-01** nech **false**, jinak může být horizont při přebytku z pole B a plné/nedostupné baterii **infeasible** (solver export potřebuje jako fyzikální ventil tam, kde FVE B nelze štípnout ani odpojit modelem bez GEN řádku). - **Uložené vstupy plánu** (`planning_interval`): `load_baseline_w`, `pv_*_forecast_raw_w`, `pv_*_forecast_solver_w` pro UI a audit. - **Více FVE polí s různou orientací:** `planning_engine._load_slots` sčítá predikovaný výkon za 15min přes **všechna** `asset_pv_array` dané lokality — `pv_a_forecast_w` = součet řádků s `controllable = true`, `pv_b_forecast_w` = součet s `controllable = false`. Pro každé pole a slot se bere **nejnovější** `forecast_pv_run` (`ORDER BY created_at DESC`, `DISTINCT ON (pv_array_id)`). Curtailment v LP zůstává **jedno** agregované `pv_a` (součet řiditelných polí); per-string curtailment by vyžadovalo rozšíření modelu. - **Kalibrace PV forecastu (delta profil):** tabulka `ems.site_pv_forecast_calibration` drží per `site_id` mimo jiné `delta_learn_min_ts` (dolní mez řádků z `forecast_accuracy` pro učení delty), volitelně `pv_curtailment_policy_effective_from` a přepsání parametrů (`top_n_days`, `half_life_days`, …). `ems.fn_fill_forecast_accuracy` nastavuje `learning_eligible` / `learning_exclude_reason` (sloty před cutoffem, nebo se škrcením / gen cut-off / záznamem v `ems.cutoff_switch_log` po účinnosti policy se z učení vyřadí; u škrcení zůstává `actual_power_w` NULL). Telemetrie: `ems.telemetry_inverter.is_export_limited` nebo `pv_derating_flags <> 0` v okně 15min → stejné vyloučení (`telemetry_derating`). `ems.fn_pv_forecast_delta_profile` vrací `deltas_by_array` i součtové `deltas`; `ems.fn_load_planning_slots_full` aplikuje stejnou **per-pole** korekci jako UI (`fn_forecast_pv_slots_range_corrected`); pokud v JSON profilu chybí `deltas_by_array`, použije se souhrnné `deltas` rozpuštěné podle podílu výkonu pole na slotu (solver má tak stále použitou korekci i bez per-pole JSON). @@ -62,7 +65,7 @@ order by interval_start; - Má **zelený bonus** (dotace za každé vyrobené kWh bez ohledu na cenu) - Výroba pole B musí být vždy plně spotřebována nebo uložena - Při záporné prodejní ceně má nejvyšší prioritu ukládání (baterie → EV → TČ) -- Solver nikdy neexportuje výrobu pole B pokud je prodejní cena záporná +- Bez tvrdého zákazu `ge = 0` při záporném výkupu (viz výše **`block_export_on_negative_sell` / GEN cut-off**) může MILP vývoz zvolit i ekonomicky proti „bonusové“ náplni baterie; u **home-01** jde o záměrný trade-off (zelený bonus pole B, prostor baterie na záporný nákup). S **`block_export_on_negative_sell = true`** (typicky **KV1**) musí přebytek jít do baterie / curtail A, ne do sítě. > Poznámka: výše platí pro **home-01** (pv-b jako ongrid GEN se zeleným bonusem), kde pole B **nechceme curtailovat**. > U instalací typu **BA81** je na GEN portu typicky **AC coupling (mikroinvertory)** bez bonusu – výkon nelze plynule škrtit, diff --git a/frontend/src/pages/SiteConfiguration.tsx b/frontend/src/pages/SiteConfiguration.tsx index 7eecafe..fd0e441 100644 --- a/frontend/src/pages/SiteConfiguration.tsx +++ b/frontend/src/pages/SiteConfiguration.tsx @@ -90,6 +90,8 @@ const GRID_LABELS: Record = { max_import_power_w: 'Max. import (W)', max_export_power_w: 'Max. export (W)', no_export: 'Zákaz exportu', + block_export_on_negative_sell: + 'LP: při záporném výkupu zákaz vývozu (site_grid_connection; viz planning.md)', reserved_capacity_w: 'Rezervovaný výkon (W)', notes: 'Poznámky', }