|
| 1 | +"""Grid-charge target strategy helpers.""" |
| 2 | + |
| 3 | +from dataclasses import dataclass |
| 4 | +from typing import Optional |
| 5 | + |
| 6 | +GRID_CHARGE_TARGET_STRATEGY_FIXED = 'fixed' |
| 7 | +GRID_CHARGE_TARGET_STRATEGY_FORECAST = 'forecast' |
| 8 | +GRID_CHARGE_TARGET_STRATEGIES = ( |
| 9 | + GRID_CHARGE_TARGET_STRATEGY_FIXED, |
| 10 | + GRID_CHARGE_TARGET_STRATEGY_FORECAST, |
| 11 | +) |
| 12 | + |
| 13 | + |
| 14 | +@dataclass(frozen=True) |
| 15 | +class GridChargeTargetConfig: |
| 16 | + """Configuration for grid-charge target calculation.""" |
| 17 | + |
| 18 | + strategy: str = GRID_CHARGE_TARGET_STRATEGY_FIXED |
| 19 | + |
| 20 | + @classmethod |
| 21 | + def from_battery_control_config(cls, config: dict) -> 'GridChargeTargetConfig': |
| 22 | + """Create target strategy configuration from battery_control config.""" |
| 23 | + return cls( |
| 24 | + strategy=_parse_grid_charge_target_strategy( |
| 25 | + config.get( |
| 26 | + 'grid_charge_target_strategy', |
| 27 | + GRID_CHARGE_TARGET_STRATEGY_FIXED, |
| 28 | + ) |
| 29 | + ), |
| 30 | + ) |
| 31 | + |
| 32 | + |
| 33 | +@dataclass(frozen=True) |
| 34 | +class GridChargeTargetResult: |
| 35 | + """Energy adjusted by a grid-charge target and the effective target SoC.""" |
| 36 | + |
| 37 | + energy: float |
| 38 | + effective_soc: Optional[float] |
| 39 | + |
| 40 | + |
| 41 | +def _parse_grid_charge_target_strategy(value) -> str: |
| 42 | + """Parse the grid-charge target strategy config value.""" |
| 43 | + strategy = str(value).strip().lower() |
| 44 | + if strategy not in GRID_CHARGE_TARGET_STRATEGIES: |
| 45 | + raise ValueError( |
| 46 | + f"battery_control.grid_charge_target_strategy must be one of " |
| 47 | + f"{GRID_CHARGE_TARGET_STRATEGIES}, got {value!r}" |
| 48 | + ) |
| 49 | + return strategy |
| 50 | + |
| 51 | + |
| 52 | +def _validate_strategy(strategy: str) -> None: |
| 53 | + if strategy not in GRID_CHARGE_TARGET_STRATEGIES: |
| 54 | + raise ValueError( |
| 55 | + f"grid_charge_target_strategy must be one of " |
| 56 | + f"{GRID_CHARGE_TARGET_STRATEGIES}, got '{strategy}'" |
| 57 | + ) |
| 58 | + |
| 59 | + |
| 60 | +def _capped_forecast_soc( |
| 61 | + configured_min_grid_charge_soc: float, |
| 62 | + additional_reserve_energy: float, |
| 63 | + max_capacity: float, |
| 64 | + max_charging_from_grid_limit: float) -> float: |
| 65 | + """Return floor-plus-reserve target SoC capped by the grid-charge limit.""" |
| 66 | + if max_capacity <= 0: |
| 67 | + raise ValueError("max_capacity must be greater than 0") |
| 68 | + target_soc = configured_min_grid_charge_soc + ( |
| 69 | + additional_reserve_energy / max_capacity) |
| 70 | + return min(target_soc, max_charging_from_grid_limit) |
| 71 | + |
| 72 | + |
| 73 | +def apply_grid_charge_target_to_recharge( |
| 74 | + config: GridChargeTargetConfig, |
| 75 | + recharge_energy: float, |
| 76 | + required_energy: float, |
| 77 | + stored_energy: float, |
| 78 | + configured_min_grid_charge_soc: Optional[float], |
| 79 | + max_capacity: float, |
| 80 | + max_charging_from_grid_limit: float) -> GridChargeTargetResult: |
| 81 | + """Apply the configured grid-charge target to recharge energy. |
| 82 | +
|
| 83 | + ``fixed`` applies the configured SoC floor: when grid charging is already |
| 84 | + required, charge at least up to ``min_grid_charge_soc``. |
| 85 | +
|
| 86 | + ``forecast`` uses the existing high-price-slot ``required_energy`` as the |
| 87 | + source of truth and treats ``min_grid_charge_soc`` as the reserve that |
| 88 | + should remain after those slots. The target is therefore the configured |
| 89 | + floor plus the required high-price energy. |
| 90 | + """ |
| 91 | + if configured_min_grid_charge_soc is None: |
| 92 | + return GridChargeTargetResult(recharge_energy, None) |
| 93 | + |
| 94 | + _validate_strategy(config.strategy) |
| 95 | + effective_soc = configured_min_grid_charge_soc |
| 96 | + if required_energy <= 0.0: |
| 97 | + return GridChargeTargetResult(recharge_energy, effective_soc) |
| 98 | + |
| 99 | + if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST: |
| 100 | + effective_soc = _capped_forecast_soc( |
| 101 | + configured_min_grid_charge_soc, |
| 102 | + required_energy, |
| 103 | + max_capacity, |
| 104 | + max_charging_from_grid_limit, |
| 105 | + ) |
| 106 | + |
| 107 | + target_energy = max_capacity * effective_soc |
| 108 | + soc_recharge_energy = max(0.0, target_energy - stored_energy) |
| 109 | + adjusted_recharge_energy = max(recharge_energy, soc_recharge_energy) |
| 110 | + |
| 111 | + if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST: |
| 112 | + max_grid_charge_energy = max( |
| 113 | + 0.0, |
| 114 | + max_capacity * max_charging_from_grid_limit - stored_energy, |
| 115 | + ) |
| 116 | + adjusted_recharge_energy = min( |
| 117 | + adjusted_recharge_energy, |
| 118 | + max_grid_charge_energy, |
| 119 | + ) |
| 120 | + |
| 121 | + return GridChargeTargetResult(adjusted_recharge_energy, effective_soc) |
| 122 | + |
| 123 | + |
| 124 | +def apply_grid_charge_target_to_reserve( |
| 125 | + config: GridChargeTargetConfig, |
| 126 | + reserved_energy: float, |
| 127 | + min_soc_energy: float, |
| 128 | + configured_min_grid_charge_soc: Optional[float], |
| 129 | + max_capacity: float, |
| 130 | + max_charging_from_grid_limit: float, |
| 131 | + active: bool) -> GridChargeTargetResult: |
| 132 | + """Apply the configured grid-charge target to protected reserve energy.""" |
| 133 | + if configured_min_grid_charge_soc is None or not active: |
| 134 | + return GridChargeTargetResult(reserved_energy, configured_min_grid_charge_soc) |
| 135 | + |
| 136 | + _validate_strategy(config.strategy) |
| 137 | + effective_soc = configured_min_grid_charge_soc |
| 138 | + |
| 139 | + if config.strategy == GRID_CHARGE_TARGET_STRATEGY_FORECAST: |
| 140 | + effective_soc = _capped_forecast_soc( |
| 141 | + configured_min_grid_charge_soc, |
| 142 | + reserved_energy, |
| 143 | + max_capacity, |
| 144 | + max_charging_from_grid_limit, |
| 145 | + ) |
| 146 | + |
| 147 | + target_energy = max_capacity * effective_soc |
| 148 | + target_usable_energy = max(0.0, target_energy - min_soc_energy) |
| 149 | + adjusted_reserved_energy = max(reserved_energy, target_usable_energy) |
| 150 | + return GridChargeTargetResult(adjusted_reserved_energy, effective_soc) |
0 commit comments