feat: add pvlib pv forecast provider (#1214)

Add a PV forecast provider that calculates the forecast using a PVLib system model
and weather forecast from the EOS weather forecast provider.

Additional module and inverter models can be easily added as the database
is build from PVLib and SAM databases and a bundled csv file.

The module model and inververt model names are provided by new endpoints
to be used in configuration.

The provider is based on the fantastic work of EMHASS. See
https://github.com/davidusb-geek/emhass/blob/master/src/emhass/forecast.py

A short description of the provider is added to the documentation.

Besides the new features there are the fixes and improvements:

* feat: improve EOSdash config page

* fix: kex_to_series for start_datetime

  Make key_to_series always start the series at start_datetime.

* fix: default provider for GENETIC and GENETIC0 optimization

  To make the default less dependent on internet servers (with API changes and
  availability issues) the default for PVForecast is set to PVForecastPVLib
  and for ElecPrice to ElecPriceFixed. The default weather provider is changed
  to OpenMeteo.

* fix: EOSdash display resampled prediction values

  Make EOSdash display resampled prediction values where  resampling fits to
  the prediction value type. Use bar width that fits to 15 minutes value samples.

* chore: add a UI hints system to EOSdash

  The UI hints system eases the definition of forms for configuration items.
  There are also forms for items in maps and lists. These forms allow to add and delete
  items to/ from  maps and lists. The forms ensure that all required fields of newly
  added items are filled.

* chore: Create an enum for valid optimization algorithms

* chore. Make config also provide the available energy management modes.

  Used for configuration hints.

* chore: Randomize default device id in configuration

Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
Bobby Noelte
2026-08-07 13:13:17 +02:00
committed by GitHub
parent 9189fc890e
commit 894790f577
49 changed files with 29242 additions and 667 deletions
+3 -3
View File
@@ -209,7 +209,7 @@
| ac_to_dc_efficiency | `float` | `rw` | `1.0` | Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss). |
| battery_id | `str | None` | `rw` | `None` | ID of battery controlled by this inverter. |
| dc_to_ac_efficiency | `float` | `rw` | `1.0` | Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss). |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| device_id | `str` | `rw` | `required` | ID of device |
| max_ac_charge_power_w | `float | None` | `rw` | `None` | Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging. |
| max_power_w | `float | None` | `rw` | `None` | Maximum power [W]. |
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
@@ -372,7 +372,7 @@ as a cohesive unit for scheduling and availability checking.
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| device_id | `str` | `rw` | `required` | ID of device |
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
| time_windows | `akkudoktoreos.config.configabc.TimeWindowSequence | None` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
@@ -454,7 +454,7 @@ as a cohesive unit for scheduling and availability checking.
| capacity_wh | `int` | `rw` | `8000` | Capacity [Wh]. |
| charge_rates | `list[float] | None` | `rw` | `[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]` | Charge rates as factor of maximum charging power [0.00 ... 1.00]. None triggers fallback to default charge-rates. |
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| device_id | `str` | `rw` | `required` | ID of device |
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh]. |
| max_charge_power_w | `float | None` | `rw` | `5000` | Maximum charging power [W]. |
+24 -2
View File
@@ -8,13 +8,14 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| interval | `EOS_EMS__INTERVAL` | `float` | `rw` | `300.0` | Intervall between EOS energy management runs [seconds]. |
| mode | `EOS_EMS__MODE` | `<enum 'EnergyManagementMode'>` | `rw` | `required` | Energy management mode [DISABLED | OPTIMIZATION | PREDICTION]. |
| mode | `EOS_EMS__MODE` | `<enum 'EnergyManagementMode'>` | `rw` | `required` | Energy management mode [DISABLED | PREDICTION | OPTIMIZATION]. Defaults to DISABLED. |
| modes | | `list[str]` | `ro` | `N/A` | Available energy management modes. |
| startup_delay | `EOS_EMS__STARTUP_DELAY` | `float` | `rw` | `5` | Startup delay in seconds for EOS energy management runs. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
@@ -28,3 +29,24 @@
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"ems": {
"startup_delay": 5.0,
"interval": 300.0,
"mode": "OPTIMIZATION",
"modes": [
"DISABLED",
"PREDICTION",
"OPTIMIZATION"
]
}
}
```
<!-- pyml enable line-length -->
+1
View File
@@ -217,6 +217,7 @@
"token": "your-token",
"site_id": 12345
},
"pvlib": {},
"pvnode": {
"api_key": "",
"site_id": null,
+2 -2
View File
@@ -63,8 +63,8 @@
"providers": [
"LoadAkkudoktor",
"LoadAkkudoktorAdjusted",
"LoadVrm",
"LoadImport"
"LoadImport",
"LoadVrm"
]
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| algorithm | `EOS_OPTIMIZATION__ALGORITHM` | `str` | `rw` | `GENETIC` | The optimization algorithm. Defaults to GENETIC |
| algorithm | `EOS_OPTIMIZATION__ALGORITHM` | `<enum 'OptimizationAlgorithm'>` | `rw` | `required` | Optimization algorithm [GENETIC | GENETIC0]. Defaults to GENETIC. |
| algorithms | | `list[str]` | `ro` | `N/A` | Available optimization algorithms. |
| genetic | `EOS_OPTIMIZATION__GENETIC` | `GeneticCommonSettings` | `rw` | `required` | GENETIC optimization algorithm configuration. |
| genetic0 | `EOS_OPTIMIZATION__GENETIC0` | `Genetic0CommonSettings` | `rw` | `required` | GENETIC0 optimization algorithm configuration. |
+36 -6
View File
@@ -18,6 +18,7 @@
| provider | `EOS_PVFORECAST__PROVIDER` | `str | None` | `rw` | `None` | PVForecast provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available PVForecast provider ids. |
| pvforecastimport | `EOS_PVFORECAST__PVFORECASTIMPORT` | `PVForecastImportCommonSettings` | `rw` | `required` | PV forecast import provider settings |
| pvlib | `EOS_PVFORECAST__PVLIB` | `PVForecastPVLibCommonSettings` | `rw` | `required` | PVLib provider settings |
| pvnode | `EOS_PVFORECAST__PVNODE` | `PVForecastPVNodeCommonSettings` | `rw` | `required` | PVNode provider settings |
| solcast | `EOS_PVFORECAST__SOLCAST` | `PVForecastSolcastCommonSettings` | `rw` | `required` | Solcast provider settings |
| vrm | `EOS_PVFORECAST__VRM` | `PVForecastVrmCommonSettings` | `rw` | `required` | Victron Remote Management (VRM) provider settings |
@@ -41,6 +42,7 @@
"token": "your-token",
"site_id": 12345
},
"pvlib": {},
"pvnode": {
"api_key": "",
"site_id": null,
@@ -122,6 +124,7 @@
"token": "your-token",
"site_id": 12345
},
"pvlib": {},
"pvnode": {
"api_key": "",
"site_id": null,
@@ -183,11 +186,12 @@
"max_planes": 1,
"providers": [
"PVForecastAkkudoktor",
"PVForecastVrm",
"PVForecastPVNode",
"PVForecastForecastSolar",
"PVForecastImport",
"PVForecastPVLib",
"PVForecastPVNode",
"PVForecastSolcast",
"PVForecastImport"
"PVForecastVrm"
],
"planes_peakpower": [
5.0,
@@ -317,6 +321,32 @@
```
<!-- pyml enable line-length -->
### Common settings for pvforecast data calculation with PVLib
<!-- pyml disable line-length -->
:::{table} pvforecast::pvlib
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"pvlib": {}
}
}
```
<!-- pyml enable line-length -->
### Common settings for pvforecast data import from file or JSON string
<!-- pyml disable line-length -->
@@ -357,13 +387,13 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| albedo | `float | None` | `rw` | `None` | Proportion of the light hitting the ground that it reflects back. |
| albedo | `float | None` | `rw` | `0.2` | Proportion of the light hitting the ground that it reflects back. |
| inverter_model | `str | None` | `rw` | `None` | Model of the inverter of this plane. |
| inverter_paco | `int | None` | `rw` | `None` | AC power rating of the inverter [W]. |
| loss | `float | None` | `rw` | `14.0` | Sum of PV system losses in percent |
| module_model | `str | None` | `rw` | `None` | Model of the PV modules of this plane. |
| modules_per_string | `int | None` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
| mountingplace | `str | None` | `rw` | `free` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
| mountingplace | `str | None` | `rw` | `building` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
| optimal_surface_tilt | `bool | None` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
| optimalangles | `bool | None` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
| peakpower | `float | None` | `rw` | `None` | Nominal power of PV system in kW. |
@@ -395,7 +425,7 @@
],
"peakpower": 5.0,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"mountingplace": "building",
"loss": 14.0,
"trackingtype": 0,
"optimal_surface_tilt": false,
+2 -2
View File
@@ -47,8 +47,8 @@
"providers": [
"BrightSky",
"ClearOutside",
"OpenMeteo",
"WeatherImport"
"WeatherImport",
"OpenMeteo"
]
}
}
+45 -1
View File
@@ -1,6 +1,6 @@
# Akkudoktor-EOS
**Version**: `v0.3.0.dev2608010961051894`
**Version**: `v0.3.0.dev2608071015082558`
<!-- pyml disable line-length -->
**Description**: This project provides a comprehensive solution for simulating and optimizing an energy system based on renewable energy sources. With a focus on photovoltaic (PV) systems, battery storage (batteries), load management (consumer requirements), heat pumps, electric vehicles, and consideration of electricity price data, this system enables forecasting and optimization of energy flow and costs over a specified period.
@@ -1464,6 +1464,50 @@ Args:
---
## GET /v1/prediction/pvforecast/pvlib/inverters
<!-- pyml disable line-length -->
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_pvforecast_inverters_get_v1_prediction_pvforecast_pvlib_inverters_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_pvforecast_inverters_get_v1_prediction_pvforecast_pvlib_inverters_get)
<!-- pyml enable line-length -->
Fastapi Prediction Pvforecast Inverters Get
<!-- pyml disable line-length -->
```python
"""
Get inverter names supported by PVForecast PVLib provider.
"""
```
<!-- pyml enable line-length -->
**Responses**:
- **200**: Successful Response
---
## GET /v1/prediction/pvforecast/pvlib/modules
<!-- pyml disable line-length -->
**Links**: [local](http://localhost:8503/docs#/default/fastapi_prediction_pvforecast_modules_get_v1_prediction_pvforecast_pvlib_modules_get), [eos](https://petstore3.swagger.io/?url=https://raw.githubusercontent.com/Akkudoktor-EOS/EOS/refs/heads/main/openapi.json#/default/fastapi_prediction_pvforecast_modules_get_v1_prediction_pvforecast_pvlib_modules_get)
<!-- pyml enable line-length -->
Fastapi Prediction Pvforecast Modules Get
<!-- pyml disable line-length -->
```python
"""
Get module names supported by PVForecast PVLib provider.
"""
```
<!-- pyml enable line-length -->
**Responses**:
- **200**: Successful Response
---
## DELETE /v1/prediction/range
<!-- pyml disable line-length -->
+24
View File
@@ -693,6 +693,30 @@ The PV forecast data must be provided in one of the formats described in
The data may additionally or solely be provided by the
**PUT** `/v1/prediction/import/PVForecastImport` endpoint.
### PVForecastPVLib Provider
The `PVForecastPVLib` provider calculates PV power forecasts locally using the
[PVLib](https://pvlib-python.readthedocs.io/) simulation library. Unlike the
API-based providers, no external forecast service is required. The provider
uses the configured PV system geometry together with the weather prediction
(`weather_ghi`, `weather_dni`, `weather_dhi`, `weather_temp_air`, etc.) to
simulate the expected DC module power and AC inverter output.
The provider supports multiple PV planes and automatically sums their power.
Module and inverter models are selected from the CEC database by name or by
their nominal power rating. AkkudoktorEOS automatically generates and caches
the required CEC databases on first use by combining the current SAM database,
legacy PVLib entries, and the additional EMHASS models.
The following prediction keys are provided:
- `pvforecast_ac_power`: Total AC power (W).
- `pvforecast_dc_power`: Total DC power (W).
Currently, the configuration options `userhorizon`, `optimalangles`, and
tracking systems (`trackingtype != 0`) are ignored. If no `albedo` is
configured, a default value of `0.2` is used.
### PVForecastPVNode Provider
The `PVForecastPVNode` provider retrieves native 15-minute PV power forecasts from the
+131 -24
View File
@@ -8,7 +8,7 @@
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
},
"version": "v0.3.0.dev2608010961051894"
"version": "v0.3.0.dev2608071015082558"
},
"paths": {
"/v1/admin/cache/clear": {
@@ -2391,6 +2391,58 @@
}
}
},
"/v1/prediction/pvforecast/pvlib/modules": {
"get": {
"tags": [
"prediction"
],
"summary": "Fastapi Prediction Pvforecast Modules Get",
"description": "Get module names supported by PVForecast PVLib provider.",
"operationId": "fastapi_prediction_pvforecast_modules_get_v1_prediction_pvforecast_pvlib_modules_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"type": "string"
},
"type": "array",
"title": "Response Fastapi Prediction Pvforecast Modules Get V1 Prediction Pvforecast Pvlib Modules Get"
}
}
}
}
}
}
},
"/v1/prediction/pvforecast/pvlib/inverters": {
"get": {
"tags": [
"prediction"
],
"summary": "Fastapi Prediction Pvforecast Inverters Get",
"description": "Get inverter names supported by PVForecast PVLib provider.",
"operationId": "fastapi_prediction_pvforecast_inverters_get_v1_prediction_pvforecast_pvlib_inverters_get",
"responses": {
"200": {
"description": "Successful Response",
"content": {
"application/json": {
"schema": {
"items": {
"type": "string"
},
"type": "array",
"title": "Response Fastapi Prediction Pvforecast Inverters Get V1 Prediction Pvforecast Pvlib Inverters Get"
}
}
}
}
}
}
},
"/v1/energy-management/optimization/solution": {
"get": {
"tags": [
@@ -2427,8 +2479,7 @@
"in": "path",
"required": true,
"schema": {
"type": "string",
"title": "Algorithm"
"$ref": "#/components/schemas/OptimizationAlgorithm"
}
}
],
@@ -2828,7 +2879,6 @@
"type": "string",
"title": "Device Id",
"description": "ID of device",
"default": "<unknown>",
"examples": [
"battery1",
"ev1",
@@ -2982,7 +3032,6 @@
"type": "string",
"title": "Device Id",
"description": "ID of device",
"default": "<unknown>",
"examples": [
"battery1",
"ev1",
@@ -3242,7 +3291,7 @@
"$ref": "#/components/schemas/DatabaseCommonSettings-Output"
},
"ems": {
"$ref": "#/components/schemas/EnergyManagementCommonSettings"
"$ref": "#/components/schemas/EnergyManagementCommonSettings-Output"
},
"logging": {
"$ref": "#/components/schemas/LoggingCommonSettings-Output"
@@ -4524,7 +4573,7 @@
"title": "EnergyChartsBiddingZones",
"description": "Energy Charts Bidding Zones."
},
"EnergyManagementCommonSettings": {
"EnergyManagementCommonSettings-Input": {
"properties": {
"startup_delay": {
"type": "number",
@@ -4545,10 +4594,9 @@
},
"mode": {
"$ref": "#/components/schemas/EnergyManagementMode",
"description": "Energy management mode [DISABLED | OPTIMIZATION | PREDICTION].",
"description": "Energy management mode [DISABLED | PREDICTION | OPTIMIZATION]. Defaults to DISABLED.",
"examples": [
"OPTIMIZATION",
"PREDICTION"
"OPTIMIZATION"
]
}
},
@@ -4556,6 +4604,49 @@
"title": "EnergyManagementCommonSettings",
"description": "Energy Management Configuration."
},
"EnergyManagementCommonSettings-Output": {
"properties": {
"startup_delay": {
"type": "number",
"minimum": 1.0,
"title": "Startup Delay",
"description": "Startup delay in seconds for EOS energy management runs.",
"default": 5
},
"interval": {
"type": "number",
"minimum": 60.0,
"title": "Interval",
"description": "Intervall between EOS energy management runs [seconds].",
"default": 300.0,
"examples": [
"300"
]
},
"mode": {
"$ref": "#/components/schemas/EnergyManagementMode",
"description": "Energy management mode [DISABLED | PREDICTION | OPTIMIZATION]. Defaults to DISABLED.",
"examples": [
"OPTIMIZATION"
]
},
"modes": {
"items": {
"type": "string"
},
"type": "array",
"title": "Modes",
"description": "Available energy management modes.",
"readOnly": true
}
},
"type": "object",
"required": [
"modes"
],
"title": "EnergyManagementCommonSettings",
"description": "Energy Management Configuration."
},
"EnergyManagementMode": {
"type": "string",
"enum": [
@@ -8181,7 +8272,6 @@
"type": "string",
"title": "Device Id",
"description": "ID of device",
"default": "<unknown>",
"examples": [
"battery1",
"ev1",
@@ -8244,7 +8334,6 @@
"type": "string",
"title": "Device Id",
"description": "ID of device",
"default": "<unknown>",
"examples": [
"battery1",
"ev1",
@@ -8782,7 +8871,6 @@
"type": "string",
"title": "Device Id",
"description": "ID of device",
"default": "<unknown>",
"examples": [
"battery1",
"ev1",
@@ -8876,7 +8964,6 @@
"type": "string",
"title": "Device Id",
"description": "ID of device",
"default": "<unknown>",
"examples": [
"battery1",
"ev1",
@@ -9781,13 +9868,20 @@
"title": "OMBCStatus",
"description": "Reports the current operational status of an Operation Mode Based Control system.\n\nThis model provides real-time status information about an OMBC-controlled device,\nincluding which operation mode is currently active, how it is configured,\nand information about recent mode transitions. It enables monitoring of the\ndevice's operational state and tracking mode transition history."
},
"OptimizationAlgorithm": {
"type": "string",
"enum": [
"GENETIC",
"GENETIC0"
],
"title": "OptimizationAlgorithm",
"description": "Optimization Algorithm."
},
"OptimizationCommonSettings-Input": {
"properties": {
"algorithm": {
"type": "string",
"title": "Algorithm",
"description": "The optimization algorithm. Defaults to GENETIC",
"default": "GENETIC",
"$ref": "#/components/schemas/OptimizationAlgorithm",
"description": "Optimization algorithm [GENETIC | GENETIC0]. Defaults to GENETIC.",
"examples": [
"GENETIC",
"GENETIC0"
@@ -9825,10 +9919,8 @@
"OptimizationCommonSettings-Output": {
"properties": {
"algorithm": {
"type": "string",
"title": "Algorithm",
"description": "The optimization algorithm. Defaults to GENETIC",
"default": "GENETIC",
"$ref": "#/components/schemas/OptimizationAlgorithm",
"description": "Optimization algorithm [GENETIC | GENETIC0]. Defaults to GENETIC.",
"examples": [
"GENETIC",
"GENETIC0"
@@ -10412,6 +10504,10 @@
"$ref": "#/components/schemas/PVForecastVrmCommonSettings",
"description": "Victron Remote Management (VRM) provider settings"
},
"pvlib": {
"$ref": "#/components/schemas/PVForecastPVLibCommonSettings",
"description": "PVLib provider settings"
},
"pvnode": {
"$ref": "#/components/schemas/PVForecastPVNodeCommonSettings",
"description": "PVNode provider settings"
@@ -10529,6 +10625,10 @@
"$ref": "#/components/schemas/PVForecastVrmCommonSettings",
"description": "Victron Remote Management (VRM) provider settings"
},
"pvlib": {
"$ref": "#/components/schemas/PVForecastPVLibCommonSettings",
"description": "PVLib provider settings"
},
"pvnode": {
"$ref": "#/components/schemas/PVForecastPVNodeCommonSettings",
"description": "PVNode provider settings"
@@ -10740,6 +10840,12 @@
"title": "PVForecastImportCommonSettings",
"description": "Common settings for pvforecast data import from file or JSON string."
},
"PVForecastPVLibCommonSettings": {
"properties": {},
"type": "object",
"title": "PVForecastPVLibCommonSettings",
"description": "Common settings for pvforecast data calculation with PVLib."
},
"PVForecastPVNodeCommonSettings": {
"properties": {
"api_key": {
@@ -10889,7 +10995,7 @@
],
"title": "Mountingplace",
"description": "Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated.",
"default": "free"
"default": "building"
},
"loss": {
"anyOf": [
@@ -10969,6 +11075,7 @@
],
"title": "Albedo",
"description": "Proportion of the light hitting the ground that it reflects back.",
"default": 0.2,
"examples": [
null
]
@@ -11455,7 +11562,7 @@
"ems": {
"anyOf": [
{
"$ref": "#/components/schemas/EnergyManagementCommonSettings"
"$ref": "#/components/schemas/EnergyManagementCommonSettings-Input"
},
{
"type": "null"
+1 -1
View File
@@ -697,7 +697,7 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
SettingsEOSDefaults.__init__(self, *args, **kwargs)
self._initialized = True
logger.debug(f"Config setup:\n{self}")
logger.trace(f"Config setup:\n{self}")
def merge_settings(self, settings: SettingsEOS) -> None:
"""Merges the provided settings into the global settings for EOS, with optional overwrite.
+44 -33
View File
@@ -1258,9 +1258,8 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
- 15-minute interval → buckets on :00, :15, :30, :45
- 1-hour interval → buckets on the hour
When False (default), the origin is ``query_start`` (or ``"start_day"`` when
no start is given), preserving the existing behaviour where buckets are
aligned to the query window rather than the clock.
When False (default), the origin is the requested start_datetime, or the timestamp
of the first returned sample if no start time was specified.
Set to True when storing compacted records back to the database so that the
resulting timestamps are predictable and human-readable. Leave False for
@@ -1323,25 +1322,33 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
key=key, start_datetime=query_start, end_datetime=query_end, dropna=dropna
)
# Determine the resampling start to be used to calculate resample origin
if start_datetime is not None:
# Use user supplied start datetime for resampling start
resample_start = start_datetime
elif not series.empty:
# Use first data sample to define the resampling start
resample_start = to_datetime(series.index[0])
else:
# No explicit start and no data available.
resample_start = None
# Ensure we have at least one value
if series.empty:
dummy_time = (
query_start - interval if query_start is not None else to_datetime(to_maxtime=False)
)
dummy_time = start_datetime or end_datetime or to_datetime(to_maxtime=False)
series = pd.Series(
[None],
index=pd.DatetimeIndex([dummy_time], tz="UTC"),
name=key,
)
# prepend context samples
if query_start is not None:
idx = series.index
# Number of samples before query_start
start_index = idx.searchsorted(pd.Timestamp(query_start), side="left")
if start_index == 0:
# No value before query_start -> prepend dummy
prepend = pd.Series(
[series.iloc[0]],
index=pd.DatetimeIndex([query_start - interval], tz="UTC"),
@@ -1350,30 +1357,9 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
series = pd.concat([prepend, series])
elif start_index > 1:
# Keep only the last sample before query_start
series = series.iloc[start_index - 1 :]
# Determine resample origin
if align_to_interval:
# Snap to nearest UTC epoch-aligned floor of the interval so that bucket
# timestamps land on wall-clock-round boundaries (:00, :15, :30, :45 etc.)
# regardless of sub-second jitter in query_start.
interval_sec = int(interval.total_seconds())
if interval_sec > 0:
start_epoch = int(query_start.timestamp())
floored_epoch = (start_epoch // interval_sec) * interval_sec
resample_origin: Union[str, pd.Timestamp] = pd.Timestamp(
floored_epoch, unit="s", tz="UTC"
)
else:
resample_origin = query_start
else:
# Original behaviour: align to the query window start.
resample_origin = query_start
else:
# We do not have a query_start, align resample buckets to midnight of first day
resample_origin = "start_day"
# append context samples
if query_end is not None:
if compare_datetimes(to_datetime(series.index[-1]), query_end).lt:
append = pd.Series(
@@ -1383,6 +1369,26 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
)
series = pd.concat([series, append])
# Determine resampling origin
if align_to_interval and resample_start:
interval_sec = int(interval.total_seconds())
if interval_sec > 0:
start_epoch = int(resample_start.timestamp())
floored_epoch = (start_epoch // interval_sec) * interval_sec
resample_origin: Union[pd.Timestamp, str] = pd.Timestamp(
floored_epoch, unit="s", tz="UTC"
)
else:
resample_origin = resample_start
else:
# Preserve original behaviour: buckets start at the resample start.
resample_origin = resample_start
if resample_origin is None:
# We have no resample origin - take start of day as default
resample_origin = "start_day"
# Check for numeric values
numeric_series = pd.to_numeric(series, errors="coerce") # ensures float64, not object dtype
is_numeric = numeric_series.dropna().notna().all()
@@ -2611,7 +2617,8 @@ class DataContainer(SingletonMixin, DataABC):
continue
if series is None:
raise KeyError(f"No data found for key '{key}'.")
provider_ids = [provider.provider_id() for provider in self.enabled_providers]
raise KeyError(f"No data found for key '{key}' in enabled providers '{provider_ids}'.")
return series
@@ -2804,7 +2811,6 @@ class DataContainer(SingletonMixin, DataABC):
if end_datetime:
end_datetime = end_datetime.add(seconds=1)
# Create a DatetimeIndex based on start, end, and interval
if start_datetime is None or end_datetime is None:
raise ValueError(
f"Can not determine datetime range. Got '{start_datetime}'..'{end_datetime}'."
@@ -2832,8 +2838,13 @@ class DataContainer(SingletonMixin, DataABC):
)
if reference_index is None:
reference_index = series.index
reference_index = series.index.copy()
elif not series.index.equals(reference_index):
logger.error(
f"keys_to_dataframe: Time index mismatch for key '{key}'.\n"
f"ref: {reference_index},\n"
f"index: {series.index}"
)
raise ValueError(f"Time index mismatch for key '{key}'.")
data[key] = series
+8 -5
View File
@@ -27,7 +27,10 @@ from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.optimization.optimization import (
OptimizationAlgorithm,
OptimizationSolution,
)
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
# The executor to execute the CPU heavy energy management run
@@ -168,7 +171,7 @@ class EnergyManagement(
self,
start_datetime: Optional[DateTime] = None,
mode: Optional[EnergyManagementMode] = None,
algorithm: Optional[str] = None,
algorithm: Optional[OptimizationAlgorithm] = None,
genetic_parameters: Optional[GeneticOptimizationParameters] = None,
genetic_generations: Optional[int] = None,
genetic_seed: Optional[int] = None,
@@ -192,7 +195,7 @@ class EnergyManagement(
- "DISABLED": Does not run.
Defaults to the mode defined in the current configuration.
algorithm (str, optional):
algorithm (OptimizationAlgorithm, optional):
The algorithm to use. Must be one of:
- "GENETIC": Optimization uses the `GENETIC` optimization algorithm.
- "GENETIC0": Optimization uses the `GENETIC0` optimization algorithm.
@@ -276,7 +279,7 @@ class EnergyManagement(
algorithm = self.config.optimization.algorithm
# --- GENETIC algorithm ---
if algorithm == "GENETIC":
if algorithm == OptimizationAlgorithm.GENETIC:
# Prepare optimization parameters
# This also creates default configurations for missing values and updates the predictions
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
@@ -342,7 +345,7 @@ class EnergyManagement(
)
# --- GENETIC0 algorithm ---
elif algorithm == "GENETIC0":
elif algorithm == OptimizationAlgorithm.GENETIC0:
# Prepare optimization parameters
# This also creates default configurations for missing values and updates the predictions
logger.info(f"{algorithm}: Starting optimzation parameter preparation.")
+13 -3
View File
@@ -5,7 +5,7 @@ Kept in an extra module to avoid cyclic dependencies on package import.
from enum import StrEnum
from pydantic import Field
from pydantic import Field, computed_field
from akkudoktoreos.config.configabc import SettingsBaseModel, is_home_assistant_addon
@@ -51,7 +51,17 @@ class EnergyManagementCommonSettings(SettingsBaseModel):
mode: EnergyManagementMode = Field(
default_factory=ems_default_mode,
json_schema_extra={
"description": "Energy management mode [DISABLED | OPTIMIZATION | PREDICTION].",
"examples": ["OPTIMIZATION", "PREDICTION"],
"description": (
f"Energy management mode "
f"[{' | '.join(mode.value for mode in EnergyManagementMode)}]. "
f"Defaults to {ems_default_mode()}."
),
"examples": ["OPTIMIZATION"],
},
)
@computed_field # type: ignore[prop-decorator]
@property
def modes(self) -> list[str]:
"""Available energy management modes."""
return [mode.value for mode in EnergyManagementMode]
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,10 @@
Name,Vac,Pso,Paco,Pdco,Vdco,C0,C1,C2,C3,Pnt,Vdcmax,Idcmax,Mppt_low,Mppt_high,CEC_Date,CEC_hybrid,CEC_Type
Units,V,W,W,W,V,1/W,1/V,1/V,1/V,W,V,A,V,V,,,
[0],inv_snl_ac_voltage,inv_snl_pso,inv_snl_paco,inv_snl_pdco,inv_snl_vdco,inv_snl_c0,inv_snl_c1,inv_snl_c2,inv_snl_c3,inv_snl_pnt,inv_snl_vdcmax,inv_snl_idcmax,inv_snl_mppt_low,inv_snl_mppt_hi,inv_cec_date,inv_cec_hybrid,inv_cec_type
Sungrow: SH25T,400,250,27500,26000,600,-0.0000828,-0.000759,-0.0001722,-0.0000414,25,1100,80,200,950,01/01/2025,Y,Hybrid
Sungrow: SH20T,400,200,22000,21000,600,-0.00007584,-0.0007245,-0.0001587,-0.00003834,25,1100,80,200,950,01/01/2025,Y,Hybrid
Sungrow: SH15T,400,150,16500,15700,600,-0.000069,-0.00069,-0.000138,-0.0000345,25,1100,80,200,950,01/01/2025,Y,Hybrid
Sungrow: SH10RT,400,100,11000,10467,600,-0.00005892,-0.0006555,-0.0001173,-0.00002934,25,1100,80,200,950,01/01/2025,Y,Hybrid
Sungrow: SH6RT,400,55,6000,6120,600,-0.000108,-0.001202,-0.000215,-0.0000538,25,1000,25,200,950,01/01/2025,Y,Hybrid
Sungrow: SH10RS,400,100,10000,9500,600,-0.000065,-0.00075,-0.00013,-0.000032,25,1000,25,250,850,01/01/2025,N,Grid-Tie
Fox ESS: KH/KA10,240,32,10500,15000,360,-0.000001128,-0.000001128,0.0025,0.0025,15,600,20,80,500,01/01/2025,Y,Hybrid
1 Name Vac Pso Paco Pdco Vdco C0 C1 C2 C3 Pnt Vdcmax Idcmax Mppt_low Mppt_high CEC_Date CEC_hybrid CEC_Type
2 Units V W W W V 1/W 1/V 1/V 1/V W V A V V
3 [0] inv_snl_ac_voltage inv_snl_pso inv_snl_paco inv_snl_pdco inv_snl_vdco inv_snl_c0 inv_snl_c1 inv_snl_c2 inv_snl_c3 inv_snl_pnt inv_snl_vdcmax inv_snl_idcmax inv_snl_mppt_low inv_snl_mppt_hi inv_cec_date inv_cec_hybrid inv_cec_type
4 Sungrow: SH25T 400 250 27500 26000 600 -0.0000828 -0.000759 -0.0001722 -0.0000414 25 1100 80 200 950 01/01/2025 Y Hybrid
5 Sungrow: SH20T 400 200 22000 21000 600 -0.00007584 -0.0007245 -0.0001587 -0.00003834 25 1100 80 200 950 01/01/2025 Y Hybrid
6 Sungrow: SH15T 400 150 16500 15700 600 -0.000069 -0.00069 -0.000138 -0.0000345 25 1100 80 200 950 01/01/2025 Y Hybrid
7 Sungrow: SH10RT 400 100 11000 10467 600 -0.00005892 -0.0006555 -0.0001173 -0.00002934 25 1100 80 200 950 01/01/2025 Y Hybrid
8 Sungrow: SH6RT 400 55 6000 6120 600 -0.000108 -0.001202 -0.000215 -0.0000538 25 1000 25 200 950 01/01/2025 Y Hybrid
9 Sungrow: SH10RS 400 100 10000 9500 600 -0.000065 -0.00075 -0.00013 -0.000032 25 1000 25 250 850 01/01/2025 N Grid-Tie
10 Fox ESS: KH/KA10 240 32 10500 15000 360 -0.000001128 -0.000001128 0.0025 0.0025 15 600 20 80 500 01/01/2025 Y Hybrid
@@ -0,0 +1,9 @@
Name,Manufacturer,Technology,Bifacial,STC,PTC,A_c,Length,Width,N_s,I_sc_ref,V_oc_ref,I_mp_ref,V_mp_ref,alpha_sc,beta_oc,T_NOCT,a_ref,I_L_ref,I_o_ref,R_s,R_sh_ref,Adjust,gamma_pmp,BIPV,Version,Date
Units,,,,,,m2,m,m,,A,V,A,V,A/K,V/K,C,V,A,A,Ohm,Ohm,%,%/K,,,
[0],lib_manufacturer,cec_material,lib_is_bifacial,,,cec_area,lib_length,lib_width,cec_n_s,cec_i_sc_ref,cec_v_oc_ref,cec_i_mp_ref,cec_v_mp_ref,cec_alpha_sc,cec_beta_oc,cec_t_noct,cec_a_ref,cec_i_l_ref,cec_i_o_ref,cec_r_s,cec_r_sh_ref,cec_adjust,cec_gamma_pmp,,,
Jinko Solar JKM475N-60HL4-V,Jinko Solar,Mono-c-Si,0,475.0,432.0,2.16,1.903,1.134,120,14.23,42.54,13.49,35.21,0.046,-0.17,45.0,1.3,14.23,2e-7,0.2,300.0,1,-0.003,N,2021.08.01,01/03/2021
Hanwha Q CELLS Q.PEAK L-G5 300W,Hanwha Q CELLS,Mono-c-Si,N,300.0,273.0,1.67,1.67,1.0,72,9.57,39.58,9.01,33.29,0.0006,-0.33,45.0,1.2,9.58,1e-10,0.5,1500,1,-0.38,N,2019.08.01,01/03/2019
Risen RSM40-8-390M,Risen,Mono-c-Si,0,390.0,355.0,1.82,1.755,1.038,80,13.98,40.70,13.19,31.8,0.048,-0.15,45.0,1.25,13.98,1.9e-7,0.38,445.0,1,-0.35,N,2024.01.01,01/04/2024
AIKO-A475-MAH54Mw,AIKO,Mono-c-Si,0,475.0,432.3,1.99,1.757,1.134,108,14.35,41.24,13.66,34.80,0.0072,-0.091,45.0,1.2,14.35,5.0e-10,0.20,1000,1,-0.26,N,2024.12.01,14/05/2025
Jolywood JW-HD108N-420W,Jolywood,Mono-c-Si,1,420.0,382.0,1.96,1.728,1.134,108,13.98,37.9,13.17,31.9,0.0064,-0.10,42.0,1.2,13.98,5.0e-10,0.20,1000,1,-0.32,N,2022.10.01,09/12/2025
Huasun HS-182-B108-DSN440,Huasun,Mono-c-Si,1,440.0,400.0,1.95,1.722,1.134,108,13.05,41.91,12.53,35.12,0.0052,-0.10,44.0,1.3,13.05,1e-9,0.22,900,1,-0.26,N,2023.04.17,01/01/2026
1 Name Manufacturer Technology Bifacial STC PTC A_c Length Width N_s I_sc_ref V_oc_ref I_mp_ref V_mp_ref alpha_sc beta_oc T_NOCT a_ref I_L_ref I_o_ref R_s R_sh_ref Adjust gamma_pmp BIPV Version Date
2 Units m2 m m A V A V A/K V/K C V A A Ohm Ohm % %/K
3 [0] lib_manufacturer cec_material lib_is_bifacial cec_area lib_length lib_width cec_n_s cec_i_sc_ref cec_v_oc_ref cec_i_mp_ref cec_v_mp_ref cec_alpha_sc cec_beta_oc cec_t_noct cec_a_ref cec_i_l_ref cec_i_o_ref cec_r_s cec_r_sh_ref cec_adjust cec_gamma_pmp
4 Jinko Solar JKM475N-60HL4-V Jinko Solar Mono-c-Si 0 475.0 432.0 2.16 1.903 1.134 120 14.23 42.54 13.49 35.21 0.046 -0.17 45.0 1.3 14.23 2e-7 0.2 300.0 1 -0.003 N 2021.08.01 01/03/2021
5 Hanwha Q CELLS Q.PEAK L-G5 300W Hanwha Q CELLS Mono-c-Si N 300.0 273.0 1.67 1.67 1.0 72 9.57 39.58 9.01 33.29 0.0006 -0.33 45.0 1.2 9.58 1e-10 0.5 1500 1 -0.38 N 2019.08.01 01/03/2019
6 Risen RSM40-8-390M Risen Mono-c-Si 0 390.0 355.0 1.82 1.755 1.038 80 13.98 40.70 13.19 31.8 0.048 -0.15 45.0 1.25 13.98 1.9e-7 0.38 445.0 1 -0.35 N 2024.01.01 01/04/2024
7 AIKO-A475-MAH54Mw AIKO Mono-c-Si 0 475.0 432.3 1.99 1.757 1.134 108 14.35 41.24 13.66 34.80 0.0072 -0.091 45.0 1.2 14.35 5.0e-10 0.20 1000 1 -0.26 N 2024.12.01 14/05/2025
8 Jolywood JW-HD108N-420W Jolywood Mono-c-Si 1 420.0 382.0 1.96 1.728 1.134 108 13.98 37.9 13.17 31.9 0.0064 -0.10 42.0 1.2 13.98 5.0e-10 0.20 1000 1 -0.32 N 2022.10.01 09/12/2025
9 Huasun HS-182-B108-DSN440 Huasun Mono-c-Si 1 440.0 400.0 1.95 1.722 1.134 108 13.05 41.91 12.53 35.12 0.0052 -0.10 44.0 1.3 13.05 1e-9 0.22 900 1 -0.26 N 2023.04.17 01/01/2026
+10 -1
View File
@@ -1,5 +1,7 @@
"""Abstract and base classes for devices."""
import secrets
import string
from enum import StrEnum
from pydantic import Field
@@ -7,11 +9,18 @@ from pydantic import Field
from akkudoktoreos.config.configabc import SettingsBaseModel
def device_default_id() -> str:
"""Provide random default device id."""
alphabet = string.ascii_letters + string.digits
device_id = "".join(secrets.choice(alphabet) for _ in range(10))
return device_id
class DevicesBaseSettings(SettingsBaseModel):
"""Base devices setting."""
device_id: str = Field(
default="<unknown>",
default_factory=device_default_id,
json_schema_extra={
"description": "ID of device",
"examples": ["battery1", "ev1", "inverter1", "dishwasher"],
@@ -293,6 +293,24 @@ class GeneticOptimizationParameters(
# Assure predictions are uptodate
await cls.prediction.update_data()
try: # Try first - predition is also needed by the default PV forecast
array = await cls.prediction.key_to_array(
key="weather_temp_air",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
weather_temp_air = array.tolist()
except Exception as e:
logger.info(
"No weather forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.weather.provider = "OpenMeteo"
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="pvforecast_ac_power",
@@ -311,36 +329,52 @@ class GeneticOptimizationParameters(
cls.config.merge_settings_from_dict(
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider": "PVForecastPVLib",
"max_planes": 4,
"planes": [
{
"peakpower": 5.0,
"surface_tilt": 7,
"surface_azimuth": 170,
"surface_tilt": 7,
"userhorizon": [20, 27, 22, 20],
"peakpower": 5.0,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 10000,
"modules_per_string": 12,
"strings_per_inverter": 1,
},
{
"peakpower": 4.8,
"surface_azimuth": 90,
"surface_tilt": 7,
"surface_azimuth": 90,
"userhorizon": [30, 30, 30, 50],
"peakpower": 4.8,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 10000,
"modules_per_string": 12,
"strings_per_inverter": 1,
},
{
"peakpower": 1.4,
"surface_azimuth": 140,
"surface_tilt": 60,
"surface_azimuth": 140,
"userhorizon": [60, 30, 0, 30],
"peakpower": 1.4,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 2000,
"modules_per_string": 5,
"strings_per_inverter": 1,
},
{
"peakpower": 1.6,
"surface_azimuth": 185,
"surface_tilt": 45,
"surface_azimuth": 185,
"userhorizon": [45, 25, 30, 60],
"peakpower": 1.6,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 1400,
"modules_per_string": 4,
"strings_per_inverter": 1,
},
],
},
@@ -363,7 +397,24 @@ class GeneticOptimizationParameters(
attempt,
e,
)
cls.config.elecprice.provider = "ElecPriceAkkudoktor"
cls.config.merge_settings_from_dict(
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"windows": [
{
"duration": "1 day",
"start_time": "00:00:00.000000",
"value": 0.288,
}
]
}
},
"provider": "ElecPriceFixed",
},
},
)
# Retry
continue
try:
@@ -420,24 +471,6 @@ class GeneticOptimizationParameters(
)
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="weather_temp_air",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
weather_temp_air = array.tolist()
except Exception as e:
logger.info(
"No weather forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.weather.provider = "BrightSky"
# Retry
continue
# Add device data
@@ -284,6 +284,24 @@ class Genetic0OptimizationParameters(
# Assure predictions are uptodate
await cls.prediction.update_data()
try: # Try first - predition is also needed by the default PV forecast
array = await cls.prediction.key_to_array(
key="weather_temp_air",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
weather_temp_air = array.tolist()
except Exception as e:
logger.info(
"No weather forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.weather.provider = "OpenMeteo"
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="pvforecast_ac_power",
@@ -302,36 +320,52 @@ class Genetic0OptimizationParameters(
cls.config.merge_settings_from_dict(
{
"pvforecast": {
"provider": "PVForecastAkkudoktor",
"provider": "PVForecastPVLib",
"max_planes": 4,
"planes": [
{
"peakpower": 5.0,
"surface_tilt": 7,
"surface_azimuth": 170,
"surface_tilt": 7,
"userhorizon": [20, 27, 22, 20],
"peakpower": 5.0,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 10000,
"modules_per_string": 12,
"strings_per_inverter": 1,
},
{
"peakpower": 4.8,
"surface_azimuth": 90,
"surface_tilt": 7,
"surface_azimuth": 90,
"userhorizon": [30, 30, 30, 50],
"peakpower": 4.8,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 10000,
"modules_per_string": 12,
"strings_per_inverter": 1,
},
{
"peakpower": 1.4,
"surface_azimuth": 140,
"surface_tilt": 60,
"surface_azimuth": 140,
"userhorizon": [60, 30, 0, 30],
"peakpower": 1.4,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 2000,
"modules_per_string": 5,
"strings_per_inverter": 1,
},
{
"peakpower": 1.6,
"surface_azimuth": 185,
"surface_tilt": 45,
"surface_azimuth": 185,
"userhorizon": [45, 25, 30, 60],
"peakpower": 1.6,
"module_model": "AXITEC_AC_410MH_144S",
"inverter_model": "Sungrow__SH25T",
"inverter_paco": 1400,
"modules_per_string": 4,
"strings_per_inverter": 1,
},
],
},
@@ -354,7 +388,24 @@ class Genetic0OptimizationParameters(
attempt,
e,
)
cls.config.elecprice.provider = "ElecPriceAkkudoktor"
cls.config.merge_settings_from_dict(
{
"elecprice": {
"elecpricefixed": {
"time_windows": {
"windows": [
{
"duration": "1 day",
"start_time": "00:00:00.000000",
"value": 0.288,
}
]
}
},
"provider": "ElecPriceFixed",
},
},
)
# Retry
continue
try:
@@ -411,24 +462,6 @@ class Genetic0OptimizationParameters(
)
# Retry
continue
try:
array = await cls.prediction.key_to_array(
key="weather_temp_air",
start_datetime=parameter_start_datetime,
end_datetime=parameter_end_datetime,
interval=interval,
fill_method="ffill",
)
weather_temp_air = array.tolist()
except Exception as e:
logger.info(
"No weather forecast data available - defaulting to demo data. Parameter preparation attempt {}: {}",
attempt,
e,
)
cls.config.weather.provider = "BrightSky"
# Retry
continue
# Add device data
+19 -11
View File
@@ -1,3 +1,4 @@
from enum import StrEnum
from typing import Optional
from pydantic import Field, computed_field
@@ -13,22 +14,29 @@ from akkudoktoreos.optimization.genetic.geneticsettings import GeneticCommonSett
from akkudoktoreos.utils.datetimeutil import DateTime
def optimization_algorithms() -> list[str]:
"""Valid optimization algorithms."""
# Return static built-in optimization algorithms.
return [
"GENETIC",
"GENETIC0",
]
class OptimizationAlgorithm(StrEnum):
"""Optimization Algorithm."""
GENETIC = "GENETIC"
GENETIC0 = "GENETIC0"
def optimization_default_algorithm() -> OptimizationAlgorithm:
"""Provide default optimization algorithm."""
return OptimizationAlgorithm.GENETIC
class OptimizationCommonSettings(SettingsBaseModel):
"""General Optimization Configuration."""
algorithm: str = Field(
default="GENETIC",
algorithm: OptimizationAlgorithm = Field(
default_factory=optimization_default_algorithm,
json_schema_extra={
"description": "The optimization algorithm. Defaults to GENETIC",
"description": (
f"Optimization algorithm "
f"[{' | '.join(mode.value for mode in OptimizationAlgorithm)}]. "
f"Defaults to {optimization_default_algorithm()}."
),
"examples": ["GENETIC", "GENETIC0"],
},
)
@@ -54,7 +62,7 @@ class OptimizationCommonSettings(SettingsBaseModel):
@property
def algorithms(self) -> list[str]:
"""Available optimization algorithms."""
return optimization_algorithms()
return [algo.value for algo in OptimizationAlgorithm]
@computed_field # type: ignore[prop-decorator]
@property
+27 -18
View File
@@ -52,6 +52,7 @@ from akkudoktoreos.prediction.predictionabc import PredictionContainer
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLib
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcast
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
@@ -97,6 +98,7 @@ loadforecast_vrm = LoadVrm()
loadforecast_import = LoadImport()
pvforecast_akkudoktor = PVForecastAkkudoktor()
pvforecast_vrm = PVForecastVrm()
pvforecast_pvlib = PVForecastPVLib()
pvforecast_pvnode = PVForecastPVNode()
pvforecast_forecastsolar = PVForecastForecastSolar()
pvforecast_solcast = PVForecastSolcast()
@@ -122,18 +124,19 @@ def prediction_providers() -> list[
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadVrm,
LoadImport,
LoadVrm,
PVForecastAkkudoktor,
PVForecastVrm,
PVForecastPVNode,
PVForecastForecastSolar,
PVForecastSolcast,
PVForecastImport,
PVForecastPVLib,
PVForecastPVNode,
PVForecastSolcast,
PVForecastVrm,
WeatherBrightSky,
WeatherClearOutside,
WeatherOpenMeteo,
WeatherImport,
WeatherOpenMeteo,
]
]:
"""Return list of prediction providers.
@@ -158,6 +161,7 @@ def prediction_providers() -> list[
loadforecast_import, \
pvforecast_akkudoktor, \
pvforecast_vrm, \
pvforecast_pvlib, \
pvforecast_pvnode, \
pvforecast_forecastsolar, \
pvforecast_solcast, \
@@ -168,7 +172,14 @@ def prediction_providers() -> list[
weather_import
# Care for provider sequence as providers may rely on others to be updated before.
#
# Inter provider dependencies:
# - pvforecast_pvlib depends on weather
return [
weather_brightsky, # weather maybe needed by the pvforcast, keep it before
weather_clearoutside,
weather_import,
weather_openmeteo,
elecprice_akkudoktor,
elecprice_energy_charts,
elecprice_fixed,
@@ -182,18 +193,15 @@ def prediction_providers() -> list[
feedintariff_tibber,
loadforecast_akkudoktor,
loadforecast_akkudoktor_adjusted,
loadforecast_vrm,
loadforecast_import,
loadforecast_vrm,
pvforecast_akkudoktor,
pvforecast_vrm,
pvforecast_pvnode,
pvforecast_forecastsolar,
pvforecast_solcast,
pvforecast_import,
weather_brightsky,
weather_clearoutside,
weather_openmeteo,
weather_import,
pvforecast_pvlib,
pvforecast_pvnode,
pvforecast_solcast,
pvforecast_vrm,
]
@@ -215,18 +223,19 @@ class Prediction(PredictionContainer):
FeedInTariffTibber,
LoadAkkudoktor,
LoadAkkudoktorAdjusted,
LoadVrm,
LoadImport,
LoadVrm,
PVForecastAkkudoktor,
PVForecastVrm,
PVForecastPVNode,
PVForecastForecastSolar,
PVForecastSolcast,
PVForecastImport,
PVForecastPVLib,
PVForecastPVNode,
PVForecastSolcast,
PVForecastVrm,
WeatherBrightSky,
WeatherClearOutside,
WeatherOpenMeteo,
WeatherImport,
WeatherOpenMeteo,
]
] = Field(
default_factory=prediction_providers,
+12 -5
View File
@@ -11,6 +11,7 @@ from akkudoktoreos.prediction.pvforecastforecastsolar import (
PVForecastForecastSolarCommonSettings,
)
from akkudoktoreos.prediction.pvforecastimport import PVForecastImportCommonSettings
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLibCommonSettings
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNodeCommonSettings
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcastCommonSettings
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrmCommonSettings
@@ -25,11 +26,12 @@ def pvforecast_provider_ids() -> list[str]:
# Return at least provider used in example
return [
"PVForecastAkkudoktor",
"PVForecastImport",
"PVForecastVrm",
"PVForecastPVNode",
"PVForecastForecastSolar",
"PVForecastImport",
"PVForecastPVLib",
"PVForecastPVNode",
"PVForecastSolcast",
"PVForecastVrm",
]
return [
@@ -82,7 +84,7 @@ class PVForecastPlaneSetting(SettingsBaseModel):
},
)
mountingplace: Optional[str] = Field(
default="free",
default="building",
json_schema_extra={
"description": "Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated."
},
@@ -114,7 +116,7 @@ class PVForecastPlaneSetting(SettingsBaseModel):
},
)
albedo: Optional[float] = Field(
default=None,
default=0.2,
json_schema_extra={
"description": "Proportion of the light hitting the ground that it reflects back.",
"examples": [None],
@@ -206,6 +208,11 @@ class PVForecastCommonSettings(SettingsBaseModel):
json_schema_extra={"description": "Victron Remote Management (VRM) provider settings"},
)
pvlib: PVForecastPVLibCommonSettings = Field(
default_factory=PVForecastPVLibCommonSettings,
json_schema_extra={"description": "PVLib provider settings"},
)
pvnode: PVForecastPVNodeCommonSettings = Field(
default_factory=PVForecastPVNodeCommonSettings,
json_schema_extra={"description": "PVNode provider settings"},
@@ -0,0 +1,517 @@
"""Calculates pvforecast forecast data using PVLib."""
import bz2
import pickle
from pathlib import Path
from typing import ClassVar, Literal, Optional, Union
import numpy as np
import pandas as pd
from loguru import logger
from pvlib.location import Location
from pvlib.modelchain import ModelChain
from pvlib.pvsystem import PVSystem, retrieve_sam
from pvlib.solarposition import get_solarposition
from pvlib.temperature import TEMPERATURE_MODEL_PARAMETERS
from akkudoktoreos.config.configabc import SettingsBaseModel
from akkudoktoreos.core.coreabc import PredictionMixin, get_config
from akkudoktoreos.prediction.pvforecastabc import PVForecastProvider
from akkudoktoreos.utils.datetimeutil import to_duration
DeviceType = Literal["module", "inverter"]
# Memory cache for CEC databases
_cec_cache: dict[Path, tuple[int, pd.DataFrame]] = {}
def _cec_modules_path() -> Path:
"""Provide path to the modules database."""
try:
return get_config().general.data_folder_path / "cec_modules.pbz2"
except Exception:
# Config may not be initialized
return Path("modules_invalid_dummy_path")
def _cec_inverters_path() -> Path:
"""Provide path to the inverters database."""
try:
return get_config().general.data_folder_path / "cec_inverters.pbz2"
except Exception:
# Config may not be initialized
return Path("inverters_invalid_dummy_path")
def _update_cec_database() -> None:
"""Build the EOS CEC module and inverter databases.
This follows the EMHASS database generation procedure:
- start with the current SAM database
- restore models missing from the new SAM database
but present in the PVLib database
- add EMHASS custom module/inverter definitions
- store compressed pickle databases
Script taken from https://github.com/davidusb-geek/emhass/blob/master/scripts/save_pvlib_module_inverter_database.py
"""
data_path = Path(__file__).parent.parent / "data"
logger.info("Reading original outdated database bundled with PVLib")
cec_modules_old = retrieve_sam("CECMod")
cec_inverters_old = retrieve_sam("cecinverter")
# Download from https://github.com/NatLabRockies/SAM/tree/develop/samples/CEC%20Module%20and%20Inverter%20Libraries/CEC%20Modules
logger.info("Reading downloaded modules database from SAM")
cec_modules = retrieve_sam(path=str(data_path / "cec_modules.csv"))
cec_modules = cec_modules.loc[:, ~cec_modules.columns.duplicated()]
# DOwnload from https://github.com/NatLabRockies/SAM/tree/develop/samples/CEC%20Module%20and%20Inverter%20Libraries/CEC%20Inverters
logger.info("Reading downloaded inverters database from SAM")
cec_inverters = retrieve_sam(path=str(data_path / "cec_inverters.csv"))
cec_inverters = cec_inverters.loc[:, ~cec_inverters.columns.duplicated()]
# Download from https://github.com/davidusb-geek/emhass/tree/master/src/emhass/data
logger.info("Reading custom EMHASS database")
cec_modules_emhass = retrieve_sam(path=str(data_path / "emhass_modules.csv"))
cec_inverters_emhass = retrieve_sam(path=str(data_path / "emhass_inverters.csv"))
#
# Modules
#
cols_to_keep = [col for col in cec_modules_old.columns if col not in cec_modules.columns]
cec_modules = pd.concat(
[
cec_modules,
cec_modules_old[cols_to_keep],
],
axis=1,
)
logger.info(f"Copied {len(cols_to_keep)} old PVLib module entries")
cols_to_keep = [col for col in cec_modules_emhass.columns if col not in cec_modules.columns]
cec_modules = pd.concat(
[
cec_modules,
cec_modules_emhass[cols_to_keep],
],
axis=1,
)
logger.info(f"Copied {len(cols_to_keep)} custom EMHASS module entries")
#
# Inverters
#
cols_to_keep = [col for col in cec_inverters_old.columns if col not in cec_inverters.columns]
cec_inverters = pd.concat(
[
cec_inverters,
cec_inverters_old[cols_to_keep],
],
axis=1,
)
logger.info(f"Copied {len(cols_to_keep)} old PVLib inverter entries")
cols_to_keep = [col for col in cec_inverters_emhass.columns if col not in cec_inverters.columns]
cec_inverters = pd.concat(
[
cec_inverters,
cec_inverters_emhass[cols_to_keep],
],
axis=1,
)
logger.info(f"Copied {len(cols_to_keep)} custom EMHASS inverter entries")
#
# Save databases
#
with bz2.BZ2File(_cec_modules_path(), "wb") as file:
pickle.dump(cec_modules, file)
with bz2.BZ2File(_cec_inverters_path(), "wb") as file:
pickle.dump(cec_inverters, file)
logger.info(f"CEC databases written: {_cec_modules_path()}, {_cec_inverters_path()}")
def _load_cec_database(path: Path) -> pd.DataFrame:
"""Load a CEC database, reloading only if the file changed.
If database does not exists it is created.
"""
if not path.exists():
# Create databases
_update_cec_database()
mtime = path.stat().st_mtime_ns
cached = _cec_cache.get(path)
if cached is not None:
cached_mtime, database = cached
if cached_mtime == mtime:
return database
with bz2.BZ2File(path, "rb") as f:
database = pickle.load(f) # noqa: S301
_cec_cache[path] = (mtime, database)
return database
def _cec_modules() -> pd.DataFrame:
"""Provide CEC modules database."""
return _load_cec_database(_cec_modules_path())
def _cec_inverters() -> pd.DataFrame:
"""Provide CEC inverters database."""
return _load_cec_database(_cec_inverters_path())
class PVForecastPVLibCommonSettings(SettingsBaseModel):
"""Common settings for pvforecast data calculation with PVLib."""
# Nothing in here
class PVForecastPVLib(PredictionMixin, PVForecastProvider):
"""Calculate PV forecast data using PVLib."""
_warned_features: ClassVar[set[str]] = set()
@classmethod
def provider_id(cls) -> str:
"""Return the unique identifier for the PVForecastPVLib provider."""
return "PVForecastPVLib"
def _warn_once(self, feature: str, message: str) -> None:
"""Log a warning only once for this process."""
if feature not in self._warned_features:
self._warned_features.add(feature)
logger.warning(message)
def _get_model_power(self, params: pd.Series, device_type: DeviceType) -> Optional[float]:
"""Helper to extract power rating based on device type and available parameters."""
if device_type == "module":
if "STC" in params:
return params["STC"]
if "I_mp_ref" in params and "V_mp_ref" in params:
return params["I_mp_ref"] * params["V_mp_ref"]
elif device_type == "inverter":
if "Paco" in params:
return params["Paco"]
if "Pdco" in params:
return params["Pdco"]
return None
def _find_closest_model(
self, target_power: float, database: pd.DataFrame, device_type: DeviceType
) -> Optional[pd.Series]:
"""Find the model in the database that has a power rating closest to the target_power."""
closest_model = None
min_diff = float("inf")
# Handle DataFrame
for _, params in database.items():
power = self._get_model_power(params, device_type)
if power is not None:
diff = abs(power - target_power)
if diff < min_diff:
min_diff = diff
closest_model = params
if closest_model is not None:
# Safely get name if it exists (DataFrame Series usually have a .name attribute)
model_name = getattr(closest_model, "name", "unknown")
logger.info(f"Closest {device_type} model to {target_power}W found: {model_name}")
else:
logger.warning(f"No suitable {device_type} model found close to {target_power}W")
return closest_model
def _get_model(
self, model_spec: Union[str, int, float], database: pd.DataFrame, device_type: DeviceType
) -> Optional[pd.Series]:
"""Retrieve a model from the database by name or by power rating."""
# If it's a string, try to find it by name
if isinstance(model_spec, str):
if model_spec in database:
return database[model_spec]
# If not found by name, check if it is a number string (e.g., "300")
try:
target_power = float(model_spec)
return self._find_closest_model(target_power, database, device_type)
except ValueError:
# Not a number, fallback to original behavior (will likely raise KeyError later)
logger.warning(f"{device_type} model '{model_spec}' not found in database.")
return database[model_spec]
# If it's a number (int or float), find closest by power
elif isinstance(model_spec, int | float):
return self._find_closest_model(model_spec, database, device_type)
else:
logger.error(f"Invalid type for {device_type} model: {type(model_spec)}")
return None
def _calculate_pvlib_power(self, df_weather: pd.DataFrame) -> pd.DataFrame:
"""Simulate PV power generation using PVLib.
Returns:
Dataframe with pv_dc_power, ac_power
Note:
Taken from emhass
"""
# Validate weather data
required = [
"temp_air",
"ghi",
"dni",
"dhi",
]
missing = df_weather[required].isna().any()
if missing.any():
raise ValueError(f"PV weather contains NaN values: {missing[missing].index.tolist()}")
df_weather[required] = df_weather[required].astype(float)
# Setting the main parameters of the PV plant
location = Location(
latitude=self.config.general.latitude, longitude=self.config.general.longitude
)
temp_params = TEMPERATURE_MODEL_PARAMETERS["sapm"]["close_mount_glass_glass"]
def run_single_config(plane_idx: int) -> pd.DataFrame:
"""Inner helper to run a single simulation configuration.
Returns:
Dataframe with times, weather, solar_position, airmass, total_irrad, aoi,
aoi_modifier, spectral_modifier, and effective_irradiance, cell_temperature,
dc, ac, losses, diode_params (if dc_model is a single diode model).
"""
plane = self.config.pvforecast.planes[plane_idx]
# Check configuration
# - warnings
if plane.userhorizon is not None:
self._warn_once(
"userhorizon",
f"userhorizon is currently not supported by the {self.provider_id()} provider.",
)
if plane.optimalangles:
self._warn_once(
"optimalangles",
f"optimalangles is currently not supported by the {self.provider_id()} provider.",
)
if plane.loss not in (None, 0):
self._warn_once(
"loss",
f"loss is currently not supported by the {self.provider_id()} provider.",
)
if plane.trackingtype not in (None, 0):
self._warn_once(
"trackingtype",
f"trackingtype is currently not supported by the {self.provider_id()} provider.",
)
if plane.inverter_paco is not None:
self._warn_once(
"inverter_paco",
f"inverter_paco is currently not supported by the {self.provider_id()} provider.",
)
# - mandatory parameters
if plane.surface_tilt is None:
raise ValueError(f"Plane {plane_idx}: surface_tilt must be configured.")
if plane.surface_azimuth is None:
raise ValueError(f"Plane {plane_idx}: surface_azimuth must be configured.")
if plane.module_model is None:
raise ValueError(f"Plane {plane_idx}: module_model must be configured.")
module = self._get_model(plane.module_model, _cec_modules(), "module")
if plane.inverter_model is None:
raise ValueError("fPlane {plane_idx}: inverter_model must be configured.")
inverter = self._get_model(plane.inverter_model, _cec_inverters(), "inverter")
if plane.modules_per_string is None:
raise ValueError(f"Plane {plane_idx}: modules_per_string must be configured.")
if plane.strings_per_inverter is None:
raise ValueError(f"Plane {plane_idx}: strings_per_inverter must be configured.")
mountingplace = plane.mountingplace.lower()
if mountingplace == "building":
temp_params = TEMPERATURE_MODEL_PARAMETERS["sapm"]["close_mount_glass_glass"]
elif mountingplace == "free" or mountingplace is None:
temp_params = TEMPERATURE_MODEL_PARAMETERS["sapm"]["open_rack_glass_glass"]
else:
raise ValueError(
f"Plane {plane_idx}: mountingplace '{plane.mountingplace}' invalid."
)
if plane.albedo is None:
df_weather["albedo"] = 0.2 # Take a default
self._warn_once(
f"Plane {plane_idx}: albedo",
f"Plane {plane_idx}: albedo set to 0.2 (was None).",
)
else:
df_weather["albedo"] = plane.albedo
system = PVSystem(
surface_tilt=plane.surface_tilt,
surface_azimuth=plane.surface_azimuth,
module_parameters=module,
inverter_parameters=inverter,
temperature_model_parameters=temp_params,
modules_per_string=plane.modules_per_string,
strings_per_inverter=plane.strings_per_inverter,
)
mc = ModelChain(system, location, aoi_model="physical")
# For testing split out parameter preparation
# mc.prepare_inputs(df_weather)
# print("surface_tilt:", plane.surface_tilt, type(plane.surface_tilt))
# print("surface_azimuth:", plane.surface_azimuth, type(plane.surface_azimuth))
# print("albedo:", plane.albedo, type(plane.albedo))
# print("weather irradiance")
# print(df_weather[["ghi", "dni", "dhi"]])
# print("after prepare_inputs")
# print("solar position")
# print(mc.results.solar_position[["zenith", "azimuth"]])
# print(mc.results.total_irrad)
# print(mc.results.aoi)
# print(mc.results.effective_irradiance)
mc.run_model(df_weather)
return mc.results
df_pvforecast = pd.DataFrame(
{
"pv_dc_power": 0.0,
"ac_power": 0.0,
},
index=df_weather.index,
)
for plane_idx in range(len(self.config.pvforecast.planes)):
result = run_single_config(plane_idx)
df_pvforecast["pv_dc_power"] += result.dc["p_mp"].fillna(0.0)
df_pvforecast["ac_power"] += result.ac.fillna(0.0)
# replace any negative PV values with zero
df_pvforecast["pv_dc_power"] = df_pvforecast["pv_dc_power"].clip(lower=0.0)
df_pvforecast["ac_power"] = df_pvforecast["ac_power"].clip(lower=0.0)
return df_pvforecast
@staticmethod
def compute_solar_angles(df: pd.DataFrame, latitude: float, longitude: float) -> pd.DataFrame:
"""Compute solar angles (elevation, azimuth) based on timestamps and location.
:param df: DataFrame with a DateTime index.
:param latitude: Latitude of the PV system.
:param longitude: Longitude of the PV system.
:return: DataFrame with added solar elevation and azimuth.
"""
df = df.copy()
solpos = get_solarposition(df.index, latitude, longitude)
df["solar_elevation"] = solpos["elevation"]
df["solar_azimuth"] = solpos["azimuth"]
return df
@staticmethod
def add_cyclic_hour_features(df: pd.DataFrame) -> pd.DataFrame:
"""Encode the time of day as a continuous sin/cos pair.
A raw integer hour feature is piecewise constant: with sub-hourly
optimization time steps a (linear) regression model then produces a
discontinuity at every hour boundary, which shows up as a sawtooth in
the adjusted PV forecast. The cyclic encoding is computed from the
fractional hour (hour + minute/60) so it evolves smoothly within the
hour and stays continuous across midnight.
:param df: DataFrame with a DateTime index.
:type df: pd.DataFrame
:return: DataFrame with added hour_sin and hour_cos columns.
:rtype: pd.DataFrame
"""
if not isinstance(df.index, pd.DatetimeIndex):
raise ValueError("DataFrame must have a DatetimeIndex to compute cyclic hour features.")
df = df.copy()
fractional_hour = df.index.hour + df.index.minute / 60.0 # type: ignore[attr-defined]
df["hour_sin"] = np.sin(2 * np.pi * fractional_hour / 24.0)
df["hour_cos"] = np.cos(2 * np.pi * fractional_hour / 24.0)
return df
async def _update_data(self, force_update: Optional[bool] = False) -> None:
# Both _sequence_lock and _record_lock are already held by the caller.
# Use internal sync methods only — never await public async counterparts.
# Assure we have something to request PV power for.
if not self.config.pvforecast.planes:
# No planes for PV
error_msg = "Requested PV forecast, but no planes configured."
logger.error(f"Configuration error: {error_msg}")
raise ValueError(error_msg)
start_datetime = self.ems_start_datetime.start_of("day")
end_datetime = self.ems_start_datetime.add(hours=self.config.prediction.hours)
# Prepare weather data for the PV forecast calculation
#
# We need a dataframe with the following columns:
# - "temp_air" temperature_2m
# - "relative_humidity" relative_humidity_2m
# - "precipitable_water" precipitable_water (cm)
# - "cloud_cover" cloud_cover
# - "wind_speed" wind_speed_10m
# - "ghi" shortwave_radiation_instant
# - "dhi" diffuse_radiation_instant
# - "dni" direct_normal_irradiance_instant
#
# Data shall be given in 15-minutes intervals
keys = [
"weather_temp_air",
"weather_relative_humidity",
"weather_preciptable_water",
"weather_total_clouds",
"weather_wind_speed",
"weather_ghi",
"weather_dhi",
"weather_dni",
]
df_weather = await self.prediction.keys_to_dataframe(
keys=keys,
start_datetime=start_datetime,
end_datetime=end_datetime,
interval=to_duration("15 minutes"),
fill_method="linear",
resample_method="mean",
dropna=True,
boundary="context",
align_to_interval=True,
)
df_weather = df_weather.rename(
columns={
"weather_temp_air": "temp_air",
"weather_relative_humidity": "relative_humidity",
"weather_preciptable_water": "precipitable_water",
"weather_total_clouds": "cloud_cover",
"weather_wind_speed": "wind_speed",
"weather_ghi": "ghi",
"weather_dhi": "dhi",
"weather_dni": "dni",
}
)
# Calculate th PV forecast
df_pvforecast = self._calculate_pvlib_power(df_weather)
for row in df_pvforecast.itertuples():
await self._update_value(row.Index, "pvforecast_dc_power", float(row.pv_dc_power)) # type: ignore
await self._update_value(row.Index, "pvforecast_ac_power", float(row.ac_power)) # type: ignore
+123
View File
@@ -0,0 +1,123 @@
"""Shared helpers for list- and map-of-sub-model configuration cards.
Used by both ``itemscard.py`` (``list[PydanticSubModel]`` fields) and
``mapcard.py`` (``dict[str, PydanticSubModel]`` fields) to avoid duplicating
the Pydantic-introspection and required-field-collection logic between the
two card types. Free of imports from ``configuration.py``, ``itemscard.py``,
or ``mapcard.py`` to avoid circular dependencies.
"""
from typing import Any, cast
from monsterui.franken import Div, Input, P
from pydantic import BaseModel
from pydantic_core import PydanticUndefined
def resolve_model_cls(item_model: Any) -> type[BaseModel]:
"""Resolve a Pydantic model class from either a class or an instance.
Args:
item_model: A Pydantic model class or an instance of one.
Returns:
The model class, cast for mypy's benefit (``isinstance(x, type)``
alone narrows to plain ``type``, not ``type[BaseModel]``).
"""
if isinstance(item_model, type):
return cast(type[BaseModel], item_model)
return cast(type[BaseModel], type(item_model))
def item_model_defaults(item_model: Any) -> tuple[dict, list[str]]:
"""Build defaults for a Pydantic sub-model and report required-but-unset fields.
Constructs a model instance using only fields that have defaults (either
``default`` or ``default_factory``), then serialises via
``model_dump(mode="json")`` to produce a fully JSON-safe dict. Fields
without any default are reported separately rather than silently
omitted a freshly-constructed instance missing them would fail the
model's own validation (e.g. ``consumption_wh``/``duration_h`` on
``HomeApplianceCommonSettings``), so callers must collect values for
them before persisting a new item or entry.
Args:
item_model: A Pydantic model class or instance whose ``model_fields``
will be inspected.
Returns:
A tuple of ``(defaults, required_missing)`` where ``defaults``
contains every field that has a ``default`` or ``default_factory``,
JSON-safe and ready to serialise, and ``required_missing`` lists the
field names that have neither.
"""
model_cls = resolve_model_cls(item_model)
kwargs: dict[str, Any] = {}
required_missing: list[str] = []
for field_name, field_info in model_cls.model_fields.items():
if field_info.default is not PydanticUndefined:
kwargs[field_name] = field_info.default
elif field_info.default_factory is not None:
kwargs[field_name] = field_info.default_factory()
else:
required_missing.append(field_name)
instance = model_cls.model_construct(**kwargs)
defaults = instance.model_dump(mode="json", exclude_unset=True)
return defaults, required_missing
def required_field_inputs(
item_model: Any,
required_missing: list[str],
id_prefix: str,
) -> tuple[list[Div], list[str]]:
"""Build labelled inputs for a sub-model's required-but-undefaulted fields.
Produces one ``Div``-wrapped ``Input`` per field in ``required_missing``,
each carrying HTML ``required`` so the browser blocks form submission
until every field has a value, plus the corresponding JS expressions for
reading those inputs back out at submit time.
Args:
item_model: The Pydantic model class or instance the fields belong
to, used to pick ``type="number"`` vs ``type="text"``.
required_missing: Field names with no default, as returned by
``item_model_defaults``.
id_prefix: A CSS/DOM-safe prefix (e.g. derived from the config name)
used to build unique element ids for each input.
Returns:
A tuple of ``(inputs, js_pairs)`` where ``inputs`` is the list of
rendered ``Div`` components to place in the form, and ``js_pairs``
is a list of ``'"field_name": <js expression>'`` strings suitable
for splicing into a JS object literal that reads the DOM values.
"""
model_cls = resolve_model_cls(item_model)
inputs: list[Div] = []
js_pairs: list[str] = []
for field_name in required_missing:
field_id = f"{id_prefix}-{field_name}".replace(".", "-").replace("_", "-")
annotation = model_cls.model_fields[field_name].annotation
is_numeric = annotation in (int, float)
inputs.append(
Div(
P(field_name, cls="text-xs text-muted-foreground"),
Input(
id=field_id,
type="number" if is_numeric else "text",
required=True,
placeholder=field_name,
),
)
)
value_expr = (
f'Number(document.getElementById("{field_id}").value)'
if is_numeric
else f'document.getElementById("{field_id}").value'
)
js_pairs.append(f'"{field_name}": {value_expr}')
return inputs, js_pairs
+262 -76
View File
@@ -22,6 +22,7 @@ from monsterui.franken import ( # Select: Does not work - using Select from Fas
Form,
Grid,
Input,
Kbd,
Option,
P,
Pre,
@@ -31,6 +32,27 @@ from monsterui.franken import ( # Select: Does not work - using Select from Fas
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.markdown import Markdown
# ---------------------------------------------------------------------------
# HTMX CONTEXT
# ---------------------------------------------------------------------------
# All HTMX requests MUST include these elements to preserve UI state.
#
# Currently includes:
# - #config-search → keeps search/filter state across interactions
#
# If you add more global UI state (e.g. filters, toggles), include them here.
# ---------------------------------------------------------------------------
HTMX_STATE_ELEMENTS = [
"#config-search", # search/filter state
# "#config-filter", # future: filter dropdown
# "#config-scope", # future: scope selector
]
HTMX_INCLUDE = ", ".join(HTMX_STATE_ELEMENTS)
scrollbar_viewport_styles = (
"scrollbar-width: none; -ms-overflow-style: none; -webkit-overflow-scrolling: touch;"
@@ -224,6 +246,7 @@ def make_config_update_form() -> Callable[[str, str], Grid]:
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_include=HTMX_INCLUDE,
),
),
id=f"{config_id}-update-form",
@@ -262,6 +285,7 @@ def make_config_update_value_form(
.querySelector("[name='{config_id}_selected_value']")
.value
}}""",
hx_include=HTMX_INCLUDE,
),
Select(
Option("Select a value...", value="", selected=True, disabled=True),
@@ -336,6 +360,7 @@ def make_config_update_list_form(available_values: list[str]) -> Callable[[str,
])].filter(v => v !== "")
)
}}""",
hx_include=HTMX_INCLUDE,
),
Select(
Option("Select a value...", value="", selected=True, disabled=True),
@@ -365,6 +390,7 @@ def make_config_update_list_form(available_values: list[str]) -> Callable[[str,
])].filter(v => v !== document.querySelector("[name='{config_id}_selected_delete_value']").value.trim())
)
}}""",
hx_include=HTMX_INCLUDE,
),
Select(
Option("Select a value...", value="", selected=True, disabled=True),
@@ -427,6 +453,7 @@ def make_config_update_map_form(
)
)
}}""",
hx_include=HTMX_INCLUDE,
),
(
Select(
@@ -478,6 +505,7 @@ def make_config_update_map_form(
)
)
}}""",
hx_include=HTMX_INCLUDE,
),
Select(
Option("Select key...", value="", selected=True, disabled=True),
@@ -500,11 +528,57 @@ def make_config_update_time_windows_windows_form(
) -> Callable[[str, str], Grid]:
"""Factory for a form that edits the windows field of a TimeWindowSequence.
Renders one collapsible row per existing window with inline edit inputs
pre-filled with the current values, a two-click delete control (trash icon
arms on first click, red confirm button on second), and an "Add window"
section at the bottom for appending new entries.
Args:
value_description: If given, a numeric value field is included in the form
and shown in the column header (e.g. "electricity_price_kwh [Amt/kWh]").
If None, no value field is rendered.
value_description: If given, a numeric value field is included in
both the edit rows and the add section, labelled with this string
(e.g. ``"electricity_price_kwh [Amt/kWh]"``). When ``None`` no
value field is rendered.
Returns:
A factory ``(config_name: str, value: str) -> Grid``.
"""
DOW_LABELS = [
"0 Monday",
"1 Tuesday",
"2 Wednesday",
"3 Thursday",
"4 Friday",
"5 Saturday",
"6 Sunday",
]
def _dow_select(name: str, current: Optional[int]) -> Select:
"""Render a day-of-week dropdown pre-selected to *current*."""
return Select(
Option("— any day —", value="", selected=(current is None)),
*[
Option(lbl, value=str(i), selected=(current == i))
for i, lbl in enumerate(DOW_LABELS)
],
name=name,
cls="border rounded px-2 py-1 text-sm",
)
def _window_summary(win: dict) -> str:
"""One-line human-readable label for an existing window."""
parts = [win.get("start_time", ""), win.get("duration", "")]
if value_description is not None:
parts.append(str(win.get("value", "")))
dow = win.get("day_of_week")
if dow is not None:
parts.append(f"dow={dow}")
date_val = win.get("date")
if date_val:
parts.append(f"date={date_val}")
locale_val = win.get("locale")
if locale_val:
parts.append(f"locale={locale_val}")
return " | ".join(p for p in parts if p)
def ConfigUpdateTimeWindowsWindowsForm(config_name: str, value: str) -> Grid:
config_id = config_name.lower().replace(".", "-")
@@ -515,125 +589,209 @@ def make_config_update_time_windows_windows_form(
except (json.JSONDecodeError, AttributeError):
current_windows = []
DOW_LABELS = [
"0 Monday",
"1 Tuesday",
"2 Wednesday",
"3 Thursday",
"4 Friday",
"5 Saturday",
"6 Sunday",
]
num_cols = 5 + (1 if value_description is not None else 0)
# ---- Existing windows rows ----
# ----------------------------------------------------------------
# Existing window rows — each is a collapsible <details> with
# pre-filled edit inputs and a two-click delete control.
# ----------------------------------------------------------------
window_rows = []
for idx, win in enumerate(current_windows):
start_time = win.get("start_time", "")
duration = win.get("duration", "")
dow = win.get("day_of_week")
date_val = win.get("date")
locale_val = win.get("locale")
dow_str = f" dow={dow}" if dow is not None else ""
date_str = f" date={date_val}" if date_val else ""
locale_str = f" locale={locale_val}" if locale_val else ""
if value_description is not None:
val = win.get("value", "")
val_str = f" | {val} {value_description}"
else:
val_str = ""
label = f"{start_time} | {duration}{val_str}{dow_str}{date_str}{locale_str}"
wid = f"{config_id}_w{idx}"
remaining = [w for i, w in enumerate(current_windows) if i != idx]
remaining_json = json.dumps(json.dumps(remaining))
window_rows.append(
DivHStacked(
rem_json = json.dumps(json.dumps(remaining))
# --- Two-click delete ---
delete_ctrl = Details(
Summary(
UkIcon(
"trash-2", cls="text-muted-foreground hover:text-destructive cursor-pointer"
),
cls="list-none",
),
Div(
ConfigButton(
UkIcon("trash-2"),
" Confirm delete",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {remaining_json} }}',
cls="px-2 py-1",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {rem_json} }}',
hx_include=HTMX_INCLUDE,
cls="px-3 py-1 text-sm bg-destructive text-destructive-foreground hover:bg-destructive/90",
),
cls="absolute z-10 mt-1 p-2 rounded-md border bg-background shadow-md",
),
cls="relative",
)
# --- Save-edit JS: build updated list with this window replaced ---
before_json = json.dumps(current_windows[:idx])
after_json = json.dumps(current_windows[idx + 1 :])
val_js_read = (
f"const val = parseFloat(document.querySelector(\"[name='{wid}_value']\").value);"
if value_description is not None
else ""
)
val_js_guard = "isNaN(val)" if value_description is not None else "false"
val_js_field = "value: val," if value_description is not None else ""
save_button = ConfigButton(
UkIcon("save"),
" Save",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f"""js:{{
action: "update",
key: "{config_name}",
value: (() => {{
const start = document.querySelector("[name='{wid}_start_time']").value.trim();
const dur = document.querySelector("[name='{wid}_duration']").value.trim();
{val_js_read}
const dowRaw = document.querySelector("[name='{wid}_dow']").value;
const date = document.querySelector("[name='{wid}_date']").value.trim();
const locale = document.querySelector("[name='{wid}_locale']").value.trim();
if (!start || !dur || {val_js_guard}) return {json.dumps(json.dumps(current_windows))};
const edited = {{
start_time: start,
duration: dur,
{val_js_field}
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
date: date !== "" ? date : null,
locale: locale !== "" ? locale : null,
}};
const updated = [...{before_json}, edited, ...{after_json}];
return JSON.stringify(updated);
}})()
}}""",
hx_include=HTMX_INCLUDE,
)
# --- Edit inputs pre-filled from current window ---
edit_cols = [
Input(
value=win.get("start_time", ""),
name=f"{wid}_start_time",
placeholder="e.g. 08:00",
cls="border rounded px-2 py-1 text-sm",
),
Input(
value=win.get("duration", ""),
name=f"{wid}_duration",
placeholder="e.g. 8 hours",
cls="border rounded px-2 py-1 text-sm",
),
]
if value_description is not None:
edit_cols.append(
Input(
value=str(win.get("value", "")),
name=f"{wid}_value",
type="number",
step="0.001",
cls="border rounded px-2 py-1 text-sm",
)
)
edit_cols += [
_dow_select(f"{wid}_dow", win.get("day_of_week")),
Input(
value=win.get("date") or "",
name=f"{wid}_date",
placeholder="YYYY-MM-DD",
cls="border rounded px-2 py-1 text-sm",
),
Input(
value=win.get("locale") or "",
name=f"{wid}_locale",
placeholder="e.g. de",
cls="border rounded px-2 py-1 text-sm",
),
]
window_rows.append(
Details(
Summary(
DivHStacked(
delete_ctrl,
P(_window_summary(win), cls="ml-2 text-sm font-mono cursor-pointer"),
),
cls="list-none",
),
Grid(
Grid(*edit_cols, cols=num_cols, cls="gap-2 mt-2"),
save_button,
cols=1,
cls="gap-2 mt-1 p-2 border rounded-md bg-muted/30",
),
P(label, cls="ml-2 text-sm font-mono"),
)
)
# ---- Column headers and inputs ----
num_cols = 5 + (1 if value_description is not None else 0)
# ----------------------------------------------------------------
# Add new window section
# ----------------------------------------------------------------
add_wid = f"{config_id}_new"
header_cols = [
P("start_time *", cls="text-xs text-muted-foreground font-semibold"),
P("duration *", cls="text-xs text-muted-foreground font-semibold"),
]
input_cols = [
add_input_cols = [
Input(
placeholder="e.g. 08:00 Europe/Berlin",
name=f"{config_id}_tw_start_time",
name=f"{add_wid}_start_time",
cls="border rounded px-2 py-1 text-sm",
),
Input(
placeholder="e.g. 8 hours",
name=f"{config_id}_tw_duration",
name=f"{add_wid}_duration",
cls="border rounded px-2 py-1 text-sm",
),
]
if value_description is not None:
header_cols.append(
P(f"{value_description} *", cls="text-xs text-muted-foreground font-semibold")
)
input_cols.append(
add_input_cols.append(
Input(
placeholder="e.g. 0.288",
name=f"{config_id}_tw_value",
name=f"{add_wid}_value",
type="number",
step="0.001",
cls="border rounded px-2 py-1 text-sm",
)
)
header_cols += [
P("day_of_week", cls="text-xs text-muted-foreground font-semibold"),
P("date (YYYY-MM-DD)", cls="text-xs text-muted-foreground font-semibold"),
P("locale", cls="text-xs text-muted-foreground font-semibold"),
]
input_cols += [
Select(
Option("— any day —", value="", selected=True),
*[Option(lbl, value=str(i)) for i, lbl in enumerate(DOW_LABELS)],
name=f"{config_id}_tw_dow",
cls="border rounded px-2 py-1 text-sm",
),
add_input_cols += [
_dow_select(f"{add_wid}_dow", None),
Input(
placeholder="e.g. 2025-12-24",
name=f"{config_id}_tw_date",
name=f"{add_wid}_date",
cls="border rounded px-2 py-1 text-sm",
),
Input(
placeholder="e.g. de",
name=f"{config_id}_tw_locale",
name=f"{add_wid}_locale",
cls="border rounded px-2 py-1 text-sm",
),
]
# ---- JS for Add button ----
current_json = json.dumps(json.dumps(current_windows))
if value_description is not None:
val_js_read = f"const val = parseFloat(document.querySelector(\"[name='{config_id}_tw_value']\").value);"
val_js_guard = "isNaN(val)"
val_js_field = "value: val,"
else:
val_js_read = ""
val_js_guard = "false"
val_js_field = ""
add_val_js_read = (
f"const val = parseFloat(document.querySelector(\"[name='{add_wid}_value']\").value);"
if value_description is not None
else ""
)
add_val_js_guard = "isNaN(val)" if value_description is not None else "false"
add_val_js_field = "value: val," if value_description is not None else ""
add_section = Grid(
Grid(*header_cols, cols=num_cols),
Grid(*input_cols, cols=num_cols),
Grid(*add_input_cols, cols=num_cols),
ConfigButton(
UkIcon("plus"),
" Add window",
@@ -644,17 +802,17 @@ def make_config_update_time_windows_windows_form(
action: "update",
key: "{config_name}",
value: (() => {{
const start = document.querySelector("[name='{config_id}_tw_start_time']").value.trim();
const dur = document.querySelector("[name='{config_id}_tw_duration']").value.trim();
{val_js_read}
const dowRaw = document.querySelector("[name='{config_id}_tw_dow']").value;
const date = document.querySelector("[name='{config_id}_tw_date']").value.trim();
const locale = document.querySelector("[name='{config_id}_tw_locale']").value.trim();
if (!start || !dur || {val_js_guard}) return {current_json};
const start = document.querySelector("[name='{add_wid}_start_time']").value.trim();
const dur = document.querySelector("[name='{add_wid}_duration']").value.trim();
{add_val_js_read}
const dowRaw = document.querySelector("[name='{add_wid}_dow']").value;
const date = document.querySelector("[name='{add_wid}_date']").value.trim();
const locale = document.querySelector("[name='{add_wid}_locale']").value.trim();
if (!start || !dur || {add_val_js_guard}) return {current_json};
const newWin = {{
start_time: start,
duration: dur,
{val_js_field}
duration: dur,
{add_val_js_field}
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
date: date !== "" ? date : null,
locale: locale !== "" ? locale : null,
@@ -664,6 +822,7 @@ def make_config_update_time_windows_windows_form(
return JSON.stringify(existing);
}})()
}}""",
hx_include=HTMX_INCLUDE,
),
cols=1,
cls="gap-2 mt-2",
@@ -675,7 +834,7 @@ def make_config_update_time_windows_windows_form(
*window_rows,
P("Add new window", cls="text-sm font-semibold mt-3 mb-1"),
P(
"* required | day_of_week: overridden by date if both set",
"* required | day_of_week overridden by date if both set",
cls="text-xs text-muted-foreground mb-1",
),
add_section,
@@ -696,6 +855,7 @@ def ConfigCard(
default: str,
description: str,
deprecated: Optional[Union[str, bool]],
scope: Optional[list[str]],
update_error: Optional[str],
update_value: Optional[str],
update_open: Optional[bool],
@@ -757,7 +917,14 @@ def ConfigCard(
cls="list-none",
),
Grid(
TextView(description),
Div(
DivHStacked(
*[Kbd(s) for s in scope],
)
if scope
else None,
Markdown(description),
),
P(config_type),
)
if not deprecated
@@ -795,6 +962,23 @@ def ConfigCard(
)
def ConfigSection(title: str, *content: Any, open: bool = False) -> Details:
"""Collapsible section for grouping configuration entries."""
return Details(
Summary(
Div(
UkIcon("chevron-right", cls="transition-transform group-open:rotate-90"),
H3(title, cls="ml-2"),
cls="flex items-center gap-2 cursor-pointer",
),
cls="list-none",
),
Div(*content, cls="space-y-3 mt-3"),
open=open,
cls="group border rounded-lg p-2",
)
def DashboardHeader(title: Optional[str]) -> Div:
"""Creates a styled header with a title.
@@ -826,6 +1010,7 @@ def DashboardFooter(*c: Any, path: str) -> Card:
hx_trigger="every 5s",
hx_target="#footer-content",
hx_swap="innerHTML",
hx_include=HTMX_INCLUDE,
)
@@ -866,6 +1051,7 @@ def DashboardTabs(dashboard_items: dict[str, str]) -> Card:
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='js:{ "dark": window.matchMedia("(prefers-color-scheme: dark)").matches }',
hx_include=HTMX_INCLUDE,
),
)
for menu, path in dashboard_items.items()
+172 -304
View File
@@ -1,3 +1,4 @@
import enum
import json
from collections.abc import Sequence
from typing import Any, Dict, List, Optional, TypeVar, Union
@@ -5,39 +6,30 @@ from typing import Any, Dict, List, Optional, TypeVar, Union
import requests
from loguru import logger
from monsterui.franken import (
H3,
H4,
Card,
CardTitle,
Details,
Div,
DividerLine,
DivLAligned,
DivRAligned,
Form,
Grid,
Input,
LabelCheckboxX,
P,
Summary,
UkIcon,
)
from pydantic.fields import ComputedFieldInfo, FieldInfo
from pydantic_core import PydanticUndefined
from akkudoktoreos.config.config import ConfigEOS
from akkudoktoreos.core.pydantic import PydanticBaseModel
from akkudoktoreos.prediction.pvforecast import PVForecastPlaneSetting
from akkudoktoreos.server.dash.components import (
HTMX_INCLUDE,
ConfigCard,
JsonView,
TextView,
make_config_update_list_form,
make_config_update_map_form,
make_config_update_time_windows_windows_form,
make_config_update_value_form,
ConfigSection,
Input,
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.itemscard import ConfigItemsCard
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
from akkudoktoreos.server.dash.uihints import (
UI_HINTS,
resolve_form_factory,
)
T = TypeVar("T")
@@ -156,23 +148,77 @@ def get_deprecated(
return getattr(subfield_info, "deprecated", None)
def get_scope(
extra: Dict[str, Any],
) -> Optional[list[str]]:
"""Fetch x-scope.
Returns the value of json_schema_extra["x-scope"] as a list of strings, or None if not set.
"""
scope = extra.get("x-scope")
if scope is None:
return None
if isinstance(scope, list):
return [str(s) for s in scope]
return [str(scope)]
def get_default_value(field_info: Union[FieldInfo, ComputedFieldInfo], regular_field: bool) -> Any:
"""Retrieve the default value of a field.
"""Retrieve the default value of a field as a JSON-safe Python object.
Handles both ``default`` and ``default_factory`` fields, and converts the
resulting value to a JSON-safe representation before returning. This
covers all non-primitive default types encountered in the EOS config
models: Pydantic model instances, lists of models, enums, ``Path``
objects, and anything else that plain ``json.dumps`` would reject.
For computed fields or fields with no default of any kind, a sentinel
string is returned instead.
Args:
field_info (Union[FieldInfo, ComputedFieldInfo]): The field metadata from Pydantic.
regular_field (bool): Indicates if the field is a regular field.
field_info: The field metadata from Pydantic.
regular_field: ``True`` for a ``FieldInfo`` (regular field),
``False`` for a ``ComputedFieldInfo``.
Returns:
Any: The default value of the field or "N/A" if not a regular field.
A JSON-safe Python object (dict, list, str, int, float, bool, or
``None``) representing the field default, or ``"N/A"`` when no
meaningful default exists.
"""
default_value = ""
if regular_field:
if (val := field_info.default) is not PydanticUndefined:
default_value = val
import pathlib
if not regular_field:
return "N/A"
# Resolve the raw default — prefer plain default, fall back to factory
if field_info.default is not PydanticUndefined:
val = field_info.default
elif field_info.default_factory is not None:
try:
val = field_info.default_factory()
except Exception:
return ""
else:
default_value = "N/A"
return default_value
return ""
def _to_json_safe(v: Any) -> Any:
"""Recursively convert a value to a JSON-safe type."""
if v is None or isinstance(v, (bool, int, float, str)):
return v
if isinstance(v, PydanticBaseModel):
return v.model_dump(mode="json")
if isinstance(v, enum.Enum):
return v.value
if isinstance(v, pathlib.PurePath):
return str(v)
if isinstance(v, dict):
return {str(k): _to_json_safe(w) for k, w in v.items()}
if isinstance(v, (list, tuple, set, frozenset)):
return [_to_json_safe(item) for item in v]
# Last resort: str() — at minimum json.dumps won't crash
return str(v)
return _to_json_safe(val)
def resolve_nested_types(field_type: Any, parent_types: list[str]) -> list[tuple[Any, list[str]]]:
@@ -252,6 +298,7 @@ def create_config_details(
config["default"] = json.dumps(get_default_value(subfield_info, regular_field))
config["description"] = get_description(subfield_info, extra)
config["deprecated"] = get_deprecated(subfield_info, extra)
config["scope"] = get_scope(extra)
if isinstance(subfield_info, ComputedFieldInfo):
config["read-only"] = "ro"
type_description = str(subfield_info.return_type)
@@ -307,193 +354,15 @@ def get_config(eos_host: str, eos_port: Union[str, int]) -> dict[str, Any]:
return config
def ConfigPlanesCard(
config_name: str,
config_type: str,
read_only: str,
value: str,
default: str,
description: str,
max_planes: int,
update_error: Optional[str],
update_value: Optional[str],
update_open: Optional[bool],
) -> Card:
"""Creates a styled configuration card for PV planes.
def config_matches_search(config: dict, search: str) -> bool:
if not search:
return True
This function generates a configuration card that is displayed in the UI with
various sections such as configuration name, type, description, default value,
current value, and error details. It supports both read-only and editable modes.
Args:
config_name (str): The name of the PV planes configuration.
config_type (str): The type of the PV planes configuration.
read_only (str): Indicates if the PV planes configuration is read-only ("rw" for read-write,
any other value indicates read-only).
value (str): The current value of the PV planes configuration.
default (str): The default value of the PV planes configuration.
description (str): A description of the PV planes configuration.
max_planes (int): Maximum number of planes that can be set
update_error (Optional[str]): The error message, if any, during the update process.
update_value (Optional[str]): The value to be updated, if different from the current value.
update_open (Optional[bool]): A flag indicating whether the update section of the card
should be initially expanded.
Returns:
Card: A styled Card component containing the PV planes configuration details.
"""
config_id = config_name.replace(".", "-")
# Remember overall planes update status
planes_update_error = update_error
planes_update_value = update_value
if not planes_update_value:
planes_update_value = value
planes_update_open = update_open
if not planes_update_open:
planes_update_open = False
# Create EOS planes configuration
eos_planes = json.loads(value)
eos_planes_config = {
"pvforecast": {
"planes": eos_planes,
},
}
# Create cards for all planes
rows = []
for i in range(0, max_planes):
plane_config = create_config_details(
PVForecastPlaneSetting(),
eos_planes_config,
values_prefix=["pvforecast", "planes", str(i)],
)
plane_rows = []
plane_update_open = False
if eos_planes and len(eos_planes) > i:
plane_value = json.dumps(eos_planes[i])
else:
plane_value = json.dumps(None)
for config_key in sorted(plane_config.keys()):
config = plane_config[config_key]
update_error = config_update_latest.get(config["name"], {}).get("error") # type: ignore
update_value = config_update_latest.get(config["name"], {}).get("value") # type: ignore
update_open = config_update_latest.get(config["name"], {}).get("open") # type: ignore
update_form_factory = None
if update_open:
planes_update_open = True
plane_update_open = True
# Make mypy happy - should never trigger
if (
not isinstance(update_error, (str, type(None)))
or not isinstance(update_value, (str, type(None)))
or not isinstance(update_open, (bool, type(None)))
):
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
if config["name"].endswith("pvtechchoice"):
update_form_factory = make_config_update_value_form(
["crystSi", "CIS", "CdTe", "Unknown"]
)
elif config["name"].endswith("mountingplace"):
update_form_factory = make_config_update_value_form(["free", "building"])
plane_rows.append(
ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
update_error,
update_value,
update_open,
update_form_factory,
)
)
rows.append(
Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
H4(f"pvforecast.planes.{i}"),
),
DivRAligned(
P(read_only),
),
),
JsonView(json.loads(plane_value)),
),
cls="list-none",
),
*plane_rows,
cls="space-y-4 gap-4",
open=plane_update_open,
),
cls="w-full",
)
)
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
),
DivRAligned(
P(read_only),
),
),
JsonView(json.loads(value)),
),
cls="list-none",
),
Grid(
TextView(description),
P(config_type),
),
# Default
Grid(
DivRAligned(P("default")),
P(default),
)
if read_only == "rw"
else None,
# Set value
Grid(
DivRAligned(P("update")),
Grid(
Form(
Input(value="update", type="hidden", id="action"),
Input(value=config_name, type="hidden", id="key"),
Input(value=planes_update_value, type="text", id="value"),
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
),
),
)
if read_only == "rw"
else None,
# Last error
Grid(
DivRAligned(P("update error")),
TextView(planes_update_error),
)
if planes_update_error
else None,
# Now come the single element configs
*rows,
cls="space-y-4 gap-4",
open=planes_update_open,
),
cls="w-full",
return (
search in config["name"].lower()
or search in config["description"].lower()
or search in config["type"].lower()
or search in config["value"].lower()
)
@@ -569,6 +438,24 @@ def Configuration(
# Process configuration data
config_details = create_config_details(ConfigEOS, config)
# Configuration search
search_value = (data.get("search", "") if data else "").strip().lower()
SearchBar = Card(
Input(
placeholder="Search configuration… (name, description, type)",
name="search",
value=search_value,
hx_get=request_url_for("/eosdash/configuration"),
hx_push_url="true",
hx_trigger="keyup changed delay:250ms",
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals='js:{ "dark": window.matchMedia("(prefers-color-scheme: dark)").matches }',
cls="w-full border rounded px-3 py-2",
)
)
ConfigMenu = Card(
# CheckboxGroup to toggle config data visibility
Grid(
@@ -588,6 +475,7 @@ def Configuration(
+ '", '
+ '"dark": window.matchMedia("(prefers-color-scheme: dark)").matches '
+ "}",
hx_include=HTMX_INCLUDE,
# lbl_cls=f"text-{solution_color[renderer]}",
)
for renderer in list(config_visible.keys())
@@ -597,8 +485,6 @@ def Configuration(
header=CardTitle("Choose What's Shown"),
)
rows = []
last_category = ""
# find some special configuration values
try:
max_planes = int(config_details["pvforecast.max_planes"]["value"])
@@ -640,16 +526,16 @@ def Configuration(
logger.debug(f"devices_measurement_keys {devices_measurement_keys}")
# build visual representation
sections: dict[str, list[Any]] = {}
for config_key in sorted(config_details.keys()):
config = config_details[config_key]
category = config["name"].split(".")[0]
if category != last_category:
rows.append(H3(category))
rows.append(DividerLine())
last_category = category
update_error = config_update_latest.get(config["name"], {}).get("error")
update_value = config_update_latest.get(config["name"], {}).get("value")
update_open = config_update_latest.get(config["name"], {}).get("open")
# Make mypy happy - should never trigger
if (
not isinstance(update_error, (str, type(None)))
@@ -659,97 +545,79 @@ def Configuration(
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
if (
# Do not display read only values
not config_visible["config-visible-read-only"]["visible"]
and config["read-only"] != "rw"
):
# Do not display read only values
continue
if (
config["type"]
== "Optional[list[akkudoktoreos.prediction.pvforecast.PVForecastPlaneSetting]]"
and not config["deprecated"]
):
# Special configuration for PV planes
rows.append(
ConfigPlanesCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
max_planes,
update_error,
update_value,
update_open,
)
if not config_matches_search(config, search_value):
# Search value given but does not match
continue
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items" and not config["deprecated"]:
card = ConfigItemsCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
elif hint and hint.form == "map_items" and not config["deprecated"]:
card = ConfigMapCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
elif not config["deprecated"]:
update_form_factory = None
if config["name"].endswith(".provider"):
# Special configuration for prediction provider setting
try:
provider_ids = json.loads(config_details[config["name"] + "s"]["value"])
except Exception:
provider_ids = []
if config["type"].startswith("Optional[list"):
update_form_factory = make_config_update_list_form(provider_ids)
else:
provider_ids.append("None")
update_form_factory = make_config_update_value_form(provider_ids)
elif config["name"].startswith("adapter.homeassistant.config_entity_ids"):
# Home Assistant adapter config entities
update_form_factory = make_config_update_map_form(None, homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.load_emr_entity_ids"):
# Home Assistant adapter load energy meter readings entities
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.grid_export_emr_entity_ids"):
# Home Assistant adapter grid export energy meter readings entities
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.grid_import_emr_entity_ids"):
# Home Assistant adapter grid import energy meter readings entities
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.pv_production_emr_entity_ids"):
# Home Assistant adapter pv energy meter readings entities
update_form_factory = make_config_update_list_form(homeassistant_entity_ids)
elif config["name"].startswith("adapter.homeassistant.device_measurement_entity_ids"):
# Home Assistant adapter device measurement entities
update_form_factory = make_config_update_map_form(
devices_measurement_keys, homeassistant_entity_ids
)
elif config["name"].startswith("adapter.homeassistant.device_instruction_entity_ids"):
# Home Assistant adapter device instruction entities
update_form_factory = make_config_update_list_form(
eos_device_instruction_entity_ids
)
elif config["name"].startswith("adapter.homeassistant.solution_entity_ids"):
# Home Assistant adapter optimization solution entities
update_form_factory = make_config_update_list_form(eos_solution_entity_ids)
elif config["name"].startswith("ems.mode"):
# Energy management mode
update_form_factory = make_config_update_value_form(
["OPTIMIZATION", "PREDICTION", "DISABLED"]
)
elif config["name"].endswith("elecpricefixed.time_windows.windows"):
update_form_factory = make_config_update_time_windows_windows_form(
value_description="electricity_price_kwh [Amt/kWh]"
)
update_form_factory = resolve_form_factory(hint, config_details) if hint else None
card = ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
config["scope"],
update_error,
update_value,
update_open,
update_form_factory,
)
else:
continue
rows.append(
ConfigCard(
config["name"],
config["type"],
config["read-only"],
config["value"],
config["default"],
config["description"],
config["deprecated"],
update_error,
update_value,
update_open,
update_form_factory,
)
sections.setdefault(category, []).append(card)
section_components = []
for category in sorted(sections.keys()):
cards = sections[category]
# Open if searching OR if last update was here
open_section = bool(search_value)
if not open_section:
open_section = any(
config_update_latest.get(c["name"], {}).get("open")
for c in config_details.values()
if c["name"].startswith(category)
)
return Div(ConfigMenu, *rows, cls="space-y-3")
section_components.append(ConfigSection(category, *cards, open=open_section))
return Div(
Grid(
ConfigMenu,
SearchBar,
),
*section_components,
cls="space-y-4",
)
+555
View File
@@ -0,0 +1,555 @@
"""Generic expandable list-of-sub-model configuration card for EOSdash.
This module provides `ConfigItemsCard`, a reusable FastHTML/MonsterUI
card component that renders any ``list[PydanticSubModel]`` config field as a
collapsible outer card containing one collapsible inner card per list item.
It is intentionally free of imports from ``configuration.py`` to avoid
circular dependencies. The one runtime dependency on
``create_config_details`` is injected by the caller.
Typical usage in ``configuration.py``::
from akkudoktoreos.server.dash.itemscard import ConfigItemsCard
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items" and not config["deprecated"]:
rows.append(
ConfigItemsCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
)
"""
import json
from typing import Any, Callable, Optional
from loguru import logger
from monsterui.franken import (
H4,
Card,
Details,
Div,
DivHStacked,
DivLAligned,
DivRAligned,
Form,
Grid,
Input,
Kbd,
P,
Summary,
UkIcon,
)
from akkudoktoreos.server.dash.carditems import (
item_model_defaults,
required_field_inputs,
)
from akkudoktoreos.server.dash.components import (
ConfigButton,
ConfigCard,
JsonView,
UpdateError,
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.markdown import Markdown
from akkudoktoreos.server.dash.uihints import (
UiHint,
hint_for_indexed_field,
resolve_form_factory,
resolve_item_model,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _add_control(
config_name: str,
item_model: Any,
items_list: list,
read_only: str,
) -> Any:
"""Build the 'Add item' control for the outer card header.
When the item model can be fully defaulted, this is a one-click button
that appends immediately. When required fields have no default (e.g.
`consumption_wh`/`duration_h` on `HomeApplianceCommonSettings`), clicking
would otherwise submit an invalid item, so instead this renders a small
inline form that collects those values first and only builds the PUT
payload once every required input is non-empty (enforced via HTML
`required`).
"""
if read_only != "rw":
return None
new_item_defaults, required_missing = item_model_defaults(item_model)
if not required_missing:
appended_json = json.dumps(json.dumps(items_list + [new_item_defaults]))
return ConfigButton(
UkIcon("plus"),
" Add item",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {appended_json} }}',
cls="ml-4 px-3 py-1 text-sm",
)
id_prefix = f"new-item-{config_name}"
inputs, js_pairs = required_field_inputs(item_model, required_missing, id_prefix)
defaults_json = json.dumps(new_item_defaults)
items_json = json.dumps(items_list)
build_value_expr = (
"(function(){"
f"var base = {defaults_json};"
f"var extra = {{ {', '.join(js_pairs)} }};"
f"var items = {items_json};"
"return JSON.stringify(items.concat([Object.assign({}, base, extra)]));"
"})()"
)
return Details(
Summary(
UkIcon("plus"),
" Add item",
cls="list-none cursor-pointer inline-flex items-center gap-1 ml-4",
),
Form(
*inputs,
ConfigButton(
"Create",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {build_value_expr} }}',
cls="mt-2 px-3 py-1 text-sm",
),
cls="absolute z-10 mt-1 p-3 rounded-md border bg-background shadow-md space-y-2",
),
cls="relative",
)
def _delete_control(config_name: str, items_list: list, index: int) -> Details:
"""Build the two-click delete control for a single inner item card header.
The first click opens a ``<details>`` panel revealing a red "Confirm
delete" button. Clicking outside collapses it. The second click
(on the confirm button) submits an ``hx_put`` with the list minus the
given index.
Args:
config_name: Dotted config key name, e.g. ``"pvforecast.planes"``.
items_list: The current full list of item dicts.
index: The zero-based index of the item to delete.
Returns:
A ``Details`` component implementing the two-click confirm pattern.
"""
remaining_json = json.dumps(json.dumps([w for j, w in enumerate(items_list) if j != index]))
return Details(
Summary(
UkIcon("trash-2", cls="text-muted-foreground hover:text-destructive cursor-pointer"),
cls="list-none",
),
Div(
ConfigButton(
UkIcon("trash-2"),
" Confirm delete",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {remaining_json} }}',
cls="px-3 py-1 text-sm bg-destructive text-destructive-foreground hover:bg-destructive/90",
),
cls="absolute z-10 mt-1 p-2 rounded-md border bg-background shadow-md",
),
cls="relative",
)
def _inner_card(
config_name: str,
item_path: str,
path_parts: list[str],
index: int,
item_value: str,
is_empty: bool,
read_only: str,
item_rows: list,
item_update_open: bool,
delete_control: Optional[Details],
) -> Card:
"""Render a single collapsible inner card for one list item.
Args:
config_name: Dotted config key of the parent list field.
item_path: Dotted path prefix for this item type, e.g.
``"pvforecast.planes"``.
path_parts: ``item_path`` split on ``"."``.
index: Zero-based position of this item in the list.
item_value: JSON-encoded current value of this item.
is_empty: ``True`` when the item dict is falsy (empty or ``None``).
read_only: ``"rw"`` or ``"ro"`` inherited from the parent field.
item_rows: Pre-built list of ``ConfigCard`` children for this item.
item_update_open: Whether this card should start expanded.
delete_control: The two-click delete ``Details`` widget, or ``None``
for read-only fields.
Returns:
A ``Card`` component for this item slot.
"""
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
H4(
f"{item_path}.{index}",
cls="text-muted-foreground" if is_empty else "",
),
delete_control,
),
DivRAligned(
P(
"empty" if is_empty else read_only,
cls="text-xs text-muted-foreground" if is_empty else "",
),
),
),
JsonView(json.loads(item_value)),
),
cls="list-none",
),
*item_rows,
cls="space-y-4 gap-4",
open=item_update_open,
),
cls=f"w-full {'opacity-60' if is_empty else ''}",
)
def _outer_card(
config_name: str,
config_type: str,
read_only: str,
value: str,
default: str,
description: str,
scope: Optional[list[str]],
num_items: int,
add_button: Any,
items_update_value: str,
items_update_error: Optional[str],
items_update_open: bool,
rows: list,
) -> Card:
"""Render the outer collapsible card for the whole list field.
Args:
config_name: Dotted config key name.
config_type: Human-readable type string from config details.
read_only: ``"rw"`` or ``"ro"``.
value: JSON-encoded current list value.
default: JSON-encoded default value.
description: Field description text.
num_items: Current number of items, shown as a badge.
add_button: The "Add item" control from ``_add_control`` a
``ConfigButton`` when the item model is fully defaulted, a
``Details``/``Form`` combo when required fields must be
collected first, or ``None`` for read-only fields.
items_update_value: Value to pre-fill the fallback text input.
items_update_error: Error string from the last failed update, or
``None``.
items_update_open: Whether the outer card starts expanded.
rows: Pre-built list of inner ``Card`` components.
Returns:
The outer ``Card`` component.
"""
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
P(
f"{num_items} item{'s' if num_items != 1 else ''}",
cls="ml-2 text-xs text-muted-foreground",
),
add_button,
),
DivRAligned(P(read_only)),
),
JsonView(json.loads(value)),
),
cls="list-none",
),
Grid(
Div(
DivHStacked(*[Kbd(s) for s in scope]) if scope else None,
Markdown(description),
),
P(config_type),
),
# Default value row
Grid(
DivRAligned(P("default")),
P(default),
)
if read_only == "rw"
else None,
# Raw JSON fallback update form
Grid(
DivRAligned(P("update")),
Grid(
Form(
Input(value="update", type="hidden", id="action"),
Input(value=config_name, type="hidden", id="key"),
Input(value=items_update_value, type="text", id="value"),
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
),
),
)
if read_only == "rw"
else None,
# Last update error
Grid(
DivRAligned(P("update error")),
UpdateError(items_update_error),
)
if items_update_error
else None,
# Per-item inner cards
*rows,
cls="space-y-4 gap-4",
open=items_update_open,
),
cls="w-full",
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def ConfigItemsCard(
config: dict,
hint: UiHint,
config_details: dict[str, dict],
config_update_latest: dict[str, dict],
create_config_details: Callable,
) -> Card:
"""Creates a styled configuration card for a list of Pydantic sub-model items.
Renders a collapsible outer card representing the list field as a whole,
containing one collapsible inner card per item in the list. Each inner
card expands into individual ``ConfigCard`` rows for every field of the
item's Pydantic sub-model.
The list length is driven entirely by user interaction there is no fixed
maximum.
An "Add item" control in the outer card header creates a new item
pre-filled with the sub-model's Pydantic field defaults. When every
field has a default, this is a one-click button that appends and PUTs
immediately. When the sub-model has fields with no default (e.g.
``consumption_wh``/``duration_h`` on ``HomeApplianceCommonSettings``),
the control instead expands into a small inline form that collects
those required values first the PUT is only built, via HTML
``required`` inputs, once every missing field is filled in, so an
invalid item is never persisted. Each inner card header carries a
trash icon that arms on first click (showing a red "Confirm delete"
button via a ``<details>`` toggle) and deletes on the second click,
with no modal required.
Per-item field forms are resolved via ``hint_for_indexed_field`` using the
parent hint's ``item_path``, so per-field UI customisation (dropdowns,
selects, etc.) is driven entirely by ``UI_HINTS`` entries no hard-coded
field-name checks are needed here.
The outer card always includes a plain-text fallback update form for the
whole list value so that recovery from a validation error is always
possible.
Args:
config: A single entry from the ``config_details`` dict produced by
``create_config_details()``. Must contain the keys ``"name"``,
``"type"``, ``"read-only"``, ``"value"``, ``"default"``,
``"description"``, ``"deprecated"``, and ``"scope"``.
hint: The ``UiHint`` for this field. Must have ``form == "items"``
and valid ``item_model`` (resolved via ``resolve_item_model``) and
``item_path`` values. ``max_items_from`` is ignored the list
grows and shrinks freely via Add / Delete.
config_details: The full config detail dict for the current page
render, used to look up per-item field update state.
config_update_latest: The module-level dict that tracks the most
recent update attempt for each config key, with sub-keys
``"error"``, ``"value"``, and ``"open"``.
create_config_details: The ``create_config_details`` callable from
``configuration.py``, injected to avoid a circular import.
Signature: ``(model, values, values_prefix) -> dict[str, dict]``.
Returns:
Card: A fully rendered outer ``Card`` component containing the list
summary with item count and an "Add item" control (a one-click
button, or an inline required-fields form see above), description,
default value row, a raw-JSON fallback update form, an optional
error row, and one collapsible inner ``Card`` per existing item each
with a two-click delete control.
Raises:
TypeError: If ``update_error``, ``update_value``, or ``update_open``
retrieved from ``config_update_latest`` are not of the expected
types (``str | None``, ``str | None``, ``bool | None``
respectively). This should never trigger in normal operation but
is checked explicitly to satisfy static analysis.
Example:
Typical call from inside the ``Configuration()`` render loop::
from akkudoktoreos.server.dash.itemscard import ConfigItemsCard
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items" and not config["deprecated"]:
rows.append(
ConfigItemsCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
)
"""
config_name = config["name"]
config_type = config["type"]
read_only = config["read-only"]
value = config["value"]
default = config["default"]
description = config["description"]
item_model = resolve_item_model(hint)
item_path = hint.item_path # e.g. "pvforecast.planes"
if item_path is None:
raise ValueError(f"Hint needs item_path to be listed. Got {hint}")
path_parts = item_path.split(".") # e.g. ["pvforecast", "planes"]
items_list = json.loads(value) or []
num_items = len(items_list)
# Synthetic wrapper dict so create_config_details can traverse the value:
# e.g. {"pvforecast": {"planes": [...]}}
wrapped = json.loads(value)
for key in reversed(path_parts):
wrapped = {key: wrapped}
# Outer card update state — resolved once before the inner loop
items_update_error = config_update_latest.get(config_name, {}).get("error")
items_update_value = config_update_latest.get(config_name, {}).get("value") or value
items_update_open = config_update_latest.get(config_name, {}).get("open") or False
# Add button.
# One-click append when the item model is fully defaulted, otherwise an inline form that
# collects required fields before the PUT fires (see _add_control / _item_model_defaults
# docstrings).
add_button = _add_control(config_name, item_model, items_list, read_only)
# Build inner cards
rows = []
for i in range(num_items):
item_config = create_config_details(
item_model,
wrapped,
values_prefix=path_parts + [str(i)],
)
item_rows = []
item_update_open = False
item_value = json.dumps(items_list[i]) if items_list[i] is not None else json.dumps(None)
is_empty = not items_list[i]
for field_key in sorted(item_config.keys()):
sub = item_config[field_key]
update_error = config_update_latest.get(sub["name"], {}).get("error")
update_value = config_update_latest.get(sub["name"], {}).get("value")
update_open = config_update_latest.get(sub["name"], {}).get("open")
if update_open:
items_update_open = True # bubble up to outer card
item_update_open = True
# Make mypy happy — should never trigger
if (
not isinstance(update_error, (str, type(None)))
or not isinstance(update_value, (str, type(None)))
or not isinstance(update_open, (bool, type(None)))
):
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
sub_hint = hint_for_indexed_field(sub["name"], item_path)
update_form_factory = (
resolve_form_factory(sub_hint, config_details) if sub_hint else None
)
item_rows.append(
ConfigCard(
sub["name"],
sub["type"],
sub["read-only"],
sub["value"],
sub["default"],
sub["description"],
sub["deprecated"],
sub["scope"],
update_error,
update_value,
update_open,
update_form_factory,
)
)
rows.append(
_inner_card(
config_name=config_name,
item_path=item_path,
path_parts=path_parts,
index=i,
item_value=item_value,
is_empty=is_empty,
read_only=read_only,
item_rows=item_rows,
item_update_open=item_update_open,
delete_control=_delete_control(config_name, items_list, i)
if read_only == "rw"
else None,
)
)
return _outer_card(
config_name=config_name,
config_type=config_type,
read_only=read_only,
value=value,
default=default,
description=description,
scope=config.get("scope"),
num_items=num_items,
add_button=add_button,
items_update_value=items_update_value,
items_update_error=items_update_error,
items_update_open=items_update_open,
rows=rows,
)
+570
View File
@@ -0,0 +1,570 @@
"""Generic expandable map-of-sub-model configuration card for EOSdash.
This module provides `ConfigMapCard`, a reusable FastHTML/MonsterUI
card component that renders any ``dict[str, PydanticSubModel]`` config field
as a collapsible outer card containing one collapsible inner card per map
entry, keyed by a user-supplied string name.
It is intentionally free of imports from ``configuration.py`` to avoid
circular dependencies. The one runtime dependency on
``create_config_details`` is injected by the caller.
The structure mirrors ``itemscard.py`` with these key differences:
- The stored value is ``dict[str, dict]`` rather than ``list[dict]``.
- The "Add entry" control includes a text input for the key name.
- Delete removes by key rather than by index.
- Inner card headers display the string key instead of a numeric index.
- ``create_config_details`` is called with the string key as the final
``values_prefix`` segment.
Typical usage in ``configuration.py``::
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "map_items" and not config["deprecated"]:
rows.append(
ConfigMapCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
)
"""
import json
from typing import Any, Callable, Optional
from loguru import logger
from monsterui.franken import (
H4,
Card,
Details,
Div,
DivHStacked,
DivLAligned,
DivRAligned,
Form,
Grid,
Input,
Kbd,
P,
Summary,
UkIcon,
)
from akkudoktoreos.server.dash.carditems import (
item_model_defaults,
required_field_inputs,
)
from akkudoktoreos.server.dash.components import (
ConfigButton,
ConfigCard,
JsonView,
UpdateError,
)
from akkudoktoreos.server.dash.context import request_url_for
from akkudoktoreos.server.dash.markdown import Markdown
from akkudoktoreos.server.dash.uihints import (
UiHint,
hint_for_indexed_field,
resolve_form_factory,
resolve_item_model,
)
# ---------------------------------------------------------------------------
# Internal helpers
# ---------------------------------------------------------------------------
def _add_control(
config_name: str,
items_map: dict,
item_model: Any,
config_id: str,
read_only: str,
) -> Any:
"""Build the "Add entry" control.
Always includes a text input for the new key name. When the item model
can be fully defaulted, that's the only input needed — clicking "Add
entry" reads the key and submits the map with the new key set to the
model defaults. When required fields have no default (e.g.
`consumption_wh`/`duration_h` on `HomeApplianceCommonSettings`), the
control also renders inputs for those fields, all wrapped in a form so
HTML `required` blocks submission until the key and every required
field are filled in otherwise clicking "Add entry" would persist an
entry that fails the model's own validation.
If the typed key already exists the existing entry is overwritten this
is intentional and allows renaming-by-copy when combined with delete.
Args:
config_name: Dotted config key name.
items_map: The current map, used as the base for the JS merge.
item_model: The Pydantic model class or instance for one map entry.
config_id: CSS-safe version of ``config_name`` (dots replaced with
hyphens), used to scope element ids and the key input's name.
Returns:
A ``Form`` containing the key input, any required-field inputs, and
the "Add entry" button.
"""
new_entry_defaults, required_missing = item_model_defaults(item_model)
id_prefix = f"{config_id}-new-entry"
inputs, js_pairs = required_field_inputs(item_model, required_missing, id_prefix)
extra_js = f"{{ {', '.join(js_pairs)} }}" if js_pairs else "{}"
current_json = json.dumps(items_map)
defaults_json = json.dumps(new_entry_defaults)
build_value_expr = f"""(() => {{
const k = document.querySelector("[name='{config_id}_new_key']").value.trim();
if (!k) return {json.dumps(json.dumps(items_map))};
const defaults = {defaults_json};
const extra = {extra_js};
Object.assign(defaults, extra);
if ('device_id' in defaults) defaults.device_id = k;
const updated = Object.assign({{}}, {current_json}, {{ [k]: defaults }});
return JSON.stringify(updated);
}})()"""
return Form(
Grid(
Input(
placeholder="Entry name / key",
name=f"{config_id}_new_key",
id=f"{config_id}-new-key",
required=True,
cls="border rounded px-3 py-2 text-sm",
),
*inputs,
cols=2,
cls="gap-2",
),
ConfigButton(
UkIcon("plus"),
" Add entry",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {build_value_expr} }}',
cls="mt-2",
),
cls="space-y-2 mt-3",
)
def _delete_control(config_name: str, items_map: dict, key: str) -> Details:
"""Build the two-click delete control for a single inner entry card header.
The first click opens a ``<details>`` panel revealing a red "Confirm
delete" button. Clicking outside collapses it. The second click submits
an ``hx_put`` with the map minus the given key.
Args:
config_name: Dotted config key name, e.g. ``"devices.batteries"``.
items_map: The current full map of entries.
key: The string key of the entry to delete.
Returns:
A ``Details`` component implementing the two-click confirm pattern.
"""
remaining = {k: v for k, v in items_map.items() if k != key}
remaining_json = json.dumps(json.dumps(remaining))
return Details(
Summary(
UkIcon("trash-2", cls="text-muted-foreground hover:text-destructive cursor-pointer"),
cls="list-none",
),
Div(
ConfigButton(
UkIcon("trash-2"),
" Confirm delete",
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
hx_vals=f'js:{{ action: "update", key: "{config_name}", value: {remaining_json} }}',
cls="px-3 py-1 text-sm bg-destructive text-destructive-foreground hover:bg-destructive/90",
),
cls="absolute z-10 mt-1 p-2 rounded-md border bg-background shadow-md",
),
cls="relative",
)
def _inner_card(
config_name: str,
item_path: str,
key: str,
item_value: str,
is_empty: bool,
read_only: str,
item_rows: list,
item_update_open: bool,
delete_control: Optional[Details],
) -> Card:
"""Render a single collapsible inner card for one map entry.
Args:
config_name: Dotted config key of the parent map field.
item_path: Dotted path prefix for this entry type, e.g.
``"devices.batteries"``.
key: The string key identifying this entry in the map.
item_value: JSON-encoded current value of this entry.
is_empty: ``True`` when the entry dict is falsy (empty or ``None``).
read_only: ``"rw"`` or ``"ro"`` inherited from the parent field.
item_rows: Pre-built list of ``ConfigCard`` children for this entry.
item_update_open: Whether this card should start expanded.
delete_control: The two-click delete ``Details`` widget, or ``None``
for read-only fields.
Returns:
A ``Card`` component for this map entry.
"""
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
H4(
f"{item_path}.{key}",
cls="text-muted-foreground" if is_empty else "",
),
delete_control,
),
DivRAligned(
P(
"empty" if is_empty else read_only,
cls="text-xs text-muted-foreground" if is_empty else "",
),
),
),
JsonView(json.loads(item_value)),
),
cls="list-none",
),
*item_rows,
cls="space-y-4 gap-4",
open=item_update_open,
),
cls=f"w-full {'opacity-60' if is_empty else ''}",
)
def _outer_card(
config_name: str,
config_type: str,
read_only: str,
value: str,
default: str,
description: str,
scope: Optional[list[str]],
num_entries: int,
items_update_value: str,
items_update_error: Optional[str],
items_update_open: bool,
rows: list,
add_control: Optional[Grid],
) -> Card:
"""Render the outer collapsible card for the whole map field.
Args:
config_name: Dotted config key name.
config_type: Human-readable type string from config details.
read_only: ``"rw"`` or ``"ro"``.
value: JSON-encoded current map value.
default: JSON-encoded default value.
description: Field description text.
num_entries: Current number of entries, shown as a badge.
items_update_value: Value to pre-fill the fallback text input.
items_update_error: Error string from the last failed update, or
``None``.
items_update_open: Whether the outer card starts expanded.
rows: Pre-built list of inner ``Card`` components.
add_control: The "Add entry" ``Grid`` widget, or ``None`` for
read-only fields.
Returns:
The outer ``Card`` component.
"""
return Card(
Details(
Summary(
Grid(
Grid(
DivLAligned(
UkIcon(icon="play"),
P(config_name),
P(
f"{num_entries} entr{'ies' if num_entries != 1 else 'y'}",
cls="ml-2 text-xs text-muted-foreground",
),
),
DivRAligned(P(read_only)),
),
JsonView(json.loads(value)),
),
cls="list-none",
),
# Add entry control below summary
add_control,
Grid(
Div(
DivHStacked(*[Kbd(s) for s in scope]) if scope else None,
Markdown(description),
),
P(config_type),
),
# Default value row
Grid(
DivRAligned(P("default")),
P(default),
)
if read_only == "rw"
else None,
# Raw JSON fallback update form
Grid(
DivRAligned(P("update")),
Grid(
Form(
Input(value="update", type="hidden", id="action"),
Input(value=config_name, type="hidden", id="key"),
Input(value=items_update_value, type="text", id="value"),
hx_put=request_url_for("/eosdash/configuration"),
hx_target="#page-content",
hx_swap="innerHTML",
),
),
)
if read_only == "rw"
else None,
# Last update error
Grid(
DivRAligned(P("update error")),
UpdateError(items_update_error),
)
if items_update_error
else None,
# Per-entry inner cards
*rows,
cls="space-y-4 gap-4",
open=items_update_open,
),
cls="w-full",
)
# ---------------------------------------------------------------------------
# Public API
# ---------------------------------------------------------------------------
def ConfigMapCard(
config: dict,
hint: UiHint,
config_details: dict[str, dict],
config_update_latest: dict[str, dict],
create_config_details: Callable,
) -> Card:
"""Creates a styled configuration card for a map of Pydantic sub-model entries.
Renders a collapsible outer card representing the map field as a whole,
containing one collapsible inner card per map entry keyed by a string
name. Each inner card expands into individual ``ConfigCard`` rows for
every field of the entry's Pydantic sub-model.
The map contents are driven entirely by user interaction. An "Add entry"
control at the bottom of the outer card accepts a key name and appends a
new entry pre-filled with the sub-model's Pydantic field defaults. Each
inner card header carries a trash icon that arms on first click (showing
a red "Confirm delete" button via a ``<details>`` toggle) and deletes on
the second click, with no modal required.
Per-entry field forms are resolved via ``hint_for_indexed_field`` using
the parent hint's ``item_path``, so per-field UI customisation
(dropdowns, selects, etc.) is driven entirely by ``UI_HINTS`` entries
no hard-coded field-name checks are needed here.
The outer card always includes a plain-text fallback update form for the
whole map value so that recovery from a validation error is always
possible.
Args:
config: A single entry from the ``config_details`` dict produced by
``create_config_details()``. Must contain the keys ``"name"``,
``"type"``, ``"read-only"``, ``"value"``, ``"default"``,
``"description"``, ``"deprecated"``, and ``"scope"``.
hint: The ``UiHint`` for this field. Must have
``form == "map_items"`` and valid ``item_model`` (resolved via
``resolve_item_model``) and ``item_path`` values.
config_details: The full config detail dict for the current page
render, used to look up per-entry field update state.
config_update_latest: The module-level dict that tracks the most
recent update attempt for each config key, with sub-keys
``"error"``, ``"value"``, and ``"open"``.
create_config_details: The ``create_config_details`` callable from
``configuration.py``, injected to avoid a circular import.
Signature: ``(model, values, values_prefix) -> dict[str, dict]``.
Returns:
Card: A fully rendered outer ``Card`` component containing the map
summary with entry count, description, default value row, a raw-JSON
fallback update form, an optional error row, one collapsible inner
``Card`` per existing entry each with a two-click delete control, and
an "Add entry" control at the bottom.
Raises:
TypeError: If ``update_error``, ``update_value``, or ``update_open``
retrieved from ``config_update_latest`` are not of the expected
types (``str | None``, ``str | None``, ``bool | None``
respectively). This should never trigger in normal operation but
is checked explicitly to satisfy static analysis.
Example:
Typical call from inside the ``Configuration()`` render loop::
from akkudoktoreos.server.dash.mapcard import ConfigMapCard
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "map_items" and not config["deprecated"]:
rows.append(
ConfigMapCard(
config=config,
hint=hint,
config_details=config_details,
config_update_latest=config_update_latest,
create_config_details=create_config_details,
)
)
"""
config_name = config["name"]
config_type = config["type"]
read_only = config["read-only"]
value = config["value"]
default = config["default"]
description = config["description"]
config_id = config_name.lower().replace(".", "-")
item_model = resolve_item_model(hint)
item_path = hint.item_path # e.g. "devices.batteries"
if item_path is None:
raise ValueError(f"Hint needs item_path to be mapped. Got {hint}")
path_parts = item_path.split(".") # e.g. ["devices", "batteries"]
items_map = json.loads(value) or {}
num_entries = len(items_map)
# Outer card update state — resolved once before the inner loop
items_update_error = config_update_latest.get(config_name, {}).get("error")
items_update_value = config_update_latest.get(config_name, {}).get("value") or value
items_update_open = config_update_latest.get(config_name, {}).get("open") or False
# Add entry control (key input + button) shown at the bottom of the card.
# One-click append when the item model is fully defaulted, otherwise an inline form that
# collects required fields before the PUT fires (see _add_control / _item_model_defaults
# docstrings).
add_control = _add_control(
config_name=config_name,
items_map=items_map,
item_model=item_model,
config_id=config_id,
read_only=read_only,
)
# Build inner cards — one per map key, sorted for stable ordering
rows = []
for key in sorted(items_map.keys()):
entry = items_map[key]
# Synthetic wrapper: e.g. {"devices": {"batteries": {"bat1": {...}}}}
wrapped = {key: entry}
for part in reversed(path_parts):
wrapped = {part: wrapped}
item_config = create_config_details(
item_model,
wrapped,
values_prefix=path_parts + [key],
)
item_rows = []
item_update_open = False
item_value = json.dumps(entry) if entry is not None else json.dumps(None)
is_empty = not entry
for field_key in sorted(item_config.keys()):
sub = item_config[field_key]
update_error = config_update_latest.get(sub["name"], {}).get("error")
update_value = config_update_latest.get(sub["name"], {}).get("value")
update_open = config_update_latest.get(sub["name"], {}).get("open")
if update_open:
items_update_open = True # bubble up to outer card
item_update_open = True
# Make mypy happy — should never trigger
if (
not isinstance(update_error, (str, type(None)))
or not isinstance(update_value, (str, type(None)))
or not isinstance(update_open, (bool, type(None)))
):
error_msg = "update_error or update_value or update_open of wrong type."
logger.error(error_msg)
raise TypeError(error_msg)
sub_hint = hint_for_indexed_field(sub["name"], item_path)
update_form_factory = (
resolve_form_factory(sub_hint, config_details) if sub_hint else None
)
item_rows.append(
ConfigCard(
sub["name"],
sub["type"],
sub["read-only"],
sub["value"],
sub["default"],
sub["description"],
sub["deprecated"],
sub["scope"],
update_error,
update_value,
update_open,
update_form_factory,
)
)
rows.append(
_inner_card(
config_name=config_name,
item_path=item_path,
key=key,
item_value=item_value,
is_empty=is_empty,
read_only=read_only,
item_rows=item_rows,
item_update_open=item_update_open,
delete_control=_delete_control(config_name, items_map, key)
if read_only == "rw"
else None,
)
)
return _outer_card(
config_name=config_name,
config_type=config_type,
read_only=read_only,
value=value,
default=default,
description=description,
scope=config.get("scope"),
num_entries=num_entries,
items_update_value=items_update_value,
items_update_error=items_update_error,
items_update_open=items_update_open,
rows=rows,
add_control=add_control,
)
+58 -29
View File
@@ -25,6 +25,7 @@ from akkudoktoreos.core.emplan import (
EnergyManagementInstruction,
EnergyManagementPlan,
FRBCInstruction,
OMBCInstruction,
)
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.server.dash.bokeh import Bokeh, bokey_apply_theme_to_plot
@@ -72,7 +73,7 @@ solution_excludes = [
# Current state of solution displayed
solution_visible: dict[str, bool] = {
"pv_energy_wh": True,
"pvforecast_power_w": True,
"elec_price_amt_kwh": True,
"feed_in_tariff_amt_kwh": True,
}
@@ -143,7 +144,7 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
solution_columns = [x for x in solution_columns if x not in instruction_columns]
prediction_df = solution.prediction.to_dataframe()
if prediction_df.empty or len(prediction_df.columns) <= 1:
if prediction_df.empty:
raise ValueError(
f"Prediction DataFrame is empty or missing plottable columns: {list(prediction_df.columns)}"
)
@@ -151,10 +152,12 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
raise ValueError(
f"Prediction DataFrame is missing column 'date_time': {list(prediction_df.columns)}"
)
prediction_columns = list(prediction_df.columns)
prediction_columns_to_join = prediction_df.columns.difference(df.columns)
df = df.join(prediction_df[prediction_columns_to_join], how="inner")
# Only plot if there are actual data columns beyond date_time
prediction_columns = [c for c in prediction_df.columns if c != "date_time"]
# No prediction data to plot — skip prediction section silently
if prediction_columns:
prediction_columns_to_join = prediction_df.columns.difference(df.columns)
df = df.join(prediction_df[prediction_columns_to_join], how="inner")
# Exclude columns that currently do not have a value
excludes = solution_excludes
@@ -185,15 +188,20 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
validate_source(source)
# Calculate minimum and maximum Range
power_w_min = 0.0
power_w_max = 0.0
energy_wh_min = 0.0
energy_wh_max = 0.0
amt_kwh_min = 0.0
amt_kwh_max = 0.0
amt_min = 0.0
amt_max = 0.0
soc_factor_min = 0.0
soc_factor_max = 1.0
factor_min = 0.0
factor_max = 1.0
for col in df.columns:
if col.endswith("power_w"):
power_w_min = min(power_w_min, float(df[col].min()))
power_w_max = max(power_w_max, float(df[col].max()))
if col.endswith("energy_wh"):
energy_wh_min = min(energy_wh_min, float(df[col].min()))
energy_wh_max = max(energy_wh_max, float(df[col].max()))
@@ -207,10 +215,11 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
continue
# Adjust to similar y-axis 0-point
values_min_max = [
(power_w_min, power_w_max),
(energy_wh_min, energy_wh_max),
(amt_kwh_min, amt_kwh_max),
(amt_min, amt_max),
(soc_factor_min, soc_factor_max),
(factor_min, factor_max),
]
# First get the maximum factor for the min value related the maximum value
min_max_factor = 0.0
@@ -221,11 +230,15 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
min_max_factor = value_factor
# Adapt the min values to have the same relative min/max factor on all y-axis
power_w_min = min_max_factor * power_w_max * -1.0
energy_wh_min = min_max_factor * energy_wh_max * -1.0
amt_kwh_min = min_max_factor * amt_kwh_max * -1.0
amt_min = min_max_factor * amt_max * -1.0
soc_factor_min = min_max_factor * soc_factor_max * -1.0
factor_min = min_max_factor * factor_max * -1.0
# add 5% to min and max values for better display
power_w_range_orig = power_w_max - power_w_min
power_w_max += 0.05 * power_w_range_orig
power_w_min -= 0.05 * power_w_range_orig
energy_wh_range_orig = energy_wh_max - energy_wh_min
energy_wh_max += 0.05 * energy_wh_range_orig
energy_wh_min -= 0.05 * energy_wh_range_orig
@@ -235,9 +248,9 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
amt_range_orig = amt_max - amt_min
amt_max += 0.05 * amt_range_orig
amt_min -= 0.05 * amt_range_orig
soc_factor_range_orig = soc_factor_max - soc_factor_min
soc_factor_max += 0.05 * soc_factor_range_orig
soc_factor_min -= 0.05 * soc_factor_range_orig
factor_range_orig = factor_max - factor_min
factor_max += 0.05 * factor_range_orig
factor_min -= 0.05 * factor_range_orig
if eosstatus.eos_health is not None:
last_run_datetime = eosstatus.eos_health["energy-management"]["last_run_datetime"]
@@ -250,27 +263,31 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
title=f"Optimization Solution - last run: {last_run_datetime}",
x_axis_type="datetime",
x_axis_label=f"Datetime [localtime {date_time_tz}] - start: {start_datetime}",
y_axis_label="Energy [Wh]",
y_axis_label="Power [W]",
sizing_mode="stretch_width",
y_range=Range1d(energy_wh_min, energy_wh_max),
y_range=Range1d(power_w_min, power_w_max),
height=400,
)
plot.extra_y_ranges = {
"factor": Range1d(soc_factor_min, soc_factor_max), # y2
"amt_kwh": Range1d(amt_kwh_min, amt_kwh_max), # y3
"amt": Range1d(amt_min, amt_max), # y4
"energy": Range1d(energy_wh_min, energy_wh_max), # y2
"factor": Range1d(factor_min, factor_max), # y3
"amt_kwh": Range1d(amt_kwh_min, amt_kwh_max), # y4
"amt": Range1d(amt_min, amt_max), # y5
}
# y2 axis
y2_axis = LinearAxis(y_range_name="factor", axis_label="Factor [0.0..1.0]")
y2_axis = LinearAxis(y_range_name="energy", axis_label="Energy [Wh]")
plot.add_layout(y2_axis, "left")
# y3 axis
y3_axis = LinearAxis(y_range_name="amt_kwh", axis_label="Electricity Price [amount/kWh]")
y3_axis.axis_label_text_color = "red"
plot.add_layout(y3_axis, "right")
y3_axis = LinearAxis(y_range_name="factor", axis_label="Factor [0.0..1.0]")
plot.add_layout(y3_axis, "left")
# y4 axis
y4_axis = LinearAxis(y_range_name="amt", axis_label="Amount")
y4_axis = LinearAxis(y_range_name="amt_kwh", axis_label="Electricty Price [Amount/kWh]")
y4_axis.axis_label_text_color = "red"
plot.add_layout(y4_axis, "right")
# y5 axis
y5_axis = LinearAxis(y_range_name="amt", axis_label="Amount [Amount]")
plot.add_layout(y5_axis, "right")
plot.toolbar.autohide = True
@@ -309,7 +326,7 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
else:
line_dash = "solid"
if visible:
if col.endswith("energy_wh"):
if col.endswith("power_w"):
r = plot.step(
x="date_time_local",
y=col,
@@ -319,15 +336,16 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
color=color_palette[color],
line_dash=line_dash,
)
elif col.endswith("soc_factor"):
r = plot.line(
elif col.endswith("energy_wh"):
r = plot.step(
x="date_time_local",
y=col,
mode="after",
source=source,
legend_label=col,
color=color_palette[color],
line_dash=line_dash,
y_range_name="factor",
y_range_name="energy",
)
elif col.endswith("factor"):
r = plot.step(
@@ -374,7 +392,9 @@ def SolutionCard(solution: OptimizationSolution, config: SettingsEOS, data: Opti
y_range_name="amt",
)
else:
raise ValueError(f"Unexpected column name: {col}")
# Skip columns with unrecognized suffix rather than raising
logger.warning(f"Skipping column with unrecognized suffix: {col}")
r = None
else:
r = None
@@ -542,9 +562,18 @@ def InstructionCard(
icon = "washing-machine"
else:
icon = "play"
if isinstance(instruction, (DDBCInstruction, FRBCInstruction)):
# Initialize defaults so all code paths are covered
summary = summary or ""
summary_detail = ""
if isinstance(instruction, OMBCInstruction):
summary = f"{instruction.operation_mode_id}"
summary_detail = f"{instruction.operation_mode_factor:.2f}"
elif isinstance(instruction, (DDBCInstruction, FRBCInstruction)):
summary = f"{instruction.operation_mode_id}"
summary_detail = f"{instruction.operation_mode_factor}"
return Card(
Details(
Summary(
+59 -25
View File
@@ -6,12 +6,12 @@ from bokeh.models import ColumnDataSource, LinearAxis, Range1d
from bokeh.plotting import figure
from monsterui.franken import FT, Grid, P
from akkudoktoreos.core.pydantic import PydanticDateTimeDataFrame
from akkudoktoreos.core.pydantic import PydanticDateTimeSeries
from akkudoktoreos.server.dash.bokeh import Bokeh, bokey_apply_theme_to_plot
from akkudoktoreos.server.dash.components import Error
# bar width for 1 hour bars (time given in millseconds)
BAR_WIDTH_1HOUR = 1000 * 60 * 60
# bar width for 15 minutes bars (time given in millseconds)
BAR_WIDTH_15MIN = 1000 * 60 * 15
def PVForecast(predictions: pd.DataFrame, config: dict, date_time_tz: str, dark: bool) -> FT:
@@ -30,7 +30,7 @@ def PVForecast(predictions: pd.DataFrame, config: dict, date_time_tz: str, dark:
x="date_time",
top="pvforecast_ac_power",
source=source,
width=BAR_WIDTH_1HOUR * 0.8,
width=BAR_WIDTH_15MIN * 0.8,
legend_label="AC Power",
color="lightblue",
)
@@ -54,7 +54,7 @@ def ElectricityPriceForecast(
),
title=f"Electricity Price Prediction ({provider})",
x_axis_label=f"Datetime [localtime {date_time_tz}]",
y_axis_label="Price [amount/kWh]",
y_axis_label="Price [Amt./kWh]",
sizing_mode="stretch_width",
height=400,
)
@@ -62,7 +62,7 @@ def ElectricityPriceForecast(
x="date_time",
top="elecprice_marketprice_kwh",
source=source,
width=BAR_WIDTH_1HOUR * 0.8,
width=BAR_WIDTH_15MIN * 0.8,
legend_label="Market Price",
color="lightblue",
)
@@ -208,7 +208,9 @@ def Prediction(eos_host: str, eos_port: Union[str, int], data: Optional[dict] =
if data and data.get("dark", None) == "true":
dark = True
# Get current configuration from server
# ---------------------------------------------------------------------
# Get configuration
# ---------------------------------------------------------------------
try:
result = requests.get(f"{server}/v1/config", timeout=10)
result.raise_for_status()
@@ -220,31 +222,63 @@ def Prediction(eos_host: str, eos_port: Union[str, int], data: Optional[dict] =
)
config = result.json()
# Get Forecasts
# ---------------------------------------------------------------------
# Describe how every prediction should be retrieved.
# ---------------------------------------------------------------------
prediction_requests = [
("pvforecast_ac_power", "first", "ffill"),
("elecprice_marketprice_kwh", "first", "ffill"),
("weather_relative_humidity", "mean", "linear"),
("weather_temp_air", "mean", "linear"),
("weather_ghi", "mean", "linear"),
("weather_dni", "mean", "linear"),
("weather_dhi", "mean", "linear"),
("loadforecast_power_w", "first", "ffill"),
("loadakkudoktor_std_power_w", "first", "ffill"),
("loadakkudoktor_mean_power_w", "first", "ffill"),
]
# ---------------------------------------------------------------------
# Fetch all series
# ---------------------------------------------------------------------
series_list = []
try:
params = {
"keys": [
"pvforecast_ac_power",
"elecprice_marketprice_kwh",
"weather_relative_humidity",
"weather_temp_air",
"weather_ghi",
"weather_dni",
"weather_dhi",
"loadforecast_power_w",
"loadakkudoktor_std_power_w",
"loadakkudoktor_mean_power_w",
],
}
result = requests.get(f"{server}/v1/prediction/dataframe", params=params, timeout=10)
result.raise_for_status()
predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe()
for options in prediction_requests:
key = options[0]
resample_method = options[1]
fill_method = options[2]
params = {
"key": key,
"interval": "15 minutes",
"processing": "resampled",
"resample_method": resample_method,
"fill_method": fill_method,
}
result = requests.get(
f"{server}/v1/prediction/series",
params=params,
timeout=10,
)
result.raise_for_status()
series = PydanticDateTimeSeries(**result.json()).to_series().rename(key)
series_list.append(series)
except requests.exceptions.HTTPError as err:
detail = result.json()["detail"]
return Error(f"Can not retrieve predictions from {server}: {err}, {detail}")
except Exception as err:
return Error(f"Can not retrieve predictions from {server}: {err}")
# ---------------------------------------------------------------------
# Merge into dataframe
# ---------------------------------------------------------------------
predictions = pd.concat(series_list, axis=1).reset_index()
predictions.rename(columns={"index": "date_time"}, inplace=True)
# Remove time offset from UTC to get naive local time and make bokeh plot in local time
date_time_tz = predictions["date_time"].dt.tz
predictions["date_time"] = pd.to_datetime(predictions["date_time"]).dt.tz_localize(None)
+419
View File
@@ -0,0 +1,419 @@
"""UI hint registry for EOSdash configuration forms.
This module decouples UI rendering decisions from both the domain models and the
main ``Configuration()`` render function. Instead of a long if/elif chain that
maps config field paths to form factories, all those decisions live here as
structured ``UiHint`` entries in ``UI_HINTS``.
Typical usage in ``configuration.py``::
from akkudoktoreos.server.dash.uihints import UI_HINTS, resolve_form_factory
hint = UI_HINTS.get(config["name"])
if hint and hint.form == "items":
rows.append(ConfigItemsCard(config, hint, config_details, config_update_latest))
elif not config["deprecated"]:
update_form_factory = resolve_form_factory(hint, config_details) if hint else None
rows.append(ConfigCard(..., update_form_factory))
``ConfigItemsCard`` must live in ``configuration.py`` because it depends on
``create_config_details`` and ``config_update_latest``. This module only
carries the *data* that drives it.
"""
import json
from dataclasses import dataclass, field
from typing import Any, Callable, Literal, Optional
from akkudoktoreos.server.dash.components import (
make_config_update_list_form,
make_config_update_map_form,
make_config_update_time_windows_windows_form,
make_config_update_value_form,
)
# ---------------------------------------------------------------------------
# Form type literals
# ---------------------------------------------------------------------------
UiFormType = Literal[
"text", # plain text input (default)
"select", # single-value dropdown
"select_list", # add/delete multi-value list
"map", # key/value pair editor
"time_windows", # time-window sequence editor
"items", # expandable list of sub-model cards
"map_items", # expandable map of sub-model cards
]
# ---------------------------------------------------------------------------
# UiHint dataclass
# ---------------------------------------------------------------------------
@dataclass
class UiHint:
"""Rendering hints for a single configuration field.
Attributes:
form:
Which form widget to use. Defaults to ``"text"``.
options:
Static allowed values for ``"select"`` / ``"select_list"``.
options_from:
Dotted config-field path whose runtime value provides the
option list (JSON-encoded ``list[str]``). Takes precedence
over ``options`` when both are set.
param_from:
Dotted config-field path for a secondary runtime parameter.
Used by ``"map"`` for the *keys* dropdown.
append_none:
Append ``"None"`` to the resolved option list. Useful for
nullable single-value selects such as ``*.provider`` fields.
value_description:
Label for the extra numeric column in the ``"time_windows"``
form (e.g. ``"electricity_price_kwh [Amt/kWh]"``). When
``None`` no value column is rendered.
item_model:
*``"items"`` only.* The Pydantic model class (or instance)
whose fields define the per-item sub-cards, e.g.
``PVForecastPlaneSetting``. Set via ``_ensure_item_models()``
at first use to avoid circular imports.
item_path:
*``"items"`` only.* Dotted path that locates the list inside
the synthetic config dict built from the field value. Used to
construct the ``values_prefix`` for ``create_config_details``.
Example: planes are wrapped as
``{"pvforecast": {"planes": <value>}}`` so ``item_path`` is
``"pvforecast.planes"``.
max_items_from:
*``"items"`` only.* Dotted config-field path whose integer
value caps the number of rendered sub-cards (e.g.
``"pvforecast.max_planes"``). When ``None`` the length of
the actual list is used instead.
"""
form: UiFormType = "text"
# select / select_list / map
options: list[str] = field(default_factory=list)
options_from: Optional[str] = None
param_from: Optional[str] = None
append_none: bool = False
# time_windows
value_description: Optional[str] = None
# items
item_model: Optional[Any] = None
item_path: Optional[str] = None
max_items_from: Optional[str] = None
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
UI_HINTS: dict[str, UiHint] = {
# ------------------------------------------------------------------
# Adapter - Home Assistant adapter
# ------------------------------------------------------------------
"adapter.homeassistant.config_entity_ids": UiHint(
form="map",
options_from="adapter.homeassistant.homeassistant_entity_ids",
),
"adapter.homeassistant.load_emr_entity_ids": UiHint(
form="select_list",
options_from="adapter.homeassistant.homeassistant_entity_ids",
),
"adapter.homeassistant.grid_export_emr_entity_ids": UiHint(
form="select_list",
options_from="adapter.homeassistant.homeassistant_entity_ids",
),
"adapter.homeassistant.grid_import_emr_entity_ids": UiHint(
form="select_list",
options_from="adapter.homeassistant.homeassistant_entity_ids",
),
"adapter.homeassistant.pv_production_emr_entity_ids": UiHint(
form="select_list",
options_from="adapter.homeassistant.homeassistant_entity_ids",
),
"adapter.homeassistant.device_measurement_entity_ids": UiHint(
form="map",
param_from="devices.measurement_keys",
options_from="adapter.homeassistant.homeassistant_entity_ids",
),
"adapter.homeassistant.device_instruction_entity_ids": UiHint(
form="select_list",
options_from="adapter.homeassistant.eos_device_instruction_entity_ids",
),
"adapter.homeassistant.solution_entity_ids": UiHint(
form="select_list",
options_from="adapter.homeassistant.eos_solution_entity_ids",
),
# ------------------------------------------------------------------
# Devices
# ------------------------------------------------------------------
"devices.batteries": UiHint(
form="items",
item_path="devices.batteries",
),
"devices.electric_vehicles": UiHint(
form="items",
item_path="devices.electric_vehicles",
),
"devices.home_appliances": UiHint(
form="items",
item_path="devices.home_appliances",
),
# Sub-field hint for the time_windows field inside each appliance entry
"devices.home_appliances.cycle_time_windows.windows": UiHint(
form="time_windows",
value_description="cycle index (0-based)",
),
# ------------------------------------------------------------------
# Electricity price — fixed time windows
# ------------------------------------------------------------------
"elecprice.provider": UiHint(
form="select",
options_from="elecprice.providers",
append_none=True,
),
"elecprice.elecpricefixed.time_windows.windows": UiHint(
form="time_windows",
value_description="electricity_price_kwh [Amt/kWh]",
),
# ------------------------------------------------------------------
# EMS
# ------------------------------------------------------------------
"ems.mode": UiHint(
form="select",
options_from="ems.modes",
),
# ------------------------------------------------------------------
# Load
# ------------------------------------------------------------------
"load.provider": UiHint(
form="select",
options_from="load.providers",
append_none=True,
),
# ------------------------------------------------------------------
# Optimization
# ------------------------------------------------------------------
"optimization.algorithm": UiHint(
form="select",
options_from="optimization.algorithms",
),
# ------------------------------------------------------------------
# PV forecast — planes
# item_model is populated lazily by _ensure_item_models() below.
# ------------------------------------------------------------------
"pvforecast.provider": UiHint(
form="select",
options_from="pvforecast.providers",
append_none=True,
),
"pvforecast.planes": UiHint(
form="items",
item_path="pvforecast.planes",
max_items_from="pvforecast.max_planes",
),
# Per-plane sub-fields; resolved by hint_for_indexed_field()
"pvforecast.planes.pvtechchoice": UiHint(
form="select",
options=["crystSi", "CIS", "CdTe", "Unknown"],
),
"pvforecast.planes.mountingplace": UiHint(
form="select",
options=["free", "building"],
),
# ------------------------------------------------------------------
# Weather
# ------------------------------------------------------------------
"weather.providers": UiHint(
form="select_list",
options_from="weather.providers",
),
}
# ---------------------------------------------------------------------------
# Lazy item_model resolution (avoids circular imports at module load time)
# ---------------------------------------------------------------------------
_item_models_resolved = False
def _ensure_item_models() -> None:
"""Populate ``item_model`` on any ``"items"`` hints that need it.
Domain model imports are deferred to this function so that importing
``uihints`` early in the boot sequence does not trigger circular imports.
"""
if UI_HINTS["pvforecast.planes"].item_model is None:
from akkudoktoreos.prediction.pvforecast import ( # noqa: PLC0415
PVForecastPlaneSetting,
)
UI_HINTS["pvforecast.planes"].item_model = PVForecastPlaneSetting
if UI_HINTS["devices.batteries"].item_model is None:
from akkudoktoreos.devices.devices import (
BatteriesCommonSettings,
)
UI_HINTS["devices.batteries"].item_model = BatteriesCommonSettings
if UI_HINTS["devices.electric_vehicles"].item_model is None:
from akkudoktoreos.devices.devices import (
BatteriesCommonSettings,
)
UI_HINTS["devices.electric_vehicles"].item_model = BatteriesCommonSettings
if UI_HINTS["devices.home_appliances"].item_model is None:
from akkudoktoreos.devices.devices import (
HomeApplianceCommonSettings,
)
UI_HINTS["devices.home_appliances"].item_model = HomeApplianceCommonSettings
def resolve_item_model(hint: UiHint) -> Optional[Any]:
"""Return the ``item_model`` for an ``"items"`` hint, resolving lazily.
Args:
hint: A ``UiHint`` with ``form == "items"``.
Returns:
The model class or instance, or ``None`` if unset.
"""
global _item_models_resolved
if not _item_models_resolved:
_ensure_item_models()
_item_models_resolved = True
return hint.item_model
# ---------------------------------------------------------------------------
# Resolver
# ---------------------------------------------------------------------------
def resolve_form_factory(
hint: UiHint,
config_details: dict[str, dict],
) -> Optional[Callable]:
"""Materialise a ``UiHint`` into a concrete ``update_form_factory`` callable.
For ``"items"`` hints this returns ``None`` the caller must dispatch
to ``ConfigItemsCard`` separately after checking ``hint.form == "items"``.
For ``"text"`` this returns ``None`` the caller uses the default
plain-text input. All other form types return a callable.
Args:
hint:
The ``UiHint`` to materialise.
config_details:
The fully-resolved config detail dict from
``create_config_details()``. Used to look up runtime option
lists via ``options_from`` / ``param_from``.
Returns:
A ``(config_name: str, value: str) -> Grid`` factory, or ``None``.
"""
def _load_list(key: str) -> list[str]:
try:
result = json.loads(config_details[key]["value"])
return result if isinstance(result, list) else []
except Exception:
return []
if hint.form in ("text", "items", "map_items"):
return None
if hint.form == "select":
options: list[str] = []
if hint.options_from:
options = _load_list(hint.options_from)
if not options:
options = list(hint.options)
if hint.append_none and "None" not in options:
options.append("None")
return make_config_update_value_form(options)
if hint.form == "select_list":
options = []
if hint.options_from:
options = _load_list(hint.options_from)
if not options:
options = list(hint.options)
return make_config_update_list_form(options)
if hint.form == "map":
available_values: Optional[list[str]] = None
available_keys: Optional[list[str]] = None
if hint.options_from:
available_values = _load_list(hint.options_from) or None
if hint.param_from:
available_keys = _load_list(hint.param_from) or None
return make_config_update_map_form(available_keys, available_values)
if hint.form == "time_windows":
return make_config_update_time_windows_windows_form(
value_description=hint.value_description,
)
return None # unreachable for valid UiFormType values
# ---------------------------------------------------------------------------
# Suffix-based lookup for indexed sub-model fields
# ---------------------------------------------------------------------------
def hint_for_indexed_field(field_name: str, list_path: str) -> Optional[UiHint]:
"""Return the UiHint for a sub-field inside an 'items' or 'map_items' list.
Strips the index segment (numeric for lists, any string for maps) from a
dotted field name and looks up the canonical hint key.
Args:
field_name:
Full dotted config name including the index, e.g.
``"pvforecast.planes.2.mountingplace"`` or
``"devices.home_appliances.dishwasher1.time_windows"``.
list_path:
The ``item_path`` from the parent ``UiHint``, e.g.
``"pvforecast.planes"`` or ``"devices.home_appliances"``.
Returns:
The matching ``UiHint``, or ``None`` if none is registered.
"""
prefix = list_path + "."
if not field_name.startswith(prefix):
return None
remainder = field_name[len(prefix) :] # e.g. "2.mountingplace" or "dishwasher1.time_windows"
parts = remainder.split(".", 1)
if len(parts) < 2:
return None
# Accept both numeric (list) and string (map) index segments
canonical = list_path + "." + parts[1]
return UI_HINTS.get(canonical)
def hint_for_plane_field(field_name: str) -> Optional[UiHint]:
"""Back-compat wrapper — prefer ``hint_for_indexed_field`` directly."""
return hint_for_indexed_field(field_name, "pvforecast.planes")
+21 -6
View File
@@ -64,11 +64,15 @@ from akkudoktoreos.optimization.genetic0.genetic0visualize import (
genetic0_prepare_visualize,
)
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSolution
from akkudoktoreos.optimization.optimization import OptimizationSolution
from akkudoktoreos.optimization.optimization import (
OptimizationAlgorithm,
OptimizationSolution,
)
from akkudoktoreos.prediction.elecprice import ElecPriceCommonSettings
from akkudoktoreos.prediction.load import LoadCommonSettings
from akkudoktoreos.prediction.loadakkudoktor import LoadAkkudoktorCommonSettings
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
from akkudoktoreos.prediction.pvforecastpvlib import _cec_inverters, _cec_modules
from akkudoktoreos.server.rest.error import (
EOSProblem,
create_error_page,
@@ -1770,6 +1774,18 @@ async def fastapi_prediction_range_delete(
) from e
@app.get("/v1/prediction/pvforecast/pvlib/modules", tags=["prediction"])
def fastapi_prediction_pvforecast_modules_get() -> list[str]:
"""Get module names supported by PVForecast PVLib provider."""
return _cec_modules().columns.tolist()
@app.get("/v1/prediction/pvforecast/pvlib/inverters", tags=["prediction"])
def fastapi_prediction_pvforecast_inverters_get() -> list[str]:
"""Get inverter names supported by PVForecast PVLib provider."""
return _cec_inverters().columns.tolist()
@app.get("/v1/energy-management/optimization/solution", tags=["energy-management"])
def fastapi_energy_management_optimization_solution_get() -> OptimizationSolution:
"""Get the latest solution of the optimization."""
@@ -1790,7 +1806,7 @@ def fastapi_energy_management_optimization_solution_get() -> OptimizationSolutio
@app.get("/v1/energy-management/optimization/solution/{algorithm}", tags=["energy-management"])
async def fastapi_energy_management_optimization_solution_algorithm_get(
algorithm: str,
algorithm: OptimizationAlgorithm,
) -> Union[GeneticSolution, Genetic0Solution]:
"""Get the latest algorithm specific solution of the optimization.
@@ -1799,7 +1815,6 @@ async def fastapi_energy_management_optimization_solution_algorithm_get(
"""
solution: Optional[Union[GeneticSolution, Genetic0Solution]] = None
algorithm = algorithm.upper()
if algorithm not in get_config().optimization.algorithms:
raise EOSProblem(
status=404,
@@ -1807,9 +1822,9 @@ async def fastapi_energy_management_optimization_solution_algorithm_get(
detail=f"Optimization algorithm '{algorithm}' unknown.",
)
if algorithm == "GENETIC":
if algorithm == OptimizationAlgorithm.GENETIC:
solution = get_ems().genetic_solution()
elif algorithm == "GENETIC0":
elif algorithm == OptimizationAlgorithm.GENETIC0:
solution = get_ems().genetic0_solution()
if solution is None:
@@ -2162,7 +2177,7 @@ async def fastapi_optimize(
await get_ems().run(
start_datetime=start_datetime,
mode=EnergyManagementMode.OPTIMIZATION,
algorithm="GENETIC0",
algorithm=OptimizationAlgorithm.GENETIC0,
genetic0_parameters=parameters,
genetic0_generations=ngen,
)
+73
View File
@@ -1,7 +1,10 @@
import bz2
import hashlib
import json
import logging
import os
import pickle
import signal
import subprocess
import sys
@@ -14,6 +17,7 @@ from pathlib import Path
from typing import Generator, Optional, Union
from unittest.mock import PropertyMock, patch
import pandas as pd
import pendulum
import psutil
import pytest
@@ -176,6 +180,74 @@ def cfg_non_existent(request):
# ------------------------------------
@pytest.fixture(scope="session")
def cec_databases_data() -> tuple[pd.DataFrame, pd.DataFrame]:
"""Load CEC test databases once per test session."""
DIR_TESTDATA = Path(__file__).parent / "testdata" / "pvforecastpvlib"
FILE_TESTDATA_CEC_INVERTERS_PBZ2 = DIR_TESTDATA / "cec_inverters.pbz2"
FILE_TESTDATA_CEC_MODULES_PBZ2 = DIR_TESTDATA / "cec_modules.pbz2"
with bz2.BZ2File(FILE_TESTDATA_CEC_MODULES_PBZ2, "rb") as f:
modules: pd.DataFrame = pickle.load(f)
with bz2.BZ2File(FILE_TESTDATA_CEC_INVERTERS_PBZ2, "rb") as f:
inverters: pd.DataFrame = pickle.load(f)
return modules, inverters
@pytest.fixture(autouse=True)
def cec_databases(monkeypatch, cec_databases_data) -> tuple[pd.DataFrame, pd.DataFrame]:
"""Short-circuit CEC database access for every test (per-test patch, session-cached data).
Config requests the database in PVForecastPVLibCommonSettings by a computed_field.
To undo this fixture in a specific class do:
@pytest.fixture(autouse=True)
def cec_databases(self):
yield None
"""
modules, inverters = cec_databases_data
def fake_update_cec_database() -> None:
#print(f"_update_cec_database faked")
pass
def fake_load_cec_database(path: Path) -> pd.DataFrame:
#print(f"_loadcec_database faked")
if "inverter" in path.name:
return inverters
if "module" in path.name:
return modules
raise ValueError(f"Unexpected CEC database path in test: {path}")
def fake_cec_inverters() -> pd.DataFrame:
#print(f"_cec_inverters faked")
return inverters
def fake_cec_modules() -> pd.DataFrame:
#print(f"_cec_modules faked")
return modules
monkeypatch.setattr(
"akkudoktoreos.prediction.pvforecastpvlib._update_cec_database",
fake_update_cec_database,
)
monkeypatch.setattr(
"akkudoktoreos.prediction.pvforecastpvlib._load_cec_database",
fake_load_cec_database,
)
monkeypatch.setattr(
"akkudoktoreos.prediction.pvforecastpvlib._cec_inverters",
fake_cec_inverters,
)
monkeypatch.setattr(
"akkudoktoreos.prediction.pvforecastpvlib._cec_modules",
fake_cec_modules,
)
return modules, inverters
@pytest.fixture
def config_default_dirs(tmpdir):
"""Fixture that provides a list of directories to be used as config dir."""
@@ -234,6 +306,7 @@ def user_data_dir(config_default_dirs):
@pytest.fixture
def config_eos_factory(
cec_databases,
disable_debug_logging,
user_config_dir,
user_data_dir,
+2 -1
View File
@@ -17,6 +17,7 @@ from akkudoktoreos.core.emsettings import EnergyManagementMode
from akkudoktoreos.optimization.genetic0.genetic0params import (
Genetic0OptimizationParameters,
)
from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
from akkudoktoreos.utils.datetimeutil import to_datetime
config_eos = get_config()
@@ -431,7 +432,7 @@ def run_optimization(
ems_eos.run(
start_datetime=start_datetime,
mode=EnergyManagementMode.OPTIMIZATION,
algorithm="GENETIC0",
algorithm=OptimizationAlgorithm.GENETIC0,
genetic0_parameters=parameters,
genetic0_generations=ngen,
genetic0_seed=seed,
+2
View File
@@ -17,6 +17,7 @@ from akkudoktoreos.core.emsettings import EnergyManagementMode
from akkudoktoreos.optimization.genetic.geneticparams import (
GeneticOptimizationParameters,
)
from akkudoktoreos.optimization.optimization import OptimizationAlgorithm
from akkudoktoreos.utils.datetimeutil import to_datetime
config_eos = get_config()
@@ -432,6 +433,7 @@ def run_optimization(
ems_eos.run(
start_datetime=start_datetime,
mode=EnergyManagementMode.OPTIMIZATION,
algorithm=OptimizationAlgorithm.GENETIC,
genetic_parameters=parameters,
genetic_generations=ngen,
genetic_seed=seed,
+10
View File
@@ -46,6 +46,16 @@ IGNORE_LOCATIONS = [
# pathlib
r"\.Path.*",
# PVLib
r"\.pvlib.*",
r"\.PVSystem.*",
r"\.disc.*",
r"\.Location.*",
r"\.ModelChain.*",
r"\.retrieve_sam.*",
r"\.get_solarposition.*",
r"\.TEMPERATURE_MODEL_PARAMETERS.*",
# MarkdownIt
r"\.MarkdownIt.*",
+45 -36
View File
@@ -26,6 +26,7 @@ from akkudoktoreos.prediction.prediction import (
from akkudoktoreos.prediction.pvforecastakkudoktor import PVForecastAkkudoktor
from akkudoktoreos.prediction.pvforecastforecastsolar import PVForecastForecastSolar
from akkudoktoreos.prediction.pvforecastimport import PVForecastImport
from akkudoktoreos.prediction.pvforecastpvlib import PVForecastPVLib
from akkudoktoreos.prediction.pvforecastpvnode import PVForecastPVNode
from akkudoktoreos.prediction.pvforecastsolcast import PVForecastSolcast
from akkudoktoreos.prediction.pvforecastvrm import PVForecastVrm
@@ -45,6 +46,10 @@ def prediction():
def forecast_providers():
"""Fixture for singleton forecast provider instances."""
return [
WeatherBrightSky(),
WeatherClearOutside(),
WeatherImport(),
WeatherOpenMeteo(),
ElecPriceAkkudoktor(),
ElecPriceEnergyCharts(),
ElecPriceFixed(),
@@ -58,18 +63,15 @@ def forecast_providers():
FeedInTariffTibber(),
LoadAkkudoktor(),
LoadAkkudoktorAdjusted(),
LoadVrm(),
LoadImport(),
LoadVrm(),
PVForecastAkkudoktor(),
PVForecastVrm(),
PVForecastPVNode(),
PVForecastForecastSolar(),
PVForecastSolcast(),
PVForecastImport(),
WeatherBrightSky(),
WeatherClearOutside(),
WeatherOpenMeteo(),
WeatherImport(),
PVForecastPVLib(),
PVForecastPVNode(),
PVForecastSolcast(),
PVForecastVrm(),
]
@@ -102,31 +104,32 @@ def test_initialization(prediction, forecast_providers):
def test_provider_sequence(prediction):
"""Test the provider sequence is maintained in the Prediction instance."""
assert isinstance(prediction.providers[0], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[1], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[2], ElecPriceFixed)
assert isinstance(prediction.providers[3], ElecPriceImport)
assert isinstance(prediction.providers[4], ElecPriceTibber)
assert isinstance(prediction.providers[5], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[6], FeedInTariffDvhubOnline)
assert isinstance(prediction.providers[7], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[8], FeedInTariffFixed)
assert isinstance(prediction.providers[9], FeedInTariffImport)
assert isinstance(prediction.providers[10], FeedInTariffTibber)
assert isinstance(prediction.providers[11], LoadAkkudoktor)
assert isinstance(prediction.providers[12], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[13], LoadVrm)
assert isinstance(prediction.providers[14], LoadImport)
assert isinstance(prediction.providers[15], PVForecastAkkudoktor)
assert isinstance(prediction.providers[16], PVForecastVrm)
assert isinstance(prediction.providers[17], PVForecastPVNode)
assert isinstance(prediction.providers[18], PVForecastForecastSolar)
assert isinstance(prediction.providers[19], PVForecastSolcast)
assert isinstance(prediction.providers[20], PVForecastImport)
assert isinstance(prediction.providers[21], WeatherBrightSky)
assert isinstance(prediction.providers[22], WeatherClearOutside)
assert isinstance(prediction.providers[23], WeatherOpenMeteo)
assert isinstance(prediction.providers[24], WeatherImport)
assert isinstance(prediction.providers[0], WeatherBrightSky)
assert isinstance(prediction.providers[1], WeatherClearOutside)
assert isinstance(prediction.providers[2], WeatherImport)
assert isinstance(prediction.providers[3], WeatherOpenMeteo)
assert isinstance(prediction.providers[4], ElecPriceAkkudoktor)
assert isinstance(prediction.providers[5], ElecPriceEnergyCharts)
assert isinstance(prediction.providers[6], ElecPriceFixed)
assert isinstance(prediction.providers[7], ElecPriceImport)
assert isinstance(prediction.providers[8], ElecPriceTibber)
assert isinstance(prediction.providers[9], FeedInTariffAkkudoktor)
assert isinstance(prediction.providers[10], FeedInTariffDvhubOnline)
assert isinstance(prediction.providers[11], FeedInTariffEnergyCharts)
assert isinstance(prediction.providers[12], FeedInTariffFixed)
assert isinstance(prediction.providers[13], FeedInTariffImport)
assert isinstance(prediction.providers[14], FeedInTariffTibber)
assert isinstance(prediction.providers[15], LoadAkkudoktor)
assert isinstance(prediction.providers[16], LoadAkkudoktorAdjusted)
assert isinstance(prediction.providers[17], LoadImport)
assert isinstance(prediction.providers[18], LoadVrm)
assert isinstance(prediction.providers[19], PVForecastAkkudoktor)
assert isinstance(prediction.providers[20], PVForecastForecastSolar)
assert isinstance(prediction.providers[21], PVForecastImport)
assert isinstance(prediction.providers[22], PVForecastPVLib)
assert isinstance(prediction.providers[23], PVForecastPVNode)
assert isinstance(prediction.providers[24], PVForecastSolcast)
assert isinstance(prediction.providers[25], PVForecastVrm)
def test_provider_by_id(prediction, forecast_providers):
@@ -145,20 +148,26 @@ def test_prediction_repr(prediction):
assert "ElecPriceImport" in result
assert "ElecPriceTibber" in result
assert "FeedInTariffAkkudoktor" in result
assert "FeedInTariffDvhubOnline" in result
assert "FeedInTariffEnergyCharts" in result
assert "FeedInTariffFixed" in result
assert "FeedInTariffImport" in result
assert "FeedInTariffTibber" in result
assert "LoadAkkudoktor" in result
assert "LoadVrm" in result
assert "LoadAkkudoktorAdjusted" in result
assert "LoadImport" in result
assert "LoadVrm" in result
assert "PVForecastAkkudoktor" in result
assert "PVForecastVrm" in result
assert "PVForecastForecastSolar" in result
assert "PVForecastImport" in result
assert "PVForecastPVLib" in result
assert "PVForecastPVNode" in result
assert "PVForecastSolcast" in result
assert "PVForecastVrm" in result
assert "WeatherBrightSky" in result
assert "WeatherClearOutside" in result
assert "WeatherOpenMeteo" in result
assert "WeatherImport" in result
assert "WeatherOpenMeteo" in result
@pytest.mark.asyncio
File diff suppressed because it is too large Load Diff
+3 -3
View File
@@ -209,7 +209,7 @@
| ac_to_dc_efficiency | `float` | `rw` | `1.0` | Efficiency of AC to DC conversion for grid-to-battery AC charging (0-1). Set to 0 to disable AC charging. Default 1.0 (no additional inverter loss). |
| battery_id | `str | None` | `rw` | `None` | ID of battery controlled by this inverter. |
| dc_to_ac_efficiency | `float` | `rw` | `1.0` | Efficiency of DC to AC conversion for battery discharging to AC load/grid (0-1). Default 1.0 (no additional inverter loss). |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| device_id | `str` | `rw` | `required` | ID of device |
| max_ac_charge_power_w | `float | None` | `rw` | `None` | Maximum AC charging power in watts. null means no additional limit. Set to 0 to disable AC charging. |
| max_power_w | `float | None` | `rw` | `None` | Maximum power [W]. |
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the inverter stati that are measurements. |
@@ -372,7 +372,7 @@ as a cohesive unit for scheduling and availability checking.
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| consumption_wh | `int` | `rw` | `required` | Energy consumption [Wh]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| device_id | `str` | `rw` | `required` | ID of device |
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
| measurement_keys | `list[str] | None` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
| time_windows | `akkudoktoreos.config.configabc.TimeWindowSequence | None` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
@@ -454,7 +454,7 @@ as a cohesive unit for scheduling and availability checking.
| capacity_wh | `int` | `rw` | `8000` | Capacity [Wh]. |
| charge_rates | `list[float] | None` | `rw` | `[0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]` | Charge rates as factor of maximum charging power [0.00 ... 1.00]. None triggers fallback to default charge-rates. |
| charging_efficiency | `float` | `rw` | `0.88` | Charging efficiency [0.01 ... 1.00]. |
| device_id | `str` | `rw` | `<unknown>` | ID of device |
| device_id | `str` | `rw` | `required` | ID of device |
| discharging_efficiency | `float` | `rw` | `0.88` | Discharge efficiency [0.01 ... 1.00]. |
| levelized_cost_of_storage_kwh | `float` | `rw` | `0.0` | Levelized cost of storage (LCOS), the average lifetime cost of delivering one kWh [amount/kWh]. |
| max_charge_power_w | `float | None` | `rw` | `5000` | Maximum charging power [W]. |
+24 -2
View File
@@ -8,13 +8,14 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| interval | `EOS_EMS__INTERVAL` | `float` | `rw` | `300.0` | Intervall between EOS energy management runs [seconds]. |
| mode | `EOS_EMS__MODE` | `<enum 'EnergyManagementMode'>` | `rw` | `required` | Energy management mode [DISABLED | OPTIMIZATION | PREDICTION]. |
| mode | `EOS_EMS__MODE` | `<enum 'EnergyManagementMode'>` | `rw` | `required` | Energy management mode [DISABLED | PREDICTION | OPTIMIZATION]. Defaults to DISABLED. |
| modes | | `list[str]` | `ro` | `N/A` | Available energy management modes. |
| startup_delay | `EOS_EMS__STARTUP_DELAY` | `float` | `rw` | `5` | Startup delay in seconds for EOS energy management runs. |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
**Example Input**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
@@ -28,3 +29,24 @@
}
```
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"ems": {
"startup_delay": 5.0,
"interval": 300.0,
"mode": "OPTIMIZATION",
"modes": [
"DISABLED",
"PREDICTION",
"OPTIMIZATION"
]
}
}
```
<!-- pyml enable line-length -->
+1
View File
@@ -217,6 +217,7 @@
"token": "your-token",
"site_id": 12345
},
"pvlib": {},
"pvnode": {
"api_key": "",
"site_id": null,
+2 -2
View File
@@ -63,8 +63,8 @@
"providers": [
"LoadAkkudoktor",
"LoadAkkudoktorAdjusted",
"LoadVrm",
"LoadImport"
"LoadImport",
"LoadVrm"
]
}
}
+1 -1
View File
@@ -7,7 +7,7 @@
| Name | Environment Variable | Type | Read-Only | Default | Description |
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
| algorithm | `EOS_OPTIMIZATION__ALGORITHM` | `str` | `rw` | `GENETIC` | The optimization algorithm. Defaults to GENETIC |
| algorithm | `EOS_OPTIMIZATION__ALGORITHM` | `<enum 'OptimizationAlgorithm'>` | `rw` | `required` | Optimization algorithm [GENETIC | GENETIC0]. Defaults to GENETIC. |
| algorithms | | `list[str]` | `ro` | `N/A` | Available optimization algorithms. |
| genetic | `EOS_OPTIMIZATION__GENETIC` | `GeneticCommonSettings` | `rw` | `required` | GENETIC optimization algorithm configuration. |
| genetic0 | `EOS_OPTIMIZATION__GENETIC0` | `Genetic0CommonSettings` | `rw` | `required` | GENETIC0 optimization algorithm configuration. |
+36 -6
View File
@@ -18,6 +18,7 @@
| provider | `EOS_PVFORECAST__PROVIDER` | `str | None` | `rw` | `None` | PVForecast provider id of provider to be used. |
| providers | | `list[str]` | `ro` | `N/A` | Available PVForecast provider ids. |
| pvforecastimport | `EOS_PVFORECAST__PVFORECASTIMPORT` | `PVForecastImportCommonSettings` | `rw` | `required` | PV forecast import provider settings |
| pvlib | `EOS_PVFORECAST__PVLIB` | `PVForecastPVLibCommonSettings` | `rw` | `required` | PVLib provider settings |
| pvnode | `EOS_PVFORECAST__PVNODE` | `PVForecastPVNodeCommonSettings` | `rw` | `required` | PVNode provider settings |
| solcast | `EOS_PVFORECAST__SOLCAST` | `PVForecastSolcastCommonSettings` | `rw` | `required` | Solcast provider settings |
| vrm | `EOS_PVFORECAST__VRM` | `PVForecastVrmCommonSettings` | `rw` | `required` | Victron Remote Management (VRM) provider settings |
@@ -41,6 +42,7 @@
"token": "your-token",
"site_id": 12345
},
"pvlib": {},
"pvnode": {
"api_key": "",
"site_id": null,
@@ -122,6 +124,7 @@
"token": "your-token",
"site_id": 12345
},
"pvlib": {},
"pvnode": {
"api_key": "",
"site_id": null,
@@ -183,11 +186,12 @@
"max_planes": 1,
"providers": [
"PVForecastAkkudoktor",
"PVForecastVrm",
"PVForecastPVNode",
"PVForecastForecastSolar",
"PVForecastImport",
"PVForecastPVLib",
"PVForecastPVNode",
"PVForecastSolcast",
"PVForecastImport"
"PVForecastVrm"
],
"planes_peakpower": [
5.0,
@@ -317,6 +321,32 @@
```
<!-- pyml enable line-length -->
### Common settings for pvforecast data calculation with PVLib
<!-- pyml disable line-length -->
:::{table} pvforecast::pvlib
:widths: 10 10 5 5 30
:align: left
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
:::
<!-- pyml enable line-length -->
<!-- pyml disable no-emphasis-as-heading -->
**Example Input/Output**
<!-- pyml enable no-emphasis-as-heading -->
<!-- pyml disable line-length -->
```json
{
"pvforecast": {
"pvlib": {}
}
}
```
<!-- pyml enable line-length -->
### Common settings for pvforecast data import from file or JSON string
<!-- pyml disable line-length -->
@@ -357,13 +387,13 @@
| Name | Type | Read-Only | Default | Description |
| ---- | ---- | --------- | ------- | ----------- |
| albedo | `float | None` | `rw` | `None` | Proportion of the light hitting the ground that it reflects back. |
| albedo | `float | None` | `rw` | `0.2` | Proportion of the light hitting the ground that it reflects back. |
| inverter_model | `str | None` | `rw` | `None` | Model of the inverter of this plane. |
| inverter_paco | `int | None` | `rw` | `None` | AC power rating of the inverter [W]. |
| loss | `float | None` | `rw` | `14.0` | Sum of PV system losses in percent |
| module_model | `str | None` | `rw` | `None` | Model of the PV modules of this plane. |
| modules_per_string | `int | None` | `rw` | `None` | Number of the PV modules of the strings of this plane. |
| mountingplace | `str | None` | `rw` | `free` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
| mountingplace | `str | None` | `rw` | `building` | Type of mounting for PV system. Options are 'free' for free-standing and 'building' for building-integrated. |
| optimal_surface_tilt | `bool | None` | `rw` | `False` | Calculate the optimum tilt angle. Ignored for two-axis tracking. |
| optimalangles | `bool | None` | `rw` | `False` | Calculate the optimum tilt and azimuth angles. Ignored for two-axis tracking. |
| peakpower | `float | None` | `rw` | `None` | Nominal power of PV system in kW. |
@@ -395,7 +425,7 @@
],
"peakpower": 5.0,
"pvtechchoice": "crystSi",
"mountingplace": "free",
"mountingplace": "building",
"loss": 14.0,
"trackingtype": 0,
"optimal_surface_tilt": false,
+2 -2
View File
@@ -47,8 +47,8 @@
"providers": [
"BrightSky",
"ClearOutside",
"OpenMeteo",
"WeatherImport"
"WeatherImport",
"OpenMeteo"
]
}
}
Binary file not shown.
Binary file not shown.