diff --git a/backend/services/control/exporter_monolith.py b/backend/services/control/exporter_monolith.py index 375b38d..4d341fc 100644 --- a/backend/services/control/exporter_monolith.py +++ b/backend/services/control/exporter_monolith.py @@ -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 ( diff --git a/backend/services/control/inverter.py b/backend/services/control/inverter.py index be43a9f..10ef893 100644 --- a/backend/services/control/inverter.py +++ b/backend/services/control/inverter.py @@ -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 diff --git a/backend/services/control/setpoints.py b/backend/services/control/setpoints.py index 8073f9e..6c0b68a 100644 --- a/backend/services/control/setpoints.py +++ b/backend/services/control/setpoints.py @@ -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() diff --git a/backend/services/planning_engine.py b/backend/services/planning_engine.py index 0fadb22..17d6843 100644 --- a/backend/services/planning_engine.py +++ b/backend/services/planning_engine.py @@ -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 = { diff --git a/backend/tests/test_control_deye_passive_pv_charge.py b/backend/tests/test_control_deye_passive_pv_charge.py new file mode 100644 index 0000000..8a10fc7 --- /dev/null +++ b/backend/tests/test_control_deye_passive_pv_charge.py @@ -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() diff --git a/backend/tests/test_planning_dispatch_milp.py b/backend/tests/test_planning_dispatch_milp.py index 6ff505c..e1e2402 100644 --- a/backend/tests/test_planning_dispatch_milp.py +++ b/backend/tests/test_planning_dispatch_milp.py @@ -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, diff --git a/backend/tests/test_planning_safety_commitment.py b/backend/tests/test_planning_safety_commitment.py new file mode 100644 index 0000000..b01b54b --- /dev/null +++ b/backend/tests/test_planning_safety_commitment.py @@ -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() diff --git a/db/migration/V077__planner_safety_charge_asset_battery.sql b/db/migration/V077__planner_safety_charge_asset_battery.sql new file mode 100644 index 0000000..73705e5 --- /dev/null +++ b/db/migration/V077__planner_safety_charge_asset_battery.sql @@ -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.'; diff --git a/db/routines/R__037_fn_planning_run_commit.sql b/db/routines/R__037_fn_planning_run_commit.sql index ddaa1e7..c4ea29f 100644 --- a/db/routines/R__037_fn_planning_run_commit.sql +++ b/db/routines/R__037_fn_planning_run_commit.sql @@ -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; diff --git a/db/routines/R__039_fn_planning_site_context.sql b/db/routines/R__039_fn_planning_site_context.sql index 5d0c0a5..2f8334e 100644 --- a/db/routines/R__039_fn_planning_site_context.sql +++ b/db/routines/R__039_fn_planning_site_context.sql @@ -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 diff --git a/db/routines/R__063_fn_load_planning_slots_full.sql b/db/routines/R__063_fn_load_planning_slots_full.sql index 3f9dd45..aee9aa4 100644 --- a/db/routines/R__063_fn_load_planning_slots_full.sql +++ b/db/routines/R__063_fn_load_planning_slots_full.sql @@ -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.'; diff --git a/db/routines/R__087_fn_planning_run_debug.sql b/db/routines/R__087_fn_planning_run_debug.sql new file mode 100644 index 0000000..a56100c --- /dev/null +++ b/db/routines/R__087_fn_planning_run_debug.sql @@ -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.'; diff --git a/docs/03-data-model.md b/docs/03-data-model.md index d7ae516..1cabb4f 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -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 ); ``` diff --git a/docs/04-modules/control.md b/docs/04-modules/control.md index 12fe0f3..1d99ea4 100644 --- a/docs/04-modules/control.md +++ b/docs/04-modules/control.md @@ -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` | diff --git a/docs/04-modules/planning.md b/docs/04-modules/planning.md index 65c62af..8863018 100644 --- a/docs/04-modules/planning.md +++ b/docs/04-modules/planning.md @@ -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();`** (`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). diff --git a/docs/04-modules/provozni-rezimy-checklist.md b/docs/04-modules/provozni-rezimy-checklist.md index 30d8135..e8263d9 100644 --- a/docs/04-modules/provozni-rezimy-checklist.md +++ b/docs/04-modules/provozni-rezimy-checklist.md @@ -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` - diff --git a/docs/07-mcp-postgres-ems.md b/docs/07-mcp-postgres-ems.md index e57a217..586ce43 100644 --- a/docs/07-mcp-postgres-ems.md +++ b/docs/07-mcp-postgres-ems.md @@ -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. ---