fix solar sell pri male zaporne cene
This commit is contained in:
@@ -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ů. |
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
13
db/migration/V074__site_grid_block_export_negative_sell.sql
Normal file
13
db/migration/V074__site_grid_block_export_negative_sell.sql
Normal 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';
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user