feat(devices): add battery profiles

A battery profile describes a battery model: the charge and discharge limit
maps over temperature and SoC, the charge and discharge efficiency curves
over C-rate, the technical SoC bounds, the hard temperature cutoffs and,
for a model, its nominal capacity and power limits. EOS packages the
generic profile generic-lfp; users add their own profiles.

A battery or electric vehicle selects its profile by battery_profile and
describes how the installation differs from it in profile_adjustment
(capacity, power limits of the installation, periodic full charge). The
effective values are resolved from both at the measured operating point
(temperature, SoC, power) and shown as effective_* fields.

The GENETIC simulation uses the limits and efficiencies of the profile over
SoC, with precomputed tables as a fast path; GENETIC0 gets the values at the
operating point. Optimization parameters are always valid: a battery whose
profile is missing, unreadable or invalid uses the packaged generic-lfp
profile, and the former defaults only if that is not usable either; each is
logged as error, and an optimization run is never cancelled because of a
battery.

Additional changes
- Add a generic device profile store: user and packaged profiles, atomic
  saves, compiled profiles cached per file and checked again at most once a
  second, plain file names as profile names ('schema' is reserved). On
  Windows, a save or read that collides with the other is retried briefly.
- Add the REST API /v1/profile to list the profile kinds and to list, read
  (document and JSON schema), save and delete profiles.
- Check the hard temperature cutoffs on the exact temperature.
- Resolve inhibited charging or discharging to a power of 0, e.g. for a cold
  battery; give GENETIC0 its own discharge limit for that.
- Limit generic profiles without own power limits to the former 5000 W.
- Accept only the implemented units; current limits need the nominal
  voltage.
- Migrate legacy battery settings into the profile adjustment, keep the
  former charge power limit as discharge limit (also for a Home Assistant
  entity on it) and report dropped efficiencies as errors.
- Limit GENETIC battery discharge by the discharge power.
- Allow battery profile lookups while the configuration is created.
- Resolve a battery once per configuration dump.
- Package the bundled profiles, document battery profiles and regenerate
  the configuration and OpenAPI documentation.
