planner v2 vc. porovnani
This commit is contained in:
@@ -46,3 +46,5 @@ TELEMETRY_POLL_INTERVAL_SEC=60
|
|||||||
PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ
|
PLANNING_HP_MAX_COST_CZK_KWH=3.0 # max Kč/kWh tepla pro spuštění TČ
|
||||||
PLANNING_CHEAP_PRICE_THRESHOLD=0.85
|
PLANNING_CHEAP_PRICE_THRESHOLD=0.85
|
||||||
PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15
|
PLANNING_EXPENSIVE_PRICE_THRESHOLD=1.15
|
||||||
|
PLANNING_ENGINE_VERSION=v1 # v1 = původní planner, v2 = nová policy větev
|
||||||
|
PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params
|
||||||
|
|||||||
@@ -45,6 +45,8 @@ class Settings(BaseSettings):
|
|||||||
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
|
planning_hp_max_cost_czk_kwh: float = Field(default=3.0)
|
||||||
planning_cheap_price_threshold: float = Field(default=0.85)
|
planning_cheap_price_threshold: float = Field(default=0.85)
|
||||||
planning_expensive_price_threshold: float = Field(default=1.15)
|
planning_expensive_price_threshold: float = Field(default=1.15)
|
||||||
|
planning_engine_version: str = Field(default="v1")
|
||||||
|
planning_engine_compare_enabled: bool = Field(default=False)
|
||||||
|
|
||||||
|
|
||||||
@lru_cache
|
@lru_cache
|
||||||
|
|||||||
@@ -41,12 +41,117 @@ class PlanningIntervalDto(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
class CurrentPlanResponseModel(BaseModel):
|
class PlanningBundleDto(BaseModel):
|
||||||
run: dict[str, Any]
|
run: dict[str, Any]
|
||||||
intervals: list[PlanningIntervalDto]
|
intervals: list[PlanningIntervalDto]
|
||||||
summary: dict[str, Any]
|
summary: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class CurrentPlanResponseModel(PlanningBundleDto):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class ComparisonSlotDiffDto(BaseModel):
|
||||||
|
interval_start: str
|
||||||
|
active: dict[str, Any]
|
||||||
|
comparison: dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
class PlanningCompareResponseModel(BaseModel):
|
||||||
|
active: PlanningBundleDto
|
||||||
|
comparison: PlanningBundleDto
|
||||||
|
diff: dict[str, Any]
|
||||||
|
slot_diffs: list[ComparisonSlotDiffDto]
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_from_payload(payload: dict[str, Any], *, run_key: str) -> PlanningBundleDto:
|
||||||
|
run_raw = payload.get(run_key) or {}
|
||||||
|
if not isinstance(run_raw, dict):
|
||||||
|
run_raw = {}
|
||||||
|
intervals_raw = payload.get("intervals") or []
|
||||||
|
if not isinstance(intervals_raw, list):
|
||||||
|
intervals_raw = []
|
||||||
|
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
|
||||||
|
summary = payload.get("summary") or {}
|
||||||
|
if not isinstance(summary, dict):
|
||||||
|
summary = {}
|
||||||
|
return PlanningBundleDto(run=run_raw, intervals=intervals, summary=summary)
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_from_current(payload: dict[str, Any]) -> PlanningBundleDto:
|
||||||
|
return _bundle_from_payload(payload, run_key="run")
|
||||||
|
|
||||||
|
|
||||||
|
def _bundle_from_debug(payload: dict[str, Any]) -> PlanningBundleDto:
|
||||||
|
return _bundle_from_payload(payload, run_key="planning_run")
|
||||||
|
|
||||||
|
|
||||||
|
def _extract_run_id(bundle: PlanningBundleDto) -> int | None:
|
||||||
|
raw = bundle.run.get("id")
|
||||||
|
try:
|
||||||
|
return int(raw)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_plan_diff(
|
||||||
|
active: PlanningBundleDto,
|
||||||
|
comparison: PlanningBundleDto,
|
||||||
|
) -> tuple[dict[str, Any], list[ComparisonSlotDiffDto]]:
|
||||||
|
active_by_ts = {i.interval_start: i for i in active.intervals}
|
||||||
|
compare_by_ts = {i.interval_start: i for i in comparison.intervals}
|
||||||
|
diffs: list[ComparisonSlotDiffDto] = []
|
||||||
|
interesting_keys = (
|
||||||
|
"battery_setpoint_w",
|
||||||
|
"battery_soc_target_pct",
|
||||||
|
"grid_setpoint_w",
|
||||||
|
"export_limit_w",
|
||||||
|
"export_mode",
|
||||||
|
"deye_physical_mode",
|
||||||
|
"deye_gen_cutoff_enabled",
|
||||||
|
"pv_a_curtailed_w",
|
||||||
|
"expected_cost_czk",
|
||||||
|
)
|
||||||
|
for ts, a in active_by_ts.items():
|
||||||
|
b = compare_by_ts.get(ts)
|
||||||
|
if b is None:
|
||||||
|
continue
|
||||||
|
active_payload = a.model_dump()
|
||||||
|
comparison_payload = b.model_dump()
|
||||||
|
if any(active_payload.get(k) != comparison_payload.get(k) for k in interesting_keys):
|
||||||
|
diffs.append(
|
||||||
|
ComparisonSlotDiffDto(
|
||||||
|
interval_start=ts,
|
||||||
|
active={k: active_payload.get(k) for k in interesting_keys},
|
||||||
|
comparison={k: comparison_payload.get(k) for k in interesting_keys},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _summary_num(bundle: PlanningBundleDto, key: str) -> float:
|
||||||
|
raw = bundle.summary.get(key)
|
||||||
|
try:
|
||||||
|
return float(raw) if raw is not None else 0.0
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 0.0
|
||||||
|
|
||||||
|
active_cost = _summary_num(active, "total_expected_cost_czk")
|
||||||
|
compare_cost = _summary_num(comparison, "total_expected_cost_czk")
|
||||||
|
diff = {
|
||||||
|
"active_total_expected_cost_czk": active_cost,
|
||||||
|
"comparison_total_expected_cost_czk": compare_cost,
|
||||||
|
"total_expected_cost_czk": round(active_cost - compare_cost, 4),
|
||||||
|
"absolute_total_expected_cost_czk": round(abs(active_cost - compare_cost), 4),
|
||||||
|
"active_charge_slots": int(_summary_num(active, "charge_slots")),
|
||||||
|
"comparison_charge_slots": int(_summary_num(comparison, "charge_slots")),
|
||||||
|
"active_discharge_slots": int(_summary_num(active, "discharge_slots")),
|
||||||
|
"comparison_discharge_slots": int(_summary_num(comparison, "discharge_slots")),
|
||||||
|
"active_export_slots": int(_summary_num(active, "export_slots")),
|
||||||
|
"comparison_export_slots": int(_summary_num(comparison, "export_slots")),
|
||||||
|
"changed_slots": len(diffs),
|
||||||
|
}
|
||||||
|
return diff, diffs
|
||||||
|
|
||||||
|
|
||||||
@router.get("/current", response_model=CurrentPlanResponseModel)
|
@router.get("/current", response_model=CurrentPlanResponseModel)
|
||||||
async def get_current_plan(
|
async def get_current_plan(
|
||||||
site_id: int,
|
site_id: int,
|
||||||
@@ -69,14 +174,85 @@ async def get_current_plan(
|
|||||||
if bundle.get("error") == "no_active_plan":
|
if bundle.get("error") == "no_active_plan":
|
||||||
raise HTTPException(status_code=404, detail="No active plan")
|
raise HTTPException(status_code=404, detail="No active plan")
|
||||||
|
|
||||||
intervals_raw = bundle.get("intervals") or []
|
plan = _bundle_from_current(bundle)
|
||||||
if not isinstance(intervals_raw, list):
|
|
||||||
intervals_raw = []
|
|
||||||
intervals = [PlanningIntervalDto.model_validate(d) for d in intervals_raw if isinstance(d, dict)]
|
|
||||||
return CurrentPlanResponseModel(
|
return CurrentPlanResponseModel(
|
||||||
run=bundle.get("run") or {},
|
run=plan.run,
|
||||||
intervals=intervals,
|
intervals=plan.intervals,
|
||||||
summary=bundle.get("summary") or {},
|
summary=plan.summary,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/compare", response_model=PlanningCompareResponseModel)
|
||||||
|
async def get_plan_compare(
|
||||||
|
site_id: int,
|
||||||
|
pool: Annotated[asyncpg.Pool, Depends(get_pg_pool)],
|
||||||
|
) -> PlanningCompareResponseModel:
|
||||||
|
async with pool.acquire() as conn:
|
||||||
|
site_ok = await conn.fetchval(
|
||||||
|
"SELECT EXISTS(SELECT 1 FROM ems.site WHERE id = $1)", site_id
|
||||||
|
)
|
||||||
|
if not site_ok:
|
||||||
|
raise HTTPException(status_code=404, detail="Site not found")
|
||||||
|
|
||||||
|
active_raw = await fetch_json(
|
||||||
|
conn,
|
||||||
|
"select ems.fn_plan_current_bundle($1::int)",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if not isinstance(active_raw, dict):
|
||||||
|
active_raw = json.loads(active_raw)
|
||||||
|
if active_raw.get("error") == "no_active_plan":
|
||||||
|
raise HTTPException(status_code=404, detail="No active plan")
|
||||||
|
|
||||||
|
active = _bundle_from_current(active_raw)
|
||||||
|
active_run_id = _extract_run_id(active)
|
||||||
|
if active_run_id is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No active plan")
|
||||||
|
|
||||||
|
compare_run_id = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
select pr.id
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.site_id = $1::int
|
||||||
|
and pr.status = 'comparison'
|
||||||
|
and (pr.solver_params->>'comparison_of_run_id')::int = $2::int
|
||||||
|
order by pr.created_at desc
|
||||||
|
limit 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
active_run_id,
|
||||||
|
)
|
||||||
|
if compare_run_id is None:
|
||||||
|
compare_run_id = await conn.fetchval(
|
||||||
|
"""
|
||||||
|
select pr.id
|
||||||
|
from ems.planning_run pr
|
||||||
|
where pr.site_id = $1::int
|
||||||
|
and pr.status = 'comparison'
|
||||||
|
order by pr.created_at desc
|
||||||
|
limit 1
|
||||||
|
""",
|
||||||
|
site_id,
|
||||||
|
)
|
||||||
|
if compare_run_id is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No comparison plan")
|
||||||
|
compare_raw = await fetch_json(
|
||||||
|
conn,
|
||||||
|
"select ems.fn_planning_run_debug($1::int)",
|
||||||
|
int(compare_run_id),
|
||||||
|
)
|
||||||
|
if not isinstance(compare_raw, dict):
|
||||||
|
compare_raw = json.loads(compare_raw)
|
||||||
|
if compare_raw is None:
|
||||||
|
raise HTTPException(status_code=404, detail="No comparison plan")
|
||||||
|
|
||||||
|
comparison = _bundle_from_debug(compare_raw)
|
||||||
|
diff, slot_diffs = _build_plan_diff(active, comparison)
|
||||||
|
return PlanningCompareResponseModel(
|
||||||
|
active=active,
|
||||||
|
comparison=comparison,
|
||||||
|
diff=diff,
|
||||||
|
slot_diffs=slot_diffs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from zoneinfo import ZoneInfo
|
|||||||
|
|
||||||
import pulp
|
import pulp
|
||||||
|
|
||||||
|
from app.config import get_settings
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@@ -64,6 +66,140 @@ def _timestamptz_from_db(val: object) -> Optional[datetime]:
|
|||||||
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
return datetime.fromisoformat(str(val).replace("Z", "+00:00"))
|
||||||
|
|
||||||
|
|
||||||
|
def _planner_engine_version(explicit: str | None = None) -> str:
|
||||||
|
if explicit is not None and str(explicit).strip():
|
||||||
|
return str(explicit).strip().lower()
|
||||||
|
return str(get_settings().planning_engine_version or "v1").strip().lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _planner_compare_enabled() -> bool:
|
||||||
|
return bool(get_settings().planning_engine_compare_enabled)
|
||||||
|
|
||||||
|
|
||||||
|
def _planner_peer_version(version: str) -> str:
|
||||||
|
v = str(version).strip().lower()
|
||||||
|
if v == "v1":
|
||||||
|
return "v2"
|
||||||
|
if v == "v2":
|
||||||
|
return "v1"
|
||||||
|
return "v1"
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_result_summary(results: list["DispatchResult"], duration_ms: int, version: str) -> dict[str, Any]:
|
||||||
|
charge_slots = [r.interval_start.isoformat() for r in results if r.battery_setpoint_w > 500]
|
||||||
|
discharge_slots = [r.interval_start.isoformat() for r in results if r.battery_setpoint_w < -500]
|
||||||
|
export_slots = [r.interval_start.isoformat() for r in results if r.grid_setpoint_w < 0]
|
||||||
|
return {
|
||||||
|
"planner_version": version,
|
||||||
|
"solver_duration_ms": int(duration_ms),
|
||||||
|
"total_expected_cost_czk": round(sum(float(r.expected_cost_czk) for r in results), 4),
|
||||||
|
"charge_slots": len(charge_slots),
|
||||||
|
"discharge_slots": len(discharge_slots),
|
||||||
|
"export_slots": len(export_slots),
|
||||||
|
"first_charge_slot": charge_slots[0] if charge_slots else None,
|
||||||
|
"first_discharge_slot": discharge_slots[0] if discharge_slots else None,
|
||||||
|
"first_export_slot": export_slots[0] if export_slots else None,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _dispatch_result_comparison(
|
||||||
|
active_results: list["DispatchResult"],
|
||||||
|
active_ms: int,
|
||||||
|
active_version: str,
|
||||||
|
peer_results: list["DispatchResult"],
|
||||||
|
peer_ms: int,
|
||||||
|
peer_version: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
active_summary = _dispatch_result_summary(active_results, active_ms, active_version)
|
||||||
|
peer_summary = _dispatch_result_summary(peer_results, peer_ms, peer_version)
|
||||||
|
slot_rows: list[dict[str, Any]] = []
|
||||||
|
for a, b in zip(active_results, peer_results):
|
||||||
|
row = {
|
||||||
|
"interval_start": a.interval_start.isoformat(),
|
||||||
|
"active": {
|
||||||
|
"battery_setpoint_w": a.battery_setpoint_w,
|
||||||
|
"grid_setpoint_w": a.grid_setpoint_w,
|
||||||
|
"export_mode": a.export_mode,
|
||||||
|
"deye_physical_mode": a.deye_physical_mode,
|
||||||
|
"deye_gen_cutoff_enabled": a.deye_gen_cutoff_enabled,
|
||||||
|
"pv_a_curtailed_w": a.pv_a_curtailed_w,
|
||||||
|
"battery_soc_target": a.battery_soc_target,
|
||||||
|
"expected_cost_czk": a.expected_cost_czk,
|
||||||
|
},
|
||||||
|
"peer": {
|
||||||
|
"battery_setpoint_w": b.battery_setpoint_w,
|
||||||
|
"grid_setpoint_w": b.grid_setpoint_w,
|
||||||
|
"export_mode": b.export_mode,
|
||||||
|
"deye_physical_mode": b.deye_physical_mode,
|
||||||
|
"deye_gen_cutoff_enabled": b.deye_gen_cutoff_enabled,
|
||||||
|
"pv_a_curtailed_w": b.pv_a_curtailed_w,
|
||||||
|
"battery_soc_target": b.battery_soc_target,
|
||||||
|
"expected_cost_czk": b.expected_cost_czk,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if row["active"] != row["peer"]:
|
||||||
|
slot_rows.append(row)
|
||||||
|
|
||||||
|
total_cost_diff = round(
|
||||||
|
float(active_summary["total_expected_cost_czk"]) - float(peer_summary["total_expected_cost_czk"]),
|
||||||
|
4,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"compare_enabled": True,
|
||||||
|
"active": active_summary,
|
||||||
|
"peer": peer_summary,
|
||||||
|
"diff": {
|
||||||
|
"total_expected_cost_czk": total_cost_diff,
|
||||||
|
"absolute_total_expected_cost_czk": round(abs(total_cost_diff), 4),
|
||||||
|
"changed_slots": len(slot_rows),
|
||||||
|
},
|
||||||
|
"slot_diffs": slot_rows,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _maybe_add_planner_comparison(
|
||||||
|
*,
|
||||||
|
slots: list["PlanningSlot"],
|
||||||
|
battery,
|
||||||
|
heat_pump,
|
||||||
|
grid,
|
||||||
|
ev_sessions: list,
|
||||||
|
vehicles: list,
|
||||||
|
current_soc_wh: float,
|
||||||
|
current_tuv_temp_c: float,
|
||||||
|
operating_mode: str,
|
||||||
|
tuv_delta_stats: Optional[dict[tuple[int, int], float]],
|
||||||
|
active_version: str,
|
||||||
|
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
|
||||||
|
) -> dict[str, Any] | None:
|
||||||
|
if not _planner_compare_enabled():
|
||||||
|
return None
|
||||||
|
peer_version = _planner_peer_version(active_version)
|
||||||
|
if peer_version == active_version:
|
||||||
|
return None
|
||||||
|
peer_results, peer_ms, peer_snapshot = solve_dispatch(
|
||||||
|
slots,
|
||||||
|
battery,
|
||||||
|
heat_pump,
|
||||||
|
grid,
|
||||||
|
ev_sessions,
|
||||||
|
vehicles,
|
||||||
|
current_soc_wh,
|
||||||
|
current_tuv_temp_c,
|
||||||
|
tuv_delta_stats=tuv_delta_stats,
|
||||||
|
operating_mode=operating_mode,
|
||||||
|
charge_commitment_prev_w=charge_commitment_prev_w,
|
||||||
|
planner_version=peer_version,
|
||||||
|
)
|
||||||
|
# active_results / active_ms jsou doplněny později v calleru
|
||||||
|
return {
|
||||||
|
"peer_version": peer_version,
|
||||||
|
"peer_results": peer_results,
|
||||||
|
"peer_ms": peer_ms,
|
||||||
|
"peer_snapshot": peer_snapshot,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def _planning_horizon_end(site_id: int, horizon_from: datetime, db) -> Optional[datetime]:
|
async def _planning_horizon_end(site_id: int, horizon_from: datetime, db) -> Optional[datetime]:
|
||||||
"""Konec horizontu z DB (`fn_planning_horizon_end`); NULL = rolling skip / daily fallback."""
|
"""Konec horizontu z DB (`fn_planning_horizon_end`); NULL = rolling skip / daily fallback."""
|
||||||
raw = await db.fetchval(
|
raw = await db.fetchval(
|
||||||
@@ -453,6 +589,7 @@ def solve_dispatch(
|
|||||||
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
tuv_delta_stats: Optional[dict[tuple[int, int], float]] = None,
|
||||||
operating_mode: str = "AUTO",
|
operating_mode: str = "AUTO",
|
||||||
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
|
charge_commitment_prev_w: Optional[list[Optional[float]]] = None,
|
||||||
|
planner_version: str | None = None,
|
||||||
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
|
) -> tuple[list[DispatchResult], int, dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
LP solver pro dispatch optimalizaci.
|
LP solver pro dispatch optimalizaci.
|
||||||
@@ -462,6 +599,8 @@ def solve_dispatch(
|
|||||||
if T < 1:
|
if T < 1:
|
||||||
raise RuntimeError("solve_dispatch requires at least one slot")
|
raise RuntimeError("solve_dispatch requires at least one slot")
|
||||||
EV = len(vehicles) # počet EV (typicky 2)
|
EV = len(vehicles) # počet EV (typicky 2)
|
||||||
|
planner_version_resolved = _planner_engine_version(planner_version)
|
||||||
|
planner_v2 = planner_version_resolved == "v2"
|
||||||
|
|
||||||
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
|
EV_ROUNDTRIP_FACTOR = 1.0 / (battery.charge_efficiency * battery.discharge_efficiency)
|
||||||
cycle_penalty_mult = _pv_scarcity_penalty_multiplier(slots, battery)
|
cycle_penalty_mult = _pv_scarcity_penalty_multiplier(slots, battery)
|
||||||
@@ -577,7 +716,7 @@ def solve_dispatch(
|
|||||||
SELF_SUSTAIN_EXPORT_PENALTY_CZK_KWH = 100.0
|
SELF_SUSTAIN_EXPORT_PENALTY_CZK_KWH = 100.0
|
||||||
# Penalizace vypnutí GEN portu (mikroinvertory): preferujeme nechat zapnuto a vypnout jen když
|
# Penalizace vypnutí GEN portu (mikroinvertory): preferujeme nechat zapnuto a vypnout jen když
|
||||||
# by to jinak vedlo k nežádoucímu exportu / infeasible řešení.
|
# by to jinak vedlo k nežádoucímu exportu / infeasible řešení.
|
||||||
GEN_CUTOFF_PENALTY_CZK_KWH = 5.0
|
GEN_CUTOFF_PENALTY_CZK_KWH = 2.0 if planner_v2 else 5.0
|
||||||
|
|
||||||
# Heuristika: pokud existuje necurtailable PV B a v budoucnu v horizontu nastane buy < 0,
|
# Heuristika: pokud existuje necurtailable PV B a v budoucnu v horizontu nastane buy < 0,
|
||||||
# chceme mít motivaci držet baterii „prázdnější“ pro pozdější výhodný import / bonusové PV B okno.
|
# chceme mít motivaci držet baterii „prázdnější“ pro pozdější výhodný import / bonusové PV B okno.
|
||||||
@@ -639,7 +778,10 @@ def solve_dispatch(
|
|||||||
# Záměr: safety není obecná „nabij co nejdřív“ motivace; je to preference využít přebytek PV.
|
# Záměr: safety není obecná „nabij co nejdřív“ motivace; je to preference využít přebytek PV.
|
||||||
active = bool(
|
active = bool(
|
||||||
sft is not None
|
sft is not None
|
||||||
and bool(slots[t].is_daytime_pv_surplus_slot)
|
and (
|
||||||
|
bool(slots[t].is_daytime_pv_surplus_slot)
|
||||||
|
or (planner_v2 and float(slots[t].buy_price) < 0.0)
|
||||||
|
)
|
||||||
and not hs
|
and not hs
|
||||||
)
|
)
|
||||||
safety_active.append(active)
|
safety_active.append(active)
|
||||||
@@ -1063,6 +1205,7 @@ def solve_dispatch(
|
|||||||
"inputs": {
|
"inputs": {
|
||||||
"current_soc_wh": float(current_soc_wh),
|
"current_soc_wh": float(current_soc_wh),
|
||||||
"operating_mode": operating_mode,
|
"operating_mode": operating_mode,
|
||||||
|
"planner_version": planner_version_resolved,
|
||||||
"battery": {
|
"battery": {
|
||||||
"usable_capacity_wh": float(battery.usable_capacity_wh),
|
"usable_capacity_wh": float(battery.usable_capacity_wh),
|
||||||
"min_soc_wh": float(battery.min_soc_wh),
|
"min_soc_wh": float(battery.min_soc_wh),
|
||||||
@@ -1093,7 +1236,13 @@ def solve_dispatch(
|
|||||||
# Denní plán (15:00)
|
# Denní plán (15:00)
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily") -> tuple[int, int]:
|
async def run_daily_plan(
|
||||||
|
site_id: int,
|
||||||
|
db,
|
||||||
|
triggered_by: str = "scheduler:daily",
|
||||||
|
*,
|
||||||
|
planner_version: str | None = None,
|
||||||
|
) -> tuple[int, int]:
|
||||||
"""
|
"""
|
||||||
Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00)
|
Hlavní denní plánování. Spouštět v 15:00 po importu cen (14:00)
|
||||||
a aktualizaci forecastu (14:30).
|
a aktualizaci forecastu (14:30).
|
||||||
@@ -1115,13 +1264,40 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
|||||||
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
|
battery, hp, grid, vehicles, ev_sessions, soc_wh, tuv_temp, operating_mode, tuv_stats = (
|
||||||
await _load_site_context(site_id, db)
|
await _load_site_context(site_id, db)
|
||||||
)
|
)
|
||||||
|
planner_version_resolved = _planner_engine_version(planner_version)
|
||||||
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
|
slots = await _load_slots(site_id, horizon_from, horizon_to, db, soc_wh=soc_wh)
|
||||||
|
|
||||||
results, duration_ms, solver_snapshot = solve_dispatch(
|
results, duration_ms, solver_snapshot = solve_dispatch(
|
||||||
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
slots, battery, hp, grid, ev_sessions, vehicles, soc_wh, tuv_temp,
|
||||||
tuv_delta_stats=tuv_stats,
|
tuv_delta_stats=tuv_stats,
|
||||||
operating_mode=operating_mode or "AUTO",
|
operating_mode=operating_mode or "AUTO",
|
||||||
|
planner_version=planner_version_resolved,
|
||||||
)
|
)
|
||||||
|
comparison_ctx = _maybe_add_planner_comparison(
|
||||||
|
slots=slots,
|
||||||
|
battery=battery,
|
||||||
|
heat_pump=hp,
|
||||||
|
grid=grid,
|
||||||
|
ev_sessions=ev_sessions,
|
||||||
|
vehicles=vehicles,
|
||||||
|
current_soc_wh=soc_wh,
|
||||||
|
current_tuv_temp_c=tuv_temp,
|
||||||
|
operating_mode=operating_mode or "AUTO",
|
||||||
|
tuv_delta_stats=tuv_stats,
|
||||||
|
active_version=planner_version_resolved,
|
||||||
|
)
|
||||||
|
if comparison_ctx is not None:
|
||||||
|
peer_results = comparison_ctx["peer_results"]
|
||||||
|
peer_ms = comparison_ctx["peer_ms"]
|
||||||
|
peer_snapshot = comparison_ctx["peer_snapshot"]
|
||||||
|
solver_snapshot["comparison"] = _dispatch_result_comparison(
|
||||||
|
results,
|
||||||
|
duration_ms,
|
||||||
|
planner_version_resolved,
|
||||||
|
peer_results,
|
||||||
|
peer_ms,
|
||||||
|
comparison_ctx["peer_version"],
|
||||||
|
)
|
||||||
|
|
||||||
slot_inputs = _build_slot_inputs(slots, slots)
|
slot_inputs = _build_slot_inputs(slots, slots)
|
||||||
run_id = await _save_planning_run(
|
run_id = await _save_planning_run(
|
||||||
@@ -1139,6 +1315,26 @@ async def run_daily_plan(site_id: int, db, triggered_by: str = "scheduler:daily"
|
|||||||
slot_inputs=slot_inputs,
|
slot_inputs=slot_inputs,
|
||||||
solver_snapshot=solver_snapshot,
|
solver_snapshot=solver_snapshot,
|
||||||
)
|
)
|
||||||
|
if comparison_ctx is not None:
|
||||||
|
compare_snapshot = dict(peer_snapshot)
|
||||||
|
compare_snapshot["comparison_of_run_id"] = run_id
|
||||||
|
compare_snapshot["compare_peer_version"] = comparison_ctx["peer_version"]
|
||||||
|
await _save_planning_run(
|
||||||
|
site_id,
|
||||||
|
comparison_ctx["peer_results"],
|
||||||
|
horizon_from,
|
||||||
|
horizon_to,
|
||||||
|
run_type="daily",
|
||||||
|
triggered_by=f"{triggered_by}:compare",
|
||||||
|
replan_from=None,
|
||||||
|
soc_wh=soc_wh,
|
||||||
|
duration_ms=comparison_ctx["peer_ms"],
|
||||||
|
correction=1.0,
|
||||||
|
db=db,
|
||||||
|
slot_inputs=slot_inputs,
|
||||||
|
activate_run=False,
|
||||||
|
solver_snapshot=compare_snapshot,
|
||||||
|
)
|
||||||
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
|
logger.info(f"[site={site_id}] Daily plan done in {duration_ms} ms")
|
||||||
return run_id, duration_ms
|
return run_id, duration_ms
|
||||||
|
|
||||||
@@ -1153,6 +1349,7 @@ async def run_rolling_replan(
|
|||||||
*,
|
*,
|
||||||
triggered_by: str = "scheduler:rolling",
|
triggered_by: str = "scheduler:rolling",
|
||||||
allow_skip: bool = True,
|
allow_skip: bool = True,
|
||||||
|
planner_version: str | None = None,
|
||||||
) -> tuple[Optional[int], Optional[int]]:
|
) -> tuple[Optional[int], Optional[int]]:
|
||||||
"""
|
"""
|
||||||
Rolling replan každých 15 minut.
|
Rolling replan každých 15 minut.
|
||||||
@@ -1167,6 +1364,7 @@ async def run_rolling_replan(
|
|||||||
"""
|
"""
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
replan_from = _current_slot_start(now)
|
replan_from = _current_slot_start(now)
|
||||||
|
planner_version_resolved = _planner_engine_version(planner_version)
|
||||||
|
|
||||||
ar_raw = await db.fetchval(
|
ar_raw = await db.fetchval(
|
||||||
"select ems.fn_planning_active_run($1::int)",
|
"select ems.fn_planning_active_run($1::int)",
|
||||||
@@ -1175,7 +1373,12 @@ async def run_rolling_replan(
|
|||||||
ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw)
|
ar = ar_raw if isinstance(ar_raw, dict) else json.loads(ar_raw)
|
||||||
if ar.get("error") == "no_active_plan":
|
if ar.get("error") == "no_active_plan":
|
||||||
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
|
logger.warning(f"[site={site_id}] Rolling replan: no active plan, triggering daily plan")
|
||||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
return await run_daily_plan(
|
||||||
|
site_id,
|
||||||
|
db,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
planner_version=planner_version_resolved,
|
||||||
|
)
|
||||||
|
|
||||||
horizon_to = await _planning_horizon_end(site_id, replan_from, db)
|
horizon_to = await _planning_horizon_end(site_id, replan_from, db)
|
||||||
if horizon_to is None:
|
if horizon_to is None:
|
||||||
@@ -1189,14 +1392,24 @@ async def run_rolling_replan(
|
|||||||
"[site=%s] Rolling replan: fn_planning_horizon_end NULL, running daily plan",
|
"[site=%s] Rolling replan: fn_planning_horizon_end NULL, running daily plan",
|
||||||
site_id,
|
site_id,
|
||||||
)
|
)
|
||||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
return await run_daily_plan(
|
||||||
|
site_id,
|
||||||
|
db,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
planner_version=planner_version_resolved,
|
||||||
|
)
|
||||||
|
|
||||||
if (horizon_to - replan_from).total_seconds() < 1800:
|
if (horizon_to - replan_from).total_seconds() < 1800:
|
||||||
if allow_skip:
|
if allow_skip:
|
||||||
logger.info(f"[site={site_id}] Rolling replan: horizon almost exhausted, skipping")
|
logger.info(f"[site={site_id}] Rolling replan: horizon almost exhausted, skipping")
|
||||||
return None, None
|
return None, None
|
||||||
logger.info(f"[site={site_id}] Rolling replan: horizon exhausted, running daily plan")
|
logger.info(f"[site={site_id}] Rolling replan: horizon exhausted, running daily plan")
|
||||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
return await run_daily_plan(
|
||||||
|
site_id,
|
||||||
|
db,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
planner_version=planner_version_resolved,
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}")
|
logger.info(f"[site={site_id}] Rolling replan from {replan_from} → {horizon_to}")
|
||||||
|
|
||||||
@@ -1248,7 +1461,33 @@ async def run_rolling_replan(
|
|||||||
tuv_delta_stats=tuv_stats,
|
tuv_delta_stats=tuv_stats,
|
||||||
operating_mode=operating_mode or "AUTO",
|
operating_mode=operating_mode or "AUTO",
|
||||||
charge_commitment_prev_w=commitment_prev,
|
charge_commitment_prev_w=commitment_prev,
|
||||||
|
planner_version=planner_version_resolved,
|
||||||
)
|
)
|
||||||
|
comparison_ctx = _maybe_add_planner_comparison(
|
||||||
|
slots=slots,
|
||||||
|
battery=battery,
|
||||||
|
heat_pump=hp,
|
||||||
|
grid=grid,
|
||||||
|
ev_sessions=ev_sessions,
|
||||||
|
vehicles=vehicles,
|
||||||
|
current_soc_wh=soc_wh,
|
||||||
|
current_tuv_temp_c=tuv_temp,
|
||||||
|
operating_mode=operating_mode or "AUTO",
|
||||||
|
tuv_delta_stats=tuv_stats,
|
||||||
|
active_version=planner_version_resolved,
|
||||||
|
charge_commitment_prev_w=commitment_prev,
|
||||||
|
)
|
||||||
|
if comparison_ctx is not None:
|
||||||
|
peer_results = comparison_ctx["peer_results"]
|
||||||
|
peer_ms = comparison_ctx["peer_ms"]
|
||||||
|
solver_snapshot["comparison"] = _dispatch_result_comparison(
|
||||||
|
results,
|
||||||
|
duration_ms,
|
||||||
|
planner_version_resolved,
|
||||||
|
peer_results,
|
||||||
|
peer_ms,
|
||||||
|
comparison_ctx["peer_version"],
|
||||||
|
)
|
||||||
|
|
||||||
slot_inputs = _build_slot_inputs(slots_raw_pv, slots)
|
slot_inputs = _build_slot_inputs(slots_raw_pv, slots)
|
||||||
run_id = await _save_planning_run(
|
run_id = await _save_planning_run(
|
||||||
@@ -1266,6 +1505,26 @@ async def run_rolling_replan(
|
|||||||
slot_inputs=slot_inputs,
|
slot_inputs=slot_inputs,
|
||||||
solver_snapshot=solver_snapshot,
|
solver_snapshot=solver_snapshot,
|
||||||
)
|
)
|
||||||
|
if comparison_ctx is not None:
|
||||||
|
compare_snapshot = dict(comparison_ctx["peer_snapshot"])
|
||||||
|
compare_snapshot["comparison_of_run_id"] = run_id
|
||||||
|
compare_snapshot["compare_peer_version"] = comparison_ctx["peer_version"]
|
||||||
|
await _save_planning_run(
|
||||||
|
site_id,
|
||||||
|
comparison_ctx["peer_results"],
|
||||||
|
replan_from,
|
||||||
|
horizon_to,
|
||||||
|
run_type="rolling",
|
||||||
|
triggered_by=f"{triggered_by}:compare",
|
||||||
|
replan_from=replan_from,
|
||||||
|
soc_wh=soc_wh,
|
||||||
|
duration_ms=comparison_ctx["peer_ms"],
|
||||||
|
correction=correction_factor,
|
||||||
|
db=db,
|
||||||
|
slot_inputs=slot_inputs,
|
||||||
|
activate_run=False,
|
||||||
|
solver_snapshot=compare_snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
# Historický log rolling korekce: dřív se psal z Pythonu. Nově se rolling faktor počítá v DB
|
# Historický log rolling korekce: dřív se psal z Pythonu. Nově se rolling faktor počítá v DB
|
||||||
# v kanonické PV řadě; log se případně přesune do DB (todo).
|
# v kanonické PV řadě; log se případně přesune do DB (todo).
|
||||||
@@ -1280,14 +1539,25 @@ async def run_plan_api(
|
|||||||
db,
|
db,
|
||||||
*,
|
*,
|
||||||
triggered_by: str = "api",
|
triggered_by: str = "api",
|
||||||
|
planner_version: str | None = None,
|
||||||
) -> tuple[int, int]:
|
) -> tuple[int, int]:
|
||||||
"""Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms)."""
|
"""Ruční / UI spuštění plánu. Vždy vrátí (run_id, solver_duration_ms)."""
|
||||||
pt = plan_type.lower().strip()
|
pt = plan_type.lower().strip()
|
||||||
|
planner_version_resolved = _planner_engine_version(planner_version)
|
||||||
if pt == "daily":
|
if pt == "daily":
|
||||||
return await run_daily_plan(site_id, db, triggered_by=triggered_by)
|
return await run_daily_plan(
|
||||||
|
site_id,
|
||||||
|
db,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
planner_version=planner_version_resolved,
|
||||||
|
)
|
||||||
if pt == "rolling":
|
if pt == "rolling":
|
||||||
rid, ms = await run_rolling_replan(
|
rid, ms = await run_rolling_replan(
|
||||||
site_id, db, triggered_by=triggered_by, allow_skip=False
|
site_id,
|
||||||
|
db,
|
||||||
|
triggered_by=triggered_by,
|
||||||
|
allow_skip=False,
|
||||||
|
planner_version=planner_version_resolved,
|
||||||
)
|
)
|
||||||
if rid is None or ms is None:
|
if rid is None or ms is None:
|
||||||
raise RuntimeError("Rolling replan did not return a run")
|
raise RuntimeError("Rolling replan did not return a run")
|
||||||
@@ -1583,6 +1853,7 @@ async def _save_planning_run(
|
|||||||
soc_wh, duration_ms, correction, db,
|
soc_wh, duration_ms, correction, db,
|
||||||
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
slot_inputs: Optional[list[tuple[int, int, int, int, int]]] = None,
|
||||||
*,
|
*,
|
||||||
|
activate_run: bool = True,
|
||||||
solver_snapshot: Optional[dict[str, Any]] = None,
|
solver_snapshot: Optional[dict[str, Any]] = None,
|
||||||
) -> int:
|
) -> int:
|
||||||
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
|
"""Uloží výsledky solveru přes ems.fn_planning_run_commit."""
|
||||||
@@ -1637,7 +1908,7 @@ async def _save_planning_run(
|
|||||||
"""
|
"""
|
||||||
select ems.fn_planning_run_commit(
|
select ems.fn_planning_run_commit(
|
||||||
$1::int, $2::timestamptz, $3::timestamptz,
|
$1::int, $2::timestamptz, $3::timestamptz,
|
||||||
$4::jsonb, $5::jsonb
|
$4::jsonb, $5::jsonb, $6::boolean
|
||||||
)
|
)
|
||||||
""",
|
""",
|
||||||
site_id,
|
site_id,
|
||||||
@@ -1645,5 +1916,6 @@ async def _save_planning_run(
|
|||||||
horizon_to,
|
horizon_to,
|
||||||
json.dumps(run_meta, default=str),
|
json.dumps(run_meta, default=str),
|
||||||
json.dumps(intervals, default=str),
|
json.dumps(intervals, default=str),
|
||||||
|
activate_run,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -7,8 +7,10 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from types import SimpleNamespace
|
from types import SimpleNamespace
|
||||||
|
|
||||||
from services.planning_engine import (
|
from services.planning_engine import (
|
||||||
|
DispatchResult,
|
||||||
PlanningSlot,
|
PlanningSlot,
|
||||||
_dynamic_arb_floor_wh_series,
|
_dynamic_arb_floor_wh_series,
|
||||||
|
_dispatch_result_comparison,
|
||||||
_prewindow_deferral_slots,
|
_prewindow_deferral_slots,
|
||||||
_slots_until_buy_le_threshold,
|
_slots_until_buy_le_threshold,
|
||||||
_slots_until_sell_lt,
|
_slots_until_sell_lt,
|
||||||
@@ -205,6 +207,83 @@ def replace_slot(
|
|||||||
|
|
||||||
|
|
||||||
class PlanningDispatchMilpTests(unittest.TestCase):
|
class PlanningDispatchMilpTests(unittest.TestCase):
|
||||||
|
def test_dispatch_result_comparison_marks_changed_slots(self) -> None:
|
||||||
|
dt = datetime(2026, 4, 3, 12, 0, tzinfo=timezone.utc)
|
||||||
|
active = [
|
||||||
|
DispatchResult(
|
||||||
|
interval_start=dt,
|
||||||
|
battery_setpoint_w=1000,
|
||||||
|
battery_soc_target=50.0,
|
||||||
|
grid_setpoint_w=0,
|
||||||
|
export_limit_w=0,
|
||||||
|
export_mode="NONE",
|
||||||
|
deye_physical_mode="PASSIVE",
|
||||||
|
deye_gen_cutoff_enabled=False,
|
||||||
|
ev1_setpoint_w=None,
|
||||||
|
ev2_setpoint_w=None,
|
||||||
|
ev1_via_bat_w=0,
|
||||||
|
ev2_via_bat_w=0,
|
||||||
|
heat_pump_enabled=False,
|
||||||
|
heat_pump_setpoint_w=0,
|
||||||
|
pv_a_curtailed_w=0,
|
||||||
|
expected_cost_czk=1.0,
|
||||||
|
effective_buy_price=1.0,
|
||||||
|
effective_sell_price=1.0,
|
||||||
|
is_predicted_price=False,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
peer = [
|
||||||
|
DispatchResult(
|
||||||
|
interval_start=dt,
|
||||||
|
battery_setpoint_w=2000,
|
||||||
|
battery_soc_target=55.0,
|
||||||
|
grid_setpoint_w=-1000,
|
||||||
|
export_limit_w=1000,
|
||||||
|
export_mode="PV_SURPLUS",
|
||||||
|
deye_physical_mode="SELL",
|
||||||
|
deye_gen_cutoff_enabled=True,
|
||||||
|
ev1_setpoint_w=None,
|
||||||
|
ev2_setpoint_w=None,
|
||||||
|
ev1_via_bat_w=0,
|
||||||
|
ev2_via_bat_w=0,
|
||||||
|
heat_pump_enabled=False,
|
||||||
|
heat_pump_setpoint_w=0,
|
||||||
|
pv_a_curtailed_w=200,
|
||||||
|
expected_cost_czk=2.0,
|
||||||
|
effective_buy_price=1.0,
|
||||||
|
effective_sell_price=1.0,
|
||||||
|
is_predicted_price=False,
|
||||||
|
)
|
||||||
|
]
|
||||||
|
cmp = _dispatch_result_comparison(active, 10, "v1", peer, 12, "v2")
|
||||||
|
self.assertEqual(cmp["active"]["planner_version"], "v1")
|
||||||
|
self.assertEqual(cmp["peer"]["planner_version"], "v2")
|
||||||
|
self.assertEqual(cmp["diff"]["changed_slots"], 1)
|
||||||
|
self.assertEqual(len(cmp["slot_diffs"]), 1)
|
||||||
|
|
||||||
|
def test_planner_version_is_recorded_in_snapshot(self) -> None:
|
||||||
|
slots = [_slot(load=500, buy=1.0, sell=1.0, pv_a=0, pv_b=0) for _ in range(2)]
|
||||||
|
battery = _battery()
|
||||||
|
hp = SimpleNamespace(rated_heating_power_w=0, tuv_min_temp_c=45.0, tuv_target_temp_c=55.0)
|
||||||
|
grid = SimpleNamespace(max_import_power_w=20_000, max_export_power_w=20_000)
|
||||||
|
vehicles = [
|
||||||
|
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||||
|
SimpleNamespace(max_charge_power_w=0, battery_capacity_kwh=1.0, default_target_soc_pct=80.0),
|
||||||
|
]
|
||||||
|
results, _ms, snap = solve_dispatch(
|
||||||
|
slots,
|
||||||
|
battery,
|
||||||
|
hp,
|
||||||
|
grid,
|
||||||
|
[],
|
||||||
|
vehicles,
|
||||||
|
current_soc_wh=0.5 * battery.usable_capacity_wh,
|
||||||
|
current_tuv_temp_c=50.0,
|
||||||
|
planner_version="v2",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(results), 2)
|
||||||
|
self.assertEqual(snap["inputs"]["planner_version"], "v2")
|
||||||
|
|
||||||
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
|
def test_neg_sell_with_future_neg_buy_prefers_curtail_pv_a_over_export(self) -> None:
|
||||||
"""
|
"""
|
||||||
Když:
|
Když:
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ create or replace function ems.fn_planning_run_commit(
|
|||||||
p_horizon_start timestamptz,
|
p_horizon_start timestamptz,
|
||||||
p_horizon_end timestamptz,
|
p_horizon_end timestamptz,
|
||||||
p_run_meta jsonb,
|
p_run_meta jsonb,
|
||||||
p_intervals jsonb
|
p_intervals jsonb,
|
||||||
|
p_activate_run boolean default true
|
||||||
)
|
)
|
||||||
returns int
|
returns int
|
||||||
language plpgsql
|
language plpgsql
|
||||||
@@ -29,7 +30,7 @@ begin
|
|||||||
p_site_id,
|
p_site_id,
|
||||||
p_horizon_start,
|
p_horizon_start,
|
||||||
p_horizon_end,
|
p_horizon_end,
|
||||||
'draft',
|
case when p_activate_run then 'draft' else 'comparison' end,
|
||||||
nullif(trim(p_run_meta->>'run_type'), ''),
|
nullif(trim(p_run_meta->>'run_type'), ''),
|
||||||
nullif(trim(p_run_meta->>'triggered_by'), ''),
|
nullif(trim(p_run_meta->>'triggered_by'), ''),
|
||||||
case
|
case
|
||||||
@@ -128,13 +129,16 @@ begin
|
|||||||
|
|
||||||
update ems.planning_run
|
update ems.planning_run
|
||||||
set status = 'superseded'
|
set status = 'superseded'
|
||||||
where site_id = p_site_id
|
where p_activate_run
|
||||||
|
and site_id = p_site_id
|
||||||
and status = 'active'
|
and status = 'active'
|
||||||
and id <> v_run_id;
|
and id <> v_run_id;
|
||||||
|
|
||||||
update ems.planning_run
|
if p_activate_run then
|
||||||
set status = 'active'
|
update ems.planning_run
|
||||||
where id = v_run_id;
|
set status = 'active'
|
||||||
|
where id = v_run_id;
|
||||||
|
end if;
|
||||||
|
|
||||||
return v_run_id;
|
return v_run_id;
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -541,6 +541,8 @@ a nechal si kapacitu na nabití v oknech záporných cen.
|
|||||||
PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout
|
PLANNING_SOLVER_TIME_LIMIT_SEC=10 # HiGHS timeout
|
||||||
PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE
|
PLANNING_CURTAILMENT_PENALTY=0.001 # Kč/Wh penalizace za omezení FVE
|
||||||
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
|
PLANNING_HP_RELAXATION_THRESHOLD=0.3 # pod 30% rated = OFF při post-processingu
|
||||||
|
PLANNING_ENGINE_VERSION=v1 # v1 = původní planner, v2 = nová policy větev
|
||||||
|
PLANNING_ENGINE_COMPARE_ENABLED=false # true = spočítat i druhou verzi a uložit comparison do solver_params
|
||||||
```
|
```
|
||||||
|
|
||||||
> **Zelený bonus:** Sazba a platnost jsou v `ems.asset_pv_array` (`green_bonus_*`). Bonus **není** v objective function LP solveru – jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v **`fn_fill_audit_interval`** přes `ems.fn_green_bonus_revenue()` a ukládá se do `audit_interval.green_bonus_czk`; v přehledech (např. `vw_audit_daily`) je samostatná položka příjmů vedle nákladů ze sítě. Viz `docs/04-modules/market-prices.md` → sekce Zelený bonus.
|
> **Zelený bonus:** Sazba a platnost jsou v `ems.asset_pv_array` (`green_bonus_*`). Bonus **není** v objective function LP solveru – jako aditivní konstanta k nákladům by optimalizaci stejně neměnil. Příjem z bonusu se počítá v **`fn_fill_audit_interval`** přes `ems.fn_green_bonus_revenue()` a ukládá se do `audit_interval.green_bonus_czk`; v přehledech (např. `vw_audit_daily`) je samostatná položka příjmů vedle nákladů ze sítě. Viz `docs/04-modules/market-prices.md` → sekce Zelený bonus.
|
||||||
@@ -565,3 +567,103 @@ highspy>=1.7.0 # HiGHS Python binding (rychlejší než HiGHS_CMD)
|
|||||||
- [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát
|
- [ ] EV rozdělení výkonu mezi 2 nabíječky – zatím řešeno jako agregát
|
||||||
- [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K
|
- [ ] Curtailment pole A – ověřit Modbus registr pro Output Power Limit na Deye SUN-20K
|
||||||
- [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů)
|
- [ ] Testovat solver na reálných datech – ověřit čas výpočtu pro 36h horizont (144 slotů)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Planner v2
|
||||||
|
|
||||||
|
Tahle sekce popisuje návrh druhé verze planneru. Cíl je mít samostatný solver, který bude vycházet ze stejného vstupu a bude zapisovat do stejného `planning_interval`, ale provozní pravidla budou čitelné a striktně dané zadáním.
|
||||||
|
|
||||||
|
### Význam hranic SoC
|
||||||
|
|
||||||
|
- `reserve_soc_percent` = ranní cílová hranice, na kterou se má baterie dobít, pokud to denní forecast a ceny umožňují
|
||||||
|
- `min_soc_percent` = fyzická / TOU podlaha, pod kterou baterie nesmí klesnout
|
||||||
|
- `reserve_soc_percent` je tedy provozní kotva pro den, zatímco `min_soc_percent` je tvrdé minimum
|
||||||
|
- `reserve_soc_percent` není predikce noční spotřeby; jen znamená „než začne export z FVE do sítě, drž baterii aspoň sem“
|
||||||
|
|
||||||
|
### Základní pravidla v2
|
||||||
|
|
||||||
|
#### Ráno
|
||||||
|
|
||||||
|
- pokud denní forecast dává dostatek výroby nebo levných hodin, planner dobije baterii minimálně na `reserve_soc_percent`
|
||||||
|
- tato rezerva slouží jako ochrana proti neplánované spotřebě během dne
|
||||||
|
- `min_soc_percent` se v ranní fázi nepoužívá jako cíl, ale jen jako spodní limit
|
||||||
|
|
||||||
|
#### Záporná nákupní cena
|
||||||
|
|
||||||
|
- při `buy_price < 0` má prioritu nabíjení ze sítě
|
||||||
|
- cílem je uložit levnou energii pro pozdější dražší prodej
|
||||||
|
- to ale neznamená, že se má baterie dobít hned v první záporné hodině; pokud jsou v horizontu ještě zápornější ceny, může být lepší nabíjet později
|
||||||
|
- nabíjení ze sítě je omezené jen fyzickými limity baterie a připojení
|
||||||
|
|
||||||
|
#### Záporná prodejní cena
|
||||||
|
|
||||||
|
- při `sell_price < 0` je export do sítě zakázán
|
||||||
|
- řiditelná FVE A se může škrtit
|
||||||
|
- neřiditelná FVE B se neškrtí, pouze se povinně zohlední v bilanci
|
||||||
|
- baterie se nejdřív nabíjí z přebytku FVE, potom se využije flexibilní spotřeba
|
||||||
|
- pokud je potřeba uvolnit místo pro pozdější extrémně záporné ceny, může planner baterii předem záměrně mírně vybít až na bezpečnou ekonomickou podlahu
|
||||||
|
|
||||||
|
#### Nezáporná prodejní cena
|
||||||
|
|
||||||
|
- věta „prodám vše“ v tomto návrhu neznamená povinné okamžité vybití baterie
|
||||||
|
- znamená pouze to, že pokud je baterie už plná z levných nebo záporných hodin, přebytek FVE A jde do sítě
|
||||||
|
- pokud ještě dává větší smysl uložit energii pro pozdější dražší prodej, má přednost uložení do baterie
|
||||||
|
- dynamické zátěže jako TUV a wallbox zůstávají plně součástí bilance; jejich spotřeba může být využita jako další „úložiště“ levné energie
|
||||||
|
|
||||||
|
#### Prodej z baterie
|
||||||
|
|
||||||
|
- při cenové špičce má baterie prodávat do sítě
|
||||||
|
- v2 má využít baterii jako arbitrážní zásobník mezi levnými a drahými okny
|
||||||
|
- vybíjení nesmí klesnout pod `min_soc_percent`
|
||||||
|
|
||||||
|
#### PV A a PV B
|
||||||
|
|
||||||
|
- PV A je řiditelná a může být curtailovaná
|
||||||
|
- PV B je neřiditelná a nikdy se neplánuje jako curtailovaná výroba
|
||||||
|
- PV B je vždy pevný vstup do bilance
|
||||||
|
|
||||||
|
#### BA81 / GEN cutoff
|
||||||
|
|
||||||
|
- v lokalitě BA81 může být zapnutý `deye_gen_microinverter_cutoff_enabled`
|
||||||
|
- pokud by při záporné prodejní ceně nebo no-export politice vznikal nežádoucí export z GEN portu, planner v2 musí umět aktivovat cutoff mikroinvertoru
|
||||||
|
- cutoff má být součást rozhodnutí planneru, ne dodatečná heuristika v exporteru
|
||||||
|
|
||||||
|
### Co má být v plánu zapsané
|
||||||
|
|
||||||
|
Planner v2 má do `planning_interval` zapisovat stejné základní položky jako dosavadní verze:
|
||||||
|
|
||||||
|
- `battery_setpoint_w`
|
||||||
|
- `battery_soc_target_pct`
|
||||||
|
- `grid_setpoint_w`
|
||||||
|
- `export_limit_w`
|
||||||
|
- `export_mode`
|
||||||
|
- `deye_physical_mode`
|
||||||
|
- `deye_gen_cutoff_enabled`
|
||||||
|
- `pv_a_curtailed_w`
|
||||||
|
- `expected_cost_czk`
|
||||||
|
- `effective_buy_price`
|
||||||
|
- `effective_sell_price`
|
||||||
|
|
||||||
|
### Implementační oddělení od v1
|
||||||
|
|
||||||
|
- v1 zůstává beze změny
|
||||||
|
- v2 bude samostatný modul planneru
|
||||||
|
- přepnutí mezi v1 a v2 bude na úrovni orchestrace nebo konfigurace lokality
|
||||||
|
- exportér i control pipeline mají dál číst standardní výstup z `planning_interval`
|
||||||
|
- pokud je zapnuté `PLANNING_ENGINE_COMPARE_ENABLED`, backend spočítá obě verze nad stejným vstupem, aktivní verzi zapíše do plánu a druhou uloží i jako samostatný read-only `planning_run` se stavem `comparison`
|
||||||
|
- compare čtení jde přes `GET /api/v1/sites/{site_id}/plan/compare` a endpoint páruje compare run na aktivní run přes `comparison_of_run_id`
|
||||||
|
- FE stránka `frontend/src/pages/Planning.tsx` ukazuje souhrn aktivní verze, compare verze, slotové rozdíly a compare křivku baterie v grafu
|
||||||
|
- fyzicky se na střídač aplikuje jen aktivní plán; compare běh slouží jen pro audit a vizualizaci
|
||||||
|
|
||||||
|
### Shrnutí v jedné větě
|
||||||
|
|
||||||
|
Planner v2 má dělat přesně toto:
|
||||||
|
|
||||||
|
- ráno držet baterii na `reserve_soc_percent`
|
||||||
|
- při záporných nákupních cenách nabíjet ze sítě
|
||||||
|
- při záporných prodejních cenách zakázat export
|
||||||
|
- při cenových špičkách prodávat z baterie
|
||||||
|
- PV A škrtit jen když je to nutné
|
||||||
|
- PV B nikdy neškrtit
|
||||||
|
- BA81 řešit přes GEN cutoff
|
||||||
|
|||||||
@@ -290,3 +290,16 @@ To znamená:
|
|||||||
- live registry: `backend/app/routers/sites.py`
|
- live registry: `backend/app/routers/sites.py`
|
||||||
- FE plánování: `frontend/src/pages/Planning.tsx`
|
- FE plánování: `frontend/src/pages/Planning.tsx`
|
||||||
- FE live registry: `frontend/src/components/ControlPanel.tsx`
|
- FE live registry: `frontend/src/components/ControlPanel.tsx`
|
||||||
|
|
||||||
|
## 10. Planner v2
|
||||||
|
|
||||||
|
Pro přesné zadání nové verze planneru se řiď sekcí **Planner v2** v [`docs/04-modules/planning.md`](/home/dusan.vojacek@triglav.local/Documents/AI-projekty/ems-cursor/docs/04-modules/planning.md).
|
||||||
|
|
||||||
|
Krátké shrnutí:
|
||||||
|
|
||||||
|
- `reserve_soc_percent` = ranní cílová rezerva
|
||||||
|
- `min_soc_percent` = tvrdá TOU / fyzická podlaha
|
||||||
|
- PV A je řiditelná, PV B je neřiditelná
|
||||||
|
- při záporné prodejní ceně se zakazuje export
|
||||||
|
- v BA81 se cutoff mikroinvertoru řeší přímo v planneru
|
||||||
|
- pokud je zapnuté `PLANNING_ENGINE_COMPARE_ENABLED`, backend spočítá i druhou verzi nad stejným vstupem, uloží ji jako read-only `planning_run` se stavem `comparison`, napáruje ji na aktivní run přes `comparison_of_run_id` a FE ji ukáže v `/plan/compare`; fyzicky se aplikuje jen aktivní plán
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import type {
|
|||||||
SitePvForecastCalibrationRow,
|
SitePvForecastCalibrationRow,
|
||||||
} from '../types/siteConfiguration'
|
} from '../types/siteConfiguration'
|
||||||
import type { Notification } from '../types/dashboard'
|
import type { Notification } from '../types/dashboard'
|
||||||
import type { CurrentPlanResponse, RunPlanResponse } from '../types/plan'
|
import type { CurrentPlanResponse, PlanningCompareResponse, RunPlanResponse } from '../types/plan'
|
||||||
|
|
||||||
const client: AxiosInstance = axios.create({
|
const client: AxiosInstance = axios.create({
|
||||||
baseURL: '/api/v1',
|
baseURL: '/api/v1',
|
||||||
@@ -124,6 +124,13 @@ export async function getCurrentPlan(siteId: number): Promise<CurrentPlanRespons
|
|||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function getPlanCompare(siteId: number): Promise<PlanningCompareResponse> {
|
||||||
|
const { data } = await client.get<PlanningCompareResponse>(`/sites/${siteId}/plan/compare`, {
|
||||||
|
timeout: 60_000,
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
/** Řada FVE předpovědi (součet polí) po 15 min — doplnění grafu za horizont uloženého plánu. */
|
/** Řada FVE předpovědi (součet polí) po 15 min — doplnění grafu za horizont uloženého plánu. */
|
||||||
export type ForecastPvSlotRow = {
|
export type ForecastPvSlotRow = {
|
||||||
interval_start: string
|
interval_start: string
|
||||||
|
|||||||
@@ -25,13 +25,18 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
getCurrentPlan,
|
getCurrentPlan,
|
||||||
|
getPlanCompare,
|
||||||
postImportSitePrices,
|
postImportSitePrices,
|
||||||
postRunForecast,
|
postRunForecast,
|
||||||
postRunPlan,
|
postRunPlan,
|
||||||
} from '../api/backend'
|
} from '../api/backend'
|
||||||
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
|
import { floorSlotUtcMs, SLOT_MS } from '../components/charts/chartConstants'
|
||||||
import { useSiteStatus } from '../hooks/useSiteStatus'
|
import { useSiteStatus } from '../hooks/useSiteStatus'
|
||||||
import type { CurrentPlanResponse, PlanningIntervalDto } from '../types/plan'
|
import type {
|
||||||
|
CurrentPlanResponse,
|
||||||
|
PlanningCompareResponse,
|
||||||
|
PlanningIntervalDto,
|
||||||
|
} from '../types/plan'
|
||||||
|
|
||||||
const TZ = 'Europe/Prague'
|
const TZ = 'Europe/Prague'
|
||||||
|
|
||||||
@@ -389,10 +394,21 @@ type ChartRow = {
|
|||||||
pv_a_w: number
|
pv_a_w: number
|
||||||
battery_soc_target_pct: number | null
|
battery_soc_target_pct: number | null
|
||||||
battery_setpoint_w: number
|
battery_setpoint_w: number
|
||||||
|
compare_battery_setpoint_w?: number | null
|
||||||
effective_buy_price: number | null
|
effective_buy_price: number | null
|
||||||
raw: PlanningIntervalDto
|
raw: PlanningIntervalDto
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function recordNumber(value: unknown): number | null {
|
||||||
|
if (value == null) return null
|
||||||
|
const n = Number(value)
|
||||||
|
return Number.isFinite(n) ? n : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function recordString(value: unknown): string | null {
|
||||||
|
return typeof value === 'string' && value.length > 0 ? value : null
|
||||||
|
}
|
||||||
|
|
||||||
type PlanPrepActionsProps = {
|
type PlanPrepActionsProps = {
|
||||||
prepAction: null | 'import' | 'forecast' | 'init'
|
prepAction: null | 'import' | 'forecast' | 'init'
|
||||||
replanning: boolean
|
replanning: boolean
|
||||||
@@ -494,6 +510,7 @@ function PlanTooltip({
|
|||||||
const soc = p.battery_soc_target_pct
|
const soc = p.battery_soc_target_pct
|
||||||
const exportLimit = i.export_limit_w
|
const exportLimit = i.export_limit_w
|
||||||
const exportMode = i.export_mode ?? 'NONE'
|
const exportMode = i.export_mode ?? 'NONE'
|
||||||
|
const compareBattery = p.compare_battery_setpoint_w
|
||||||
return (
|
return (
|
||||||
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
|
<div className="rounded-lg border border-slate-600 bg-slate-950 px-3 py-2 text-xs text-slate-200 shadow-xl">
|
||||||
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
|
<div className="mb-1 font-medium text-slate-100">{formatLocal(i.interval_start)}</div>
|
||||||
@@ -520,6 +537,7 @@ function PlanTooltip({
|
|||||||
<div>SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}</div>
|
<div>SoC cíl: {soc != null && !Number.isNaN(Number(soc)) ? `${Number(soc).toFixed(1)} %` : '—'}</div>
|
||||||
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
|
<div>Dům: {i.load_baseline_w ?? '—'} W</div>
|
||||||
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
|
<div>Baterie: {i.battery_setpoint_w ?? '—'} W</div>
|
||||||
|
{compareBattery != null ? <div>Compare baterie: {compareBattery} W</div> : null}
|
||||||
<div>Síť (čistý EM): {i.grid_setpoint_w ?? '—'} W</div>
|
<div>Síť (čistý EM): {i.grid_setpoint_w ?? '—'} W</div>
|
||||||
<div>TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}</div>
|
<div>TČ: {i.heat_pump_enabled ? 'zapnuto' : 'vypnuto'}</div>
|
||||||
<div>
|
<div>
|
||||||
@@ -592,8 +610,11 @@ export default function Planning() {
|
|||||||
const siteId = site?.site_id ?? null
|
const siteId = site?.site_id ?? null
|
||||||
|
|
||||||
const [data, setData] = useState<CurrentPlanResponse | null>(null)
|
const [data, setData] = useState<CurrentPlanResponse | null>(null)
|
||||||
|
const [compareData, setCompareData] = useState<PlanningCompareResponse | null>(null)
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [compareLoading, setCompareLoading] = useState(true)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [compareError, setCompareError] = useState<string | null>(null)
|
||||||
const [replanning, setReplanning] = useState(false)
|
const [replanning, setReplanning] = useState(false)
|
||||||
const [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
|
const [prepAction, setPrepAction] = useState<null | 'import' | 'forecast' | 'init'>(null)
|
||||||
const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow')
|
const [importDate, setImportDate] = useState<'today' | 'tomorrow'>('tomorrow')
|
||||||
@@ -604,10 +625,32 @@ export default function Planning() {
|
|||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (siteId == null) return
|
if (siteId == null) return
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setCompareLoading(true)
|
||||||
setError(null)
|
setError(null)
|
||||||
|
setCompareError(null)
|
||||||
try {
|
try {
|
||||||
const res = await getCurrentPlan(siteId)
|
const [planRes, compareRes] = await Promise.allSettled([
|
||||||
setData(res)
|
getCurrentPlan(siteId),
|
||||||
|
getPlanCompare(siteId),
|
||||||
|
])
|
||||||
|
|
||||||
|
if (planRes.status === 'fulfilled') {
|
||||||
|
setData(planRes.value)
|
||||||
|
} else if (axios.isAxiosError(planRes.reason) && planRes.reason.response?.status === 404) {
|
||||||
|
setData({ run: null, intervals: [], summary: null })
|
||||||
|
setError(null)
|
||||||
|
} else {
|
||||||
|
throw planRes.reason
|
||||||
|
}
|
||||||
|
|
||||||
|
if (compareRes.status === 'fulfilled') {
|
||||||
|
setCompareData(compareRes.value)
|
||||||
|
} else if (axios.isAxiosError(compareRes.reason) && compareRes.reason.response?.status === 404) {
|
||||||
|
setCompareData(null)
|
||||||
|
} else {
|
||||||
|
setCompareError(axiosDetail(compareRes.reason))
|
||||||
|
setCompareData(null)
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||||
setData({ run: null, intervals: [], summary: null })
|
setData({ run: null, intervals: [], summary: null })
|
||||||
@@ -618,6 +661,7 @@ export default function Planning() {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
|
setCompareLoading(false)
|
||||||
}
|
}
|
||||||
}, [siteId])
|
}, [siteId])
|
||||||
|
|
||||||
@@ -679,16 +723,19 @@ export default function Planning() {
|
|||||||
}, [chartIntervals, chartHorizonH])
|
}, [chartIntervals, chartHorizonH])
|
||||||
|
|
||||||
const chartRows: ChartRow[] = useMemo(() => {
|
const chartRows: ChartRow[] = useMemo(() => {
|
||||||
|
const compareIntervals = compareData?.comparison?.intervals ?? []
|
||||||
|
const compareMap = new Map(compareIntervals.map((i) => [i.interval_start, i]))
|
||||||
return chartIntervals.map((i) => ({
|
return chartIntervals.map((i) => ({
|
||||||
label: formatLocalTime(i.interval_start),
|
label: formatLocalTime(i.interval_start),
|
||||||
ts: slotStartUtcMs(i.interval_start),
|
ts: slotStartUtcMs(i.interval_start),
|
||||||
pv_a_w: pvChartFveW(i, nowMs),
|
pv_a_w: pvChartFveW(i, nowMs),
|
||||||
battery_soc_target_pct: i.battery_soc_target_pct,
|
battery_soc_target_pct: i.battery_soc_target_pct,
|
||||||
battery_setpoint_w: i.battery_setpoint_w ?? 0,
|
battery_setpoint_w: i.battery_setpoint_w ?? 0,
|
||||||
|
compare_battery_setpoint_w: compareMap.get(i.interval_start)?.battery_setpoint_w ?? null,
|
||||||
effective_buy_price: i.effective_buy_price,
|
effective_buy_price: i.effective_buy_price,
|
||||||
raw: i,
|
raw: i,
|
||||||
}))
|
}))
|
||||||
}, [chartIntervals, nowMs])
|
}, [chartIntervals, nowMs, compareData?.comparison?.intervals])
|
||||||
|
|
||||||
async function onReplan() {
|
async function onReplan() {
|
||||||
if (siteId == null) return
|
if (siteId == null) return
|
||||||
@@ -795,6 +842,10 @@ export default function Planning() {
|
|||||||
const correctionPct =
|
const correctionPct =
|
||||||
run?.forecast_correction_factor != null ? run.forecast_correction_factor * 100 : null
|
run?.forecast_correction_factor != null ? run.forecast_correction_factor * 100 : null
|
||||||
const correctionUp = (run?.forecast_correction_factor ?? 1) >= 1
|
const correctionUp = (run?.forecast_correction_factor ?? 1) >= 1
|
||||||
|
const compareActiveSummary = compareData?.active?.summary ?? null
|
||||||
|
const comparePeerSummary = compareData?.comparison?.summary ?? null
|
||||||
|
const compareDiff = compareData?.diff ?? null
|
||||||
|
const compareSlotDiffs = compareData?.slot_diffs ?? []
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto max-w-6xl space-y-8 p-4 md:p-6">
|
<div className="mx-auto max-w-6xl space-y-8 p-4 md:p-6">
|
||||||
@@ -956,6 +1007,139 @@ export default function Planning() {
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Sekce 2 */}
|
{/* Sekce 2 */}
|
||||||
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||||
|
<h2 className="mb-2 text-sm font-medium uppercase tracking-wide text-slate-400">Porovnání v1 / v2</h2>
|
||||||
|
{compareLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-slate-400">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" /> Načítám compare…
|
||||||
|
</div>
|
||||||
|
) : compareError && !compareData ? (
|
||||||
|
<div className="rounded-md border border-amber-900/60 bg-amber-950/30 px-3 py-2 text-sm text-amber-200">
|
||||||
|
{compareError}
|
||||||
|
</div>
|
||||||
|
) : compareData ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{compareError && (
|
||||||
|
<div className="rounded-md border border-amber-900/60 bg-amber-950/30 px-3 py-2 text-sm text-amber-200">
|
||||||
|
{compareError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-950/40 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-500">Aktivní verze</p>
|
||||||
|
<p className="mt-1 font-mono text-lg text-white">
|
||||||
|
{recordString(compareData.active.run?.run_type) ?? '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Solver: {recordNumber(compareData.active.run?.solver_duration_ms) != null
|
||||||
|
? `${recordNumber(compareData.active.run?.solver_duration_ms)} ms`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
Náklady: {recordNumber(compareActiveSummary?.total_expected_cost_czk) != null
|
||||||
|
? `${recordNumber(compareActiveSummary?.total_expected_cost_czk)?.toFixed(2)} Kč`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-slate-800 bg-slate-950/40 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-slate-500">Compare verze</p>
|
||||||
|
<p className="mt-1 font-mono text-lg text-white">
|
||||||
|
{recordString(compareData.comparison.run?.run_type) ?? '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Solver: {recordNumber(compareData.comparison.run?.solver_duration_ms) != null
|
||||||
|
? `${recordNumber(compareData.comparison.run?.solver_duration_ms)} ms`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-slate-500">
|
||||||
|
Náklady: {recordNumber(comparePeerSummary?.total_expected_cost_czk) != null
|
||||||
|
? `${recordNumber(comparePeerSummary?.total_expected_cost_czk)?.toFixed(2)} Kč`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-lg border border-cyan-900/50 bg-cyan-950/20 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-wide text-cyan-200/80">Rozdíl</p>
|
||||||
|
<p className="mt-1 font-mono text-lg text-cyan-100">
|
||||||
|
{recordNumber(compareDiff?.total_expected_cost_czk) != null
|
||||||
|
? `${recordNumber(compareDiff?.total_expected_cost_czk)?.toFixed(2)} Kč`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-cyan-100/70">
|
||||||
|
Změněných slotů:{' '}
|
||||||
|
{recordNumber(compareDiff?.changed_slots) != null ? recordNumber(compareDiff?.changed_slots) : '—'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-cyan-100/70">
|
||||||
|
Aktivní / compare export sloty:{' '}
|
||||||
|
{recordNumber(compareDiff?.active_export_slots) != null
|
||||||
|
? `${recordNumber(compareDiff?.active_export_slots)} / ${recordNumber(compareDiff?.comparison_export_slots)}`
|
||||||
|
: '—'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{compareSlotDiffs.length > 0 ? (
|
||||||
|
<div className="max-h-[320px] overflow-auto rounded-lg border border-slate-800/80">
|
||||||
|
<table className="w-full border-collapse text-left text-xs">
|
||||||
|
<thead className="sticky top-0 bg-slate-900 text-slate-500 shadow-[0_1px_0_0_rgb(30_41_59)]">
|
||||||
|
<tr>
|
||||||
|
<th className="whitespace-nowrap px-2 py-2 font-medium">Slot</th>
|
||||||
|
<th className="whitespace-nowrap px-2 py-2 font-medium">Aktivní bat. W</th>
|
||||||
|
<th className="whitespace-nowrap px-2 py-2 font-medium">Compare bat. W</th>
|
||||||
|
<th className="whitespace-nowrap px-2 py-2 font-medium">Aktivní grid W</th>
|
||||||
|
<th className="whitespace-nowrap px-2 py-2 font-medium">Compare grid W</th>
|
||||||
|
<th className="whitespace-nowrap px-2 py-2 font-medium">Aktivní export</th>
|
||||||
|
<th className="whitespace-nowrap px-2 py-2 font-medium">Compare export</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{compareSlotDiffs.slice(0, 48).map((row) => (
|
||||||
|
<tr key={row.interval_start} className="border-b border-slate-800/80">
|
||||||
|
<td className="whitespace-nowrap px-2 py-1.5 font-mono text-slate-300">
|
||||||
|
{formatLocalTime(row.interval_start)}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 font-mono text-slate-200">
|
||||||
|
{recordNumber(row.active.battery_setpoint_w) != null
|
||||||
|
? recordNumber(row.active.battery_setpoint_w)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 font-mono text-slate-200">
|
||||||
|
{recordNumber(row.comparison.battery_setpoint_w) != null
|
||||||
|
? recordNumber(row.comparison.battery_setpoint_w)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 font-mono text-slate-200">
|
||||||
|
{recordNumber(row.active.grid_setpoint_w) != null
|
||||||
|
? recordNumber(row.active.grid_setpoint_w)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 font-mono text-slate-200">
|
||||||
|
{recordNumber(row.comparison.grid_setpoint_w) != null
|
||||||
|
? recordNumber(row.comparison.grid_setpoint_w)
|
||||||
|
: '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 font-mono text-slate-200">
|
||||||
|
{recordString(row.active.export_mode) ?? '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-2 py-1.5 font-mono text-slate-200">
|
||||||
|
{recordString(row.comparison.export_mode) ?? '—'}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">Compare běh je uložen, ale nemá slotové rozdíly k zobrazení.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-slate-500">
|
||||||
|
Compare plán zatím není k dispozici. Spusťte plánování s aktivním režimem v1/v2 compare.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Sekce 3 */}
|
||||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||||
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
|
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Graf plánu</h2>
|
||||||
<HorizonToggle
|
<HorizonToggle
|
||||||
@@ -1029,6 +1213,17 @@ export default function Planning() {
|
|||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</Bar>
|
</Bar>
|
||||||
|
<Line
|
||||||
|
yAxisId="power"
|
||||||
|
type="monotone"
|
||||||
|
dataKey="compare_battery_setpoint_w"
|
||||||
|
name="Compare baterie W"
|
||||||
|
stroke="#fb923c"
|
||||||
|
dot={false}
|
||||||
|
strokeWidth={2}
|
||||||
|
strokeDasharray="6 4"
|
||||||
|
connectNulls
|
||||||
|
/>
|
||||||
<Line
|
<Line
|
||||||
yAxisId="soc"
|
yAxisId="soc"
|
||||||
type="monotone"
|
type="monotone"
|
||||||
@@ -1056,7 +1251,7 @@ export default function Planning() {
|
|||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Sekce 3 */}
|
{/* Sekce 4 */}
|
||||||
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
<section className="rounded-xl border border-slate-800 bg-slate-900/40 p-4 shadow-lg">
|
||||||
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
|
<h2 className="mb-1 text-sm font-medium uppercase tracking-wide text-slate-400">Tabulka slotů</h2>
|
||||||
<HorizonToggle value={tableHorizonH} onChange={setTableHorizonH} disabled={futureSlots.length === 0} />
|
<HorizonToggle value={tableHorizonH} onChange={setTableHorizonH} disabled={futureSlots.length === 0} />
|
||||||
|
|||||||
@@ -59,6 +59,25 @@ export type CurrentPlanResponse = {
|
|||||||
summary: PlanningSummaryDto | null
|
summary: PlanningSummaryDto | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PlanningBundleDto = {
|
||||||
|
run: Record<string, unknown>
|
||||||
|
intervals: PlanningIntervalDto[]
|
||||||
|
summary: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanningSlotDiffDto = {
|
||||||
|
interval_start: string
|
||||||
|
active: Record<string, unknown>
|
||||||
|
comparison: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export type PlanningCompareResponse = {
|
||||||
|
active: PlanningBundleDto
|
||||||
|
comparison: PlanningBundleDto
|
||||||
|
diff: Record<string, unknown>
|
||||||
|
slot_diffs: PlanningSlotDiffDto[]
|
||||||
|
}
|
||||||
|
|
||||||
export type RunPlanResponse = {
|
export type RunPlanResponse = {
|
||||||
run_id: number
|
run_id: number
|
||||||
solver_duration_ms: number
|
solver_duration_ms: number
|
||||||
|
|||||||
Reference in New Issue
Block a user