mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-08-09 18:36:11 +00:00
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:
@@ -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]. |
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
"token": "your-token",
|
||||
"site_id": 12345
|
||||
},
|
||||
"pvlib": {},
|
||||
"pvnode": {
|
||||
"api_key": "",
|
||||
"site_id": null,
|
||||
|
||||
@@ -63,8 +63,8 @@
|
||||
"providers": [
|
||||
"LoadAkkudoktor",
|
||||
"LoadAkkudoktorAdjusted",
|
||||
"LoadVrm",
|
||||
"LoadImport"
|
||||
"LoadImport",
|
||||
"LoadVrm"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
"providers": [
|
||||
"BrightSky",
|
||||
"ClearOutside",
|
||||
"OpenMeteo",
|
||||
"WeatherImport"
|
||||
"WeatherImport",
|
||||
"OpenMeteo"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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
|
||||
|
@@ -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,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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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,21 +528,20 @@ 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``.
|
||||
"""
|
||||
|
||||
def ConfigUpdateTimeWindowsWindowsForm(config_name: str, value: str) -> Grid:
|
||||
config_id = config_name.lower().replace(".", "-")
|
||||
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
current_windows: list[dict] = parsed if isinstance(parsed, list) else []
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
current_windows = []
|
||||
|
||||
DOW_LABELS = [
|
||||
"0 – Monday",
|
||||
"1 – Tuesday",
|
||||
@@ -525,115 +552,246 @@ def make_config_update_time_windows_windows_form(
|
||||
"6 – Sunday",
|
||||
]
|
||||
|
||||
# ---- Existing windows rows ----
|
||||
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(".", "-")
|
||||
|
||||
try:
|
||||
parsed = json.loads(value)
|
||||
current_windows: list[dict] = parsed if isinstance(parsed, list) else []
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
current_windows = []
|
||||
|
||||
num_cols = 5 + (1 if value_description is not None else 0)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 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",
|
||||
),
|
||||
P(label, cls="ml-2 text-sm font-mono"),
|
||||
)
|
||||
cls="absolute z-10 mt-1 p-2 rounded-md border bg-background shadow-md",
|
||||
),
|
||||
cls="relative",
|
||||
)
|
||||
|
||||
# ---- Column headers and inputs ----
|
||||
num_cols = 5 + (1 if value_description is not None else 0)
|
||||
# --- 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 ""
|
||||
|
||||
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 = [
|
||||
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(
|
||||
placeholder="e.g. 08:00 Europe/Berlin",
|
||||
name=f"{config_id}_tw_start_time",
|
||||
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",
|
||||
name=f"{config_id}_tw_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(
|
||||
edit_cols.append(
|
||||
Input(
|
||||
placeholder="e.g. 0.288",
|
||||
name=f"{config_id}_tw_value",
|
||||
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",
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------
|
||||
# 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"),
|
||||
]
|
||||
add_input_cols = [
|
||||
Input(
|
||||
placeholder="e.g. 08:00 Europe/Berlin",
|
||||
name=f"{add_wid}_start_time",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
),
|
||||
Input(
|
||||
placeholder="e.g. 8 hours",
|
||||
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")
|
||||
)
|
||||
add_input_cols.append(
|
||||
Input(
|
||||
placeholder="e.g. 0.288",
|
||||
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}
|
||||
{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()
|
||||
|
||||
@@ -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,85 +545,39 @@ 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]"
|
||||
)
|
||||
|
||||
rows.append(
|
||||
ConfigCard(
|
||||
update_form_factory = resolve_form_factory(hint, config_details) if hint else None
|
||||
card = ConfigCard(
|
||||
config["name"],
|
||||
config["type"],
|
||||
config["read-only"],
|
||||
@@ -745,11 +585,39 @@ def Configuration(
|
||||
config["default"],
|
||||
config["description"],
|
||||
config["deprecated"],
|
||||
config["scope"],
|
||||
update_error,
|
||||
update_value,
|
||||
update_open,
|
||||
update_form_factory,
|
||||
)
|
||||
else:
|
||||
continue
|
||||
|
||||
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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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,8 +152,10 @@ 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)
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
for options in prediction_requests:
|
||||
key = options[0]
|
||||
resample_method = options[1]
|
||||
fill_method = options[2]
|
||||
|
||||
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",
|
||||
],
|
||||
"key": key,
|
||||
"interval": "15 minutes",
|
||||
"processing": "resampled",
|
||||
"resample_method": resample_method,
|
||||
"fill_method": fill_method,
|
||||
}
|
||||
result = requests.get(f"{server}/v1/prediction/dataframe", params=params, timeout=10)
|
||||
|
||||
result = requests.get(
|
||||
f"{server}/v1/prediction/series",
|
||||
params=params,
|
||||
timeout=10,
|
||||
)
|
||||
result.raise_for_status()
|
||||
predictions = PydanticDateTimeDataFrame(**result.json()).to_dataframe()
|
||||
|
||||
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)
|
||||
|
||||
@@ -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")
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 -->
|
||||
|
||||
@@ -217,6 +217,7 @@
|
||||
"token": "your-token",
|
||||
"site_id": 12345
|
||||
},
|
||||
"pvlib": {},
|
||||
"pvnode": {
|
||||
"api_key": "",
|
||||
"site_id": null,
|
||||
|
||||
+2
-2
@@ -63,8 +63,8 @@
|
||||
"providers": [
|
||||
"LoadAkkudoktor",
|
||||
"LoadAkkudoktorAdjusted",
|
||||
"LoadVrm",
|
||||
"LoadImport"
|
||||
"LoadImport",
|
||||
"LoadVrm"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
@@ -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
@@ -47,8 +47,8 @@
|
||||
"providers": [
|
||||
"BrightSky",
|
||||
"ClearOutside",
|
||||
"OpenMeteo",
|
||||
"WeatherImport"
|
||||
"WeatherImport",
|
||||
"OpenMeteo"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
BIN
Binary file not shown.
Reference in New Issue
Block a user