mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-11-13 09:13:30 +01:00
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:
co-authored by
Claude Opus 5.5
parent
dd41e5d57c
commit
e255f987bf
@@ -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",
|
||||
}
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user