refactor-control-monolith #4
@@ -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 (
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:00–06: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 = {
|
||||
|
||||
52
backend/tests/test_control_deye_passive_pv_charge.py
Normal file
52
backend/tests/test_control_deye_passive_pv_charge.py
Normal 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()
|
||||
@@ -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,
|
||||
|
||||
140
backend/tests/test_planning_safety_commitment.py
Normal file
140
backend/tests/test_planning_safety_commitment.py
Normal 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()
|
||||
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal file
25
db/migration/V077__planner_safety_charge_asset_battery.sql
Normal 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.';
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 00–12 a 12–24 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:00–06:00 Europe/Prague), safety_soc_target_wh (6–19), '
|
||||
'lookahead max buy/sell pro měkké LP penalizace.';
|
||||
|
||||
76
db/routines/R__087_fn_planning_run_debug.sql
Normal file
76
db/routines/R__087_fn_planning_run_debug.sql
Normal 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.';
|
||||
@@ -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
|
||||
);
|
||||
```
|
||||
|
||||
@@ -150,9 +150,9 @@ bits 0–1). 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 0–1). 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` |
|
||||
|
||||
@@ -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[T−1]` (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:00–06:00 Europe/Prague), buffer % z `asset_battery.planner_night_baseload_buffer_percent`, lookahead `future_*_czk_kwh`, volitelný `safety_soc_target_wh` (6–19) 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).
|
||||
|
||||
@@ -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`
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
Reference in New Issue
Block a user