mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-03-09 08:06:17 +00:00
Adds inverter AC/DC efficiency and break-even penalty (#888)
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
Some checks failed
Bump Version / Bump Version Workflow (push) Has been cancelled
docker-build / platform-excludes (push) Has been cancelled
docker-build / build (push) Has been cancelled
docker-build / merge (push) Has been cancelled
pre-commit / pre-commit (push) Has been cancelled
Run Pytest on Pull Request / test (push) Has been cancelled
* feat: add inverter AC/DC efficiency and break-even penalty * test: update tests/test_geneticoptimize.py with new ac_charge_break_even parameter * docs: update documentation * chore: update version numbers in configuration files to v0.2.0.dev2602272006923535
This commit is contained in:
committed by
GitHub
parent
04420e66ab
commit
3ccc25d731
@@ -193,6 +193,44 @@ class InverterCommonSettings(DevicesBaseSettings):
|
||||
},
|
||||
)
|
||||
|
||||
ac_to_dc_efficiency: float = Field(
|
||||
default=1.0,
|
||||
ge=0,
|
||||
le=1,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). "
|
||||
"Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss)."
|
||||
),
|
||||
"examples": [0.95, 1.0, 0.0],
|
||||
},
|
||||
)
|
||||
|
||||
dc_to_ac_efficiency: float = Field(
|
||||
default=1.0,
|
||||
gt=0,
|
||||
le=1,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). "
|
||||
"Default 1.0 (no additional inverter loss)."
|
||||
),
|
||||
"examples": [0.95, 1.0],
|
||||
},
|
||||
)
|
||||
|
||||
max_ac_charge_power_w: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Maximum AC charging power in watts. "
|
||||
"null means no additional limit. Set to 0 to disable AC charging."
|
||||
),
|
||||
"examples": [None, 0, 5000],
|
||||
},
|
||||
)
|
||||
|
||||
@computed_field # type: ignore[prop-decorator]
|
||||
@property
|
||||
def measurement_keys(self) -> Optional[list[str]]:
|
||||
|
||||
@@ -26,6 +26,9 @@ class Inverter:
|
||||
self.max_power_wh = (
|
||||
self.parameters.max_power_wh
|
||||
) # Maximum power that the inverter can handle
|
||||
self.dc_to_ac_efficiency = self.parameters.dc_to_ac_efficiency
|
||||
self.ac_to_dc_efficiency = self.parameters.ac_to_dc_efficiency
|
||||
self.max_ac_charge_power_w = self.parameters.max_ac_charge_power_w
|
||||
|
||||
def process_energy(
|
||||
self, generation: float, consumption: float, hour: int
|
||||
@@ -35,6 +38,9 @@ class Inverter:
|
||||
grid_import = 0.0
|
||||
self_consumption = 0.0
|
||||
|
||||
# Cache inverter DC→AC efficiency for discharge path
|
||||
dc_to_ac_eff = self.dc_to_ac_efficiency
|
||||
|
||||
if generation >= consumption:
|
||||
if consumption > self.max_power_wh:
|
||||
# If consumption exceeds maximum inverter power
|
||||
@@ -56,21 +62,29 @@ class Inverter:
|
||||
if remaining_load_evq > 0:
|
||||
# Akku muss den Restverbrauch decken
|
||||
if self.battery:
|
||||
from_battery, discharge_losses = self.battery.discharge_energy(
|
||||
remaining_load_evq, hour
|
||||
# Request more DC from battery to account for DC→AC conversion loss
|
||||
dc_request = remaining_load_evq / dc_to_ac_eff
|
||||
from_battery_dc, discharge_losses = self.battery.discharge_energy(
|
||||
dc_request, hour
|
||||
)
|
||||
remaining_load_evq -= from_battery # Restverbrauch nach Akkuentladung
|
||||
losses += discharge_losses
|
||||
# Convert DC output to AC
|
||||
from_battery_ac = from_battery_dc * dc_to_ac_eff
|
||||
inverter_discharge_losses = from_battery_dc - from_battery_ac
|
||||
remaining_load_evq -= from_battery_ac
|
||||
losses += discharge_losses + inverter_discharge_losses
|
||||
else:
|
||||
from_battery_ac = 0.0
|
||||
|
||||
# Wenn der Akku den Restverbrauch nicht vollständig decken kann, wird der Rest ins Netz gezogen
|
||||
if remaining_load_evq > 0:
|
||||
grid_import += remaining_load_evq
|
||||
remaining_load_evq = 0
|
||||
else:
|
||||
from_battery = 0.0
|
||||
from_battery_ac = 0.0
|
||||
|
||||
if remaining_power > 0:
|
||||
# Load battery with excess energy
|
||||
# Load battery with excess energy (DC path, no inverter conversion needed)
|
||||
charge_losses = 0.0
|
||||
if self.battery:
|
||||
charged_energie, charge_losses = self.battery.charge_energy(
|
||||
remaining_power, hour
|
||||
@@ -88,7 +102,7 @@ class Inverter:
|
||||
|
||||
losses += charge_losses
|
||||
self_consumption = (
|
||||
consumption + from_battery
|
||||
consumption + from_battery_ac
|
||||
) # Self-consumption is equal to the load
|
||||
|
||||
else:
|
||||
@@ -98,15 +112,21 @@ class Inverter:
|
||||
|
||||
# Discharge battery to cover shortfall, if possible
|
||||
if self.battery:
|
||||
battery_discharge, discharge_losses = self.battery.discharge_energy(
|
||||
min(shortfall, available_ac_power), hour
|
||||
# Need shortfall in AC, request more DC from battery for DC→AC conversion
|
||||
ac_needed = min(shortfall, available_ac_power)
|
||||
dc_request = ac_needed / dc_to_ac_eff
|
||||
battery_discharge_dc, discharge_losses = self.battery.discharge_energy(
|
||||
dc_request, hour
|
||||
)
|
||||
losses += discharge_losses
|
||||
# Convert DC output to AC
|
||||
battery_discharge_ac = battery_discharge_dc * dc_to_ac_eff
|
||||
inverter_discharge_losses = battery_discharge_dc - battery_discharge_ac
|
||||
losses += discharge_losses + inverter_discharge_losses
|
||||
else:
|
||||
battery_discharge = 0
|
||||
battery_discharge_ac = 0
|
||||
|
||||
# Draw remaining required power from the grid (discharge_losses are already substraved in the battery)
|
||||
grid_import = shortfall - battery_discharge
|
||||
self_consumption = generation + battery_discharge
|
||||
# Draw remaining required power from the grid (discharge_losses are already subtracted in the battery)
|
||||
grid_import = shortfall - battery_discharge_ac
|
||||
self_consumption = generation + battery_discharge_ac
|
||||
|
||||
return grid_export, grid_import, losses, self_consumption
|
||||
|
||||
@@ -243,11 +243,30 @@ class GeneticSimulation(PydanticBaseModel):
|
||||
soc_per_hour = np.full((total_hours), np.nan)
|
||||
|
||||
soc_per_hour[0] = battery_fast.current_soc_percentage()
|
||||
|
||||
# Determine AC charging availability from inverter parameters
|
||||
if inverter_fast:
|
||||
ac_to_dc_eff_fast = inverter_fast.ac_to_dc_efficiency
|
||||
dc_to_ac_eff_fast = inverter_fast.dc_to_ac_efficiency
|
||||
max_ac_charge_w_fast = inverter_fast.max_ac_charge_power_w
|
||||
else:
|
||||
ac_to_dc_eff_fast = 1.0
|
||||
dc_to_ac_eff_fast = 1.0
|
||||
max_ac_charge_w_fast = None
|
||||
|
||||
ac_charging_possible = ac_to_dc_eff_fast > 0 and (
|
||||
max_ac_charge_w_fast is None or max_ac_charge_w_fast > 0
|
||||
)
|
||||
|
||||
# If AC charging is disabled via inverter, zero out AC charge hours
|
||||
if not ac_charging_possible:
|
||||
ac_charge_hours_fast = np.zeros_like(ac_charge_hours_fast)
|
||||
|
||||
# Fill the charge array of the battery
|
||||
dc_charge_hours_fast[0:start_hour] = 0
|
||||
dc_charge_hours_fast[end_hour:] = 0
|
||||
ac_charge_hours_fast[0:start_hour] = 0
|
||||
dc_charge_hours_fast[end_hour:] = 0
|
||||
ac_charge_hours_fast[end_hour:] = 0
|
||||
battery_fast.charge_array = np.where(
|
||||
ac_charge_hours_fast != 0, ac_charge_hours_fast, dc_charge_hours_fast
|
||||
)
|
||||
@@ -258,6 +277,10 @@ class GeneticSimulation(PydanticBaseModel):
|
||||
else:
|
||||
# Default return if no battery is available
|
||||
soc_per_hour = np.full((total_hours), 0)
|
||||
ac_to_dc_eff_fast = 1.0
|
||||
dc_to_ac_eff_fast = 1.0
|
||||
max_ac_charge_w_fast = None
|
||||
ac_charging_possible = False
|
||||
|
||||
if ev_fast:
|
||||
# Pre-allocate arrays for the results, optimized for speed
|
||||
@@ -330,15 +353,37 @@ class GeneticSimulation(PydanticBaseModel):
|
||||
if battery_fast:
|
||||
soc_per_hour[hour_idx] = battery_fast.current_soc_percentage() # save begin state
|
||||
hour_ac_charge = ac_charge_hours_fast[hour]
|
||||
if hour_ac_charge > 0.0:
|
||||
battery_charged_energy_actual, battery_losses_actual = (
|
||||
battery_fast.charge_energy(None, hour, charge_factor=hour_ac_charge)
|
||||
)
|
||||
if hour_ac_charge > 0.0 and ac_charging_possible:
|
||||
# Cap charge factor by max_ac_charge_power_w if set
|
||||
effective_charge_factor = hour_ac_charge
|
||||
if max_ac_charge_w_fast is not None and battery_fast.max_charge_power_w > 0:
|
||||
# DC power = max_charge_power_w * factor
|
||||
# AC power = DC power / ac_to_dc_eff
|
||||
# AC power must be <= max_ac_charge_power_w
|
||||
max_dc_factor = (
|
||||
max_ac_charge_w_fast * ac_to_dc_eff_fast
|
||||
) / battery_fast.max_charge_power_w
|
||||
effective_charge_factor = min(effective_charge_factor, max_dc_factor)
|
||||
|
||||
total_battery_energy = battery_charged_energy_actual + battery_losses_actual
|
||||
consumption += total_battery_energy
|
||||
energy_consumption_grid_actual += total_battery_energy
|
||||
losses_wh_per_hour[hour_idx] += battery_losses_actual
|
||||
if effective_charge_factor > 0:
|
||||
battery_charged_energy_actual, battery_losses_actual = (
|
||||
battery_fast.charge_energy(
|
||||
None, hour, charge_factor=effective_charge_factor
|
||||
)
|
||||
)
|
||||
|
||||
# DC energy entering the battery (before battery internal efficiency)
|
||||
dc_energy = battery_charged_energy_actual + battery_losses_actual
|
||||
# AC energy consumed from grid (accounts for AC→DC conversion loss)
|
||||
ac_energy = dc_energy / ac_to_dc_eff_fast
|
||||
# Inverter AC→DC conversion losses
|
||||
inverter_charge_losses = ac_energy - dc_energy
|
||||
|
||||
consumption += ac_energy
|
||||
energy_consumption_grid_actual += ac_energy
|
||||
losses_wh_per_hour[hour_idx] += (
|
||||
battery_losses_actual + inverter_charge_losses
|
||||
)
|
||||
|
||||
# Update hourly arrays
|
||||
feedin_energy_per_hour[hour_idx] = energy_feedin_grid_actual
|
||||
@@ -814,12 +859,110 @@ class GeneticOptimization(OptimizationBase):
|
||||
|
||||
# Adjust total balance with battery value and penalties for unmet SOC
|
||||
if self.simulation.battery:
|
||||
restwert_akku = (
|
||||
self.simulation.battery.current_energy_content()
|
||||
* parameters.ems.preis_euro_pro_wh_akku
|
||||
)
|
||||
battery_energy_content = self.simulation.battery.current_energy_content()
|
||||
# Apply DC→AC inverter efficiency to residual battery value
|
||||
# (stored DC energy must pass through inverter to be usable as AC)
|
||||
if self.simulation.inverter:
|
||||
battery_energy_content *= self.simulation.inverter.dc_to_ac_efficiency
|
||||
restwert_akku = battery_energy_content * parameters.ems.preis_euro_pro_wh_akku
|
||||
gesamtbilanz += -restwert_akku
|
||||
|
||||
# --- AC charging break-even penalty ---
|
||||
# Penalise AC charging decisions that cannot be economically justified given the
|
||||
# round-trip losses (AC→DC charge conversion, battery internal, DC→AC discharge
|
||||
# conversion) and the best available future electricity prices.
|
||||
#
|
||||
# Key insight: energy already stored in the battery (from PV, zero grid cost) covers
|
||||
# the most expensive future hours first. AC charging from the grid only makes sense
|
||||
# for the hours that remain uncovered, and only when the discharge price exceeds
|
||||
# P_charge / η_round_trip.
|
||||
#
|
||||
# This penalty does not double-count the simulation result – it amplifies the "bad
|
||||
# decision" signal so that the genetic algorithm converges faster away from
|
||||
# unprofitable charging regions.
|
||||
if (
|
||||
self.simulation.battery
|
||||
and self.simulation.inverter
|
||||
and self.simulation.ac_charge_hours is not None
|
||||
and self.simulation.elect_price_hourly is not None
|
||||
and self.simulation.load_energy_array is not None
|
||||
):
|
||||
inv = self.simulation.inverter
|
||||
bat = self.simulation.battery
|
||||
|
||||
# Full round-trip efficiency: 1 Wh drawn from grid → η Wh delivered to AC load
|
||||
round_trip_eff = (
|
||||
inv.ac_to_dc_efficiency
|
||||
* bat.charging_efficiency
|
||||
* bat.discharging_efficiency
|
||||
* inv.dc_to_ac_efficiency
|
||||
)
|
||||
|
||||
if round_trip_eff > 0:
|
||||
ac_charge_arr = self.simulation.ac_charge_hours
|
||||
prices_arr = self.simulation.elect_price_hourly
|
||||
load_arr = self.simulation.load_energy_array
|
||||
n = len(prices_arr)
|
||||
|
||||
# Usable AC energy already in battery from prior PV charging (zero grid cost).
|
||||
# This covers the most expensive future hours first, pushing AC charging demand
|
||||
# to cheaper hours where the break-even hurdle may not be met.
|
||||
initial_soc_wh = (bat.initial_soc_percentage / 100.0) * bat.capacity_wh
|
||||
free_ac_wh = (
|
||||
max(0.0, initial_soc_wh - bat.min_soc_wh)
|
||||
* bat.discharging_efficiency
|
||||
* inv.dc_to_ac_efficiency
|
||||
)
|
||||
|
||||
# Configurable penalty multiplier (default 1 = economic loss in €)
|
||||
try:
|
||||
ac_penalty_factor = float(
|
||||
self.config.optimization.genetic.penalties["ac_charge_break_even"]
|
||||
)
|
||||
except Exception:
|
||||
ac_penalty_factor = 1.0
|
||||
|
||||
for hour in range(start_hour, min(len(ac_charge_arr), n)):
|
||||
ac_factor = ac_charge_arr[hour]
|
||||
if ac_factor <= 0.0:
|
||||
continue
|
||||
|
||||
charge_price = prices_arr[hour]
|
||||
if charge_price <= 0:
|
||||
continue
|
||||
|
||||
# Price that a future discharge hour must reach to break even
|
||||
break_even_price = charge_price / round_trip_eff
|
||||
|
||||
# Build list of (price, load_wh) for all future hours in the horizon
|
||||
future = [
|
||||
(float(prices_arr[h]), float(load_arr[h])) for h in range(hour + 1, n)
|
||||
]
|
||||
# Sort descending by price so we "use" the most expensive hours first
|
||||
future.sort(key=lambda x: -x[0])
|
||||
|
||||
# Consume free PV energy against the highest-price future hours.
|
||||
# The first uncovered (partially or fully) hour defines the best
|
||||
# price still available for the new AC charge.
|
||||
remaining_free = free_ac_wh
|
||||
best_uncovered_price = 0.0
|
||||
for fp, fl in future:
|
||||
if remaining_free >= fl:
|
||||
# Entire expensive hour is already covered by free PV energy
|
||||
remaining_free -= fl
|
||||
else:
|
||||
# First hour not (fully) covered: this is where new charge goes
|
||||
best_uncovered_price = fp
|
||||
break
|
||||
|
||||
if best_uncovered_price < break_even_price:
|
||||
# AC charging at this hour is economically unjustified.
|
||||
# Penalty = excess cost per Wh × DC energy requested this hour.
|
||||
dc_wh = bat.max_charge_power_w * ac_factor
|
||||
ac_wh = dc_wh / max(inv.ac_to_dc_efficiency, 1e-9)
|
||||
excess_cost_per_wh = break_even_price - best_uncovered_price
|
||||
gesamtbilanz += ac_wh * excess_cost_per_wh * ac_penalty_factor
|
||||
|
||||
if self.optimize_ev and parameters.eauto and self.simulation.ev:
|
||||
try:
|
||||
penalty = self.config.optimization.genetic.penalties["ev_soc_miss"]
|
||||
|
||||
@@ -157,3 +157,40 @@ class InverterParameters(DeviceParameters):
|
||||
default=None,
|
||||
json_schema_extra={"description": "ID of battery", "examples": [None, "battery1"]},
|
||||
)
|
||||
ac_to_dc_efficiency: float = Field(
|
||||
default=1.0,
|
||||
ge=0,
|
||||
le=1,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Efficiency of AC to DC conversion (for AC/grid charging of battery). "
|
||||
"Set to 0 to disable AC charging via inverter. "
|
||||
"Default 1.0 for backward compatibility (no additional inverter loss)."
|
||||
),
|
||||
"examples": [0.95, 1.0, 0.0],
|
||||
},
|
||||
)
|
||||
dc_to_ac_efficiency: float = Field(
|
||||
default=1.0,
|
||||
gt=0,
|
||||
le=1,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Efficiency of DC to AC conversion (for battery discharging to AC load/grid). "
|
||||
"Default 1.0 for backward compatibility (no additional inverter loss)."
|
||||
),
|
||||
"examples": [0.95, 1.0],
|
||||
},
|
||||
)
|
||||
max_ac_charge_power_w: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Maximum AC charging power in watts. "
|
||||
"None means no additional limit (battery's own max_charge_power_w applies). "
|
||||
"Set to 0 to disable AC charging."
|
||||
),
|
||||
"examples": [None, 0, 5000],
|
||||
},
|
||||
)
|
||||
|
||||
@@ -224,6 +224,11 @@ class GeneticOptimizationParameters(
|
||||
if "ev_soc_miss" not in cls.config.optimization.genetic.penalties:
|
||||
logger.info("ev_soc_miss penalty function parameter unknown - defaulting to 10.")
|
||||
cls.config.optimization.genetic.penalties["ev_soc_miss"] = 10
|
||||
if "ac_charge_break_even" not in cls.config.optimization.genetic.penalties:
|
||||
# Default multiplier 1.0: penalty equals the exact economic loss in € from
|
||||
# charging at a price that cannot be recovered given the round-trip efficiency
|
||||
# and the best available future discharge price (after free PV energy is used).
|
||||
cls.config.optimization.genetic.penalties["ac_charge_break_even"] = 1.0
|
||||
|
||||
# Get start solution from last run
|
||||
start_solution = None
|
||||
@@ -548,6 +553,9 @@ class GeneticOptimizationParameters(
|
||||
device_id=inverter_config.device_id,
|
||||
max_power_wh=inverter_config.max_power_w,
|
||||
battery_id=inverter_config.battery_id,
|
||||
ac_to_dc_efficiency=inverter_config.ac_to_dc_efficiency,
|
||||
dc_to_ac_efficiency=inverter_config.dc_to_ac_efficiency,
|
||||
max_ac_charge_power_w=inverter_config.max_ac_charge_power_w,
|
||||
)
|
||||
except:
|
||||
logger.info(
|
||||
|
||||
Reference in New Issue
Block a user