mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-10 00:16:39 +00:00
feat(optimization): port tested terminal and tail value primitives
Source d2e2d58237. 22 primitive tests pass; integration with the optimizer, forecast horizon and API is still pending.
Co-authored-by: Andreas <drbacke@gmx.de>
Co-authored-by: Christin <info@bikinibottom.capital>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
"""Resample actual forecast intervals without extrapolating missing provider data."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
|
||||
|
||||
def bounded_forecast_array(
|
||||
prediction: Any,
|
||||
*,
|
||||
key: str,
|
||||
start_datetime: Any,
|
||||
end_datetime: Any,
|
||||
interval: Any,
|
||||
**kwargs: Any,
|
||||
) -> np.ndarray:
|
||||
"""Hold interval averages only within their source interval.
|
||||
|
||||
EOS forecasts carry interval starts. Infer source cadence from timestamps,
|
||||
conservatively bounded to one hour; never extend the last value indefinitely.
|
||||
Explicit NaNs and holes remain missing. Downsampling requires full coverage.
|
||||
"""
|
||||
target_seconds = int(interval.total_seconds())
|
||||
try:
|
||||
series = prediction.key_to_series(key, dropna=False)
|
||||
except KeyError:
|
||||
series = pd.Series(dtype=float, index=pd.DatetimeIndex([], tz="UTC"))
|
||||
series = pd.to_numeric(series, errors="coerce").sort_index()
|
||||
series = series[~series.index.duplicated(keep="last")]
|
||||
cadence = 3600
|
||||
if len(series) > 1:
|
||||
gaps = np.diff(series.index.as_unit("ns").asi8) / 1e9
|
||||
cadence = int(min(3600, np.min(gaps[gaps > 0])))
|
||||
step = min(cadence, target_seconds)
|
||||
index = pd.date_range(start=start_datetime, end=end_datetime, freq=f"{step}s", inclusive="left")
|
||||
if series.empty:
|
||||
sampled = pd.Series(np.nan, index=index)
|
||||
else:
|
||||
sampled = series.reindex(index, method="ffill", tolerance=pd.Timedelta(seconds=cadence - 1))
|
||||
groups = sampled.resample(f"{target_seconds}s", origin=start_datetime)
|
||||
result = groups.mean()
|
||||
result[groups.count().to_numpy() < target_seconds / step] = np.nan
|
||||
return result.to_numpy(dtype=float)
|
||||
@@ -0,0 +1,303 @@
|
||||
"""Chronological, run-local battery lookahead using the production device physics.
|
||||
|
||||
The Bellman recursion interpolates continuation values on a stored-energy grid.
|
||||
Tail actions never enter the executable control arrays. The selected path may
|
||||
be returned separately as diagnostics so users can inspect the lookahead.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
from pydantic import PrivateAttr
|
||||
|
||||
from akkudoktoreos.devices.genetic.battery import Battery
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import TailPlanSlot, TerminalValueCurve
|
||||
|
||||
|
||||
TailAction = tuple[int, int, float, float]
|
||||
|
||||
|
||||
def _action_name(action: TailAction) -> str:
|
||||
dc, discharge, ac_rate, export_rate = action
|
||||
if export_rate > 0:
|
||||
return "BATTERY_EXPORT"
|
||||
if ac_rate > 0:
|
||||
return "GRID_CHARGE"
|
||||
if dc and discharge:
|
||||
return "SELF_CONSUMPTION"
|
||||
if dc:
|
||||
return "PV_CHARGE_ONLY"
|
||||
if discharge:
|
||||
return "DISCHARGE_ONLY"
|
||||
return "HOLD"
|
||||
|
||||
|
||||
def _simulate_action(
|
||||
*,
|
||||
bat: Battery,
|
||||
inv: Inverter,
|
||||
energy_wh: float,
|
||||
action: TailAction,
|
||||
price: float,
|
||||
load: float,
|
||||
pv: float,
|
||||
tariff: float,
|
||||
direct_marketing: bool,
|
||||
) -> dict[str, float]:
|
||||
"""Apply one tail action from one stored-energy state."""
|
||||
dc, discharge, ac_rate, export = action
|
||||
bat.soc_wh = float(energy_wh)
|
||||
bat._charged_raw_wh_per_slot.fill(0)
|
||||
bat._discharged_raw_wh_per_slot.fill(0)
|
||||
ac_enabled = inv.ac_to_dc_efficiency > 0 and (
|
||||
inv.max_ac_charge_power_w is None or inv.max_ac_charge_power_w > 0
|
||||
)
|
||||
bat.charge_array[0] = ac_rate if ac_rate > 0 and ac_enabled else dc
|
||||
bat.discharge_array[0] = discharge if export == 0 or tariff > 0 else 0
|
||||
sold, bought, losses, _ = inv.process_energy(
|
||||
pv,
|
||||
load,
|
||||
0,
|
||||
allow_battery_grid_export=direct_marketing and export > 0 and tariff > 0,
|
||||
battery_grid_export_factor=export,
|
||||
)
|
||||
ac_grid_charge_wh = 0.0
|
||||
if ac_rate > 0 and inv.ac_to_dc_efficiency > 0:
|
||||
rate = ac_rate
|
||||
if inv.max_ac_charge_power_w is not None and bat.max_charge_power_w > 0:
|
||||
rate = min(
|
||||
rate,
|
||||
inv.max_ac_charge_power_w * inv.ac_to_dc_efficiency / bat.max_charge_power_w,
|
||||
)
|
||||
bat.charge_array[0] = rate
|
||||
if rate > 0:
|
||||
stored, loss = bat.charge_energy(None, 0, charge_factor=rate)
|
||||
ac_grid_charge_wh = (stored + loss) / inv.ac_to_dc_efficiency
|
||||
bought += ac_grid_charge_wh
|
||||
losses += loss + max(ac_grid_charge_wh - stored - loss, 0.0)
|
||||
if direct_marketing and tariff < 0:
|
||||
sold = 0.0
|
||||
discharged_wh = bat.discharged_energy_wh(0)
|
||||
reward = (
|
||||
sold * tariff - bought * price - (discharged_wh * bat.levelized_cost_of_storage_kwh / 1000)
|
||||
)
|
||||
return {
|
||||
"next_state_wh": bat.soc_wh,
|
||||
"reward_euro": reward,
|
||||
"grid_export_wh": sold,
|
||||
"grid_import_wh": bought,
|
||||
"battery_charge_wh": (bat._charged_raw_wh_per_slot[0] * bat.charging_efficiency),
|
||||
"battery_discharge_wh": discharged_wh,
|
||||
"losses_wh": losses,
|
||||
"ac_grid_charge_wh": ac_grid_charge_wh,
|
||||
}
|
||||
|
||||
|
||||
class TailValueCurve(TerminalValueCurve):
|
||||
"""Value of usable AC battery energy, including the value of empty capacity.
|
||||
|
||||
Neither values nor marginal values are constrained to be monotone. The empty
|
||||
state can earn money by charging at negative prices and selling later.
|
||||
"""
|
||||
|
||||
_trace_context: dict = PrivateAttr(default_factory=dict)
|
||||
|
||||
def value(self, energy_wh: float) -> float:
|
||||
return (
|
||||
float(np.interp(energy_wh, self.energy_wh, self.value_euro)) if self.energy_wh else 0.0
|
||||
)
|
||||
|
||||
def component_values(self, energy_wh: float) -> tuple[float, float]:
|
||||
"""Return tail operating cash flow and continuation credit separately."""
|
||||
if not self.energy_wh:
|
||||
return 0.0, 0.0
|
||||
operating = float(np.interp(energy_wh, self.energy_wh, self.operating_value_euro))
|
||||
continuation = float(np.interp(energy_wh, self.energy_wh, self.continuation_value_euro))
|
||||
return operating, continuation
|
||||
|
||||
def diagnostic_plan(self, energy_wh: float, control_horizon_hours: float) -> list[TailPlanSlot]:
|
||||
"""Replay the optimal tail path for one control-end battery state."""
|
||||
context = self._trace_context
|
||||
if not context:
|
||||
return []
|
||||
bat = Battery(
|
||||
context["battery_parameters"],
|
||||
prediction_hours=1,
|
||||
slot_duration_h=context["slot_duration_h"],
|
||||
)
|
||||
inv = Inverter(
|
||||
context["inverter_parameters"],
|
||||
battery=bat,
|
||||
slot_duration_h=context["slot_duration_h"],
|
||||
)
|
||||
conversion = bat.discharging_efficiency * inv.dc_to_ac_efficiency
|
||||
state_wh = bat.min_soc_wh + (energy_wh / conversion if conversion > 0 else 0.0)
|
||||
state_wh = float(np.clip(state_wh, bat.min_soc_wh, bat.max_soc_wh))
|
||||
plan: list[TailPlanSlot] = []
|
||||
arrays = zip(
|
||||
context["prices"],
|
||||
context["load"],
|
||||
context["pv"],
|
||||
context["tariffs"],
|
||||
)
|
||||
for slot, (price, load, pv, tariff) in enumerate(arrays):
|
||||
candidates: list[tuple[float, TailAction, dict[str, float], float]] = []
|
||||
for action in context["actions"]:
|
||||
result = _simulate_action(
|
||||
bat=bat,
|
||||
inv=inv,
|
||||
energy_wh=state_wh,
|
||||
action=action,
|
||||
price=price,
|
||||
load=load,
|
||||
pv=pv,
|
||||
tariff=tariff,
|
||||
direct_marketing=context["direct_marketing"],
|
||||
)
|
||||
remaining = float(
|
||||
np.interp(
|
||||
result["next_state_wh"],
|
||||
context["states"],
|
||||
context["future_values"][slot + 1],
|
||||
)
|
||||
)
|
||||
candidates.append((result["reward_euro"] + remaining, action, result, remaining))
|
||||
candidates.sort(key=lambda candidate: candidate[0], reverse=True)
|
||||
chosen_value, chosen_action, chosen_result, remaining = candidates[0]
|
||||
alternative = next(
|
||||
(
|
||||
candidate
|
||||
for candidate in candidates[1:]
|
||||
if _action_name(candidate[1]) != _action_name(chosen_action)
|
||||
),
|
||||
candidates[1] if len(candidates) > 1 else candidates[0],
|
||||
)
|
||||
dc, discharge, ac_rate, export_rate = chosen_action
|
||||
start_soc = state_wh / bat.capacity_wh * 100
|
||||
state_wh = chosen_result["next_state_wh"]
|
||||
plan.append(
|
||||
TailPlanSlot(
|
||||
slot=slot,
|
||||
hour_from_start=control_horizon_hours + slot * context["slot_duration_h"],
|
||||
action=_action_name(chosen_action),
|
||||
alternative_action=_action_name(alternative[1]),
|
||||
decision_margin_euro=max(chosen_value - alternative[0], 0.0),
|
||||
soc_start_percentage=start_soc,
|
||||
soc_end_percentage=state_wh / bat.capacity_wh * 100,
|
||||
pv_wh=pv,
|
||||
load_wh=load,
|
||||
grid_import_wh=chosen_result["grid_import_wh"],
|
||||
grid_export_wh=chosen_result["grid_export_wh"],
|
||||
battery_charge_wh=chosen_result["battery_charge_wh"],
|
||||
battery_discharge_wh=chosen_result["battery_discharge_wh"],
|
||||
import_price_euro_per_kwh=price * 1000,
|
||||
feed_in_tariff_euro_per_kwh=tariff * 1000,
|
||||
slot_value_euro=chosen_result["reward_euro"],
|
||||
remaining_value_euro=remaining,
|
||||
ac_charge_factor=ac_rate,
|
||||
dc_charge_allowed=dc,
|
||||
discharge_allowed=discharge,
|
||||
battery_grid_export_factor=export_rate,
|
||||
)
|
||||
)
|
||||
return plan
|
||||
|
||||
|
||||
def build_tail_value_curve(
|
||||
*,
|
||||
battery: Battery,
|
||||
inverter: Inverter,
|
||||
prices_euro_per_wh: np.ndarray,
|
||||
load_wh: np.ndarray,
|
||||
pv_wh: np.ndarray,
|
||||
feed_in_euro_per_wh: np.ndarray,
|
||||
continuation: TerminalValueCurve,
|
||||
charge_rates: list[float],
|
||||
export_rates: list[float],
|
||||
direct_marketing: bool,
|
||||
grid_points: int = 101,
|
||||
) -> TailValueCurve:
|
||||
"""Solve the finite tail once, backwards in time, without mutating run devices.
|
||||
|
||||
LCOS uses delivered DC energy, exactly as in GeneticSimulation. State grid
|
||||
endpoints include battery minimum and maximum SOC. Continuous next states are
|
||||
interpolated rather than rounded (which would invent or destroy energy).
|
||||
"""
|
||||
arrays = [
|
||||
np.asarray(a, dtype=float)
|
||||
for a in (prices_euro_per_wh, load_wh, pv_wh, feed_in_euro_per_wh)
|
||||
]
|
||||
if len({len(a) for a in arrays}) != 1 or any(not np.isfinite(a).all() for a in arrays):
|
||||
raise ValueError("Tail forecasts must have equal lengths and contain only finite values")
|
||||
bat = Battery(battery.parameters, prediction_hours=1, slot_duration_h=battery.slot_duration_h)
|
||||
bat.charge_array = np.zeros(1, dtype=float)
|
||||
inv = Inverter(inverter.parameters, battery=bat, slot_duration_h=battery.slot_duration_h)
|
||||
states = np.linspace(bat.min_soc_wh, bat.max_soc_wh, grid_points)
|
||||
usable = (states - bat.min_soc_wh) * bat.discharging_efficiency * inv.dc_to_ac_efficiency
|
||||
continuation_values = np.array([continuation.value(e) for e in usable])
|
||||
operating_values = np.zeros(len(states))
|
||||
values = continuation_values.copy()
|
||||
# (DC charge, local discharge, AC rate, export rate). Preserve production
|
||||
# modes; direct marketing permits disabling DC charge to make headroom.
|
||||
actions: list[TailAction] = [(1, 0, 0.0, 0.0), (1, 1, 0.0, 0.0)]
|
||||
actions += [(1, 0, rate, 0.0) for rate in charge_rates if rate > 0]
|
||||
if direct_marketing:
|
||||
actions += [(0, 0, 0.0, 0.0), (0, 1, 0.0, 0.0)]
|
||||
actions += [(0, 0, rate, 0.0) for rate in charge_rates if rate > 0]
|
||||
actions += [(dc, 1, 0.0, rate) for dc in (0, 1) for rate in export_rates if rate > 0]
|
||||
future_values: list[np.ndarray] = [np.empty(0)] * (len(arrays[0]) + 1)
|
||||
future_values[-1] = continuation_values.copy()
|
||||
for slot in reversed(range(len(arrays[0]))):
|
||||
price, load, pv, tariff = (array[slot] for array in arrays)
|
||||
best = np.full(len(states), -np.inf)
|
||||
best_operating = np.zeros(len(states))
|
||||
best_continuation = np.zeros(len(states))
|
||||
for action in actions:
|
||||
next_states = np.empty(len(states))
|
||||
rewards = np.empty(len(states))
|
||||
for i, energy in enumerate(states):
|
||||
result = _simulate_action(
|
||||
bat=bat,
|
||||
inv=inv,
|
||||
energy_wh=energy,
|
||||
action=action,
|
||||
price=price,
|
||||
load=load,
|
||||
pv=pv,
|
||||
tariff=tariff,
|
||||
direct_marketing=direct_marketing,
|
||||
)
|
||||
rewards[i] = result["reward_euro"]
|
||||
next_states[i] = result["next_state_wh"]
|
||||
candidate_operating = rewards + np.interp(next_states, states, operating_values)
|
||||
candidate_continuation = np.interp(next_states, states, continuation_values)
|
||||
candidate = candidate_operating + candidate_continuation
|
||||
better = candidate > best
|
||||
best[better] = candidate[better]
|
||||
best_operating[better] = candidate_operating[better]
|
||||
best_continuation[better] = candidate_continuation[better]
|
||||
values = best
|
||||
operating_values = best_operating
|
||||
continuation_values = best_continuation
|
||||
future_values[slot] = best.copy()
|
||||
result = TailValueCurve(
|
||||
energy_wh=usable.tolist(),
|
||||
value_euro=values.tolist(),
|
||||
operating_value_euro=operating_values.tolist(),
|
||||
continuation_value_euro=continuation_values.tolist(),
|
||||
marginal_euro_per_kwh=(np.diff(values) / np.maximum(np.diff(usable), 1e-9) * 1000).tolist(),
|
||||
window_slots=len(arrays[0]),
|
||||
)
|
||||
result._trace_context = {
|
||||
"battery_parameters": battery.parameters,
|
||||
"inverter_parameters": inverter.parameters,
|
||||
"slot_duration_h": battery.slot_duration_h,
|
||||
"states": states,
|
||||
"future_values": future_values,
|
||||
"actions": actions,
|
||||
"prices": arrays[0],
|
||||
"load": arrays[1],
|
||||
"pv": arrays[2],
|
||||
"tariffs": arrays[3],
|
||||
"direct_marketing": direct_marketing,
|
||||
}
|
||||
return result
|
||||
@@ -0,0 +1,359 @@
|
||||
"""Terminal value of the energy left in the battery at the end of the horizon.
|
||||
|
||||
The optimizer stops at the horizon, but the energy still stored in the battery
|
||||
keeps its worth: it replaces grid imports that would otherwise be paid for
|
||||
afterwards. Crediting that worth with a single price per kWh - the historical
|
||||
``preis_euro_pro_wh_akku`` - cannot describe it, because the value of stored
|
||||
energy is **not linear in the amount stored**:
|
||||
|
||||
- The first kWh replaces the most expensive hour after the horizon.
|
||||
- The next one replaces the second most expensive hour, and so on.
|
||||
- Once every hour that PV cannot cover is served, further energy replaces
|
||||
nothing; it is worth an export at best, and nothing at worst.
|
||||
|
||||
The resulting value function is monotone and concave. A scalar has to pick one
|
||||
slope: high enough for the first kWh means hoarding a full battery, low enough
|
||||
for the last kWh means running it empty by midnight. This module builds the
|
||||
curve instead.
|
||||
|
||||
There is no forecast beyond the horizon, so the trailing window of the horizon
|
||||
itself stands in for the day that follows: same season, same household rhythm,
|
||||
same tariff structure. That approximation is the reason the curve is a planning
|
||||
aid, not a prediction - which is also why the marginal values are deliberately
|
||||
conservative wherever a choice exists.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
|
||||
|
||||
class TerminalValueCurve(PydanticBaseModel):
|
||||
"""Piecewise linear, concave value of battery energy left at the horizon.
|
||||
|
||||
``energy_wh`` and ``value_euro`` are the breakpoints of the cumulative
|
||||
value, ``marginal_euro_per_kwh`` the slope of each segment. Both arrays
|
||||
start at the origin; the curve is flat beyond its last breakpoint.
|
||||
"""
|
||||
|
||||
energy_wh: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Breakpoints of usable AC energy left in the battery [Wh]."
|
||||
},
|
||||
)
|
||||
value_euro: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={"description": "Cumulative credit at each breakpoint [EUR]."},
|
||||
)
|
||||
operating_value_euro: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Tail operating component at each breakpoint [EUR]; empty for a proxy curve."
|
||||
},
|
||||
)
|
||||
continuation_value_euro: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": "Continuation component at each breakpoint [EUR]; empty for a proxy curve."
|
||||
},
|
||||
)
|
||||
marginal_euro_per_kwh: list[float] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Marginal value of the segment that starts at each breakpoint "
|
||||
"[EUR/kWh]. May be negative or non-monotone in TAIL mode."
|
||||
)
|
||||
},
|
||||
)
|
||||
residual_energy_wh: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Energy up to which the curve is backed by residual load - the "
|
||||
"knee. Everything beyond it is only worth an export."
|
||||
)
|
||||
},
|
||||
)
|
||||
window_slots: int = Field(
|
||||
default=0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Number of trailing horizon slots the curve was derived from. "
|
||||
"Fewer slots than a full day mean a shorter proxy period."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
def value(self, energy_wh: float) -> float:
|
||||
"""Return the credit for ``energy_wh`` of usable AC energy [EUR].
|
||||
|
||||
Args:
|
||||
energy_wh: Usable AC energy left in the battery.
|
||||
|
||||
Returns:
|
||||
Interpolated value of the curve; 0.0 for an empty curve.
|
||||
"""
|
||||
if not self.energy_wh or energy_wh <= 0.0:
|
||||
return 0.0
|
||||
return float(np.interp(energy_wh, self.energy_wh, self.value_euro))
|
||||
|
||||
|
||||
class TailDiagnostics(PydanticBaseModel):
|
||||
"""Forecast summary used by the deterministic tail optimization."""
|
||||
|
||||
slots: int = 0
|
||||
slot_hours: float = 0.0
|
||||
soc_grid_points: int = 0
|
||||
min_import_price_euro_per_kwh: float = 0.0
|
||||
max_import_price_euro_per_kwh: float = 0.0
|
||||
min_feed_in_tariff_euro_per_kwh: float = 0.0
|
||||
max_feed_in_tariff_euro_per_kwh: float = 0.0
|
||||
negative_import_price_slots: int = 0
|
||||
positive_battery_export_slots: int = 0
|
||||
|
||||
|
||||
class TailPlanSlot(PydanticBaseModel):
|
||||
"""One diagnostic slot of the optimal tail path.
|
||||
|
||||
These values explain the lookahead used for fitness. They are diagnostics
|
||||
only and are never copied into the executable control arrays.
|
||||
"""
|
||||
|
||||
slot: int
|
||||
hour_from_start: float
|
||||
action: str
|
||||
alternative_action: str = ""
|
||||
decision_margin_euro: float = 0.0
|
||||
soc_start_percentage: float
|
||||
soc_end_percentage: float
|
||||
pv_wh: float
|
||||
load_wh: float
|
||||
grid_import_wh: float
|
||||
grid_export_wh: float
|
||||
battery_charge_wh: float
|
||||
battery_discharge_wh: float
|
||||
import_price_euro_per_kwh: float
|
||||
feed_in_tariff_euro_per_kwh: float
|
||||
slot_value_euro: float
|
||||
remaining_value_euro: float
|
||||
ac_charge_factor: float
|
||||
dc_charge_allowed: int
|
||||
discharge_allowed: int
|
||||
battery_grid_export_factor: float
|
||||
|
||||
|
||||
class TerminalValueResult(PydanticBaseModel):
|
||||
"""What the optimizer credited for the energy left in the battery."""
|
||||
|
||||
control_horizon_hours: float = 0
|
||||
requested_tail_hours: float = 0
|
||||
effective_tail_hours: float = 0
|
||||
tail_end_hour: float = 0
|
||||
continuation_mode: str = "FIXED"
|
||||
|
||||
mode: str = Field(
|
||||
json_schema_extra={
|
||||
"description": "Terminal value mode the run used: TAIL, AUTO or FIXED.",
|
||||
"examples": ["TAIL", "AUTO", "FIXED"],
|
||||
}
|
||||
)
|
||||
battery_energy_wh: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": "Usable AC energy left in the battery at the end of the horizon [Wh]."
|
||||
},
|
||||
)
|
||||
credited_euro: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={"description": "Credit applied to the total balance [EUR]."},
|
||||
)
|
||||
tail_operating_euro: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Optimal net cash flow within the effective tail for the selected "
|
||||
"control-end battery state [EUR]."
|
||||
)
|
||||
},
|
||||
)
|
||||
continuation_value_euro: float = Field(
|
||||
default=0.0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Continuation credit remaining at the end of the optimal tail path [EUR]."
|
||||
)
|
||||
},
|
||||
)
|
||||
curve: Optional[TerminalValueCurve] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Combined tail value curve (tail operation plus continuation) read by fitness; "
|
||||
"None in FIXED mode."
|
||||
)
|
||||
},
|
||||
)
|
||||
continuation_curve: Optional[TerminalValueCurve] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": "Conservative AUTO proxy constructed at the effective tail end."
|
||||
},
|
||||
)
|
||||
tail_diagnostics: Optional[TailDiagnostics] = None
|
||||
tail_plan: list[TailPlanSlot] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Diagnostic optimal battery path inside the tail. It explains the "
|
||||
"lookahead but is never an executable control plan."
|
||||
)
|
||||
},
|
||||
)
|
||||
reason: str = Field(
|
||||
default="",
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Why this mode applied. Empty in AUTO mode; in FIXED mode it "
|
||||
"says whether FIXED was configured or whether AUTO fell back "
|
||||
"because no curve could be derived."
|
||||
),
|
||||
"examples": ["", "terminal_value_mode is FIXED"],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def build_terminal_value_curve(
|
||||
*,
|
||||
prices_euro_per_wh: np.ndarray,
|
||||
load_wh: np.ndarray,
|
||||
pv_wh: np.ndarray,
|
||||
feed_in_euro_per_wh: np.ndarray,
|
||||
max_energy_wh: float,
|
||||
lcos_euro_per_kwh: float = 0.0,
|
||||
dc_to_ac_efficiency: float = 1.0,
|
||||
grid_export_allowed: bool = False,
|
||||
) -> TerminalValueCurve:
|
||||
"""Build the terminal value curve from the trailing horizon window.
|
||||
|
||||
Every slot of the window contributes its residual load - the part of the
|
||||
load that PV does not cover - at its import price. Sorting those slots by
|
||||
price and accumulating them yields the marginal value of the first, second,
|
||||
... kWh in the battery. Energy beyond the residual load can only be
|
||||
exported, and only when direct marketing allows it.
|
||||
|
||||
Args:
|
||||
prices_euro_per_wh: Import prices of the window [EUR/Wh].
|
||||
load_wh: Load per slot of the window [Wh].
|
||||
pv_wh: PV generation per slot of the window [Wh].
|
||||
feed_in_euro_per_wh: Feed-in tariff of the window [EUR/Wh].
|
||||
max_energy_wh: Usable AC energy of a full battery [Wh]; the curve ends here.
|
||||
lcos_euro_per_kwh: Levelized cost of storage, already charged per
|
||||
delivered DC energy in the simulation and therefore subtracted here
|
||||
so stored energy is not credited twice.
|
||||
dc_to_ac_efficiency: Inverter efficiency, used to convert the LCOS from
|
||||
delivered DC energy to the AC energy of the curve.
|
||||
grid_export_allowed: Whether the battery may feed the grid (direct
|
||||
marketing). Without it, energy beyond the residual load gets no
|
||||
credit: it can neither be exported nor is its use covered by the
|
||||
proxy window.
|
||||
|
||||
Returns:
|
||||
The curve; empty when the window carries no usable information.
|
||||
"""
|
||||
window = min(len(prices_euro_per_wh), len(load_wh), len(pv_wh))
|
||||
if window <= 0 or max_energy_wh <= 0.0:
|
||||
return TerminalValueCurve()
|
||||
|
||||
residual = np.maximum(load_wh[:window] - pv_wh[:window], 0.0)
|
||||
prices = np.asarray(prices_euro_per_wh[:window], dtype=float)
|
||||
|
||||
# LCOS is charged on delivered DC energy; the curve is in AC energy.
|
||||
lcos_per_wh_ac = (lcos_euro_per_kwh / 1000.0) / max(dc_to_ac_efficiency, 1e-9)
|
||||
|
||||
order = np.argsort(-prices)
|
||||
energy_points: list[float] = [0.0]
|
||||
value_points: list[float] = [0.0]
|
||||
marginals: list[float] = []
|
||||
|
||||
cumulative_energy = 0.0
|
||||
cumulative_value = 0.0
|
||||
for index in order:
|
||||
slot_energy = float(residual[index])
|
||||
if slot_energy <= 0.0:
|
||||
continue
|
||||
# Negative or very cheap hours are not worth storing energy for.
|
||||
marginal = max(float(prices[index]) - lcos_per_wh_ac, 0.0)
|
||||
if marginal <= 0.0:
|
||||
continue
|
||||
slot_energy = min(slot_energy, max_energy_wh - cumulative_energy)
|
||||
if slot_energy <= 0.0:
|
||||
break
|
||||
cumulative_energy += slot_energy
|
||||
cumulative_value += slot_energy * marginal
|
||||
energy_points.append(cumulative_energy)
|
||||
value_points.append(cumulative_value)
|
||||
marginals.append(marginal * 1000.0)
|
||||
|
||||
# Everything beyond the residual load can only be sold. A median feed-in
|
||||
# tariff rather than the best one: exporting all of it in the single best
|
||||
# slot is not something the horizon can promise.
|
||||
residual_energy_wh = cumulative_energy
|
||||
if grid_export_allowed and cumulative_energy < max_energy_wh:
|
||||
positive_feed_in = [
|
||||
float(value) for value in feed_in_euro_per_wh[:window] if float(value) > 0.0
|
||||
]
|
||||
export_marginal = max(
|
||||
(float(np.median(positive_feed_in)) if positive_feed_in else 0.0) - lcos_per_wh_ac,
|
||||
0.0,
|
||||
)
|
||||
if export_marginal > 0.0:
|
||||
remaining = max_energy_wh - cumulative_energy
|
||||
cumulative_energy += remaining
|
||||
cumulative_value += remaining * export_marginal
|
||||
energy_points.append(cumulative_energy)
|
||||
value_points.append(cumulative_value)
|
||||
marginals.append(export_marginal * 1000.0)
|
||||
|
||||
if len(energy_points) <= 1:
|
||||
logger.debug("Terminal value curve is empty - no priced residual load in the window.")
|
||||
return TerminalValueCurve(window_slots=window)
|
||||
|
||||
# The segment slopes are decreasing by construction (prices were sorted),
|
||||
# so the curve is concave; the export tail is the flattest segment.
|
||||
return TerminalValueCurve(
|
||||
energy_wh=energy_points,
|
||||
value_euro=value_points,
|
||||
marginal_euro_per_kwh=marginals,
|
||||
residual_energy_wh=residual_energy_wh,
|
||||
window_slots=window,
|
||||
)
|
||||
|
||||
|
||||
def trailing_window(
|
||||
values: Optional[np.ndarray],
|
||||
end_slot: int,
|
||||
window_slots: int,
|
||||
) -> np.ndarray:
|
||||
"""Return the ``window_slots`` values in front of ``end_slot``.
|
||||
|
||||
Args:
|
||||
values: Full slot array, or None.
|
||||
end_slot: Exclusive end of the window (end of the optimization horizon).
|
||||
window_slots: Desired window length; a shorter horizon yields less.
|
||||
|
||||
Returns:
|
||||
The window as a float array, empty when no data is available.
|
||||
"""
|
||||
if values is None:
|
||||
return np.zeros(0, dtype=float)
|
||||
end = min(int(end_slot), len(values))
|
||||
start = max(end - int(window_slots), 0)
|
||||
if end <= start:
|
||||
return np.zeros(0, dtype=float)
|
||||
return np.asarray(values[start:end], dtype=float)
|
||||
@@ -0,0 +1,200 @@
|
||||
"""Economic tail scenarios and hard control/forecast boundaries."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.config import SettingsEOSDefaults
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.devices.genetic.battery import Battery
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter
|
||||
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
|
||||
from akkudoktoreos.optimization.genetic.genetic import GeneticOptimization
|
||||
from akkudoktoreos.devices.genetic.inverter import InverterParameters
|
||||
from akkudoktoreos.devices.genetic.battery import SolarPanelBatteryParameters
|
||||
from akkudoktoreos.optimization.genetic.geneticparams import GeneticOptimizationParameters
|
||||
from akkudoktoreos.optimization.genetic.tailvalue import build_tail_value_curve
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import TerminalValueCurve
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration
|
||||
|
||||
|
||||
def devices(power=1000, efficiency=1.0, lcos=0, ac_limit=None, export_power=5000):
|
||||
bat = Battery(
|
||||
SolarPanelBatteryParameters(
|
||||
device_id="battery1",
|
||||
capacity_wh=1000,
|
||||
max_charge_power_w=power,
|
||||
charging_efficiency=efficiency,
|
||||
discharging_efficiency=efficiency,
|
||||
initial_soc_percentage=50,
|
||||
levelized_cost_of_storage_kwh=lcos,
|
||||
charge_rates=[0, 0.5, 1],
|
||||
),
|
||||
prediction_hours=1,
|
||||
)
|
||||
inv = Inverter(
|
||||
InverterParameters(
|
||||
device_id="inverter1",
|
||||
battery_id="battery1",
|
||||
max_power_wh=export_power,
|
||||
dc_to_ac_efficiency=1,
|
||||
ac_to_dc_efficiency=1,
|
||||
max_ac_charge_power_w=ac_limit,
|
||||
),
|
||||
battery=bat,
|
||||
)
|
||||
return bat, inv
|
||||
|
||||
|
||||
def curve(
|
||||
prices=(-0.1, 0.3),
|
||||
tariffs=(0, 0.3),
|
||||
direct=True,
|
||||
continuation=None,
|
||||
load=None,
|
||||
pv=None,
|
||||
**kwargs,
|
||||
):
|
||||
bat, inv = devices(**kwargs)
|
||||
return build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array(prices) / 1000,
|
||||
feed_in_euro_per_wh=np.array(tariffs) / 1000,
|
||||
load_wh=np.zeros(len(prices)) if load is None else np.array(load),
|
||||
pv_wh=np.zeros(len(prices)) if pv is None else np.array(pv),
|
||||
continuation=continuation or TerminalValueCurve(),
|
||||
charge_rates=[0.5, 1],
|
||||
export_rates=[1],
|
||||
direct_marketing=direct,
|
||||
)
|
||||
|
||||
|
||||
def test_headroom_has_value_and_empty_state_can_earn():
|
||||
c = curve()
|
||||
assert c.value(0) == pytest.approx(0.4)
|
||||
tail, continuation = c.component_values(0)
|
||||
assert tail == pytest.approx(0.4)
|
||||
assert continuation == pytest.approx(0.0)
|
||||
assert c.value(0) == pytest.approx(tail + continuation)
|
||||
assert c.value(500) > c.value(1000)
|
||||
assert any(v < 0 for v in c.marginal_euro_per_kwh)
|
||||
|
||||
|
||||
def test_chronology_changes_arbitrage():
|
||||
forward = curve()
|
||||
reverse = curve(prices=(0.3, -0.1), tariffs=(0.3, 0))
|
||||
assert forward.value(0) > reverse.value(0)
|
||||
|
||||
|
||||
def test_discharge_and_ac_power_limits():
|
||||
limited = curve(prices=(1,), tariffs=(1,), power=100)
|
||||
assert limited.value(1000) == pytest.approx(0.1)
|
||||
limited_ac = curve(ac_limit=100)
|
||||
assert limited_ac.value(0) == pytest.approx(0.04)
|
||||
limited_inverter = curve(prices=(1,), tariffs=(1,), export_power=50)
|
||||
assert limited_inverter.value(1000) == pytest.approx(0.05)
|
||||
|
||||
|
||||
def test_losses_and_lcos_reduce_arbitrage():
|
||||
ideal = curve(prices=(0.1, 0.3))
|
||||
lossy = curve(prices=(0.1, 0.3), efficiency=0.8)
|
||||
assert 0 < lossy.value(0) < ideal.value(0)
|
||||
assert curve(prices=(0.1, 0.3), lcos=0.25).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_no_battery_export_without_permission():
|
||||
assert curve(prices=(0.1, 0.3), direct=False).value(0) == pytest.approx(0)
|
||||
|
||||
|
||||
def test_pv_surplus_can_be_stored_for_local_load():
|
||||
c = curve(prices=(0.2, 0.3), tariffs=(0, 0), pv=[1000, 0], load=[0, 1000], direct=False)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
# Without PV the same empty battery must buy energy to serve the load.
|
||||
assert curve(prices=(0.2, 0.3), tariffs=(0, 0), load=[0, 1000], direct=False).value(
|
||||
0
|
||||
) < c.value(0)
|
||||
|
||||
|
||||
def test_continuation_survives_tail_end():
|
||||
continuation = TerminalValueCurve(energy_wh=[0, 1000], value_euro=[0, 0.2])
|
||||
c = curve(prices=(0.5,), tariffs=(0,), continuation=continuation)
|
||||
assert c.value(1000) == pytest.approx(0.2)
|
||||
tail, continuation_credit = c.component_values(1000)
|
||||
assert tail == pytest.approx(0.0)
|
||||
assert continuation_credit == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_tail_diagnostic_plan_explains_the_selected_path():
|
||||
c = curve()
|
||||
plan = c.diagnostic_plan(0, control_horizon_hours=24)
|
||||
assert len(plan) == 2
|
||||
assert plan[0].hour_from_start == 24
|
||||
assert plan[0].action == "GRID_CHARGE"
|
||||
assert plan[0].soc_end_percentage > plan[0].soc_start_percentage
|
||||
assert plan[0].grid_import_wh > 0
|
||||
assert plan[1].action == "BATTERY_EXPORT"
|
||||
assert plan[1].soc_end_percentage < plan[1].soc_start_percentage
|
||||
assert plan[1].grid_export_wh > 0
|
||||
assert sum(slot.slot_value_euro for slot in plan) == pytest.approx(c.value(0))
|
||||
|
||||
|
||||
|
||||
|
||||
def test_provider_values_are_not_extrapolated():
|
||||
from types import SimpleNamespace
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
series = pd.Series([1.0, 2.0], index=pd.date_range(start=start, periods=2, freq="h"))
|
||||
provider = SimpleNamespace(key_to_series=lambda *a, **kw: series)
|
||||
result = bounded_forecast_array(
|
||||
provider,
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=3),
|
||||
interval=to_duration("15 minutes"),
|
||||
)
|
||||
assert result[:8].tolist() == [1.0] * 4 + [2.0] * 4
|
||||
assert np.isnan(result[8:]).all()
|
||||
|
||||
|
||||
|
||||
def test_disabled_ac_conversion_cannot_earn_negative_price_revenue():
|
||||
bat, inv = devices()
|
||||
inv.parameters.ac_to_dc_efficiency = 0
|
||||
c = build_tail_value_curve(
|
||||
battery=bat,
|
||||
inverter=inv,
|
||||
prices_euro_per_wh=np.array([-0.001, 0.001]),
|
||||
feed_in_euro_per_wh=np.array([0.0, 0.001]),
|
||||
load_wh=np.zeros(2),
|
||||
pv_wh=np.zeros(2),
|
||||
continuation=TerminalValueCurve(),
|
||||
charge_rates=[1],
|
||||
export_rates=[1],
|
||||
direct_marketing=True,
|
||||
)
|
||||
assert c.value(0) == pytest.approx(0)
|
||||
assert bat.soc_wh == 500 # Building the tail never mutates the real battery.
|
||||
|
||||
|
||||
|
||||
def test_missing_provider_key_stays_missing():
|
||||
from types import SimpleNamespace
|
||||
|
||||
def unavailable(*a, **kw):
|
||||
raise KeyError("price unavailable")
|
||||
|
||||
start = to_datetime("2026-09-05T00:00:00Z")
|
||||
result = bounded_forecast_array(
|
||||
SimpleNamespace(key_to_series=unavailable),
|
||||
key="price",
|
||||
start_datetime=start,
|
||||
end_datetime=start.add(hours=2),
|
||||
interval=to_duration("1 hour"),
|
||||
)
|
||||
assert np.isnan(result).all()
|
||||
assert len(result) == 2
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
"""Tests for the concave terminal value of the energy left in the battery."""
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.optimization.genetic.terminalvalue import (
|
||||
build_terminal_value_curve,
|
||||
trailing_window,
|
||||
)
|
||||
|
||||
|
||||
def _curve(**overrides):
|
||||
"""Two expensive slots, one cheap one, no PV, 10 kWh of usable battery."""
|
||||
params = dict(
|
||||
prices_euro_per_wh=np.array([0.0004, 0.0003, 0.0001]),
|
||||
load_wh=np.array([1000.0, 1000.0, 1000.0]),
|
||||
pv_wh=np.array([0.0, 0.0, 0.0]),
|
||||
feed_in_euro_per_wh=np.array([0.00008, 0.00008, 0.00008]),
|
||||
max_energy_wh=10000.0,
|
||||
lcos_euro_per_kwh=0.0,
|
||||
dc_to_ac_efficiency=1.0,
|
||||
grid_export_allowed=False,
|
||||
)
|
||||
params.update(overrides)
|
||||
return build_terminal_value_curve(**params)
|
||||
|
||||
|
||||
def test_marginal_value_follows_the_most_expensive_hours_first():
|
||||
"""The first stored kWh replaces the most expensive slot, then the next."""
|
||||
curve = _curve()
|
||||
|
||||
# 0.40, 0.30 and 0.10 EUR/kWh, in that order.
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4, 0.3, 0.1])
|
||||
assert curve.energy_wh == pytest.approx([0.0, 1000.0, 2000.0, 3000.0])
|
||||
assert curve.value_euro == pytest.approx([0.0, 0.4, 0.7, 0.8])
|
||||
|
||||
|
||||
def test_curve_is_concave_and_saturates():
|
||||
"""Marginal values only decrease, and beyond the last breakpoint nothing is added."""
|
||||
curve = _curve()
|
||||
marginals = curve.marginal_euro_per_kwh
|
||||
|
||||
assert all(a >= b for a, b in zip(marginals, marginals[1:]))
|
||||
# The residual load of the window is 3 kWh - more energy replaces nothing.
|
||||
assert curve.value(3000.0) == pytest.approx(0.8)
|
||||
assert curve.value(9000.0) == pytest.approx(0.8)
|
||||
|
||||
|
||||
def test_value_interpolates_within_a_segment():
|
||||
"""Half of the first slot is worth half of the first segment."""
|
||||
curve = _curve()
|
||||
assert curve.value(500.0) == pytest.approx(0.2)
|
||||
|
||||
|
||||
def test_pv_reduces_the_residual_load():
|
||||
"""Only load that PV cannot cover can be replaced by stored energy."""
|
||||
curve = _curve(pv_wh=np.array([600.0, 1000.0, 0.0]))
|
||||
|
||||
# Slot 0 keeps 400 Wh, slot 1 is fully covered by PV, slot 2 keeps 1000 Wh.
|
||||
assert curve.energy_wh == pytest.approx([0.0, 400.0, 1400.0])
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4, 0.1])
|
||||
|
||||
|
||||
def test_lcos_is_subtracted_from_the_marginal_value():
|
||||
"""Storage cost is already charged on discharge and must not be credited twice."""
|
||||
curve = _curve(lcos_euro_per_kwh=0.05, dc_to_ac_efficiency=1.0)
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.35, 0.25, 0.05])
|
||||
|
||||
|
||||
def test_negative_prices_do_not_create_value():
|
||||
"""Storing energy for an hour that pays nothing is not worth anything."""
|
||||
curve = _curve(prices_euro_per_wh=np.array([0.0004, -0.0001, 0.0]))
|
||||
assert curve.marginal_euro_per_kwh == pytest.approx([0.4])
|
||||
assert curve.value(5000.0) == pytest.approx(0.4)
|
||||
|
||||
|
||||
def test_export_tail_only_with_direct_marketing():
|
||||
"""Surplus beyond the residual load is worth an export - if export is allowed."""
|
||||
without = _curve(grid_export_allowed=False)
|
||||
with_export = _curve(grid_export_allowed=True)
|
||||
|
||||
assert without.value(10000.0) == pytest.approx(0.8)
|
||||
# 7 kWh beyond the residual load at the median feed-in tariff of 0.08 EUR/kWh.
|
||||
assert with_export.value(10000.0) == pytest.approx(0.8 + 7.0 * 0.08)
|
||||
assert with_export.marginal_euro_per_kwh[-1] == pytest.approx(0.08)
|
||||
|
||||
|
||||
def test_residual_energy_marks_the_knee():
|
||||
"""The knee separates load-backed value from the export tail."""
|
||||
without = _curve(grid_export_allowed=False)
|
||||
with_export = _curve(grid_export_allowed=True)
|
||||
|
||||
# 3 kWh of residual load in the window, whether or not export is allowed.
|
||||
assert without.residual_energy_wh == pytest.approx(3000.0)
|
||||
assert with_export.residual_energy_wh == pytest.approx(3000.0)
|
||||
# Only the export tail reaches beyond it.
|
||||
assert without.energy_wh[-1] == pytest.approx(3000.0)
|
||||
assert with_export.energy_wh[-1] == pytest.approx(10000.0)
|
||||
|
||||
|
||||
def test_curve_is_capped_by_the_usable_battery_energy():
|
||||
"""A battery smaller than the residual load ends the curve early."""
|
||||
curve = _curve(max_energy_wh=1500.0)
|
||||
assert curve.energy_wh[-1] == pytest.approx(1500.0)
|
||||
assert curve.value(5000.0) == pytest.approx(0.4 + 0.5 * 0.3)
|
||||
|
||||
|
||||
def test_empty_window_yields_an_empty_curve():
|
||||
"""Without data there is no curve, and no credit."""
|
||||
curve = build_terminal_value_curve(
|
||||
prices_euro_per_wh=np.zeros(0),
|
||||
load_wh=np.zeros(0),
|
||||
pv_wh=np.zeros(0),
|
||||
feed_in_euro_per_wh=np.zeros(0),
|
||||
max_energy_wh=10000.0,
|
||||
)
|
||||
assert curve.energy_wh == []
|
||||
assert curve.value(5000.0) == 0.0
|
||||
|
||||
|
||||
def test_trailing_window_takes_the_end_of_the_horizon():
|
||||
values = np.arange(10, dtype=float)
|
||||
|
||||
assert list(trailing_window(values, end_slot=8, window_slots=3)) == [5.0, 6.0, 7.0]
|
||||
# A window longer than the horizon yields what there is.
|
||||
assert list(trailing_window(values, end_slot=2, window_slots=5)) == [0.0, 1.0]
|
||||
assert list(trailing_window(None, end_slot=8, window_slots=3)) == []
|
||||
Reference in New Issue
Block a user