Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ This documentation is intended for Home Assistant users, energy enthusiasts, and
8. Optionally configure a PV source if you have solar and want WattPlan to plan around it.
9. Optionally configure an export price source if you have PV and want exported power to carry a value instead of defaulting to zero.
10. Add [batteries, comfort loads, or optional loads](docs/extras.md) if you want WattPlan to control more than just forecasting.
11. Make automations to apply the WattPlan actions, to your devices - such as setting batteries to charge, or starting your HVAC
11. Make automations to apply the WattPlan actions to your devices, such as setting batteries to charge or starting your HVAC. See the [real-life examples](docs/extras.md#real-life-examples) for one battery policy automation pattern.
Comment thread
LordMike marked this conversation as resolved.
Outdated

## Configuration Steps
After installing WattPlan via HACS, configure the following:
Expand All @@ -47,7 +47,7 @@ After installing WattPlan via HACS, configure the following:
## Documentation
- [docs/source-data.md](docs/source-data.md) - Source modes, data model, and how to feed WattPlan price, export price, usage, and PV data
- [docs/example-deye-solcast-stromligning.md](docs/example-deye-solcast-stromligning.md) - Concrete end-to-end example using Strømligning, Deye, and Solcast
- [docs/extras.md](docs/extras.md) - Batteries, comfort loads, optional loads, and how to wire WattPlan actions into your own automations
- [docs/extras.md](docs/extras.md) - Batteries, comfort loads, optional loads, real-life examples, and how to wire WattPlan actions into your own automations
- [docs/entities-and-services.md](docs/entities-and-services.md) - All exposed entities and services, including battery targets
- [docs/optimizer-profiles.md](docs/optimizer-profiles.md) - What Aggressive, Balanced, and Conservative mean in practice
- [docs/error-handling.md](docs/error-handling.md) - Health states, degraded operation, and what `ok`, `degraded`, and `failed` mean
Expand Down
24 changes: 13 additions & 11 deletions custom_components/wattplan/coordinator_logic/projection.py
Original file line number Diff line number Diff line change
Expand Up @@ -63,12 +63,12 @@ def planner_output_from_result(
next_action_timestamp = next_change[0] if next_change is not None else None
next_point = next_change[1] if next_change is not None else None
next_action = (
str(next_point.get("state", "hold"))
str(next_point.get("state", "self_consume"))
if isinstance(next_point, dict)
else None
)
batteries[subentry_id] = {
"action": str(current.get("state", "hold")),
"action": str(current.get("state", "self_consume")),
"next_action_timestamp": (
next_action_timestamp.isoformat()
if next_action_timestamp is not None
Expand Down Expand Up @@ -264,7 +264,11 @@ def _build_plan_details_payload(
plan_details[f"{key_base}_action"] = [
self._map_action_code(str(action))
for action in self._series_from_schedule(
schedule, horizon_slots, "state", default="hold", stringify=True
schedule,
horizon_slots,
"state",
default="self_consume",
stringify=True,
)
]
plan_details[f"{key_base}_level_kwh"] = self._rounded_series(
Expand Down Expand Up @@ -370,8 +374,8 @@ def _aggregate_plan_details_series(
aggregated.append(round(sum(float(value) for value in chunk), 2))
elif key.endswith("_action"):
actions = {str(value) for value in chunk if isinstance(value, str)}
active_actions = sorted(action for action in actions if action != "h")
aggregated.append("+".join(active_actions) if active_actions else "h")
active_actions = sorted(action for action in actions if action != "sc")
aggregated.append("+".join(active_actions) if active_actions else "sc")
else:
aggregated.append(chunk[-1])
return aggregated
Expand Down Expand Up @@ -457,9 +461,7 @@ def _next_change_point(

def _map_action_code(self, action: str) -> str:
return {
"charge_grid": "c_g",
"charge_pv": "c_p",
"charge_grid_pv": "c_gp",
"discharge": "d",
"hold": "h",
}.get(action, "h")
"grid_charge": "gc",
"preserve": "p",
"self_consume": "sc",
}.get(action, "sc")
3 changes: 0 additions & 3 deletions custom_components/wattplan/flows/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,6 @@ def _battery_form_defaults(data: dict[str, Any]) -> dict[str, Any]:
defaults[SECTION_BATTERY_ADVANCED] = {
CONF_CHARGE_EFFICIENCY: defaults.get(CONF_CHARGE_EFFICIENCY, 0.9),
CONF_DISCHARGE_EFFICIENCY: defaults.get(CONF_DISCHARGE_EFFICIENCY, 0.9),
CONF_PREFER_PV_SURPLUS_CHARGING: defaults.get(
CONF_PREFER_PV_SURPLUS_CHARGING, False
),
}
return defaults

Expand Down
6 changes: 0 additions & 6 deletions custom_components/wattplan/flows/source_shared.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,6 @@
CONF_OPTIONS_COUNT,
CONF_PLANNING_ENABLED,
CONF_OPTIMIZER_PROFILE,
CONF_PREFER_PV_SURPLUS_CHARGING,
CONF_PROVIDERS,
CONF_RESAMPLE_MODE,
CONF_ROLLING_WINDOW_HOURS,
Expand Down Expand Up @@ -1396,7 +1395,6 @@ def _battery_schema() -> vol.Schema:
default={
CONF_CHARGE_EFFICIENCY: 0.9,
CONF_DISCHARGE_EFFICIENCY: 0.9,
CONF_PREFER_PV_SURPLUS_CHARGING: False,
},
): section(
vol.Schema(
Expand All @@ -1423,10 +1421,6 @@ def _battery_schema() -> vol.Schema:
mode=selector.NumberSelectorMode.BOX,
)
),
vol.Required(
CONF_PREFER_PV_SURPLUS_CHARGING,
default=False,
): selector.BooleanSelector(),
}
),
{"collapsed": True},
Expand Down
39 changes: 34 additions & 5 deletions custom_components/wattplan/optimizer/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ def _validate_mode(cls, value):
)
prefer_pv_surplus_charging: bool = Field(
False,
description="Whether PV surplus should be preferentially stored here.",
description="Internal/deferred PV surplus sink hint; not a public action state.",
)
can_charge_from: int = Field(
int(ChargeSource.PV),
Expand Down Expand Up @@ -306,10 +306,18 @@ class OptimizationParams(BaseModel):
0.0, description="Additional cost applied to charging/discharging throughput."
)
action_deadband_kwh: float = Field(
0.0, description="Commands smaller than this are treated as hold."
0.0, description="Commands smaller than this are treated as neutral flow."
)
mode_switch_cost: float = Field(
0.0, description="Cost for switching between charge/hold/discharge behavior."
0.0, description="Cost for switching between modeled charge/idle/discharge flow."
)
infer_battery_preserve_policy: bool = Field(
True,
description=(
"When true, run the model-backed counterfactual used to emit battery "
"preserve policy states. When false, battery_preserve output flags "
"are always false."
),
)
battery_entities: List[BatteryEntityParams] = Field(
..., description="List of battery-like entities."
Expand Down Expand Up @@ -538,6 +546,7 @@ class NormalizedState:
battery_charge_grid: np.ndarray
battery_charge_pv: np.ndarray
battery_discharge: np.ndarray
battery_preserve: np.ndarray
comfort_on: np.ndarray
comfort_lock_mode: np.ndarray
comfort_lock_remaining: np.ndarray
Expand All @@ -551,14 +560,20 @@ class CalculationInput:
solar_input: np.ndarray
usage: np.ndarray
rolling_window_slots: int
infer_battery_preserve_policy: bool
battery_entities: List[BatteryEntity]
comfort_entities: List[ComfortEntity]
optional_entities: List[NormalizedOptionalEntity]
state: NormalizedState | None
fingerprint: str


def _entity_fingerprint(battery_entities, comfort_entities, rolling_window_slots):
def _entity_fingerprint(
battery_entities,
comfort_entities,
rolling_window_slots,
infer_battery_preserve_policy,
):
# Reuse must only happen when the optimization problem is materially the
# same. Battery targets change feasible early-slot decisions, so they must
# participate in the fingerprint used to accept a previous state blob.
Expand Down Expand Up @@ -606,6 +621,7 @@ def _entity_fingerprint(battery_entities, comfort_entities, rolling_window_slots
for e in comfort_entities
],
"rolling_window_slots": int(rolling_window_slots),
"infer_battery_preserve_policy": bool(infer_battery_preserve_policy),
}
raw = json.dumps(payload, separators=(",", ":"), sort_keys=True).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
Expand Down Expand Up @@ -638,6 +654,10 @@ def _parse_state_blob(state_blob):
battery_charge_grid = np.asarray(obj["battery_charge_grid"], dtype=np.float64)
battery_charge_pv = np.asarray(obj["battery_charge_pv"], dtype=np.float64)
battery_discharge = np.asarray(obj["battery_discharge"], dtype=np.float64)
battery_preserve = np.asarray(
obj.get("battery_preserve", np.zeros_like(battery_discharge)),
dtype=np.bool_,
)
comfort_on = np.asarray(obj["comfort_on"], dtype=np.float64)
comfort_lock_mode = np.asarray(obj["comfort_lock_mode"], dtype=np.float64)
comfort_lock_remaining = np.asarray(
Expand All @@ -657,6 +677,8 @@ def _parse_state_blob(state_blob):
battery_charge_grid = battery_charge_grid.reshape(0, num_steps)
if battery_charge_pv.ndim == 1 and battery_charge_pv.size == 0:
battery_charge_pv = battery_charge_pv.reshape(0, num_steps)
if battery_preserve.ndim == 1 and battery_preserve.size == 0:
battery_preserve = battery_preserve.reshape(0, num_steps)
if comfort_on.ndim == 1 and comfort_on.size == 0:
comfort_on = comfort_on.reshape(0, num_steps)
if comfort_lock_mode.ndim == 1 and comfort_lock_mode.size == 0:
Expand All @@ -680,6 +702,8 @@ def _parse_state_blob(state_blob):
raise ValueError("state.battery_charge_grid shape mismatch")
if battery_charge_pv.ndim != 2 or battery_charge_pv.shape[1] != num_steps:
raise ValueError("state.battery_charge_pv shape mismatch")
if battery_preserve.ndim != 2 or battery_preserve.shape[1] != num_steps:
raise ValueError("state.battery_preserve shape mismatch")
if comfort_on.ndim != 2 or comfort_on.shape[1] != num_steps:
raise ValueError("state.comfort_on shape mismatch")
if comfort_lock_mode.ndim != 2 or comfort_lock_mode.shape[1] != num_steps:
Expand Down Expand Up @@ -714,6 +738,7 @@ def _parse_state_blob(state_blob):
battery_charge_grid=battery_charge_grid,
battery_charge_pv=battery_charge_pv,
battery_discharge=battery_discharge,
battery_preserve=battery_preserve,
comfort_on=comfort_on,
comfort_lock_mode=comfort_lock_mode,
comfort_lock_remaining=comfort_lock_remaining,
Expand Down Expand Up @@ -802,7 +827,10 @@ def normalize_calculation_input(params: OptimizationParams):
)

fingerprint = _entity_fingerprint(
battery_entities, comfort_entities, params.rolling_window_slots
battery_entities,
comfort_entities,
params.rolling_window_slots,
params.infer_battery_preserve_policy,
)
state = _parse_state_blob(params.state)

Expand All @@ -813,6 +841,7 @@ def normalize_calculation_input(params: OptimizationParams):
solar_input=solar_input,
usage=usage,
rolling_window_slots=int(params.rolling_window_slots),
infer_battery_preserve_policy=bool(params.infer_battery_preserve_policy),
battery_entities=battery_entities,
comfort_entities=comfort_entities,
optional_entities=optional_entities,
Expand Down
Loading
Loading