mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-03-12 09:36:17 +00:00
feat: add fixed electricity prediction with time window support (#930)
Some checks are pending
Bump Version / Bump Version Workflow (push) Waiting to run
docker-build / platform-excludes (push) Waiting to run
docker-build / build (push) Blocked by required conditions
docker-build / merge (push) Blocked by required conditions
pre-commit / pre-commit (push) Waiting to run
Run Pytest on Pull Request / test (push) Waiting to run
Some checks are pending
Bump Version / Bump Version Workflow (push) Waiting to run
docker-build / platform-excludes (push) Waiting to run
docker-build / build (push) Blocked by required conditions
docker-build / merge (push) Blocked by required conditions
pre-commit / pre-commit (push) Waiting to run
Run Pytest on Pull Request / test (push) Waiting to run
Add a fixed electricity prediction that supports prices per time window.
The time windows may flexible be defined by day or date.
The prediction documentation is updated to also cover the ElecPriceFixed
provider.
The feature includes several changes that are not directly related to the
electricity price prediction implementation but are necessary to keep
EOS running properly and to test and document the changes.
* feat: add value time windows
Add time windows with an associated float value.
* feat: harden eos measurements endpoints error detection and reporting
Cover more errors that may be raised during endpoint access. Report the
errors including trace information to ease debugging.
* feat: extend server configuration to cover all arguments
Make the argument controlled options also available in server configuration.
* fix: eos config configuration by cli arguments
Move the command line argument handling to config eos so that it is
excuted whenever eos config is rebuild or reset.
* chore: extend measurement endpoint system test
* chore: refactor time windows
Move time windows to configabc as they are only used in configurations.
Also move all tests to test_configabc.
* chore: provide config update errors in eosdash with summarized error text
If there is an update error provide the error text as a summary. On click
provide the full error text.
* chore: force eosdash ip address and port in makefile dev run
Ensure eosdash ip address and port are correctly set for development runs.
Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
3
Makefile
3
Makefile
@@ -121,7 +121,7 @@ run-dash:
|
||||
|
||||
run-dash-dev:
|
||||
@echo "Starting EOSdash development server, please wait..."
|
||||
$(PYTHON) -m akkudoktoreos.server.eosdash --host localhost --port 8504 --log_level DEBUG --reload true
|
||||
$(PYTHON) -m akkudoktoreos.server.eosdash --host localhost --port 8504 --eos-host localhost --eos-port 8503 --log_level DEBUG --reload true
|
||||
|
||||
# Target to setup tests.
|
||||
test-setup: install
|
||||
@@ -189,6 +189,7 @@ prepare-version: install
|
||||
$(PYTHON) ./scripts/generate_openapi_md.py --output-file docs/_generated/openapi.md
|
||||
$(PYTHON) ./scripts/generate_openapi.py --output-file openapi.json
|
||||
$(PYTEST) -vv --finalize tests/test_doc.py
|
||||
$(PRECOMMIT) run --all-files
|
||||
|
||||
test-version:
|
||||
echo "Test version information to be correctly set in all version files"
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
# the root directory (no add-on folder as usual).
|
||||
|
||||
name: "Akkudoktor-EOS"
|
||||
version: "0.2.0.dev2603071785688456"
|
||||
version: "0.2.0.dev2603110720349451"
|
||||
slug: "eos"
|
||||
description: "Akkudoktor-EOS add-on"
|
||||
url: "https://github.com/Akkudoktor-EOS/EOS"
|
||||
|
||||
@@ -263,6 +263,105 @@
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Model defining a daily or date time window with optional localization support
|
||||
|
||||
Represents a time interval starting at `start_time` and lasting for `duration`.
|
||||
Can restrict applicability to a specific day of the week or a specific calendar date.
|
||||
Supports day names in multiple languages via locale-aware parsing.
|
||||
|
||||
Timezone contract:
|
||||
|
||||
``start_time`` is always **naive** (no ``tzinfo``). It is interpreted as a
|
||||
local wall-clock time in whatever timezone the caller's ``date_time`` or
|
||||
``reference_date`` carries. When those arguments are timezone-aware the
|
||||
window boundaries are evaluated in that timezone; when they are naive,
|
||||
arithmetic is performed as-is (no timezone conversion occurs).
|
||||
|
||||
``date``, being a calendar ``Date`` object, is inherently timezone-free.
|
||||
|
||||
This design avoids the ambiguity that arises when a stored ``start_time``
|
||||
carries its own timezone that differs from the caller's timezone, and keeps
|
||||
the model serialisable without timezone state.
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} devices::home_appliances::list::time_windows::windows::list
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
|
||||
| day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
|
||||
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
|
||||
| locale | `Optional[str]` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
|
||||
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"devices": {
|
||||
"home_appliances": [
|
||||
{
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00:00.000000",
|
||||
"duration": "2 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Model representing a sequence of time windows with collective operations
|
||||
|
||||
Manages multiple TimeWindow objects and provides methods to work with them
|
||||
as a cohesive unit for scheduling and availability checking.
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} devices::home_appliances::list::time_windows
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| windows | `list[akkudoktoreos.config.configabc.TimeWindow]` | `rw` | `required` | List of TimeWindow objects that make up this sequence. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"devices": {
|
||||
"home_appliances": [
|
||||
{
|
||||
"time_windows": {
|
||||
"windows": []
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Home Appliance devices base settings
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
@@ -276,7 +375,7 @@
|
||||
| device_id | `str` | `rw` | `<unknown>` | ID of device |
|
||||
| duration_h | `int` | `rw` | `required` | Usage duration in hours [0 ... 24]. |
|
||||
| measurement_keys | `Optional[list[str]]` | `ro` | `N/A` | Measurement keys for the home appliance stati that are measurements. |
|
||||
| time_windows | `Optional[akkudoktoreos.utils.datetimeutil.TimeWindowSequence]` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
|
||||
| time_windows | `Optional[akkudoktoreos.config.configabc.TimeWindowSequence]` | `rw` | `None` | Sequence of allowed time windows. Defaults to optimization general time window. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -296,7 +395,7 @@
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "10:00:00.000000 Europe/Berlin",
|
||||
"start_time": "10:00:00.000000",
|
||||
"duration": "2 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
@@ -327,7 +426,7 @@
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "10:00:00.000000 Europe/Berlin",
|
||||
"start_time": "10:00:00.000000",
|
||||
"duration": "2 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
| Name | Environment Variable | Type | Read-Only | Default | Description |
|
||||
| ---- | -------------------- | ---- | --------- | ------- | ----------- |
|
||||
| charges_kwh | `EOS_ELECPRICE__CHARGES_KWH` | `Optional[float]` | `rw` | `None` | Electricity price charges [€/kWh]. Will be added to variable market price. |
|
||||
| elecpricefixed | `EOS_ELECPRICE__ELECPRICEFIXED` | `ElecPriceFixedCommonSettings` | `rw` | `required` | Fixed electricity price provider settings. |
|
||||
| elecpriceimport | `EOS_ELECPRICE__ELECPRICEIMPORT` | `ElecPriceImportCommonSettings` | `rw` | `required` | Import provider settings. |
|
||||
| energycharts | `EOS_ELECPRICE__ENERGYCHARTS` | `ElecPriceEnergyChartsCommonSettings` | `rw` | `required` | Energy Charts provider settings. |
|
||||
| provider | `EOS_ELECPRICE__PROVIDER` | `Optional[str]` | `rw` | `None` | Electricity price provider id of provider to be used. |
|
||||
@@ -27,6 +28,11 @@
|
||||
"provider": "ElecPriceAkkudoktor",
|
||||
"charges_kwh": 0.21,
|
||||
"vat_rate": 1.19,
|
||||
"elecpricefixed": {
|
||||
"time_windows": {
|
||||
"windows": []
|
||||
}
|
||||
},
|
||||
"elecpriceimport": {
|
||||
"import_file_path": null,
|
||||
"import_json": null
|
||||
@@ -50,6 +56,11 @@
|
||||
"provider": "ElecPriceAkkudoktor",
|
||||
"charges_kwh": 0.21,
|
||||
"vat_rate": 1.19,
|
||||
"elecpricefixed": {
|
||||
"time_windows": {
|
||||
"windows": []
|
||||
}
|
||||
},
|
||||
"elecpriceimport": {
|
||||
"import_file_path": null,
|
||||
"import_json": null
|
||||
@@ -60,6 +71,7 @@
|
||||
"providers": [
|
||||
"ElecPriceAkkudoktor",
|
||||
"ElecPriceEnergyCharts",
|
||||
"ElecPriceFixed",
|
||||
"ElecPriceImport"
|
||||
]
|
||||
}
|
||||
@@ -126,3 +138,138 @@
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Value applicable during a specific time window
|
||||
|
||||
This model extends `TimeWindow` by associating a value with the defined time interval.
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} elecprice::elecpricefixed::time_windows::windows::list
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date for the time window. Naive — matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set. |
|
||||
| day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
|
||||
| duration | `Duration` | `rw` | `required` | Duration of the time window starting from `start_time`. |
|
||||
| locale | `Optional[str]` | `rw` | `None` | Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc. |
|
||||
| start_time | `Time` | `rw` | `required` | Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time(). |
|
||||
| value | `Optional[float]` | `rw` | `None` | Value applicable during this time window. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"elecprice": {
|
||||
"elecpricefixed": {
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00:00.000000",
|
||||
"duration": "2 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.288
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Sequence of value time windows
|
||||
|
||||
This model specializes `TimeWindowSequence` to ensure that all
|
||||
contained windows are instances of `ValueTimeWindow`.
|
||||
It provides the full set of sequence operations (containment checks,
|
||||
availability, start time calculations) for value windows.
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} elecprice::elecpricefixed::time_windows
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| windows | `list[akkudoktoreos.config.configabc.ValueTimeWindow]` | `rw` | `required` | Ordered list of value time windows. Each window defines a time interval and an associated value. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"elecprice": {
|
||||
"elecpricefixed": {
|
||||
"time_windows": {
|
||||
"windows": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
### Common configuration settings for fixed electricity pricing
|
||||
|
||||
This model defines a fixed electricity price schedule using a sequence
|
||||
of time windows. Each window specifies a time interval and the electricity
|
||||
price applicable during that interval.
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
:::{table} elecprice::elecpricefixed
|
||||
:widths: 10 10 5 5 30
|
||||
:align: left
|
||||
|
||||
| Name | Type | Read-Only | Default | Description |
|
||||
| ---- | ---- | --------- | ------- | ----------- |
|
||||
| time_windows | `ValueTimeWindowSequence` | `rw` | `required` | Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied. |
|
||||
:::
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
<!-- pyml disable no-emphasis-as-heading -->
|
||||
**Example Input/Output**
|
||||
<!-- pyml enable no-emphasis-as-heading -->
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```json
|
||||
{
|
||||
"elecprice": {
|
||||
"elecpricefixed": {
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00:00.000000",
|
||||
"duration": "8 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.288
|
||||
},
|
||||
{
|
||||
"start_time": "08:00:00.000000",
|
||||
"duration": "16 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null,
|
||||
"value": 0.34
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
@@ -99,6 +99,11 @@
|
||||
"provider": "ElecPriceAkkudoktor",
|
||||
"charges_kwh": 0.21,
|
||||
"vat_rate": 1.19,
|
||||
"elecpricefixed": {
|
||||
"time_windows": {
|
||||
"windows": []
|
||||
}
|
||||
},
|
||||
"elecpriceimport": {
|
||||
"import_file_path": null,
|
||||
"import_json": null
|
||||
@@ -237,7 +242,9 @@
|
||||
"startup_eosdash": true,
|
||||
"eosdash_host": "127.0.0.1",
|
||||
"eosdash_port": 8504,
|
||||
"eosdash_supervise_interval_sec": 10
|
||||
"eosdash_supervise_interval_sec": 10,
|
||||
"run_as_user": null,
|
||||
"reload": true
|
||||
},
|
||||
"utils": {},
|
||||
"weather": {
|
||||
|
||||
@@ -12,6 +12,8 @@
|
||||
| eosdash_supervise_interval_sec | `EOS_SERVER__EOSDASH_SUPERVISE_INTERVAL_SEC` | `int` | `rw` | `10` | Supervision interval for EOS server to supervise EOSdash [seconds]. |
|
||||
| host | `EOS_SERVER__HOST` | `Optional[str]` | `rw` | `127.0.0.1` | EOS server IP address. Defaults to 127.0.0.1. |
|
||||
| port | `EOS_SERVER__PORT` | `Optional[int]` | `rw` | `8503` | EOS server IP port number. Defaults to 8503. |
|
||||
| reload | `EOS_SERVER__RELOAD` | `Optional[bool]` | `rw` | `False` | Enable server auto-reload for debugging or development. Default is False. Monitors the package directory for changes and reloads the server. |
|
||||
| run_as_user | `EOS_SERVER__RUN_AS_USER` | `Optional[str]` | `rw` | `None` | The name of the target user to switch to. If ``None`` (default), the current effective user is used and no privilege change is attempted. |
|
||||
| startup_eosdash | `EOS_SERVER__STARTUP_EOSDASH` | `Optional[bool]` | `rw` | `True` | EOS server to start EOSdash server. Defaults to True. |
|
||||
| verbose | `EOS_SERVER__VERBOSE` | `Optional[bool]` | `rw` | `False` | Enable debug output |
|
||||
:::
|
||||
@@ -31,7 +33,9 @@
|
||||
"startup_eosdash": true,
|
||||
"eosdash_host": "127.0.0.1",
|
||||
"eosdash_port": 8504,
|
||||
"eosdash_supervise_interval_sec": 10
|
||||
"eosdash_supervise_interval_sec": 10,
|
||||
"run_as_user": null,
|
||||
"reload": true
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Akkudoktor-EOS
|
||||
|
||||
**Version**: `v0.2.0.dev2603071785688456`
|
||||
**Version**: `v0.2.0.dev2603110720349451`
|
||||
|
||||
<!-- 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.
|
||||
@@ -876,14 +876,6 @@ Merge the measurement data given as dataframe into EOS measurements.
|
||||
|
||||
Fastapi Measurement Keys Get
|
||||
|
||||
<!-- pyml disable line-length -->
|
||||
```python
|
||||
"""
|
||||
Get a list of available measurement keys.
|
||||
"""
|
||||
```
|
||||
<!-- pyml enable line-length -->
|
||||
|
||||
**Responses**:
|
||||
|
||||
- **200**: Successful Response
|
||||
|
||||
@@ -123,10 +123,12 @@ Configuration options:
|
||||
|
||||
- `ElecPriceAkkudoktor`: Retrieves from Akkudoktor.net.
|
||||
- `ElecPriceEnergyCharts`: Retrieves from Energy-Charts.info.
|
||||
- `ElecPriceFixed`: Caluclates from configured time window prices.
|
||||
- `ElecPriceImport`: Imports from a file or JSON string.
|
||||
|
||||
- `charges_kwh`: Electricity price charges (€/kWh).
|
||||
- `vat_rate`: VAT rate factor applied to electricity price when charges are used (default: 1.19).
|
||||
- `elecpricefixed.time_windows.windows`: The time windows with associated electricity prices.
|
||||
- `elecpriceimport.import_file_path`: Path to the file to import electricity price forecast data from.
|
||||
- `elecpriceimport.import_json`: JSON string, dictionary of electricity price forecast value lists.
|
||||
- `energycharts.bidding_zone`: Bidding zone Energy Charts shall provide price data for.
|
||||
@@ -142,12 +144,12 @@ option are added.
|
||||
### ElecPriceEnergyCharts Provider
|
||||
|
||||
The `ElecPriceEnergyCharts` provider retrieves day-ahead electricity market prices from
|
||||
[Energy-Charts.info](https://www.Energy-Charts.info). It supports both short-term and extended forecasting by combining
|
||||
real-time market data with historical price trends.
|
||||
[Energy-Charts.info](https://www.Energy-Charts.info). It supports both short-term and extended
|
||||
forecasting by combining real-time market data with historical price trends.
|
||||
|
||||
- For the next 24 hours, market prices are fetched directly from Energy-Charts.info.
|
||||
- For periods beyond 24 hours, prices are estimated using extrapolation based on historical data and the latest
|
||||
available market values.
|
||||
- For periods beyond 24 hours, prices are estimated using extrapolation based on historical data
|
||||
and the latest available market values.
|
||||
|
||||
Charges and VAT
|
||||
|
||||
@@ -157,6 +159,11 @@ Charges and VAT
|
||||
|
||||
**Note:** For the most accurate forecasts, it is recommended to set the `historic_hours` parameter to 840.
|
||||
|
||||
### ElecPriceFixed Provider
|
||||
|
||||
The `ElecPriceFixed` provider calculates the day-ahead electricity market prices from the configuration
|
||||
of electricity price time windows set up by the user.
|
||||
|
||||
### ElecPriceImport Provider
|
||||
|
||||
The `ElecPriceImport` provider is designed to import electricity prices from a file or a JSON
|
||||
|
||||
395
openapi.json
395
openapi.json
@@ -8,7 +8,7 @@
|
||||
"name": "Apache 2.0",
|
||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||
},
|
||||
"version": "v0.2.0.dev2603071785688456"
|
||||
"version": "v0.2.0.dev2603110720349451"
|
||||
},
|
||||
"paths": {
|
||||
"/v1/admin/cache/clear": {
|
||||
@@ -929,7 +929,6 @@
|
||||
"measurement"
|
||||
],
|
||||
"summary": "Fastapi Measurement Keys Get",
|
||||
"description": "Get a list of available measurement keys.",
|
||||
"operationId": "fastapi_measurement_keys_get_v1_measurement_keys_get",
|
||||
"responses": {
|
||||
"200": {
|
||||
@@ -3371,6 +3370,10 @@
|
||||
1.19
|
||||
]
|
||||
},
|
||||
"elecpricefixed": {
|
||||
"$ref": "#/components/schemas/ElecPriceFixedCommonSettings-Input",
|
||||
"description": "Fixed electricity price provider settings."
|
||||
},
|
||||
"elecpriceimport": {
|
||||
"$ref": "#/components/schemas/ElecPriceImportCommonSettings",
|
||||
"description": "Import provider settings."
|
||||
@@ -3434,6 +3437,10 @@
|
||||
1.19
|
||||
]
|
||||
},
|
||||
"elecpricefixed": {
|
||||
"$ref": "#/components/schemas/ElecPriceFixedCommonSettings-Output",
|
||||
"description": "Fixed electricity price provider settings."
|
||||
},
|
||||
"elecpriceimport": {
|
||||
"$ref": "#/components/schemas/ElecPriceImportCommonSettings",
|
||||
"description": "Import provider settings."
|
||||
@@ -3474,6 +3481,60 @@
|
||||
"title": "ElecPriceEnergyChartsCommonSettings",
|
||||
"description": "Common settings for Energy Charts electricity price provider."
|
||||
},
|
||||
"ElecPriceFixedCommonSettings-Input": {
|
||||
"properties": {
|
||||
"time_windows": {
|
||||
"$ref": "#/components/schemas/ValueTimeWindowSequence-Input",
|
||||
"description": "Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied.",
|
||||
"examples": [
|
||||
{
|
||||
"windows": [
|
||||
{
|
||||
"duration": "8 hours",
|
||||
"start_time": "00:00",
|
||||
"value": 0.288
|
||||
},
|
||||
{
|
||||
"duration": "16 hours",
|
||||
"start_time": "08:00",
|
||||
"value": 0.34
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "ElecPriceFixedCommonSettings",
|
||||
"description": "Common configuration settings for fixed electricity pricing.\n\nThis model defines a fixed electricity price schedule using a sequence\nof time windows. Each window specifies a time interval and the electricity\nprice applicable during that interval."
|
||||
},
|
||||
"ElecPriceFixedCommonSettings-Output": {
|
||||
"properties": {
|
||||
"time_windows": {
|
||||
"$ref": "#/components/schemas/ValueTimeWindowSequence-Output",
|
||||
"description": "Sequence of time windows defining the fixed price schedule. If not provided, no fixed pricing is applied.",
|
||||
"examples": [
|
||||
{
|
||||
"windows": [
|
||||
{
|
||||
"duration": "8 hours",
|
||||
"start_time": "00:00",
|
||||
"value": 0.288
|
||||
},
|
||||
{
|
||||
"duration": "16 hours",
|
||||
"start_time": "08:00",
|
||||
"value": 0.34
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "ElecPriceFixedCommonSettings",
|
||||
"description": "Common configuration settings for fixed electricity pricing.\n\nThis model defines a fixed electricity price schedule using a sequence\nof time windows. Each window specifies a time interval and the electricity\nprice applicable during that interval."
|
||||
},
|
||||
"ElecPriceImportCommonSettings": {
|
||||
"properties": {
|
||||
"import_file_path": {
|
||||
@@ -8369,6 +8430,38 @@
|
||||
"examples": [
|
||||
10
|
||||
]
|
||||
},
|
||||
"run_as_user": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Run As User",
|
||||
"description": "The name of the target user to switch to. If ``None`` (default), the current effective user is used and no privilege change is attempted.",
|
||||
"examples": [
|
||||
null,
|
||||
"user"
|
||||
]
|
||||
},
|
||||
"reload": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "boolean"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Reload",
|
||||
"description": "Enable server auto-reload for debugging or development. Default is False. Monitors the package directory for changes and reloads the server.",
|
||||
"default": false,
|
||||
"examples": [
|
||||
true
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -8704,13 +8797,19 @@
|
||||
"properties": {
|
||||
"start_time": {
|
||||
"title": "Start Time",
|
||||
"description": "Start time of the time window (time of day)."
|
||||
"description": "Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time().",
|
||||
"examples": [
|
||||
"00:00:00"
|
||||
]
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"format": "duration",
|
||||
"title": "Duration",
|
||||
"description": "Duration of the time window starting from `start_time`."
|
||||
"description": "Duration of the time window starting from `start_time`.",
|
||||
"examples": [
|
||||
"2 hours"
|
||||
]
|
||||
},
|
||||
"day_of_week": {
|
||||
"anyOf": [
|
||||
@@ -8725,7 +8824,10 @@
|
||||
}
|
||||
],
|
||||
"title": "Day Of Week",
|
||||
"description": "Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set."
|
||||
"description": "Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"date": {
|
||||
"anyOf": [
|
||||
@@ -8738,7 +8840,10 @@
|
||||
}
|
||||
],
|
||||
"title": "Date",
|
||||
"description": "Optional specific calendar date for the time window. Overrides `day_of_week` if set."
|
||||
"description": "Optional specific calendar date for the time window. Naive \u2014 matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"locale": {
|
||||
"anyOf": [
|
||||
@@ -8750,7 +8855,10 @@
|
||||
}
|
||||
],
|
||||
"title": "Locale",
|
||||
"description": "Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc."
|
||||
"description": "Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -8759,19 +8867,25 @@
|
||||
"duration"
|
||||
],
|
||||
"title": "TimeWindow",
|
||||
"description": "Model defining a daily or specific date time window with optional localization support.\n\nRepresents a time interval starting at `start_time` and lasting for `duration`.\nCan restrict applicability to a specific day of the week or a specific calendar date.\nSupports day names in multiple languages via locale-aware parsing."
|
||||
"description": "Model defining a daily or date time window with optional localization support.\n\nRepresents a time interval starting at `start_time` and lasting for `duration`.\nCan restrict applicability to a specific day of the week or a specific calendar date.\nSupports day names in multiple languages via locale-aware parsing.\n\nTimezone contract:\n\n``start_time`` is always **naive** (no ``tzinfo``). It is interpreted as a\nlocal wall-clock time in whatever timezone the caller's ``date_time`` or\n``reference_date`` carries. When those arguments are timezone-aware the\nwindow boundaries are evaluated in that timezone; when they are naive,\narithmetic is performed as-is (no timezone conversion occurs).\n\n``date``, being a calendar ``Date`` object, is inherently timezone-free.\n\nThis design avoids the ambiguity that arises when a stored ``start_time``\ncarries its own timezone that differs from the caller's timezone, and keeps\nthe model serialisable without timezone state."
|
||||
},
|
||||
"TimeWindow-Output": {
|
||||
"properties": {
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"title": "Start Time",
|
||||
"description": "Start time of the time window (time of day)."
|
||||
"description": "Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time().",
|
||||
"examples": [
|
||||
"00:00:00"
|
||||
]
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"title": "Duration",
|
||||
"description": "Duration of the time window starting from `start_time`."
|
||||
"description": "Duration of the time window starting from `start_time`.",
|
||||
"examples": [
|
||||
"2 hours"
|
||||
]
|
||||
},
|
||||
"day_of_week": {
|
||||
"anyOf": [
|
||||
@@ -8786,7 +8900,10 @@
|
||||
}
|
||||
],
|
||||
"title": "Day Of Week",
|
||||
"description": "Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set."
|
||||
"description": "Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"date": {
|
||||
"anyOf": [
|
||||
@@ -8799,7 +8916,10 @@
|
||||
}
|
||||
],
|
||||
"title": "Date",
|
||||
"description": "Optional specific calendar date for the time window. Overrides `day_of_week` if set."
|
||||
"description": "Optional specific calendar date for the time window. Naive \u2014 matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"locale": {
|
||||
"anyOf": [
|
||||
@@ -8811,7 +8931,10 @@
|
||||
}
|
||||
],
|
||||
"title": "Locale",
|
||||
"description": "Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc."
|
||||
"description": "Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
@@ -8820,22 +8943,15 @@
|
||||
"duration"
|
||||
],
|
||||
"title": "TimeWindow",
|
||||
"description": "Model defining a daily or specific date time window with optional localization support.\n\nRepresents a time interval starting at `start_time` and lasting for `duration`.\nCan restrict applicability to a specific day of the week or a specific calendar date.\nSupports day names in multiple languages via locale-aware parsing."
|
||||
"description": "Model defining a daily or date time window with optional localization support.\n\nRepresents a time interval starting at `start_time` and lasting for `duration`.\nCan restrict applicability to a specific day of the week or a specific calendar date.\nSupports day names in multiple languages via locale-aware parsing.\n\nTimezone contract:\n\n``start_time`` is always **naive** (no ``tzinfo``). It is interpreted as a\nlocal wall-clock time in whatever timezone the caller's ``date_time`` or\n``reference_date`` carries. When those arguments are timezone-aware the\nwindow boundaries are evaluated in that timezone; when they are naive,\narithmetic is performed as-is (no timezone conversion occurs).\n\n``date``, being a calendar ``Date`` object, is inherently timezone-free.\n\nThis design avoids the ambiguity that arises when a stored ``start_time``\ncarries its own timezone that differs from the caller's timezone, and keeps\nthe model serialisable without timezone state."
|
||||
},
|
||||
"TimeWindowSequence-Input": {
|
||||
"properties": {
|
||||
"windows": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TimeWindow-Input"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TimeWindow-Input"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Windows",
|
||||
"description": "List of TimeWindow objects that make up this sequence."
|
||||
}
|
||||
@@ -8847,17 +8963,10 @@
|
||||
"TimeWindowSequence-Output": {
|
||||
"properties": {
|
||||
"windows": {
|
||||
"anyOf": [
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TimeWindow-Output"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/TimeWindow-Output"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Windows",
|
||||
"description": "List of TimeWindow objects that make up this sequence."
|
||||
}
|
||||
@@ -8912,6 +9021,220 @@
|
||||
],
|
||||
"title": "ValidationError"
|
||||
},
|
||||
"ValueTimeWindow-Input": {
|
||||
"properties": {
|
||||
"start_time": {
|
||||
"title": "Start Time",
|
||||
"description": "Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time().",
|
||||
"examples": [
|
||||
"00:00:00"
|
||||
]
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"format": "duration",
|
||||
"title": "Duration",
|
||||
"description": "Duration of the time window starting from `start_time`.",
|
||||
"examples": [
|
||||
"2 hours"
|
||||
]
|
||||
},
|
||||
"day_of_week": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Day Of Week",
|
||||
"description": "Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"date": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Date",
|
||||
"description": "Optional specific calendar date for the time window. Naive \u2014 matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"locale": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Locale",
|
||||
"description": "Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 0.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Value",
|
||||
"description": "Value applicable during this time window.",
|
||||
"examples": [
|
||||
0.288
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"start_time",
|
||||
"duration"
|
||||
],
|
||||
"title": "ValueTimeWindow",
|
||||
"description": "Value applicable during a specific time window.\n\nThis model extends `TimeWindow` by associating a value with the defined time interval."
|
||||
},
|
||||
"ValueTimeWindow-Output": {
|
||||
"properties": {
|
||||
"start_time": {
|
||||
"type": "string",
|
||||
"title": "Start Time",
|
||||
"description": "Naive start time of the time window (time of day, no timezone). Interpreted in the timezone of the datetime passed to contains() or earliest_start_time().",
|
||||
"examples": [
|
||||
"00:00:00"
|
||||
]
|
||||
},
|
||||
"duration": {
|
||||
"type": "string",
|
||||
"title": "Duration",
|
||||
"description": "Duration of the time window starting from `start_time`.",
|
||||
"examples": [
|
||||
"2 hours"
|
||||
]
|
||||
},
|
||||
"day_of_week": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "integer"
|
||||
},
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Day Of Week",
|
||||
"description": "Optional day of the week restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"date": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string",
|
||||
"format": "date"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Date",
|
||||
"description": "Optional specific calendar date for the time window. Naive \u2014 matched against the local date of the datetime passed to contains(). Overrides `day_of_week` if set.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"locale": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Locale",
|
||||
"description": "Locale used to parse weekday names in `day_of_week` when given as string. If not set, Pendulum's default locale is used. Examples: 'en', 'de', 'fr', etc.",
|
||||
"examples": [
|
||||
null
|
||||
]
|
||||
},
|
||||
"value": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "number",
|
||||
"minimum": 0.0
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Value",
|
||||
"description": "Value applicable during this time window.",
|
||||
"examples": [
|
||||
0.288
|
||||
]
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"required": [
|
||||
"start_time",
|
||||
"duration"
|
||||
],
|
||||
"title": "ValueTimeWindow",
|
||||
"description": "Value applicable during a specific time window.\n\nThis model extends `TimeWindow` by associating a value with the defined time interval."
|
||||
},
|
||||
"ValueTimeWindowSequence-Input": {
|
||||
"properties": {
|
||||
"windows": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValueTimeWindow-Input"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Windows",
|
||||
"description": "Ordered list of value time windows. Each window defines a time interval and an associated value."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "ValueTimeWindowSequence",
|
||||
"description": "Sequence of value time windows.\n\nThis model specializes `TimeWindowSequence` to ensure that all\ncontained windows are instances of `ValueTimeWindow`.\nIt provides the full set of sequence operations (containment checks,\navailability, start time calculations) for value windows."
|
||||
},
|
||||
"ValueTimeWindowSequence-Output": {
|
||||
"properties": {
|
||||
"windows": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/ValueTimeWindow-Output"
|
||||
},
|
||||
"type": "array",
|
||||
"title": "Windows",
|
||||
"description": "Ordered list of value time windows. Each window defines a time interval and an associated value."
|
||||
}
|
||||
},
|
||||
"type": "object",
|
||||
"title": "ValueTimeWindowSequence",
|
||||
"description": "Sequence of value time windows.\n\nThis model specializes `TimeWindowSequence` to ensure that all\ncontained windows are instances of `ValueTimeWindow`.\nIt provides the full set of sequence operations (containment checks,\navailability, start time calculations) for value windows."
|
||||
},
|
||||
"WeatherCommonProviderSettings": {
|
||||
"properties": {
|
||||
"WeatherImport": {
|
||||
|
||||
@@ -32,6 +32,7 @@ from akkudoktoreos.core.decorators import classproperty
|
||||
from akkudoktoreos.core.emsettings import (
|
||||
EnergyManagementCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
from akkudoktoreos.core.logsettings import LoggingCommonSettings
|
||||
from akkudoktoreos.core.pydantic import PydanticModelNestedValueMixin, merge_models
|
||||
from akkudoktoreos.core.version import __version__
|
||||
@@ -44,6 +45,7 @@ from akkudoktoreos.prediction.load import LoadCommonSettings
|
||||
from akkudoktoreos.prediction.prediction import PredictionCommonSettings
|
||||
from akkudoktoreos.prediction.pvforecast import PVForecastCommonSettings
|
||||
from akkudoktoreos.prediction.weather import WeatherCommonSettings
|
||||
from akkudoktoreos.server.rest.cli import cli_argument_parser
|
||||
from akkudoktoreos.server.server import ServerCommonSettings
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_timezone
|
||||
from akkudoktoreos.utils.utils import UtilsCommonSettings
|
||||
@@ -421,6 +423,62 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
- It ensures that a fallback to a default configuration file is always possible.
|
||||
"""
|
||||
|
||||
def lazy_config_cli_settings() -> dict:
|
||||
"""CLI settings.
|
||||
|
||||
This function runs at **instance creation**, not class definition. Ensures if ConfigEOS
|
||||
is recreated this function is run.
|
||||
"""
|
||||
args, args_unknown = cli_argument_parser().parse_known_args() # defaults to sys.ARGV
|
||||
|
||||
# Initialize nested settings dictionary
|
||||
settings: dict[str, Any] = {}
|
||||
|
||||
# Helper function to set nested dictionary values
|
||||
def set_nested(dict_obj: dict[str, Any], path: str, value: Any) -> None:
|
||||
"""Set a value in a nested dictionary using dot notation path."""
|
||||
parts = path.split(".")
|
||||
current = dict_obj
|
||||
for part in parts[:-1]:
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
current[parts[-1]] = value
|
||||
|
||||
# Server host
|
||||
if args.host is not None:
|
||||
set_nested(settings, "server.host", args.host)
|
||||
logger.debug(f"CLI arg: server.host set to {args.host}")
|
||||
|
||||
# Server port
|
||||
if args.port is not None:
|
||||
set_nested(settings, "server.port", args.port)
|
||||
logger.debug(f"CLI arg: server.port set to {args.port}")
|
||||
|
||||
# Server startup_eosdash
|
||||
if args.startup_eosdash is not None:
|
||||
set_nested(settings, "server.startup_eosdash", args.startup_eosdash)
|
||||
logger.debug(f"CLI arg: server.startup_eosdash set to {args.startup_eosdash}")
|
||||
|
||||
# Logging level (skip if "none" as that means don't change)
|
||||
if args.log_level is not None and args.log_level.lower() != "none":
|
||||
log_level = args.log_level.upper()
|
||||
if log_level in LOGGING_LEVELS:
|
||||
set_nested(settings, "logging.console_level", log_level)
|
||||
logger.debug(f"CLI arg: logging.console_level set to {log_level}")
|
||||
else:
|
||||
logger.warning(f"Invalid log level '{args.log_level}' ignored")
|
||||
|
||||
if args.run_as_user is not None:
|
||||
set_nested(settings, "server.run_as_user", args.run_as_user)
|
||||
logger.debug(f"CLI arg: server.run_as_user set to {args.run_as_user}")
|
||||
|
||||
if args.reload is not None:
|
||||
set_nested(settings, "server.reload", args.reload)
|
||||
logger.debug(f"CLI arg: server.reload set to {args.reload}")
|
||||
|
||||
return settings
|
||||
|
||||
def lazy_config_file_settings() -> dict:
|
||||
"""Config file settings.
|
||||
|
||||
@@ -561,7 +619,8 @@ class ConfigEOS(SingletonMixin, SettingsEOSDefaults):
|
||||
# The settings are all lazyly evaluated at instance creation time to allow for
|
||||
# runtime configuration.
|
||||
setting_sources = [
|
||||
lazy_config_file_settings, # Prio high
|
||||
lazy_config_cli_settings, # Prio high
|
||||
lazy_config_file_settings,
|
||||
lazy_init_settings,
|
||||
lazy_env_settings,
|
||||
lazy_dotenv_settings,
|
||||
|
||||
@@ -1,8 +1,21 @@
|
||||
"""Abstract and base classes for configuration."""
|
||||
|
||||
from typing import Any, ClassVar
|
||||
import calendar
|
||||
from typing import Any, ClassVar, Iterator, Optional, Union
|
||||
|
||||
import numpy as np
|
||||
import pendulum
|
||||
from babel.dates import get_day_names
|
||||
from pydantic import Field, field_serializer, field_validator, model_validator
|
||||
|
||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
Date,
|
||||
DateTime,
|
||||
Duration,
|
||||
Time,
|
||||
to_duration,
|
||||
)
|
||||
|
||||
|
||||
class SettingsBaseModel(PydanticBaseModel):
|
||||
@@ -10,3 +23,801 @@ class SettingsBaseModel(PydanticBaseModel):
|
||||
|
||||
# EOS configuration - set by ConfigEOS
|
||||
config: ClassVar[Any] = None
|
||||
|
||||
|
||||
class TimeWindow(SettingsBaseModel):
|
||||
"""Model defining a daily or date time window with optional localization support.
|
||||
|
||||
Represents a time interval starting at `start_time` and lasting for `duration`.
|
||||
Can restrict applicability to a specific day of the week or a specific calendar date.
|
||||
Supports day names in multiple languages via locale-aware parsing.
|
||||
|
||||
Timezone contract:
|
||||
|
||||
``start_time`` is always **naive** (no ``tzinfo``). It is interpreted as a
|
||||
local wall-clock time in whatever timezone the caller's ``date_time`` or
|
||||
``reference_date`` carries. When those arguments are timezone-aware the
|
||||
window boundaries are evaluated in that timezone; when they are naive,
|
||||
arithmetic is performed as-is (no timezone conversion occurs).
|
||||
|
||||
``date``, being a calendar ``Date`` object, is inherently timezone-free.
|
||||
|
||||
This design avoids the ambiguity that arises when a stored ``start_time``
|
||||
carries its own timezone that differs from the caller's timezone, and keeps
|
||||
the model serialisable without timezone state.
|
||||
"""
|
||||
|
||||
start_time: Time = Field(
|
||||
...,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Naive start time of the time window (time of day, no timezone). "
|
||||
"Interpreted in the timezone of the datetime passed to contains() "
|
||||
"or earliest_start_time()."
|
||||
),
|
||||
"examples": [
|
||||
"00:00:00",
|
||||
],
|
||||
},
|
||||
)
|
||||
duration: Duration = Field(
|
||||
...,
|
||||
json_schema_extra={
|
||||
"description": "Duration of the time window starting from `start_time`.",
|
||||
"examples": [
|
||||
"2 hours",
|
||||
],
|
||||
},
|
||||
)
|
||||
day_of_week: Optional[Union[int, str]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Optional day of the week restriction. "
|
||||
"Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. "
|
||||
"If None, applies every day unless `date` is set."
|
||||
),
|
||||
"examples": [
|
||||
None,
|
||||
],
|
||||
},
|
||||
)
|
||||
date: Optional[Date] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Optional specific calendar date for the time window. "
|
||||
"Naive — matched against the local date of the datetime passed to contains(). "
|
||||
"Overrides `day_of_week` if set."
|
||||
),
|
||||
"examples": [
|
||||
None,
|
||||
],
|
||||
},
|
||||
)
|
||||
locale: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Locale used to parse weekday names in `day_of_week` when given as string. "
|
||||
"If not set, Pendulum's default locale is used. "
|
||||
"Examples: 'en', 'de', 'fr', etc."
|
||||
),
|
||||
"examples": [
|
||||
None,
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("start_time", mode="after")
|
||||
@classmethod
|
||||
def require_naive_start_time(cls, value: Time) -> Time:
|
||||
"""Strip timezone from ``start_time`` if present, emitting a debug message.
|
||||
|
||||
``start_time`` must be naive: it is interpreted as wall-clock time in
|
||||
the timezone of the ``date_time`` / ``reference_date`` supplied at call
|
||||
time. The project's ``to_time`` helper may silently attach a timezone
|
||||
during deserialisation; rather than rejecting such values the validator
|
||||
strips the timezone and logs a debug message so the behaviour is
|
||||
transparent without breaking normal construction.
|
||||
|
||||
Args:
|
||||
value: The ``Time`` value to validate.
|
||||
|
||||
Returns:
|
||||
A naive ``Time`` with the same hour / minute / second / microsecond
|
||||
but no ``tzinfo``.
|
||||
"""
|
||||
if value.tzinfo is not None:
|
||||
import logging
|
||||
|
||||
logging.getLogger(__name__).debug(
|
||||
"TimeWindow.start_time received an aware Time (%s); "
|
||||
"stripping timezone '%s'. start_time is always interpreted "
|
||||
"as wall-clock time in the timezone of the datetime passed "
|
||||
"to contains() / earliest_start_time() / latest_start_time().",
|
||||
value,
|
||||
value.tzinfo,
|
||||
)
|
||||
value = value.replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
@field_validator("duration", mode="before")
|
||||
@classmethod
|
||||
def transform_to_duration(cls, value: Any) -> Duration:
|
||||
"""Converts various duration formats into Duration.
|
||||
|
||||
Args:
|
||||
value: The value to convert to Duration.
|
||||
|
||||
Returns:
|
||||
Duration: The converted Duration object.
|
||||
"""
|
||||
return to_duration(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_day_of_week_with_locale(self) -> "TimeWindow":
|
||||
"""Validates and normalizes the `day_of_week` field using the specified locale.
|
||||
|
||||
This method supports both integer (0–6) and string inputs for ``day_of_week``.
|
||||
String inputs are matched first against English weekday names (case-insensitive),
|
||||
and then against localized weekday names using the provided ``locale``.
|
||||
|
||||
If a valid match is found, ``day_of_week`` is converted to its corresponding
|
||||
integer value (0 for Monday through 6 for Sunday).
|
||||
|
||||
Returns:
|
||||
TimeWindow: The validated instance with ``day_of_week`` normalized to an integer.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``day_of_week`` is an invalid integer (not in 0–6),
|
||||
or an unrecognized string (not matching English or localized names),
|
||||
or of an unsupported type.
|
||||
"""
|
||||
if self.day_of_week is None:
|
||||
return self
|
||||
|
||||
if isinstance(self.day_of_week, int):
|
||||
if not 0 <= self.day_of_week <= 6:
|
||||
raise ValueError("day_of_week must be in 0 (Monday) to 6 (Sunday)")
|
||||
return self
|
||||
|
||||
if isinstance(self.day_of_week, str):
|
||||
# Try matching against English names first (lowercase)
|
||||
english_days = {name.lower(): i for i, name in enumerate(calendar.day_name)}
|
||||
lowercase_value = self.day_of_week.lower()
|
||||
if lowercase_value in english_days:
|
||||
self.day_of_week = english_days[lowercase_value]
|
||||
return self
|
||||
|
||||
# Try localized names
|
||||
if self.locale:
|
||||
localized_days = {
|
||||
get_day_names("wide", locale=self.locale)[i].lower(): i for i in range(7)
|
||||
}
|
||||
if lowercase_value in localized_days:
|
||||
self.day_of_week = localized_days[lowercase_value]
|
||||
return self
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid weekday name '{self.day_of_week}' for locale '{self.locale}'. "
|
||||
f"Expected English names (monday–sunday) or localized names."
|
||||
)
|
||||
|
||||
raise ValueError(f"Invalid type for day_of_week: {type(self.day_of_week)}")
|
||||
|
||||
@field_serializer("duration")
|
||||
def serialize_duration(self, value: Duration) -> str:
|
||||
"""Serialize duration to string."""
|
||||
return str(value)
|
||||
|
||||
def _window_start_end(self, reference_date: DateTime) -> tuple[DateTime, DateTime]:
|
||||
"""Get the actual start and end datetimes for the time window on a given date.
|
||||
|
||||
``start_time`` is naive and is interpreted as a wall-clock time in
|
||||
the timezone of ``reference_date``. When ``reference_date`` is
|
||||
timezone-aware the resulting window boundaries carry the same timezone;
|
||||
when it is naive the arithmetic is performed without timezone conversion.
|
||||
|
||||
Args:
|
||||
reference_date: The reference date on which to calculate the window.
|
||||
May be timezone-aware or naive.
|
||||
|
||||
Returns:
|
||||
tuple[DateTime, DateTime]: Start and end datetimes for the time window,
|
||||
in the same timezone as ``reference_date``.
|
||||
"""
|
||||
# start_time is always naive: just replace the time components on
|
||||
# reference_date directly. The result inherits reference_date's timezone
|
||||
# (or lack thereof) automatically.
|
||||
start = reference_date.replace(
|
||||
hour=self.start_time.hour,
|
||||
minute=self.start_time.minute,
|
||||
second=self.start_time.second,
|
||||
microsecond=self.start_time.microsecond,
|
||||
)
|
||||
end = start + self.duration
|
||||
return start, end
|
||||
|
||||
def contains(self, date_time: DateTime, duration: Optional[Duration] = None) -> bool:
|
||||
"""Check whether a datetime (and optional duration) fits within the time window.
|
||||
|
||||
``start_time`` is naive and is interpreted as wall-clock time in the
|
||||
timezone of ``date_time``. Day-of-week and date constraints are
|
||||
evaluated against ``date_time`` after any timezone conversion has
|
||||
been applied.
|
||||
|
||||
Args:
|
||||
date_time: The datetime to test. May be timezone-aware or naive.
|
||||
duration: An optional duration that must fit entirely within the
|
||||
time window starting from ``date_time``.
|
||||
|
||||
Returns:
|
||||
bool: True if the datetime (and optional duration) is fully
|
||||
contained in the time window, False otherwise.
|
||||
"""
|
||||
# Date and weekday constraints are checked against date_time as-is;
|
||||
# since start_time is naive it is always interpreted in date_time's tz.
|
||||
if self.date and date_time.date() != self.date:
|
||||
return False
|
||||
|
||||
if self.day_of_week is not None and date_time.day_of_week != self.day_of_week:
|
||||
return False
|
||||
|
||||
start, end = self._window_start_end(date_time)
|
||||
|
||||
if not (start <= date_time < end):
|
||||
return False
|
||||
|
||||
if duration is not None:
|
||||
return date_time + duration <= end
|
||||
|
||||
return True
|
||||
|
||||
def earliest_start_time(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> Optional[DateTime]:
|
||||
"""Get the earliest datetime that allows a duration to fit within the time window.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit within the window.
|
||||
reference_date: The date to check for the time window. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The earliest start time for the duration, or None if it doesn't fit.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
if self.date and reference_date.date() != self.date:
|
||||
return None
|
||||
|
||||
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
|
||||
return None
|
||||
|
||||
if duration > self.duration:
|
||||
return None
|
||||
|
||||
window_start, _ = self._window_start_end(reference_date)
|
||||
return window_start
|
||||
|
||||
def latest_start_time(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> Optional[DateTime]:
|
||||
"""Get the latest datetime that allows a duration to fit within the time window.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit within the window.
|
||||
reference_date: The date to check for the time window. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The latest start time for the duration, or None if it doesn't fit.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
if self.date and reference_date.date() != self.date:
|
||||
return None
|
||||
|
||||
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
|
||||
return None
|
||||
|
||||
if duration > self.duration:
|
||||
return None
|
||||
|
||||
window_start, window_end = self._window_start_end(reference_date)
|
||||
latest_start = window_end - duration
|
||||
|
||||
if latest_start < window_start:
|
||||
return None
|
||||
|
||||
return latest_start
|
||||
|
||||
def can_fit_duration(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> bool:
|
||||
"""Check if a duration can fit within the time window on a given date.
|
||||
|
||||
Args:
|
||||
duration: The duration to check.
|
||||
reference_date: The date to check for the time window. Defaults to today.
|
||||
|
||||
Returns:
|
||||
bool: True if the duration can fit, False otherwise.
|
||||
"""
|
||||
return self.earliest_start_time(duration, reference_date) is not None
|
||||
|
||||
def available_duration(self, reference_date: Optional[DateTime] = None) -> Optional[Duration]:
|
||||
"""Get the total available duration for the time window on a given date.
|
||||
|
||||
Args:
|
||||
reference_date: The date to check for the time window. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The available duration, or None if the date doesn't match constraints.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
if self.date and reference_date.date() != self.date:
|
||||
return None
|
||||
|
||||
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
|
||||
return None
|
||||
|
||||
return self.duration
|
||||
|
||||
|
||||
class TimeWindowSequence(SettingsBaseModel):
|
||||
"""Model representing a sequence of time windows with collective operations.
|
||||
|
||||
Manages multiple TimeWindow objects and provides methods to work with them
|
||||
as a cohesive unit for scheduling and availability checking.
|
||||
"""
|
||||
|
||||
windows: list[TimeWindow] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={"description": "List of TimeWindow objects that make up this sequence."},
|
||||
)
|
||||
|
||||
def __iter__(self) -> Iterator[TimeWindow]:
|
||||
"""Allow iteration over the time windows."""
|
||||
return iter(self.windows)
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of time windows in the sequence."""
|
||||
return len(self.windows)
|
||||
|
||||
def __getitem__(self, index: int) -> TimeWindow:
|
||||
"""Allow indexing into the time windows."""
|
||||
return self.windows[index]
|
||||
|
||||
def contains(self, date_time: DateTime, duration: Optional[Duration] = None) -> bool:
|
||||
"""Check if any time window in the sequence contains the given datetime and duration.
|
||||
|
||||
Args:
|
||||
date_time: The datetime to test.
|
||||
duration: An optional duration that must fit entirely within one of the time windows.
|
||||
|
||||
Returns:
|
||||
bool: True if any time window contains the datetime (and optional duration), False if no windows.
|
||||
"""
|
||||
return any(window.contains(date_time, duration) for window in self.windows)
|
||||
|
||||
def earliest_start_time(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> Optional[DateTime]:
|
||||
"""Get the earliest datetime across all windows that allows a duration to fit.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit within a window.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The earliest start time across all windows, or None if no window can fit the duration.
|
||||
"""
|
||||
if not self.windows:
|
||||
return None
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
earliest_times = [
|
||||
t
|
||||
for window in self.windows
|
||||
if (t := window.earliest_start_time(duration, reference_date)) is not None
|
||||
]
|
||||
return min(earliest_times) if earliest_times else None
|
||||
|
||||
def latest_start_time(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> Optional[DateTime]:
|
||||
"""Get the latest datetime across all windows that allows a duration to fit.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit within a window.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The latest start time across all windows, or None if no window can fit the duration.
|
||||
"""
|
||||
if not self.windows:
|
||||
return None
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
latest_times = [
|
||||
t
|
||||
for window in self.windows
|
||||
if (t := window.latest_start_time(duration, reference_date)) is not None
|
||||
]
|
||||
return max(latest_times) if latest_times else None
|
||||
|
||||
def can_fit_duration(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> bool:
|
||||
"""Check if the duration can fit within any time window in the sequence.
|
||||
|
||||
Args:
|
||||
duration: The duration to check.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
bool: True if any window can fit the duration, False if no windows.
|
||||
"""
|
||||
return any(window.can_fit_duration(duration, reference_date) for window in self.windows)
|
||||
|
||||
def available_duration(self, reference_date: Optional[DateTime] = None) -> Optional[Duration]:
|
||||
"""Get the total available duration across all applicable windows.
|
||||
|
||||
Args:
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The sum of available durations from all applicable windows, or None if no windows apply.
|
||||
"""
|
||||
if not self.windows:
|
||||
return None
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
durations = [
|
||||
d
|
||||
for window in self.windows
|
||||
if (d := window.available_duration(reference_date)) is not None
|
||||
]
|
||||
if not durations:
|
||||
return None
|
||||
total = Duration()
|
||||
for d in durations:
|
||||
total += d
|
||||
return total
|
||||
|
||||
def get_applicable_windows(self, reference_date: Optional[DateTime] = None) -> list[TimeWindow]:
|
||||
"""Get all windows that apply to the given reference date.
|
||||
|
||||
Args:
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
List of TimeWindow objects that apply to the reference date.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
return [
|
||||
window
|
||||
for window in self.windows
|
||||
if window.available_duration(reference_date) is not None
|
||||
]
|
||||
|
||||
def find_windows_for_duration(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> list[TimeWindow]:
|
||||
"""Find all windows that can accommodate the given duration.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
List of TimeWindow objects that can fit the duration.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
return [
|
||||
window for window in self.windows if window.can_fit_duration(duration, reference_date)
|
||||
]
|
||||
|
||||
def get_all_possible_start_times(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> list[tuple[DateTime, DateTime, TimeWindow]]:
|
||||
"""Get all possible start time ranges for a duration across all windows.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
List of tuples containing (earliest_start, latest_start, window) for each
|
||||
window that can accommodate the duration.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
result = []
|
||||
for window in self.windows:
|
||||
earliest = window.earliest_start_time(duration, reference_date)
|
||||
latest = window.latest_start_time(duration, reference_date)
|
||||
if earliest is not None and latest is not None:
|
||||
result.append((earliest, latest, window))
|
||||
return result
|
||||
|
||||
def to_array(
|
||||
self,
|
||||
start_datetime: DateTime,
|
||||
end_datetime: DateTime,
|
||||
interval: Duration,
|
||||
dropna: bool = True,
|
||||
boundary: str = "context",
|
||||
align_to_interval: bool = True,
|
||||
) -> np.ndarray:
|
||||
"""Return a 1-D NumPy array indicating window coverage over a time grid.
|
||||
|
||||
The time grid is constructed from ``start_datetime`` to ``end_datetime``
|
||||
(exclusive) in steps of ``interval``, matching the ``key_to_array``
|
||||
signature used by the prediction store. Each element is ``1.0`` when
|
||||
the corresponding step falls inside any window in this sequence, and
|
||||
``0.0`` otherwise.
|
||||
|
||||
Parameters mirror ``key_to_array`` so that ``to_array`` can be used as
|
||||
a drop-in source in the same contexts:
|
||||
|
||||
Args:
|
||||
start_datetime: First step of the time grid (inclusive).
|
||||
end_datetime: Upper bound of the time grid (exclusive).
|
||||
interval: Fixed step size between consecutive grid points.
|
||||
dropna: Unused for ``TimeWindowSequence`` (no NaN values are
|
||||
produced — every step is either ``0.0`` or ``1.0``). Accepted
|
||||
for signature compatibility.
|
||||
boundary: Controls range enforcement. Only ``"context"`` is
|
||||
currently supported; the output is always clipped to
|
||||
``[start_datetime, end_datetime)``.
|
||||
align_to_interval: When ``True``, ``start_datetime`` is floored to
|
||||
the nearest interval boundary in wall-clock time before
|
||||
generating the grid (e.g. 08:10 with a 1-hour interval becomes
|
||||
08:00). The timezone (or naivety) of ``start_datetime`` is
|
||||
preserved exactly — no UTC conversion is performed. When
|
||||
``False``, ``start_datetime`` is used as-is.
|
||||
|
||||
Returns:
|
||||
``np.ndarray`` of shape ``(n_steps,)`` with ``dtype=float64``.
|
||||
``1.0`` at position ``i`` means step ``i`` is inside a window;
|
||||
``0.0`` means it is not.
|
||||
|
||||
Raises:
|
||||
ValueError: If ``boundary`` is not ``"context"``.
|
||||
"""
|
||||
if boundary != "context":
|
||||
raise ValueError(f"Unsupported boundary {boundary!r}. Only 'context' is supported.")
|
||||
|
||||
interval_s = interval.total_seconds()
|
||||
|
||||
if align_to_interval and interval_s > 0:
|
||||
# Floor purely in wall-clock seconds so the timezone (or naivety)
|
||||
# of start_datetime is never touched and no UTC conversion occurs.
|
||||
# This is correct regardless of the machine's local timezone.
|
||||
wall_s = (
|
||||
start_datetime.hour * 3600
|
||||
+ start_datetime.minute * 60
|
||||
+ start_datetime.second
|
||||
+ start_datetime.microsecond / 1_000_000
|
||||
)
|
||||
remainder_s = wall_s % interval_s
|
||||
if remainder_s:
|
||||
start_datetime = start_datetime.subtract(seconds=remainder_s)
|
||||
|
||||
result = []
|
||||
current = start_datetime
|
||||
while current < end_datetime:
|
||||
result.append(1.0 if self.contains(current) else 0.0)
|
||||
current = current.add(seconds=interval_s)
|
||||
|
||||
return np.array(result, dtype=np.float64)
|
||||
|
||||
def add_window(self, window: TimeWindow) -> None:
|
||||
"""Add a new time window to the sequence.
|
||||
|
||||
Args:
|
||||
window: The TimeWindow to add.
|
||||
"""
|
||||
self.windows.append(window)
|
||||
|
||||
def remove_window(self, index: int) -> TimeWindow:
|
||||
"""Remove a time window from the sequence by index.
|
||||
|
||||
Args:
|
||||
index: The index of the window to remove.
|
||||
|
||||
Returns:
|
||||
The removed TimeWindow.
|
||||
|
||||
Raises:
|
||||
IndexError: If the index is out of range.
|
||||
"""
|
||||
if not self.windows:
|
||||
raise IndexError("pop from empty list")
|
||||
return self.windows.pop(index)
|
||||
|
||||
def clear_windows(self) -> None:
|
||||
"""Remove all windows from the sequence."""
|
||||
self.windows.clear()
|
||||
|
||||
def sort_windows_by_start_time(self, reference_date: Optional[DateTime] = None) -> None:
|
||||
"""Sort the windows by their start time on the given reference date.
|
||||
|
||||
Windows that don't apply to the reference date are placed at the end.
|
||||
|
||||
Args:
|
||||
reference_date: The date to use for sorting. Defaults to today.
|
||||
"""
|
||||
if not self.windows:
|
||||
return
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
def sort_key(window: TimeWindow) -> tuple[int, DateTime]:
|
||||
start_time = window.earliest_start_time(Duration(), reference_date)
|
||||
if start_time is None:
|
||||
return (1, reference_date)
|
||||
return (0, start_time)
|
||||
|
||||
self.windows.sort(key=sort_key)
|
||||
|
||||
|
||||
class ValueTimeWindow(TimeWindow):
|
||||
"""Value applicable during a specific time window.
|
||||
|
||||
This model extends `TimeWindow` by associating a value with the defined time interval.
|
||||
"""
|
||||
|
||||
value: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
json_schema_extra={
|
||||
"description": ("Value applicable during this time window."),
|
||||
"examples": [0.288],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ValueTimeWindowSequence(TimeWindowSequence):
|
||||
"""Sequence of value time windows.
|
||||
|
||||
This model specializes `TimeWindowSequence` to ensure that all
|
||||
contained windows are instances of `ValueTimeWindow`.
|
||||
It provides the full set of sequence operations (containment checks,
|
||||
availability, start time calculations) for value windows.
|
||||
"""
|
||||
|
||||
windows: list[ValueTimeWindow] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Ordered list of value time windows. "
|
||||
"Each window defines a time interval and an associated value."
|
||||
),
|
||||
},
|
||||
)
|
||||
|
||||
def get_value_for_datetime(self, dt: DateTime) -> float:
|
||||
"""Get value for a specific datetime.
|
||||
|
||||
Args:
|
||||
dt: Datetime to get value for.
|
||||
|
||||
Returns:
|
||||
float: value or 0.0 if no window matches.
|
||||
"""
|
||||
for window in self.windows:
|
||||
if window.contains(dt):
|
||||
return window.value or 0.0
|
||||
return 0.0
|
||||
|
||||
def to_array(
|
||||
self,
|
||||
start_datetime: DateTime,
|
||||
end_datetime: DateTime,
|
||||
interval: Duration,
|
||||
dropna: bool = True,
|
||||
boundary: str = "context",
|
||||
align_to_interval: bool = True,
|
||||
) -> np.ndarray:
|
||||
"""Return a 1-D NumPy array of window values over a time grid.
|
||||
|
||||
The time grid is constructed from ``start_datetime`` to ``end_datetime``
|
||||
(exclusive) in steps of ``interval``, matching the ``key_to_array``
|
||||
signature used by the prediction store. Each element holds the
|
||||
``value`` of the first matching window at that step, ``0.0`` when no
|
||||
window matches, or ``NaN`` when the matching window has ``value=None``
|
||||
and ``dropna=False``.
|
||||
|
||||
When ``dropna=True`` steps whose matching window has ``value=None`` are
|
||||
omitted from the output entirely (the array is shorter than the full
|
||||
grid), consistent with the ``key_to_array`` ``dropna`` contract.
|
||||
|
||||
Parameters mirror ``key_to_array`` so that ``to_array`` can be used as
|
||||
a drop-in source in the same contexts:
|
||||
|
||||
Args:
|
||||
start_datetime: First step of the time grid (inclusive).
|
||||
end_datetime: Upper bound of the time grid (exclusive).
|
||||
interval: Fixed step size between consecutive grid points.
|
||||
dropna: When ``True``, steps whose matching window carries
|
||||
``value=None`` are dropped from the output array. When
|
||||
``False``, those steps emit ``NaN``.
|
||||
boundary: Controls range enforcement. Only ``"context"`` is
|
||||
currently supported; the output is always clipped to
|
||||
``[start_datetime, end_datetime)``.
|
||||
align_to_interval: When ``True``, ``start_datetime`` is floored to
|
||||
the nearest interval boundary in wall-clock time before
|
||||
generating the grid (e.g. 08:10 with a 1-hour interval becomes
|
||||
08:00). The timezone (or naivety) of ``start_datetime`` is
|
||||
preserved exactly — no UTC conversion is performed. When
|
||||
``False``, ``start_datetime`` is used as-is.
|
||||
|
||||
Returns:
|
||||
``np.ndarray`` of shape ``(n_steps,)`` with ``dtype=float64``.
|
||||
Positive values are window values; ``0.0`` means no window matched;
|
||||
``NaN`` means a window matched but its value was ``None`` (only
|
||||
when ``dropna=False``).
|
||||
|
||||
Raises:
|
||||
ValueError: If ``boundary`` is not ``"context"``.
|
||||
"""
|
||||
if boundary != "context":
|
||||
raise ValueError(f"Unsupported boundary {boundary!r}. Only 'context' is supported.")
|
||||
|
||||
interval_s = interval.total_seconds()
|
||||
|
||||
if align_to_interval and interval_s > 0:
|
||||
# Floor purely in wall-clock seconds so the timezone (or naivety)
|
||||
# of start_datetime is never touched and no UTC conversion occurs.
|
||||
# This is correct regardless of the machine's local timezone.
|
||||
wall_s = (
|
||||
start_datetime.hour * 3600
|
||||
+ start_datetime.minute * 60
|
||||
+ start_datetime.second
|
||||
+ start_datetime.microsecond / 1_000_000
|
||||
)
|
||||
remainder_s = wall_s % interval_s
|
||||
if remainder_s:
|
||||
start_datetime = start_datetime.subtract(seconds=remainder_s)
|
||||
|
||||
result = []
|
||||
current = start_datetime
|
||||
while current < end_datetime:
|
||||
step_value: Optional[float] = None
|
||||
matched = False
|
||||
for window in self.windows:
|
||||
if window.contains(current):
|
||||
step_value = window.value
|
||||
matched = True
|
||||
break
|
||||
|
||||
if not matched:
|
||||
result.append(0.0)
|
||||
elif step_value is None:
|
||||
if not dropna:
|
||||
result.append(float("nan"))
|
||||
# else: omit this step entirely (dropna=True)
|
||||
else:
|
||||
result.append(step_value)
|
||||
|
||||
current = current.add(seconds=interval_s)
|
||||
|
||||
return np.array(result, dtype=np.float64)
|
||||
|
||||
@@ -680,6 +680,10 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
The matching DataRecord, the nearest DataRecord within the specified time window
|
||||
if no exact match exists, or ``None`` if no suitable record is found.
|
||||
"""
|
||||
# Ensure target_datetime is a datetime object
|
||||
if not isinstance(target_datetime, DateTime):
|
||||
target_datetime = to_datetime(target_datetime)
|
||||
|
||||
# Ensure datetime objects are normalized
|
||||
db_target = DatabaseTimestamp.from_datetime(target_datetime)
|
||||
|
||||
@@ -702,6 +706,10 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
Raises:
|
||||
ValueError: If ``time_window`` is negative.
|
||||
"""
|
||||
# Ensure target_datetime is a datetime object
|
||||
if not isinstance(target_datetime, DateTime):
|
||||
target_datetime = to_datetime(target_datetime)
|
||||
|
||||
# Ensure datetime objects are normalized
|
||||
db_target = DatabaseTimestamp.from_datetime(target_datetime)
|
||||
|
||||
@@ -780,6 +788,10 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
for key in values:
|
||||
self._validate_key_writable(key)
|
||||
|
||||
# Ensure date is a datetime object
|
||||
if not isinstance(date, DateTime):
|
||||
date = to_datetime(date)
|
||||
|
||||
# Ensure datetime objects are normalized
|
||||
db_target = DatabaseTimestamp.from_datetime(date)
|
||||
|
||||
@@ -1083,6 +1095,8 @@ class DataSequence(DataABC, DatabaseRecordProtocolMixin[DataRecord]):
|
||||
interval = to_duration("1 hour")
|
||||
resample_freq = "1h"
|
||||
else:
|
||||
# Ensure interval is normalized
|
||||
interval = to_duration(interval)
|
||||
resample_freq = to_duration(interval, as_string="pandas")
|
||||
|
||||
# Extend window for context resampling
|
||||
|
||||
@@ -9,13 +9,13 @@ from loguru import logger
|
||||
from numpydantic import NDArray, Shape
|
||||
from pydantic import Field, computed_field, field_validator, model_validator
|
||||
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel, TimeWindowSequence
|
||||
from akkudoktoreos.core.cache import CacheFileStore
|
||||
from akkudoktoreos.core.coreabc import ConfigMixin, SingletonMixin
|
||||
from akkudoktoreos.core.emplan import ResourceStatus
|
||||
from akkudoktoreos.core.pydantic import ConfigDict, PydanticBaseModel
|
||||
from akkudoktoreos.devices.devicesabc import DevicesBaseSettings
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, TimeWindowSequence, to_datetime
|
||||
from akkudoktoreos.utils.datetimeutil import DateTime, to_datetime
|
||||
|
||||
# Default charge rates for battery
|
||||
BATTERY_DEFAULT_CHARGE_RATES: list[float] = [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
|
||||
|
||||
@@ -1,13 +1,8 @@
|
||||
import numpy as np
|
||||
|
||||
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
|
||||
from akkudoktoreos.optimization.genetic.geneticdevices import HomeApplianceParameters
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
TimeWindow,
|
||||
TimeWindowSequence,
|
||||
to_datetime,
|
||||
to_duration,
|
||||
to_time,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_datetime, to_duration, to_time
|
||||
|
||||
|
||||
class HomeAppliance:
|
||||
|
||||
@@ -4,8 +4,8 @@ from typing import Optional
|
||||
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import TimeWindowSequence
|
||||
from akkudoktoreos.optimization.genetic.geneticabc import GeneticParametersBaseModel
|
||||
from akkudoktoreos.utils.datetimeutil import TimeWindowSequence
|
||||
|
||||
|
||||
class DeviceParameters(GeneticParametersBaseModel):
|
||||
|
||||
@@ -8,6 +8,7 @@ from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import (
|
||||
ElecPriceEnergyChartsCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixedCommonSettings
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImportCommonSettings
|
||||
|
||||
|
||||
@@ -37,6 +38,7 @@ class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
"examples": ["ElecPriceAkkudoktor"],
|
||||
},
|
||||
)
|
||||
|
||||
charges_kwh: Optional[float] = Field(
|
||||
default=None,
|
||||
ge=0,
|
||||
@@ -45,6 +47,7 @@ class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
"examples": [0.21],
|
||||
},
|
||||
)
|
||||
|
||||
vat_rate: Optional[float] = Field(
|
||||
default=1.19,
|
||||
ge=0,
|
||||
@@ -54,6 +57,11 @@ class ElecPriceCommonSettings(SettingsBaseModel):
|
||||
},
|
||||
)
|
||||
|
||||
elecpricefixed: ElecPriceFixedCommonSettings = Field(
|
||||
default_factory=ElecPriceFixedCommonSettings,
|
||||
json_schema_extra={"description": "Fixed electricity price provider settings."},
|
||||
)
|
||||
|
||||
elecpriceimport: ElecPriceImportCommonSettings = Field(
|
||||
default_factory=ElecPriceImportCommonSettings,
|
||||
json_schema_extra={"description": "Import provider settings."},
|
||||
|
||||
111
src/akkudoktoreos/prediction/elecpricefixed.py
Normal file
111
src/akkudoktoreos/prediction/elecpricefixed.py
Normal file
@@ -0,0 +1,111 @@
|
||||
"""Provides fixed price electricity price data."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from loguru import logger
|
||||
from pydantic import Field
|
||||
|
||||
from akkudoktoreos.config.configabc import (
|
||||
SettingsBaseModel,
|
||||
ValueTimeWindowSequence,
|
||||
)
|
||||
from akkudoktoreos.prediction.elecpriceabc import ElecPriceProvider
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration
|
||||
|
||||
|
||||
class ElecPriceFixedCommonSettings(SettingsBaseModel):
|
||||
"""Common configuration settings for fixed electricity pricing.
|
||||
|
||||
This model defines a fixed electricity price schedule using a sequence
|
||||
of time windows. Each window specifies a time interval and the electricity
|
||||
price applicable during that interval.
|
||||
"""
|
||||
|
||||
time_windows: ValueTimeWindowSequence = Field(
|
||||
default_factory=ValueTimeWindowSequence,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Sequence of time windows defining the fixed "
|
||||
"price schedule. If not provided, no fixed pricing is applied."
|
||||
),
|
||||
"examples": [
|
||||
{
|
||||
"windows": [
|
||||
{"start_time": "00:00", "duration": "8 hours", "value": 0.288},
|
||||
{"start_time": "08:00", "duration": "16 hours", "value": 0.34},
|
||||
],
|
||||
}
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
class ElecPriceFixed(ElecPriceProvider):
|
||||
"""Fixed price electricity price data.
|
||||
|
||||
ElecPriceFixed is a singleton-based class that retrieves electricity price data
|
||||
from a fixed schedule defined by time windows.
|
||||
|
||||
The provider generates hourly electricity prices based on the configured time windows.
|
||||
For each hour in the forecast period, it determines which time window applies and
|
||||
assigns the corresponding price.
|
||||
|
||||
Attributes:
|
||||
time_windows: Sequence of time windows with associated electricity prices.
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
def provider_id(cls) -> str:
|
||||
"""Return the unique identifier for the ElecPriceFixed provider."""
|
||||
return "ElecPriceFixed"
|
||||
|
||||
def _update_data(self, force_update: Optional[bool] = False) -> None:
|
||||
"""Update electricity price data from fixed schedule.
|
||||
|
||||
Generates electricity prices based on the configured time windows
|
||||
at the optimization interval granularity. The price sequence starts
|
||||
synchronized to the wall clock at the next full interval boundary.
|
||||
|
||||
Args:
|
||||
force_update: If True, forces update even if data exists.
|
||||
|
||||
Raises:
|
||||
ValueError: If no time windows are configured.
|
||||
"""
|
||||
time_windows_seq = self.config.elecprice.elecpricefixed.time_windows
|
||||
|
||||
if time_windows_seq is None or not time_windows_seq.windows:
|
||||
error_msg = "No time windows configured for fixed electricity price"
|
||||
logger.error(error_msg)
|
||||
raise ValueError(error_msg)
|
||||
|
||||
start_datetime = self.ems_start_datetime
|
||||
interval_seconds = self.config.optimization.interval
|
||||
total_hours = self.config.prediction.hours
|
||||
interval = to_duration(interval_seconds)
|
||||
|
||||
end_datetime = start_datetime.add(hours=total_hours)
|
||||
|
||||
logger.debug(
|
||||
f"Generating fixed electricity prices for {total_hours} hours "
|
||||
f"starting at {start_datetime}"
|
||||
)
|
||||
|
||||
# Build the full price array in one call — kWh values aligned to the
|
||||
# optimization grid. to_array mirrors the key_to_array signature so
|
||||
# the grid is constructed identically to how prediction data is read.
|
||||
prices_kwh = time_windows_seq.to_array(
|
||||
start_datetime=start_datetime,
|
||||
end_datetime=end_datetime,
|
||||
interval=interval,
|
||||
dropna=True,
|
||||
boundary="context",
|
||||
align_to_interval=True,
|
||||
)
|
||||
|
||||
# Convert kWh → Wh and store one entry per interval step.
|
||||
for idx, price_kwh in enumerate(prices_kwh):
|
||||
current_dt = start_datetime.add(seconds=idx * interval_seconds)
|
||||
self.update_value(current_dt, "elecprice_marketprice_wh", price_kwh / 1000.0)
|
||||
|
||||
logger.debug(f"Successfully generated {len(prices_kwh)} fixed electricity price entries")
|
||||
@@ -33,6 +33,7 @@ from pydantic import Field
|
||||
from akkudoktoreos.config.configabc import SettingsBaseModel
|
||||
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||
@@ -72,6 +73,7 @@ class PredictionCommonSettings(SettingsBaseModel):
|
||||
# Initialize forecast providers, all are singletons.
|
||||
elecprice_akkudoktor = ElecPriceAkkudoktor()
|
||||
elecprice_energy_charts = ElecPriceEnergyCharts()
|
||||
elecprice_fixed = ElecPriceFixed()
|
||||
elecprice_import = ElecPriceImport()
|
||||
feedintariff_fixed = FeedInTariffFixed()
|
||||
feedintariff_import = FeedInTariffImport()
|
||||
@@ -91,6 +93,7 @@ def prediction_providers() -> list[
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceFixed,
|
||||
ElecPriceImport,
|
||||
FeedInTariffFixed,
|
||||
FeedInTariffImport,
|
||||
@@ -110,6 +113,7 @@ def prediction_providers() -> list[
|
||||
global \
|
||||
elecprice_akkudoktor, \
|
||||
elecprice_energy_charts, \
|
||||
elecprice_fixed, \
|
||||
elecprice_import, \
|
||||
feedintariff_fixed, \
|
||||
feedintariff_import, \
|
||||
@@ -128,6 +132,7 @@ def prediction_providers() -> list[
|
||||
return [
|
||||
elecprice_akkudoktor,
|
||||
elecprice_energy_charts,
|
||||
elecprice_fixed,
|
||||
elecprice_import,
|
||||
feedintariff_fixed,
|
||||
feedintariff_import,
|
||||
@@ -151,6 +156,7 @@ class Prediction(PredictionContainer):
|
||||
Union[
|
||||
ElecPriceAkkudoktor,
|
||||
ElecPriceEnergyCharts,
|
||||
ElecPriceFixed,
|
||||
ElecPriceImport,
|
||||
FeedInTariffFixed,
|
||||
FeedInTariffImport,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import json
|
||||
import re
|
||||
from typing import Any, Callable, Optional, Union
|
||||
|
||||
from fasthtml.common import H1, Button, Div, Li, Select
|
||||
@@ -165,6 +166,44 @@ def ConfigButton(*c: Any, cls: Optional[Union[str, tuple]] = None, **kwargs: Any
|
||||
return Button(*c, submit=False, **kwargs)
|
||||
|
||||
|
||||
def UpdateError(error_text: str) -> Alert:
|
||||
"""Renders a compact error with collapsible full detail.
|
||||
|
||||
Extracts the short pydantic validation message (text after
|
||||
'validation error for ...') as the summary. Falls back to
|
||||
the first line if no match is found.
|
||||
|
||||
Args:
|
||||
error_text: The full error string from a config update failure.
|
||||
|
||||
Returns:
|
||||
Alert: A collapsible error element with error styling.
|
||||
"""
|
||||
short = None
|
||||
match = re.search(r"validation error for [^\n]+\n([^\n]+)\n\s+([^\[]+)", error_text)
|
||||
if match:
|
||||
short = f"Validation error: {match.group(1).strip()}: {match.group(2).strip()}"
|
||||
if not short:
|
||||
short = error_text.splitlines()[0].strip()
|
||||
|
||||
return Alert(
|
||||
Details(
|
||||
Summary(
|
||||
DivLAligned(
|
||||
UkIcon("triangle-alert"),
|
||||
P(short, cls="text-sm ml-2"),
|
||||
),
|
||||
cls="list-none cursor-pointer",
|
||||
),
|
||||
Pre(
|
||||
Code(error_text, cls="language-python"),
|
||||
cls="rounded-lg bg-muted p-3 mt-2 max-h-[30vh] overflow-y-auto overflow-x-hidden whitespace-pre-wrap text-xs",
|
||||
),
|
||||
),
|
||||
cls=AlertT.error,
|
||||
)
|
||||
|
||||
|
||||
def make_config_update_form() -> Callable[[str, str], Grid]:
|
||||
"""Factory for a form that sets a single configuration value.
|
||||
|
||||
@@ -456,6 +495,199 @@ def make_config_update_map_form(
|
||||
return ConfigUpdateMapForm
|
||||
|
||||
|
||||
def make_config_update_time_windows_windows_form(
|
||||
value_description: Optional[str] = None,
|
||||
) -> Callable[[str, str], Grid]:
|
||||
"""Factory for a form that edits the windows field of a TimeWindowSequence.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
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",
|
||||
"2 – Wednesday",
|
||||
"3 – Thursday",
|
||||
"4 – Friday",
|
||||
"5 – Saturday",
|
||||
"6 – Sunday",
|
||||
]
|
||||
|
||||
# ---- Existing windows rows ----
|
||||
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}"
|
||||
|
||||
remaining = [w for i, w in enumerate(current_windows) if i != idx]
|
||||
remaining_json = json.dumps(json.dumps(remaining))
|
||||
window_rows.append(
|
||||
DivHStacked(
|
||||
ConfigButton(
|
||||
UkIcon("trash-2"),
|
||||
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",
|
||||
),
|
||||
P(label, cls="ml-2 text-sm font-mono"),
|
||||
)
|
||||
)
|
||||
|
||||
# ---- Column headers and inputs ----
|
||||
num_cols = 5 + (1 if value_description is not None else 0)
|
||||
|
||||
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 = [
|
||||
Input(
|
||||
placeholder="e.g. 08:00 Europe/Berlin",
|
||||
name=f"{config_id}_tw_start_time",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
),
|
||||
Input(
|
||||
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(
|
||||
Input(
|
||||
placeholder="e.g. 0.288",
|
||||
name=f"{config_id}_tw_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",
|
||||
),
|
||||
Input(
|
||||
placeholder="e.g. 2025-12-24",
|
||||
name=f"{config_id}_tw_date",
|
||||
cls="border rounded px-2 py-1 text-sm",
|
||||
),
|
||||
Input(
|
||||
placeholder="e.g. de",
|
||||
name=f"{config_id}_tw_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_section = Grid(
|
||||
Grid(*header_cols, cols=num_cols),
|
||||
Grid(*input_cols, cols=num_cols),
|
||||
ConfigButton(
|
||||
UkIcon("plus"),
|
||||
" Add window",
|
||||
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='{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 newWin = {{
|
||||
start_time: start,
|
||||
duration: dur,
|
||||
{val_js_field}
|
||||
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
|
||||
date: date !== "" ? date : null,
|
||||
locale: locale !== "" ? locale : null,
|
||||
}};
|
||||
const existing = {json.dumps(current_windows)};
|
||||
existing.push(newWin);
|
||||
return JSON.stringify(existing);
|
||||
}})()
|
||||
}}""",
|
||||
),
|
||||
cols=1,
|
||||
cls="gap-2 mt-2",
|
||||
)
|
||||
|
||||
return Grid(
|
||||
DivRAligned(P("update time windows")),
|
||||
Grid(
|
||||
*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",
|
||||
cls="text-xs text-muted-foreground mb-1",
|
||||
),
|
||||
add_section,
|
||||
cols=1,
|
||||
cls="gap-1",
|
||||
),
|
||||
id=f"{config_id}-update-time-windows-windows-form",
|
||||
)
|
||||
|
||||
return ConfigUpdateTimeWindowsWindowsForm
|
||||
|
||||
|
||||
def ConfigCard(
|
||||
config_name: str,
|
||||
config_type: str,
|
||||
@@ -548,7 +780,7 @@ def ConfigCard(
|
||||
# Last error
|
||||
Grid(
|
||||
DivRAligned(P("update error")),
|
||||
TextView(update_error),
|
||||
UpdateError(update_error),
|
||||
)
|
||||
if update_error
|
||||
else None,
|
||||
|
||||
@@ -34,6 +34,7 @@ from akkudoktoreos.server.dash.components import (
|
||||
TextView,
|
||||
make_config_update_list_form,
|
||||
make_config_update_map_form,
|
||||
make_config_update_time_windows_windows_form,
|
||||
make_config_update_value_form,
|
||||
)
|
||||
from akkudoktoreos.server.dash.context import request_url_for
|
||||
@@ -730,6 +731,10 @@ def Configuration(
|
||||
update_form_factory = make_config_update_value_form(
|
||||
["OPTIMIZATION", "PREDICTION", "None"]
|
||||
)
|
||||
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(
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import argparse
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
@@ -15,7 +14,7 @@ import psutil
|
||||
import uvicorn
|
||||
from fastapi import Body, FastAPI
|
||||
from fastapi import Path as FastapiPath
|
||||
from fastapi import Query, Request
|
||||
from fastapi import Query, Request, status
|
||||
from fastapi.exceptions import HTTPException
|
||||
from fastapi.responses import (
|
||||
FileResponse,
|
||||
@@ -57,13 +56,13 @@ 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.server.rest.cli import cli_apply_args_to_config, cli_parse_args
|
||||
from akkudoktoreos.server.rest.error import create_error_page
|
||||
from akkudoktoreos.server.rest.starteosdash import supervise_eosdash
|
||||
from akkudoktoreos.server.retentionmanager import RetentionManager
|
||||
from akkudoktoreos.server.server import (
|
||||
drop_root_privileges,
|
||||
fix_data_directories_permissions,
|
||||
get_default_host,
|
||||
get_host_ip,
|
||||
wait_for_port_free,
|
||||
)
|
||||
@@ -458,12 +457,12 @@ def fastapi_config_revert_put(
|
||||
"""
|
||||
try:
|
||||
get_config().revert_settings(backup_id)
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Error on reverting of configuration: {e}",
|
||||
)
|
||||
return get_config()
|
||||
|
||||
|
||||
@app.put("/v1/config/file", tags=["config"])
|
||||
@@ -475,12 +474,12 @@ def fastapi_config_file_put() -> ConfigEOS:
|
||||
"""
|
||||
try:
|
||||
get_config().to_config_file()
|
||||
return get_config()
|
||||
except:
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Cannot save configuration to file '{get_config().config_file_path}'.",
|
||||
)
|
||||
return get_config()
|
||||
|
||||
|
||||
@app.get("/v1/config", tags=["config"])
|
||||
@@ -490,7 +489,10 @@ def fastapi_config_get() -> ConfigEOS:
|
||||
Returns:
|
||||
configuration (ConfigEOS): The current configuration.
|
||||
"""
|
||||
return get_config()
|
||||
try:
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error on configuration retrieval: {e}")
|
||||
|
||||
|
||||
@app.put("/v1/config", tags=["config"])
|
||||
@@ -509,9 +511,9 @@ def fastapi_config_put(settings: SettingsEOS) -> ConfigEOS:
|
||||
"""
|
||||
try:
|
||||
get_config().merge_settings(settings)
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=400, detail=f"Error on update of configuration: {e}")
|
||||
return get_config()
|
||||
|
||||
|
||||
@app.put("/v1/config/{path:path}", tags=["config"])
|
||||
@@ -534,6 +536,7 @@ def fastapi_config_put_key(
|
||||
"""
|
||||
try:
|
||||
get_config().set_nested_value(path, value)
|
||||
return get_config()
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
raise HTTPException(
|
||||
@@ -541,8 +544,6 @@ def fastapi_config_put_key(
|
||||
detail=f"Error on update of configuration '{path}','{value}':\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
return get_config()
|
||||
|
||||
|
||||
@app.get("/v1/config/{path:path}", tags=["config"])
|
||||
def fastapi_config_get_key(
|
||||
@@ -660,8 +661,17 @@ def fastapi_devices_status_put(
|
||||
|
||||
@app.get("/v1/measurement/keys", tags=["measurement"])
|
||||
def fastapi_measurement_keys_get() -> list[str]:
|
||||
"""Get a list of available measurement keys."""
|
||||
return sorted(get_measurement().record_keys)
|
||||
try:
|
||||
"""Get a list of available measurement keys."""
|
||||
return sorted(get_measurement().record_keys)
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception("Unexpected error retieving measurement keys")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/measurement/series", tags=["measurement"])
|
||||
@@ -669,10 +679,22 @@ def fastapi_measurement_series_get(
|
||||
key: Annotated[str, Query(description="Measurement key.")],
|
||||
) -> PydanticDateTimeSeries:
|
||||
"""Get the measurements of given key as series."""
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
try:
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error retieving measurement: {key}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/value", tags=["measurement"])
|
||||
@@ -682,19 +704,42 @@ def fastapi_measurement_value_put(
|
||||
value: Union[float | str],
|
||||
) -> PydanticDateTimeSeries:
|
||||
"""Merge the measurement of given key and value into EOS measurements at given datetime."""
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
if isinstance(value, str):
|
||||
# Try to convert to float
|
||||
try:
|
||||
value = float(value)
|
||||
except:
|
||||
logger.debug(
|
||||
f'/v1/measurement/value key: {key} value: "{value}" - string value not convertable to float'
|
||||
try:
|
||||
if isinstance(value, str):
|
||||
try:
|
||||
value = float(value)
|
||||
except ValueError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Value '{value}' cannot be converted to float",
|
||||
)
|
||||
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"Key '{key}' not found in measurements",
|
||||
)
|
||||
get_measurement().update_value(datetime, key, value)
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
|
||||
try:
|
||||
dt = to_datetime(datetime)
|
||||
except Exception as e:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail=f"Invalid datetime '{datetime}': {e}",
|
||||
)
|
||||
|
||||
get_measurement().update_value(dt, key, value)
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as e:
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error updating measurement: {datetime}, {key}, {value}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/series", tags=["measurement"])
|
||||
@@ -702,26 +747,56 @@ def fastapi_measurement_series_put(
|
||||
key: Annotated[str, Query(description="Measurement key.")], series: PydanticDateTimeSeries
|
||||
) -> PydanticDateTimeSeries:
|
||||
"""Merge measurement given as series into given key."""
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
pdseries = series.to_series() # make pandas series from PydanticDateTimeSeries
|
||||
get_measurement().key_from_series(key=key, series=pdseries)
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
try:
|
||||
if key not in get_measurement().record_keys:
|
||||
raise HTTPException(status_code=404, detail=f"Key '{key}' is not available.")
|
||||
pdseries = series.to_series() # make pandas series from PydanticDateTimeSeries
|
||||
get_measurement().key_from_series(key=key, series=pdseries)
|
||||
pdseries = get_measurement().key_to_series(key=key)
|
||||
return PydanticDateTimeSeries.from_series(pdseries)
|
||||
except HTTPException:
|
||||
# Re-raise HTTP exceptions
|
||||
raise
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error updating measurement: {key}, {series}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/dataframe", tags=["measurement"])
|
||||
def fastapi_measurement_dataframe_put(data: PydanticDateTimeDataFrame) -> None:
|
||||
"""Merge the measurement data given as dataframe into EOS measurements."""
|
||||
dataframe = data.to_dataframe()
|
||||
get_measurement().import_from_dataframe(dataframe)
|
||||
try:
|
||||
dataframe = data.to_dataframe()
|
||||
get_measurement().import_from_dataframe(dataframe)
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error updating measurement: {data}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.put("/v1/measurement/data", tags=["measurement"])
|
||||
def fastapi_measurement_data_put(data: PydanticDateTimeData) -> None:
|
||||
"""Merge the measurement data given as datetime data into EOS measurements."""
|
||||
datetimedata = data.to_dict()
|
||||
get_measurement().import_from_dict(datetimedata)
|
||||
try:
|
||||
datetimedata = data.to_dict()
|
||||
get_measurement().import_from_dict(datetimedata)
|
||||
except Exception as e:
|
||||
# Log unexpected errors
|
||||
trace = "".join(traceback.TracebackException.from_exception(e).format())
|
||||
logger.exception(f"Unexpected error updating measurement: {data}")
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
||||
detail=f"Internal server error:\n{e}\n{trace}",
|
||||
)
|
||||
|
||||
|
||||
@app.get("/v1/prediction/providers", tags=["prediction"])
|
||||
@@ -1427,52 +1502,42 @@ def run_eos() -> None:
|
||||
Returns:
|
||||
None
|
||||
"""
|
||||
# get_config(init=True) creates the configuration
|
||||
# this should not be done before nor later
|
||||
# get_config(init=True) creates the configuration, includes also ARGV args.
|
||||
# This should not be done before nor later
|
||||
config_eos = get_config(init=True)
|
||||
|
||||
# set logging to what is in config
|
||||
# Set logging to what is in config
|
||||
logger.remove()
|
||||
logging_track_config(config_eos, "logging", None, None)
|
||||
|
||||
# make logger track logging changes in config
|
||||
# Make logger track logging changes in config
|
||||
config_eos.track_nested_value("/logging", logging_track_config)
|
||||
|
||||
# Set config to actual environment variable & config file content
|
||||
config_eos.reset_settings()
|
||||
# Ensure host and port config settings are at least set to default values
|
||||
if config_eos.server.host is None:
|
||||
config_eos.set_nested_value("server/host", get_default_host())
|
||||
if config_eos.server.port is None:
|
||||
config_eos.set_nested_value("server/port", 8503)
|
||||
|
||||
# add arguments to config
|
||||
args: argparse.Namespace
|
||||
args_unknown: list[str]
|
||||
args, args_unknown = cli_parse_args()
|
||||
cli_apply_args_to_config(args)
|
||||
if config_eos.server.port is None: # make mypy happy
|
||||
raise RuntimeError("server.port is None despite default setup")
|
||||
|
||||
# prepare runtime arguments
|
||||
if args:
|
||||
run_as_user = args.run_as_user
|
||||
# Setup EOS reload for development
|
||||
if args.reload is None:
|
||||
reload = False
|
||||
else:
|
||||
logger.debug(f"reload set by argument to {args.reload}")
|
||||
reload = args.reload
|
||||
else:
|
||||
run_as_user = None
|
||||
if config_eos.server.eosdash_host is None:
|
||||
config_eos.set_nested_value("server/eosdash_host", config_eos.server.host)
|
||||
if config_eos.server.eosdash_port is None:
|
||||
config_eos.set_nested_value("server/eosdash_port", config_eos.server.port + 1)
|
||||
|
||||
# Switch data directories ownership to user
|
||||
fix_data_directories_permissions(run_as_user=run_as_user)
|
||||
fix_data_directories_permissions(run_as_user=config_eos.server.run_as_user)
|
||||
|
||||
# Switch privileges to run_as_user
|
||||
drop_root_privileges(run_as_user=run_as_user)
|
||||
drop_root_privileges(run_as_user=config_eos.server.run_as_user)
|
||||
|
||||
# Init the other singletons (besides config_eos)
|
||||
singletons_init()
|
||||
|
||||
# Wait for EOS port to be free - e.g. in case of restart
|
||||
port = config_eos.server.port
|
||||
if port is None:
|
||||
port = 8503
|
||||
wait_for_port_free(port, timeout=120, waiting_app_name="EOS")
|
||||
wait_for_port_free(config_eos.server.port, timeout=120, waiting_app_name="EOS")
|
||||
|
||||
# Normalize log_level to uvicorn log level
|
||||
VALID_UVICORN_LEVELS = {"critical", "error", "warning", "info", "debug", "trace"}
|
||||
@@ -1486,15 +1551,21 @@ def run_eos() -> None:
|
||||
elif uv_log_level not in VALID_UVICORN_LEVELS:
|
||||
uv_log_level = "info" # fallback
|
||||
|
||||
logger.info(f"Starting EOS server on {config_eos.server.host}:{config_eos.server.port}")
|
||||
if config_eos.server.startup_eosdash:
|
||||
logger.info(
|
||||
f"EOSdash will be available at {config_eos.server.eosdash_host}:{config_eos.server.eosdash_port}"
|
||||
)
|
||||
|
||||
try:
|
||||
# Let uvicorn run the fastAPI app
|
||||
uvicorn.run(
|
||||
"akkudoktoreos.server.eos:app",
|
||||
host=str(config_eos.server.host),
|
||||
port=port,
|
||||
port=config_eos.server.port,
|
||||
log_level=uv_log_level,
|
||||
access_log=True, # Fix server access logging to True
|
||||
reload=reload,
|
||||
reload=config_eos.server.reload,
|
||||
proxy_headers=True,
|
||||
forwarded_allow_ips="*",
|
||||
)
|
||||
@@ -1504,12 +1575,7 @@ def run_eos() -> None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""Parse command-line arguments and start the EOS server with the specified options.
|
||||
|
||||
This function sets up the argument parser to accept command-line arguments for
|
||||
host, port, log_level, access_log, and reload. It uses default values from the
|
||||
config_eos module if arguments are not provided. After parsing the arguments,
|
||||
it starts the EOS server with the specified configurations.
|
||||
"""Start the EOS server with the specified options.
|
||||
|
||||
Command-line Arguments:
|
||||
--host (str): Host for the EOS server (default: value from config).
|
||||
|
||||
@@ -485,6 +485,10 @@ def run_eosdash() -> None:
|
||||
elif uv_log_level not in VALID_UVICORN_LEVELS:
|
||||
uv_log_level = "info" # fallback
|
||||
|
||||
logger.info(
|
||||
f"Starting EOSdash server on {config_eosdash['eosdash_host']}:{config_eosdash['eosdash_port']}"
|
||||
)
|
||||
|
||||
try:
|
||||
uvicorn.run(
|
||||
"akkudoktoreos.server.eosdash:app",
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import argparse
|
||||
|
||||
from loguru import logger
|
||||
|
||||
from akkudoktoreos.core.coreabc import get_config
|
||||
from akkudoktoreos.core.logabc import LOGGING_LEVELS
|
||||
from akkudoktoreos.server.server import get_default_host
|
||||
from akkudoktoreos.utils.stringutil import str2bool
|
||||
|
||||
|
||||
@@ -71,79 +66,3 @@ def cli_parse_args(
|
||||
"""
|
||||
args, args_unknown = cli_argument_parser().parse_known_args(argv)
|
||||
return args, args_unknown
|
||||
|
||||
|
||||
def cli_apply_args_to_config(args: argparse.Namespace) -> None:
|
||||
"""Apply parsed CLI arguments to the EOS configuration.
|
||||
|
||||
This function updates the EOS configuration with values provided via
|
||||
the command line. For each parameter, the precedence is:
|
||||
|
||||
CLI argument > existing config value > default value
|
||||
|
||||
Currently handled arguments:
|
||||
|
||||
- log_level: Updates "logging/console_level" in config.
|
||||
- host: Updates "server/host" in config.
|
||||
- port: Updates "server/port" in config.
|
||||
- startup_eosdash: Updates "server/startup_eosdash" in config.
|
||||
- eosdash_host/port: Initialized if EOSdash is enabled and not already set.
|
||||
|
||||
Args:
|
||||
args: Parsed command-line arguments from argparse.
|
||||
"""
|
||||
config_eos = get_config()
|
||||
|
||||
# Setup parameters from args, config_eos and default
|
||||
# Remember parameters in config
|
||||
|
||||
# Setup EOS logging level - first to have the other logging messages logged
|
||||
if args.log_level is not None:
|
||||
log_level = args.log_level.upper()
|
||||
# Ensure log_level from command line is in config settings
|
||||
if log_level in LOGGING_LEVELS:
|
||||
# Setup console logging level using nested value
|
||||
# - triggers logging configuration by logging_track_config
|
||||
config_eos.set_nested_value("logging/console_level", log_level)
|
||||
logger.debug(f"logging/console_level configuration set by argument to {log_level}")
|
||||
|
||||
# Setup EOS server host
|
||||
if args.host:
|
||||
host = args.host
|
||||
logger.debug(f"server/host configuration set by argument to {host}")
|
||||
elif config_eos.server.host:
|
||||
host = config_eos.server.host
|
||||
else:
|
||||
host = get_default_host()
|
||||
# Ensure host from command line is in config settings
|
||||
config_eos.set_nested_value("server/host", host)
|
||||
|
||||
# Setup EOS server port
|
||||
if args.port:
|
||||
port = args.port
|
||||
logger.debug(f"server/port configuration set by argument to {port}")
|
||||
elif config_eos.server.port:
|
||||
port = config_eos.server.port
|
||||
else:
|
||||
port = 8503
|
||||
# Ensure port from command line is in config settings
|
||||
config_eos.set_nested_value("server/port", port)
|
||||
|
||||
# Setup EOSdash startup
|
||||
if args.startup_eosdash is not None:
|
||||
# Ensure startup_eosdash from command line is in config settings
|
||||
config_eos.set_nested_value("server/startup_eosdash", args.startup_eosdash)
|
||||
logger.debug(
|
||||
f"server/startup_eosdash configuration set by argument to {args.startup_eosdash}"
|
||||
)
|
||||
|
||||
if config_eos.server.startup_eosdash:
|
||||
# Ensure EOSdash host and port config settings are at least set to default values
|
||||
|
||||
# Setup EOS server host
|
||||
if config_eos.server.eosdash_host is None:
|
||||
config_eos.set_nested_value("server/eosdash_host", host)
|
||||
|
||||
# Setup EOS server host
|
||||
if config_eos.server.eosdash_port is None:
|
||||
config_eos.set_nested_value("server/eosdash_port", port + 1)
|
||||
|
||||
@@ -309,8 +309,14 @@ async def supervise_eosdash() -> None:
|
||||
config_eos = get_config()
|
||||
|
||||
# Skip if EOSdash not configured to start
|
||||
if not getattr(config_eos.server, "startup_eosdash", False):
|
||||
startup_eosdash = config_eos.server.startup_eosdash
|
||||
if not startup_eosdash:
|
||||
logger.debug(
|
||||
f"EOSdash subprocess not monitored - startup_eosdash not set: '{startup_eosdash}'"
|
||||
)
|
||||
return
|
||||
else:
|
||||
logger.debug(f"EOSdash subprocess monitored - startup_eosdash set: '{startup_eosdash}'")
|
||||
|
||||
host = config_eos.server.eosdash_host
|
||||
port = config_eos.server.eosdash_port
|
||||
|
||||
@@ -374,6 +374,31 @@ class ServerCommonSettings(SettingsBaseModel):
|
||||
],
|
||||
},
|
||||
)
|
||||
run_as_user: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"The name of the target user to switch to. If ``None`` (default), the current "
|
||||
"effective user is used and no privilege change is attempted."
|
||||
),
|
||||
"examples": [
|
||||
None,
|
||||
"user",
|
||||
],
|
||||
},
|
||||
)
|
||||
reload: Optional[bool] = Field(
|
||||
default=False,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Enable server auto-reload for debugging or development. Default is False. "
|
||||
"Monitors the package directory for changes and reloads the server."
|
||||
),
|
||||
"examples": [
|
||||
True,
|
||||
],
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("host", "eosdash_host", mode="before")
|
||||
def validate_server_host(cls, value: Optional[str]) -> Optional[str]:
|
||||
@@ -386,3 +411,13 @@ class ServerCommonSettings(SettingsBaseModel):
|
||||
if value is not None and not (1024 <= value <= 49151):
|
||||
raise ValueError("Server port number must be between 1024 and 49151.")
|
||||
return value
|
||||
|
||||
@field_validator("run_as_user")
|
||||
def validate_user(cls, value: Optional[str]) -> Optional[str]:
|
||||
if value is not None:
|
||||
# Resolve target user info
|
||||
try:
|
||||
pw_record = pwd.getpwnam(value)
|
||||
except KeyError:
|
||||
raise ValueError(f"User '{value}' does not exist.")
|
||||
return value
|
||||
|
||||
@@ -10,7 +10,7 @@ Features:
|
||||
- Convert durations from strings or numerics into `pendulum.Duration`.
|
||||
- Infer timezone from UTC offset or geolocation.
|
||||
- Support for custom output formats (ISO 8601, UTC normalized, or user-specified formats).
|
||||
- Makes pendulum types usable in pydantic models using `pydantic_extra_types.pendulum_dt`
|
||||
- Makes pendulum types usable in pydantic models using `pydantic_extra_types.pe ndulum_dt`
|
||||
and the `Time` class.
|
||||
|
||||
Types:
|
||||
@@ -19,7 +19,6 @@ Types:
|
||||
- `DateTime`: Pendulum's timezone-aware datetime type.
|
||||
- `Date`: Pendulum's date type.
|
||||
- `Duration`: Pendulum's representation of a time delta.
|
||||
- `TimeWindow`: Daily or specific date time window with optional localization support.
|
||||
|
||||
Functions:
|
||||
----------
|
||||
@@ -45,22 +44,15 @@ Usage Examples:
|
||||
See each function's docstring for detailed argument options and examples.
|
||||
"""
|
||||
|
||||
import calendar
|
||||
import datetime
|
||||
import re
|
||||
from typing import Any, Iterator, List, Literal, Optional, Tuple, Union, overload
|
||||
from typing import Any, List, Literal, Optional, Tuple, Union, overload
|
||||
|
||||
import pendulum
|
||||
from babel.dates import get_day_names
|
||||
from loguru import logger
|
||||
from pendulum.tz.timezone import Timezone
|
||||
from pydantic import (
|
||||
BaseModel,
|
||||
Field,
|
||||
GetCoreSchemaHandler,
|
||||
field_serializer,
|
||||
field_validator,
|
||||
model_validator,
|
||||
)
|
||||
from pydantic_core import core_schema
|
||||
from pydantic_extra_types.pendulum_dt import ( # make pendulum types pydantic
|
||||
@@ -830,627 +822,6 @@ def to_time(
|
||||
raise ValueError(f"Invalid time value: {value!r} of type: {type(value)}") from e
|
||||
|
||||
|
||||
class TimeWindow(BaseModel):
|
||||
"""Model defining a daily or specific date time window with optional localization support.
|
||||
|
||||
Represents a time interval starting at `start_time` and lasting for `duration`.
|
||||
Can restrict applicability to a specific day of the week or a specific calendar date.
|
||||
Supports day names in multiple languages via locale-aware parsing.
|
||||
"""
|
||||
|
||||
start_time: Time = Field(
|
||||
..., json_schema_extra={"description": "Start time of the time window (time of day)."}
|
||||
)
|
||||
duration: Duration = Field(
|
||||
...,
|
||||
json_schema_extra={
|
||||
"description": "Duration of the time window starting from `start_time`."
|
||||
},
|
||||
)
|
||||
day_of_week: Optional[Union[int, str]] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Optional day of the week restriction. "
|
||||
"Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. "
|
||||
"If None, applies every day unless `date` is set."
|
||||
)
|
||||
},
|
||||
)
|
||||
date: Optional[Date] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Optional specific calendar date for the time window. Overrides `day_of_week` if set."
|
||||
)
|
||||
},
|
||||
)
|
||||
locale: Optional[str] = Field(
|
||||
default=None,
|
||||
json_schema_extra={
|
||||
"description": (
|
||||
"Locale used to parse weekday names in `day_of_week` when given as string. "
|
||||
"If not set, Pendulum's default locale is used. "
|
||||
"Examples: 'en', 'de', 'fr', etc."
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@field_validator("duration", mode="before")
|
||||
@classmethod
|
||||
def transform_to_duration(cls, value: Any) -> Duration:
|
||||
"""Converts various duration formats into Duration.
|
||||
|
||||
Args:
|
||||
value: The value to convert to Duration.
|
||||
|
||||
Returns:
|
||||
Duration: The converted Duration object.
|
||||
"""
|
||||
return to_duration(value)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_day_of_week_with_locale(self) -> "TimeWindow":
|
||||
"""Validates and normalizes the `day_of_week` field using the specified locale.
|
||||
|
||||
This method supports both integer (0–6) and string inputs for `day_of_week`.
|
||||
String inputs are matched first against English weekday names (case-insensitive),
|
||||
and then against localized weekday names using the provided `locale`.
|
||||
|
||||
If a valid match is found, `day_of_week` is converted to its corresponding
|
||||
integer value (0 for Monday through 6 for Sunday).
|
||||
|
||||
Returns:
|
||||
TimeWindow: The validated instance with `day_of_week` normalized to an integer.
|
||||
|
||||
Raises:
|
||||
ValueError: If `day_of_week` is an invalid integer (not in 0–6),
|
||||
or an unrecognized string (not matching English or localized names),
|
||||
or of an unsupported type.
|
||||
"""
|
||||
if self.day_of_week is None:
|
||||
return self
|
||||
|
||||
if isinstance(self.day_of_week, int):
|
||||
if not 0 <= self.day_of_week <= 6:
|
||||
raise ValueError("day_of_week must be in 0 (Monday) to 6 (Sunday)")
|
||||
return self
|
||||
|
||||
if isinstance(self.day_of_week, str):
|
||||
# Try matching against English names first (lowercase)
|
||||
english_days = {name.lower(): i for i, name in enumerate(calendar.day_name)}
|
||||
lowercase_value = self.day_of_week.lower()
|
||||
if lowercase_value in english_days:
|
||||
self.day_of_week = english_days[lowercase_value]
|
||||
return self
|
||||
|
||||
# Try localized names
|
||||
if self.locale:
|
||||
localized_days = {
|
||||
get_day_names("wide", locale=self.locale)[i].lower(): i for i in range(7)
|
||||
}
|
||||
if lowercase_value in localized_days:
|
||||
self.day_of_week = localized_days[lowercase_value]
|
||||
return self
|
||||
|
||||
raise ValueError(
|
||||
f"Invalid weekday name '{self.day_of_week}' for locale '{self.locale}'. "
|
||||
f"Expected English names (monday–sunday) or localized names."
|
||||
)
|
||||
|
||||
raise ValueError(f"Invalid type for day_of_week: {type(self.day_of_week)}")
|
||||
|
||||
@field_serializer("duration")
|
||||
def serialize_duration(self, value: Duration) -> str:
|
||||
"""Serialize duration to string."""
|
||||
return str(value)
|
||||
|
||||
def _window_start_end(self, reference_date: DateTime) -> tuple[DateTime, DateTime]:
|
||||
"""Get the actual start and end datetimes for the time window on a given date.
|
||||
|
||||
This method computes the concrete start and end datetimes of the configured
|
||||
time window for a specific date, taking into account timezone information.
|
||||
|
||||
Handles timezone-aware and naive `DateTime` and `Time` objects:
|
||||
- If both `reference_date` and `start_time` have timezones but differ,
|
||||
`start_time` is converted to the timezone of `reference_date`.
|
||||
- If only one has a timezone, the other inherits it.
|
||||
- If both are naive, UTC is assumed for both.
|
||||
|
||||
Args:
|
||||
reference_date: The reference date on which to calculate the window.
|
||||
|
||||
Returns:
|
||||
tuple[DateTime, DateTime]: A tuple containing the start and end datetimes
|
||||
for the time window, both timezone-aware.
|
||||
"""
|
||||
ref_tz = reference_date.timezone
|
||||
start_tz = self.start_time.tzinfo
|
||||
|
||||
# --- Timezone resolution logic ---
|
||||
if ref_tz and start_tz:
|
||||
# Both aware: align start_time to reference_date's tz
|
||||
if ref_tz != start_tz:
|
||||
start_time = self.start_time.in_timezone(ref_tz)
|
||||
else:
|
||||
start_time = self.start_time
|
||||
elif ref_tz and not start_tz:
|
||||
# Only reference_date aware → assume same tz for time
|
||||
start_time = self.start_time.replace_timezone(ref_tz)
|
||||
elif not ref_tz and start_tz:
|
||||
# Only start_time aware → apply its tz to reference_date
|
||||
reference_date = reference_date.replace(tzinfo=start_tz)
|
||||
start_time = self.start_time
|
||||
else:
|
||||
# Both naive → default to UTC
|
||||
reference_date = reference_date.replace(tzinfo="UTC")
|
||||
start_time = self.start_time.replace_timezone("UTC")
|
||||
|
||||
# --- Build window start ---
|
||||
start = reference_date.replace(
|
||||
hour=start_time.hour,
|
||||
minute=start_time.minute,
|
||||
second=start_time.second,
|
||||
microsecond=start_time.microsecond,
|
||||
)
|
||||
|
||||
# --- Compute window end ---
|
||||
end = start + self.duration
|
||||
return start, end
|
||||
|
||||
def contains(self, date_time: DateTime, duration: Optional[Duration] = None) -> bool:
|
||||
"""Check whether a datetime (and optional duration) fits within the time window.
|
||||
|
||||
This method checks if a given datetime `date_time` lies within the start time and duration
|
||||
defined by the `TimeWindow`. If `duration` is provided, it also ensures that
|
||||
the full duration starting at `date_time` ends before or at the end of the time window.
|
||||
|
||||
Handles timezone-aware and naive datetimes:
|
||||
- If both `date_time` and `start_time` are timezone-aware but differ → align `start_time`
|
||||
to `date_time`’s timezone.
|
||||
- If only one has a timezone → assign it to the other.
|
||||
- If both are naive → assume UTC for both.
|
||||
|
||||
If `day_of_week` or `date` are specified in the time window, the method will also
|
||||
ensure that `date_time` falls on the correct day or matches the exact date.
|
||||
|
||||
Args:
|
||||
date_time: The datetime to test.
|
||||
duration: An optional duration that must fit entirely within the time window
|
||||
starting from `date_time`.
|
||||
|
||||
Returns:
|
||||
bool: True if the datetime (and optional duration) is fully contained in the
|
||||
time window, False otherwise.
|
||||
"""
|
||||
start_time = self.start_time # work on a local copy to avoid mutating self
|
||||
start_tz = getattr(start_time, "tzinfo", None)
|
||||
ref_tz = date_time.timezone
|
||||
|
||||
# --- Handle timezone logic ---
|
||||
if ref_tz and start_tz:
|
||||
# Both aware but different → align start_time to date_time's timezone
|
||||
if ref_tz != start_tz:
|
||||
start_time = start_time.in_timezone(ref_tz)
|
||||
elif ref_tz and not start_tz:
|
||||
# Only date_time aware → assign its timezone to start_time
|
||||
start_time = start_time.replace_timezone(ref_tz)
|
||||
elif not ref_tz and start_tz:
|
||||
# Only start_time aware → assign its timezone to date_time
|
||||
date_time = date_time.replace(tzinfo=start_tz)
|
||||
else:
|
||||
# Both naive → assume UTC
|
||||
date_time = date_time.replace(tzinfo="UTC")
|
||||
start_time = start_time.replace_timezone("UTC")
|
||||
|
||||
# --- Date and weekday constraints ---
|
||||
if self.date and date_time.date() != self.date:
|
||||
return False
|
||||
|
||||
if self.day_of_week is not None and date_time.day_of_week != self.day_of_week:
|
||||
return False
|
||||
|
||||
# --- Compute window start and end for this date ---
|
||||
start, end = self._window_start_end(date_time)
|
||||
|
||||
# --- Check containment ---
|
||||
if not (start <= date_time < end):
|
||||
return False
|
||||
|
||||
if duration is not None:
|
||||
date_time_end = date_time + duration
|
||||
return date_time_end <= end
|
||||
|
||||
return True
|
||||
|
||||
def earliest_start_time(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> Optional[DateTime]:
|
||||
"""Get the earliest datetime that allows a duration to fit within the time window.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit within the window.
|
||||
reference_date: The date to check for the time window. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The earliest start time for the duration, or None if it doesn't fit.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
# Check if the reference date matches our constraints
|
||||
if self.date and reference_date.date() != self.date:
|
||||
return None
|
||||
|
||||
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
|
||||
return None
|
||||
|
||||
# Check if the duration can fit within the time window
|
||||
if duration > self.duration:
|
||||
return None
|
||||
|
||||
window_start, window_end = self._window_start_end(reference_date)
|
||||
|
||||
# The earliest start time is simply the window start time
|
||||
return window_start
|
||||
|
||||
def latest_start_time(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> Optional[DateTime]:
|
||||
"""Get the latest datetime that allows a duration to fit within the time window.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit within the window.
|
||||
reference_date: The date to check for the time window. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The latest start time for the duration, or None if it doesn't fit.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
# Check if the reference date matches our constraints
|
||||
if self.date and reference_date.date() != self.date:
|
||||
return None
|
||||
|
||||
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
|
||||
return None
|
||||
|
||||
# Check if the duration can fit within the time window
|
||||
if duration > self.duration:
|
||||
return None
|
||||
|
||||
window_start, window_end = self._window_start_end(reference_date)
|
||||
|
||||
# The latest start time is the window end minus the duration
|
||||
latest_start = window_end - duration
|
||||
|
||||
# Ensure the latest start time is not before the window start
|
||||
if latest_start < window_start:
|
||||
return None
|
||||
|
||||
return latest_start
|
||||
|
||||
def can_fit_duration(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> bool:
|
||||
"""Check if a duration can fit within the time window on a given date.
|
||||
|
||||
Args:
|
||||
duration: The duration to check.
|
||||
reference_date: The date to check for the time window. Defaults to today.
|
||||
|
||||
Returns:
|
||||
bool: True if the duration can fit, False otherwise.
|
||||
"""
|
||||
return self.earliest_start_time(duration, reference_date) is not None
|
||||
|
||||
def available_duration(self, reference_date: Optional[DateTime] = None) -> Optional[Duration]:
|
||||
"""Get the total available duration for the time window on a given date.
|
||||
|
||||
Args:
|
||||
reference_date: The date to check for the time window. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The available duration, or None if the date doesn't match constraints.
|
||||
"""
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
if self.date and reference_date.date() != self.date:
|
||||
return None
|
||||
|
||||
if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week:
|
||||
return None
|
||||
|
||||
return self.duration
|
||||
|
||||
|
||||
class TimeWindowSequence(BaseModel):
|
||||
"""Model representing a sequence of time windows with collective operations.
|
||||
|
||||
Manages multiple TimeWindow objects and provides methods to work with them
|
||||
as a cohesive unit for scheduling and availability checking.
|
||||
"""
|
||||
|
||||
windows: Optional[list[TimeWindow]] = Field(
|
||||
default_factory=list,
|
||||
json_schema_extra={"description": "List of TimeWindow objects that make up this sequence."},
|
||||
)
|
||||
|
||||
@field_validator("windows")
|
||||
@classmethod
|
||||
def validate_windows(cls, v: Optional[list[TimeWindow]]) -> list[TimeWindow]:
|
||||
"""Validate windows and convert None to empty list."""
|
||||
if v is None:
|
||||
return []
|
||||
return v
|
||||
|
||||
def model_post_init(self, __context: Any) -> None:
|
||||
"""Ensure windows is always a list after initialization."""
|
||||
if self.windows is None:
|
||||
self.windows = []
|
||||
|
||||
def __iter__(self) -> Iterator[TimeWindow]:
|
||||
"""Allow iteration over the time windows."""
|
||||
return iter(self.windows or [])
|
||||
|
||||
def __len__(self) -> int:
|
||||
"""Return the number of time windows in the sequence."""
|
||||
return len(self.windows or [])
|
||||
|
||||
def __getitem__(self, index: int) -> TimeWindow:
|
||||
"""Allow indexing into the time windows."""
|
||||
if not self.windows:
|
||||
raise IndexError("list index out of range")
|
||||
return self.windows[index]
|
||||
|
||||
def contains(self, date_time: DateTime, duration: Optional[Duration] = None) -> bool:
|
||||
"""Check if any time window in the sequence contains the given datetime and duration.
|
||||
|
||||
Args:
|
||||
date_time: The datetime to test.
|
||||
duration: An optional duration that must fit entirely within one of the time windows.
|
||||
|
||||
Returns:
|
||||
bool: True if any time window contains the datetime (and optional duration), False if no windows.
|
||||
"""
|
||||
if not self.windows:
|
||||
return False
|
||||
return any(window.contains(date_time, duration) for window in self.windows)
|
||||
|
||||
def earliest_start_time(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> Optional[DateTime]:
|
||||
"""Get the earliest datetime across all windows that allows a duration to fit.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit within a window.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The earliest start time across all windows, or None if no window can fit the duration.
|
||||
"""
|
||||
if not self.windows:
|
||||
return None
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
earliest_times = []
|
||||
|
||||
for window in self.windows:
|
||||
earliest = window.earliest_start_time(duration, reference_date)
|
||||
if earliest is not None:
|
||||
earliest_times.append(earliest)
|
||||
|
||||
return min(earliest_times) if earliest_times else None
|
||||
|
||||
def latest_start_time(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> Optional[DateTime]:
|
||||
"""Get the latest datetime across all windows that allows a duration to fit.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit within a window.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The latest start time across all windows, or None if no window can fit the duration.
|
||||
"""
|
||||
if not self.windows:
|
||||
return None
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
latest_times = []
|
||||
|
||||
for window in self.windows:
|
||||
latest = window.latest_start_time(duration, reference_date)
|
||||
if latest is not None:
|
||||
latest_times.append(latest)
|
||||
|
||||
return max(latest_times) if latest_times else None
|
||||
|
||||
def can_fit_duration(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> bool:
|
||||
"""Check if the duration can fit within any time window in the sequence.
|
||||
|
||||
Args:
|
||||
duration: The duration to check.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
bool: True if any window can fit the duration, False if no windows.
|
||||
"""
|
||||
if not self.windows:
|
||||
return False
|
||||
|
||||
return any(window.can_fit_duration(duration, reference_date) for window in self.windows)
|
||||
|
||||
def available_duration(self, reference_date: Optional[DateTime] = None) -> Optional[Duration]:
|
||||
"""Get the total available duration across all applicable windows.
|
||||
|
||||
Args:
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
The sum of available durations from all applicable windows, or None if no windows apply.
|
||||
"""
|
||||
if not self.windows:
|
||||
return None
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
total_duration = Duration()
|
||||
has_applicable_windows = False
|
||||
|
||||
for window in self.windows:
|
||||
window_duration = window.available_duration(reference_date)
|
||||
if window_duration is not None:
|
||||
total_duration += window_duration
|
||||
has_applicable_windows = True
|
||||
|
||||
return total_duration if has_applicable_windows else None
|
||||
|
||||
def get_applicable_windows(self, reference_date: Optional[DateTime] = None) -> list[TimeWindow]:
|
||||
"""Get all windows that apply to the given reference date.
|
||||
|
||||
Args:
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
List of TimeWindow objects that apply to the reference date.
|
||||
"""
|
||||
if not self.windows:
|
||||
return []
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
applicable_windows = []
|
||||
|
||||
for window in self.windows:
|
||||
if window.available_duration(reference_date) is not None:
|
||||
applicable_windows.append(window)
|
||||
|
||||
return applicable_windows
|
||||
|
||||
def find_windows_for_duration(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> list[TimeWindow]:
|
||||
"""Find all windows that can accommodate the given duration.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
List of TimeWindow objects that can fit the duration.
|
||||
"""
|
||||
if not self.windows:
|
||||
return []
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
fitting_windows = []
|
||||
|
||||
for window in self.windows:
|
||||
if window.can_fit_duration(duration, reference_date):
|
||||
fitting_windows.append(window)
|
||||
|
||||
return fitting_windows
|
||||
|
||||
def get_all_possible_start_times(
|
||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||
) -> list[tuple[DateTime, DateTime, TimeWindow]]:
|
||||
"""Get all possible start time ranges for a duration across all windows.
|
||||
|
||||
Args:
|
||||
duration: The duration that needs to fit.
|
||||
reference_date: The date to check for the time windows. Defaults to today.
|
||||
|
||||
Returns:
|
||||
List of tuples containing (earliest_start, latest_start, window) for each
|
||||
window that can accommodate the duration.
|
||||
"""
|
||||
if not self.windows:
|
||||
return []
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
possible_times = []
|
||||
|
||||
for window in self.windows:
|
||||
earliest = window.earliest_start_time(duration, reference_date)
|
||||
latest = window.latest_start_time(duration, reference_date)
|
||||
|
||||
if earliest is not None and latest is not None:
|
||||
possible_times.append((earliest, latest, window))
|
||||
|
||||
return possible_times
|
||||
|
||||
def add_window(self, window: TimeWindow) -> None:
|
||||
"""Add a new time window to the sequence.
|
||||
|
||||
Args:
|
||||
window: The TimeWindow to add.
|
||||
"""
|
||||
if self.windows is None:
|
||||
self.windows = []
|
||||
self.windows.append(window)
|
||||
|
||||
def remove_window(self, index: int) -> TimeWindow:
|
||||
"""Remove a time window from the sequence by index.
|
||||
|
||||
Args:
|
||||
index: The index of the window to remove.
|
||||
|
||||
Returns:
|
||||
The removed TimeWindow.
|
||||
|
||||
Raises:
|
||||
IndexError: If the index is out of range.
|
||||
"""
|
||||
if not self.windows:
|
||||
raise IndexError("pop from empty list")
|
||||
return self.windows.pop(index)
|
||||
|
||||
def clear_windows(self) -> None:
|
||||
"""Remove all windows from the sequence."""
|
||||
if self.windows is not None:
|
||||
self.windows.clear()
|
||||
|
||||
def sort_windows_by_start_time(self, reference_date: Optional[DateTime] = None) -> None:
|
||||
"""Sort the windows by their start time on the given reference date.
|
||||
|
||||
Windows that don't apply to the reference date are placed at the end.
|
||||
|
||||
Args:
|
||||
reference_date: The date to use for sorting. Defaults to today.
|
||||
"""
|
||||
if not self.windows:
|
||||
return
|
||||
|
||||
if reference_date is None:
|
||||
reference_date = pendulum.today()
|
||||
|
||||
def sort_key(window: TimeWindow) -> tuple[int, DateTime]:
|
||||
"""Sort key: (priority, start_time) where priority 0 = applicable, 1 = not applicable."""
|
||||
start_time = window.earliest_start_time(Duration(), reference_date)
|
||||
if start_time is None:
|
||||
# Non-applicable windows get a high priority (sorted last) and a dummy time
|
||||
return (1, reference_date)
|
||||
return (0, start_time)
|
||||
|
||||
self.windows.sort(key=sort_key)
|
||||
|
||||
|
||||
@overload
|
||||
def to_datetime(
|
||||
date_input: Optional[Any] = None,
|
||||
@@ -1782,7 +1153,7 @@ def to_duration(
|
||||
elif isinstance(input_value, str):
|
||||
# first try pendulum.parse
|
||||
try:
|
||||
parsed = pendulum.parse(input_value)
|
||||
parsed = pendulum.parse(input_value, strict=False)
|
||||
if isinstance(parsed, pendulum.Duration):
|
||||
duration = parsed # Already a duration
|
||||
else:
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -23,8 +23,6 @@ from akkudoktoreos.utils.datetimeutil import (
|
||||
DateTime,
|
||||
Duration,
|
||||
Time,
|
||||
TimeWindow,
|
||||
TimeWindowSequence,
|
||||
_parse_time_string,
|
||||
compare_datetimes,
|
||||
hours_in_day,
|
||||
@@ -839,459 +837,6 @@ class TestPendulumTypes:
|
||||
assert model.run_duration.total_minutes() == 180
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# TimeWindow
|
||||
# -----------------------------
|
||||
|
||||
|
||||
class TestTimeWindow:
|
||||
"""Tests for the TimeWindow model."""
|
||||
|
||||
def test_datetime_within_and_outside_window(self):
|
||||
"""Test datetime containment logic inside and outside the time window."""
|
||||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=3))
|
||||
assert window.contains(DateTime(2025, 7, 12, 7, 30)) is True # Inside
|
||||
assert window.contains(DateTime(2025, 7, 12, 9, 30)) is False # Outside
|
||||
|
||||
def test_contains_with_duration(self):
|
||||
"""Test datetime with duration that does and doesn't fit in the window."""
|
||||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=3))
|
||||
assert window.contains(DateTime(2025, 7, 12, 6, 30), duration=Duration(minutes=60)) is True
|
||||
assert window.contains(DateTime(2025, 7, 12, 6, 30), duration=Duration(hours=3)) is False
|
||||
|
||||
def test_day_of_week_filter(self):
|
||||
"""Test time window restricted by day of week."""
|
||||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week=5) # Saturday
|
||||
assert window.contains(DateTime(2025, 7, 12, 6, 30)) is True # Saturday
|
||||
assert window.contains(DateTime(2025, 7, 11, 6, 30)) is False # Friday
|
||||
|
||||
def test_day_of_week_as_english_name(self):
|
||||
"""Test time window with English weekday name."""
|
||||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week="monday")
|
||||
assert window.contains(DateTime(2025, 7, 7, 6, 30)) is True # Monday
|
||||
assert window.contains(DateTime(2025, 7, 5, 6, 30)) is False # Saturday
|
||||
|
||||
def test_specific_date_filter(self):
|
||||
"""Test time window restricted by exact date."""
|
||||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), date=Date(2025, 7, 12))
|
||||
assert window.contains(DateTime(2025, 7, 12, 6, 30)) is True
|
||||
assert window.contains(DateTime(2025, 7, 13, 6, 30)) is False
|
||||
|
||||
def test_invalid_field_types_raise_validation(self):
|
||||
"""Test invalid types raise a Pydantic validation error."""
|
||||
with pytest.raises(ValidationError):
|
||||
TimeWindow(start_time="not_a_time", duration="3h")
|
||||
|
||||
@pytest.mark.parametrize("locale, weekday_name, expected_dow", [
|
||||
("de", "Montag", 0),
|
||||
("de", "Samstag", 5),
|
||||
("es", "lunes", 0),
|
||||
("es", "sábado", 5),
|
||||
("fr", "lundi", 0),
|
||||
("fr", "samedi", 5),
|
||||
])
|
||||
def test_localized_day_names(self, locale, weekday_name, expected_dow):
|
||||
"""Test that localized weekday names are resolved to correct weekday index."""
|
||||
window = TimeWindow(start_time=Time(6, 0), duration=Duration(hours=2), day_of_week=weekday_name, locale=locale)
|
||||
assert window.day_of_week == expected_dow
|
||||
|
||||
|
||||
# ------------------
|
||||
# TimeWindowSequence
|
||||
# ------------------
|
||||
|
||||
|
||||
class TestTimeWindowSequence:
|
||||
"""Test suite for TimeWindowSequence model."""
|
||||
|
||||
@pytest.fixture
|
||||
def sample_time_window_1(self):
|
||||
"""Morning window: 9:00 AM - 12:00 PM."""
|
||||
return TimeWindow(
|
||||
start_time=Time(9, 0, 0),
|
||||
duration=Duration(hours=3)
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_time_window_2(self):
|
||||
"""Afternoon window: 2:00 PM - 5:00 PM."""
|
||||
return TimeWindow(
|
||||
start_time=Time(14, 0, 0),
|
||||
duration=Duration(hours=3)
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def monday_window(self):
|
||||
"""Monday only window: 10:00 AM - 11:00 AM."""
|
||||
return TimeWindow(
|
||||
start_time=Time(10, 0, 0),
|
||||
duration=Duration(hours=1),
|
||||
day_of_week=0 # Monday
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def specific_date_window(self):
|
||||
"""Specific date window: 1:00 PM - 3:00 PM on 2025-01-15."""
|
||||
return TimeWindow(
|
||||
start_time=Time(13, 0, 0),
|
||||
duration=Duration(hours=2),
|
||||
date=Date(2025, 1, 15)
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def sample_sequence(self, sample_time_window_1, sample_time_window_2):
|
||||
"""Sequence with morning and afternoon windows."""
|
||||
return TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2])
|
||||
|
||||
@pytest.fixture
|
||||
def sample_sequence_json(self, sample_time_window_1, sample_time_window_2):
|
||||
"""Sequence with morning and afternoon windows."""
|
||||
seq_json = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2]).model_dump()
|
||||
return seq_json
|
||||
|
||||
@pytest.fixture
|
||||
def sample_sequence_json_str(self, sample_time_window_1, sample_time_window_2):
|
||||
"""Sequence with morning and afternoon windows."""
|
||||
seq_json_str = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2]).model_dumps(indent=2)
|
||||
return seq_json_str
|
||||
|
||||
@pytest.fixture
|
||||
def reference_date(self):
|
||||
"""Reference date for testing: 2025-01-15 (Wednesday)."""
|
||||
return pendulum.parse("2025-01-15T08:00:00")
|
||||
|
||||
def test_init_with_none_windows(self):
|
||||
"""Test initialization with None windows creates empty list."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert sequence.windows == []
|
||||
assert len(sequence) == 0
|
||||
|
||||
def test_init_with_explicit_none(self):
|
||||
"""Test initialization with explicit None windows."""
|
||||
sequence = TimeWindowSequence(windows=None)
|
||||
assert sequence.windows == []
|
||||
assert len(sequence) == 0
|
||||
|
||||
def test_init_with_empty_list(self):
|
||||
"""Test initialization with empty list."""
|
||||
sequence = TimeWindowSequence(windows=[])
|
||||
assert sequence.windows == []
|
||||
assert len(sequence) == 0
|
||||
|
||||
def test_init_with_windows(self, sample_time_window_1, sample_time_window_2):
|
||||
"""Test initialization with windows."""
|
||||
sequence = TimeWindowSequence(windows=[sample_time_window_1, sample_time_window_2])
|
||||
assert len(sequence) == 2
|
||||
assert sequence.windows is not None # make mypy happy
|
||||
assert sequence.windows[0] == sample_time_window_1
|
||||
assert sequence.windows[1] == sample_time_window_2
|
||||
|
||||
def test_iterator_protocol(self, sample_sequence):
|
||||
"""Test that sequence supports iteration."""
|
||||
windows = list(sample_sequence)
|
||||
assert len(windows) == 2
|
||||
assert all(isinstance(window, TimeWindow) for window in windows)
|
||||
|
||||
def test_indexing(self, sample_sequence, sample_time_window_1):
|
||||
"""Test indexing into sequence."""
|
||||
assert sample_sequence[0] == sample_time_window_1
|
||||
|
||||
def test_length(self, sample_sequence):
|
||||
"""Test len() support."""
|
||||
assert len(sample_sequence) == 2
|
||||
|
||||
def test_contains_empty_sequence(self, reference_date):
|
||||
"""Test contains() with empty sequence returns False."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert not sequence.contains(reference_date)
|
||||
assert not sequence.contains(reference_date, Duration(hours=1))
|
||||
|
||||
def test_contains_datetime_in_window(self, sample_sequence, reference_date):
|
||||
"""Test contains() finds datetime in one of the windows."""
|
||||
# 10:00 AM should be in the morning window (9:00 AM - 12:00 PM)
|
||||
test_time = reference_date.replace(hour=10, minute=0)
|
||||
assert sample_sequence.contains(test_time)
|
||||
|
||||
def test_contains_datetime_not_in_any_window(self, sample_sequence, reference_date):
|
||||
"""Test contains() returns False when datetime is not in any window."""
|
||||
# 1:00 PM should not be in any window (gap between morning and afternoon)
|
||||
test_time = reference_date.replace(hour=13, minute=0)
|
||||
assert not sample_sequence.contains(test_time)
|
||||
|
||||
def test_contains_with_duration_fits(self, sample_sequence, reference_date):
|
||||
"""Test contains() with duration that fits in a window."""
|
||||
# 10:00 AM with 1 hour duration should fit in morning window
|
||||
test_time = reference_date.replace(hour=10, minute=0)
|
||||
assert sample_sequence.contains(test_time, Duration(hours=1))
|
||||
|
||||
def test_contains_with_duration_too_long(self, sample_sequence, reference_date):
|
||||
"""Test contains() with duration that doesn't fit in any window."""
|
||||
# 11:00 AM with 2 hours duration won't fit in remaining morning window time
|
||||
test_time = reference_date.replace(hour=11, minute=0)
|
||||
assert not sample_sequence.contains(test_time, Duration(hours=2))
|
||||
|
||||
def test_earliest_start_time_empty_sequence(self, reference_date):
|
||||
"""Test earliest_start_time() with empty sequence returns None."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert sequence.earliest_start_time(Duration(hours=1), reference_date) is None
|
||||
|
||||
def test_earliest_start_time_finds_earliest(self, sample_sequence, reference_date):
|
||||
"""Test earliest_start_time() finds the earliest time across all windows."""
|
||||
# Should return 9:00 AM (start of morning window)
|
||||
earliest = sample_sequence.earliest_start_time(Duration(hours=1), reference_date)
|
||||
expected = reference_date.replace(hour=9, minute=0, second=0, microsecond=0)
|
||||
assert earliest == expected
|
||||
|
||||
def test_earliest_start_time_duration_too_long(self, sample_sequence, reference_date):
|
||||
"""Test earliest_start_time() with duration longer than any window."""
|
||||
# 4 hours won't fit in any 3-hour window
|
||||
assert sample_sequence.earliest_start_time(Duration(hours=4), reference_date) is None
|
||||
|
||||
def test_latest_start_time_empty_sequence(self, reference_date):
|
||||
"""Test latest_start_time() with empty sequence returns None."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert sequence.latest_start_time(Duration(hours=1), reference_date) is None
|
||||
|
||||
def test_latest_start_time_finds_latest(self, sample_sequence, reference_date):
|
||||
"""Test latest_start_time() finds the latest time across all windows."""
|
||||
# Should return 4:00 PM (latest start for 1 hour in afternoon window)
|
||||
latest = sample_sequence.latest_start_time(Duration(hours=1), reference_date)
|
||||
expected = reference_date.replace(hour=16, minute=0, second=0, microsecond=0)
|
||||
assert latest == expected
|
||||
|
||||
def test_can_fit_duration_empty_sequence(self, reference_date):
|
||||
"""Test can_fit_duration() with empty sequence returns False."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert not sequence.can_fit_duration(Duration(hours=1), reference_date)
|
||||
|
||||
def test_can_fit_duration_fits_in_one_window(self, sample_sequence, reference_date):
|
||||
"""Test can_fit_duration() returns True when duration fits in one window."""
|
||||
assert sample_sequence.can_fit_duration(Duration(hours=2), reference_date)
|
||||
|
||||
def test_can_fit_duration_too_long(self, sample_sequence, reference_date):
|
||||
"""Test can_fit_duration() returns False when duration is too long."""
|
||||
assert not sample_sequence.can_fit_duration(Duration(hours=4), reference_date)
|
||||
|
||||
def test_available_duration_empty_sequence(self, reference_date):
|
||||
"""Test available_duration() with empty sequence returns None."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert sequence.available_duration(reference_date) is None
|
||||
|
||||
def test_available_duration_sums_all_windows(self, sample_sequence, reference_date):
|
||||
"""Test available_duration() sums durations from all applicable windows."""
|
||||
# 3 hours + 3 hours = 6 hours total
|
||||
total = sample_sequence.available_duration(reference_date)
|
||||
assert total == Duration(hours=6)
|
||||
|
||||
def test_available_duration_with_day_restriction(self, monday_window, reference_date):
|
||||
"""Test available_duration() respects day restrictions."""
|
||||
sequence = TimeWindowSequence(windows=[monday_window])
|
||||
|
||||
# Reference date is Wednesday, so Monday window shouldn't apply
|
||||
assert sequence.available_duration(reference_date) is None
|
||||
|
||||
# Monday date should apply
|
||||
monday_date = pendulum.parse("2025-01-13T08:00:00") # Monday
|
||||
assert sequence.available_duration(monday_date) == Duration(hours=1)
|
||||
|
||||
def test_get_applicable_windows_empty_sequence(self, reference_date):
|
||||
"""Test get_applicable_windows() with empty sequence."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert sequence.get_applicable_windows(reference_date) == []
|
||||
|
||||
def test_get_applicable_windows_all_apply(self, sample_sequence, reference_date):
|
||||
"""Test get_applicable_windows() returns all windows when they all apply."""
|
||||
applicable = sample_sequence.get_applicable_windows(reference_date)
|
||||
assert len(applicable) == 2
|
||||
|
||||
def test_get_applicable_windows_with_restrictions(self, monday_window, reference_date):
|
||||
"""Test get_applicable_windows() respects day restrictions."""
|
||||
sequence = TimeWindowSequence(windows=[monday_window])
|
||||
|
||||
# Wednesday - no applicable windows
|
||||
assert sequence.get_applicable_windows(reference_date) == []
|
||||
|
||||
# Monday - one applicable window
|
||||
monday_date = pendulum.parse("2025-01-13T08:00:00")
|
||||
applicable = sequence.get_applicable_windows(monday_date)
|
||||
assert len(applicable) == 1
|
||||
assert applicable[0] == monday_window
|
||||
|
||||
def test_find_windows_for_duration_empty_sequence(self, reference_date):
|
||||
"""Test find_windows_for_duration() with empty sequence."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert sequence.find_windows_for_duration(Duration(hours=1), reference_date) == []
|
||||
|
||||
def test_find_windows_for_duration_all_fit(self, sample_sequence, reference_date):
|
||||
"""Test find_windows_for_duration() when duration fits in all windows."""
|
||||
fitting = sample_sequence.find_windows_for_duration(Duration(hours=2), reference_date)
|
||||
assert len(fitting) == 2
|
||||
|
||||
def test_find_windows_for_duration_some_fit(self, sample_sequence, reference_date):
|
||||
"""Test find_windows_for_duration() when duration fits in some windows."""
|
||||
# Add a short window that can't fit 2.5 hours
|
||||
short_window = TimeWindow(start_time=Time(18, 0, 0), duration=Duration(hours=1))
|
||||
sequence = TimeWindowSequence(windows=sample_sequence.windows + [short_window])
|
||||
|
||||
fitting = sequence.find_windows_for_duration(Duration(hours=2, minutes=30), reference_date)
|
||||
assert len(fitting) == 2 # Only the first two windows can fit 2.5 hours
|
||||
|
||||
def test_get_all_possible_start_times_empty_sequence(self, reference_date):
|
||||
"""Test get_all_possible_start_times() with empty sequence."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert sequence.get_all_possible_start_times(Duration(hours=1), reference_date) == []
|
||||
|
||||
def test_get_all_possible_start_times_multiple_windows(self, sample_sequence, reference_date):
|
||||
"""Test get_all_possible_start_times() returns ranges for all fitting windows."""
|
||||
ranges = sample_sequence.get_all_possible_start_times(Duration(hours=1), reference_date)
|
||||
assert len(ranges) == 2
|
||||
|
||||
# Check morning window range
|
||||
earliest_morning, latest_morning, morning_window = ranges[0]
|
||||
assert earliest_morning == reference_date.replace(hour=9, minute=0, second=0, microsecond=0)
|
||||
assert latest_morning == reference_date.replace(hour=11, minute=0, second=0, microsecond=0)
|
||||
|
||||
# Check afternoon window range
|
||||
earliest_afternoon, latest_afternoon, afternoon_window = ranges[1]
|
||||
assert earliest_afternoon == reference_date.replace(hour=14, minute=0, second=0, microsecond=0)
|
||||
assert latest_afternoon == reference_date.replace(hour=16, minute=0, second=0, microsecond=0)
|
||||
|
||||
def test_add_window(self, sample_time_window_1):
|
||||
"""Test adding a window to the sequence."""
|
||||
sequence = TimeWindowSequence()
|
||||
assert len(sequence) == 0
|
||||
|
||||
sequence.add_window(sample_time_window_1)
|
||||
assert len(sequence) == 1
|
||||
assert sequence[0] == sample_time_window_1
|
||||
|
||||
def test_remove_window(self, sample_sequence, sample_time_window_1):
|
||||
"""Test removing a window from the sequence."""
|
||||
assert len(sample_sequence) == 2
|
||||
|
||||
removed = sample_sequence.remove_window(0)
|
||||
assert removed == sample_time_window_1
|
||||
assert len(sample_sequence) == 1
|
||||
|
||||
def test_remove_window_invalid_index(self, sample_sequence):
|
||||
"""Test removing a window with invalid index raises IndexError."""
|
||||
with pytest.raises(IndexError):
|
||||
sample_sequence.remove_window(10)
|
||||
|
||||
def test_remove_window_from_empty_sequence(self):
|
||||
"""Test removing a window from empty sequence raises IndexError."""
|
||||
sequence = TimeWindowSequence()
|
||||
with pytest.raises(IndexError):
|
||||
sequence.remove_window(0)
|
||||
|
||||
def test_clear_windows(self, sample_sequence):
|
||||
"""Test clearing all windows from the sequence."""
|
||||
assert len(sample_sequence) == 2
|
||||
|
||||
sample_sequence.clear_windows()
|
||||
assert len(sample_sequence) == 0
|
||||
assert sample_sequence.windows == []
|
||||
|
||||
def test_sort_windows_by_start_time(self, reference_date):
|
||||
"""Test sorting windows by start time."""
|
||||
# Create windows in reverse chronological order
|
||||
afternoon_window = TimeWindow(start_time=Time(14, 0, 0), duration=Duration(hours=2))
|
||||
morning_window = TimeWindow(start_time=Time(9, 0, 0), duration=Duration(hours=2))
|
||||
evening_window = TimeWindow(start_time=Time(18, 0, 0), duration=Duration(hours=2))
|
||||
|
||||
sequence = TimeWindowSequence(windows=[afternoon_window, morning_window, evening_window])
|
||||
sequence.sort_windows_by_start_time(reference_date)
|
||||
|
||||
# Should now be sorted: morning, afternoon, evening
|
||||
assert sequence[0] == morning_window
|
||||
assert sequence[1] == afternoon_window
|
||||
assert sequence[2] == evening_window
|
||||
|
||||
def test_sort_windows_with_non_applicable_windows(self, monday_window, reference_date):
|
||||
"""Test sorting windows with some non-applicable windows."""
|
||||
daily_window = TimeWindow(start_time=Time(10, 0, 0), duration=Duration(hours=1))
|
||||
|
||||
sequence = TimeWindowSequence(windows=[monday_window, daily_window])
|
||||
sequence.sort_windows_by_start_time(reference_date) # Wednesday
|
||||
|
||||
# Daily window should come first (applicable), Monday window last (not applicable)
|
||||
assert sequence[0] == daily_window
|
||||
assert sequence[1] == monday_window
|
||||
|
||||
def test_sort_windows_empty_sequence(self, reference_date):
|
||||
"""Test sorting an empty sequence doesn't raise errors."""
|
||||
sequence = TimeWindowSequence()
|
||||
sequence.sort_windows_by_start_time(reference_date)
|
||||
assert len(sequence) == 0
|
||||
|
||||
def test_default_reference_date_handling(self, sample_sequence):
|
||||
"""Test that methods handle default reference date (today) correctly."""
|
||||
# These should not raise errors and should return reasonable values
|
||||
assert isinstance(sample_sequence.can_fit_duration(Duration(hours=1)), bool)
|
||||
assert sample_sequence.available_duration() is not None
|
||||
assert isinstance(sample_sequence.get_applicable_windows(), list)
|
||||
|
||||
def test_specific_date_window_functionality(self, specific_date_window):
|
||||
"""Test functionality with specific date restrictions."""
|
||||
sequence = TimeWindowSequence(windows=[specific_date_window])
|
||||
|
||||
# Should work on the specific date
|
||||
specific_date = pendulum.parse("2025-01-15T12:00:00")
|
||||
assert sequence.can_fit_duration(Duration(hours=1), specific_date)
|
||||
|
||||
# Should not work on other dates
|
||||
other_date = pendulum.parse("2025-01-16T12:00:00")
|
||||
assert not sequence.can_fit_duration(Duration(hours=1), other_date)
|
||||
|
||||
def test_edge_cases_with_zero_duration(self, sample_sequence, reference_date):
|
||||
"""Test edge cases with zero duration."""
|
||||
zero_duration = Duration()
|
||||
|
||||
# Should be able to fit zero duration
|
||||
assert sample_sequence.can_fit_duration(zero_duration, reference_date)
|
||||
|
||||
# Should find start times for zero duration
|
||||
earliest = sample_sequence.earliest_start_time(zero_duration, reference_date)
|
||||
assert earliest is not None
|
||||
|
||||
def test_overlapping_windows(self, reference_date):
|
||||
"""Test behavior with overlapping windows."""
|
||||
window1 = TimeWindow(start_time=Time(10, 0, 0), duration=Duration(hours=3))
|
||||
window2 = TimeWindow(start_time=Time(11, 0, 0), duration=Duration(hours=3))
|
||||
|
||||
sequence = TimeWindowSequence(windows=[window1, window2])
|
||||
|
||||
# Should handle overlapping windows correctly
|
||||
test_time = reference_date.replace(hour=11, minute=30)
|
||||
assert sequence.contains(test_time)
|
||||
|
||||
# Total duration should be sum of both windows (even though they overlap)
|
||||
total = sequence.available_duration(reference_date)
|
||||
assert total == Duration(hours=6)
|
||||
|
||||
def test_sequence_model_dump(self, sample_sequence_json):
|
||||
"""Test that model dump creates the correct json."""
|
||||
assert sample_sequence_json == json.loads("""
|
||||
{
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "09:00:00.000000",
|
||||
"duration": "3 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null
|
||||
},
|
||||
{
|
||||
"start_time": "14:00:00.000000",
|
||||
"duration": "3 hours",
|
||||
"day_of_week": null,
|
||||
"date": null,
|
||||
"locale": null
|
||||
}
|
||||
]
|
||||
}""")
|
||||
|
||||
|
||||
# -----------------------------
|
||||
# to_datetime
|
||||
# -----------------------------
|
||||
|
||||
330
tests/test_elecpricefixed.py
Normal file
330
tests/test_elecpricefixed.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""Tests for fixed electricity price prediction module."""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
import numpy as np
|
||||
import pandas as pd
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.configabc import ValueTimeWindow, ValueTimeWindowSequence
|
||||
from akkudoktoreos.core.cache import CacheFileStore
|
||||
from akkudoktoreos.core.coreabc import get_ems
|
||||
from akkudoktoreos.prediction.elecpricefixed import (
|
||||
ElecPriceFixed,
|
||||
ElecPriceFixedCommonSettings,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import Duration, to_datetime
|
||||
|
||||
DIR_TESTDATA = Path(__file__).absolute().parent.joinpath("testdata")
|
||||
FILE_TESTDATA_ELECPRICEFIXED_CONFIG_JSON = DIR_TESTDATA.joinpath("elecpricefixed_config.json")
|
||||
|
||||
|
||||
class TestElecPriceFixedCommonSettings:
|
||||
"""Tests for ElecPriceFixedCommonSettings model."""
|
||||
|
||||
def test_create_settings_with_windows(self):
|
||||
"""Test creating settings with time windows."""
|
||||
settings_dict = {
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "00:00",
|
||||
"duration": "8 hours",
|
||||
"value": 0.288
|
||||
},
|
||||
{
|
||||
"start_time": "08:00",
|
||||
"duration": "16 hours",
|
||||
"value": 0.34
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
settings = ElecPriceFixedCommonSettings(**settings_dict)
|
||||
assert settings is not None
|
||||
assert settings.time_windows is not None
|
||||
assert settings.time_windows.windows is not None
|
||||
assert len(settings.time_windows.windows) == 2
|
||||
|
||||
def test_create_settings_without_windows(self):
|
||||
"""Test creating settings without time windows."""
|
||||
settings = ElecPriceFixedCommonSettings()
|
||||
assert settings.time_windows is not None
|
||||
assert settings.time_windows.windows == []
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def provider(monkeypatch, config_eos):
|
||||
"""Fixture to create a ElecPriceFixed provider instance."""
|
||||
# Set environment variables
|
||||
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "ElecPriceFixed")
|
||||
|
||||
# Create time windows
|
||||
time_windows = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
start_time="00:00",
|
||||
duration="8 hours",
|
||||
value=0.288
|
||||
),
|
||||
ValueTimeWindow(
|
||||
start_time="08:00",
|
||||
duration="16 hours",
|
||||
value=0.34
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
# Create settings and assign to config
|
||||
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(time_windows=time_windows)
|
||||
|
||||
ElecPriceFixed.reset_instance()
|
||||
return ElecPriceFixed()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache_store():
|
||||
"""A pytest fixture that creates a new CacheFileStore instance for testing."""
|
||||
return CacheFileStore()
|
||||
|
||||
|
||||
class TestElecPriceFixed:
|
||||
"""Tests for ElecPriceFixed provider."""
|
||||
|
||||
def test_provider_id(self, provider):
|
||||
"""Test provider ID returns correct value."""
|
||||
assert provider.provider_id() == "ElecPriceFixed"
|
||||
|
||||
def test_singleton_instance(self, provider):
|
||||
"""Test that ElecPriceFixed behaves as a singleton."""
|
||||
another_instance = ElecPriceFixed()
|
||||
assert provider is another_instance
|
||||
|
||||
def test_invalid_provider(self, provider, monkeypatch):
|
||||
"""Test requesting an unsupported provider."""
|
||||
monkeypatch.setenv("EOS_ELECPRICE__ELECPRICE_PROVIDER", "<invalid>")
|
||||
provider.config.reset_settings()
|
||||
assert not provider.enabled()
|
||||
|
||||
def test_update_data_hourly_intervals(self, provider, config_eos):
|
||||
"""Test updating data with hourly intervals (3600s)."""
|
||||
# Set start datetime
|
||||
ems_eos = get_ems()
|
||||
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
|
||||
ems_eos.set_start_datetime(start_dt)
|
||||
|
||||
# Configure hourly intervals
|
||||
config_eos.optimization.interval = 3600
|
||||
config_eos.prediction.hours = 24
|
||||
|
||||
# Update data
|
||||
provider.update_data(force_enable=True, force_update=True)
|
||||
|
||||
# Verify data was generated
|
||||
assert len(provider) == 24 # 24 hours * 1 interval per hour
|
||||
|
||||
# Check prices
|
||||
records = provider.records
|
||||
|
||||
# First 8 hours should be night rate (0.288 kWh = 0.000288 Wh)
|
||||
for i in range(8):
|
||||
assert abs(records[i].elecprice_marketprice_wh - 0.000288) < 1e-6
|
||||
# Verify timestamps are on hour boundaries
|
||||
assert records[i].date_time.minute == 0
|
||||
assert records[i].date_time.second == 0
|
||||
|
||||
# Next 16 hours should be day rate (0.34 kWh = 0.00034 Wh)
|
||||
for i in range(8, 24):
|
||||
assert abs(records[i].elecprice_marketprice_wh - 0.00034) < 1e-6
|
||||
|
||||
def test_update_data_15min_intervals(self, provider, config_eos):
|
||||
"""Test updating data with 15-minute intervals (900s)."""
|
||||
ems_eos = get_ems()
|
||||
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
|
||||
ems_eos.set_start_datetime(start_dt)
|
||||
|
||||
config_eos.optimization.interval = 900
|
||||
config_eos.prediction.hours = 10 # spans both windows: 00:00–10:00 = 40 intervals
|
||||
|
||||
provider.update_data(force_enable=True, force_update=True)
|
||||
|
||||
# 10 hours * 4 intervals per hour = 40 intervals
|
||||
assert len(provider) == 40
|
||||
|
||||
records = provider.records
|
||||
|
||||
# Check timestamps are on 15-minute boundaries
|
||||
for record in records:
|
||||
assert record.date_time.minute in (0, 15, 30, 45)
|
||||
assert record.date_time.second == 0
|
||||
|
||||
# First 32 intervals: 00:00–08:00, night rate (8h * 4 = 32)
|
||||
for i in range(32):
|
||||
assert abs(records[i].elecprice_marketprice_wh - 0.000288) < 1e-6, (
|
||||
f"Expected night rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
|
||||
)
|
||||
|
||||
# Remaining 8 intervals: 08:00–10:00, day rate (2h * 4 = 8)
|
||||
for i in range(32, 40):
|
||||
assert abs(records[i].elecprice_marketprice_wh - 0.00034) < 1e-6, (
|
||||
f"Expected day rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
|
||||
)
|
||||
|
||||
def test_update_data_30min_intervals(self, provider, config_eos):
|
||||
"""Test updating data with 30-minute intervals (1800s)."""
|
||||
ems_eos = get_ems()
|
||||
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
|
||||
ems_eos.set_start_datetime(start_dt)
|
||||
|
||||
config_eos.optimization.interval = 1800
|
||||
config_eos.prediction.hours = 10 # spans both windows: 00:00–10:00 = 20 intervals
|
||||
|
||||
provider.update_data(force_enable=True, force_update=True)
|
||||
|
||||
# 10 hours * 2 intervals per hour = 20 intervals
|
||||
assert len(provider) == 20
|
||||
|
||||
records = provider.records
|
||||
|
||||
# Check timestamps are on 30-minute boundaries
|
||||
for record in records:
|
||||
assert record.date_time.minute in (0, 30)
|
||||
assert record.date_time.second == 0
|
||||
|
||||
# First 16 intervals: 00:00–08:00, night rate (8h * 2 = 16)
|
||||
for i in range(16):
|
||||
assert abs(records[i].elecprice_marketprice_wh - 0.000288) < 1e-6, (
|
||||
f"Expected night rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
|
||||
)
|
||||
|
||||
# Remaining 4 intervals: 08:00–10:00, day rate (2h * 2 = 4)
|
||||
for i in range(16, 20):
|
||||
assert abs(records[i].elecprice_marketprice_wh - 0.00034) < 1e-6, (
|
||||
f"Expected day rate at interval {i}, got {records[i].elecprice_marketprice_wh}"
|
||||
)
|
||||
|
||||
def test_update_data_without_config(self, provider, config_eos):
|
||||
"""Test update_data fails without configuration."""
|
||||
# Remove elecpricefixed settings
|
||||
config_eos.elecprice.elecpricefixed = {}
|
||||
|
||||
with pytest.raises(ValueError, match="No time windows configured"):
|
||||
provider.update_data(force_enable=True, force_update=True)
|
||||
|
||||
def test_update_data_without_time_windows(self, provider, config_eos):
|
||||
"""Test update_data fails without time windows."""
|
||||
# Set empty time windows
|
||||
empty_settings = ElecPriceFixedCommonSettings(time_windows=ValueTimeWindowSequence(windows=[]))
|
||||
config_eos.elecprice.elecpricefixed = empty_settings
|
||||
|
||||
with pytest.raises(ValueError, match="No time windows configured"):
|
||||
provider.update_data(force_enable=True, force_update=True)
|
||||
|
||||
def test_key_to_array_resampling(self, provider, config_eos):
|
||||
"""Test that key_to_array can resample to different intervals."""
|
||||
# Setup provider with hourly data
|
||||
ems_eos = get_ems()
|
||||
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
|
||||
ems_eos.set_start_datetime(start_dt)
|
||||
|
||||
config_eos.optimization.interval = 3600
|
||||
config_eos.prediction.hours = 24
|
||||
|
||||
provider.update_data(force_enable=True, force_update=True)
|
||||
|
||||
# Get data as hourly array (original)
|
||||
hourly_array = provider.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=start_dt,
|
||||
end_datetime=start_dt.add(hours=24)
|
||||
)
|
||||
|
||||
assert len(hourly_array) == 24
|
||||
assert abs(hourly_array[0] - 0.000288) < 1e-6 # Night rate
|
||||
assert abs(hourly_array[8] - 0.00034) < 1e-6 # Day rate
|
||||
|
||||
# Resample to 15-minute intervals
|
||||
quarter_hour_array = provider.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=start_dt,
|
||||
end_datetime=start_dt.add(hours=24),
|
||||
interval="15 minutes"
|
||||
)
|
||||
|
||||
assert len(quarter_hour_array) == 96 # 24 * 4
|
||||
# First 4 15-min intervals should be night rate
|
||||
for i in range(4):
|
||||
assert abs(quarter_hour_array[i] - 0.000288) < 1e-6
|
||||
|
||||
# Resample to 30-minute intervals
|
||||
half_hour_array = provider.key_to_array(
|
||||
key="elecprice_marketprice_wh",
|
||||
start_datetime=start_dt,
|
||||
end_datetime=start_dt.add(hours=24),
|
||||
interval="30 minutes"
|
||||
)
|
||||
|
||||
assert len(half_hour_array) == 48 # 24 * 2
|
||||
# First 2 30-min intervals should be night rate
|
||||
for i in range(2):
|
||||
assert abs(half_hour_array[i] - 0.000288) < 1e-6
|
||||
|
||||
|
||||
class TestElecPriceFixedIntegration:
|
||||
"""Integration tests for ElecPriceFixed."""
|
||||
|
||||
@pytest.mark.skip(reason="For development only")
|
||||
def test_fixed_price_development(self, config_eos):
|
||||
"""Test fixed price provider with real configuration."""
|
||||
# Create provider with config
|
||||
provider = ElecPriceFixed()
|
||||
|
||||
# Setup realistic test scenario
|
||||
ems_eos = get_ems()
|
||||
start_dt = to_datetime("2024-01-01 00:00:00", in_timezone="Europe/Berlin")
|
||||
ems_eos.set_start_datetime(start_dt)
|
||||
|
||||
# Configure with realistic German electricity prices (2024)
|
||||
time_windows = ValueTimeWindowSequence(
|
||||
windows=[
|
||||
ValueTimeWindow(
|
||||
start_time="00:00",
|
||||
duration="8 hours",
|
||||
value=0.288 # Night rate
|
||||
),
|
||||
ValueTimeWindow(
|
||||
start_time="08:00",
|
||||
duration="16 hours",
|
||||
value=0.34 # Day rate
|
||||
)
|
||||
]
|
||||
)
|
||||
|
||||
config_eos.elecprice.elecpricefixed = ElecPriceFixedCommonSettings(time_windows=time_windows)
|
||||
config_eos.prediction.hours = 168 # 7 days
|
||||
config_eos.optimization.interval = 900 # 15 minutes
|
||||
|
||||
# Update data
|
||||
provider.update_data(force_enable=True, force_update=True)
|
||||
|
||||
# Verify data
|
||||
expected_intervals = 168 * 4 # 7 days * 24h * 4 intervals
|
||||
assert len(provider) == expected_intervals
|
||||
|
||||
# Save configuration for documentation
|
||||
config_data = {
|
||||
"time_windows": [
|
||||
{
|
||||
"start_time": str(window.start_time),
|
||||
"duration": str(window.duration),
|
||||
"value": window.value
|
||||
}
|
||||
for window in config_eos.elecprice.elecpricefixed.time_windows.windows
|
||||
]
|
||||
}
|
||||
|
||||
with FILE_TESTDATA_ELECPRICEFIXED_CONFIG_JSON.open("w", encoding="utf-8") as f:
|
||||
json.dump(config_data, f, indent=4)
|
||||
@@ -1,6 +1,7 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
|
||||
from akkudoktoreos.devices.genetic.battery import Battery
|
||||
from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter
|
||||
@@ -16,12 +17,7 @@ from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSimulationResult
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
TimeWindow,
|
||||
TimeWindowSequence,
|
||||
to_duration,
|
||||
to_time,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration, to_time
|
||||
|
||||
start_hour = 1
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from akkudoktoreos.config.configabc import TimeWindow, TimeWindowSequence
|
||||
from akkudoktoreos.devices.genetic.battery import Battery
|
||||
from akkudoktoreos.devices.genetic.homeappliance import HomeAppliance
|
||||
from akkudoktoreos.devices.genetic.inverter import Inverter
|
||||
@@ -16,12 +17,7 @@ from akkudoktoreos.optimization.genetic.geneticparams import (
|
||||
GeneticOptimizationParameters,
|
||||
)
|
||||
from akkudoktoreos.optimization.genetic.geneticsolution import GeneticSimulationResult
|
||||
from akkudoktoreos.utils.datetimeutil import (
|
||||
TimeWindow,
|
||||
TimeWindowSequence,
|
||||
to_duration,
|
||||
to_time,
|
||||
)
|
||||
from akkudoktoreos.utils.datetimeutil import to_duration, to_time
|
||||
|
||||
start_hour = 0
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ from pydantic import ValidationError
|
||||
from akkudoktoreos.core.coreabc import get_prediction
|
||||
from akkudoktoreos.prediction.elecpriceakkudoktor import ElecPriceAkkudoktor
|
||||
from akkudoktoreos.prediction.elecpriceenergycharts import ElecPriceEnergyCharts
|
||||
from akkudoktoreos.prediction.elecpricefixed import ElecPriceFixed
|
||||
from akkudoktoreos.prediction.elecpriceimport import ElecPriceImport
|
||||
from akkudoktoreos.prediction.feedintarifffixed import FeedInTariffFixed
|
||||
from akkudoktoreos.prediction.feedintariffimport import FeedInTariffImport
|
||||
@@ -37,6 +38,7 @@ def forecast_providers():
|
||||
return [
|
||||
ElecPriceAkkudoktor(),
|
||||
ElecPriceEnergyCharts(),
|
||||
ElecPriceFixed(),
|
||||
ElecPriceImport(),
|
||||
FeedInTariffFixed(),
|
||||
FeedInTariffImport(),
|
||||
@@ -76,32 +78,34 @@ def test_prediction_common_settings_invalid(field_name, invalid_value, expected_
|
||||
def test_initialization(prediction, forecast_providers):
|
||||
"""Test that Prediction is initialized with the correct providers in sequence."""
|
||||
assert isinstance(prediction, Prediction)
|
||||
assert prediction.providers == forecast_providers
|
||||
for idx, provider in enumerate(prediction.providers):
|
||||
assert provider.provider_id() == forecast_providers[idx].provider_id()
|
||||
|
||||
|
||||
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], ElecPriceImport)
|
||||
assert isinstance(prediction.providers[3], FeedInTariffFixed)
|
||||
assert isinstance(prediction.providers[4], FeedInTariffImport)
|
||||
assert isinstance(prediction.providers[5], LoadAkkudoktor)
|
||||
assert isinstance(prediction.providers[6], LoadAkkudoktorAdjusted)
|
||||
assert isinstance(prediction.providers[7], LoadVrm)
|
||||
assert isinstance(prediction.providers[8], LoadImport)
|
||||
assert isinstance(prediction.providers[9], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[10], PVForecastVrm)
|
||||
assert isinstance(prediction.providers[11], PVForecastImport)
|
||||
assert isinstance(prediction.providers[12], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[13], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[14], WeatherImport)
|
||||
assert isinstance(prediction.providers[2], ElecPriceFixed)
|
||||
assert isinstance(prediction.providers[3], ElecPriceImport)
|
||||
assert isinstance(prediction.providers[4], FeedInTariffFixed)
|
||||
assert isinstance(prediction.providers[5], FeedInTariffImport)
|
||||
assert isinstance(prediction.providers[6], LoadAkkudoktor)
|
||||
assert isinstance(prediction.providers[7], LoadAkkudoktorAdjusted)
|
||||
assert isinstance(prediction.providers[8], LoadVrm)
|
||||
assert isinstance(prediction.providers[9], LoadImport)
|
||||
assert isinstance(prediction.providers[10], PVForecastAkkudoktor)
|
||||
assert isinstance(prediction.providers[11], PVForecastVrm)
|
||||
assert isinstance(prediction.providers[12], PVForecastImport)
|
||||
assert isinstance(prediction.providers[13], WeatherBrightSky)
|
||||
assert isinstance(prediction.providers[14], WeatherClearOutside)
|
||||
assert isinstance(prediction.providers[15], WeatherImport)
|
||||
|
||||
|
||||
def test_provider_by_id(prediction, forecast_providers):
|
||||
"""Test that provider_by_id method returns the correct provider."""
|
||||
for provider in forecast_providers:
|
||||
assert prediction.provider_by_id(provider.provider_id()) == provider
|
||||
assert prediction.provider_by_id(provider.provider_id()).provider_id() == provider.provider_id()
|
||||
|
||||
|
||||
def test_prediction_repr(prediction):
|
||||
@@ -110,6 +114,7 @@ def test_prediction_repr(prediction):
|
||||
assert "Prediction([" in result
|
||||
assert "ElecPriceAkkudoktor" in result
|
||||
assert "ElecPriceEnergyCharts" in result
|
||||
assert "ElecPriceFixed" in result
|
||||
assert "ElecPriceImport" in result
|
||||
assert "FeedInTariffFixed" in result
|
||||
assert "FeedInTariffImport" in result
|
||||
|
||||
@@ -166,6 +166,252 @@ class TestSystem:
|
||||
else:
|
||||
pass
|
||||
|
||||
def test_measurement(self, server_setup_for_class, is_system_test):
|
||||
"""Test measurement endpoints comprehensively."""
|
||||
server = server_setup_for_class["server"]
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 1. Setup: Reset config with test measurement keys
|
||||
# ----------------------------------------------------------------------
|
||||
with FILE_TESTDATA_EOSSERVER_CONFIG_1.open("r", encoding="utf-8", newline=None) as fd:
|
||||
config = json.load(fd)
|
||||
|
||||
config.setdefault("measurement", {})
|
||||
config["measurement"]["pv_production_emr_keys"] = ["pv1_emr", "pv2_emr"]
|
||||
config["measurement"]["load_emr_keys"] = ["load1_emr"]
|
||||
|
||||
result = requests.put(f"{server}/v1/config", json=config)
|
||||
assert result.status_code == HTTPStatus.OK, f"Config update failed: {result.text}"
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 2. GET /v1/measurement/keys
|
||||
# ----------------------------------------------------------------------
|
||||
result = requests.get(f"{server}/v1/measurement/keys")
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to get measurement keys: {result.text}"
|
||||
|
||||
keys = result.json()
|
||||
assert isinstance(keys, list)
|
||||
assert "pv1_emr" in keys
|
||||
assert "pv2_emr" in keys
|
||||
assert "load1_emr" in keys
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 3. PUT /v1/measurement/value
|
||||
# ----------------------------------------------------------------------
|
||||
# Float value
|
||||
result = requests.put(
|
||||
f"{server}/v1/measurement/value",
|
||||
params={
|
||||
"datetime": "2026-03-08T18:00:00Z",
|
||||
"key": "pv1_emr",
|
||||
"value": "1000.0",
|
||||
},
|
||||
)
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to PUT float value: {result.text}"
|
||||
series_response = result.json()
|
||||
# PydanticDateTimeSeries has shape: {"data": {datetime_str: value}, "dtype": str, "tz": str|None}
|
||||
assert "data" in series_response
|
||||
assert isinstance(series_response["data"], dict)
|
||||
assert len(series_response["data"]) >= 1
|
||||
|
||||
# String value that converts to float
|
||||
result = requests.put(
|
||||
f"{server}/v1/measurement/value",
|
||||
params={
|
||||
"datetime": "2026-03-08T19:00:00Z",
|
||||
"key": "pv1_emr",
|
||||
"value": "2000.0",
|
||||
},
|
||||
)
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to PUT string float value: {result.text}"
|
||||
|
||||
# Non-numeric string value must be rejected
|
||||
result = requests.put(
|
||||
f"{server}/v1/measurement/value",
|
||||
params={
|
||||
"datetime": "2026-03-08T20:00:00Z",
|
||||
"key": "pv1_emr",
|
||||
"value": "not_a_number",
|
||||
},
|
||||
)
|
||||
assert result.status_code == HTTPStatus.BAD_REQUEST, (
|
||||
f"Expected 400 for non-numeric string, got {result.status_code}"
|
||||
)
|
||||
|
||||
# Non-existent key must be rejected
|
||||
result = requests.put(
|
||||
f"{server}/v1/measurement/value",
|
||||
params={
|
||||
"datetime": "2026-03-08T18:00:00Z",
|
||||
"key": "non_existent_key",
|
||||
"value": "1000.0",
|
||||
},
|
||||
)
|
||||
assert result.status_code == HTTPStatus.NOT_FOUND, (
|
||||
f"Expected 404 for unknown key, got {result.status_code}"
|
||||
)
|
||||
|
||||
# Missing required parameter (datetime)
|
||||
result = requests.put(
|
||||
f"{server}/v1/measurement/value",
|
||||
params={"key": "pv1_emr", "value": "1000.0"},
|
||||
)
|
||||
assert result.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, (
|
||||
f"Expected 422 for missing datetime, got {result.status_code}"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 4. GET /v1/measurement/series
|
||||
# ----------------------------------------------------------------------
|
||||
result = requests.get(f"{server}/v1/measurement/series", params={"key": "pv1_emr"})
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to GET series: {result.text}"
|
||||
|
||||
series_response = result.json()
|
||||
# PydanticDateTimeSeries: {"data": {datetime_str: value, ...}, "dtype": "float64", "tz": ...}
|
||||
assert "data" in series_response
|
||||
assert isinstance(series_response["data"], dict)
|
||||
assert "dtype" in series_response
|
||||
assert len(series_response["data"]) >= 2 # at least the two values inserted above
|
||||
|
||||
# Non-existent key must be rejected
|
||||
result = requests.get(
|
||||
f"{server}/v1/measurement/series", params={"key": "non_existent_key"}
|
||||
)
|
||||
assert result.status_code == HTTPStatus.NOT_FOUND, (
|
||||
f"Expected 404 for unknown series key, got {result.status_code}"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 5. PUT /v1/measurement/series
|
||||
# PydanticDateTimeSeries payload: {"data": {datetime_str: value, ...}, "dtype": "float64", "tz": "UTC"}
|
||||
# ----------------------------------------------------------------------
|
||||
series_payload = {
|
||||
"data": {
|
||||
"2026-03-08T10:00:00+00:00": 500.0,
|
||||
"2026-03-08T11:00:00+00:00": 600.0,
|
||||
"2026-03-08T12:00:00+00:00": 700.0,
|
||||
},
|
||||
"dtype": "float64",
|
||||
"tz": "UTC",
|
||||
}
|
||||
result = requests.put(
|
||||
f"{server}/v1/measurement/series",
|
||||
params={"key": "pv2_emr"},
|
||||
json=series_payload,
|
||||
)
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to PUT series: {result.text}"
|
||||
|
||||
series_response = result.json()
|
||||
assert "data" in series_response
|
||||
assert isinstance(series_response["data"], dict)
|
||||
assert len(series_response["data"]) >= 3
|
||||
|
||||
# Verify the data round-trips correctly
|
||||
result = requests.get(f"{server}/v1/measurement/series", params={"key": "pv2_emr"})
|
||||
assert result.status_code == HTTPStatus.OK
|
||||
fetched = result.json()
|
||||
fetched_values = list(fetched["data"].values())
|
||||
assert 500.0 in fetched_values
|
||||
assert 600.0 in fetched_values
|
||||
assert 700.0 in fetched_values
|
||||
|
||||
# Non-existent key must be rejected
|
||||
result = requests.put(
|
||||
f"{server}/v1/measurement/series",
|
||||
params={"key": "non_existent_key"},
|
||||
json=series_payload,
|
||||
)
|
||||
assert result.status_code == HTTPStatus.NOT_FOUND, (
|
||||
f"Expected 404 for unknown series PUT key, got {result.status_code}"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 6. PUT /v1/measurement/dataframe
|
||||
# PydanticDateTimeDataFrame payload:
|
||||
# {"data": {datetime_str: {"col1": val, ...}, ...}, "dtypes": {}, "tz": ..., "datetime_columns": [...]}
|
||||
# ----------------------------------------------------------------------
|
||||
dataframe_payload = {
|
||||
"data": {
|
||||
"2026-03-08T00:00:00+00:00": {"pv1_emr": 100.5, "load1_emr": 50.2},
|
||||
"2026-03-08T01:00:00+00:00": {"pv1_emr": 200.3, "load1_emr": 45.1},
|
||||
"2026-03-08T02:00:00+00:00": {"pv1_emr": 300.7, "load1_emr": 48.9},
|
||||
},
|
||||
"dtypes": {"pv1_emr": "float64", "load1_emr": "float64"},
|
||||
"tz": "UTC",
|
||||
"datetime_columns": [],
|
||||
}
|
||||
result = requests.put(f"{server}/v1/measurement/dataframe", json=dataframe_payload)
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to PUT dataframe: {result.text}"
|
||||
|
||||
# Verify data was loaded for both columns
|
||||
for key in ("pv1_emr", "load1_emr"):
|
||||
result = requests.get(f"{server}/v1/measurement/series", params={"key": key})
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to verify series for {key}"
|
||||
series_response = result.json()
|
||||
assert len(series_response["data"]) >= 3, f"Expected >=3 data points for {key}"
|
||||
|
||||
# Invalid dataframe structure (row columns inconsistent) must be rejected
|
||||
invalid_dataframe_payload = {
|
||||
"data": {
|
||||
"2026-03-08T00:00:00+00:00": {"pv1_emr": 100.0},
|
||||
"2026-03-08T01:00:00+00:00": {"pv1_emr": 200.0, "load1_emr": 45.0}, # extra column
|
||||
},
|
||||
"dtypes": {},
|
||||
"tz": "UTC",
|
||||
"datetime_columns": [],
|
||||
}
|
||||
result = requests.put(f"{server}/v1/measurement/dataframe", json=invalid_dataframe_payload)
|
||||
assert result.status_code == HTTPStatus.UNPROCESSABLE_ENTITY, (
|
||||
f"Expected 422 for inconsistent dataframe columns, got {result.status_code}"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 7. PUT /v1/measurement/data
|
||||
# PydanticDateTimeData payload (RootModel):
|
||||
# Dict[str, Union[str, List[Union[float, int, str, None]]]]
|
||||
# Columnar format: keys are column names (or special "start_datetime"/"interval"),
|
||||
# values are flat lists of equal length. Datetime index is given via start_datetime + interval.
|
||||
# ----------------------------------------------------------------------
|
||||
data_payload = {
|
||||
"start_datetime": "2026-03-09T00:00:00+00:00",
|
||||
"interval": "1 hour",
|
||||
"pv1_emr": [400.2, 450.1],
|
||||
"load1_emr": [60.5, 55.3],
|
||||
"pv2_emr": [150.8, 175.2],
|
||||
}
|
||||
result = requests.put(f"{server}/v1/measurement/data", json=data_payload)
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to PUT data dict: {result.text}"
|
||||
|
||||
# Verify all three keys received the values
|
||||
for key, expected_values in (
|
||||
("pv1_emr", [400.2, 450.1]),
|
||||
("load1_emr", [60.5, 55.3]),
|
||||
("pv2_emr", [150.8, 175.2]),
|
||||
):
|
||||
result = requests.get(f"{server}/v1/measurement/series", params={"key": key})
|
||||
assert result.status_code == HTTPStatus.OK, f"Failed to verify {key} after data PUT"
|
||||
fetched = result.json()
|
||||
fetched_values = list(fetched["data"].values())
|
||||
for expected in expected_values:
|
||||
assert expected in fetched_values, (
|
||||
f"Expected {expected} in {key} series, got {fetched_values}"
|
||||
)
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# 8. Edge case: invalid datetime in value PUT
|
||||
# ----------------------------------------------------------------------
|
||||
result = requests.put(
|
||||
f"{server}/v1/measurement/value",
|
||||
params={
|
||||
"datetime": "not-a-datetime",
|
||||
"key": "pv1_emr",
|
||||
"value": "1000.0",
|
||||
},
|
||||
)
|
||||
assert result.status_code == HTTPStatus.BAD_REQUEST, (
|
||||
f"Expected 400 for invalid datetime, got {result.status_code}"
|
||||
)
|
||||
|
||||
def test_admin_cache(self, server_setup_for_class, is_system_test):
|
||||
"""Test whether cache is reconstructed from cached files."""
|
||||
server = server_setup_for_class["server"]
|
||||
|
||||
4
tests/testdata/eos_config_stripped.json
vendored
4
tests/testdata/eos_config_stripped.json
vendored
@@ -40,11 +40,11 @@
|
||||
"time_windows": {
|
||||
"windows": [
|
||||
{
|
||||
"start_time": "08:00:00.000000 Europe/Berlin",
|
||||
"start_time": "08:00:00.000000",
|
||||
"duration": "5 hours"
|
||||
},
|
||||
{
|
||||
"start_time": "15:00:00.000000 Europe/Berlin",
|
||||
"start_time": "15:00:00.000000",
|
||||
"duration": "3 hours"
|
||||
}
|
||||
]
|
||||
|
||||
164
uv.lock
generated
164
uv.lock
generated
@@ -96,12 +96,12 @@ requires-dist = [
|
||||
{ name = "monsterui", specifier = "==1.0.44" },
|
||||
{ name = "mypy", marker = "extra == 'dev'", specifier = "==1.19.1" },
|
||||
{ name = "myst-parser", marker = "extra == 'dev'", specifier = "==5.0.0" },
|
||||
{ name = "numpy", specifier = "==2.4.2" },
|
||||
{ name = "numpy", specifier = "==2.4.3" },
|
||||
{ name = "numpydantic", specifier = "==1.8.0" },
|
||||
{ name = "pandas", specifier = "==3.0.1" },
|
||||
{ name = "pandas-stubs", marker = "extra == 'dev'", specifier = "==3.0.0.260204" },
|
||||
{ name = "pendulum", specifier = "==3.2.0" },
|
||||
{ name = "platformdirs", specifier = "==4.9.2" },
|
||||
{ name = "platformdirs", specifier = "==4.9.4" },
|
||||
{ name = "pre-commit", marker = "extra == 'dev'", specifier = "==4.5.1" },
|
||||
{ name = "psutil", specifier = "==7.2.2" },
|
||||
{ name = "pvlib", specifier = "==0.15.0" },
|
||||
@@ -118,7 +118,7 @@ requires-dist = [
|
||||
{ name = "scipy", specifier = "==1.17.1" },
|
||||
{ name = "sphinx", marker = "extra == 'dev'", specifier = "==9.0.4" },
|
||||
{ name = "sphinx-rtd-theme", marker = "extra == 'dev'", specifier = "==3.1.0" },
|
||||
{ name = "sphinx-tabs", marker = "extra == 'dev'", specifier = "==3.4.7" },
|
||||
{ name = "sphinx-tabs", marker = "extra == 'dev'", specifier = "==3.5.0" },
|
||||
{ name = "statsmodels", specifier = "==0.14.6" },
|
||||
{ name = "tokenize-rt", marker = "extra == 'dev'", specifier = "==6.2.0" },
|
||||
{ name = "types-docutils", marker = "extra == 'dev'", specifier = "==0.22.3.20260223" },
|
||||
@@ -1774,81 +1774,81 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "numpy"
|
||||
version = "2.4.2"
|
||||
version = "2.4.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/57/fd/0005efbd0af48e55eb3c7208af93f2862d4b1a56cd78e84309a2d959208d/numpy-2.4.2.tar.gz", hash = "sha256:659a6107e31a83c4e33f763942275fd278b21d095094044eb35569e86a21ddae", size = 20723651, upload-time = "2026-01-31T23:13:10.135Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/10/8b/c265f4823726ab832de836cdd184d0986dcf94480f81e8739692a7ac7af2/numpy-2.4.3.tar.gz", hash = "sha256:483a201202b73495f00dbc83796c6ae63137a9bdade074f7648b3e32613412dd", size = 20727743, upload-time = "2026-03-09T07:58:53.426Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/44/71852273146957899753e69986246d6a176061ea183407e95418c2aa4d9a/numpy-2.4.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e7e88598032542bd49af7c4747541422884219056c268823ef6e5e89851c8825", size = 16955478, upload-time = "2026-01-31T23:10:25.623Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/41/5d17d4058bd0cd96bcbd4d9ff0fb2e21f52702aab9a72e4a594efa18692f/numpy-2.4.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7edc794af8b36ca37ef5fcb5e0d128c7e0595c7b96a2318d1badb6fcd8ee86b1", size = 14965467, upload-time = "2026-01-31T23:10:28.186Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/48/fb1ce8136c19452ed15f033f8aee91d5defe515094e330ce368a0647846f/numpy-2.4.2-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:6e9f61981ace1360e42737e2bae58b27bf28a1b27e781721047d84bd754d32e7", size = 5475172, upload-time = "2026-01-31T23:10:30.848Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/a9/3feb49f17bbd1300dd2570432961f5c8a4ffeff1db6f02c7273bd020a4c9/numpy-2.4.2-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:cb7bbb88aa74908950d979eeaa24dbdf1a865e3c7e45ff0121d8f70387b55f73", size = 6805145, upload-time = "2026-01-31T23:10:32.352Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/39/fdf35cbd6d6e2fcad42fcf85ac04a85a0d0fbfbf34b30721c98d602fd70a/numpy-2.4.2-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4f069069931240b3fc703f1e23df63443dbd6390614c8c44a87d96cd0ec81eb1", size = 15966084, upload-time = "2026-01-31T23:10:34.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/46/6fa4ea94f1ddf969b2ee941290cca6f1bfac92b53c76ae5f44afe17ceb69/numpy-2.4.2-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c02ef4401a506fb60b411467ad501e1429a3487abca4664871d9ae0b46c8ba32", size = 16899477, upload-time = "2026-01-31T23:10:37.075Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/a1/2a424e162b1a14a5bd860a464ab4e07513916a64ab1683fae262f735ccd2/numpy-2.4.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2653de5c24910e49c2b106499803124dde62a5a1fe0eedeaecf4309a5f639390", size = 17323429, upload-time = "2026-01-31T23:10:39.704Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/a2/73014149ff250628df72c58204822ac01d768697913881aacf839ff78680/numpy-2.4.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1ae241bbfc6ae276f94a170b14785e561cb5e7f626b6688cf076af4110887413", size = 18635109, upload-time = "2026-01-31T23:10:41.924Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/0c/73e8be2f1accd56df74abc1c5e18527822067dced5ec0861b5bb882c2ce0/numpy-2.4.2-cp311-cp311-win32.whl", hash = "sha256:df1b10187212b198dd45fa943d8985a3c8cf854aed4923796e0e019e113a1bda", size = 6237915, upload-time = "2026-01-31T23:10:45.26Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/ae/e0265e0163cf127c24c3969d29f1c4c64551a1e375d95a13d32eab25d364/numpy-2.4.2-cp311-cp311-win_amd64.whl", hash = "sha256:b9c618d56a29c9cb1c4da979e9899be7578d2e0b3c24d52079c166324c9e8695", size = 12607972, upload-time = "2026-01-31T23:10:47.021Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/a5/c43029af9b8014d6ea157f192652c50042e8911f4300f8f6ed3336bf437f/numpy-2.4.2-cp311-cp311-win_arm64.whl", hash = "sha256:47c5a6ed21d9452b10227e5e8a0e1c22979811cad7dcc19d8e3e2fb8fa03f1a3", size = 10485763, upload-time = "2026-01-31T23:10:50.087Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/6e/6f394c9c77668153e14d4da83bcc247beb5952f6ead7699a1a2992613bea/numpy-2.4.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:21982668592194c609de53ba4933a7471880ccbaadcc52352694a59ecc860b3a", size = 16667963, upload-time = "2026-01-31T23:10:52.147Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f8/55483431f2b2fd015ae6ed4fe62288823ce908437ed49db5a03d15151678/numpy-2.4.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40397bda92382fcec844066efb11f13e1c9a3e2a8e8f318fb72ed8b6db9f60f1", size = 14693571, upload-time = "2026-01-31T23:10:54.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/20/18026832b1845cdc82248208dd929ca14c9d8f2bac391f67440707fff27c/numpy-2.4.2-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:b3a24467af63c67829bfaa61eecf18d5432d4f11992688537be59ecd6ad32f5e", size = 5203469, upload-time = "2026-01-31T23:10:57.343Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/33/2eb97c8a77daaba34eaa3fa7241a14ac5f51c46a6bd5911361b644c4a1e2/numpy-2.4.2-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:805cc8de9fd6e7a22da5aed858e0ab16be5a4db6c873dde1d7451c541553aa27", size = 6550820, upload-time = "2026-01-31T23:10:59.429Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/91/b97fdfd12dc75b02c44e26c6638241cc004d4079a0321a69c62f51470c4c/numpy-2.4.2-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6d82351358ffbcdcd7b686b90742a9b86632d6c1c051016484fa0b326a0a1548", size = 15663067, upload-time = "2026-01-31T23:11:01.291Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/c6/a18e59f3f0b8071cc85cbc8d80cd02d68aa9710170b2553a117203d46936/numpy-2.4.2-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e35d3e0144137d9fdae62912e869136164534d64a169f86438bc9561b6ad49f", size = 16619782, upload-time = "2026-01-31T23:11:03.669Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/83/9751502164601a79e18847309f5ceec0b1446d7b6aa12305759b72cf98b2/numpy-2.4.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:adb6ed2ad29b9e15321d167d152ee909ec73395901b70936f029c3bc6d7f4460", size = 17013128, upload-time = "2026-01-31T23:11:05.913Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/c4/c4066322256ec740acc1c8923a10047818691d2f8aec254798f3dd90f5f2/numpy-2.4.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:8906e71fd8afcb76580404e2a950caef2685df3d2a57fe82a86ac8d33cc007ba", size = 18345324, upload-time = "2026-01-31T23:11:08.248Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/af/6157aa6da728fa4525a755bfad486ae7e3f76d4c1864138003eb84328497/numpy-2.4.2-cp312-cp312-win32.whl", hash = "sha256:ec055f6dae239a6299cace477b479cca2fc125c5675482daf1dd886933a1076f", size = 5960282, upload-time = "2026-01-31T23:11:10.497Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/0f/7ceaaeaacb40567071e94dbf2c9480c0ae453d5bb4f52bea3892c39dc83c/numpy-2.4.2-cp312-cp312-win_amd64.whl", hash = "sha256:209fae046e62d0ce6435fcfe3b1a10537e858249b3d9b05829e2a05218296a85", size = 12314210, upload-time = "2026-01-31T23:11:12.176Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/a3/56c5c604fae6dd40fa2ed3040d005fca97e91bd320d232ac9931d77ba13c/numpy-2.4.2-cp312-cp312-win_arm64.whl", hash = "sha256:fbde1b0c6e81d56f5dccd95dd4a711d9b95df1ae4009a60887e56b27e8d903fa", size = 10220171, upload-time = "2026-01-31T23:11:14.684Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/22/815b9fe25d1d7ae7d492152adbc7226d3eff731dffc38fe970589fcaaa38/numpy-2.4.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:25f2059807faea4b077a2b6837391b5d830864b3543627f381821c646f31a63c", size = 16663696, upload-time = "2026-01-31T23:11:17.516Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/f0/817d03a03f93ba9c6c8993de509277d84e69f9453601915e4a69554102a1/numpy-2.4.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bd3a7a9f5847d2fb8c2c6d1c862fa109c31a9abeca1a3c2bd5a64572955b2979", size = 14688322, upload-time = "2026-01-31T23:11:19.883Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b4/f805ab79293c728b9a99438775ce51885fd4f31b76178767cfc718701a39/numpy-2.4.2-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:8e4549f8a3c6d13d55041925e912bfd834285ef1dd64d6bc7d542583355e2e98", size = 5198157, upload-time = "2026-01-31T23:11:22.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/09/826e4289844eccdcd64aac27d13b0fd3f32039915dd5b9ba01baae1f436c/numpy-2.4.2-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:aea4f66ff44dfddf8c2cffd66ba6538c5ec67d389285292fe428cb2c738c8aef", size = 6546330, upload-time = "2026-01-31T23:11:23.958Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/fb/cbfdbfa3057a10aea5422c558ac57538e6acc87ec1669e666d32ac198da7/numpy-2.4.2-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c3cd545784805de05aafe1dde61752ea49a359ccba9760c1e5d1c88a93bbf2b7", size = 15660968, upload-time = "2026-01-31T23:11:25.713Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/04/dc/46066ce18d01645541f0186877377b9371b8fa8017fa8262002b4ef22612/numpy-2.4.2-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d0d9b7c93578baafcbc5f0b83eaf17b79d345c6f36917ba0c67f45226911d499", size = 16607311, upload-time = "2026-01-31T23:11:28.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/d9/4b5adfc39a43fa6bf918c6d544bc60c05236cc2f6339847fc5b35e6cb5b0/numpy-2.4.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f74f0f7779cc7ae07d1810aab8ac6b1464c3eafb9e283a40da7309d5e6e48fbb", size = 17012850, upload-time = "2026-01-31T23:11:30.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/20/adb6e6adde6d0130046e6fdfb7675cc62bc2f6b7b02239a09eb58435753d/numpy-2.4.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:c7ac672d699bf36275c035e16b65539931347d68b70667d28984c9fb34e07fa7", size = 18334210, upload-time = "2026-01-31T23:11:33.214Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/0e/0a73b3dff26803a8c02baa76398015ea2a5434d9b8265a7898a6028c1591/numpy-2.4.2-cp313-cp313-win32.whl", hash = "sha256:8e9afaeb0beff068b4d9cd20d322ba0ee1cecfb0b08db145e4ab4dd44a6b5110", size = 5958199, upload-time = "2026-01-31T23:11:35.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/bc/6352f343522fcb2c04dbaf94cb30cca6fd32c1a750c06ad6231b4293708c/numpy-2.4.2-cp313-cp313-win_amd64.whl", hash = "sha256:7df2de1e4fba69a51c06c28f5a3de36731eb9639feb8e1cf7e4a7b0daf4cf622", size = 12310848, upload-time = "2026-01-31T23:11:38.001Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/8d/6da186483e308da5da1cc6918ce913dcfe14ffde98e710bfeff2a6158d4e/numpy-2.4.2-cp313-cp313-win_arm64.whl", hash = "sha256:0fece1d1f0a89c16b03442eae5c56dc0be0c7883b5d388e0c03f53019a4bfd71", size = 10221082, upload-time = "2026-01-31T23:11:40.392Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/a1/9510aa43555b44781968935c7548a8926274f815de42ad3997e9e83680dd/numpy-2.4.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:5633c0da313330fd20c484c78cdd3f9b175b55e1a766c4a174230c6b70ad8262", size = 14815866, upload-time = "2026-01-31T23:11:42.495Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/30/6bbb5e76631a5ae46e7923dd16ca9d3f1c93cfa8d4ed79a129814a9d8db3/numpy-2.4.2-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d9f64d786b3b1dd742c946c42d15b07497ed14af1a1f3ce840cce27daa0ce913", size = 5325631, upload-time = "2026-01-31T23:11:44.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/00/3a490938800c1923b567b3a15cd17896e68052e2145d8662aaf3e1ffc58f/numpy-2.4.2-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:b21041e8cb6a1eb5312dd1d2f80a94d91efffb7a06b70597d44f1bd2dfc315ab", size = 6646254, upload-time = "2026-01-31T23:11:46.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/e9/fac0890149898a9b609caa5af7455a948b544746e4b8fe7c212c8edd71f8/numpy-2.4.2-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00ab83c56211a1d7c07c25e3217ea6695e50a3e2f255053686b081dc0b091a82", size = 15720138, upload-time = "2026-01-31T23:11:48.082Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/5c/08887c54e68e1e28df53709f1893ce92932cc6f01f7c3d4dc952f61ffd4e/numpy-2.4.2-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fb882da679409066b4603579619341c6d6898fc83a8995199d5249f986e8e8f", size = 16655398, upload-time = "2026-01-31T23:11:50.293Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/89/253db0fa0e66e9129c745e4ef25631dc37d5f1314dad2b53e907b8538e6d/numpy-2.4.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:66cb9422236317f9d44b67b4d18f44efe6e9c7f8794ac0462978513359461554", size = 17079064, upload-time = "2026-01-31T23:11:52.927Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/d5/cbade46ce97c59c6c3da525e8d95b7abe8a42974a1dc5c1d489c10433e88/numpy-2.4.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0f01dcf33e73d80bd8dc0f20a71303abbafa26a19e23f6b68d1aa9990af90257", size = 18379680, upload-time = "2026-01-31T23:11:55.22Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/62/48f99ae172a4b63d981babe683685030e8a3df4f246c893ea5c6ef99f018/numpy-2.4.2-cp313-cp313t-win32.whl", hash = "sha256:52b913ec40ff7ae845687b0b34d8d93b60cb66dcee06996dd5c99f2fc9328657", size = 6082433, upload-time = "2026-01-31T23:11:58.096Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/38/e054a61cfe48ad9f1ed0d188e78b7e26859d0b60ef21cd9de4897cdb5326/numpy-2.4.2-cp313-cp313t-win_amd64.whl", hash = "sha256:5eea80d908b2c1f91486eb95b3fb6fab187e569ec9752ab7d9333d2e66bf2d6b", size = 12451181, upload-time = "2026-01-31T23:11:59.782Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6e/a4/a05c3a6418575e185dd84d0b9680b6bb2e2dc3e4202f036b7b4e22d6e9dc/numpy-2.4.2-cp313-cp313t-win_arm64.whl", hash = "sha256:fd49860271d52127d61197bb50b64f58454e9f578cb4b2c001a6de8b1f50b0b1", size = 10290756, upload-time = "2026-01-31T23:12:02.438Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/88/b7df6050bf18fdcfb7046286c6535cabbdd2064a3440fca3f069d319c16e/numpy-2.4.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:444be170853f1f9d528428eceb55f12918e4fda5d8805480f36a002f1415e09b", size = 16663092, upload-time = "2026-01-31T23:12:04.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/7a/1fee4329abc705a469a4afe6e69b1ef7e915117747886327104a8493a955/numpy-2.4.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d1240d50adff70c2a88217698ca844723068533f3f5c5fa6ee2e3220e3bdb000", size = 14698770, upload-time = "2026-01-31T23:12:06.96Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/0b/f9e49ba6c923678ad5bc38181c08ac5e53b7a5754dbca8e581aa1a56b1ff/numpy-2.4.2-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:7cdde6de52fb6664b00b056341265441192d1291c130e99183ec0d4b110ff8b1", size = 5208562, upload-time = "2026-01-31T23:12:09.632Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/12/d7de8f6f53f9bb76997e5e4c069eda2051e3fe134e9181671c4391677bb2/numpy-2.4.2-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:cda077c2e5b780200b6b3e09d0b42205a3d1c68f30c6dceb90401c13bff8fe74", size = 6543710, upload-time = "2026-01-31T23:12:11.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/09/63/c66418c2e0268a31a4cf8a8b512685748200f8e8e8ec6c507ce14e773529/numpy-2.4.2-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d30291931c915b2ab5717c2974bb95ee891a1cf22ebc16a8006bd59cd210d40a", size = 15677205, upload-time = "2026-01-31T23:12:14.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/6c/7f237821c9642fb2a04d2f1e88b4295677144ca93285fd76eff3bcba858d/numpy-2.4.2-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bba37bc29d4d85761deed3954a1bc62be7cf462b9510b51d367b769a8c8df325", size = 16611738, upload-time = "2026-01-31T23:12:16.525Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c2/a7/39c4cdda9f019b609b5c473899d87abff092fc908cfe4d1ecb2fcff453b0/numpy-2.4.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b2f0073ed0868db1dcd86e052d37279eef185b9c8db5bf61f30f46adac63c909", size = 17028888, upload-time = "2026-01-31T23:12:19.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/b3/e84bb64bdfea967cc10950d71090ec2d84b49bc691df0025dddb7c26e8e3/numpy-2.4.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7f54844851cdb630ceb623dcec4db3240d1ac13d4990532446761baede94996a", size = 18339556, upload-time = "2026-01-31T23:12:21.816Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f5/954a291bc1192a27081706862ac62bb5920fbecfbaa302f64682aa90beed/numpy-2.4.2-cp314-cp314-win32.whl", hash = "sha256:12e26134a0331d8dbd9351620f037ec470b7c75929cb8a1537f6bfe411152a1a", size = 6006899, upload-time = "2026-01-31T23:12:24.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/cb/eff72a91b2efdd1bc98b3b8759f6a1654aa87612fc86e3d87d6fe4f948c4/numpy-2.4.2-cp314-cp314-win_amd64.whl", hash = "sha256:068cdb2d0d644cdb45670810894f6a0600797a69c05f1ac478e8d31670b8ee75", size = 12443072, upload-time = "2026-01-31T23:12:26.33Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/75/62726948db36a56428fce4ba80a115716dc4fad6a3a4352487f8bb950966/numpy-2.4.2-cp314-cp314-win_arm64.whl", hash = "sha256:6ed0be1ee58eef41231a5c943d7d1375f093142702d5723ca2eb07db9b934b05", size = 10494886, upload-time = "2026-01-31T23:12:28.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/2f/ee93744f1e0661dc267e4b21940870cabfae187c092e1433b77b09b50ac4/numpy-2.4.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:98f16a80e917003a12c0580f97b5f875853ebc33e2eaa4bccfc8201ac6869308", size = 14818567, upload-time = "2026-01-31T23:12:30.709Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/24/6535212add7d76ff938d8bdc654f53f88d35cddedf807a599e180dcb8e66/numpy-2.4.2-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:20abd069b9cda45874498b245c8015b18ace6de8546bf50dfa8cea1696ed06ef", size = 5328372, upload-time = "2026-01-31T23:12:32.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5e/9d/c48f0a035725f925634bf6b8994253b43f2047f6778a54147d7e213bc5a7/numpy-2.4.2-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:e98c97502435b53741540a5717a6749ac2ada901056c7db951d33e11c885cc7d", size = 6649306, upload-time = "2026-01-31T23:12:34.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/81/05/7c73a9574cd4a53a25907bad38b59ac83919c0ddc8234ec157f344d57d9a/numpy-2.4.2-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da6cad4e82cb893db4b69105c604d805e0c3ce11501a55b5e9f9083b47d2ffe8", size = 15722394, upload-time = "2026-01-31T23:12:36.565Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/fa/4de10089f21fc7d18442c4a767ab156b25c2a6eaf187c0db6d9ecdaeb43f/numpy-2.4.2-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9e4424677ce4b47fe73c8b5556d876571f7c6945d264201180db2dc34f676ab5", size = 16653343, upload-time = "2026-01-31T23:12:39.188Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/f9/d33e4ffc857f3763a57aa85650f2e82486832d7492280ac21ba9efda80da/numpy-2.4.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:2b8f157c8a6f20eb657e240f8985cc135598b2b46985c5bccbde7616dc9c6b1e", size = 17078045, upload-time = "2026-01-31T23:12:42.041Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/b8/54bdb43b6225badbea6389fa038c4ef868c44f5890f95dd530a218706da3/numpy-2.4.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5daf6f3914a733336dab21a05cdec343144600e964d2fcdabaac0c0269874b2a", size = 18380024, upload-time = "2026-01-31T23:12:44.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/55/6e1a61ded7af8df04016d81b5b02daa59f2ea9252ee0397cb9f631efe9e5/numpy-2.4.2-cp314-cp314t-win32.whl", hash = "sha256:8c50dd1fc8826f5b26a5ee4d77ca55d88a895f4e4819c7ecc2a9f5905047a443", size = 6153937, upload-time = "2026-01-31T23:12:47.229Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/aa/fa6118d1ed6d776b0983f3ceac9b1a5558e80df9365b1c3aa6d42bf9eee4/numpy-2.4.2-cp314-cp314t-win_amd64.whl", hash = "sha256:fcf92bee92742edd401ba41135185866f7026c502617f422eb432cfeca4fe236", size = 12631844, upload-time = "2026-01-31T23:12:48.997Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/0a/2ec5deea6dcd158f254a7b372fb09cfba5719419c8d66343bab35237b3fb/numpy-2.4.2-cp314-cp314t-win_arm64.whl", hash = "sha256:1f92f53998a17265194018d1cc321b2e96e900ca52d54c7c77837b71b9465181", size = 10565379, upload-time = "2026-01-31T23:12:51.345Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f8/50e14d36d915ef64d8f8bc4a087fc8264d82c785eda6711f80ab7e620335/numpy-2.4.2-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:89f7268c009bc492f506abd6f5265defa7cb3f7487dc21d357c3d290add45082", size = 16833179, upload-time = "2026-01-31T23:12:53.5Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/17/17/809b5cad63812058a8189e91a1e2d55a5a18fd04611dbad244e8aeae465c/numpy-2.4.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6dee3bb76aa4009d5a912180bf5b2de012532998d094acee25d9cb8dee3e44a", size = 14889755, upload-time = "2026-01-31T23:12:55.933Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/ea/181b9bcf7627fc8371720316c24db888dcb9829b1c0270abf3d288b2e29b/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:cd2bd2bbed13e213d6b55dc1d035a4f91748a7d3edc9480c13898b0353708920", size = 5399500, upload-time = "2026-01-31T23:12:58.671Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/9f/413adf3fc955541ff5536b78fcf0754680b3c6d95103230252a2c9408d23/numpy-2.4.2-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:cf28c0c1d4c4bf00f509fa7eb02c58d7caf221b50b467bcb0d9bbf1584d5c821", size = 6714252, upload-time = "2026-01-31T23:13:00.518Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/da/643aad274e29ccbdf42ecd94dafe524b81c87bcb56b83872d54827f10543/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e04ae107ac591763a47398bb45b568fc38f02dbc4aa44c063f67a131f99346cb", size = 15797142, upload-time = "2026-01-31T23:13:02.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/27/965b8525e9cb5dc16481b30a1b3c21e50c7ebf6e9dbd48d0c4d0d5089c7e/numpy-2.4.2-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:602f65afdef699cda27ec0b9224ae5dc43e328f4c24c689deaf77133dbee74d0", size = 16727979, upload-time = "2026-01-31T23:13:04.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/e5/b7d20451657664b07986c2f6e3be564433f5dcaf3482d68eaecd79afaf03/numpy-2.4.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:be71bf1edb48ebbbf7f6337b5bfd2f895d1902f6335a5830b20141fc126ffba0", size = 12502577, upload-time = "2026-01-31T23:13:07.08Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/51/5093a2df15c4dc19da3f79d1021e891f5dcf1d9d1db6ba38891d5590f3fe/numpy-2.4.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:33b3bf58ee84b172c067f56aeadc7ee9ab6de69c5e800ab5b10295d54c581adb", size = 16957183, upload-time = "2026-03-09T07:55:57.774Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/7c/c061f3de0630941073d2598dc271ac2f6cbcf5c83c74a5870fea07488333/numpy-2.4.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:8ba7b51e71c05aa1f9bc3641463cd82308eab40ce0d5c7e1fd4038cbf9938147", size = 14968734, upload-time = "2026-03-09T07:56:00.494Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/27/d26c85cbcd86b26e4f125b0668e7a7c0542d19dd7d23ee12e87b550e95b5/numpy-2.4.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a1988292870c7cb9d0ebb4cc96b4d447513a9644801de54606dc7aabf2b7d920", size = 5475288, upload-time = "2026-03-09T07:56:02.857Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/09/3c4abbc1dcd8010bf1a611d174c7aa689fc505585ec806111b4406f6f1b1/numpy-2.4.3-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:23b46bb6d8ecb68b58c09944483c135ae5f0e9b8d8858ece5e4ead783771d2a9", size = 6805253, upload-time = "2026-03-09T07:56:04.53Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/21/bc/e7aa3f6817e40c3f517d407742337cbb8e6fc4b83ce0b55ab780c829243b/numpy-2.4.3-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a016db5c5dba78fa8fe9f5d80d6708f9c42ab087a739803c0ac83a43d686a470", size = 15969479, upload-time = "2026-03-09T07:56:06.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/51/9f5d7a41f0b51649ddf2f2320595e15e122a40610b233d51928dd6c92353/numpy-2.4.3-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:715de7f82e192e8cae5a507a347d97ad17598f8e026152ca97233e3666daaa71", size = 16901035, upload-time = "2026-03-09T07:56:09.405Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/6e/b221dd847d7181bc5ee4857bfb026182ef69499f9305eb1371cbb1aea626/numpy-2.4.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2ddb7919366ee468342b91dea2352824c25b55814a987847b6c52003a7c97f15", size = 17325657, upload-time = "2026-03-09T07:56:12.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/b8/8f3fd2da596e1063964b758b5e3c970aed1949a05200d7e3d46a9d46d643/numpy-2.4.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a315e5234d88067f2d97e1f2ef670a7569df445d55400f1e33d117418d008d52", size = 18635512, upload-time = "2026-03-09T07:56:14.629Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/24/2993b775c37e39d2f8ab4125b44337ab0b2ba106c100980b7c274a22bee7/numpy-2.4.3-cp311-cp311-win32.whl", hash = "sha256:2b3f8d2c4589b1a2028d2a770b0fc4d1f332fb5e01521f4de3199a896d158ddd", size = 6238100, upload-time = "2026-03-09T07:56:17.243Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/76/1d/edccf27adedb754db7c4511d5eac8b83f004ae948fe2d3509e8b78097d4c/numpy-2.4.3-cp311-cp311-win_amd64.whl", hash = "sha256:77e76d932c49a75617c6d13464e41203cd410956614d0a0e999b25e9e8d27eec", size = 12609816, upload-time = "2026-03-09T07:56:19.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/82/190b99153480076c8dce85f4cfe7d53ea84444145ffa54cb58dcd460d66b/numpy-2.4.3-cp311-cp311-win_arm64.whl", hash = "sha256:eb610595dd91560905c132c709412b512135a60f1851ccbd2c959e136431ff67", size = 10485757, upload-time = "2026-03-09T07:56:21.753Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/ed/6388632536f9788cea23a3a1b629f25b43eaacd7d7377e5d6bc7b9deb69b/numpy-2.4.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:61b0cbabbb6126c8df63b9a3a0c4b1f44ebca5e12ff6997b80fcf267fb3150ef", size = 16669628, upload-time = "2026-03-09T07:56:24.252Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/1b/ee2abfc68e1ce728b2958b6ba831d65c62e1b13ce3017c13943f8f9b5b2e/numpy-2.4.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7395e69ff32526710748f92cd8c9849b361830968ea3e24a676f272653e8983e", size = 14696872, upload-time = "2026-03-09T07:56:26.991Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/d1/780400e915ff5638166f11ca9dc2c5815189f3d7cf6f8759a1685e586413/numpy-2.4.3-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:abdce0f71dcb4a00e4e77f3faf05e4616ceccfe72ccaa07f47ee79cda3b7b0f4", size = 5203489, upload-time = "2026-03-09T07:56:29.414Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/bb/baffa907e9da4cc34a6e556d6d90e032f6d7a75ea47968ea92b4858826c4/numpy-2.4.3-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:48da3a4ee1336454b07497ff7ec83903efa5505792c4e6d9bf83d99dc07a1e18", size = 6550814, upload-time = "2026-03-09T07:56:32.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/12/8c9f0c6c95f76aeb20fc4a699c33e9f827fa0d0f857747c73bb7b17af945/numpy-2.4.3-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:32e3bef222ad6b052280311d1d60db8e259e4947052c3ae7dd6817451fc8a4c5", size = 15666601, upload-time = "2026-03-09T07:56:34.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/79/cc665495e4d57d0aa6fbcc0aa57aa82671dfc78fbf95fe733ed86d98f52a/numpy-2.4.3-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7dd01a46700b1967487141a66ac1a3cf0dd8ebf1f08db37d46389401512ca97", size = 16621358, upload-time = "2026-03-09T07:56:36.852Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/40/b4ecb7224af1065c3539f5ecfff879d090de09608ad1008f02c05c770cb3/numpy-2.4.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:76f0f283506c28b12bba319c0fab98217e9f9b54e6160e9c79e9f7348ba32e9c", size = 17016135, upload-time = "2026-03-09T07:56:39.337Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f7/b1/6a88e888052eed951afed7a142dcdf3b149a030ca59b4c71eef085858e43/numpy-2.4.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:737f630a337364665aba3b5a77e56a68cc42d350edd010c345d65a3efa3addcc", size = 18345816, upload-time = "2026-03-09T07:56:42.31Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/8f/103a60c5f8c3d7fc678c19cd7b2476110da689ccb80bc18050efbaeae183/numpy-2.4.3-cp312-cp312-win32.whl", hash = "sha256:26952e18d82a1dbbc2f008d402021baa8d6fc8e84347a2072a25e08b46d698b9", size = 5960132, upload-time = "2026-03-09T07:56:44.851Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/7c/f5ee1bf6ed888494978046a809df2882aad35d414b622893322df7286879/numpy-2.4.3-cp312-cp312-win_amd64.whl", hash = "sha256:65f3c2455188f09678355f5cae1f959a06b778bc66d535da07bf2ef20cd319d5", size = 12316144, upload-time = "2026-03-09T07:56:47.057Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/46/8d1cb3f7a00f2fb6394140e7e6623696e54c6318a9d9691bb4904672cf42/numpy-2.4.3-cp312-cp312-win_arm64.whl", hash = "sha256:2abad5c7fef172b3377502bde47892439bae394a71bc329f31df0fd829b41a9e", size = 10220364, upload-time = "2026-03-09T07:56:49.849Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/d0/1fe47a98ce0df229238b77611340aff92d52691bcbc10583303181abf7fc/numpy-2.4.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b346845443716c8e542d54112966383b448f4a3ba5c66409771b8c0889485dd3", size = 16665297, upload-time = "2026-03-09T07:56:52.296Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/d9/4e7c3f0e68dfa91f21c6fb6cf839bc829ec920688b1ce7ec722b1a6202fb/numpy-2.4.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2629289168f4897a3c4e23dc98d6f1731f0fc0fe52fb9db19f974041e4cc12b9", size = 14691853, upload-time = "2026-03-09T07:56:54.992Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/66/bd096b13a87549683812b53ab211e6d413497f84e794fb3c39191948da97/numpy-2.4.3-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:bb2e3cf95854233799013779216c57e153c1ee67a0bf92138acca0e429aefaee", size = 5198435, upload-time = "2026-03-09T07:56:57.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/2f/687722910b5a5601de2135c891108f51dfc873d8e43c8ed9f4ebb440b4a2/numpy-2.4.3-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:7f3408ff897f8ab07a07fbe2823d7aee6ff644c097cc1f90382511fe982f647f", size = 6546347, upload-time = "2026-03-09T07:56:59.531Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/ec/7971c4e98d86c564750393fab8d7d83d0a9432a9d78bb8a163a6dc59967a/numpy-2.4.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:decb0eb8a53c3b009b0962378065589685d66b23467ef5dac16cbe818afde27f", size = 15664626, upload-time = "2026-03-09T07:57:01.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/eb/7daecbea84ec935b7fc732e18f532073064a3816f0932a40a17f3349185f/numpy-2.4.3-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5f51900414fc9204a0e0da158ba2ac52b75656e7dce7e77fb9f84bfa343b4cc", size = 16608916, upload-time = "2026-03-09T07:57:04.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/58/2a2b4a817ffd7472dca4421d9f0776898b364154e30c95f42195041dc03b/numpy-2.4.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6bd06731541f89cdc01b261ba2c9e037f1543df7472517836b78dfb15bd6e476", size = 17015824, upload-time = "2026-03-09T07:57:06.347Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4a/ca/627a828d44e78a418c55f82dd4caea8ea4a8ef24e5144d9e71016e52fb40/numpy-2.4.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22654fe6be0e5206f553a9250762c653d3698e46686eee53b399ab90da59bd92", size = 18334581, upload-time = "2026-03-09T07:57:09.114Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/c0/76f93962fc79955fcba30a429b62304332345f22d4daec1cb33653425643/numpy-2.4.3-cp313-cp313-win32.whl", hash = "sha256:d71e379452a2f670ccb689ec801b1218cd3983e253105d6e83780967e899d687", size = 5958618, upload-time = "2026-03-09T07:57:11.432Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/3c/88af0040119209b9b5cb59485fa48b76f372c73068dbf9254784b975ac53/numpy-2.4.3-cp313-cp313-win_amd64.whl", hash = "sha256:0a60e17a14d640f49146cb38e3f105f571318db7826d9b6fef7e4dce758faecd", size = 12312824, upload-time = "2026-03-09T07:57:13.586Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ce/3d07743aced3d173f877c3ef6a454c2174ba42b584ab0b7e6d99374f51ed/numpy-2.4.3-cp313-cp313-win_arm64.whl", hash = "sha256:c9619741e9da2059cd9c3f206110b97583c7152c1dc9f8aafd4beb450ac1c89d", size = 10221218, upload-time = "2026-03-09T07:57:16.183Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/09/d96b02a91d09e9d97862f4fc8bfebf5400f567d8eb1fe4b0cc4795679c15/numpy-2.4.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:7aa4e54f6469300ebca1d9eb80acd5253cdfa36f2c03d79a35883687da430875", size = 14819570, upload-time = "2026-03-09T07:57:18.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/ca/0b1aba3905fdfa3373d523b2b15b19029f4f3031c87f4066bd9d20ef6c6b/numpy-2.4.3-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:d1b90d840b25874cf5cd20c219af10bac3667db3876d9a495609273ebe679070", size = 5326113, upload-time = "2026-03-09T07:57:21.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/63/406e0fd32fcaeb94180fd6a4c41e55736d676c54346b7efbce548b94a914/numpy-2.4.3-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:a749547700de0a20a6718293396ec237bb38218049cfce788e08fcb716e8cf73", size = 6646370, upload-time = "2026-03-09T07:57:22.804Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b6/d0/10f7dc157d4b37af92720a196be6f54f889e90dcd30dce9dc657ed92c257/numpy-2.4.3-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f3c4a151a2e529adf49c1d54f0f57ff8f9b233ee4d44af623a81553ab86368", size = 15723499, upload-time = "2026-03-09T07:57:24.693Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/f1/d1c2bf1161396629701bc284d958dc1efa3a5a542aab83cf11ee6eb4cba5/numpy-2.4.3-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:22c31dc07025123aedf7f2db9e91783df13f1776dc52c6b22c620870dc0fab22", size = 16657164, upload-time = "2026-03-09T07:57:27.676Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/be/cca19230b740af199ac47331a21c71e7a3d0ba59661350483c1600d28c37/numpy-2.4.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:148d59127ac95979d6f07e4d460f934ebdd6eed641db9c0db6c73026f2b2101a", size = 17081544, upload-time = "2026-03-09T07:57:30.664Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/c5/9602b0cbb703a0936fb40f8a95407e8171935b15846de2f0776e08af04c7/numpy-2.4.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a97cbf7e905c435865c2d939af3d93f99d18eaaa3cabe4256f4304fb51604349", size = 18380290, upload-time = "2026-03-09T07:57:33.763Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/81/9f24708953cd30be9ee36ec4778f4b112b45165812f2ada4cc5ea1c1f254/numpy-2.4.3-cp313-cp313t-win32.whl", hash = "sha256:be3b8487d725a77acccc9924f65fd8bce9af7fac8c9820df1049424a2115af6c", size = 6082814, upload-time = "2026-03-09T07:57:36.491Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e2/9e/52f6eaa13e1a799f0ab79066c17f7016a4a8ae0c1aefa58c82b4dab690b4/numpy-2.4.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1ec84fd7c8e652b0f4aaaf2e6e9cc8eaa9b1b80a537e06b2e3a2fb176eedcb26", size = 12452673, upload-time = "2026-03-09T07:57:38.281Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/04/b8cece6ead0b30c9fbd99bb835ad7ea0112ac5f39f069788c5558e3b1ab2/numpy-2.4.3-cp313-cp313t-win_arm64.whl", hash = "sha256:120df8c0a81ebbf5b9020c91439fccd85f5e018a927a39f624845be194a2be02", size = 10290907, upload-time = "2026-03-09T07:57:40.747Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/70/ae/3936f79adebf8caf81bd7a599b90a561334a658be4dcc7b6329ebf4ee8de/numpy-2.4.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:5884ce5c7acfae1e4e1b6fde43797d10aa506074d25b531b4f54bde33c0c31d4", size = 16664563, upload-time = "2026-03-09T07:57:43.817Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/62/760f2b55866b496bb1fa7da2a6db076bef908110e568b02fcfc1422e2a3a/numpy-2.4.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:297837823f5bc572c5f9379b0c9f3a3365f08492cbdc33bcc3af174372ebb168", size = 14702161, upload-time = "2026-03-09T07:57:46.169Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/af/a7a39464e2c0a21526fb4fb76e346fb172ebc92f6d1c7a07c2c139cc17b1/numpy-2.4.3-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:a111698b4a3f8dcbe54c64a7708f049355abd603e619013c346553c1fd4ca90b", size = 5208738, upload-time = "2026-03-09T07:57:48.506Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/29/8c/2a0cf86a59558fa078d83805589c2de490f29ed4fb336c14313a161d358a/numpy-2.4.3-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:4bd4741a6a676770e0e97fe9ab2e51de01183df3dcbcec591d26d331a40de950", size = 6543618, upload-time = "2026-03-09T07:57:50.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/aa/b8/612ce010c0728b1c363fa4ea3aa4c22fe1c5da1de008486f8c2f5cb92fae/numpy-2.4.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:54f29b877279d51e210e0c80709ee14ccbbad647810e8f3d375561c45ef613dd", size = 15680676, upload-time = "2026-03-09T07:57:52.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/7e/4f120ecc54ba26ddf3dc348eeb9eb063f421de65c05fc961941798feea18/numpy-2.4.3-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:679f2a834bae9020f81534671c56fd0cc76dd7e5182f57131478e23d0dc59e24", size = 16613492, upload-time = "2026-03-09T07:57:54.91Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2c/86/1b6020db73be330c4b45d5c6ee4295d59cfeef0e3ea323959d053e5a6909/numpy-2.4.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d84f0f881cb2225c2dfd7f78a10a5645d487a496c6668d6cc39f0f114164f3d0", size = 17031789, upload-time = "2026-03-09T07:57:57.641Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/3a/3b90463bf41ebc21d1b7e06079f03070334374208c0f9a1f05e4ae8455e7/numpy-2.4.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d213c7e6e8d211888cc359bab7199670a00f5b82c0978b9d1c75baf1eddbeac0", size = 18339941, upload-time = "2026-03-09T07:58:00.577Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/74/6d736c4cd962259fd8bae9be27363eb4883a2f9069763747347544c2a487/numpy-2.4.3-cp314-cp314-win32.whl", hash = "sha256:52077feedeff7c76ed7c9f1a0428558e50825347b7545bbb8523da2cd55c547a", size = 6007503, upload-time = "2026-03-09T07:58:03.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/39/c56ef87af669364356bb011922ef0734fc49dad51964568634c72a009488/numpy-2.4.3-cp314-cp314-win_amd64.whl", hash = "sha256:0448e7f9caefb34b4b7dd2b77f21e8906e5d6f0365ad525f9f4f530b13df2afc", size = 12444915, upload-time = "2026-03-09T07:58:06.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/1f/ab8528e38d295fd349310807496fabb7cf9fe2e1f70b97bc20a483ea9d4a/numpy-2.4.3-cp314-cp314-win_arm64.whl", hash = "sha256:b44fd60341c4d9783039598efadd03617fa28d041fc37d22b62d08f2027fa0e7", size = 10494875, upload-time = "2026-03-09T07:58:08.734Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/ef/b7c35e4d5ef141b836658ab21a66d1a573e15b335b1d111d31f26c8ef80f/numpy-2.4.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0a195f4216be9305a73c0e91c9b026a35f2161237cf1c6de9b681637772ea657", size = 14822225, upload-time = "2026-03-09T07:58:11.034Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/8d/7730fa9278cf6648639946cc816e7cc89f0d891602584697923375f801ed/numpy-2.4.3-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:cd32fbacb9fd1bf041bf8e89e4576b6f00b895f06d00914820ae06a616bdfef7", size = 5328769, upload-time = "2026-03-09T07:58:13.67Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/01/d2a137317c958b074d338807c1b6a383406cdf8b8e53b075d804cc3d211d/numpy-2.4.3-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:2e03c05abaee1f672e9d67bc858f300b5ccba1c21397211e8d77d98350972093", size = 6649461, upload-time = "2026-03-09T07:58:15.912Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5c/34/812ce12bc0f00272a4b0ec0d713cd237cb390666eb6206323d1cc9cedbb2/numpy-2.4.3-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7d1ce23cce91fcea443320a9d0ece9b9305d4368875bab09538f7a5b4131938a", size = 15725809, upload-time = "2026-03-09T07:58:17.787Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/25/c0/2aed473a4823e905e765fee3dc2cbf504bd3e68ccb1150fbdabd5c39f527/numpy-2.4.3-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c59020932feb24ed49ffd03704fbab89f22aa9c0d4b180ff45542fe8918f5611", size = 16655242, upload-time = "2026-03-09T07:58:20.476Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/c8/7e052b2fc87aa0e86de23f20e2c42bd261c624748aa8efd2c78f7bb8d8c6/numpy-2.4.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9684823a78a6cd6ad7511fc5e25b07947d1d5b5e2812c93fe99d7d4195130720", size = 17080660, upload-time = "2026-03-09T07:58:23.067Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f3/3d/0876746044db2adcb11549f214d104f2e1be00f07a67edbb4e2812094847/numpy-2.4.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0200b25c687033316fb39f0ff4e3e690e8957a2c3c8d22499891ec58c37a3eb5", size = 18380384, upload-time = "2026-03-09T07:58:25.839Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/12/8160bea39da3335737b10308df4f484235fd297f556745f13092aa039d3b/numpy-2.4.3-cp314-cp314t-win32.whl", hash = "sha256:5e10da9e93247e554bb1d22f8edc51847ddd7dde52d85ce31024c1b4312bfba0", size = 6154547, upload-time = "2026-03-09T07:58:28.289Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/f3/76534f61f80d74cc9cdf2e570d3d4eeb92c2280a27c39b0aaf471eda7b48/numpy-2.4.3-cp314-cp314t-win_amd64.whl", hash = "sha256:45f003dbdffb997a03da2d1d0cb41fbd24a87507fb41605c0420a3db5bd4667b", size = 12633645, upload-time = "2026-03-09T07:58:30.384Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/b6/7c0d4334c15983cec7f92a69e8ce9b1e6f31857e5ee3a413ac424e6bd63d/numpy-2.4.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4d382735cecd7bcf090172489a525cd7d4087bc331f7df9f60ddc9a296cf208e", size = 10565454, upload-time = "2026-03-09T07:58:33.031Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/e4/4dab9fb43c83719c29241c535d9e07be73bea4bc0c6686c5816d8e1b6689/numpy-2.4.3-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c6b124bfcafb9e8d3ed09130dbee44848c20b3e758b6bbf006e641778927c028", size = 16834892, upload-time = "2026-03-09T07:58:35.334Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/29/f8b6d4af90fed3dfda84ebc0df06c9833d38880c79ce954e5b661758aa31/numpy-2.4.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:76dbb9d4e43c16cf9aa711fcd8de1e2eeb27539dcefb60a1d5e9f12fae1d1ed8", size = 14893070, upload-time = "2026-03-09T07:58:37.7Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/04/a19b3c91dbec0a49269407f15d5753673a09832daed40c45e8150e6fa558/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:29363fbfa6f8ee855d7569c96ce524845e3d726d6c19b29eceec7dd555dab152", size = 5399609, upload-time = "2026-03-09T07:58:39.853Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/34/4d73603f5420eab89ea8a67097b31364bf7c30f811d4dd84b1659c7476d9/numpy-2.4.3-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:bc71942c789ef415a37f0d4eab90341425a00d538cd0642445d30b41023d3395", size = 6714355, upload-time = "2026-03-09T07:58:42.365Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/ad/1100d7229bb248394939a12a8074d485b655e8ed44207d328fdd7fcebc7b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e58765ad74dcebd3ef0208a5078fba32dc8ec3578fe84a604432950cd043d79", size = 15800434, upload-time = "2026-03-09T07:58:44.837Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/fd/16d710c085d28ba4feaf29ac60c936c9d662e390344f94a6beaa2ac9899b/numpy-2.4.3-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e236dbda4e1d319d681afcbb136c0c4a8e0f1a5c58ceec2adebb547357fe857", size = 16729409, upload-time = "2026-03-09T07:58:47.972Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/a7/b35835e278c18b85206834b3aa3abe68e77a98769c59233d1f6300284781/numpy-2.4.3-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:4b42639cdde6d24e732ff823a3fa5b701d8acad89c4142bc1d0bd6dc85200ba5", size = 12504685, upload-time = "2026-03-09T07:58:50.525Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2124,11 +2124,11 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "platformdirs"
|
||||
version = "4.9.2"
|
||||
version = "4.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1b/04/fea538adf7dbbd6d186f551d595961e564a3b6715bdf276b477460858672/platformdirs-4.9.2.tar.gz", hash = "sha256:9a33809944b9db043ad67ca0db94b14bf452cc6aeaac46a88ea55b26e2e9d291", size = 28394, upload-time = "2026-02-16T03:56:10.574Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/19/56/8d4c30c8a1d07013911a8fdbd8f89440ef9f08d07a1b50ab8ca8be5a20f9/platformdirs-4.9.4.tar.gz", hash = "sha256:1ec356301b7dc906d83f371c8f487070e99d3ccf9e501686456394622a01a934", size = 28737, upload-time = "2026-03-05T18:34:13.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/63/d7/97f7e3a6abb67d8080dd406fd4df842c2be0efaf712d1c899c32a075027c/platformdirs-4.9.4-py3-none-any.whl", hash = "sha256:68a9a4619a666ea6439f2ff250c12a853cd1cbd5158d258bd824a7df6be2f868", size = 21216, upload-time = "2026-03-05T18:34:12.172Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2764,16 +2764,16 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "sphinx-tabs"
|
||||
version = "3.4.7"
|
||||
version = "3.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "docutils" },
|
||||
{ name = "pygments" },
|
||||
{ name = "sphinx" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6a/53/a9a91995cb365e589f413b77fc75f1c0e9b4ac61bfa8da52a779ad855cc0/sphinx-tabs-3.4.7.tar.gz", hash = "sha256:991ad4a424ff54119799ba1491701aa8130dd43509474aef45a81c42d889784d", size = 15891, upload-time = "2024-10-08T13:37:27.887Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/30/ca5b0de830f369968d8e3483dd45a8908fd10169c05cd9837f0bd075982e/sphinx_tabs-3.5.0.tar.gz", hash = "sha256:91dba1187e4c35fd37380a56ac228bbd54c6c649b2351829f3bf033718277537", size = 17006, upload-time = "2026-03-03T23:00:30.404Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6b/c6/f47505b564b918a3ba60c1e99232d4942c4a7e44ecaae603e829e3d05dae/sphinx_tabs-3.4.7-py3-none-any.whl", hash = "sha256:c12d7a36fd413b369e9e9967a0a4015781b71a9c393575419834f19204bd1915", size = 9727, upload-time = "2024-10-08T13:37:26.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/45/6adc5efeb19fd5fed4027e520b5c668ce58236a2b271ade5533c4c116276/sphinx_tabs-3.5.0-py3-none-any.whl", hash = "sha256:154be49de4d5c8249ea08c5d9bf88ca8f9c31e00a178305a93cbc33e000339e5", size = 9871, upload-time = "2026-03-03T23:00:28.89Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in New Issue
Block a user