refactor-control-monolith #4

Merged
vojacekd merged 3 commits from refactor-control-monolith into main 2026-05-04 19:07:19 +02:00
17 changed files with 713 additions and 72 deletions
Showing only changes of commit bcb05d4896 - Show all commits

View File

@@ -69,7 +69,6 @@ from services.control.setpoints import (
_deye_tou_min_soc_pct,
_deye_tou_params,
_deye_tou_reserve_soc_pct,
_deye_zero_export_amps_for_passive,
get_deye_mode,
)
from services.control.verify import (

View File

@@ -25,7 +25,6 @@ from services.control.deye_helpers import (
_DEYE_INACTIVE_TOU_REGISTERS,
_deye_should_skip_time_sync_after_read,
_prague_minute_start_utc,
battery_watts_to_amps,
current_slot_hhmm,
next_slot_hhmm,
)
@@ -44,7 +43,7 @@ from services.control.setpoints import (
_deye_tou_min_soc_pct,
_deye_tou_params,
_deye_tou_reserve_soc_pct,
_deye_zero_export_amps_for_passive,
deye_battery_charge_discharge_amps,
get_deye_mode,
)
from services.modbus_client import get_modbus_client
@@ -78,25 +77,15 @@ 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":
charge_a = battery_watts_to_amps(bat_w, inv.max_charge_a)
discharge_a = 0
elif deye_mode == "SELL":
charge_a = 0
discharge_a = int(inv.max_discharge_a)
elif setpoints_now.self_sustain_local_use:
charge_a = int(inv.max_charge_a)
discharge_a = int(inv.max_discharge_a)
else:
charge_a, discharge_a = _deye_zero_export_amps_for_passive(
grid_w,
bat_w,
int(inv.max_charge_a),
int(inv.max_discharge_a),
)
charge_a, discharge_a = deye_battery_charge_discharge_amps(
lock_battery=setpoints_now.lock_battery,
deye_mode=deye_mode,
self_sustain_local_use=setpoints_now.self_sustain_local_use,
bat_w=bat_w,
grid_w=grid_w,
max_charge_a=int(inv.max_charge_a),
max_discharge_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

View File

@@ -255,6 +255,36 @@ def _deye_zero_export_amps_for_passive(
return max_charge_a, max_discharge_a
def deye_battery_charge_discharge_amps(
*,
lock_battery: bool,
deye_mode: str,
self_sustain_local_use: bool,
bat_w: int,
grid_w: int,
max_charge_a: int,
max_discharge_a: int,
) -> tuple[int, int]:
"""
Proud nabíjení / vybíjení (reg 108 / 109) pro zápis Deye.
PASSIVE + plán chce nabíjet z PV přebytku i při exportu do sítě: nenulový charge, discharge 0.
"""
if lock_battery:
return 0, 0
if deye_mode == "CHARGE":
return battery_watts_to_amps(bat_w, max_charge_a), 0
if deye_mode == "SELL":
return 0, int(max_discharge_a)
if self_sustain_local_use:
return int(max_charge_a), int(max_discharge_a)
if bat_w > 0:
return battery_watts_to_amps(bat_w, max_charge_a), 0
return _deye_zero_export_amps_for_passive(
grid_w, bat_w, int(max_charge_a), int(max_discharge_a)
)
def get_deye_mode(setpoints: ControlSetpoints) -> str:
"""Fyzický režim Deye: SELL | CHARGE | PASSIVE."""
pm = (setpoints.deye_physical_mode or "").strip().upper()

View File

@@ -14,7 +14,7 @@ import time
from dataclasses import dataclass, replace
from datetime import datetime, timezone, timedelta
from types import SimpleNamespace
from typing import Optional
from typing import Any, Optional
from zoneinfo import ZoneInfo
import pulp
@@ -159,6 +159,13 @@ def _soc_security_profile(slots: list["PlanningSlot"], battery) -> tuple[float,
return target_wh, penalty_czk_kwh
def _slot_float_nullable(d: dict[str, Any], key: str) -> float | None:
v = d.get(key)
if v is None:
return None
return float(v)
def _prague_dow_hour(interval_start: datetime) -> tuple[int, int]:
"""DOW v konvenci PostgreSQL EXTRACT(DOW, Europe/Prague): 0=Ne … 6=So."""
dt = interval_start
@@ -185,6 +192,13 @@ class PlanningSlot:
is_predicted_price: bool = False
allow_charge: bool = True
allow_discharge_export: bool = True
#: Měkké LP vstupy z `ems.fn_load_planning_slots_full` (mimo masky allow_*).
night_baseload_target_wh: float | None = None
night_baseload_buffer_wh: float | None = None
safety_soc_target_wh: float | None = None
future_avoided_buy_czk_kwh: float | None = None
future_sell_opportunity_czk_kwh: float | None = None
is_daytime_pv_surplus_slot: bool = False
# Lookahead pro relax spodní meze SoC: až 36 h od indexu slotu (pevné OTE ceny v horizontu).
@@ -438,10 +452,11 @@ def solve_dispatch(
*,
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
operating_mode: str = "AUTO",
) -> tuple[list[DispatchResult], int]:
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
"""
LP solver pro dispatch optimalizaci.
Vrátí (výsledky, solver_duration_ms).
Vrátí (výsledky, solver_duration_ms, solver_debug_snapshot).
"""
T = len(slots)
if T < 1:
@@ -603,6 +618,33 @@ def solve_dispatch(
t_anchor = first_neg_sell_idx - 1
soc_anchor_slack = pulp.LpVariable("soc_anchor_slack_wh", 0, float(battery.usable_capacity_wh))
daytime_en = bool(getattr(battery, "planner_daytime_charge_target_enabled", True))
safety_pen_czk_per_wh: list[float] = []
safety_vars: list[Optional[pulp.LpVariable]] = []
for t in range(T):
sft = slots[t].safety_soc_target_wh if daytime_en else None
fb = float(slots[t].future_avoided_buy_czk_kwh or slots[t].buy_price)
fs = float(slots[t].future_sell_opportunity_czk_kwh or slots[t].sell_price)
bv = max(fb, fs) - float(degradation_cost_effective)
bv = max(0.0, min(5.0, bv))
safety_pen_czk_per_wh.append(bv / 1000.0 if sft is not None else 0.0)
if sft is not None:
safety_vars.append(
pulp.LpVariable(f"safety_def_{t}", 0, float(battery.usable_capacity_wh))
)
else:
safety_vars.append(None)
commit_pen = float(getattr(battery, "planner_charge_commitment_penalty_czk_kwh", 0.2))
commit_lp: list[tuple[int, pulp.LpVariable, float]] = []
if charge_commitment_prev_w is not None and len(charge_commitment_prev_w) == T:
for t in range(T):
prev = charge_commitment_prev_w[t]
if prev is not None and prev > 500:
cap_prev = float(prev)
cv = pulp.LpVariable(f"ccommit_{t}", 0, cap_prev)
commit_lp.append((t, cv, cap_prev))
# --- Účelová funkce (jen OTE sloty; terminal SoC shadow price na konci horizontu) ---
prob += (
pulp.lpSum(
@@ -644,6 +686,12 @@ def solve_dispatch(
if soc_anchor_slack is not None
else 0
)
+ pulp.lpSum(
safety_vars[t] * safety_pen_czk_per_wh[t]
for t in range(T)
if safety_vars[t] is not None
)
+ pulp.lpSum(cv * INTERVAL_H / 1000.0 * commit_pen for _t, cv, _p in commit_lp)
)
# --- Omezení ---
@@ -680,6 +728,11 @@ def solve_dispatch(
- bd[t] / battery.discharge_efficiency * INTERVAL_H
)
sv = safety_vars[t]
tgt_s = slots[t].safety_soc_target_wh if daytime_en else None
if sv is not None and tgt_s is not None:
prob += sv >= float(tgt_s) - soc[t]
# ev_via_bat kryto z discharge
prob += pulp.lpSum(ev_via_bat[e][t] for e in range(EV)) <= bd[t]
@@ -762,6 +815,9 @@ def solve_dispatch(
else:
prob += ev_direct[e][t] + ev_via_bat[e][t] <= vehicles[e].max_charge_power_w
for tt, cv, prev in commit_lp:
prob += cv >= prev - bc[tt]
if om == "SELF_SUSTAIN":
for t in range(T):
prob += gi[t] <= slots[t].load_baseline_w
@@ -899,7 +955,91 @@ def solve_dispatch(
is_predicted_price = bool(slots[t].is_predicted_price),
))
return results, duration_ms
sell_rank = sorted(range(T), key=lambda i: float(slots[i].sell_price), reverse=True)[: min(3, T)]
charge_commit_snapshot = [
{
"slot": slots[tt].interval_start.isoformat(),
"previous_charge_w": prev,
"shortfall_w": float(pulp.value(cv) or 0.0),
}
for tt, cv, prev in commit_lp
]
masks_snap: list[dict[str, Any]] = []
soc_bounds_snap: list[dict[str, Any]] = []
objective_terms_snap: list[dict[str, Any]] = []
for t in range(T):
st = slots[t]
masks_snap.append(
{
"slot": st.interval_start.isoformat(),
"allow_charge": bool(st.allow_charge),
"allow_discharge_export": bool(st.allow_discharge_export),
}
)
tgt_s = st.safety_soc_target_wh if daytime_en else None
soc_bounds_snap.append(
{
"slot": st.interval_start.isoformat(),
"soc_min_wh": float(soc_panel_min[t]),
"arb_floor_wh": float(arb_floor_series[t]),
"soc_panel_min_wh": float(soc_panel_min[t]),
"safety_soc_target_wh": float(tgt_s) if tgt_s is not None else None,
}
)
fb = float(st.future_avoided_buy_czk_kwh or st.buy_price)
fs = float(st.future_sell_opportunity_czk_kwh or st.sell_price)
bv = max(fb, fs) - float(degradation_cost_effective)
bv = max(0.0, min(5.0, bv))
pen_wh = bv / 1000.0 if tgt_s is not None else 0.0
sv = safety_vars[t]
sdv = float(pulp.value(sv) or 0.0) if sv is not None else None
cshort = next((float(pulp.value(cv) or 0.0) for tt, cv, _p in commit_lp if tt == t), None)
objective_terms_snap.append(
{
"slot": st.interval_start.isoformat(),
"buy_price": float(st.buy_price),
"sell_price": float(st.sell_price),
"future_avoided_buy_czk_kwh": float(st.future_avoided_buy_czk_kwh or st.buy_price),
"future_sell_opportunity_czk_kwh": float(
st.future_sell_opportunity_czk_kwh or st.sell_price
),
"battery_value_czk_kwh": float(bv),
"safety_deficit_penalty_czk_per_wh": float(pen_wh),
"safety_deficit_wh": sdv,
"commitment_shortfall_w": cshort,
"commitment_penalty_czk_kwh": float(commit_pen) if cshort is not None else None,
}
)
night0 = slots[0]
solver_snapshot: dict[str, Any] = {
"version": 1,
"inputs": {
"current_soc_wh": float(current_soc_wh),
"operating_mode": operating_mode,
"battery": {
"usable_capacity_wh": float(battery.usable_capacity_wh),
"min_soc_wh": float(battery.min_soc_wh),
"reserve_soc_wh": float(getattr(battery, "reserve_soc_wh", 0.0)),
"degradation_cost_czk_kwh": float(battery.degradation_cost_czk_kwh),
"planner_terminal_soc_value_factor": float(battery.planner_terminal_soc_value_factor),
"planner_daytime_charge_target_enabled": daytime_en,
"planner_charge_commitment_penalty_czk_kwh": float(commit_pen),
},
},
"masks": masks_snap,
"soc_bounds": soc_bounds_snap,
"objective_terms": objective_terms_snap,
"chosen_slots": {
"charge_commitment": charge_commit_snapshot,
"high_sell_windows": [slots[i].interval_start.isoformat() for i in sell_rank],
"night_window": {
"definition": "Europe/Prague 20:0006:00 projected baseload Wh (fn_load_planning_slots_full)",
"target_wh": night0.night_baseload_target_wh,
"buffer_wh": night0.night_baseload_buffer_wh,
},
},
}
return results, duration_ms, solver_snapshot
# ============================================================
@@ -930,7 +1070,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
)
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
results, duration_ms = solve_dispatch(
results, duration_ms, solver_snapshot = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=operating_mode or "AUTO",
@@ -950,6 +1090,7 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
correction=1.0,
db=db,
slot_inputs=slot_inputs,
solver_snapshot=solver_snapshot,
)
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
return run_id, duration_ms
@@ -1023,10 +1164,13 @@ async def run_rolling_replan(
slots = apply_forecast_correction(slots, now, correction_factor)
results, duration_ms = solve_dispatch(
commitment_prev = await _load_previous_plan_charge_commitment_prev_w(site_id, slots, db)
results, duration_ms, solver_snapshot = solve_dispatch(
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
tuv_delta_stats=tuv_stats,
operating_mode=operating_mode or "AUTO",
charge_commitment_prev_w=commitment_prev,
)
slot_inputs = _build_slot_inputs(slots_before_pv_correction, slots)
@@ -1043,6 +1187,7 @@ async def run_rolling_replan(
correction=correction_factor,
db=db,
slot_inputs=slot_inputs,
solver_snapshot=solver_snapshot,
)
await db.execute(
@@ -1165,6 +1310,18 @@ async def _load_site_context(site_id: int, db):
if relax_prewin is not None
else DEFAULT_PLANNER_DISCHARGE_RELAX_PREWINDOW_SLOTS,
planner_terminal_soc_value_factor=float(b["planner_terminal_soc_value_factor"]),
planner_daytime_charge_target_enabled=bool(
b.get("planner_daytime_charge_target_enabled", True)
),
planner_night_baseload_buffer_percent=float(
b.get("planner_night_baseload_buffer_percent") or 20.0
),
planner_daytime_charge_price_quantile=float(
b.get("planner_daytime_charge_price_quantile") or 0.70
),
planner_charge_commitment_penalty_czk_kwh=float(
b.get("planner_charge_commitment_penalty_czk_kwh") or 0.20
),
)
hpj = ctx["heat_pump"]
@@ -1227,6 +1384,51 @@ async def _load_site_context(site_id: int, db):
)
async def _load_previous_plan_charge_commitment_prev_w(
site_id: int,
slots: list[PlanningSlot],
db,
) -> list[Optional[float]]:
"""
Pro rolling replan: z aktivního plánu načte battery_setpoint_w pro shodné sloty.
Kotva měkkého commitmentu jen když předchozí plán chtěl nabíjet z PV přebytku (viz podmínky).
"""
if not slots:
return []
rows = await db.fetch(
"""
select pi.interval_start,
pi.battery_setpoint_w,
pi.grid_setpoint_w,
coalesce(pi.pv_a_forecast_solver_w, 0) as pva,
coalesce(pi.pv_b_forecast_solver_w, 0) as pvb,
coalesce(pi.load_baseline_w, 0) as lb
from ems.planning_interval pi
inner join ems.planning_run pr on pr.id = pi.run_id
where pr.site_id = $1::int
and pr.status = 'active'
""",
site_id,
)
by_start = {r["interval_start"]: r for r in rows}
out: list[Optional[float]] = []
for s in slots:
r = by_start.get(s.interval_start)
if r is None:
out.append(None)
continue
bw = int(r["battery_setpoint_w"] or 0)
gw = int(r["grid_setpoint_w"] or 0)
pva = int(r["pva"] or 0)
pvb = int(r["pvb"] or 0)
lb = int(r["lb"] or 0)
if bw > 500 and (pva + pvb) > lb and gw <= 0:
out.append(float(bw))
else:
out.append(None)
return out
async def _load_slots(
site_id: int,
from_dt: datetime,
@@ -1240,7 +1442,10 @@ async def _load_slots(
"""
select slot_ord, interval_start, buy_price, sell_price, is_predicted_price,
pv_a_forecast_w, pv_b_forecast_w, load_baseline_w,
ev1_connected, ev2_connected, allow_charge, allow_discharge_export
ev1_connected, ev2_connected, allow_charge, allow_discharge_export,
night_baseload_target_wh, night_baseload_buffer_wh, safety_soc_target_wh,
future_avoided_buy_czk_kwh, future_sell_opportunity_czk_kwh,
is_daytime_pv_surplus_slot
from ems.fn_load_planning_slots_full(
$1::int, $2::timestamptz, $3::timestamptz, $4::numeric
)
@@ -1266,6 +1471,14 @@ async def _load_slots(
is_predicted_price=bool(d.get("is_predicted_price")),
allow_charge=bool(d.get("allow_charge", True)),
allow_discharge_export=bool(d.get("allow_discharge_export", True)),
night_baseload_target_wh=_slot_float_nullable(d, "night_baseload_target_wh"),
night_baseload_buffer_wh=_slot_float_nullable(d, "night_baseload_buffer_wh"),
safety_soc_target_wh=_slot_float_nullable(d, "safety_soc_target_wh"),
future_avoided_buy_czk_kwh=_slot_float_nullable(d, "future_avoided_buy_czk_kwh"),
future_sell_opportunity_czk_kwh=_slot_float_nullable(
d, "future_sell_opportunity_czk_kwh"
),
is_daytime_pv_surplus_slot=bool(d.get("is_daytime_pv_surplus_slot", False)),
)
)
if not out:
@@ -1306,11 +1519,13 @@ async def _save_planning_run(
run_type, triggered_by, replan_from,
soc_wh, duration_ms, correction, db,
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
*,
solver_snapshot: Optional[dict[str, Any]] = None,
) -> int:
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
if slot_inputs is not None and len(slot_inputs) != len(results):
raise ValueError("slot_inputs and results length mismatch")
run_meta = {
run_meta: dict[str, Any] = {
"run_type": run_type,
"triggered_by": triggered_by,
"replan_from": replan_from.isoformat() if replan_from else None,
@@ -1318,6 +1533,8 @@ async def _save_planning_run(
"solver_duration_ms": duration_ms,
"forecast_correction_factor": correction,
}
if solver_snapshot is not None:
run_meta["solver_params"] = solver_snapshot
intervals: list[dict] = []
for i, r in enumerate(results):
row: dict = {

View File

@@ -0,0 +1,52 @@
"""PASSIVE + nabíjení z PV přebytku při současném exportu → nenulový nabíjecí proud."""
from __future__ import annotations
import unittest
from services.control.setpoints import deye_battery_charge_discharge_amps
class PassivePvSurplusChargeAmpsTests(unittest.TestCase):
def test_passive_charge_while_exporting_grid_negative(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=5000,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
)
self.assertGreater(ch, 0)
self.assertEqual(dis, 0)
def test_passive_zero_export_still_zero_charge_when_no_battery_charge(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="PASSIVE",
self_sustain_local_use=False,
bat_w=0,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=100,
)
self.assertEqual(ch, 0)
self.assertEqual(dis, 100)
def test_sell_unchanged(self) -> None:
ch, dis = deye_battery_charge_discharge_amps(
lock_battery=False,
deye_mode="SELL",
self_sustain_local_use=False,
bat_w=-3000,
grid_w=-2000,
max_charge_a=100,
max_discharge_a=80,
)
self.assertEqual(ch, 0)
self.assertEqual(dis, 80)
if __name__ == "__main__":
unittest.main()

View File

@@ -237,7 +237,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.50 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -278,7 +278,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = battery.soc_max_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -317,7 +317,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.15 * battery.usable_capacity_wh
results, ms = solve_dispatch(
results, ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -357,7 +357,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.12 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -393,7 +393,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -433,7 +433,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.22 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -511,7 +511,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.88 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -593,7 +593,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -680,7 +680,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -755,7 +755,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.9 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -798,7 +798,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.55 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -853,7 +853,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
]
soc0 = 0.55 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -913,7 +913,7 @@ class PlanningDispatchMilpTests(unittest.TestCase):
),
]
soc0 = 0.34 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,
@@ -983,7 +983,7 @@ class TerminalSocShadowTests(unittest.TestCase):
),
]
soc0 = 0.5 * battery.usable_capacity_wh
results, _ms = solve_dispatch(
results, _ms, _ = solve_dispatch(
slots,
battery,
hp,

View File

@@ -0,0 +1,140 @@
"""Měkké safety SoC a rolling charge commitment v solve_dispatch."""
from __future__ import annotations
import unittest
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from services.planning_engine import PlanningSlot, solve_dispatch
def _bat(**kwargs: object) -> SimpleNamespace:
base = dict(
usable_capacity_wh=20_000.0,
min_soc_wh=2000.0,
arb_floor_wh=4000.0,
reserve_soc_wh=4000.0,
soc_max_wh=19_000.0,
charge_efficiency=0.95,
discharge_efficiency=0.95,
degradation_cost_czk_kwh=0.1,
max_charge_power_w=5000,
max_discharge_power_w=5000,
planner_terminal_soc_value_factor=0.2,
planner_extreme_buy_threshold_czk_kwh=-5.0,
planner_discharge_floor_percent=None,
planner_discharge_relax_prewindow_slots=8,
planner_daytime_charge_target_enabled=True,
planner_charge_commitment_penalty_czk_kwh=0.5,
)
base.update(kwargs)
return SimpleNamespace(**base)
def _grid() -> SimpleNamespace:
return SimpleNamespace(
max_import_power_w=11_000,
max_export_power_w=11_000,
block_export_on_negative_sell=False,
deye_gen_microinverter_cutoff_enabled=False,
)
def _hp() -> SimpleNamespace:
return SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
def _slot(
t0: datetime,
idx: int,
*,
buy: float = 3.0,
sell: float = 2.5,
pv_a: int = 0,
load: int = 1500,
safety: float | None = None,
fut_buy: float | None = None,
fut_sell: float | None = None,
) -> PlanningSlot:
return PlanningSlot(
interval_start=t0 + timedelta(minutes=15 * idx),
buy_price=buy,
sell_price=sell,
pv_a_forecast_w=pv_a,
pv_b_forecast_w=0,
load_baseline_w=load,
ev1_connected=False,
ev2_connected=False,
allow_charge=True,
allow_discharge_export=True,
safety_soc_target_wh=safety,
future_avoided_buy_czk_kwh=fut_buy,
future_sell_opportunity_czk_kwh=fut_sell,
)
class PlanningSafetyCommitmentTests(unittest.TestCase):
def test_solver_snapshot_has_version_and_masks(self) -> None:
t0 = datetime(2026, 5, 4, 8, 0, tzinfo=timezone.utc)
slots = [_slot(t0, i, buy=2.0, sell=2.0, pv_a=6000, load=1500) for i in range(8)]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
res, _ms, snap = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=5000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
)
self.assertEqual(len(res), 8)
self.assertEqual(snap.get("version"), 1)
self.assertIn("masks", snap)
self.assertEqual(len(snap["masks"]), 8)
def test_charge_commitment_snapshot_populated(self) -> None:
"""Rolling kotva: při předchozím nabíjení z PV se do snapshotu zapíše commitment."""
t0 = datetime(2026, 5, 4, 10, 0, tzinfo=timezone.utc)
slots = [_slot(t0, i, buy=1.5, sell=1.2, pv_a=8000, load=1000) for i in range(12)]
hp, grid = _hp(), _grid()
vehicles = [
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0)
] * 2
prev = [None] * 12
prev[0] = 4000.0
_res1, _, snap1 = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=4000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
charge_commitment_prev_w=prev,
)
self.assertTrue(snap1["chosen_slots"]["charge_commitment"])
_res2, _, snap2 = solve_dispatch(
slots,
_bat(),
hp,
grid,
[None, None],
vehicles,
current_soc_wh=4000.0,
current_tuv_temp_c=50.0,
operating_mode="AUTO",
charge_commitment_prev_w=None,
)
self.assertEqual(snap2["chosen_slots"]["charge_commitment"], [])
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,25 @@
-- Parametry pro denní „safety charge“ (měkké LP penalizace) a kotvu rolling replanu.
alter table ems.asset_battery
add column if not exists planner_daytime_charge_target_enabled boolean not null default true;
alter table ems.asset_battery
add column if not exists planner_night_baseload_buffer_percent numeric not null default 20;
alter table ems.asset_battery
add column if not exists planner_daytime_charge_price_quantile numeric not null default 0.70;
alter table ems.asset_battery
add column if not exists planner_charge_commitment_penalty_czk_kwh numeric not null default 0.20;
comment on column ems.asset_battery.planner_daytime_charge_target_enabled is
'Zapíná SQL/LP měkké denní cíle SoC (safety) z fn_load_planning_slots_full; ne tvrdé allow_charge masky.';
comment on column ems.asset_battery.planner_night_baseload_buffer_percent is
'Procentní přirážka k odhadu nočního baseload Wh (20 = +20 % k night_baseload_target_wh).';
comment on column ems.asset_battery.planner_daytime_charge_price_quantile is
'Rezervováno pro budoucí výběr „drahých“ oken z cenové distribuce; v1 se v LP nepoužívá.';
comment on column ems.asset_battery.planner_charge_commitment_penalty_czk_kwh is
'Koeficient měkké penalizace (Kč/kWh krátkého nedodržení) proti předchozímu plánu při rolling replanu.';

View File

@@ -23,7 +23,8 @@ begin
insert into ems.planning_run (
site_id, horizon_start, horizon_end, status,
run_type, triggered_by, replan_from,
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor
soc_at_replan_wh, solver_duration_ms, forecast_correction_factor,
solver_params
) values (
p_site_id,
p_horizon_start,
@@ -39,7 +40,12 @@ begin
end,
(p_run_meta->>'soc_at_replan_wh')::numeric,
(p_run_meta->>'solver_duration_ms')::int,
(p_run_meta->>'forecast_correction_factor')::numeric
(p_run_meta->>'forecast_correction_factor')::numeric,
case
when p_run_meta ? 'solver_params' and jsonb_typeof(p_run_meta->'solver_params') = 'object'
then p_run_meta->'solver_params'
else null::jsonb
end
)
returning id into v_run_id;

View File

@@ -67,7 +67,11 @@ begin
)::int,
'charge_slot_buffer', ab.charge_slot_buffer,
'discharge_slot_buffer', ab.discharge_slot_buffer,
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor
'planner_terminal_soc_value_factor', ab.planner_terminal_soc_value_factor,
'planner_daytime_charge_target_enabled', coalesce(ab.planner_daytime_charge_target_enabled, true),
'planner_night_baseload_buffer_percent', coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric),
'planner_daytime_charge_price_quantile', coalesce(ab.planner_daytime_charge_price_quantile, 0.70::numeric),
'planner_charge_commitment_penalty_czk_kwh', coalesce(ab.planner_charge_commitment_penalty_czk_kwh, 0.20::numeric)
)
into v_b
from ems.asset_battery ab

View File

@@ -18,7 +18,13 @@ returns table (
ev1_connected boolean,
ev2_connected boolean,
allow_charge boolean,
allow_discharge_export boolean
allow_discharge_export boolean,
night_baseload_target_wh numeric,
night_baseload_buffer_wh numeric,
safety_soc_target_wh numeric,
future_avoided_buy_czk_kwh numeric,
future_sell_opportunity_czk_kwh numeric,
is_daytime_pv_surplus_slot boolean
)
language plpgsql
volatile
@@ -47,6 +53,9 @@ declare
v_chg_pm_wh numeric;
v_dis_am_wh numeric;
v_dis_pm_wh numeric;
v_reserve_wh numeric;
v_daytime_en boolean;
v_night_buf_pct numeric;
begin
drop table if exists _ems_plan_slot_wk;
create temp table _ems_plan_slot_wk on commit drop as
@@ -280,7 +289,10 @@ begin
coalesce(ai.max_battery_discharge_w, ai.max_discharge_power_w)
)
)::numeric,
greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric)
greatest(coalesce(ab.discharge_efficiency, 1::numeric), 0.0001::numeric),
(ab.reserve_soc_percent / 100.0 * ab.usable_capacity_wh)::numeric,
coalesce(ab.planner_daytime_charge_target_enabled, true),
coalesce(ab.planner_night_baseload_buffer_percent, 20::numeric)
into
v_charge_buf,
v_discharge_buf,
@@ -290,7 +302,10 @@ begin
v_charge_eff,
v_max_charge_w,
v_max_discharge_w,
v_discharge_eff
v_discharge_eff,
v_reserve_wh,
v_daytime_en,
v_night_buf_pct
from ems.asset_battery ab
join ems.asset_inverter ai on ai.id = ab.inverter_id and ai.site_id = ab.site_id
where ab.site_id = p_site_id
@@ -395,25 +410,97 @@ begin
end if;
return query
with night_tot as (
select coalesce(sum(w2.load_baseline_w), 0) * 0.25 as night_wh
from _ems_plan_slot_wk w2
where extract(hour from w2.interval_start at time zone 'Europe/Prague') >= 20
or extract(hour from w2.interval_start at time zone 'Europe/Prague') < 6
),
enriched as (
select
w.slot_ord,
w.interval_start,
w.buy_price,
w.sell_price,
w.is_predicted_price,
w.pv_a_forecast_w,
w.pv_b_forecast_w,
w.load_baseline_w,
w.ev1_connected,
w.ev2_connected,
w.allow_charge,
w.allow_discharge_export,
nt.night_wh as night_baseload_target_wh,
nt.night_wh * (v_night_buf_pct / 100.0) as night_baseload_buffer_wh,
case
when not v_daytime_en then null::numeric
when extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 19 then
least(
v_soc_max_wh,
v_reserve_wh + (nt.night_wh + nt.night_wh * (v_night_buf_pct / 100.0))
* greatest(
0::numeric,
least(
1::numeric,
(
extract(hour from w.interval_start at time zone 'Europe/Prague')::numeric
+ (
extract(minute from w.interval_start at time zone 'Europe/Prague')::numeric
/ 60.0
)
- 6.0
) / 14.0
)
)
)
else null::numeric
end as safety_soc_target_wh,
coalesce(
max(w.buy_price) over (
order by w.slot_ord rows between 1 following and unbounded following
),
w.buy_price
) as future_avoided_buy_czk_kwh,
coalesce(
max(w.sell_price) over (
order by w.slot_ord rows between 1 following and unbounded following
),
w.sell_price
) as future_sell_opportunity_czk_kwh,
(
extract(hour from w.interval_start at time zone 'Europe/Prague') between 6 and 18
and w.pv_surplus_w > 0
) as is_daytime_pv_surplus_slot
from _ems_plan_slot_wk w
cross join night_tot nt
)
select
w.slot_ord,
w.interval_start,
w.buy_price,
w.sell_price,
w.is_predicted_price,
w.pv_a_forecast_w,
w.pv_b_forecast_w,
w.load_baseline_w,
w.ev1_connected,
w.ev2_connected,
w.allow_charge,
w.allow_discharge_export
from _ems_plan_slot_wk w
order by w.slot_ord;
e.slot_ord,
e.interval_start,
e.buy_price,
e.sell_price,
e.is_predicted_price,
e.pv_a_forecast_w,
e.pv_b_forecast_w,
e.load_baseline_w,
e.ev1_connected,
e.ev2_connected,
e.allow_charge,
e.allow_discharge_export,
e.night_baseload_target_wh,
e.night_baseload_buffer_wh,
e.safety_soc_target_wh,
e.future_avoided_buy_czk_kwh,
e.future_sell_opportunity_czk_kwh,
e.is_daytime_pv_surplus_slot
from enriched e
order by e.slot_ord;
end;
$fn$;
comment on function ems.fn_load_planning_slots_full(int, timestamptz, timestamptz, numeric) is
'15min sloty s cenami, forecastem, baseline a maskami proti mikro-cyklu (charge/discharge-export). '
'Masky charge/discharge-export se berou zvlášť pro 0012 a 1224 Europe/Prague (polovina budgetu na segment). '
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent).';
'Strop SoC pro výpočet energie k dobití: coalesce(planner_max_soc_percent, max_soc_percent). '
'Denní safety vstupy: night_baseload_* (20:0006:00 Europe/Prague), safety_soc_target_wh (619), '
'lookahead max buy/sell pro měkké LP penalizace.';

View File

@@ -0,0 +1,76 @@
-- Kompaktní JSON pro diagnostiku jednoho planning_run (MCP / UI).
create or replace function ems.fn_planning_run_debug(p_run_id int)
returns jsonb
language plpgsql
stable
as $fn$
declare
r_run ems.planning_run%rowtype;
v_intervals jsonb;
v_first_charge timestamptz;
v_first_bat_export timestamptz;
v_top_sell jsonb;
begin
select * into r_run from ems.planning_run where id = p_run_id;
if not found then
return null::jsonb;
end if;
select coalesce(jsonb_agg(to_jsonb(pi.*) order by pi.interval_start), '[]'::jsonb)
into v_intervals
from ems.planning_interval pi
where pi.run_id = p_run_id;
select pi.interval_start
into v_first_charge
from ems.planning_interval pi
where pi.run_id = p_run_id
and coalesce(pi.battery_setpoint_w, 0) > 500
order by pi.interval_start
limit 1;
select pi.interval_start
into v_first_bat_export
from ems.planning_interval pi
where pi.run_id = p_run_id
and coalesce(pi.battery_setpoint_w, 0) < -500
and coalesce(pi.grid_setpoint_w, 0) < 0
order by pi.interval_start
limit 1;
select coalesce(
jsonb_agg(
jsonb_build_object(
'interval_start', x.interval_start,
'effective_sell_price', x.effective_sell_price
)
order by x.effective_sell_price desc nulls last
),
'[]'::jsonb
)
into v_top_sell
from (
select pi.interval_start, pi.effective_sell_price
from ems.planning_interval pi
where pi.run_id = p_run_id
order by pi.effective_sell_price desc nulls last
limit 3
) x;
return jsonb_build_object(
'planning_run', to_jsonb(r_run),
'solver_params', r_run.solver_params,
'intervals', v_intervals,
'summary', jsonb_build_object(
'first_charge_slot', to_jsonb(v_first_charge),
'first_battery_export_slot', to_jsonb(v_first_bat_export),
'top_sell_slots', v_top_sell,
'solver_params_version', r_run.solver_params->'version'
)
);
end;
$fn$;
comment on function ems.fn_planning_run_debug(int) is
'Jeden jsonb: metadata planning_run, solver_params, všechny planning_interval řádky a krátký summary.';

View File

@@ -127,6 +127,8 @@ CREATE TABLE asset_battery (
-- planner_max_soc_percent, planner_discharge_floor_percent,
-- planner_extreme_buy_threshold_czk_kwh,
-- planner_terminal_soc_value_factor
-- V077: planner_daytime_charge_target_enabled, planner_night_baseload_buffer_percent,
-- planner_daytime_charge_price_quantile, planner_charge_commitment_penalty_czk_kwh
);
```
@@ -359,7 +361,7 @@ CREATE TABLE planning_run (
horizon_end TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT now(),
status TEXT DEFAULT 'draft', -- 'draft', 'approved', 'active', 'superseded'
solver_params JSONB,
solver_params JSONB, -- po V077: JSON z planning_engine (masks, soc_bounds, objective_terms, …)
notes TEXT
);
```

View File

@@ -150,9 +150,9 @@ bits 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
|---|---|
| **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 (ZERO)** | vše ostatní — reg. **108/109** z `deye_battery_charge_discharge_amps()` v `setpoints.py` (volá `write_inverter_setpoints`) |
**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. **143** je tvrdý limit exportu z lokality/invertoru (ne forecast). Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **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*).
**PASSIVE** (AUTO, ZERO): proudy **108/109** počítá **`deye_battery_charge_discharge_amps`**: pokud plán žádá **nabíjení** (`battery_w > 0`) včetně scénáře **PV přebytek + export do sítě** (`grid_setpoint_w < 0`), nastaví se **kladný nabíjecí proud (108)** a **109 = 0** — nesmí se použít čistě „zero export“ větev, která by při exportu vynutila **108 = 0** a rozbila soulad plán ↔ Deye. Jinak platí `_deye_zero_export_amps_for_passive` (export bez nabíjení → 108 = 0, import bez vybíjení → 109 = 0). **TOU** z plánu. Reg. **142** = `deye_zero_export_mode`. Reg. **143** je tvrdý limit exportu z lokality/invertoru (ne forecast). Reg. **145** (solar sell): **0** při `export_ban` mimo SELL, jinak **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ů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`.
@@ -160,8 +160,8 @@ bits 01). Detail registrů: [`modbus-registers.md`](modbus-registers.md) (reg
| Registr | Charge | PASSIVE (ZERO) | SELL | Self-consumption |
|---|---|---|---|---|
| **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 |
| **108** (charge A) | škálo dle `battery_w` | max / **0** (export bez `battery_w>0`) / **>0** při `battery_w>0` i při exportu | **0** | dle varianty |
| **109** (discharge A) | **0** | max / **0** (import, držet bat.) / **0** při `battery_w>0` + export z PV | **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 | max z DB (tvrdý limit, bez forecast heuristiky) | max z DB |
| **145** (solar sell) | 1 / 0 při `export_ban` | 1 / 0 při `export_ban` | 1 | 1 / 0 při `export_ban` |

View File

@@ -10,6 +10,9 @@
- **Dynamický horizont (jen OTE):** konec plánu z **`ems.fn_planning_horizon_end(site_id, horizon_start)`** (výchozí strop **36 h**, minimum pro rolling **1 h** obojí jako defaultní argumenty v SQL, úprava přes repeatable migraci). Pomocná `ems.fn_last_effective_ote` vrací konec posledního OTE intervalu. Rolling replan při `NULL` přeskočí; denní plán použije krátký (1 h) fallback v Pythonu. Sloty v solveru jsou bez predikovaných cen v rámci tohoto horizontu.
- **Terminal SoC shadow price:** v objective je člen `(avg_buy_prvních_24h × planner_terminal_soc_value_factor / 1000) × soc[T1]` (Kč), kde faktor je **`ems.asset_battery.planner_terminal_soc_value_factor`** přes **`ems.fn_planning_site_context`** (default v DB **0.9**); viz sekci *Tuning pro malé baterie* níže. Účel: konec horizontu nemusí končit zbytečně vyprázdněnou baterií (receding horizon).
- **Masky `allow_charge` / `allow_discharge_export` (anti-mikrocyklování):** generuje `ems.fn_load_planning_slots_full`. Důležité: pokud rolling replan startuje s baterií na 100 %, `allow_charge` se nesmí stát globálně `false` pro celý horizont jinak solver nemá motivaci baterii před PV špičkou „uvolnit“ (headroom), protože ji pak nesmí z PV znovu nabít. Aktuálně se v tomto případě `allow_charge` ponechá povolené alespoň pro sloty s `pv_surplus_w > 0`.
- **Denní safety charge (měkké LP, ne maska):** `fn_load_planning_slots_full` (**V077+**) vrací navíc odhad nočního baseload Wh (20:0006:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (619) a flag `is_daytime_pv_surplus_slot`. `planning_engine.solve_dispatch()` přidá proměnné deficit vůči cíli a penalizaci `max(future_buy, future_sell) degradace` (clamp), aby šlo prodat ve velmi drahém sell okně i přes deficit. Tvrdé `allow_charge` se kvůli tomu nemění.
- **Rolling charge commitment:** při `run_rolling_replan` se z aktivního plánu načtou sloty, kde dříve platilo `battery_setpoint_w > 500`, `pv_a+pv_b > load_baseline`, `grid_setpoint_w ≤ 0`; měkká penalizace proti snížení `bc[t]` oproti předchozímu plánu (`planner_charge_commitment_penalty_czk_kwh` na `asset_battery`). Implementace: `_load_previous_plan_charge_commitment_prev_w`, volitelný argument `charge_commitment_prev_w` u `solve_dispatch()`.
- **Debug snapshot:** každý běh ukládá JSON do `ems.planning_run.solver_params` (sekce `version`, `inputs`, `masks`, `soc_bounds`, `objective_terms`, `chosen_slots`) přes `fn_planning_run_commit` (`p_run_meta->'solver_params'`). Read-model: **`select ems.fn_planning_run_debug(<run_id>);`** (`R__087_fn_planning_run_debug.sql`).
- **Runtime guard v exportu setpointů (legacy):**
- při `AUTO` + `is_predicted_price=true` se na exportní vrstvě vynutí PASSIVE/no-export chování (u nových plánů by `is_predicted_price` v horizontu nemělo nastat).
- **Ekonomika baterie:**
@@ -498,6 +501,8 @@ COMMENT ON COLUMN ems.planning_interval.pv_a_curtailed_w IS
## Tuning pro malé baterie (např. BA81)
Kromě **`planner_terminal_soc_value_factor`** existují od **V077** měkké mechanismy **denní safety charge** a **rolling charge commitment** (viz výše) — malé instalace nelze spolehlivě stabilizovat jen slepým zvyšováním terminal faktoru na **0.9**.
### Terminal SoC shadow price (kritický parametr)
V účelové funkci LP je člen **„terminal SoC shadow price“**: energie ponechaná v baterii na konci horizontu je oceněná jako záporný příspěvek k nákladům (solver má motivaci držet část SoC, pokud se to ekonomicky vyplatí oproti okamžitému vývozu / nákupu).

View File

@@ -112,9 +112,14 @@ Jak to je v implementaci:
- `export_mode = PV_SURPLUS`
- `solar_sell = 1`
- `battery charge A = 0`
- `export_limit_w = hard cap`
Poznámka k implementaci:
- tohle je v kódu garantované až ve chvíli, kdy planner dá `battery_setpoint_w = 0`
- pokud je `battery_setpoint_w > 0`, tak současná implementace už dovoluje i nabíjení baterie, i když exportní záměr zůstává `PV_SURPLUS`
- jinými slovy: čisté „prodávám výrobu, ale baterii nechci nabíjet“ ještě není samostatný fyzický Deye režim, je to kombinace plánovacího setpointu a exportního záměru
Použití:
- vhodné, když je výkupní cena vysoká
@@ -286,4 +291,3 @@ To znamená:
- live registry: `backend/app/routers/sites.py`
- FE plánování: `frontend/src/pages/Planning.tsx`
- FE live registry: `frontend/src/components/ControlPanel.tsx`

View File

@@ -61,6 +61,11 @@ limit 10;
select ems.fn_plan_explain_bundle(2, 6);
```
```sql
-- Diagnostika posledního běhu plánovače (run_id z planning_run)
select ems.fn_planning_run_debug(8107);
```
Měnící funkce (**`ems.fn_delete_forecast_pv_prague_calendar_day`**, **`ems.fn_rebuild_consumption_baseline_stats`**, …) MCP přes **`query` neprovede**, pokud má server jen read-only práva na DB — použij psql aplikačním účtem.
---