- Ignore the report PDFs written by the visualization tests.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Assisted-by: Claude Code (claude-opus-5-5)
This commit is contained in:
Bobby Noelte
2026-11-11 22:08:18 +01:00
co-authored by Claude Opus 5.5
parent dd41e5d57c
commit e255f987bf
56 changed files with 11381 additions and 619 deletions
+2 -2
View File
@@ -39,12 +39,12 @@ class HomeAssistantAdapterCommonSettings(SettingsBaseModel):
"description": (
"Mapping of EOS config keys to Home Assistant entity IDs.\n"
"The config key has to be given by a ‘/’-separated path\n"
"e.g. devices/batteries/battery1/capacity_wh\n"
"e.g. devices/batteries/battery1/profile_adjustment/nominal_capacity_wh\n"
"Devices are addressed by their device_id. The setting must exist."
),
"examples": [
{
"devices/batteries/battery1/capacity_wh": "sensor.battery1_capacity",
"devices/batteries/battery1/profile_adjustment/nominal_capacity_wh": "sensor.battery1_capacity",
}
],
},
+33 -1
View File
@@ -469,6 +469,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
# A ClassVar: without the annotation pydantic makes it a private attribute,
# and the class attribute would be a (truthy) descriptor object.
_force_documentation_mode: ClassVar[bool] = False
# Battery profile problems logged last, see _log_battery_profile_problems().
_battery_profile_problems: ClassVar[tuple[tuple[str, ...], tuple[str, ...]]] = ((), ())
def __hash__(self) -> int:
# ConfigEOS is a singleton
@@ -806,7 +808,33 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
SettingsEOSDefaults.__init__(self, *args, **kwargs)
self._initialized = True
logger.trace(f"Config setup:\n{self}")
# Lazy: rendering the configuration evaluates all computed fields, e.g.
# the battery profile lookups. Only do it if trace logging is enabled.
logger.opt(lazy=True).trace("Config setup:\n{config}", config=lambda: str(self))
self._log_battery_profile_problems()
def _log_battery_profile_problems(self) -> None:
"""Log batteries that cannot be resolved from their battery profile.
A battery profile is checked once the configuration is set up, because
the user profile directory depends on ``general.data_folder_path``.
Problems are logged when they change, not on every settings update.
"""
if self.documentation_mode():
return
try:
errors, warnings = self.devices.check_battery_profiles()
except Exception:
logger.exception("Battery profile check failed.")
return
problems = (tuple(errors), tuple(warnings))
if problems == ConfigEOS._battery_profile_problems:
return
ConfigEOS._battery_profile_problems = problems
for message in errors:
logger.error(message)
for message in warnings:
logger.warning(message)
def merge_settings(self, settings: SettingsEOS) -> None:
"""Merges the provided settings into the global settings for EOS, with optional overwrite.
@@ -893,6 +921,10 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
node[keys[-1]] = setting
ConfigEOS._runtime_settings = deep_merge(ConfigEOS._runtime_settings, runtime_setting)
if keys and keys[0] in ("devices", "general"):
# Device settings and the data folder (user profiles) affect the batteries.
self._log_battery_profile_problems()
def reset_settings(self) -> None:
"""Reset all changed settings to environment/config file defaults.
+162 -2
View File
@@ -38,12 +38,17 @@ _KEEP_DEFAULT = object()
def _list_to_device_dict(
prefix: str,
migrate_item: Callable[[Dict[str, Any]], None] | None = None,
) -> Callable[[Any], Any]:
"""Return a transform that converts a list of device dicts to a keyed dict.
Each item must be a dict. The key is taken from the item's own
``device_id`` field when present; otherwise a key is synthesised
from *prefix* + the zero-based index (e.g. ``"bat0"``, ``"bat1"``).
A dict that is already keyed by device id is accepted as well. If given,
*migrate_item* is called with a copy of every item (after the device id was
set) and migrates it in place, e.g. to rename or drop device specific fields.
"""
def _transform(value: Any) -> Any:
@@ -67,12 +72,160 @@ def _list_to_device_dict(
item.setdefault(
"levelized_cost_of_storage_amt_kwh", item.pop("levelized_cost_of_storage_kwh")
)
if migrate_item is not None:
migrate_item(item)
result[key] = item
return result
return _transform
# Fields of BatteriesCommonSettings that used to be configuration fields and are
# computed from the battery profile and the measurements now. Old values are
# dropped with an error log.
_BATTERY_FIELDS_NOW_COMPUTED = (
"charging_efficiency",
"discharging_efficiency",
)
# Former battery fields that describe how the unit differs from its battery
# profile and move into ``profile_adjustment``: (legacy name, adjustment field).
# The first two are fields of the settings before battery profiles, the others
# of the interim settings with battery profiles but without profile adjustment.
_BATTERY_FIELDS_TO_ADJUSTMENT = (
("capacity_wh", "nominal_capacity_wh"),
# max_charge_power_w was the configured charge power limit of the installation.
("max_charge_power_w", "max_charge_power_w"),
("nominal_capacity_wh", "nominal_capacity_wh"),
("max_charge_power_limit_w", "max_charge_power_w"),
("max_discharge_power_limit_w", "max_discharge_power_w"),
# Periodic full charge settings before battery profiles.
("full_charge_interval_days", "full_charge_interval_days"),
("full_charge_detect_percentage", "full_charge_detect_percentage"),
)
# Former battery fields whose value the battery profile provides now.
# cell_chemistry was informational only; the profile has the chemistry.
_BATTERY_FIELDS_NOW_IN_PROFILE = ("cell_chemistry",)
# Keys that only battery settings with battery profiles have.
_BATTERY_PROFILE_KEYS = (
"battery_profile",
"profile_adjustment",
"nominal_capacity_wh",
"max_charge_power_limit_w",
"max_discharge_power_limit_w",
)
# Defaults of the battery settings before battery profiles. Configuration files
# leave out default values, so a battery that relied on them gets them written
# into its profile adjustment and keeps its capacity and charge power limit.
_BATTERY_PRE_PROFILE_DEFAULTS = (
("nominal_capacity_wh", 8000),
("max_charge_power_w", 5000.0),
)
def _migrate_battery_item(item: Dict[str, Any]) -> None:
"""Migrate one battery or electric vehicle settings dict in place.
- The capacity and the charge / discharge power limits of the installation
move into ``profile_adjustment``: ``capacity_wh`` and ``nominal_capacity_wh``
become ``nominal_capacity_wh``, ``max_charge_power_w`` and
``max_charge_power_limit_w`` become ``max_charge_power_w``,
``max_discharge_power_limit_w`` becomes ``max_discharge_power_w``.
- The periodic full charge interval and detection threshold move into
``profile_adjustment`` under their names.
- A value already present in ``profile_adjustment`` wins over a legacy one.
- A battery without any battery profile key is from before battery
profiles; it gets the former defaults (8000 Wh, 5000 W) for the values it
left out. Its charge power limit also limited discharging, so it becomes
the discharge power limit too.
- Fields that are computed from the battery profile now, or that the
profile provides (``cell_chemistry``), are dropped. A dropped efficiency
is logged as error, as the profile efficiency applies instead.
Configuration files are written without computed fields, so
``max_charge_power_w`` at the top level of a battery in a file is always the
legacy configuration value.
"""
before_profiles = not any(key in item for key in _BATTERY_PROFILE_KEYS)
raw_adjustment = item.get("profile_adjustment")
adjustment = dict(raw_adjustment) if isinstance(raw_adjustment, dict) else {}
for legacy_name, new_name in _BATTERY_FIELDS_TO_ADJUSTMENT:
if legacy_name in item:
value = item.pop(legacy_name)
if value is not None:
adjustment.setdefault(new_name, value)
if before_profiles:
for name, default in _BATTERY_PRE_PROFILE_DEFAULTS:
adjustment.setdefault(name, default)
# Before battery profiles the charge power limit also capped discharging.
adjustment.setdefault("max_discharge_power_w", adjustment["max_charge_power_w"])
for name in _BATTERY_FIELDS_NOW_COMPUTED:
value = item.pop(name, None)
if value is not None:
logger.error(
"Battery '{}': '{}' {} is no longer a setting; the efficiency of the "
"battery profile applies. Adapt the battery profile if needed.",
item.get("device_id"),
name,
value,
)
for name in _BATTERY_FIELDS_NOW_IN_PROFILE:
item.pop(name, None)
if adjustment:
item["profile_adjustment"] = adjustment
def _migrate_config_entity_ids(value: Any) -> Any:
"""Rewrite Home Assistant config paths of battery fields moved into the profile adjustment.
E.g. ``devices/batteries/bat0/capacity_wh`` becomes
``devices/batteries/bat0/profile_adjustment/nominal_capacity_wh``. Paths of
fields that are computed now are dropped, because they cannot be set.
"""
if not isinstance(value, dict):
return value
moved = dict(_BATTERY_FIELDS_TO_ADJUSTMENT)
result: Dict[str, Any] = {}
for path, entity_id in value.items():
parts = path.strip("/").split("/") if isinstance(path, str) else []
if (
len(parts) == 4
and parts[0] == "devices"
and parts[1] in ("batteries", "electric_vehicles")
):
if parts[3] in moved:
path = "/".join([*parts[:3], "profile_adjustment", moved[parts[3]]])
elif parts[3] in _BATTERY_FIELDS_NOW_COMPUTED:
logger.warning(
f"Dropping Home Assistant config entity '{entity_id}' for '{path}': "
"the value is computed from the battery profile now."
)
continue
elif parts[3] in _BATTERY_FIELDS_NOW_IN_PROFILE:
logger.warning(
f"Dropping Home Assistant config entity '{entity_id}' for '{path}': "
"the value comes from the battery profile now."
)
continue
result[path] = entity_id
# Before battery profiles the charge power limit also capped discharging,
# like the migrated profile adjustment does (see _migrate_battery_item).
for path, entity_id in value.items():
parts = path.strip("/").split("/") if isinstance(path, str) else []
if (
len(parts) == 4
and parts[0] == "devices"
and parts[1] in ("batteries", "electric_vehicles")
and parts[3] == "max_charge_power_w"
):
discharge_path = "/".join([*parts[:3], "profile_adjustment", "max_discharge_power_w"])
result.setdefault(discharge_path, entity_id)
return result
# -----------------------------
# Global migration map constant
# -----------------------------
@@ -98,6 +251,10 @@ MIGRATION_MAP: Dict[
"adapter/homeassistant/solution_entity_ids",
lambda v: v if isinstance(v, list) else None,
),
"adapter/homeassistant/config_entity_ids": (
"adapter/homeassistant/config_entity_ids",
_migrate_config_entity_ids,
),
# - NodeRed
# devices
# =======
@@ -105,15 +262,18 @@ MIGRATION_MAP: Dict[
# These must come *before* any sub-path entries that reference the old list indices,
# so the whole collection is moved first; the sub-path None-drops clean up leftovers.
# - batteries
# capacity and installation power limits -> profile_adjustment,
# fields that are computed now are dropped
"devices/batteries": (
"devices/batteries",
_list_to_device_dict("bat"),
_list_to_device_dict("bat", _migrate_battery_item),
),
"devices/batteries/0/initial_soc_percentage": None,
# - electric_vehicles
# same item migration as for batteries
"devices/electric_vehicles": (
"devices/electric_vehicles",
_list_to_device_dict("ev"),
_list_to_device_dict("ev", _migrate_battery_item),
),
"devices/electric_vehicles/0/initial_soc_percentage": None,
# - inverters
+22 -3
View File
@@ -35,6 +35,9 @@ _measurement_eos: Optional[Measurement] = None
_prediction_eos: Optional[Prediction] = None
_resource_registry_eos: Optional[ResourceRegistry] = None
# True while get_config() creates the configuration, see get_config().
_config_eos_setup_running: bool = False
def get_adapter(init: bool = False) -> Adapter:
"""Retrieve the singleton EOS Adapter instance.
@@ -164,10 +167,14 @@ def get_config(init: Union[bool, dict[str, bool]] = False) -> ConfigEOS:
Returns:
ConfigEOS: The global EOS configuration singleton instance.
While the configuration is being created, its settings are loaded before
``ConfigEOS()`` returns. Code that runs during that time, such as a computed
field that resolves a battery profile, gets the configuration being created.
Raises:
RuntimeError:
If the configuration has not been initialized and ``init`` is
``False``.
``False``, or its settings are not loaded yet.
Usage:
.. code-block:: python
@@ -187,17 +194,29 @@ def get_config(init: Union[bool, dict[str, bool]] = False) -> ConfigEOS:
# Access existing configuration
host = get_config().server.host
"""
global _config_eos
global _config_eos, _config_eos_setup_running
if _config_eos is None:
from akkudoktoreos.config.config import ConfigEOS
if _config_eos_setup_running:
# Called while ConfigEOS() below is running. The instance is
# registered already and usable once its settings are loaded.
config_in_setup = ConfigEOS._instances.get(ConfigEOS)
if config_in_setup is not None and getattr(config_in_setup, "_initialized", False):
return config_in_setup
raise RuntimeError("Config access before its settings are loaded.")
if not init and not ConfigEOS.documentation_mode():
raise RuntimeError("Config access before init.")
if isinstance(init, dict):
ConfigEOS._init_config_eos = init
_config_eos = ConfigEOS()
_config_eos_setup_running = True
try:
_config_eos = ConfigEOS()
finally:
_config_eos_setup_running = False
return _config_eos
@@ -0,0 +1,88 @@
{
"schema_version": "1.0",
"battery_model": "Generic LFP home battery",
"chemistry": "LFP",
"nominal_voltage_v": null,
"technical_min_soc_percent": 0.0,
"technical_max_soc_percent": 100.0,
"units": {
"temperature": "degC",
"soc": "percent",
"limit": "C-rate"
},
"interpolation": "bilinear",
"out_of_range_behavior": "clamp_to_nearest",
"axes": {
"temperature_degC": [
-10.0, -7.5, -5.0, -2.5,
0.0, 2.5, 5.0, 7.5, 10.0, 12.5, 15.0,
20.0, 25.0, 35.0, 48.0, 55.0, 60.0
],
"soc_percent": [0, 40, 80, 100]
},
"charge_limit_crate": {
"description": "Max charge C-rate; rows = temperature, cols = SOC. Rows below 0 degC are zero because the datasheet curve ends at 0 degC with C-rate 0 and the charge hard cutoff is at 0 degC. Above 80 % SOC the limit tapers linearly to the value of the 100 % SOC column.",
"values": [
[0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 0.0],
[0.0, 0.0, 0.0, 0.0],
[0.1, 0.1, 0.1, 0.1],
[0.1, 0.1, 0.1, 0.1],
[0.38, 0.38, 0.38, 0.38],
[0.38, 0.38, 0.38, 0.38],
[0.5, 0.5, 0.5, 0.5],
[0.5, 0.5, 0.5, 0.5],
[1.0, 1.0, 1.0, 0.75],
[1.0, 1.0, 1.0, 0.8],
[1.0, 1.0, 1.0, 0.8],
[0.8, 0.8, 0.8, 0.8],
[0.27, 0.27, 0.27, 0.27],
[0.0, 0.0, 0.0, 0.0]
]
},
"discharge_limit_crate": {
"description": "Generic continuous discharge C-rate for an LFP home battery. 0.5C in the normal range, derated near the low and high temperature ends. Peak discharge (typically around 1C) is deliberately not represented; only the continuous rating is stored, which is the safe value for controller-side power limiting.",
"values": [
[0.0, 0.0, 0.0, 0.0],
[0.1, 0.1, 0.1, 0.1],
[0.2, 0.2, 0.2, 0.2],
[0.3, 0.3, 0.3, 0.3],
[0.3, 0.3, 0.3, 0.3],
[0.3, 0.3, 0.3, 0.3],
[0.3, 0.3, 0.3, 0.3],
[0.5, 0.5, 0.5, 0.5],
[0.5, 0.5, 0.5, 0.5],
[0.5, 0.5, 0.5, 0.5],
[0.5, 0.5, 0.5, 0.5],
[0.5, 0.5, 0.5, 0.5],
[0.5, 0.5, 0.5, 0.5],
[0.5, 0.5, 0.5, 0.5],
[0.3, 0.3, 0.3, 0.3],
[0.2, 0.2, 0.2, 0.2],
[0.0, 0.0, 0.0, 0.0]
]
},
"charge_efficiency": {
"description": "Generic one-way DC charge efficiency of an LFP battery pack (cells and BMS) versus absolute C-rate. Losses grow with current (I^2 R), so efficiency falls slowly with C-rate. AC/DC conversion losses are not included; they belong to the inverter settings (ac_to_dc_efficiency).",
"c_rate": [0.05, 0.1, 0.25, 0.5, 0.75, 1.0],
"efficiency": [0.975, 0.975, 0.97, 0.96, 0.95, 0.94],
"interpolation": "linear",
"out_of_range_behavior": "clamp_to_nearest"
},
"discharge_efficiency": {
"description": "Generic one-way DC discharge efficiency of an LFP battery pack (cells and BMS) versus absolute C-rate. The curve ends at the 0.5C continuous discharge rating of this profile; higher C-rates are clamped to the last point. DC/AC conversion losses are not included; they belong to the inverter settings (dc_to_ac_efficiency).",
"c_rate": [0.05, 0.1, 0.25, 0.5],
"efficiency": [0.975, 0.975, 0.97, 0.96],
"interpolation": "linear",
"out_of_range_behavior": "clamp_to_nearest"
},
"hard_cutoffs": {
"charge_allowed_below_degC": 0.0,
"charge_allowed_above_degC": 60.0,
"discharge_allowed_below_degC": -10.0,
"discharge_allowed_above_degC": 60.0,
"description": "Charge is inhibited strictly below 0 degC and strictly above 60 degC. At exactly 0 degC and exactly 60 degC the charge map returns 0.0, so charging is effectively disabled there too. Discharge is inhibited strictly below -10 degC and strictly above 60 degC; the discharge map already reaches 0.0 at both ends, so the hard cutoff only governs whether the accessor returns None or 0.0."
}
}
+36 -2
View File
@@ -38,7 +38,15 @@ class DevicesCommonSettings(SettingsBaseModel):
default=None,
json_schema_extra={
"description": "Stationary battery storage devices, keyed by device_id.",
"examples": [{"bat0": {"device_id": "bat0", "capacity_wh": 8000, "ports": []}}],
"examples": [
{
"bat0": {
"device_id": "bat0",
"profile_adjustment": {"nominal_capacity_wh": 8000},
"ports": [],
}
}
],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
@@ -57,7 +65,15 @@ class DevicesCommonSettings(SettingsBaseModel):
default=None,
json_schema_extra={
"description": "Electric vehicle battery packs, keyed by device_id.",
"examples": [{"ev0": {"device_id": "ev0", "capacity_wh": 60000, "ports": []}}],
"examples": [
{
"ev0": {
"device_id": "ev0",
"profile_adjustment": {"nominal_capacity_wh": 60000},
"ports": [],
}
}
],
"x-scope": [str(ConfigScope.GENETIC), str(ConfigScope.GENETIC0)],
},
)
@@ -154,6 +170,24 @@ class DevicesCommonSettings(SettingsBaseModel):
keys.extend(device.measurement_keys or [])
return keys
def check_battery_profiles(self) -> tuple[list[str], list[str]]:
"""Check that every battery and electric vehicle can be resolved from its profile.
See ``BatteriesCommonSettings.check_profile``.
Returns:
``(errors, warnings)`` of all batteries and electric vehicles, each
message naming its device.
"""
errors: list[str] = []
warnings: list[str] = []
for device_dict in (self.batteries, self.electric_vehicles):
for device in (device_dict or {}).values():
device_errors, device_warnings = device.check_profile()
errors.extend(device_errors)
warnings.extend(device_warnings)
return errors, warnings
# Type used for indexing: (resource_id, optional actuator_id)
class ResourceKey(PydanticBaseModel):
+347 -24
View File
@@ -1,17 +1,35 @@
import math
from typing import Any, Iterator, Optional
import numpy as np
from pydantic import Field, field_validator, model_validator
from akkudoktoreos.devices.devicesabc import validate_efficiency_curve
from akkudoktoreos.devices.settings.batteryresolution import (
BATTERY_SOC_TABLE_POINTS as SOC_TABLE_POINTS,
)
from akkudoktoreos.devices.settings.batterysettings import BATTERY_DEFAULT_CHARGE_RATES
from akkudoktoreos.optimization.genetic.geneticdevices import DeviceParameters
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
# Resolution of the dense efficiency tables: one value per power percent.
_EFFICIENCY_TABLE_POINTS = 101
def _dense_efficiency_table(curve: Optional[list[tuple[float, float]]]) -> Optional[list[float]]:
"""Interpolate an efficiency curve at every power percent 0 % ... 100 %."""
if curve is None:
return None
fractions, efficiencies = zip(*curve)
grid = np.linspace(0.0, 1.0, _EFFICIENCY_TABLE_POINTS)
return [float(value) for value in np.interp(grid, fractions, efficiencies)]
def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None:
description = "Maximum charging power in watts."
return Field(default=5000, gt=0, json_schema_extra={"description": description})
# 0: the battery may not charge, e.g. the profile inhibits it at a low temperature.
return Field(default=5000, ge=0, json_schema_extra={"description": description})
def initial_soc_percentage_field(description: str) -> int:
@@ -54,6 +72,17 @@ class BaseBatteryParameters(DeviceParameters):
)
discharging_efficiency: float = discharging_efficiency_field(0.88)
max_charge_power_w: Optional[float] = max_charging_power_field()
max_discharge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum discharging power in watts. 0 means discharging is inhibited. "
"None uses max_charge_power_w."
),
"examples": [None, 4000],
},
)
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
)
@@ -93,6 +122,79 @@ class BaseBatteryParameters(DeviceParameters):
"examples": [[0.25, 0.5, 0.75, 1.0], [1.0], None],
},
)
charge_power_limit_w_by_soc: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": (
"Maximum charging power [W] per state of charge, e.g. from a battery "
f"profile. {SOC_TABLE_POINTS} values, index i is the limit at SoC i %. "
"Further limits max_charge_power_w. None means no SoC dependent limit."
),
"examples": [None],
},
)
discharge_power_limit_w_by_soc: Optional[list[float]] = Field(
default=None,
json_schema_extra={
"description": (
"Maximum discharging power [W] per state of charge, e.g. from a battery "
f"profile. {SOC_TABLE_POINTS} values, index i is the limit at SoC i %. "
"Further limits max_discharge_power_w. None means no SoC dependent limit."
),
"examples": [None],
},
)
charging_efficiency_curve: Optional[list[tuple[float, float]]] = Field(
default=None,
json_schema_extra={
"description": (
"Power dependent charging efficiency as a list of (power_fraction, "
"efficiency) points. The power fraction is the charging power relative "
"to max_charge_power_w [0.0 ... 1.0]; points must be strictly increasing "
"in power fraction, efficiencies in (0, 1]. Values are interpolated "
"linearly and clamped to the first/last point. When set, the curve "
"replaces charging_efficiency in the simulation. None means constant "
"charging_efficiency."
),
"examples": [None, [[0.0, 0.975], [0.5, 0.96], [1.0, 0.94]]],
},
)
discharging_efficiency_curve: Optional[list[tuple[float, float]]] = Field(
default=None,
json_schema_extra={
"description": (
"Power dependent discharging efficiency as a list of (power_fraction, "
"efficiency) points. The power fraction is the discharging power "
"relative to max_discharge_power_w [0.0 ... 1.0]; same rules as "
"charging_efficiency_curve. When set, the curve replaces "
"discharging_efficiency in the simulation. None means constant "
"discharging_efficiency."
),
"examples": [None, [[0.0, 0.975], [1.0, 0.96]]],
},
)
@field_validator("charge_power_limit_w_by_soc", "discharge_power_limit_w_by_soc")
@classmethod
def _validate_power_limit_by_soc(cls, value: Optional[list[float]]) -> Optional[list[float]]:
if value is None:
return None
if len(value) != SOC_TABLE_POINTS:
raise ValueError(
f"A power limit table needs {SOC_TABLE_POINTS} values (SoC 0 % ... 100 %), "
f"got {len(value)}."
)
for power_w in value:
if not math.isfinite(power_w) or power_w < 0:
raise ValueError(f"Power limits must be finite and >= 0 W, got {power_w}.")
return value
@field_validator("charging_efficiency_curve", "discharging_efficiency_curve")
@classmethod
def _validate_efficiency_curve(
cls, value: Optional[list[tuple[float, float]]]
) -> Optional[list[tuple[float, float]]]:
return validate_efficiency_curve(value)
class SolarPanelBatteryParameters(BaseBatteryParameters):
@@ -199,9 +301,106 @@ class ElectricVehicleParameters(BaseBatteryParameters):
return to_datetime(value)
class _PowerCurves:
"""Battery profile data of a simulated battery, prepared for fast access.
All values are precomputed for the slot duration of the battery, so the
simulation only scales and indexes per call. Every table has 101 entries
(index 0 ... 100); the hot paths rely on that bound.
Attributes:
charge_limit_wh_by_soc: Raw charge energy of one slot [Wh] per SoC
percent, capped at the rated charge power. None without limit table.
discharge_limit_wh_by_soc: Raw discharge energy of one slot [Wh] per
SoC percent, capped at the rated discharge power. None without table.
charging_efficiency_by_power: Charging efficiency per power percent of
the rated charge power. None without curve.
discharging_efficiency_by_power: Discharging efficiency per power
percent of the rated discharge power. None without curve.
soc_index_per_wh: Factor from stored energy [Wh] to SoC percent.
charge_index_per_wh: Factor from raw charge energy of one slot [Wh] to
power percent of the rated charge power.
discharge_index_per_wh: Factor from raw discharge energy of one slot
[Wh] to power percent of the rated discharge power.
discharged_dc_wh_per_slot: Energy delivered by the battery per slot [Wh].
charged_stored_wh_per_slot: Energy stored into the battery per slot [Wh].
"""
__slots__ = (
"charge_limit_wh_by_soc",
"discharge_limit_wh_by_soc",
"charging_efficiency_by_power",
"discharging_efficiency_by_power",
"soc_index_per_wh",
"charge_index_per_wh",
"discharge_index_per_wh",
"discharged_dc_wh_per_slot",
"charged_stored_wh_per_slot",
)
def __init__(
self,
parameters: "BaseBatteryParameters",
capacity_wh: float,
max_charge_power_w: float,
max_discharge_power_w: float,
slot_duration_h: float,
slots: int,
) -> None:
rated_charge_wh = max_charge_power_w * slot_duration_h
rated_discharge_wh = max_discharge_power_w * slot_duration_h
self.charge_limit_wh_by_soc: Optional[list[float]] = (
[min(limit_w * slot_duration_h, rated_charge_wh) for limit_w in limits]
if (limits := parameters.charge_power_limit_w_by_soc) is not None
else None
)
self.discharge_limit_wh_by_soc: Optional[list[float]] = (
[min(limit_w * slot_duration_h, rated_discharge_wh) for limit_w in limits]
if (limits := parameters.discharge_power_limit_w_by_soc) is not None
else None
)
self.charging_efficiency_by_power = _dense_efficiency_table(
parameters.charging_efficiency_curve
)
self.discharging_efficiency_by_power = _dense_efficiency_table(
parameters.discharging_efficiency_curve
)
self.soc_index_per_wh = 100.0 / capacity_wh
# A rated power of 0 W maps every energy to the 0 % entry.
self.charge_index_per_wh = 100.0 / rated_charge_wh if rated_charge_wh > 0 else 0.0
self.discharge_index_per_wh = 100.0 / rated_discharge_wh if rated_discharge_wh > 0 else 0.0
self.discharged_dc_wh_per_slot = [0.0] * slots
self.charged_stored_wh_per_slot = [0.0] * slots
def reset(self, slots: int) -> None:
"""Clear the delivered and stored energy of all slots."""
self.discharged_dc_wh_per_slot = [0.0] * slots
self.charged_stored_wh_per_slot = [0.0] * slots
def _table_entry(table: list[float], index: int) -> float:
"""Return a table entry, with the index clamped to the table."""
if index <= 0:
return table[0]
if index >= len(table):
return table[-1]
return table[index]
class Battery:
"""Represents a battery device with methods to simulate energy charging and discharging."""
# Battery profile data, set per instance only if the parameters provide it.
# A class default keeps plain batteries small: the simulation reads their
# attributes for every slot of every candidate plan, and CPython accesses
# attributes of instances with 30 or more of them noticeably slower.
_curves: Optional[_PowerCurves] = None
@property
def has_power_curves(self) -> bool:
"""Whether charging or discharging depends on SoC or power (battery profile)."""
return self._curves is not None
def __init__(
self,
parameters: BaseBatteryParameters,
@@ -251,6 +450,29 @@ class Battery:
self.max_charge_power_w = self.parameters.max_charge_power_w
else:
self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
if self.parameters.max_discharge_power_w is not None:
self.max_discharge_power_w = self.parameters.max_discharge_power_w
else:
self.max_discharge_power_w = self.max_charge_power_w
# Optional battery profile data: power limits per SoC percent and
# efficiencies per power percent.
parameters = self.parameters
if (
parameters.charge_power_limit_w_by_soc is not None
or parameters.discharge_power_limit_w_by_soc is not None
or parameters.charging_efficiency_curve is not None
or parameters.discharging_efficiency_curve is not None
):
self._curves = _PowerCurves(
parameters,
self.capacity_wh,
self.max_charge_power_w,
self.max_discharge_power_w,
self.slot_duration_h,
self.prediction_hours,
)
self.discharge_array = np.full(self.prediction_hours, 0)
self.charge_array = np.full(self.prediction_hours, 0)
# Discharged energy per slot, a plain list: it is read and written one
@@ -311,6 +533,7 @@ class Battery:
"charging_efficiency": self.charging_efficiency,
"discharging_efficiency": self.discharging_efficiency,
"max_charge_power_w": self.max_charge_power_w,
"max_discharge_power_w": self.max_discharge_power_w,
}
def reset(self) -> None:
@@ -321,8 +544,30 @@ class Battery:
self.charge_array = np.zeros(self.prediction_hours, dtype=np.int_)
self._discharged_raw_wh_per_slot = [0.0] * self.prediction_hours
self._charged_raw_wh_per_slot = [0.0] * self.prediction_hours
if self._curves is not None:
self._curves.reset(self.prediction_hours)
self._charge_limit_raw_wh_per_slot = [float("inf")] * self.prediction_hours
def _discharge_slot_cap_wh(self) -> float:
"""Return the raw discharge energy of one slot [Wh] at the current SoC."""
curves = self._curves
if curves is None or curves.discharge_limit_wh_by_soc is None:
return self.max_discharge_power_w * self.slot_duration_h
return _table_entry(
curves.discharge_limit_wh_by_soc,
int(self.soc_wh * curves.soc_index_per_wh + 0.5),
)
def _discharging_efficiency_at(self, slot_raw_wh: float) -> float:
"""Return the discharging efficiency at the slot power of ``slot_raw_wh``."""
curves = self._curves
if curves is None or curves.discharging_efficiency_by_power is None:
return self.discharging_efficiency
return _table_entry(
curves.discharging_efficiency_by_power,
int(slot_raw_wh * curves.discharge_index_per_wh + 0.5),
)
def limit_slot_charge(self, hour: int, raw_wh: float) -> None:
"""Cap the raw energy the battery may take in one slot, from all sources.
@@ -344,16 +589,18 @@ class Battery:
exports at most half the battery's rated discharge power, independent of
how much of the slot budget self-consumption already used.
"""
return self.max_charge_power_w * self.slot_duration_h * self.discharging_efficiency
rated_slot_wh = self.max_discharge_power_w * self.slot_duration_h
return rated_slot_wh * self._discharging_efficiency_at(rated_slot_wh)
def remaining_discharge_energy_wh(self, hour: int) -> float:
"""Return DC energy still deliverable within one optimization slot."""
raw_power_budget_wh = self.max_charge_power_w * self.slot_duration_h
raw_power_remaining_wh = max(
raw_power_budget_wh - self._discharged_raw_wh_per_slot[hour], 0.0
)
discharged_raw_wh = self._discharged_raw_wh_per_slot[hour]
raw_power_remaining_wh = max(self._discharge_slot_cap_wh() - discharged_raw_wh, 0.0)
raw_soc_available_wh = max(self.soc_wh - self.min_soc_wh, 0.0)
return min(raw_power_remaining_wh, raw_soc_available_wh) * self.discharging_efficiency
raw_remaining_wh = min(raw_power_remaining_wh, raw_soc_available_wh)
return raw_remaining_wh * self._discharging_efficiency_at(
discharged_raw_wh + raw_remaining_wh
)
def discharge_released(self, hour: int) -> bool:
"""Whether the plan lets the battery discharge in this slot."""
@@ -361,8 +608,16 @@ class Battery:
def discharged_energy_wh(self, hour: int) -> float:
"""Return DC energy delivered by the battery in one optimization slot."""
if self._curves is not None:
return self._curves.discharged_dc_wh_per_slot[hour]
return self._discharged_raw_wh_per_slot[hour] * self.discharging_efficiency
def charged_energy_wh(self, hour: int) -> float:
"""Return the energy stored into the battery in one optimization slot."""
if self._curves is not None:
return self._curves.charged_stored_wh_per_slot[hour]
return self._charged_raw_wh_per_slot[hour] * self.charging_efficiency
def set_discharge_per_hour(self, discharge_array: np.ndarray) -> None:
"""Sets the discharge values for each hour."""
if len(discharge_array) != self.prediction_hours:
@@ -387,10 +642,13 @@ class Battery:
"""Discharge energy from the battery.
Discharge is limited by:
* Requested delivered energy
* Remaining energy above minimum SoC
* Maximum discharge power
* Discharge efficiency
* Maximum discharge power, and the power limit at the current SoC if
``discharge_power_limit_w_by_soc`` is given
* Discharge efficiency, at the slot power if
``discharging_efficiency_curve`` is given
Args:
wh (float): Requested delivered energy in watt-hours.
@@ -413,29 +671,56 @@ class Battery:
return 0.0, 0.0
# Maximum raw discharge due to power limit, scaled to the slot duration.
# max_charge_power_w is a power [W]; energy movable in one slot is
# power x slot_duration_h.
max_raw_wh = max(
self.max_charge_power_w * self.slot_duration_h - self._discharged_raw_wh_per_slot[hour],
0.0,
) # TODO rename to max_discharge_power_w
# Actual raw withdrawal (internal)
raw_withdrawal_wh = min(raw_available_wh, max_raw_wh)
# The power limit is a power [W]; energy movable in one slot is
# power x slot_duration_h. With a battery profile the limit and the
# efficiency are looked up inline (no calls): this runs for every slot
# of every candidate plan.
discharged_raw_wh = self._discharged_raw_wh_per_slot[hour]
discharging_efficiency = self.discharging_efficiency
curves = self._curves
if curves is None:
slot_cap_wh = self.max_discharge_power_w * self.slot_duration_h
raw_withdrawal_wh = min(raw_available_wh, max(slot_cap_wh - discharged_raw_wh, 0.0))
else:
limits = curves.discharge_limit_wh_by_soc
if limits is None:
slot_cap_wh = self.max_discharge_power_w * self.slot_duration_h
else:
index = int(self.soc_wh * curves.soc_index_per_wh + 0.5)
slot_cap_wh = limits[index] if 0 <= index <= 100 else _table_entry(limits, index)
raw_withdrawal_wh = slot_cap_wh - discharged_raw_wh
if raw_withdrawal_wh < 0.0:
raw_withdrawal_wh = 0.0
if raw_available_wh < raw_withdrawal_wh:
raw_withdrawal_wh = raw_available_wh
efficiencies = curves.discharging_efficiency_by_power
if efficiencies is not None:
# Efficiency at the slot power: the energy already discharged
# in this slot plus this request (estimated with the constant
# efficiency), within the possible withdrawal.
raw_estimate_wh = wh / discharging_efficiency
if raw_estimate_wh > raw_withdrawal_wh:
raw_estimate_wh = raw_withdrawal_wh
index = int(
(discharged_raw_wh + raw_estimate_wh) * curves.discharge_index_per_wh + 0.5
)
discharging_efficiency = efficiencies[index] if index <= 100 else efficiencies[-1]
# Convert raw to delivered
max_deliverable_wh = raw_withdrawal_wh * self.discharging_efficiency
max_deliverable_wh = raw_withdrawal_wh * discharging_efficiency
# Cap by requested delivered energy
delivered_wh = min(wh, max_deliverable_wh)
# Effective raw withdrawal based on what is delivered
raw_used_wh = delivered_wh / self.discharging_efficiency
raw_used_wh = delivered_wh / discharging_efficiency
# Update SoC
self.soc_wh -= raw_used_wh
self.soc_wh = max(self.soc_wh, self.min_soc_wh)
self._discharged_raw_wh_per_slot[hour] += raw_used_wh
self._discharged_raw_wh_per_slot[hour] = discharged_raw_wh + raw_used_wh
if curves is not None:
curves.discharged_dc_wh_per_slot[hour] += delivered_wh
# Losses
losses_wh = raw_used_wh - delivered_wh
@@ -474,8 +759,12 @@ class Battery:
- Available SoC headroom (``max_soc_wh − soc_wh``)
- ``max_charge_power_w``
- The power limit at the current SoC, if ``charge_power_limit_w_by_soc``
is given. It caps the accepted energy like a battery management system
does; it does not scale the charge factor.
- A slot limit set by ``limit_slot_charge()``, if any
- ``charging_efficiency``
- ``charging_efficiency``, or the efficiency at the slot power if
``charging_efficiency_curve`` is given
Args:
wh (float | None):
@@ -512,6 +801,7 @@ class Battery:
# Provide fast (3x..5x) local read access (vs. self.xxx) for repetitive read access
soc_wh_fast = self.soc_wh
charged_raw_wh = self._charged_raw_wh_per_slot[hour]
# Scale the power cap [W] to a per-slot energy cap [Wh] (W x slot hours).
# At slot_duration_h=1.0 (hourly) this equals the legacy power value.
max_charge_per_slot_wh_fast = max(
@@ -519,10 +809,39 @@ class Battery:
self.max_charge_power_w * self.slot_duration_h,
self._charge_limit_raw_wh_per_slot[hour],
)
- self._charged_raw_wh_per_slot[hour],
- charged_raw_wh,
0.0,
)
charging_efficiency_fast = self.charging_efficiency
# Power limit at the current SoC (battery profile), as raw energy still
# acceptable in this slot. None without SoC dependent limit. Looked up
# inline (no calls): this runs for every slot of every candidate plan.
soc_limit_raw_wh: Optional[float] = None
curves = self._curves
if curves is not None:
limits = curves.charge_limit_wh_by_soc
if limits is not None:
index = int(soc_wh_fast * curves.soc_index_per_wh + 0.5)
soc_limit_raw_wh = (
limits[index] if 0 <= index <= 100 else _table_entry(limits, index)
) - charged_raw_wh
if soc_limit_raw_wh < 0.0:
soc_limit_raw_wh = 0.0
efficiencies = curves.charging_efficiency_by_power
if efficiencies is not None:
# Efficiency at the slot power: the energy already charged in
# this slot plus the intended request within the limits.
intended_raw_wh = (
wh if wh is not None else max_charge_per_slot_wh_fast * charge_factor
)
if intended_raw_wh > max_charge_per_slot_wh_fast:
intended_raw_wh = max_charge_per_slot_wh_fast
if soc_limit_raw_wh is not None and soc_limit_raw_wh < intended_raw_wh:
intended_raw_wh = soc_limit_raw_wh
if intended_raw_wh < 0.0:
intended_raw_wh = 0.0
index = int((charged_raw_wh + intended_raw_wh) * curves.charge_index_per_wh + 0.5)
charging_efficiency_fast = efficiencies[index] if index <= 100 else efficiencies[-1]
# Decide mode & determine raw_request_wh and raw_charge_wh
if wh is not None and charge_factor == 0.0: # mode 1
@@ -562,6 +881,8 @@ class Battery:
# Remaining capacity
max_raw_wh = min(raw_charge_wh, max_charge_per_slot_wh_fast)
if soc_limit_raw_wh is not None and soc_limit_raw_wh < max_raw_wh:
max_raw_wh = soc_limit_raw_wh
# Actual raw intake
raw_input_wh = raw_request_wh if raw_request_wh < max_raw_wh else max_raw_wh
@@ -576,7 +897,9 @@ class Battery:
)
self.soc_wh = new_soc
self._charged_raw_wh_per_slot[hour] += raw_input_wh
self._charged_raw_wh_per_slot[hour] = charged_raw_wh + raw_input_wh
if curves is not None:
curves.charged_stored_wh_per_slot[hour] += stored_wh
losses_wh = raw_input_wh - stored_wh
return stored_wh, losses_wh
+27 -22
View File
@@ -284,10 +284,10 @@ class Inverter:
return battery_discharge_ac, discharge_losses + inverter_discharge_losses
# This runs for every slot of every candidate plan in which the battery
# could serve load or export. For the plain Battery the release check,
# the efficiency and Battery.discharge_energy() are computed in one go,
# without the nested calls. The arithmetic is that of
# Battery.discharge_energy(); keep both in step.
# could serve load or export. For the Battery the release check and the
# efficiency are computed in one go, without the nested calls. Without
# battery profile data, Battery.discharge_energy() is inlined as well;
# keep both in step.
if battery.discharge_array[hour] == 0:
# Not released in this slot: the battery delivers nothing.
return 0.0, 0.0
@@ -299,24 +299,29 @@ class Inverter:
dc_to_ac_efficiency = self.dc_to_ac_efficiency_at(requested_ac_wh)
dc_request = requested_ac_wh / dc_to_ac_efficiency
soc_wh = battery.soc_wh
min_soc_wh = battery.min_soc_wh
discharged_raw_wh = battery._discharged_raw_wh_per_slot
discharging_efficiency = battery.discharging_efficiency
raw_available_wh = max(soc_wh - min_soc_wh, 0.0)
if raw_available_wh == 0.0:
# Match Battery.discharge_energy(): preserve a SoC below the reserve.
return 0.0, 0.0
max_raw_wh = max(
battery.max_charge_power_w * battery.slot_duration_h - discharged_raw_wh[hour], 0.0
)
max_deliverable_wh = min(raw_available_wh, max_raw_wh) * discharging_efficiency
battery_discharge_dc = min(dc_request, max_deliverable_wh)
raw_used_wh = battery_discharge_dc / discharging_efficiency
soc_wh -= raw_used_wh
battery.soc_wh = max(soc_wh, min_soc_wh)
discharged_raw_wh[hour] += raw_used_wh
discharge_losses = raw_used_wh - battery_discharge_dc
if battery._curves is not None:
# SoC / power dependent limits and efficiencies: one call.
battery_discharge_dc, discharge_losses = battery.discharge_energy(dc_request, hour)
else:
soc_wh = battery.soc_wh
min_soc_wh = battery.min_soc_wh
discharged_raw_wh = battery._discharged_raw_wh_per_slot
discharging_efficiency = battery.discharging_efficiency
raw_available_wh = max(soc_wh - min_soc_wh, 0.0)
if raw_available_wh == 0.0:
# Match Battery.discharge_energy(): preserve a SoC below the reserve.
return 0.0, 0.0
max_raw_wh = max(
battery.max_discharge_power_w * battery.slot_duration_h - discharged_raw_wh[hour],
0.0,
)
max_deliverable_wh = min(raw_available_wh, max_raw_wh) * discharging_efficiency
battery_discharge_dc = min(dc_request, max_deliverable_wh)
raw_used_wh = battery_discharge_dc / discharging_efficiency
soc_wh -= raw_used_wh
battery.soc_wh = max(soc_wh, min_soc_wh)
discharged_raw_wh[hour] += raw_used_wh
discharge_losses = raw_used_wh - battery_discharge_dc
# Same rule as _delivered_ac_wh(), without the call: a limited discharge
# is converted with the efficiency at the AC energy actually delivered.
@@ -10,7 +10,8 @@ from akkudoktoreos.optimization.genetic0.genetic0devices import Genetic0DevicePa
def max_charging_power_field(description: Optional[str] = None) -> float:
if description is None:
description = "Maximum charging power in watts."
return Field(default=5000, gt=0, json_schema_extra={"description": description})
# 0: the battery may not charge, e.g. the profile inhibits it at a low temperature.
return Field(default=5000, ge=0, json_schema_extra={"description": description})
def initial_soc_percentage_field(description: str) -> int:
@@ -53,6 +54,17 @@ class Genetic0BaseBatteryParameters(Genetic0DeviceParameters):
)
discharging_efficiency: float = discharging_efficiency_field(0.88)
max_charge_power_w: Optional[float] = max_charging_power_field()
max_discharge_power_w: Optional[float] = Field(
default=None,
ge=0,
json_schema_extra={
"description": (
"Maximum discharging power in watts. 0: the battery may not discharge. "
"None uses max_charge_power_w."
),
"examples": [None, 4000],
},
)
initial_soc_percentage: int = initial_soc_percentage_field(
"An integer representing the state of charge of the battery at the **start** of the current hour (not the current state)."
)
@@ -136,6 +148,11 @@ class Genetic0Battery:
self.max_charge_power_w = self.parameters.max_charge_power_w
else:
self.max_charge_power_w = self.capacity_wh # TODO this should not be equal capacity_wh
# The charge power may be 0 (e.g. too cold to charge) while discharging is allowed.
if self.parameters.max_discharge_power_w is not None:
self.max_discharge_power_w = self.parameters.max_discharge_power_w
else:
self.max_discharge_power_w = self.max_charge_power_w
self.discharge_array = np.full(self.prediction_hours, 0)
self.charge_array = np.full(self.prediction_hours, 0)
self.soc_wh = (self.initial_soc_percentage / 100) * self.capacity_wh
@@ -232,7 +249,7 @@ class Genetic0Battery:
return 0.0, 0.0
# Maximum raw discharge due to power limit
max_raw_wh = self.max_charge_power_w # TODO rename to max_discharge_power_w
max_raw_wh = self.max_discharge_power_w
# Actual raw withdrawal (internal)
raw_withdrawal_wh = min(raw_available_wh, max_raw_wh)
@@ -0,0 +1,779 @@
"""Battery profile documents for AkkudoktorEOS device controllers and simulation.
This module defines the schema of a battery profile document: temperature- and
SOC-dependent charge and discharge limit maps, optional C-rate-dependent
efficiency curves, model power limits, technical SOC bounds, an optional
nominal capacity and hard temperature cutoffs. C-rates refer to the effective
nominal capacity: the capacity of the battery device settings if configured,
else the nominal capacity of the profile.
The document models only describe and validate the data. The other concerns
live in their own modules:
- ``batteryprofileruntime``: the ready-to-use ``BatteryProfile`` and the
algorithms that compile a document into it (reference interpolation and
precomputation of dense lookup tables).
- ``profilestore``: where documents are stored, loading and saving them,
and a cache of compiled profiles. ``BatteryProfileSettings`` is the battery
profile type of the store (kind ``"battery"``).
The classmethods and methods of ``BatteryProfileSettings`` that load, save or
compile documents are kept as thin delegates to these modules. The recommended
entry point is the store::
from akkudoktoreos.devices.profile.profilestore import get_profile_store
profile = get_profile_store().get(BatteryProfileSettings, "generic-lfp")
p_charge = profile.max_charge_crate(temperature_degC=12.0, soc_percent=30.0)
if p_charge is None:
... # charging must be inhibited at this operating point
"""
from pathlib import Path
from typing import Any, ClassVar, List, Literal, Optional, Union
from pydantic import ConfigDict, Field, field_validator, model_validator
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.devices.profile.batteryprofileruntime import (
BatteryProfile as BatteryProfile,
)
from akkudoktoreos.devices.profile.batteryprofileruntime import (
EfficiencyInterpolation as EfficiencyInterpolation,
)
from akkudoktoreos.devices.profile.batteryprofileruntime import (
Interpolation as Interpolation,
)
from akkudoktoreos.devices.profile.batteryprofileruntime import (
OutOfRangeBehavior as OutOfRangeBehavior,
)
from akkudoktoreos.devices.profile.batteryprofileruntime import (
PrecomputedChargeDischargeLimitMap as PrecomputedChargeDischargeLimitMap,
)
from akkudoktoreos.devices.profile.batteryprofileruntime import (
PrecomputedEfficiencyCurve as PrecomputedEfficiencyCurve,
)
from akkudoktoreos.devices.profile.batteryprofileruntime import (
compile_battery_profile,
precompute_limit_maps,
reference_efficiency_lookup,
reference_max_charge_crate,
reference_max_discharge_crate,
)
from akkudoktoreos.devices.profile.profilestore import (
ProfileDocument,
get_profile_store,
)
class ProfileBaseModel(SettingsBaseModel):
"""Base of all battery profile document models.
Unknown keys are rejected, so a misspelled key (e.g. ``nominal_capacity_kwh``
for ``nominal_capacity_wh``) fails validation instead of being ignored.
"""
model_config = ConfigDict(extra="forbid")
class Units(ProfileBaseModel):
"""Units of the axes and of the limit values in a limit table.
The units describe how to interpret the numbers in Axes and ChargeDischargeLimitMap.
They do not affect the interpolation itself; they only tell the controller
how to convert the resulting limit into power or current before applying
it.
"""
temperature: Literal["degC"] = Field(
default="degC",
json_schema_extra={
"description": "Unit of the temperature axis and the hard cutoffs: 'degC'.",
"examples": ["degC"],
},
)
soc: Literal["percent"] = Field(
default="percent",
json_schema_extra={
"description": "Unit of the SOC axis: 'percent' (0..100).",
"examples": ["percent"],
},
)
limit: Literal["C-rate", "W", "A"] = Field(
...,
json_schema_extra={
"description": (
"Unit of the limit values. 'C-rate' (multiply by nominal capacity for power), "
"'W' (absolute power) or 'A' (absolute current, needs nominal_voltage_v)."
),
"examples": ["C-rate"],
},
)
class EfficiencyCurve(ProfileBaseModel):
"""Battery charge or discharge efficiency as a function of C-rate.
C-rate is based on the effective nominal battery capacity: the capacity
configured on the device if set, else the nominal capacity of the profile.
Efficiency is expressed as a fraction in the range (0, 1].
"""
description: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Free-form note about the source or meaning of this curve.",
"examples": ["Charge efficiency of a typical LFP pack"],
},
)
c_rate: List[float] = Field(
...,
min_length=2,
json_schema_extra={
"description": "Strictly increasing absolute C-rate breakpoints.",
"examples": [[0.05, 0.1, 0.25, 0.5, 1.0]],
},
)
efficiency: List[float] = Field(
...,
min_length=2,
json_schema_extra={
"description": "Efficiency fractions corresponding to each C-rate.",
"examples": [[0.90, 0.94, 0.96, 0.95, 0.91]],
},
)
interpolation: EfficiencyInterpolation = Field(
default=EfficiencyInterpolation.LINEAR,
json_schema_extra={"description": "Interpolation between C-rate breakpoints."},
)
out_of_range_behavior: OutOfRangeBehavior = Field(
default=OutOfRangeBehavior.CLAMP_TO_NEAREST,
json_schema_extra={
"description": "Clamp to the nearest endpoint or reject out-of-range C-rates."
},
)
@field_validator("c_rate")
@classmethod
def valid_c_rates(cls, values: List[float]) -> List[float]:
"""Validate non-negative, strictly increasing C-rate breakpoints."""
if any(value < 0 for value in values):
raise ValueError("C-rate breakpoints must be non-negative")
if any(right <= left for left, right in zip(values, values[1:])):
raise ValueError("C-rate breakpoints must be strictly increasing")
return values
@field_validator("efficiency")
@classmethod
def valid_efficiencies(cls, values: List[float]) -> List[float]:
"""Validate efficiency fractions in the interval (0, 1]."""
if any(not 0.0 < value <= 1.0 for value in values):
raise ValueError("efficiency values must be in the range (0, 1]")
return values
@model_validator(mode="after")
def matching_lengths(self) -> "EfficiencyCurve":
"""Ensure each C-rate breakpoint has exactly one efficiency value."""
if len(self.c_rate) != len(self.efficiency):
raise ValueError("c_rate and efficiency must contain the same number of values")
return self
def lookup(self, c_rate: float) -> Optional[float]:
"""Return the interpolated efficiency at an absolute C-rate.
Reference implementation, see
``batteryprofileruntime.reference_efficiency_lookup``. Runtime queries
use ``PrecomputedEfficiencyCurve.lookup``.
"""
return reference_efficiency_lookup(self, c_rate)
class Axes(ProfileBaseModel):
"""Breakpoints of the temperature and SOC axes of a limit table.
Both axes must be strictly increasing. The rows of a ChargeDischargeLimitMap align with
temperature_degC and its columns align with soc_percent.
"""
temperature_degC: List[float] = Field(
...,
min_length=2,
json_schema_extra={
"description": (
"Strictly increasing temperature breakpoints in degrees Celsius. "
"Rows of the limit maps align with these values."
),
"examples": [[-10, -5, 0, 5, 10, 15, 25, 35, 45, 50]],
},
)
soc_percent: List[float] = Field(
...,
min_length=2,
json_schema_extra={
"description": (
"Strictly increasing SOC breakpoints in percent (0..100). "
"Columns of the limit maps align with these values."
),
"examples": [[0, 5, 10, 20, 40, 60, 80, 90, 95, 100]],
},
)
@field_validator("temperature_degC", "soc_percent")
@classmethod
def strictly_increasing(cls, v: List[float]) -> List[float]:
"""Validate that an axis is strictly increasing.
Args:
v: Axis breakpoints, in order.
Returns:
The unchanged breakpoints if the axis is strictly increasing.
Raises:
ValueError: If any breakpoint is not greater than its predecessor.
"""
if any(b <= a for a, b in zip(v, v[1:])):
raise ValueError("axis values must be strictly increasing")
return v
@field_validator("soc_percent")
@classmethod
def soc_in_range(cls, v: List[float]) -> List[float]:
"""Validate that the SOC axis lies within [0, 100].
Args:
v: SOC breakpoints in percent.
Returns:
The unchanged breakpoints if they lie within [0, 100].
Raises:
ValueError: If the first breakpoint is below 0 or the last is
above 100.
"""
if v[0] < 0 or v[-1] > 100:
raise ValueError("soc_percent must lie within [0, 100]")
return v
class ChargeDischargeLimitMap(ProfileBaseModel):
"""Two-dimensional grid of non-negative charge or discharge limits.
The grid is indexed by temperature along rows and by SOC along columns,
matching the breakpoints of Axes. Values are in the unit declared by
Units.
"""
description: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Free-form note about what this map represents.",
"examples": ["Max charge C-rate; rows = temperature, cols = SOC"],
},
)
values: List[List[float]] = Field(
...,
min_length=2,
json_schema_extra={
"description": (
"2D grid of non-negative limit values. Rows align with "
"axes.temperature_degC, columns with axes.soc_percent."
),
"examples": [
[
[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
[0.10, 0.10, 0.15, 0.20, 0.25, 0.25, 0.15, 0.08, 0.03, 0.0],
[0.50, 0.50, 0.50, 0.50, 0.50, 0.50, 0.50, 0.25, 0.10, 0.0],
]
],
},
)
@field_validator("values")
@classmethod
def non_negative(cls, v: List[List[float]]) -> List[List[float]]:
"""Validate that all limit values are non-negative.
Args:
v: Two-dimensional grid of limit values.
Returns:
The unchanged grid if every value is >= 0.
Raises:
ValueError: If any value in the grid is negative.
"""
for row in v:
if any(x < 0 for x in row):
raise ValueError("limit values must be non-negative")
return v
class HardCutoffs(ProfileBaseModel):
"""Absolute temperature thresholds that override the limit maps.
Outside these bounds the controller must inhibit the operation entirely,
regardless of the interpolated limit map value. Charge and discharge
bounds are independent and may differ.
"""
description: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Free-form note about the cutoffs, e.g. their source.",
"examples": ["Cell manufacturer data sheet"],
},
)
charge_allowed_below_degC: float = Field(
...,
json_schema_extra={
"description": "Charging is inhibited strictly below this temperature.",
"examples": [-10.0],
},
)
charge_allowed_above_degC: float = Field(
...,
json_schema_extra={
"description": "Charging is inhibited strictly above this temperature.",
"examples": [55.0],
},
)
discharge_allowed_below_degC: float = Field(
...,
json_schema_extra={
"description": "Discharging is inhibited strictly below this temperature.",
"examples": [-15.0],
},
)
discharge_allowed_above_degC: float = Field(
...,
json_schema_extra={
"description": "Discharging is inhibited strictly above this temperature.",
"examples": [60.0],
},
)
@model_validator(mode="after")
def ordered(self) -> "HardCutoffs":
"""Validate that each lower cutoff is below its corresponding upper one.
Returns:
The validated instance.
Raises:
ValueError: If a charge or discharge lower bound is not strictly
below its upper bound.
"""
if self.charge_allowed_below_degC >= self.charge_allowed_above_degC:
raise ValueError("charge cutoff lower bound must be < upper bound")
if self.discharge_allowed_below_degC >= self.discharge_allowed_above_degC:
raise ValueError("discharge cutoff lower bound must be < upper bound")
return self
class BatteryProfileSettings(ProfileBaseModel, ProfileDocument[BatteryProfile]):
"""Temperature- and SOC-dependent charge/discharge limits for a battery.
This profile holds model-specific operating characteristics: charge and
discharge power-limit maps, efficiency curves, technical SOC bounds, hard
temperature cutoffs, optional absolute power limits, an optional nominal
voltage and the nominal capacity. It is the configuration object read by an
AkkudoktorEOS device controller to constrain a battery or hybrid inverter at
the current operating point, and by the simulation to model the same limits.
A profile contains every value the computation uses. The one exception is
the nominal capacity of a generic profile: its limits are C-rates that scale
with any capacity, so it may leave the capacity out. The battery device then
has to provide the capacity in its ``profile_adjustment``. Unknown keys are
rejected.
The caller should not query this document directly. Use
``get_profile_store().get(BatteryProfileSettings, name)`` (or the delegate
``from_profile_name``) to obtain a precomputed BatteryProfile object instead.
Attributes:
schema_version: Version of the JSON document format.
battery_model: Model name of the battery pack.
chemistry: Cell chemistry, if relevant for interpretation.
nominal_voltage_v: Nominal voltage, if known.
nominal_capacity_wh: Nominal capacity of the battery model, None for a
generic profile.
max_charge_power_w: Optional absolute model charge-power limit.
max_discharge_power_w: Optional absolute model discharge-power limit.
technical_min_soc_percent: Manufacturer-defined lower SOC limit.
technical_max_soc_percent: Manufacturer-defined upper SOC limit.
full_charge_interval_days: Maximum number of days between two full
charges (SoC recalibration), None if not needed or unknown.
full_charge_detect_percentage: Reported SOC from which the battery
counts as fully charged.
charge_efficiency: Charge-efficiency curve versus C-rate.
discharge_efficiency: Discharge-efficiency curve versus C-rate.
units: Units of the axes and of the limit values.
interpolation: Interpolation strategy between grid points.
out_of_range_behavior: Behavior for queries outside the axis range
but inside the hard cutoffs.
axes: Shared axis breakpoints.
charge_limit_crate: Grid of maximum charge limits.
discharge_limit_crate: Grid of maximum discharge limits.
hard_cutoffs: Absolute thresholds that override the maps.
"""
profile_kind: ClassVar[str] = "battery"
schema_version: str = Field(
default="1.1",
json_schema_extra={
"description": "Version of this schema, for forward-compatibility.",
"examples": ["1.1"],
},
)
battery_model: str = Field(
...,
min_length=1,
json_schema_extra={
"description": "Model name of the battery pack.",
"examples": ["BYD Battery-Box Premium HVS"],
},
)
chemistry: Optional[str] = Field(
default=None,
json_schema_extra={
"description": "Cell chemistry, if relevant for interpretation.",
"examples": ["LFP"],
},
)
nominal_voltage_v: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={
"description": "Nominal voltage, used to convert power to current.",
"examples": [102.4],
},
)
nominal_capacity_wh: Optional[float] = Field(
default=None,
gt=0,
allow_inf_nan=False,
json_schema_extra={
"description": (
"Nominal capacity of this battery model [Wh]. Required for the profile "
"of a specific battery model. A generic profile, whose limits are "
"C-rates, leaves it out; the battery device then has to set "
"'profile_adjustment.nominal_capacity_wh'."
),
"examples": [None, 10240.0],
},
)
max_charge_power_w: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Optional absolute maximum charge power for this battery model. "
"The effective limit should also respect the installed inverter/device limit."
),
"examples": [5000.0],
},
)
max_discharge_power_w: Optional[float] = Field(
default=None,
gt=0,
json_schema_extra={
"description": (
"Optional absolute maximum discharge power for this battery model. "
"The effective limit should also respect the installed inverter/device limit."
),
"examples": [5000.0],
},
)
technical_min_soc_percent: float = Field(
...,
ge=0,
le=100,
json_schema_extra={
"description": (
"Manufacturer-defined technical minimum SOC [%], not the user's "
"operational reserve setting."
),
"examples": [5.0],
},
)
technical_max_soc_percent: float = Field(
...,
ge=0,
le=100,
json_schema_extra={
"description": (
"Manufacturer-defined technical maximum SOC [%], not the user's "
"operational target setting."
),
"examples": [95.0],
},
)
full_charge_interval_days: Optional[float] = Field(
default=None,
gt=0,
allow_inf_nan=False,
json_schema_extra={
"description": (
"Maximum number of days between two full charges, e.g. the interval "
"the battery management system needs to recalibrate its state of "
"charge (typical for LFP) [d]. None if the battery needs no periodic "
"full charge or the interval is unknown."
),
"examples": [None, 21.0],
},
)
full_charge_detect_percentage: int = Field(
default=99,
ge=1,
le=100,
json_schema_extra={
"description": (
"Reported state of charge from which the battery management system "
"counts as fully charged [%]. Many never report exactly 100 %."
),
"examples": [99],
},
)
charge_efficiency: EfficiencyCurve = Field(
...,
json_schema_extra={"description": "Charge efficiency curve versus absolute C-rate."},
)
discharge_efficiency: EfficiencyCurve = Field(
...,
json_schema_extra={"description": "Discharge efficiency curve versus absolute C-rate."},
)
units: Units = Field(
...,
json_schema_extra={
"description": "Units of axes and limit values.",
},
)
interpolation: Interpolation = Field(
default=Interpolation.BILINEAR,
json_schema_extra={
"description": (
"How to interpolate between grid points: 'bilinear', 'nearest' "
"or 'none' (exact grid points only)."
),
"examples": ["bilinear"],
},
)
out_of_range_behavior: OutOfRangeBehavior = Field(
default=OutOfRangeBehavior.CLAMP_TO_NEAREST,
json_schema_extra={
"description": (
"What to do when a query lies outside the axis range but inside "
"the hard cutoffs: 'clamp_to_nearest' or 'reject'."
),
"examples": ["clamp_to_nearest"],
},
)
axes: Axes = Field(
...,
json_schema_extra={"description": "Axis breakpoints for both dimensions."},
)
charge_limit_crate: ChargeDischargeLimitMap = Field(
...,
json_schema_extra={"description": "Maximum charge limit map."},
)
discharge_limit_crate: ChargeDischargeLimitMap = Field(
...,
json_schema_extra={"description": "Maximum discharge limit map."},
)
hard_cutoffs: HardCutoffs = Field(
...,
json_schema_extra={"description": "Absolute protection thresholds."},
)
@model_validator(mode="after")
def current_limits_need_voltage(self) -> "BatteryProfileSettings":
"""Validate that current limits ('A') come with the nominal voltage.
Without it the limits cannot be converted to power.
Returns:
The validated instance.
Raises:
ValueError: If units.limit is 'A' and nominal_voltage_v is not set.
"""
if self.units.limit == "A" and self.nominal_voltage_v is None:
raise ValueError("units.limit 'A' needs nominal_voltage_v to convert currents to power")
return self
@model_validator(mode="after")
def grids_match_axes(self) -> "BatteryProfileSettings":
"""Validate that both limit maps match the shape of the shared axes.
The number of rows of each map must equal the number of temperature
breakpoints, and the number of columns must equal the number of SOC
breakpoints.
Returns:
The validated instance.
Raises:
ValueError: If either map's shape does not match the axes.
"""
n_temp = len(self.axes.temperature_degC)
n_soc = len(self.axes.soc_percent)
for name, grid in (
("charge_limit_crate", self.charge_limit_crate.values),
("discharge_limit_crate", self.discharge_limit_crate.values),
):
if len(grid) != n_temp:
raise ValueError(f"{name}: expected {n_temp} rows (temperature), got {len(grid)}")
for i, row in enumerate(grid):
if len(row) != n_soc:
raise ValueError(
f"{name}: row {i} expected {n_soc} columns (SOC), got {len(row)}"
)
if self.technical_min_soc_percent >= self.technical_max_soc_percent:
raise ValueError(
"technical_min_soc_percent must be less than technical_max_soc_percent"
)
return self
# --- storage (delegates to profilestore) ------------------------------
def save_profile(self, name: str) -> Path:
"""Save this document to the user data directory as JSON.
Saved as ``<name>.json``, see ``ProfileStore.save``. The profile name is
independent of ``battery_model``, which is free text.
Args:
name: Profile name, see ``ProfileStore`` for the allowed characters.
Returns:
The path the profile was written to.
Raises:
ValueError: If the name is invalid or reserved.
OSError: If the file or its parent directories cannot be written.
"""
return get_profile_store().save(name, self)
@classmethod
def load_profile(cls, name: str) -> "BatteryProfileSettings":
"""Load the validated document of a profile by name, not precomputed.
See ``ProfileStore.load``.
Args:
name: Profile name, without the ``.json`` suffix.
Returns:
The validated BatteryProfileSettings.
Raises:
ValueError: If the name is invalid or the document fails validation.
FileNotFoundError: If no JSON document exists for the given name.
"""
return get_profile_store().load(cls, name)
@classmethod
def from_profile_name(
cls,
name: str,
t_step: float = 1.0,
s_step: float = 1.0,
) -> BatteryProfile:
"""Return the compiled profile of a profile name.
See ``ProfileStore.get``; the result is cached.
Args:
name: Profile name, without the ``.json`` suffix.
t_step: Spacing of the precomputed temperature axis [degC].
s_step: Spacing of the precomputed SOC axis [%].
Returns:
A ready-to-use BatteryProfile object.
Raises:
FileNotFoundError: If no JSON document exists for the given name.
ValueError: If the document fails validation, or if t_step or
s_step is not positive.
"""
return get_profile_store().get(cls, name, t_step=t_step, s_step=s_step)
@classmethod
def from_json_file(
cls,
path: Union[str, Path],
t_step: float = 1.0,
s_step: float = 1.0,
) -> BatteryProfile:
"""Load a JSON document from an explicit path and compile it (not cached).
Intended for configuration overrides, tests and tooling.
Args:
path: Path to the JSON document.
t_step: Spacing of the precomputed temperature axis [degC].
s_step: Spacing of the precomputed SOC axis [%].
Returns:
A ready-to-use BatteryProfile object.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the document fails validation, or if t_step or
s_step is not positive.
"""
document = get_profile_store().load_file(cls, path)
return compile_battery_profile(document, t_step=t_step, s_step=s_step)
# --- compilation (delegates to batteryprofileruntime) -----------------
def compile_profile(
self, t_step: float = 1.0, s_step: float = 1.0, **options: Any
) -> BatteryProfile:
"""Compile this document for the profile store, see ``to_limits``.
Raises:
TypeError: If an unknown option is given.
"""
if options:
raise TypeError(f"Unknown battery profile options: {sorted(options)}")
return compile_battery_profile(self, t_step=t_step, s_step=s_step)
def to_limits(self, t_step: float = 1.0, s_step: float = 1.0) -> BatteryProfile:
"""Compile this document into a ready-to-use BatteryProfile.
See ``batteryprofileruntime.compile_battery_profile``.
"""
return compile_battery_profile(self, t_step=t_step, s_step=s_step)
def precompute_charge_discharge_limit_map(
self,
t_step: float = 1.0,
s_step: float = 1.0,
apply_cutoffs: bool = True,
) -> tuple[PrecomputedChargeDischargeLimitMap, PrecomputedChargeDischargeLimitMap]:
"""Resample both limit maps onto dense, uniformly spaced grids.
See ``batteryprofileruntime.precompute_limit_maps``.
"""
return precompute_limit_maps(
self, t_step=t_step, s_step=s_step, apply_cutoffs=apply_cutoffs
)
def max_charge_crate(self, temperature_degC: float, soc_percent: float) -> Optional[float]:
"""Return the maximum charge limit by slow per-query interpolation.
Reference implementation; controllers use ``BatteryProfile.max_charge_crate``.
See ``batteryprofileruntime.reference_max_charge_crate``.
"""
return reference_max_charge_crate(self, temperature_degC, soc_percent)
def max_discharge_crate(self, temperature_degC: float, soc_percent: float) -> Optional[float]:
"""Return the maximum discharge limit by slow per-query interpolation.
Reference implementation; controllers use ``BatteryProfile.max_discharge_crate``.
See ``batteryprofileruntime.reference_max_discharge_crate``.
"""
return reference_max_discharge_crate(self, temperature_degC, soc_percent)
@@ -0,0 +1,669 @@
"""Runtime form of a battery profile and the algorithms that build it.
A battery profile document (``BatteryProfileSettings``) describes limits as sparse,
non-uniform maps and curves. This module turns such a document into the
ready-to-use, immutable ``BatteryProfile`` object queried by controllers and the
simulation:
- ``compile_battery_profile`` resamples both limit maps onto dense, uniformly
spaced grids and precomputes the efficiency curves, so each runtime query is a
single array index lookup.
- The ``reference_*`` functions are the slow per-query interpolation the
precomputation is built from. They define the semantics of a profile document.
The module has no pydantic models and does not read files or configuration. The
interpolation enums live here because the algorithms interpret them; the
document module re-exports them.
"""
from bisect import bisect_left, bisect_right
from dataclasses import dataclass
from enum import Enum
from typing import TYPE_CHECKING, List, Optional
import numpy as np
if TYPE_CHECKING:
from akkudoktoreos.devices.profile.batteryprofile import (
BatteryProfileSettings,
EfficiencyCurve,
)
class Interpolation(str, Enum):
"""Strategy used to interpolate between grid points of a limit map.
Attributes:
BILINEAR: Interpolate linearly along both axes.
NEAREST: Snap to the nearest grid point.
NONE: Only exact grid points yield a value; anything else yields
None.
"""
BILINEAR = "bilinear"
NEAREST = "nearest"
NONE = "none"
class OutOfRangeBehavior(str, Enum):
"""Behavior when a query lies outside the axis range but inside cutoffs.
Attributes:
CLAMP_TO_NEAREST: Return the value at the nearest edge of the map.
REJECT: Return None for the out-of-range query.
"""
CLAMP_TO_NEAREST = "clamp_to_nearest"
REJECT = "reject"
class EfficiencyInterpolation(str, Enum):
"""Interpolation strategy for one-dimensional efficiency curves."""
LINEAR = "linear"
NEAREST = "nearest"
NONE = "none"
# ---------------------------------------------------------------------------
# Precomputed runtime objects
# ---------------------------------------------------------------------------
@dataclass(frozen=True)
class PrecomputedEfficiencyCurve:
"""Immutable runtime representation of a C-rate efficiency curve.
Segment slopes/intercepts and nearest-neighbor midpoints are calculated
once when the battery profile is created. Runtime lookups therefore avoid
repeatedly calculating interpolation coefficients or scanning every
breakpoint. The C-rate axis remains independent of installed capacity.
"""
c_rate: tuple[float, ...]
efficiency: tuple[float, ...]
interpolation: EfficiencyInterpolation
out_of_range_behavior: OutOfRangeBehavior
slopes: tuple[float, ...]
intercepts: tuple[float, ...]
midpoints: tuple[float, ...]
@classmethod
def from_curve(cls, curve: "EfficiencyCurve") -> "PrecomputedEfficiencyCurve":
"""Build immutable lookup data from a validated configuration curve."""
c_rates = tuple(curve.c_rate)
efficiencies = tuple(curve.efficiency)
slopes = tuple(
(efficiencies[i + 1] - efficiencies[i]) / (c_rates[i + 1] - c_rates[i])
for i in range(len(c_rates) - 1)
)
intercepts = tuple(efficiencies[i] - slopes[i] * c_rates[i] for i in range(len(slopes)))
midpoints = tuple((c_rates[i] + c_rates[i + 1]) / 2.0 for i in range(len(c_rates) - 1))
return cls(
c_rate=c_rates,
efficiency=efficiencies,
interpolation=curve.interpolation,
out_of_range_behavior=curve.out_of_range_behavior,
slopes=slopes,
intercepts=intercepts,
midpoints=midpoints,
)
def lookup(self, c_rate: float) -> Optional[float]:
"""Return efficiency at an absolute C-rate using precomputed data."""
if c_rate < self.c_rate[0]:
if self.out_of_range_behavior == OutOfRangeBehavior.REJECT:
return None
return self.efficiency[0]
if c_rate > self.c_rate[-1]:
if self.out_of_range_behavior == OutOfRangeBehavior.REJECT:
return None
return self.efficiency[-1]
if self.interpolation == EfficiencyInterpolation.NONE:
index = bisect_left(self.c_rate, c_rate)
if index < len(self.c_rate) and self.c_rate[index] == c_rate:
return self.efficiency[index]
return None
if self.interpolation == EfficiencyInterpolation.NEAREST:
# bisect_left preserves the original min() tie-break: lower point.
index = bisect_left(self.midpoints, c_rate)
return self.efficiency[index]
if c_rate == self.c_rate[-1]:
return self.efficiency[-1]
index = bisect_right(self.c_rate, c_rate) - 1
return self.slopes[index] * c_rate + self.intercepts[index]
@dataclass(frozen=True)
class PrecomputedChargeDischargeLimitMap:
"""Dense, uniformly spaced lookup table for one direction.
Instances are produced by ``precompute_limit_maps``. They resample the
sparse, non-uniform limit map onto a regular grid once, so that each
runtime query becomes a single array index lookup instead of a full
bilinear interpolation.
Attributes:
grid: Two-dimensional array indexed as grid[temp_index, soc_index].
t_min: Lowest temperature covered by the grid, in degrees Celsius.
t_step: Temperature spacing between rows, in degrees Celsius.
s_min: Lowest SOC covered by the grid, in percent.
s_step: SOC spacing between columns, in percent.
n_temp: Number of temperature rows.
n_soc: Number of SOC columns.
fill: Value returned for queries outside the grid bounds. None means
such queries yield None; a float means they yield that value
(used for clamp-to-edge semantics).
allowed_min_degC: Lowest temperature the direction is allowed at
(hard cutoff, inclusive), None for no cutoff. Checked on the exact
query temperature, before rounding to a grid cell.
allowed_max_degC: Highest temperature the direction is allowed at
(hard cutoff, inclusive), None for no cutoff.
"""
grid: np.ndarray
t_min: float
t_step: float
s_min: float
s_step: float
n_temp: int
n_soc: int
fill: Optional[float] = None
allowed_min_degC: Optional[float] = None
allowed_max_degC: Optional[float] = None
def _allowed(self, temperature_degC: float) -> bool:
"""Return whether the hard cutoffs allow the direction at the exact temperature."""
if self.allowed_min_degC is not None and temperature_degC < self.allowed_min_degC:
return False
if self.allowed_max_degC is not None and temperature_degC > self.allowed_max_degC:
return False
return True
def lookup(self, temperature_degC: float, soc_percent: float) -> Optional[float]:
"""Return the precomputed limit at the given operating point.
A temperature outside the hard cutoffs returns None; the cutoffs are
checked on the exact temperature. Otherwise the query is rounded to the
nearest grid cell. Queries whose coordinates lie outside the grid's
coordinate range return fill (or None if fill is None). The bounds check
is performed on the raw coordinates before rounding, so that a query
just below t_min is not accidentally rounded up into the first cell.
Args:
temperature_degC: Cell temperature in degrees Celsius.
soc_percent: State of charge in percent.
Returns:
The stored limit, None if the hard cutoffs inhibit the direction,
or fill if the query is outside the grid's coordinate range.
"""
if not self._allowed(temperature_degC):
return None
t_max = self.t_min + (self.n_temp - 1) * self.t_step
s_max = self.s_min + (self.n_soc - 1) * self.s_step
if (
temperature_degC < self.t_min
or temperature_degC > t_max
or soc_percent < self.s_min
or soc_percent > s_max
):
return self.fill
ti = int(round((temperature_degC - self.t_min) / self.t_step))
si = int(round((soc_percent - self.s_min) / self.s_step))
value = float(self.grid[ti, si])
if np.isnan(value):
return self.fill
return value
def lookup_many(
self,
temperatures: np.ndarray,
socs: np.ndarray,
) -> np.ndarray:
"""Vectorized lookup of many operating points at once.
Args:
temperatures: Array of temperatures in degrees Celsius.
socs: Array of SOC values in percent, same shape as
temperatures.
Returns:
Array of limits with the same shape as the inputs. Entries outside
the hard cutoffs are NaN; other out-of-bounds entries are set to
fill, or to NaN if fill is None.
"""
temperatures = np.asarray(temperatures, dtype=float)
socs = np.asarray(socs, dtype=float)
t_max = self.t_min + (self.n_temp - 1) * self.t_step
s_max = self.s_min + (self.n_soc - 1) * self.s_step
in_bounds = (
(temperatures >= self.t_min)
& (temperatures <= t_max)
& (socs >= self.s_min)
& (socs <= s_max)
)
ti = np.clip(
np.rint((temperatures - self.t_min) / self.t_step).astype(np.int64),
0,
self.n_temp - 1,
)
si = np.clip(
np.rint((socs - self.s_min) / self.s_step).astype(np.int64),
0,
self.n_soc - 1,
)
out = np.full(temperatures.shape, np.nan, dtype=float)
out[in_bounds] = self.grid[ti[in_bounds], si[in_bounds]]
if self.fill is not None:
out[~in_bounds] = self.fill
if self.allowed_min_degC is not None:
out[temperatures < self.allowed_min_degC] = np.nan
if self.allowed_max_degC is not None:
out[temperatures > self.allowed_max_degC] = np.nan
return out
@dataclass(frozen=True)
class BatteryProfile:
"""Ready-to-use charge/discharge limits for a battery.
Instances are produced by ``compile_battery_profile``, usually through the
profile store (``get_profile_store().get(BatteryProfileSettings, name)``). The metadata
fields identify the battery; precomputed limit maps and efficiency curves
answer queries at runtime.
Attributes:
battery_model: Model name of the battery pack.
chemistry: Cell chemistry, if known.
nominal_voltage_v: Nominal voltage, if known.
max_charge_power_w: Optional absolute model charge-power limit.
max_discharge_power_w: Optional absolute model discharge-power limit.
technical_min_soc_percent: Manufacturer-defined lower SOC limit.
technical_max_soc_percent: Manufacturer-defined upper SOC limit.
charge_efficiency: Charge-efficiency curve.
discharge_efficiency: Discharge-efficiency curve.
limit_unit: Unit of the values returned by the limit accessors.
charge_map: Precomputed charge limit map.
discharge_map: Precomputed discharge limit map.
nominal_capacity_wh: Nominal capacity of the battery model [Wh], None for
a generic profile.
full_charge_interval_days: Maximum number of days between two full
charges (SoC recalibration), None if not needed or unknown.
full_charge_detect_percentage: Reported SOC from which the battery
counts as fully charged [%].
"""
battery_model: str
chemistry: Optional[str]
nominal_voltage_v: Optional[float]
max_charge_power_w: Optional[float]
max_discharge_power_w: Optional[float]
technical_min_soc_percent: float
technical_max_soc_percent: float
charge_efficiency: PrecomputedEfficiencyCurve
discharge_efficiency: PrecomputedEfficiencyCurve
limit_unit: str
charge_map: PrecomputedChargeDischargeLimitMap
discharge_map: PrecomputedChargeDischargeLimitMap
nominal_capacity_wh: Optional[float] = None
full_charge_interval_days: Optional[float] = None
full_charge_detect_percentage: int = 99
def max_charge_crate(self, temperature_degC: float, soc_percent: float) -> Optional[float]:
"""Return the maximum charge limit at the given operating point.
Args:
temperature_degC: Cell temperature in degrees Celsius.
soc_percent: State of charge in percent.
Returns:
The precomputed charge limit, or None if charging must be
inhibited at this operating point.
"""
return self.charge_map.lookup(temperature_degC, soc_percent)
def max_discharge_crate(self, temperature_degC: float, soc_percent: float) -> Optional[float]:
"""Return the maximum discharge limit at the given operating point.
Args:
temperature_degC: Cell temperature in degrees Celsius.
soc_percent: State of charge in percent.
Returns:
The precomputed discharge limit, or None if discharging must be
inhibited at this operating point.
"""
return self.discharge_map.lookup(temperature_degC, soc_percent)
def charge_efficiency_at(self, c_rate: float) -> Optional[float]:
"""Return charge efficiency at the given absolute C-rate.
None if the C-rate is outside the curve and the curve rejects it, or the
curve does not interpolate and the C-rate is no breakpoint.
"""
return self.charge_efficiency.lookup(abs(c_rate))
def discharge_efficiency_at(self, c_rate: float) -> Optional[float]:
"""Return discharge efficiency at the given absolute C-rate.
None in the same cases as ``charge_efficiency_at``.
"""
return self.discharge_efficiency.lookup(abs(c_rate))
# ---------------------------------------------------------------------------
# Reference implementation (slow, per query)
# ---------------------------------------------------------------------------
def reference_efficiency_lookup(curve: "EfficiencyCurve", c_rate: float) -> Optional[float]:
"""Return the interpolated efficiency of a curve document at an absolute C-rate.
Reference semantics for ``PrecomputedEfficiencyCurve.lookup``.
Args:
curve: Validated efficiency curve document.
c_rate: Absolute C-rate.
Returns:
The efficiency, or None when the query is outside the curve and the
out-of-range behavior is REJECT, or when interpolation is NONE and the
query is not an exact breakpoint.
"""
if c_rate < curve.c_rate[0] or c_rate > curve.c_rate[-1]:
if curve.out_of_range_behavior == OutOfRangeBehavior.REJECT:
return None
return curve.efficiency[0] if c_rate < curve.c_rate[0] else curve.efficiency[-1]
if curve.interpolation == EfficiencyInterpolation.NONE:
try:
return curve.efficiency[curve.c_rate.index(c_rate)]
except ValueError:
return None
if curve.interpolation == EfficiencyInterpolation.NEAREST:
index = min(
range(len(curve.c_rate)),
key=lambda i: abs(curve.c_rate[i] - c_rate),
)
return curve.efficiency[index]
for index in range(len(curve.c_rate) - 1):
lower = curve.c_rate[index]
upper = curve.c_rate[index + 1]
if lower <= c_rate <= upper:
fraction = (c_rate - lower) / (upper - lower)
return (
curve.efficiency[index]
+ (curve.efficiency[index + 1] - curve.efficiency[index]) * fraction
)
# The endpoint is covered above; this is defensive for floating-point edge cases.
return curve.efficiency[-1]
def _bracket(axis: List[float], x: float) -> tuple[int, int, float]:
"""Return (i_low, i_high, fraction) for x along a strictly increasing axis."""
if x <= axis[0]:
return 0, 0, 0.0
if x >= axis[-1]:
return len(axis) - 1, len(axis) - 1, 0.0
for i in range(len(axis) - 1):
if axis[i] <= x <= axis[i + 1]:
frac = (x - axis[i]) / (axis[i + 1] - axis[i])
return i, i + 1, frac
return len(axis) - 1, len(axis) - 1, 0.0
def reference_limit_lookup(
document: "BatteryProfileSettings",
grid: List[List[float]],
temperature_degC: float,
soc_percent: float,
) -> Optional[float]:
"""Interpolate a value from a limit grid of a profile document.
Applies the document's out_of_range_behavior and honors the union of the
charge and discharge hard cutoffs. Callers that need a direction-specific
cutoff use ``reference_max_charge_crate`` or ``reference_max_discharge_crate``.
Args:
document: Validated battery profile document.
grid: Two-dimensional grid of limit values, indexed by temperature
along rows and SOC along columns.
temperature_degC: Query temperature in degrees Celsius.
soc_percent: Query state of charge in percent.
Returns:
The interpolated limit, or None if the query is outside the union of
the hard cutoffs, or outside the axis range when out_of_range_behavior
is REJECT, or not on an exact grid point when interpolation is NONE.
"""
temps = document.axes.temperature_degC
socs = document.axes.soc_percent
cutoffs = document.hard_cutoffs
t_lo = min(cutoffs.charge_allowed_below_degC, cutoffs.discharge_allowed_below_degC)
t_hi = max(cutoffs.charge_allowed_above_degC, cutoffs.discharge_allowed_above_degC)
if temperature_degC < t_lo or temperature_degC > t_hi:
return None
if document.out_of_range_behavior == OutOfRangeBehavior.REJECT:
if not (temps[0] <= temperature_degC <= temps[-1]):
return None
if not (socs[0] <= soc_percent <= socs[-1]):
return None
if document.interpolation == Interpolation.NEAREST:
ti = min(range(len(temps)), key=lambda i: abs(temps[i] - temperature_degC))
si = min(range(len(socs)), key=lambda i: abs(socs[i] - soc_percent))
return grid[ti][si]
if document.interpolation == Interpolation.NONE:
if temperature_degC in temps and soc_percent in socs:
return grid[temps.index(temperature_degC)][socs.index(soc_percent)]
return None
t0, t1, tf = _bracket(temps, temperature_degC)
s0, s1, sf = _bracket(socs, soc_percent)
v00, v01 = grid[t0][s0], grid[t0][s1]
v10, v11 = grid[t1][s0], grid[t1][s1]
v0 = v00 + (v01 - v00) * sf
v1 = v10 + (v11 - v10) * sf
return v0 + (v1 - v0) * tf
def reference_max_charge_crate(
document: "BatteryProfileSettings", temperature_degC: float, soc_percent: float
) -> Optional[float]:
"""Return the maximum charge limit of a profile document by per-query interpolation.
Args:
document: Validated battery profile document.
temperature_degC: Cell temperature in degrees Celsius.
soc_percent: State of charge in percent.
Returns:
The interpolated charge limit, or None if charging must be inhibited at
this operating point.
"""
c = document.hard_cutoffs
if not (c.charge_allowed_below_degC <= temperature_degC <= c.charge_allowed_above_degC):
return None
return reference_limit_lookup(
document, document.charge_limit_crate.values, temperature_degC, soc_percent
)
def reference_max_discharge_crate(
document: "BatteryProfileSettings", temperature_degC: float, soc_percent: float
) -> Optional[float]:
"""Return the maximum discharge limit of a profile document by per-query interpolation.
Args:
document: Validated battery profile document.
temperature_degC: Cell temperature in degrees Celsius.
soc_percent: State of charge in percent.
Returns:
The interpolated discharge limit, or None if discharging must be
inhibited at this operating point.
"""
c = document.hard_cutoffs
if not (c.discharge_allowed_below_degC <= temperature_degC <= c.discharge_allowed_above_degC):
return None
return reference_limit_lookup(
document, document.discharge_limit_crate.values, temperature_degC, soc_percent
)
# ---------------------------------------------------------------------------
# Compilation
# ---------------------------------------------------------------------------
def precompute_limit_maps(
document: "BatteryProfileSettings",
t_step: float = 1.0,
s_step: float = 1.0,
apply_cutoffs: bool = True,
) -> tuple[PrecomputedChargeDischargeLimitMap, PrecomputedChargeDischargeLimitMap]:
"""Resample both limit maps of a profile document onto dense, uniform grids.
The expensive interpolation is performed once here, using the document's
interpolation and out_of_range_behavior. The returned objects answer each
runtime query with a single array index lookup.
If apply_cutoffs is true, the precomputed grids extend beyond the axis
range far enough to cover the hard cutoffs, and the maps keep the exact
cutoff temperatures: a lookup outside the respective cutoff returns None,
decided on the exact temperature, not on the rounded grid cell. Cells
between the axis range and the cutoff are filled according to
out_of_range_behavior, i.e. with the clamped edge value under
CLAMP_TO_NEAREST or with NaN under REJECT.
If apply_cutoffs is false, the precomputed grids cover exactly the axis
range and the caller is responsible for checking the cutoffs separately.
Args:
document: Validated battery profile document.
t_step: Spacing of the precomputed temperature axis, in degrees
Celsius. Defaults to 1.0.
s_step: Spacing of the precomputed SOC axis, in percent.
Defaults to 1.0.
apply_cutoffs: Whether to bake the hard cutoffs into the precomputed
grids. Defaults to True.
Returns:
A pair (charge_map, discharge_map) of precomputed lookup objects.
Raises:
ValueError: If t_step or s_step is not positive, or if the axis span
is smaller than the step size.
"""
if t_step <= 0 or s_step <= 0:
raise ValueError("t_step and s_step must be positive")
temps = np.asarray(document.axes.temperature_degC, dtype=float)
socs = np.asarray(document.axes.soc_percent, dtype=float)
cutoffs = document.hard_cutoffs
if apply_cutoffs:
t_lo = min(
temps[0],
cutoffs.charge_allowed_below_degC,
cutoffs.discharge_allowed_below_degC,
)
t_hi = max(
temps[-1],
cutoffs.charge_allowed_above_degC,
cutoffs.discharge_allowed_above_degC,
)
else:
t_lo, t_hi = float(temps[0]), float(temps[-1])
t_grid = np.arange(t_lo, t_hi + t_step * 0.5, t_step)
s_grid = np.arange(socs[0], socs[-1] + s_step * 0.5, s_step)
if t_grid.size < 1 or s_grid.size < 1:
raise ValueError("axis span smaller than step size")
def resample(
values: List[List[float]],
cutoff_lo: Optional[float],
cutoff_hi: Optional[float],
) -> PrecomputedChargeDischargeLimitMap:
out = np.empty((t_grid.size, s_grid.size), dtype=float)
for i, t in enumerate(t_grid):
# The cutoffs are not baked into the cells: a cell stands for half a
# step around its temperature, so the map checks them exactly.
for j, s in enumerate(s_grid):
v = reference_limit_lookup(document, values, float(t), float(s))
out[i, j] = np.nan if v is None else float(v)
# Compiled profiles are cached and shared, so the grid must not change.
out.setflags(write=False)
return PrecomputedChargeDischargeLimitMap(
grid=out,
t_min=float(t_grid[0]),
t_step=float(t_step),
s_min=float(s_grid[0]),
s_step=float(s_step),
n_temp=int(t_grid.size),
n_soc=int(s_grid.size),
fill=None,
allowed_min_degC=cutoff_lo,
allowed_max_degC=cutoff_hi,
)
charge_map = resample(
document.charge_limit_crate.values,
cutoffs.charge_allowed_below_degC if apply_cutoffs else None,
cutoffs.charge_allowed_above_degC if apply_cutoffs else None,
)
discharge_map = resample(
document.discharge_limit_crate.values,
cutoffs.discharge_allowed_below_degC if apply_cutoffs else None,
cutoffs.discharge_allowed_above_degC if apply_cutoffs else None,
)
return charge_map, discharge_map
def compile_battery_profile(
document: "BatteryProfileSettings", t_step: float = 1.0, s_step: float = 1.0
) -> BatteryProfile:
"""Precompute a profile document into a ready-to-use BatteryProfile.
Args:
document: Validated battery profile document.
t_step: Spacing of the precomputed temperature axis, in degrees
Celsius. Defaults to 1.0.
s_step: Spacing of the precomputed SOC axis, in percent.
Defaults to 1.0.
Returns:
A ready-to-use BatteryProfile object.
Raises:
ValueError: If t_step or s_step is not positive.
"""
charge_map, discharge_map = precompute_limit_maps(document, t_step=t_step, s_step=s_step)
return BatteryProfile(
battery_model=document.battery_model,
chemistry=document.chemistry,
nominal_voltage_v=document.nominal_voltage_v,
max_charge_power_w=document.max_charge_power_w,
max_discharge_power_w=document.max_discharge_power_w,
technical_min_soc_percent=document.technical_min_soc_percent,
technical_max_soc_percent=document.technical_max_soc_percent,
charge_efficiency=PrecomputedEfficiencyCurve.from_curve(document.charge_efficiency),
discharge_efficiency=PrecomputedEfficiencyCurve.from_curve(document.discharge_efficiency),
limit_unit=document.units.limit,
charge_map=charge_map,
discharge_map=discharge_map,
nominal_capacity_wh=document.nominal_capacity_wh,
full_charge_interval_days=document.full_charge_interval_days,
full_charge_detect_percentage=document.full_charge_detect_percentage,
)
@@ -0,0 +1,607 @@
"""Storage of device profile documents.
A device profile is a JSON document that describes a device model (e.g. a
battery or an inverter model). Each profile type is a ``ProfileDocument``
subclass: it validates the document, names its kind (the profile directory)
and compiles the document into its runtime form. Defining a subclass with a
``profile_kind`` registers it, so the kind can be looked up by name (e.g. from
a REST path), see ``profile_type``.
One store serves all profile types; callers pass the profile type::
from akkudoktoreos.devices.profile.batteryprofile import BatteryProfileSettings
from akkudoktoreos.devices.profile.profilestore import get_profile_store
profile = get_profile_store().get(BatteryProfileSettings, "generic-lfp")
The store handles what is common to all profile types:
- locating documents named ``<name>.json``, searching in order
1. the user data directory: ``<general.data_folder_path>/profile/<kind>``
2. the packaged data directory: ``<package_root_path>/data/profile/<kind>``
- listing, loading and validating documents with the profile type,
- saving and deleting documents in the user data directory (packaged profiles
are never modified; deleting a user profile makes a packaged profile of the
same name apply again),
- caching the compiled form per profile type, file and compile options. Saving
or deleting drops the cache entries of the file; a file changed by other
means is recompiled when its modification time or size changes.
Concurrency: the store is a process-wide singleton and thread safe. Saves are
atomic (a reader sees the old or the new document, never a partial one) and
serialized per file. The methods block on file I/O; async code calls them
through ``asyncio.to_thread``.
"""
import functools
import inspect
import os
import re
import tempfile
import threading
import time
from pathlib import Path
from typing import (
Any,
Callable,
ClassVar,
Generic,
Hashable,
Literal,
NamedTuple,
Optional,
TypeVar,
Union,
)
import cachebox
from loguru import logger
from pydantic import BaseModel, Field
from akkudoktoreos.core.coreabc import SingletonMixin, get_config
CompiledT = TypeVar("CompiledT")
ResultT = TypeVar("ResultT")
PROFILE_SUFFIX: str = ".json"
# Profile names: one file name stem, the same on every platform (no separators,
# drive letters or "..").
PROFILE_NAME_PATTERN = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,99}")
# Names the REST API uses for other paths below /v1/profile/{kind}/.
RESERVED_PROFILE_NAMES: frozenset[str] = frozenset({"schema"})
# How long get() returns a compiled profile without checking its file again [s].
# Saving or deleting through the store applies at once; an edit of the file
# outside EOS applies after at most this time.
PROFILE_RECHECK_INTERVAL_S: float = 1.0
# Maximum number of recently checked profiles (type, name and options) kept.
PROFILE_RECENT_MAXSIZE: int = 256
# On Windows, replacing a profile file fails while a reader has it open, and a
# read fails while the file is being replaced (PermissionError). Such sharing
# violations last only as long as the other access, so the store retries them.
# Elsewhere a PermissionError is final.
_RETRY_SHARING_VIOLATIONS: bool = os.name == "nt"
# Number of attempts and the wait between them [s] for a sharing violation.
SHARING_VIOLATION_ATTEMPTS: int = 50
SHARING_VIOLATION_RETRY_DELAY_S: float = 0.01
# Profile kinds are directory names, keep them simple and portable.
_KIND_PATTERN = re.compile(r"^[a-z][a-z0-9]*$")
# Registered profile types by kind, see ProfileDocument.__pydantic_init_subclass__.
_PROFILE_TYPES: dict[str, type["ProfileDocument[Any]"]] = {}
_PROFILE_TYPES_LOCK = threading.Lock()
def _validated_kind(kind: Any, owner: str) -> str:
"""Return ``kind`` if it is a valid profile kind.
Raises:
ValueError: If the kind is not lowercase letters and digits.
"""
if not isinstance(kind, str) or not _KIND_PATTERN.match(kind):
raise ValueError(
f"Invalid profile kind {kind!r} of {owner}: use lowercase letters and digits."
)
return kind
class ProfileDocument(BaseModel, Generic[CompiledT]):
"""Base class of profile documents, parametrized by the compiled form.
Subclasses set ``profile_kind`` and implement ``compile_profile``::
class InverterProfileSettings(SettingsBaseModel, ProfileDocument[InverterProfile]):
profile_kind: ClassVar[str] = "inverter"
def compile_profile(self, **options: Any) -> InverterProfile: ...
A subclass that sets ``profile_kind`` itself is registered for that kind.
Attributes:
profile_kind: Profile kind, also the name of the profile subdirectory,
e.g. ``"battery"``. Lowercase letters and digits only.
"""
profile_kind: ClassVar[str]
@classmethod
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
"""Register a subclass that sets its own ``profile_kind``.
Raises:
ValueError: If the kind is invalid, or another profile type already
has this kind.
"""
super().__pydantic_init_subclass__(**kwargs)
if "profile_kind" not in cls.__dict__:
return # e.g. the parametrized ProfileDocument[X] or a subclass of a profile type
kind = _validated_kind(cls.__dict__["profile_kind"], cls.__name__)
with _PROFILE_TYPES_LOCK:
registered = _PROFILE_TYPES.get(kind)
if registered is not None and (
registered.__module__,
registered.__qualname__,
) != (cls.__module__, cls.__qualname__):
raise ValueError(
f"Profile kind '{kind}' of {cls.__qualname__} is already used by "
f"{registered.__module__}.{registered.__qualname__}."
)
# The same class defined again (e.g. a module reload) replaces the old one.
_PROFILE_TYPES[kind] = cls
def compile_profile(self, **options: Any) -> CompiledT:
"""Compile this document into its runtime form.
Keyword options come from ``ProfileStore.get`` and are part of the
cache key, so they must be hashable.
"""
raise NotImplementedError(f"{type(self).__name__} does not implement compile_profile")
DocumentT = TypeVar("DocumentT", bound=ProfileDocument[Any])
def profile_kind(document_type: type[ProfileDocument[Any]]) -> str:
"""Return the validated profile kind of a profile type.
Raises:
ValueError: If the profile type has no kind or an invalid one.
"""
return _validated_kind(getattr(document_type, "profile_kind", None), document_type.__name__)
def profile_type(kind: str) -> type[ProfileDocument[Any]]:
"""Return the profile type registered for a kind.
Raises:
KeyError: If no profile type is registered for the kind.
"""
with _PROFILE_TYPES_LOCK:
try:
return _PROFILE_TYPES[kind]
except KeyError:
raise KeyError(f"Unknown profile kind '{kind}'") from None
def profile_kinds() -> list[str]:
"""Return the registered profile kinds, sorted."""
with _PROFILE_TYPES_LOCK:
return sorted(_PROFILE_TYPES)
ProfileSource = Literal["user", "package"]
class ProfileEntry(BaseModel):
"""A profile available in the store."""
name: str = Field(
json_schema_extra={
"description": "Profile name, without the '.json' suffix.",
"examples": ["generic-lfp"],
}
)
source: ProfileSource = Field(
json_schema_extra={
"description": (
"Where the document in effect comes from: 'user' (user data directory, "
"editable) or 'package' (shipped with EOS, read only)."
),
"examples": ["package"],
}
)
packaged: bool = Field(
json_schema_extra={
"description": (
"Whether a packaged profile of this name exists. Deleting a user profile "
"that overrides it makes the packaged profile apply again."
),
"examples": [True],
}
)
class _CacheKey(NamedTuple):
"""Cache slot of a compiled profile: profile type, file and compile options."""
document_type: type[ProfileDocument[Any]]
path: Path
options: tuple[tuple[str, Hashable], ...]
class _FileState(NamedTuple):
"""State of a profile file the compiled profile was built from."""
mtime_ns: int
size: int
@functools.lru_cache(maxsize=None)
def _compile_signature(document_type: type[ProfileDocument[Any]]) -> inspect.Signature:
"""Return the signature of ``compile_profile`` of a profile type (cached)."""
return inspect.signature(document_type.compile_profile)
def _options_key(
document_type: type[ProfileDocument[Any]], options: dict[str, Any]
) -> tuple[tuple[str, Hashable], ...]:
"""Return the cache key part of compile options, with defaults filled in.
Calls that compile the same way share a cache entry, e.g. ``get(T, name)``
and ``get(T, name, t_step=1.0)`` if ``1.0`` is the default of ``t_step``.
Raises:
TypeError: If the options do not fit the signature of ``compile_profile``.
"""
signature = _compile_signature(document_type)
bound = signature.bind(None, **options)
bound.apply_defaults()
effective: dict[str, Any] = {}
for parameter_name, value in list(bound.arguments.items())[1:]: # skip self
if signature.parameters[parameter_name].kind is inspect.Parameter.VAR_KEYWORD:
effective.update(value)
else:
effective[parameter_name] = value
return tuple(sorted(effective.items()))
def _profile_names(directory: Path) -> set[str]:
"""Return the names of the profile documents in a directory.
Files whose stem is no valid profile name are skipped.
"""
if not directory.is_dir():
return set()
return {
path.stem
for path in directory.glob(f"*{PROFILE_SUFFIX}")
if path.is_file()
and PROFILE_NAME_PATTERN.fullmatch(path.stem)
and path.stem not in RESERVED_PROFILE_NAMES
}
def _retry_sharing_violation(operation: Callable[[], ResultT]) -> ResultT:
"""Run a file operation, retrying it while Windows reports a sharing violation.
Args:
operation: The file operation, e.g. a read or an ``os.replace``.
Returns:
The result of the operation.
Raises:
PermissionError: If the operation still fails after all attempts, or at
once outside Windows.
"""
attempt = 1
while True:
try:
return operation()
except PermissionError:
if not _RETRY_SHARING_VIOLATIONS or attempt >= SHARING_VIOLATION_ATTEMPTS:
raise
attempt += 1
time.sleep(SHARING_VIOLATION_RETRY_DELAY_S)
class ProfileStore(SingletonMixin):
"""Store for profile documents of all profile types (process-wide singleton).
Every method takes the profile type (a ``ProfileDocument`` subclass) that
selects the profile directory, the document validation and the compile
function. Use ``get_profile_store()``.
"""
def __init__(self) -> None:
if hasattr(self, "_initialized"):
return
self._compiled: dict[_CacheKey, tuple[_FileState, Any]] = {}
# Profiles checked recently: (type, name, options) -> compiled profile,
# expiring PROFILE_RECHECK_INTERVAL_S after they were checked.
self._recent: cachebox.TTLCache = cachebox.TTLCache(
PROFILE_RECENT_MAXSIZE, PROFILE_RECHECK_INTERVAL_S
)
# Incremented whenever profiles are saved, deleted or the cache is cleared:
# a lookup that started before must not record its result as recent.
self._generation = 0
# Guards _compiled, _write_locks, _generation and the generation check
# of _recent; never held for I/O.
self._cache_lock = threading.Lock()
self._write_locks: dict[Path, threading.Lock] = {}
self._initialized = True
# --- locations ---------------------------------------------------------
def user_dir(self, document_type: type[ProfileDocument[Any]]) -> Path:
"""Return the user data directory of a profile type (the write location)."""
return get_config().general.data_folder_path / "profile" / profile_kind(document_type)
def package_dir(self, document_type: type[ProfileDocument[Any]]) -> Path:
"""Return the packaged data directory of a profile type (read only)."""
return get_config().package_root_path / "data" / "profile" / profile_kind(document_type)
@staticmethod
def _filename(document_type: type[ProfileDocument[Any]], name: str) -> str:
"""Return the file name of a profile, rejecting invalid names.
A name is a single file name stem: letters, digits, ``.``, ``_`` and
``-``, starting with a letter or digit, at most 100 characters. That
rules out separators, drive letters and ``..`` on every platform.
Raises:
ValueError: If the name is invalid or reserved.
"""
kind = profile_kind(document_type)
if not PROFILE_NAME_PATTERN.fullmatch(name):
raise ValueError(
f"Invalid {kind} profile name '{name}': use letters, digits, '.', '_' and "
"'-', starting with a letter or digit (at most 100 characters)."
)
if name in RESERVED_PROFILE_NAMES:
raise ValueError(f"The {kind} profile name '{name}' is reserved.")
return f"{name}{PROFILE_SUFFIX}"
def path(self, document_type: type[ProfileDocument[Any]], name: str) -> tuple[Path, bool]:
"""Locate the document of a profile.
Searches the user data directory first and the packaged data directory
second.
Args:
document_type: Profile type.
name: Profile name without the ``.json`` suffix.
Returns:
A 2-tuple ``(path, exists)``. If the document exists, ``path`` is its
location; otherwise it is the candidate path in the user data
directory (the preferred write location).
Raises:
ValueError: If the profile kind or the name is invalid.
"""
filename = self._filename(document_type, name)
user_dir = self.user_dir(document_type)
for directory in (user_dir, self.package_dir(document_type)):
candidate = directory / filename
if candidate.is_file():
return candidate, True
return user_dir / filename, False
def existing_path(self, document_type: type[ProfileDocument[Any]], name: str) -> Path:
"""Return the path of an existing profile document.
Raises:
ValueError: If the profile kind or the name is invalid.
FileNotFoundError: If no document exists for the name. The message
names the expected path and the searched directories.
"""
path, exists = self.path(document_type, name)
if not exists:
raise FileNotFoundError(
f"No {profile_kind(document_type)} profile for '{name}': expected {path}. "
f"Available profiles are the {PROFILE_SUFFIX} files in {path.parent} "
f"and {self.package_dir(document_type)}."
)
return path
def list_profiles(self, document_type: type[ProfileDocument[Any]]) -> list[ProfileEntry]:
"""Return the profiles of a profile type, sorted by name.
A user profile that has the name of a packaged profile overrides it and
is listed once, with ``source`` ``"user"``.
"""
user = _profile_names(self.user_dir(document_type))
package = _profile_names(self.package_dir(document_type))
return [
ProfileEntry(
name=name,
source="user" if name in user else "package",
packaged=name in package,
)
for name in sorted(user | package)
]
# --- documents -----------------------------------------------------------
def load_file(self, document_type: type[DocumentT], path: Union[str, Path]) -> DocumentT:
"""Load and validate a profile document from an explicit path.
Raises:
FileNotFoundError: If the file does not exist.
ValueError: If the document fails validation.
"""
content = _retry_sharing_violation(lambda: Path(path).read_text(encoding="utf-8"))
return document_type.model_validate_json(content)
def load(self, document_type: type[DocumentT], name: str) -> DocumentT:
"""Load and validate the document of a profile by name, not compiled.
Raises:
ValueError: If the profile kind or the name is invalid, or the
document fails validation.
FileNotFoundError: If no document exists for the name.
"""
return self.load_file(document_type, self.existing_path(document_type, name))
def _write_lock(self, path: Path) -> threading.Lock:
"""Return the lock that serializes writes of one profile file."""
with self._cache_lock:
return self._write_locks.setdefault(path, threading.Lock())
def _invalidate(self, path: Path) -> None:
"""Drop the compiled profiles built from a file."""
with self._cache_lock:
for key in [key for key in self._compiled if key.path == path]:
del self._compiled[key]
# The name may now resolve to another file (e.g. a user profile that
# overrides a packaged one), so forget all recent checks.
self._recent.clear()
self._generation += 1
def save(self, name: str, document: ProfileDocument[Any]) -> Path:
"""Save a profile document to the user data directory of its type.
The file is replaced atomically: concurrent readers see the old or the
new document. On Windows, a replace or a read that collides with the
other is retried briefly. An existing file with the same name is overwritten and
missing parent directories are created. The packaged data directory is
never written.
Args:
name: Profile name without the ``.json`` suffix.
document: The document to save; its type selects the directory.
Returns:
The path the document was written to.
Raises:
ValueError: If the profile kind or the name is invalid.
OSError: If the file or its parent directories cannot be written.
"""
document_type = type(document)
path = self.user_dir(document_type) / self._filename(document_type, name)
content = document.model_dump_json(indent=2)
with self._write_lock(path):
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(dir=path.parent, prefix=f".{path.name}.", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as tmp:
tmp.write(content)
tmp.flush()
os.fsync(tmp.fileno())
_retry_sharing_violation(lambda: os.replace(tmp_name, path))
except BaseException:
Path(tmp_name).unlink(missing_ok=True)
raise
self._invalidate(path)
logger.debug("Saved {} profile '{}' to {}", profile_kind(document_type), name, path)
return path
def delete(self, document_type: type[ProfileDocument[Any]], name: str) -> Path:
"""Delete a profile document from the user data directory.
A packaged profile of the same name applies again afterwards.
Returns:
The path of the deleted file.
Raises:
ValueError: If the profile kind or the name is invalid.
PermissionError: If only a packaged profile exists for the name.
FileNotFoundError: If no profile exists for the name.
"""
filename = self._filename(document_type, name)
path = self.user_dir(document_type) / filename
with self._write_lock(path):
if path.exists() and not path.is_file():
raise ValueError(
f"The {profile_kind(document_type)} profile '{name}' is not a file: {path}."
)
try:
path.unlink()
except FileNotFoundError:
if (self.package_dir(document_type) / filename).is_file():
raise PermissionError(
f"The {profile_kind(document_type)} profile '{name}' is packaged "
"with EOS and cannot be deleted."
) from None
raise FileNotFoundError(
f"No {profile_kind(document_type)} profile for '{name}' in {path.parent}."
) from None
self._invalidate(path)
logger.debug("Deleted {} profile '{}' at {}", profile_kind(document_type), name, path)
return path
# --- compiled profiles ---------------------------------------------------
def get(
self, document_type: type[ProfileDocument[CompiledT]], name: str, **options: Any
) -> CompiledT:
"""Return the compiled form of a profile, cached per type, file and options.
Args:
document_type: Profile type.
name: Profile name without the ``.json`` suffix.
**options: Keyword options passed to ``compile_profile``. They must
be hashable; each set of effective options (defaults filled in)
has its own cache entry.
Returns:
The compiled profile.
Raises:
ValueError: If the profile kind or the name is invalid, the document
fails validation or the compile step rejects it.
FileNotFoundError: If no document exists for the name.
"""
options_key = _options_key(document_type, options)
recent_key = (document_type, name, options_key)
recent: Optional[CompiledT] = self._recent.get(recent_key)
if recent is not None:
return recent
with self._cache_lock:
generation = self._generation
path = self.existing_path(document_type, name)
stat = path.stat()
state = _FileState(stat.st_mtime_ns, stat.st_size)
key = _CacheKey(document_type, path, options_key)
with self._cache_lock:
cached = self._compiled.get(key)
if cached is not None and cached[0] == state:
compiled: CompiledT = cached[1]
else:
compiled = self.load_file(document_type, path).compile_profile(**options)
# Cache only if nothing was saved or deleted meanwhile: this lookup may have
# read the file before a save, and the saved file may have the same size
# and, on file systems with coarse timestamps, the same modification time.
with self._cache_lock:
if self._generation == generation:
self._compiled[key] = (state, compiled)
self._recent[recent_key] = compiled
return compiled
def clear_cache(self, document_type: Optional[type[ProfileDocument[Any]]] = None) -> None:
"""Drop compiled profiles, e.g. after the data directories changed.
Args:
document_type: Drop only the profiles of this type; all if None.
"""
with self._cache_lock:
self._recent.clear()
self._generation += 1
if document_type is None:
self._compiled.clear()
return
for key in [key for key in self._compiled if key.document_type is document_type]:
del self._compiled[key]
def get_profile_store() -> ProfileStore:
"""Return the store of all device profiles (process-wide singleton)."""
return ProfileStore()
@@ -0,0 +1,106 @@
"""Reading the battery operating point from measurements.
Measurement access is asynchronous (``DataSequence.key_to_value``), so it is
kept out of the battery settings model and out of the parameter resolution.
``get_measured_value`` is the only place that talks to the measurement
subsystem; ``measure_operating_point`` turns the measured values of one battery
into a ``BatteryOperatingPoint``.
"""
import math
from typing import TYPE_CHECKING, Optional
from loguru import logger
from akkudoktoreos.devices.settings.batteryresolution import (
BATTERY_DEFAULT_SOC_FACTOR,
BATTERY_DEFAULT_TEMPERATURE_DEGC,
BatteryOperatingPoint,
)
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime, to_duration
if TYPE_CHECKING:
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
# Maximum age of a measurement that is still taken as "current".
BATTERY_MEASUREMENT_MAX_AGE: str = "1 hour"
async def get_measured_value(key: str, target_datetime: DateTime) -> Optional[float]:
"""Return the measured value for a measurement key nearest to a datetime.
Args:
key: Measurement key.
target_datetime: Datetime the value is looked up for.
Returns:
The value nearest to target_datetime within BATTERY_MEASUREMENT_MAX_AGE,
or None if there is no such value or the measurement cannot be queried.
"""
try:
from akkudoktoreos.core.coreabc import get_measurement
value = await get_measurement().key_to_value(
key=key,
target_datetime=target_datetime,
time_window=to_duration(BATTERY_MEASUREMENT_MAX_AGE),
)
return None if value is None else float(value)
except Exception as exc: # a missing measurement falls back to the defaults
logger.warning(f"Battery measurement '{key}' not available: {exc}")
return None
def _soc_factor_or_default(value: Optional[float]) -> float:
"""Return a measured SoC factor clamped to [0, 1], or the default."""
if value is None or not math.isfinite(value):
return BATTERY_DEFAULT_SOC_FACTOR
return min(max(value, 0.0), 1.0)
def _temperature_or_default(value: Optional[float]) -> float:
"""Return a measured battery temperature [degC], or the default."""
if value is None or not math.isfinite(value):
return BATTERY_DEFAULT_TEMPERATURE_DEGC
return value
def _power_magnitude_or_none(value: Optional[float]) -> Optional[float]:
"""Return a measured power as positive magnitude [W], or None.
A missing, non-finite or zero measurement means the battery is not
operating in that direction, so there is no meaningful C-rate.
"""
if value is None or not math.isfinite(value) or value == 0.0:
return None
return abs(value)
async def measure_operating_point(
settings: "BatteriesCommonSettings", target_datetime: Optional[DateTime] = None
) -> BatteryOperatingPoint:
"""Read the current operating point of a battery from its measurements.
Missing, non-finite or unavailable measurements fall back to the reference
operating point (25 degC, SoC 0.5, no power); the SoC factor is clamped to
[0.0 ... 1.0]. This function never raises for missing measurements.
Args:
settings: Battery device settings, providing the measurement keys.
target_datetime: Datetime the measurements are looked up for, now if None.
Returns:
The measured operating point.
"""
target = to_datetime(target_datetime) if target_datetime is not None else to_datetime()
temperature = await get_measured_value(settings.measurement_key_temperature_degc, target)
soc_factor = await get_measured_value(settings.measurement_key_soc_factor, target)
charge_power = await get_measured_value(settings.measurement_key_charge_power_w, target)
discharge_power = await get_measured_value(settings.measurement_key_discharge_power_w, target)
return BatteryOperatingPoint(
temperature_degC=_temperature_or_default(temperature),
soc_factor=_soc_factor_or_default(soc_factor),
charge_power_w=_power_magnitude_or_none(charge_power),
discharge_power_w=_power_magnitude_or_none(discharge_power),
)
@@ -0,0 +1,694 @@
"""Resolution of the effective battery parameters.
Every effective battery parameter (capacity, power limits, efficiencies, SOC
range) comes from exactly two sources:
- the compiled battery profile (``BatteryProfile``): limit maps, efficiency
curves, model power limits, technical SOC bounds and, unless it is a generic
profile, the nominal capacity,
- the battery device settings (``BatteriesCommonSettings``): the
``profile_adjustment`` of this unit (capacity, installation power limits) and
the operational SOC range.
The profile is evaluated at an operating point (``BatteryOperatingPoint``):
temperature, SoC and the current charge / discharge power. There are no built-in
fallback values: if the parameters cannot be resolved, a
``BatteryResolutionError`` names the battery and the reason.
The one exception is the parameter preparation of the optimizations, which
always succeeds for a configuration error: it fills the values that cannot be
resolved with the former defaults (``BATTERY_FALLBACK_*``) and reports them,
see ``BatteriesCommonSettings.resolve_operating_parameters_or_defaults``.
The functions here are pure: they neither load profiles nor read measurements.
Loading the profile is done by the profile store, reading the operating point by
``batterymeasurement``.
"""
import math
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Callable, Optional
if TYPE_CHECKING:
from akkudoktoreos.devices.profile.batteryprofileruntime import (
BatteryProfile,
PrecomputedEfficiencyCurve,
)
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
# Operating point assumed when the battery measurement provides no value.
BATTERY_DEFAULT_SOC_FACTOR: float = 0.5
BATTERY_DEFAULT_TEMPERATURE_DEGC: float = 25.0
# Defaults of the battery settings before battery profiles. Used only by the
# parameter preparation of the optimizations, for values the configuration
# does not allow to resolve.
BATTERY_FALLBACK_CAPACITY_WH: int = 8000
BATTERY_FALLBACK_MAX_CHARGE_POWER_W: float = 5000.0
BATTERY_FALLBACK_MAX_DISCHARGE_POWER_W: float = 5000.0
BATTERY_FALLBACK_CHARGING_EFFICIENCY: float = 0.88
BATTERY_FALLBACK_DISCHARGING_EFFICIENCY: float = 0.88
# Power limits of the installation for a generic profile without own power
# limits, if the profile adjustment sets none: the former default, instead of
# the C-rate of the generic profile (e.g. 60 kW for a 60 kWh electric vehicle).
BATTERY_GENERIC_MAX_CHARGE_POWER_W: float = BATTERY_FALLBACK_MAX_CHARGE_POWER_W
BATTERY_GENERIC_MAX_DISCHARGE_POWER_W: float = BATTERY_FALLBACK_MAX_DISCHARGE_POWER_W
# Full charge detection threshold if neither the profile adjustment nor a usable
# profile provides one; the default of the profile document.
BATTERY_DEFAULT_FULL_CHARGE_DETECT_PERCENTAGE: int = 99
class BatteryResolutionError(ValueError):
"""The effective parameters of a battery cannot be resolved.
The message names the battery device and the reason, e.g. a generic profile
without a capacity in the profile adjustment. The cause is the configuration
(settings or battery profile); the operating point never causes it.
"""
class BatterySocRangeError(BatteryResolutionError):
"""The configured SoC range lies outside the technical SoC bounds of the profile."""
@dataclass(frozen=True)
class BatteryOperatingPoint:
"""Operating point the battery profile is evaluated at.
The defaults form the reference operating point used when no measurement
is taken into account.
Attributes:
temperature_degC: Battery temperature [degC].
soc_factor: State of charge [0.0 ... 1.0].
charge_power_w: Current charge power as positive magnitude [W], None if
the battery is not charging. Used for the charging efficiency.
discharge_power_w: Current discharge power as positive magnitude [W],
None if the battery is not discharging. Used for the discharging
efficiency.
"""
temperature_degC: float = BATTERY_DEFAULT_TEMPERATURE_DEGC
soc_factor: float = BATTERY_DEFAULT_SOC_FACTOR
charge_power_w: Optional[float] = None
discharge_power_w: Optional[float] = None
@dataclass(frozen=True)
class BatteryOperatingParameters:
"""Consistent snapshot of the effective battery parameters.
All values belong to the same operating point and the same measurements, so
one snapshot can be used for a whole optimizer conversion even if
measurements change meanwhile.
Attributes:
temperature_degC: Battery temperature the profile was evaluated at.
soc_factor: State of charge [0.0 ... 1.0] the profile was evaluated at.
capacity_wh: Effective nominal capacity [Wh].
charging_efficiency: Charging efficiency [0.01 ... 1.00].
discharging_efficiency: Discharging efficiency [0.01 ... 1.00].
max_charge_power_w: Effective maximum charging power [W].
max_discharge_power_w: Effective maximum discharging power [W], 0 if
discharging is inhibited at the operating point.
min_soc_percentage: Effective minimum SoC [%], within technical bounds.
max_soc_percentage: Effective maximum SoC [%], within technical bounds.
profile: Compiled battery profile the snapshot was resolved from, None
if it was resolved without profile. Conversions take the power
tables from it, so they need not load the profile again and use the
same profile version as the snapshot. Not part of comparisons.
"""
temperature_degC: float
soc_factor: float
capacity_wh: int
charging_efficiency: float
discharging_efficiency: float
max_charge_power_w: float
max_discharge_power_w: float
min_soc_percentage: int
max_soc_percentage: int
profile: Optional["BatteryProfile"] = field(default=None, compare=False, repr=False)
@dataclass(frozen=True)
class BatteryFullCharge:
"""Effective periodic full charge policy of a stationary battery.
Attributes:
interval_days: Maximum number of days between two full charges [d], None
if the periodic full charge is disabled.
soc_percentage: State of charge a full charge has to reach [%], within
the technical maximum SOC of the profile.
detect_percentage: Measured state of charge from which the battery
counts as fully charged [%], at most ``soc_percentage``.
"""
interval_days: Optional[float]
soc_percentage: int
detect_percentage: int
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _resolution_error(
settings: "BatteriesCommonSettings",
reason: str,
error_type: type[BatteryResolutionError] = BatteryResolutionError,
) -> BatteryResolutionError:
"""Return a resolution error naming the battery device."""
return error_type(f"Battery '{settings.device_id}': {reason}")
def profile_limit_to_power_w(limit: float, profile: "BatteryProfile", capacity_wh: int) -> float:
"""Convert a profile limit value into power [W] according to its unit.
Args:
limit: Limit value in the unit of the profile (C-rate, W or A).
profile: Compiled battery profile.
capacity_wh: Effective nominal capacity [Wh], used for C-rate limits.
Returns:
The limit as power [W].
Raises:
ValueError: If the unit is not supported, or the limit is in A and the
profile has no nominal voltage.
"""
unit = profile.limit_unit
if unit == "C-rate":
return limit * capacity_wh
if unit == "W":
return limit
if unit == "A":
if profile.nominal_voltage_v is None:
raise ValueError(
f"Battery profile '{profile.battery_model}' has limits in A "
"but no nominal_voltage_v to convert them to power."
)
return limit * profile.nominal_voltage_v
raise ValueError(f"Unsupported limit unit '{unit}' in battery profile.")
def _installation_cap_w(
profile: "BatteryProfile",
adjusted_cap_w: Optional[float],
profile_cap_w: Optional[float],
generic_default_w: float,
) -> Optional[float]:
"""Return the power limit of the installation [W], None for no limit.
The profile adjustment sets it. Without one, a generic profile (no nominal
capacity of its own) without own power limit gets ``generic_default_w``; a
profile of a battery model needs no installation limit.
Args:
profile: Compiled battery profile.
adjusted_cap_w: Power limit of the profile adjustment, None if not set.
profile_cap_w: Absolute power limit of the profile, None if not set.
generic_default_w: Limit for a generic profile without own limit.
Returns:
The power limit of the installation, None if it does not limit.
"""
if adjusted_cap_w is not None:
return adjusted_cap_w
if profile.nominal_capacity_wh is None and profile_cap_w is None:
return generic_default_w
return None
def _limited_power_w(
limit: float,
profile: "BatteryProfile",
capacity_wh: int,
profile_cap_w: Optional[float],
installation_cap_w: Optional[float],
) -> float:
"""Convert a profile limit to watts and apply the profile and installation caps."""
power_w = profile_limit_to_power_w(limit, profile, capacity_wh)
if profile_cap_w is not None:
power_w = min(power_w, profile_cap_w)
if installation_cap_w is not None:
power_w = min(power_w, installation_cap_w)
return power_w
def _efficiency_at_power(
curve: "PrecomputedEfficiencyCurve",
measured_power_w: Optional[float],
max_power_w: float,
capacity_wh: int,
) -> float:
"""Return the efficiency of a profile curve at the measured, else the maximum power.
Without positive power (e.g. discharging is inhibited) the efficiency at the
lowest C-rate of the curve applies. If the curve gives no value at the
C-rate (it rejects out-of-range C-rates or does not interpolate), the
efficiency of the nearest curve breakpoint applies.
"""
power_w = measured_power_w if measured_power_w is not None else max_power_w
if power_w <= 0:
return curve.efficiency[0]
c_rate = power_w / capacity_wh
efficiency = curve.lookup(c_rate)
if efficiency is not None:
return efficiency
nearest = min(range(len(curve.c_rate)), key=lambda i: abs(curve.c_rate[i] - c_rate))
return curve.efficiency[nearest]
# ---------------------------------------------------------------------------
# Resolution
# ---------------------------------------------------------------------------
def resolve_nominal_capacity_wh(
settings: "BatteriesCommonSettings", profile: "BatteryProfile"
) -> int:
"""Resolve the nominal capacity [Wh]: profile adjustment, else profile.
Args:
settings: Battery device settings.
profile: Compiled battery profile.
Returns:
The effective nominal capacity [Wh].
Raises:
BatteryResolutionError: If neither the profile adjustment nor the
profile provides a capacity (a generic profile without adjustment).
"""
adjusted_wh = settings.profile_adjustment.nominal_capacity_wh
if adjusted_wh is not None:
return adjusted_wh
if profile.nominal_capacity_wh is not None:
# The settings work in whole Wh; never round a tiny capacity down to 0.
return max(1, round(profile.nominal_capacity_wh))
raise _resolution_error(
settings,
f"battery profile '{settings.battery_profile}' is generic and provides no "
"nominal capacity; set 'profile_adjustment.nominal_capacity_wh'.",
)
def resolve_full_charge(
settings: "BatteriesCommonSettings", profile: Optional["BatteryProfile"]
) -> BatteryFullCharge:
"""Resolve the periodic full charge policy: profile adjustment, else profile.
The interval and the detection threshold are properties of the battery model
and come from the profile unless the profile adjustment sets them. The
target SOC and whether to charge from the grid are operating policy of the
battery settings.
Args:
settings: Battery device settings.
profile: Compiled battery profile, None if it is not usable. Then only
the profile adjustment and the defaults apply.
Returns:
The effective full charge policy. The target is capped by the technical
maximum SOC of the profile and the detection threshold by the target.
"""
adjustment = settings.profile_adjustment
interval_days = adjustment.full_charge_interval_days
detect_percentage = adjustment.full_charge_detect_percentage
soc_percentage = settings.full_charge_soc_percentage
if profile is not None:
if interval_days is None:
interval_days = profile.full_charge_interval_days
if detect_percentage is None:
detect_percentage = profile.full_charge_detect_percentage
soc_percentage = min(soc_percentage, math.floor(profile.technical_max_soc_percent))
if detect_percentage is None:
detect_percentage = BATTERY_DEFAULT_FULL_CHARGE_DETECT_PERCENTAGE
return BatteryFullCharge(
interval_days=interval_days,
soc_percentage=soc_percentage,
detect_percentage=min(detect_percentage, soc_percentage),
)
def resolve_battery_parameters(
settings: "BatteriesCommonSettings",
profile: "BatteryProfile",
capacity_wh: int,
operating_point: BatteryOperatingPoint,
) -> BatteryOperatingParameters:
"""Resolve all parameters for a profile and an operating point.
Args:
settings: Battery device settings.
profile: Compiled battery profile.
capacity_wh: Effective nominal capacity [Wh].
operating_point: Operating point to evaluate the profile at.
Returns:
The effective parameters.
The operating point never makes the resolution fail: if the profile inhibits
charging or discharging there (e.g. the battery is too cold), the maximum
charge or discharge power is 0.
Raises:
BatterySocRangeError: If the technical SOC bounds leave an empty SOC
range.
BatteryResolutionError: If the profile limits cannot be converted to
power.
"""
temperature_degC = operating_point.temperature_degC
soc_factor = operating_point.soc_factor
soc_percent = soc_factor * 100.0
adjustment = settings.profile_adjustment
# --- maximum charge power (0 if inhibited, e.g. too cold) ----------
charge_limit = profile.max_charge_crate(temperature_degC, soc_percent)
if charge_limit is None:
max_charge_power_w = 0.0
else:
try:
max_charge_power_w = max(
0.0,
_limited_power_w(
charge_limit,
profile,
capacity_wh,
profile.max_charge_power_w,
_installation_cap_w(
profile,
adjustment.max_charge_power_w,
profile.max_charge_power_w,
BATTERY_GENERIC_MAX_CHARGE_POWER_W,
),
),
)
except ValueError as exc:
raise _resolution_error(settings, str(exc)) from exc
# --- maximum discharge power (0 if inhibited, nothing requires it) --
discharge_limit = profile.max_discharge_crate(temperature_degC, soc_percent)
if discharge_limit is None:
max_discharge_power_w = 0.0
else:
try:
max_discharge_power_w = _limited_power_w(
discharge_limit,
profile,
capacity_wh,
profile.max_discharge_power_w,
_installation_cap_w(
profile,
adjustment.max_discharge_power_w,
profile.max_discharge_power_w,
BATTERY_GENERIC_MAX_DISCHARGE_POWER_W,
),
)
except ValueError as exc:
raise _resolution_error(settings, str(exc)) from exc
# --- efficiencies: measured power, else maximum power --------------
charging_efficiency = _efficiency_at_power(
profile.charge_efficiency,
operating_point.charge_power_w,
max_charge_power_w,
capacity_wh,
)
discharging_efficiency = _efficiency_at_power(
profile.discharge_efficiency,
operating_point.discharge_power_w,
max_discharge_power_w,
capacity_wh,
)
# --- SOC range: user's operational range within technical bounds ---
min_soc = max(settings.min_soc_percentage, math.ceil(profile.technical_min_soc_percent))
max_soc = min(settings.max_soc_percentage, math.floor(profile.technical_max_soc_percent))
if min_soc > max_soc:
raise _resolution_error(
settings,
f"SOC range [{min_soc}, {max_soc}] is empty after applying the technical "
f"SOC bounds of battery profile '{settings.battery_profile}'.",
BatterySocRangeError,
)
return BatteryOperatingParameters(
temperature_degC=temperature_degC,
soc_factor=soc_factor,
capacity_wh=capacity_wh,
charging_efficiency=charging_efficiency,
discharging_efficiency=discharging_efficiency,
max_charge_power_w=max_charge_power_w,
max_discharge_power_w=max_discharge_power_w,
min_soc_percentage=min_soc,
max_soc_percentage=max_soc,
profile=profile,
)
def resolve_battery_parameters_without_profile(
settings: "BatteriesCommonSettings", operating_point: BatteryOperatingPoint
) -> tuple[BatteryOperatingParameters, list[str]]:
"""Resolve the parameters from the profile adjustment and the former defaults.
For the parameter preparation of the optimizations if the battery profile
cannot be used. The values of the profile adjustment apply where set; the
former defaults (``BATTERY_FALLBACK_*``) fill the others.
Args:
settings: Battery device settings.
operating_point: Operating point the parameters belong to.
Returns:
The parameters and a description of each default value used, e.g.
``"nominal capacity 8000 Wh"``.
"""
adjustment = settings.profile_adjustment
defaults: list[str] = []
capacity_wh = adjustment.nominal_capacity_wh
if capacity_wh is None:
capacity_wh = BATTERY_FALLBACK_CAPACITY_WH
defaults.append(f"nominal capacity {capacity_wh} Wh")
max_charge_power_w = adjustment.max_charge_power_w
if max_charge_power_w is None:
max_charge_power_w = BATTERY_FALLBACK_MAX_CHARGE_POWER_W
defaults.append(f"maximum charge power {max_charge_power_w} W")
max_discharge_power_w = adjustment.max_discharge_power_w
if max_discharge_power_w is None:
max_discharge_power_w = BATTERY_FALLBACK_MAX_DISCHARGE_POWER_W
defaults.append(f"maximum discharge power {max_discharge_power_w} W")
defaults.append(f"charging efficiency {BATTERY_FALLBACK_CHARGING_EFFICIENCY}")
defaults.append(f"discharging efficiency {BATTERY_FALLBACK_DISCHARGING_EFFICIENCY}")
defaults.append("no SoC dependent power limits")
return (
BatteryOperatingParameters(
temperature_degC=operating_point.temperature_degC,
soc_factor=operating_point.soc_factor,
capacity_wh=capacity_wh,
charging_efficiency=BATTERY_FALLBACK_CHARGING_EFFICIENCY,
discharging_efficiency=BATTERY_FALLBACK_DISCHARGING_EFFICIENCY,
max_charge_power_w=max_charge_power_w,
max_discharge_power_w=max_discharge_power_w,
min_soc_percentage=settings.min_soc_percentage,
max_soc_percentage=settings.max_soc_percentage,
),
defaults,
)
# ---------------------------------------------------------------------------
# Power curves for simulations
# ---------------------------------------------------------------------------
# Number of points of a power limit table: one per SoC percent, 0 % ... 100 %.
BATTERY_SOC_TABLE_POINTS: int = 101
# Power fractions an efficiency curve is sampled at: 0.0, 0.05, ..., 1.0.
BATTERY_EFFICIENCY_CURVE_FRACTIONS: tuple[float, ...] = tuple(i / 20 for i in range(21))
@dataclass(frozen=True)
class BatteryPowerCurves:
"""Power limits and efficiencies of a battery profile at one temperature.
Prepared for simulations that evaluate the battery many times, e.g. the
GENETIC optimizer: plain lists instead of profile lookups.
Attributes:
temperature_degC: Battery temperature the profile was evaluated at.
charge_power_limit_w_by_soc: Maximum charging power [W] per SoC
percent; index i is SoC i %. 0 where charging is inhibited.
discharge_power_limit_w_by_soc: Maximum discharging power [W] per SoC
percent; 0 where discharging is inhibited.
max_charge_power_w: Highest charging power of the table [W].
max_discharge_power_w: Highest discharging power of the table [W].
charging_efficiency_curve: ``(power_fraction, efficiency)`` points; the
power fraction is relative to ``max_charge_power_w``. None if the
profile has no charge efficiency curve.
discharging_efficiency_curve: ``(power_fraction, efficiency)`` points;
the power fraction is relative to ``max_discharge_power_w``. None if
the profile has no discharge efficiency curve or discharging is
inhibited.
"""
temperature_degC: float
charge_power_limit_w_by_soc: list[float]
discharge_power_limit_w_by_soc: list[float]
max_charge_power_w: float
max_discharge_power_w: float
charging_efficiency_curve: Optional[list[tuple[float, float]]]
discharging_efficiency_curve: Optional[list[tuple[float, float]]]
def _power_limit_table_w(
crate_at: Callable[[float, float], Optional[float]],
temperature_degC: float,
profile: "BatteryProfile",
capacity_wh: int,
profile_cap_w: Optional[float],
installation_cap_w: Optional[float],
) -> list[float]:
"""Return the power limit [W] per SoC percent at one temperature."""
table: list[float] = []
for soc_percent in range(BATTERY_SOC_TABLE_POINTS):
limit = crate_at(temperature_degC, float(soc_percent))
if limit is None:
table.append(0.0)
continue
power_w = _limited_power_w(limit, profile, capacity_wh, profile_cap_w, installation_cap_w)
table.append(max(power_w, 0.0))
return table
def profile_peak_power_w(
profile: "BatteryProfile", capacity_wh: int, temperature_degC: float
) -> tuple[float, float]:
"""Return the highest charge and discharge power of the profile over SoC [W].
Only the profile and its absolute model power limits apply, no installation
limit, so the result shows whether a ``profile_adjustment`` power limit
lowers the profile at all.
Args:
profile: Compiled battery profile.
capacity_wh: Effective nominal capacity [Wh].
temperature_degC: Battery temperature to evaluate the profile at.
Returns:
``(max_charge_power_w, max_discharge_power_w)``.
Raises:
ValueError: If the profile limits cannot be converted to power.
"""
charge = _power_limit_table_w(
profile.max_charge_crate,
temperature_degC,
profile,
capacity_wh,
profile.max_charge_power_w,
None,
)
discharge = _power_limit_table_w(
profile.max_discharge_crate,
temperature_degC,
profile,
capacity_wh,
profile.max_discharge_power_w,
None,
)
return max(charge), max(discharge)
def _efficiency_curve(
lookup: Callable[[float], Optional[float]],
max_power_w: float,
capacity_wh: int,
) -> Optional[list[tuple[float, float]]]:
"""Sample an efficiency curve over the power fraction of ``max_power_w``."""
if max_power_w <= 0:
return None
curve: list[tuple[float, float]] = []
for fraction in BATTERY_EFFICIENCY_CURVE_FRACTIONS:
efficiency = lookup(fraction * max_power_w / capacity_wh)
if efficiency is None:
return None
curve.append((fraction, efficiency))
return curve
def resolve_battery_power_curves(
settings: "BatteriesCommonSettings",
profile: "BatteryProfile",
capacity_wh: int,
temperature_degC: float,
) -> Optional[BatteryPowerCurves]:
"""Evaluate the profile over the whole SoC range at one temperature.
The power limits are capped by the model power limits of the profile and
by the power limits of the profile adjustment, like the parameters resolved
by ``resolve_battery_parameters``.
Args:
settings: Battery device settings.
profile: Compiled battery profile.
capacity_wh: Effective nominal capacity [Wh].
temperature_degC: Battery temperature to evaluate the profile at.
Returns:
The power curves, or None if the profile allows no charging at any SoC
at this temperature.
Raises:
ValueError: If the profile limits cannot be converted to power (see
``profile_limit_to_power_w``).
"""
adjustment = settings.profile_adjustment
charge_table = _power_limit_table_w(
profile.max_charge_crate,
temperature_degC,
profile,
capacity_wh,
profile.max_charge_power_w,
_installation_cap_w(
profile,
adjustment.max_charge_power_w,
profile.max_charge_power_w,
BATTERY_GENERIC_MAX_CHARGE_POWER_W,
),
)
max_charge_power_w = max(charge_table)
if max_charge_power_w <= 0:
return None
discharge_table = _power_limit_table_w(
profile.max_discharge_crate,
temperature_degC,
profile,
capacity_wh,
profile.max_discharge_power_w,
_installation_cap_w(
profile,
adjustment.max_discharge_power_w,
profile.max_discharge_power_w,
BATTERY_GENERIC_MAX_DISCHARGE_POWER_W,
),
)
max_discharge_power_w = max(discharge_table)
return BatteryPowerCurves(
temperature_degC=temperature_degC,
charge_power_limit_w_by_soc=charge_table,
discharge_power_limit_w_by_soc=discharge_table,
max_charge_power_w=max_charge_power_w,
max_discharge_power_w=max_discharge_power_w,
charging_efficiency_curve=_efficiency_curve(
profile.charge_efficiency_at, max_charge_power_w, capacity_wh
),
discharging_efficiency_curve=_efficiency_curve(
profile.discharge_efficiency_at, max_discharge_power_w, capacity_wh
),
)
File diff suppressed because it is too large Load Diff
+6 -3
View File
@@ -422,15 +422,18 @@ class Measurement(SingletonMixin, DataImportMixin, DataSequence[MeasurementDataR
)
if len(samples) > 250000:
raise ValueError("More than 250000 power samples; request a shorter period.")
# Raises a BatteryResolutionError (a ValueError) naming the battery if
# its profile cannot be resolved.
parameters = battery.resolve_operating_parameters()
return estimate_capacity(
request,
settings,
channel,
samples,
battery_id=battery.device_id,
capacity_wh=battery.capacity_wh,
charging_efficiency=battery.charging_efficiency,
discharging_efficiency=battery.discharging_efficiency,
capacity_wh=parameters.capacity_wh,
charging_efficiency=parameters.charging_efficiency,
discharging_efficiency=parameters.discharging_efficiency,
)
async def _energy_from_meter_readings(
@@ -13,6 +13,7 @@ from akkudoktoreos.core.coreabc import (
PredictionMixin,
get_ems,
)
from akkudoktoreos.devices.settings.batteryresolution import BatteryFullCharge
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
from akkudoktoreos.optimization.genetic.fullcharge import (
@@ -137,9 +138,11 @@ class ConfigOptimizationRequest(
)
return 0
async def full_charge(device: BatteriesCommonSettings) -> Optional[DateTime]:
async def full_charge(
device: BatteriesCommonSettings, policy: BatteryFullCharge
) -> Optional[DateTime]:
"""Deadline of the next periodic full charge, None if disabled or unknown."""
interval_days = device.full_charge_interval_days
interval_days = policy.interval_days
if interval_days is None:
return None
try:
@@ -155,7 +158,7 @@ class ConfigOptimizationRequest(
zip(dates, values),
now=observation_time,
interval_days=interval_days,
detect_percentage=device.full_charge_detect_percentage,
detect_percentage=policy.detect_percentage,
)
if deadline is None:
logger.warning(
@@ -164,18 +167,25 @@ class ConfigOptimizationRequest(
)
return deadline
# A battery configuration error does not cancel the run: the battery gets
# default values for what cannot be resolved, and an error is logged.
battery = None
if groups["batteries"]:
device = groups["batteries"][0]
battery = device.to_genetic_pv_bat_param()
(
operating_parameters,
use_profile_tables,
) = await device.resolve_current_operating_parameters_or_defaults(observation_time)
battery = device.to_genetic_pv_bat_param(operating_parameters, use_profile_tables)
battery.initial_soc_percentage = await state(device)
deadline = await full_charge(device)
policy = device.resolve_full_charge()
deadline = await full_charge(device, policy)
horizon_end = start.add(seconds=settings.horizon * settings.interval_sec)
if deadline is not None and deadline.timestamp() <= horizon_end.timestamp():
# Due within the horizon: lift the upper limit so the battery may
# reach the target, and enforce the target only if grid charging
# is allowed for it.
target = device.full_charge_soc_percentage
# is allowed for it. The target is within the technical SOC bounds.
target = policy.soc_percentage
battery.max_soc_percentage = max(battery.max_soc_percentage, target)
if device.full_charge_allow_grid:
enforced = enforced_full_charge_deadline(deadline, observation_time)
@@ -198,7 +208,11 @@ class ConfigOptimizationRequest(
ev = None
if groups["electric_vehicles"]:
device = groups["electric_vehicles"][0]
ev = device.to_genetic_ev_bat_param()
(
operating_parameters,
use_profile_tables,
) = await device.resolve_current_operating_parameters_or_defaults(observation_time)
ev = device.to_genetic_ev_bat_param(operating_parameters, use_profile_tables)
ev.initial_soc_percentage = await state(device)
inverter = None
if groups["inverters"]:
@@ -745,8 +745,14 @@ class GeneticSimulation(PydanticBaseModel):
battery_lcos_direct = False
if battery_fast is not None and type(battery_fast) is Battery:
battery_lcos_direct = True
battery_discharged_raw_fast = battery_fast._discharged_raw_wh_per_slot
battery_discharging_efficiency_fast = battery_fast.discharging_efficiency
if battery_fast._curves is None:
battery_discharged_raw_fast = battery_fast._discharged_raw_wh_per_slot
battery_discharging_efficiency_fast = battery_fast.discharging_efficiency
else:
# With power dependent efficiencies the delivered energy is kept
# per slot (see Battery.discharged_energy_wh()).
battery_discharged_raw_fast = battery_fast._curves.discharged_dc_wh_per_slot
battery_discharging_efficiency_fast = 1.0
for hour in range(start_hour, end_hour):
hour_idx = hour - start_hour
@@ -74,6 +74,10 @@ class ElectricVehicleResult(DeviceOptimizeResult):
max_charge_power_w: int = Field(
json_schema_extra={"description": "Maximum charging power in watts."}
)
max_discharge_power_w: Optional[float] = Field(
default=None,
json_schema_extra={"description": "Maximum discharging power in watts."},
)
soc_wh: float = Field(
json_schema_extra={
"description": "State of charge of the battery in watt-hours at the start of the simulation."
@@ -553,9 +557,10 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
max(float(battery_grid_export_factor), 0.0), 1.0
)
# (0,0,1) -> Discharge for local load only
# (0,0,1) -> Discharge for local load only: serve local demand, no grid
# charging and no export.
if ac_charge <= 0.0 and dc_charge <= 0.0 and discharge_allowed:
return BatteryOperationMode.PEAK_SHAVING, 1.0
return BatteryOperationMode.SELF_CONSUMPTION, 1.0
# (ac>0,0,0) → AC charge only
if ac_charge > 0.0 and dc_charge <= 0.0 and not discharge_allowed:
@@ -614,13 +619,20 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
- Battery grid export: blocked when SOC is at or below min SOC.
"""
parameters = getattr(self, "parameters", None)
bat_list = (
[parameters.pv_battery]
if parameters is not None and parameters.pv_battery is not None
else list((self.config.devices.batteries or {}).values())
if parameters is None
else []
)
# Without parameters, convert the battery settings: they hold no
# capacity / efficiency / power fields, these come from the battery profile.
# A configuration error gives the defaults of the parameter preparation;
# the configuration check already reported it.
bat_list = []
if parameters is not None:
if parameters.pv_battery is not None:
bat_list = [parameters.pv_battery]
else:
for settings in list((self.config.devices.batteries or {}).values())[:1]:
snapshot, use_tables = settings.resolve_operating_parameters_or_defaults(
report=False
)
bat_list.append(settings.to_genetic_pv_bat_param(snapshot, use_tables))
if not bat_list:
return ac_charge, dc_charge, discharge_allowed, battery_grid_export_allowed
@@ -679,8 +691,8 @@ class GeneticSolution(ConfigMixin, GeneticParametersBaseModel):
- battery_grid_export_allowed: discharge to grid
The following battery modes are supported:
- SELF_CONSUMPTION: dc_charge > 0 and discharge_allowed == 1
- PEAK_SHAVING: ac_charge == 0 and discharge_allowed == 1
- SELF_CONSUMPTION: discharge_allowed == 1 and ac_charge == 0
(with or without dc_charge)
- GRID_SUPPORT_EXPORT: battery_grid_export_allowed == 1
- GRID_SUPPORT_IMPORT: ac_charge > 0 and discharge_allowed == 0 or 1
"""
@@ -50,6 +50,8 @@ def _simulate_action(
bat.soc_wh = float(energy_wh)
bat._charged_raw_wh_per_slot[:] = [0.0] * len(bat._charged_raw_wh_per_slot)
bat._discharged_raw_wh_per_slot[:] = [0.0] * len(bat._discharged_raw_wh_per_slot)
if bat._curves is not None:
bat._curves.reset(len(bat._charged_raw_wh_per_slot))
bat._charge_limit_raw_wh_per_slot[:] = [float("inf")] * len(bat._charge_limit_raw_wh_per_slot)
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
@@ -84,7 +86,7 @@ def _simulate_action(
"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_charge_wh": bat.charged_energy_wh(0),
"battery_discharge_wh": discharged_wh,
"losses_wh": losses,
"ac_grid_charge_wh": ac_grid_charge_wh,
@@ -37,6 +37,7 @@ from akkudoktoreos.devices.genetic0.genetic0homeappliance import (
Genetic0HomeApplianceParameters,
)
from akkudoktoreos.devices.genetic0.genetic0inverter import Genetic0InverterParameters
from akkudoktoreos.devices.settings.batterysettings import BatteriesCommonSettings
from akkudoktoreos.optimization.genetic0.genetic0abc import Genetic0ParametersBaseModel
from akkudoktoreos.optimization.genetic.forecast import bounded_forecast_array
from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
@@ -46,6 +47,20 @@ from akkudoktoreos.utils.datetimeutil import DateTime, to_duration
# StartMixin - Creates circular dependency with ems.py
# Demo devices: used if no device is configured, and for this run only if the
# configured device cannot be prepared.
DEMO_BATTERY_SETTINGS: dict = {
"device_id": "battery1",
"profile_adjustment": {"nominal_capacity_wh": 8000},
}
DEMO_ELECTRIC_VEHICLE_SETTINGS: dict = {
"device_id": "ev1",
"profile_adjustment": {"nominal_capacity_wh": 50000},
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70,
}
class Genetic0EnergyManagementParameters(Genetic0ParametersBaseModel):
"""Encapsulates energy-related forecasts and costs used in GENETIC optimization."""
@@ -577,30 +592,30 @@ class Genetic0OptimizationParameters(
else:
if cls.config.devices.batteries is None:
logger.info("No battery device data available - defaulting to demo data.")
cls.config.devices.batteries = {
"battery1": {
"device_id": "battery1",
"capacity_wh": 8000,
},
}
cls.config.devices.batteries = {"battery1": dict(DEMO_BATTERY_SETTINGS)}
# Take first battery
battery_config = list(cls.config.devices.batteries.values())[0]
try:
# Take first battery
battery_config = list(cls.config.devices.batteries.values())[0]
battery_params = battery_config.to_genetic0_pv_bat_param()
# A configuration error gives default values, reported as error.
(
battery_parameters,
_,
) = await battery_config.resolve_current_operating_parameters_or_defaults()
battery_params = battery_config.to_genetic0_pv_bat_param(battery_parameters)
except Exception as e:
logger.info(
"No battery device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
# Never cancel the run and never change the user's configuration:
# optimize with the demo battery for this run only.
logger.error(
"Battery '{}' cannot be prepared for the optimization: {} "
"Using the demo battery for this run.",
battery_config.device_id,
e,
)
cls.config.devices.batteries = {
"battery1": {
"device_id": "battery1",
"capacity_wh": 8000,
},
}
# Retry
continue
battery_config = BatteriesCommonSettings(**DEMO_BATTERY_SETTINGS)
battery_parameters, _ = (
battery_config.resolve_operating_parameters_or_defaults()
)
battery_params = battery_config.to_genetic0_pv_bat_param(battery_parameters)
# Levelized cost of ownership
if battery_config.levelized_cost_of_storage_amt_kwh is None:
logger.info(
@@ -644,34 +659,38 @@ class Genetic0OptimizationParameters(
)
cls.config.devices.max_electric_vehicles = 1
cls.config.devices.electric_vehicles = {
"ev1": {
"device_id": "ev1",
"capacity_wh": 50000,
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70,
},
"ev1": dict(DEMO_ELECTRIC_VEHICLE_SETTINGS)
}
# Take first electric_vehicle
electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0]
try:
# Take first electric_vehicle
electric_vehicle_config = list(cls.config.devices.electric_vehicles.values())[0]
electric_vehicle_params = electric_vehicle_config.to_genetic0_ev_bat_param()
# A configuration error gives default values, reported as error.
(
ev_parameters,
_,
) = await electric_vehicle_config.resolve_current_operating_parameters_or_defaults()
electric_vehicle_params = electric_vehicle_config.to_genetic0_ev_bat_param(
ev_parameters
)
except Exception as e:
logger.info(
"No electric_vehicle device data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
# Never cancel the run and never change the user's configuration:
# optimize with the demo electric vehicle for this run only.
logger.error(
"Electric vehicle '{}' cannot be prepared for the optimization: {} "
"Using the demo electric vehicle for this run.",
electric_vehicle_config.device_id,
e,
)
cls.config.devices.max_electric_vehicles = 1
cls.config.devices.electric_vehicles = {
"ev1": {
"device_id": "ev1",
"capacity_wh": 50000,
"charge_rates": [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0],
"min_soc_percentage": 70,
},
}
# Retry
continue
electric_vehicle_config = BatteriesCommonSettings(
**DEMO_ELECTRIC_VEHICLE_SETTINGS
)
(
ev_parameters,
_,
) = electric_vehicle_config.resolve_operating_parameters_or_defaults()
electric_vehicle_params = electric_vehicle_config.to_genetic0_ev_bat_param(
ev_parameters
)
# Initial SOC
try:
initial_soc_factor = await cls._nearest_soc_factor(
@@ -529,16 +529,34 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel):
- DC charge factor (PV): zeroed when battery is at or above max SOC
(the inverter curtails automatically, but this makes intent clear).
- Discharge: blocked when SOC is at or below min SOC.
The battery values are the optimizer parameters the solution was computed
with (capacity, efficiency, power limit and SOC range as resolved from the
battery profile), not the raw battery settings.
"""
bat_dict = self.config.devices.batteries
if bat_dict is None or len(bat_dict) <= 0:
parameters = getattr(self, "parameters", None)
if parameters is not None:
bat = parameters.pv_battery
else:
bat_dict = self.config.devices.batteries or {}
bat = None
if bat_dict:
# A configuration error gives the defaults of the parameter
# preparation; the configuration check already reported it.
settings = list(bat_dict.values())[0]
snapshot, _ = settings.resolve_operating_parameters_or_defaults(report=False)
bat = settings.to_genetic0_pv_bat_param(snapshot)
if bat is None:
return ac_charge, dc_charge, discharge_allowed
bat = list(bat_dict.values())[0]
min_soc = float(bat.min_soc_percentage)
max_soc = float(bat.max_soc_percentage)
capacity_wh = float(bat.capacity_wh)
ch_eff = float(bat.charging_efficiency)
# Same fallback as the GENETIC0 battery simulation.
bat_max_charge_power_w = (
float(bat.max_charge_power_w) if bat.max_charge_power_w is not None else capacity_wh
)
headroom_wh = max(0.0, (max_soc - soc_pct) / 100.0 * capacity_wh)
# --- AC charge: scale to available headroom ---
@@ -553,7 +571,7 @@ class Genetic0Solution(ConfigMixin, Genetic0ParametersBaseModel):
max_ac_cp_w = (
float(inv.max_ac_charge_power_w)
if inv and inv.max_ac_charge_power_w is not None
else float(bat.max_charge_power_w)
else bat_max_charge_power_w
)
max_dc_per_h_wh = effective_ac * max_ac_cp_w * ac_to_dc_eff * ch_eff
if max_dc_per_h_wh > headroom_wh:
+2
View File
@@ -81,6 +81,7 @@ from akkudoktoreos.server.rest.error import (
register_problem_handlers,
)
from akkudoktoreos.server.rest.measurement import router as measurement_router
from akkudoktoreos.server.rest.profile import router as profile_router
from akkudoktoreos.server.rest.pvakkuadaptrest import router as pvadaptive_router
from akkudoktoreos.server.rest.starteosdash import supervise_eosdash
from akkudoktoreos.server.retentionmanager import RetentionManager
@@ -271,6 +272,7 @@ The genetic optimization API fields were renamed from German to English. For bac
# ----------------------
app.include_router(measurement_router)
app.include_router(profile_router)
app.include_router(pvadaptive_router)
register_problem_handlers(app)
+139
View File
@@ -0,0 +1,139 @@
"""REST API of the device profile store.
Lists, reads, writes and deletes device profile documents (e.g. battery
profiles) for clients such as EOSdash. The profile kind in the path selects the
profile type (see ``profilestore.profile_type``)::
GET /v1/profile registered profile kinds
GET /v1/profile/{kind} profiles of a kind
GET /v1/profile/{kind}/schema JSON schema of the documents of a kind
GET /v1/profile/{kind}/{name} document of a profile
PUT /v1/profile/{kind}/{name} validate and save a document (user data directory)
DELETE /v1/profile/{kind}/{name} delete a user profile
A profile name is a single file name stem: letters, digits, ``.``, ``_`` and
``-``, starting with a letter or digit (``profilestore.PROFILE_NAME_PATTERN``);
``schema`` is reserved, as that path serves the JSON schema. Packaged profiles
are read only: a PUT stores a user profile that overrides the packaged one of
the same name, a DELETE of that user profile makes the packaged one apply again.
Saved profiles take effect at once: the store drops the compiled profile, and
users of the profile (battery settings, device controllers) get the new one on
their next access.
"""
import asyncio
from typing import Annotated, Any
from fastapi import APIRouter, Body
from fastapi import Path as FastapiPath
# Import the profile types so that they are registered with the store.
from akkudoktoreos.devices.profile import batteryprofile # noqa: F401
from akkudoktoreos.devices.profile.profilestore import (
ProfileDocument,
ProfileEntry,
get_profile_store,
profile_kinds,
profile_type,
)
from akkudoktoreos.server.rest.error import EOSProblem
router = APIRouter(prefix="/v1/profile", tags=["profile"])
KindPath = Annotated[str, FastapiPath(description="Profile kind, e.g. 'battery'.")]
NamePath = Annotated[
str,
FastapiPath(
description="Profile name without '.json', e.g. 'generic-lfp' (letters, digits, '.', '_', '-')."
),
]
def _document_type(kind: str) -> type[ProfileDocument[Any]]:
"""Return the profile type of a kind, or raise 404."""
try:
return profile_type(kind)
except KeyError as exc:
raise EOSProblem(
status=404, title="Unknown profile kind", detail=f"Unknown profile kind '{kind}'."
) from exc
@router.get("", response_model=list[str])
async def get_profile_kinds() -> list[str]:
"""Return the registered profile kinds."""
return profile_kinds()
@router.get("/{kind}", response_model=list[ProfileEntry])
async def get_profiles(kind: KindPath) -> list[ProfileEntry]:
"""Return the profiles of a kind, from the user and the packaged data directory."""
document_type = _document_type(kind)
return await asyncio.to_thread(get_profile_store().list_profiles, document_type)
@router.get("/{kind}/schema")
async def get_profile_schema(kind: KindPath) -> dict[str, Any]:
"""Return the JSON schema of the profile documents of a kind."""
return _document_type(kind).model_json_schema()
@router.get("/{kind}/{name}")
async def get_profile(kind: KindPath, name: NamePath) -> dict[str, Any]:
"""Return the document of a profile (user profile first, then packaged)."""
document_type = _document_type(kind)
try:
document = await asyncio.to_thread(get_profile_store().load, document_type, name)
except FileNotFoundError as exc:
raise EOSProblem(status=404, title="Profile not found", detail=str(exc)) from exc
except ValueError as exc: # invalid name or invalid document on disk
raise EOSProblem(status=422, title="Profile retrieval failed", detail=str(exc)) from exc
return document.model_dump(mode="json")
@router.put("/{kind}/{name}")
async def put_profile(
kind: KindPath,
name: NamePath,
document: Annotated[dict[str, Any], Body(description="The profile document.")],
) -> dict[str, Any]:
"""Validate a profile document and save it to the user data directory.
Returns the saved (validated, defaults filled in) document.
"""
document_type = _document_type(kind)
try:
validated = document_type.model_validate(document)
await asyncio.to_thread(get_profile_store().save, name, validated)
except ValueError as exc: # pydantic ValidationError is a ValueError
raise EOSProblem(status=422, title="Profile save failed", detail=str(exc)) from exc
except OSError as exc:
raise EOSProblem(
status=500,
title="Profile save failed",
detail=f"Cannot save {kind} profile '{name}': {exc}",
cause=exc,
) from exc
return validated.model_dump(mode="json")
@router.delete("/{kind}/{name}", response_model=ProfileEntry | None)
async def delete_profile(kind: KindPath, name: NamePath) -> ProfileEntry | None:
"""Delete a user profile.
Returns the profile that applies now (the packaged one of the same name),
or null if there is none.
"""
document_type = _document_type(kind)
store = get_profile_store()
try:
await asyncio.to_thread(store.delete, document_type, name)
except PermissionError as exc:
raise EOSProblem(status=409, title="Profile delete refused", detail=str(exc)) from exc
except FileNotFoundError as exc:
raise EOSProblem(status=404, title="Profile not found", detail=str(exc)) from exc
except ValueError as exc:
raise EOSProblem(status=422, title="Profile delete failed", detail=str(exc)) from exc
entries = await asyncio.to_thread(store.list_profiles, document_type)
return next((entry for entry in entries if entry.name == name), None)