fix solar sell pri male zaporne cene
Some checks failed
CI and deploy / migration-check (push) Failing after 11s
CI and deploy / deploy (push) Has been skipped

This commit is contained in:
Dusan Vojacek
2026-05-01 10:38:40 +02:00
parent 6743224cc5
commit e686bc1d2c
8 changed files with 89 additions and 7 deletions

View File

@@ -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ů. |

View File

@@ -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 bits01).
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),
)

View File

@@ -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."""

View File

@@ -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';

View File

@@ -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

View File

@@ -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,

View File

@@ -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;
-**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,

View File

@@ -90,6 +90,8 @@ const GRID_LABELS: Record<string, string> = {
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',
}