diff --git a/.env b/.env index 5d8945ec..35f21dbf 100644 --- a/.env +++ b/.env @@ -11,7 +11,7 @@ DOCKER_COMPOSE_DATA_DIR=${HOME}/.local/share/net.akkudoktor.eos # ----------------------------------------------------------------------------- # Image / build # ----------------------------------------------------------------------------- -VERSION=0.3.0.dev2610260646141478 +VERSION=0.3.0.dev2610261491542381 PYTHON_VERSION=3.13.15 # ----------------------------------------------------------------------------- diff --git a/config.yaml b/config.yaml index 11e1a9a8..88e6eae0 100644 --- a/config.yaml +++ b/config.yaml @@ -6,7 +6,7 @@ # the root directory (no add-on folder as usual). name: "Akkudoktor-EOS" -version: "0.3.0.dev2610260646141478" +version: "0.3.0.dev2610261491542381" slug: "eos" description: "Akkudoktor-EOS add-on" url: "https://github.com/Akkudoktor-EOS/EOS" diff --git a/docs/_generated/configelecfee.md b/docs/_generated/configelecfee.md index 79a99ca8..7403e070 100644 --- a/docs/_generated/configelecfee.md +++ b/docs/_generated/configelecfee.md @@ -124,10 +124,12 @@ This model extends `TimeWindow` by associating a value with the defined time int | 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`. | +| date | `Optional[pydantic_extra_types.pendulum_dt.Date]` | `rw` | `None` | Optional specific calendar date on which the time window must start. For overnight windows, this restriction applies to the start date, not the date of each datetime tested by contains(). | +| day_of_week | `Union[int, str, NoneType]` | `rw` | `None` | Optional day of the week on which the time window must start. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. For overnight windows, the restriction applies to the start date. | +| duration | `Duration` | `rw` | `0 microseconds` | Duration of the time window starting from `start_time`. | +| end_time | `Optional[akkudoktoreos.utils.datetimeutil.Time]` | `rw` | `None` | Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration. | | 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. | +| month | `Optional[int]` | `rw` | `None` | Optional calendar month restriction (1=January to 12=December). The window's start date must fall in this month, including for overnight windows that extend into the following month. | | 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. | ::: @@ -147,8 +149,10 @@ This model extends `TimeWindow` by associating a value with the defined time int { "start_time": "00:00:00.000000", "duration": "2 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.288 } @@ -230,16 +234,20 @@ fee applicable during that interval. { "start_time": "00:00:00.000000", "duration": "8 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.00288 }, { "start_time": "08:00:00.000000", "duration": "16 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.0034 } @@ -250,8 +258,10 @@ fee applicable during that interval. { "start_time": "00:00:00.000000", "duration": "1 day", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 19.0 } @@ -262,16 +272,20 @@ fee applicable during that interval. { "start_time": "00:00:00.000000", "duration": "8 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.00288 }, { "start_time": "08:00:00.000000", "duration": "16 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.0034 } @@ -282,8 +296,10 @@ fee applicable during that interval. { "start_time": "00:00:00.000000", "duration": "1 day", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 19.0 } diff --git a/docs/_generated/configelecprice.md b/docs/_generated/configelecprice.md index 1dbe62f1..9bdd371e 100644 --- a/docs/_generated/configelecprice.md +++ b/docs/_generated/configelecprice.md @@ -249,16 +249,20 @@ price applicable during that interval. { "start_time": "00:00:00.000000", "duration": "8 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.288 }, { "start_time": "08:00:00.000000", "duration": "16 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.34 } diff --git a/docs/_generated/configfeedintariff.md b/docs/_generated/configfeedintariff.md index 2a855cd5..af505187 100644 --- a/docs/_generated/configfeedintariff.md +++ b/docs/_generated/configfeedintariff.md @@ -175,16 +175,20 @@ { "start_time": "00:00:00.000000", "duration": "8 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.028 }, { "start_time": "08:00:00.000000", "duration": "16 hours", + "end_time": null, "day_of_week": null, "date": null, + "month": null, "locale": null, "value": 0.034 } diff --git a/docs/_generated/openapi.md b/docs/_generated/openapi.md index c3fea87b..c557ccdb 100644 --- a/docs/_generated/openapi.md +++ b/docs/_generated/openapi.md @@ -1,6 +1,6 @@ # Akkudoktor-EOS -**Version**: `v0.3.0.dev2610260646141478` +**Version**: `v0.3.0.dev2610261491542381` **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. diff --git a/docs/akkudoktoreos/configtimewindow.md b/docs/akkudoktoreos/configtimewindow.md index ebf245e0..ab2fbf5f 100644 --- a/docs/akkudoktoreos/configtimewindow.md +++ b/docs/akkudoktoreos/configtimewindow.md @@ -5,9 +5,20 @@ ## Overview -The `TimeWindowSequence` model is used to configure allowed time slots for home appliance runs. +The `TimeWindowSequence` model configures allowed time slots for home appliance runs. It contains a collection of `TimeWindow` objects that define when appliances can operate. +Each window specifies a start time, a duration, and optional restrictions on the calendar +date when the window starts. + +Restrictions can be based on a specific date, a day of the week, or a calendar month. +All restrictions apply to the **start date of the window**, not to every date or timestamp +covered by the window. + +This distinction is important for windows that cross midnight. For example, a window starting +on Friday at 22:00 and ending on Saturday at 06:00 remains applicable on Saturday morning +when restricted to Friday. + ## Basic Structure A `TimeWindowSequence` is configured as a JSON object with a `windows` array: @@ -20,60 +31,78 @@ A `TimeWindowSequence` is configured as a JSON object with a `windows` array: "duration": "PT2H", "day_of_week": null, "date": null, + "month": null, "locale": null } ] } ``` +The example defines a daily window from 09:00 to 11:00 without calendar restrictions. + ## TimeWindow Fields -Each `TimeWindow` object has the following fields: +Each `TimeWindow` object supports the following fields: -- **`start_time`** (required): Time when the window begins -- **`duration`** (required): How long the window lasts -- **`day_of_week`** (optional): Restrict to specific day of week -- **`date`** (optional): Restrict to specific calendar date -- **`locale`** (optional): Language for day name parsing +- **`start_time`** (required): Local time when the window begins. +- **`duration`** (required): Duration of the window, expressed as a time interval. +- **`end_time`** (optional): Explicit end time of the window. The effective end is the later + of this time and the end calculated from `start_time` plus `duration`. +- **`day_of_week`** (optional): Restricts the window's start date to a weekday. +- **`date`** (optional): Restricts the window's start date to a specific calendar date. +- **`month`** (optional): Restricts the window's start date to a calendar month, from 1 to 12. +- **`locale`** (optional): Locale used when parsing localized weekday names. + +The `date`, `day_of_week`, and `month` restrictions are independent. If more than one is +specified, all specified restrictions must match the window's start date. ## Time Formats ### Start Time (`start_time`) -The `start_time` field accepts various time formats: +The `start_time` field accepts various time formats. Each example below is followed by the +wall-clock time it resolves to. #### 24-Hour Format ```json { - "start_time": "14:30" // 2:30 PM + "start_time": "14:30" } ``` +→ 14:30 (2:30 PM). + #### 12-Hour Format with AM/PM ```json { - "start_time": "2:30 PM" // 2:30 PM + "start_time": "2:30 PM" } ``` +→ 14:30 (2:30 PM). + #### Compact Format ```json { - "start_time": "1430" // 2:30 PM + "start_time": "1430" } ``` +→ 14:30 (2:30 PM). + #### With Seconds ```json { - "start_time": "14:30:45" // 2:30:45 PM + "start_time": "14:30:45" } ``` +→ 14:30:45 (2:30:45 PM). + #### With Microseconds ```json @@ -82,89 +111,121 @@ The `start_time` field accepts various time formats: } ``` +→ 14:30:45.123456 (2:30:45.123456 PM). + #### European Format ```json { - "start_time": "14h30" // 2:30 PM + "start_time": "14h30" } ``` +→ 14:30 (2:30 PM). + #### Short Formats ```json { - "start_time": "14" // 2:00 PM + "start_time": "14" } ``` +→ 14:00 (2:00 PM). + ```json { - "start_time": "2PM" // 2:00 PM + "start_time": "2PM" } ``` +→ 14:00 (2:00 PM). + #### Decimal Time ```json { - "start_time": "14.5" // 2:30 PM (14:30) + "start_time": "14.5" } ``` +→ 14:30 (2:30 PM). + #### With Timezones +If supported by the configured time parser, timezone information can be included: + ```json { "start_time": "14:30 UTC" } ``` +→ 14:30 in the UTC timezone. + ```json { "start_time": "2:30 PM EST" } ``` +→ 14:30 in the EST timezone. + ```json { "start_time": "14:30 +05:30" } ``` +→ 14:30 at UTC+05:30. + +A window's start time defines its position within the calendar day. The calendar restrictions +determine which dates may supply that start time. + ### Duration (`duration`) -The `duration` field supports multiple formats for maximum flexibility: +The `duration` field defines the length of a window beginning at `start_time`. + +Duration values can be represented in several formats. ISO 8601 durations are recommended +for configuration files and API usage. #### ISO 8601 Duration Format (Recommended) ```json { - "duration": "PT2H30M" // 2 hours 30 minutes + "duration": "PT2H30M" } ``` -```json -{ - "duration": "PT3H" // 3 hours -} -``` +→ 2 hours 30 minutes. ```json { - "duration": "PT90M" // 90 minutes + "duration": "PT3H" } ``` +→ 3 hours. + ```json { - "duration": "PT1H30M45S" // 1 hour 30 minutes 45 seconds + "duration": "PT90M" } ``` +→ 90 minutes. + +```json +{ + "duration": "PT1H30M45S" +} +``` + +→ 1 hour 30 minutes 45 seconds. + #### Human-Readable String Format -The system accepts natural language duration strings: +Human-readable duration strings are also supported: ```json { @@ -172,286 +233,386 @@ The system accepts natural language duration strings: } ``` +→ 2 hours 30 minutes. + ```json { "duration": "3 hours" } ``` +→ 3 hours. + ```json { "duration": "90 minutes" } ``` +→ 90 minutes. + ```json { "duration": "1 hour 30 minutes 45 seconds" } ``` +→ 1 hour 30 minutes 45 seconds. + ```json { "duration": "2 days 5 hours" } ``` +→ 2 days and 5 hours. + ```json { "duration": "1 day 2 hours 30 minutes" } ``` +→ 1 day, 2 hours, and 30 minutes. + #### Singular and Plural Forms -Both singular and plural forms are supported: +Both singular and plural unit names are supported: ```json { - "duration": "1 day" // Singular + "duration": "1 day" } ``` -```json -{ - "duration": "2 days" // Plural -} -``` +→ 1 day. ```json { - "duration": "1 hour" // Singular + "duration": "2 days" } ``` +→ 2 days. + ```json { - "duration": "5 hours" // Plural + "duration": "1 hour" } ``` +→ 1 hour. + +```json +{ + "duration": "5 hours" +} +``` + +→ 5 hours. + #### Numeric Formats +A numeric duration represents seconds. + ##### Seconds as Integer ```json { - "duration": 3600 // 3600 seconds = 1 hour + "duration": 3600 } ``` -```json -{ - "duration": 1800 // 1800 seconds = 30 minutes -} -``` +→ 3600 seconds = 1 hour. ##### Seconds as Float ```json { - "duration": 3600.5 // 3600.5 seconds = 1 hour 0.5 seconds + "duration": 3600.5 } ``` -##### Tuple Format [days, hours, minutes, seconds] +→ 3600.5 seconds = 1 hour and 0.5 seconds. + +##### Tuple Format + +A tuple contains four elements: days, hours, minutes, and seconds. ```json { - "duration": [0, 2, 30, 0] // 0 days, 2 hours, 30 minutes, 0 seconds + "duration": [0, 2, 30, 0] } ``` +→ 0 days, 2 hours, 30 minutes, 0 seconds = 2 hours 30 minutes. + ```json { - "duration": [1, 0, 0, 0] // 1 day + "duration": [1, 0, 0, 0] } ``` +→ 1 day. + ```json { - "duration": [0, 0, 45, 30] // 45 minutes 30 seconds + "duration": [0, 0, 45, 30] } ``` +→ 45 minutes 30 seconds. + ```json { - "duration": [2, 5, 15, 45] // 2 days, 5 hours, 15 minutes, 45 seconds + "duration": [2, 5, 15, 45] } ``` -#### Mixed Time Units - -You can combine different time units in string format: - -```json -{ - "duration": "1 day 4 hours 30 minutes 15 seconds" -} -``` - -```json -{ - "duration": "3 days 2 hours" -} -``` - -```json -{ - "duration": "45 minutes 30 seconds" -} -``` - -#### Common Duration Examples - -##### Short Durations - -```json -{ - "duration": "30 minutes" // Quick appliance cycle -} -``` - -```json -{ - "duration": "PT30M" // ISO format equivalent -} -``` - -```json -{ - "duration": 1800 // Numeric equivalent (seconds) -} -``` - -##### Medium Durations - -```json -{ - "duration": "2 hours 15 minutes" -} -``` - -```json -{ - "duration": "PT2H15M" // ISO format equivalent -} -``` - -```json -{ - "duration": [0, 2, 15, 0] // Tuple format equivalent -} -``` - -##### Long Durations - -```json -{ - "duration": "1 day 8 hours" // All-day appliance window -} -``` - -```json -{ - "duration": "PT32H" // ISO format equivalent -} -``` - -```json -{ - "duration": [1, 8, 0, 0] // Tuple format equivalent -} -``` +→ 2 days, 5 hours, 15 minutes, 45 seconds. #### Validation Rules for Duration -- **ISO 8601 format**: Must start with `PT` and use valid duration specifiers (H, M, S) -- **String format**: Must contain valid time units (day/days, hour/hours, minute/minutes, second/seconds) -- **Numeric format**: Must be a positive number representing seconds -- **Tuple format**: Must be exactly 4 elements: [days, hours, minutes, seconds] -- **All formats**: Duration must be positive (greater than 0) +- ISO 8601 strings must use supported duration specifiers. +- Human-readable strings must contain recognized time units. +- Numeric values represent seconds. +- Tuple values must contain exactly four elements. +- Durations must satisfy the model's validation rules. -#### Duration Format Recommendations +Use ISO 8601 strings such as `"PT2H30M"` for concise, portable configuration. -1. **Use ISO 8601 format** for API consistency: `"PT2H30M"` -2. **Use human-readable strings** for configuration files: `"2 hours 30 minutes"` -3. **Use numeric format** for programmatic calculations: `9000` (seconds) -4. **Use tuple format** for structured data: `[0, 2, 30, 0]` +### Explicit End Time (`end_time`) -#### Error Handling for Duration +The optional `end_time` field defines an explicit clock time for the window's end. -Common duration errors and solutions: +The effective end is the later of: -- **Invalid ISO format**: Ensure proper `PT` prefix and valid specifiers -- **Unknown time units**: Use day/days, hour/hours, minute/minutes, second/seconds -- **Negative duration**: All durations must be positive -- **Invalid tuple length**: Tuple must have exactly 4 elements -- **String too long**: Duration strings have a maximum length limit for security +- `start_time` plus `duration`; +- the explicit `end_time`, interpreted on the appropriate calendar day. -## Day of Week Restrictions +If `end_time` is omitted, the window ends at `start_time` plus `duration`. -### Using Numbers (0=Monday, 6=Sunday) +If `end_time` is earlier than or equal to `start_time`, it belongs to the following calendar day. +This allows a window to cross midnight. + +For example: ```json { - "day_of_week": 0 // Monday + "start_time": "22:00", + "end_time": "06:00", + "duration": "PT2H" } ``` +The explicit end is 06:00 on the following day. The effective window therefore runs from +22:00 until 06:00, rather than ending at midnight or after two hours. + +If the duration extends beyond the explicit end, the duration determines the effective end: + ```json { - "day_of_week": 6 // Sunday + "start_time": "22:00", + "end_time": "02:00", + "duration": "PT10H" } ``` -### Using English Day Names +Here, the effective end is 08:00 on the following day. + +When both times are equal, the explicit end is on the following day: ```json { + "start_time": "00:00", + "end_time": "00:00", + "duration": "PT2H" +} +``` + +In this example, the explicit end is midnight at the end of the start date. + +The end boundary is exclusive. A timestamp exactly at the effective end does not belong to +the window. + +## Calendar Restrictions + +The calendar restrictions determine which dates can be used as the start date of a window. + +When multiple restrictions are configured, they must all match the same start date. + +### Day of Week (`day_of_week`) + +The `day_of_week` field restricts a window to a particular weekday. + +#### Using Numbers + +Weekday numbers range from 0 to 6: + +- `0`: Monday +- `1`: Tuesday +- `2`: Wednesday +- `3`: Thursday +- `4`: Friday +- `5`: Saturday +- `6`: Sunday + +For example, this window starts on Mondays: + +```json +{ + "start_time": "08:00", + "duration": "PT4H", + "day_of_week": 0 +} +``` + +#### Using English Day Names + +```json +{ + "start_time": "08:00", + "duration": "PT4H", "day_of_week": "Monday" } ``` ```json { - "day_of_week": "sunday" // Case insensitive + "start_time": "08:00", + "duration": "PT4H", + "day_of_week": "sunday" } ``` -### Using Localized Day Names +Day names are parsed according to the supported name formats. + +#### Using Localized Day Names + +A locale can be supplied when localized weekday names are used: ```json { - "day_of_week": "Montag", // German for Monday + "start_time": "14:00", + "duration": "PT2H", + "day_of_week": "Montag", "locale": "de" } ``` ```json { - "day_of_week": "Lundi", // French for Monday + "start_time": "14:00", + "duration": "PT2H", + "day_of_week": "Lundi", "locale": "fr" } ``` -## Date Restrictions +#### Weekday Restrictions on Overnight Windows -### Specific Date +A weekday restriction applies to the date on which the window starts. + +For example, this window starts on Friday and continues into Saturday: ```json { - "date": "2024-12-25" // Christmas Day 2024 + "start_time": "22:00", + "end_time": "06:00", + "duration": "PT2H", + "day_of_week": 4 } ``` -**Note**: When `date` is specified, `day_of_week` is ignored. +The window remains applicable at 02:00 on Saturday because its start date is Friday. +The fact that the current timestamp falls on Saturday does not invalidate the window. + +### Specific Date (`date`) + +The `date` field restricts a window to one calendar date, using the ISO date format +`YYYY-MM-DD`. + +For example: + +```json +{ + "start_time": "10:00", + "duration": "PT4H", + "date": "2026-12-25" +} +``` + +This window starts on December 25, 2026. + +The restriction applies to the window's start date, not every date covered by the window. +Consequently, an overnight window starting on December 25 can remain applicable after +midnight on December 26. + +The `date` restriction does not override `day_of_week` or `month`. If multiple restrictions +are configured, all of them must match the start date. + +### Month (`month`) + +The `month` field restricts the window's start date to a calendar month. + +The valid values are integers from 1 to 12: + +- `1`: January +- `2`: February +- `3`: March +- `4`: April +- `5`: May +- `6`: June +- `7`: July +- `8`: August +- `9`: September +- `10`: October +- `11`: November +- `12`: December + +For example, this window is available on start dates in October: + +```json +{ + "start_time": "08:00", + "duration": "PT4H", + "month": 10 +} +``` + +The month restriction applies to the start date. If a window starts on October 31 and extends +into November, it remains applicable during the part of the window that falls on November 1. + +A month restriction is useful for seasonal schedules, such as restricting an appliance window +to winter or summer months. + +### Combining Restrictions + +The `date`, `day_of_week`, and `month` fields can be combined. + +Every specified restriction must match the start date. For example: + +```json +{ + "start_time": "22:00", + "end_time": "06:00", + "duration": "PT2H", + "day_of_week": 4, + "month": 10 +} +``` + +This window can start only on a Friday in October. Once it starts, it remains applicable +after midnight, even if the following date is in November. + +A specific date can be combined with weekday and month restrictions in the same way. +If the restrictions conflict, the window is not applicable. ## Complete Examples ### Example 1: Basic Daily Window -Allow appliance to run between 9:00 AM and 11:00 AM every day: +Allow an appliance to run between 09:00 and 11:00 every day: ```json { @@ -466,43 +627,23 @@ Allow appliance to run between 9:00 AM and 11:00 AM every day: ### Example 2: Weekday Only -Allow appliance to run between 8:00 AM and 6:00 PM on weekdays: +Allow an appliance to run between 08:00 and 18:00 on weekdays: ```json { "windows": [ - { - "start_time": "08:00", - "duration": "PT10H", - "day_of_week": 0 - }, - { - "start_time": "08:00", - "duration": "PT10H", - "day_of_week": 1 - }, - { - "start_time": "08:00", - "duration": "PT10H", - "day_of_week": 2 - }, - { - "start_time": "08:00", - "duration": "PT10H", - "day_of_week": 3 - }, - { - "start_time": "08:00", - "duration": "PT10H", - "day_of_week": 4 - } + { "start_time": "08:00", "duration": "PT10H", "day_of_week": 0 }, + { "start_time": "08:00", "duration": "PT10H", "day_of_week": 1 }, + { "start_time": "08:00", "duration": "PT10H", "day_of_week": 2 }, + { "start_time": "08:00", "duration": "PT10H", "day_of_week": 3 }, + { "start_time": "08:00", "duration": "PT10H", "day_of_week": 4 } ] } ``` ### Example 3: Multiple Daily Windows -Allow appliance to run during morning and evening hours: +Allow an appliance to run during morning and evening hours: ```json { @@ -521,53 +662,25 @@ Allow appliance to run during morning and evening hours: ### Example 4: Weekend Special Hours -Different hours for weekdays and weekends: +Use different hours on weekdays and weekends: ```json { "windows": [ - { - "start_time": "08:00", - "duration": "PT8H", - "day_of_week": "Monday" - }, - { - "start_time": "08:00", - "duration": "PT8H", - "day_of_week": "Tuesday" - }, - { - "start_time": "08:00", - "duration": "PT8H", - "day_of_week": "Wednesday" - }, - { - "start_time": "08:00", - "duration": "PT8H", - "day_of_week": "Thursday" - }, - { - "start_time": "08:00", - "duration": "PT8H", - "day_of_week": "Friday" - }, - { - "start_time": "10:00", - "duration": "PT6H", - "day_of_week": "Saturday" - }, - { - "start_time": "10:00", - "duration": "PT6H", - "day_of_week": "Sunday" - } + { "start_time": "08:00", "duration": "PT8H", "day_of_week": "Monday" }, + { "start_time": "08:00", "duration": "PT8H", "day_of_week": "Tuesday" }, + { "start_time": "08:00", "duration": "PT8H", "day_of_week": "Wednesday" }, + { "start_time": "08:00", "duration": "PT8H", "day_of_week": "Thursday" }, + { "start_time": "08:00", "duration": "PT8H", "day_of_week": "Friday" }, + { "start_time": "10:00", "duration": "PT6H", "day_of_week": "Saturday" }, + { "start_time": "10:00", "duration": "PT6H", "day_of_week": "Sunday" } ] } ``` ### Example 5: Holiday Schedule -Special schedule for a specific date: +Allow an appliance to run on a specific date: ```json { @@ -575,7 +688,7 @@ Special schedule for a specific date: { "start_time": "10:00", "duration": "PT4H", - "date": "2024-12-25" + "date": "2026-12-25" } ] } @@ -583,7 +696,7 @@ Special schedule for a specific date: ### Example 6: Localized Configuration -Using German day names: +Use German weekday names: ```json { @@ -610,128 +723,118 @@ Using German day names: } ``` -### Example 7: Complex Schedule with Timezones +### Example 7: Month Restriction -Multiple windows with different timezones: +Allow an appliance to run during daytime in June, July, and August: ```json { "windows": [ - { - "start_time": "09:00 UTC", - "duration": "PT4H", - "day_of_week": "Monday" - }, - { - "start_time": "2:00 PM EST", - "duration": "PT3H", - "day_of_week": "Friday" - } + { "start_time": "09:00", "duration": "PT6H", "month": 6 }, + { "start_time": "09:00", "duration": "PT6H", "month": 7 }, + { "start_time": "09:00", "duration": "PT6H", "month": 8 } ] } ``` -### Example 8: Night Shift Schedule +### Example 8: Overnight Window with Calendar Restrictions -Crossing midnight (note: each window is within a single day): +Allow a window to start on Fridays in October and continue overnight: ```json { "windows": [ { "start_time": "22:00", - "duration": "PT2H" - }, - { - "start_time": "00:00", - "duration": "PT6H" + "end_time": "06:00", + "duration": "PT2H", + "day_of_week": 4, + "month": 10 } ] } ``` +The window can start only on a Friday in October. It remains applicable after midnight, +including when the calendar date changes to November. + +### Example 9: Night Shift Schedule + +Allow an appliance to run overnight, from 22:00 until 06:00: + +```json +{ + "windows": [ + { + "start_time": "22:00", + "end_time": "06:00", + "duration": "PT2H" + } + ] +} +``` + +The window starts at 22:00 and ends at 06:00 the next day. The explicit end time determines +the effective end because it is later than the end calculated from the duration. + ## Advanced Usage Patterns ### Off-Peak Hours -Configure appliance to run during off-peak electricity hours: +Configure windows for overnight off-peak periods: ```json { "windows": [ { "start_time": "23:00", + "end_time": "07:00", "duration": "PT1H" - }, - { - "start_time": "00:00", - "duration": "PT7H" } ] } ``` +The explicit end time extends the window to 07:00 the next day. + ### Workday Lunch Break -Allow appliance to run during lunch break on workdays: +Allow an appliance to run during a lunch break on weekdays: ```json { "windows": [ - { - "start_time": "12:00", - "duration": "PT1H", - "day_of_week": 0 - }, - { - "start_time": "12:00", - "duration": "PT1H", - "day_of_week": 1 - }, - { - "start_time": "12:00", - "duration": "PT1H", - "day_of_week": 2 - }, - { - "start_time": "12:00", - "duration": "PT1H", - "day_of_week": 3 - }, - { - "start_time": "12:00", - "duration": "PT1H", - "day_of_week": 4 - } + { "start_time": "12:00", "duration": "PT1H", "day_of_week": 0 }, + { "start_time": "12:00", "duration": "PT1H", "day_of_week": 1 }, + { "start_time": "12:00", "duration": "PT1H", "day_of_week": 2 }, + { "start_time": "12:00", "duration": "PT1H", "day_of_week": 3 }, + { "start_time": "12:00", "duration": "PT1H", "day_of_week": 4 } ] } ``` ### Seasonal Schedule -Different schedules for different dates: +Use month restrictions for recurring seasonal availability: ```json { "windows": [ - { - "start_time": "08:00", - "duration": "PT10H", - "date": "2024-06-21" - }, - { - "start_time": "09:00", - "duration": "PT8H", - "date": "2024-12-21" - } + { "start_time": "08:00", "duration": "PT10H", "month": 6 }, + { "start_time": "08:00", "duration": "PT10H", "month": 7 }, + { "start_time": "08:00", "duration": "PT10H", "month": 8 } ] } ``` +These windows are available in June, July, and August every year. + ## Common Patterns ### 1. Always Available +Define a window covering a full day: + ```json { "windows": [ @@ -745,40 +848,24 @@ Different schedules for different dates: ### 2. Business Hours +Allow operation from 09:00 to 17:00 on weekdays: + ```json { "windows": [ - { - "start_time": "09:00", - "duration": "PT8H", - "day_of_week": 0 - }, - { - "start_time": "09:00", - "duration": "PT8H", - "day_of_week": 1 - }, - { - "start_time": "09:00", - "duration": "PT8H", - "day_of_week": 2 - }, - { - "start_time": "09:00", - "duration": "PT8H", - "day_of_week": 3 - }, - { - "start_time": "09:00", - "duration": "PT8H", - "day_of_week": 4 - } + { "start_time": "09:00", "duration": "PT8H", "day_of_week": 0 }, + { "start_time": "09:00", "duration": "PT8H", "day_of_week": 1 }, + { "start_time": "09:00", "duration": "PT8H", "day_of_week": 2 }, + { "start_time": "09:00", "duration": "PT8H", "day_of_week": 3 }, + { "start_time": "09:00", "duration": "PT8H", "day_of_week": 4 } ] } ``` ### 3. Never Available +An empty `windows` array defines a sequence with no available windows: + ```json { "windows": [] @@ -787,32 +874,38 @@ Different schedules for different dates: ## Validation Rules -- `start_time` must be a valid time format -- `duration` must be a positive duration -- `day_of_week` must be 0-6 (integer) or valid day name (string) -- `date` must be a valid ISO date format (YYYY-MM-DD) -- If `date` is specified, `day_of_week` is ignored -- `locale` must be a valid locale code when using localized day names +- `start_time` must use a supported time format. +- `duration` must satisfy the model's duration validation rules. +- `end_time`, when supplied, must use a supported time format. +- `day_of_week` must be an integer from 0 to 6 or a supported weekday name. +- `date` must be a valid calendar date. +- `month` must be an integer from 1 to 12. +- `date`, `day_of_week`, and `month` all apply to the window's start date. +- All specified calendar restrictions must match the start date. +- `locale` is used when parsing localized weekday names. ## Tips and Best Practices -1. **Use 24-hour format** for clarity: `"14:30"` instead of `"2:30 PM"` -2. **Keep durations reasonable** for appliance operation cycles -3. **Test timezone handling** if using timezone-aware times -4. **Use specific dates** for holiday schedules -5. **Consider overlapping windows** for flexibility -6. **Use localization** for international deployments -7. **Document your patterns** for maintenance +1. Prefer 24-hour time notation, such as `"14:30"`. +2. Choose durations appropriate for the appliance's operating cycle. +3. Use `end_time` when a window should end at a specific clock time. +4. Use `date` for one-off schedules and `month` for recurring seasonal schedules. +5. Combine calendar restrictions when a window should apply only on specific dates. +6. Remember that calendar restrictions apply to the start date, even across midnight. +7. Avoid overlapping windows unless overlapping availability is intentional. +8. Test timezone-aware schedules, especially around daylight-saving transitions. ## Error Handling -Common errors and solutions: +Common configuration errors include: -- **Invalid time format**: Use supported time formats listed above -- **Invalid duration**: Use ISO 8601 duration format (PT1H30M) -- **Invalid day name**: Check spelling and locale settings -- **Invalid date**: Use YYYY-MM-DD format -- **Unknown locale**: Use standard locale codes (en, de, fr, etc.) +- **Invalid time format**: Use a supported time representation. +- **Invalid duration**: Use a supported duration representation, such as `"PT1H30M"`. +- **Invalid weekday**: Use a weekday number from 0 to 6 or a supported day name. +- **Invalid date**: Use a valid calendar date. +- **Invalid month**: Use an integer from 1 to 12. +- **Conflicting restrictions**: Ensure that all specified restrictions can match the same date. +- **Invalid locale**: Use a locale supported by the configured day-name parser. ## Integration Examples @@ -837,7 +930,10 @@ except ValidationError as e: "windows": [ { "start_time": "22:00", - "duration": "PT2H" + "end_time": "06:00", + "duration": "PT2H", + "day_of_week": 4, + "month": 10 }, { "start_time": "06:00", diff --git a/openapi.json b/openapi.json index c962398b..a27ef907 100644 --- a/openapi.json +++ b/openapi.json @@ -8,7 +8,7 @@ "name": "Apache 2.0", "url": "https://www.apache.org/licenses/LICENSE-2.0.html" }, - "version": "v0.3.0.dev2610260646141478" + "version": "v0.3.0.dev2610261491542381" }, "paths": { "/v1/measurement/battery-capacity/{battery_id}": { @@ -16246,10 +16246,25 @@ "format": "duration", "title": "Duration", "description": "Duration of the time window starting from `start_time`.", + "default": "P0D", "examples": [ "2 hours" ] }, + "end_time": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "End Time", + "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration.", + "examples": [ + null, + "12:00:00" + ] + }, "day_of_week": { "anyOf": [ { @@ -16263,7 +16278,7 @@ } ], "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 on which the time window must start. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. For overnight windows, the restriction applies to the start date.", "examples": [ null ] @@ -16279,11 +16294,29 @@ } ], "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.", + "description": "Optional specific calendar date on which the time window must start. For overnight windows, this restriction applies to the start date, not the date of each datetime tested by contains().", "examples": [ null ] }, + "month": { + "anyOf": [ + { + "type": "integer", + "maximum": 12.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Month", + "description": "Optional calendar month restriction (1=January to 12=December). The window's start date must fall in this month, including for overnight windows that extend into the following month.", + "examples": [ + null, + 10 + ] + }, "locale": { "anyOf": [ { @@ -16302,8 +16335,7 @@ }, "type": "object", "required": [ - "start_time", - "duration" + "start_time" ], "title": "TimeWindow", "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." @@ -16326,6 +16358,22 @@ "2 hours" ] }, + "end_time": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Time", + "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration.", + "examples": [ + null, + "12:00:00" + ] + }, "day_of_week": { "anyOf": [ { @@ -16339,7 +16387,7 @@ } ], "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 on which the time window must start. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. For overnight windows, the restriction applies to the start date.", "examples": [ null ] @@ -16355,11 +16403,29 @@ } ], "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.", + "description": "Optional specific calendar date on which the time window must start. For overnight windows, this restriction applies to the start date, not the date of each datetime tested by contains().", "examples": [ null ] }, + "month": { + "anyOf": [ + { + "type": "integer", + "maximum": 12.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Month", + "description": "Optional calendar month restriction (1=January to 12=December). The window's start date must fall in this month, including for overnight windows that extend into the following month.", + "examples": [ + null, + 10 + ] + }, "locale": { "anyOf": [ { @@ -16378,8 +16444,7 @@ }, "type": "object", "required": [ - "start_time", - "duration" + "start_time" ], "title": "TimeWindow", "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." @@ -16474,10 +16539,25 @@ "format": "duration", "title": "Duration", "description": "Duration of the time window starting from `start_time`.", + "default": "P0D", "examples": [ "2 hours" ] }, + "end_time": { + "anyOf": [ + {}, + { + "type": "null" + } + ], + "title": "End Time", + "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration.", + "examples": [ + null, + "12:00:00" + ] + }, "day_of_week": { "anyOf": [ { @@ -16491,7 +16571,7 @@ } ], "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 on which the time window must start. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. For overnight windows, the restriction applies to the start date.", "examples": [ null ] @@ -16507,11 +16587,29 @@ } ], "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.", + "description": "Optional specific calendar date on which the time window must start. For overnight windows, this restriction applies to the start date, not the date of each datetime tested by contains().", "examples": [ null ] }, + "month": { + "anyOf": [ + { + "type": "integer", + "maximum": 12.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Month", + "description": "Optional calendar month restriction (1=January to 12=December). The window's start date must fall in this month, including for overnight windows that extend into the following month.", + "examples": [ + null, + 10 + ] + }, "locale": { "anyOf": [ { @@ -16546,8 +16644,7 @@ }, "type": "object", "required": [ - "start_time", - "duration" + "start_time" ], "title": "ValueTimeWindow", "description": "Value applicable during a specific time window.\n\nThis model extends `TimeWindow` by associating a value with the defined time interval." @@ -16570,6 +16667,22 @@ "2 hours" ] }, + "end_time": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "End Time", + "description": "Optional exclusive end time of the window. The effective end is the later of end_time and start_time + duration. End time is dalight saving time change save, whereas duration is always fixed. If None (the default), the window ends at start_time + duration.", + "examples": [ + null, + "12:00:00" + ] + }, "day_of_week": { "anyOf": [ { @@ -16583,7 +16696,7 @@ } ], "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 on which the time window must start. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. For overnight windows, the restriction applies to the start date.", "examples": [ null ] @@ -16599,11 +16712,29 @@ } ], "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.", + "description": "Optional specific calendar date on which the time window must start. For overnight windows, this restriction applies to the start date, not the date of each datetime tested by contains().", "examples": [ null ] }, + "month": { + "anyOf": [ + { + "type": "integer", + "maximum": 12.0, + "minimum": 1.0 + }, + { + "type": "null" + } + ], + "title": "Month", + "description": "Optional calendar month restriction (1=January to 12=December). The window's start date must fall in this month, including for overnight windows that extend into the following month.", + "examples": [ + null, + 10 + ] + }, "locale": { "anyOf": [ { @@ -16638,8 +16769,7 @@ }, "type": "object", "required": [ - "start_time", - "duration" + "start_time" ], "title": "ValueTimeWindow", "description": "Value applicable during a specific time window.\n\nThis model extends `TimeWindow` by associating a value with the defined time interval." diff --git a/src/akkudoktoreos/config/configabc.py b/src/akkudoktoreos/config/configabc.py index d97d636e..f44eb740 100644 --- a/src/akkudoktoreos/config/configabc.py +++ b/src/akkudoktoreos/config/configabc.py @@ -10,6 +10,7 @@ import numpy as np import pandas as pd import pendulum from babel.dates import get_day_names +from loguru import logger from pydantic import Field, field_serializer, field_validator, model_validator from akkudoktoreos.core.pydantic import PydanticBaseModel @@ -111,7 +112,7 @@ class TimeWindow(SettingsBaseModel): }, ) duration: Duration = Field( - ..., + default=Duration(0), json_schema_extra={ "description": "Duration of the time window starting from `start_time`.", "examples": [ @@ -119,13 +120,25 @@ class TimeWindow(SettingsBaseModel): ], }, ) + end_time: Optional[Time] = Field( + default=None, + json_schema_extra={ + "description": ( + "Optional exclusive end time of the window. " + "The effective end is the later of end_time and start_time + duration. " + "End time is dalight saving time change save, whereas duration is always fixed. " + "If None (the default), the window ends at start_time + duration." + ), + "examples": [None, "12:00:00"], + }, + ) day_of_week: Optional[Union[int, str]] = Field( default=None, json_schema_extra={ "description": ( - "Optional day of the week restriction. " + "Optional day of the week on which the time window must start. " "Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. " - "If None, applies every day unless `date` is set." + "For overnight windows, the restriction applies to the start date." ), "examples": [ None, @@ -136,15 +149,28 @@ class TimeWindow(SettingsBaseModel): 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." + "Optional specific calendar date on which the time window must start. " + "For overnight windows, this restriction applies to the start date, " + "not the date of each datetime tested by contains()." ), "examples": [ None, ], }, ) + month: Optional[int] = Field( + default=None, + ge=1, + le=12, + json_schema_extra={ + "description": ( + "Optional calendar month restriction (1=January to 12=December). " + "The window's start date must fall in this month, including for " + "overnight windows that extend into the following month." + ), + "examples": [None, 10], + }, + ) locale: Optional[str] = Field( default=None, json_schema_extra={ @@ -159,35 +185,29 @@ class TimeWindow(SettingsBaseModel): }, ) - @field_validator("start_time", mode="after") + @field_validator("start_time", "end_time", mode="after") @classmethod - def require_naive_start_time(cls, value: Time) -> Time: - """Strip timezone from ``start_time`` if present, emitting a debug message. + def require_naive_time(cls, value: Optional[Time]) -> Optional[Time]: + """Ensure configured times are naive wall-clock times. - ``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. + Times are interpreted in the timezone of the supplied reference + datetime. If a timezone is present, it is removed while preserving + the hour, minute, second, and microsecond. Args: - value: The ``Time`` value to validate. + value: The time to validate, or ``None`` for an optional field. Returns: - A naive ``Time`` with the same hour / minute / second / microsecond - but no ``tzinfo``. + The naive time, or ``None`` if no time was configured. """ + if value is None: + return None 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 " + logger.debug( + f"TimeWindow.start_time received an aware Time ({value}); " + f"stripping timezone '{value.tzinfo}'. start_time or end_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, + "to contains() / earliest_start_time() / latest_start_time()." ) value = value.replace(tzinfo=None) return value @@ -262,68 +282,129 @@ class TimeWindow(SettingsBaseModel): 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. + """Return the effective start and exclusive end of the time window. - ``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. + The explicit end time is interpreted as the first occurrence of that + wall-clock time strictly after the window start. Consequently, an end + time less than or equal to the start time refers to the following day. + + The effective end is the later of the explicit end boundary and the + end implied by the configured duration. Args: - reference_date: The reference date on which to calculate the window. - May be timezone-aware or naive. + reference_date: Datetime identifying the calendar date on which + the window starts. Its time of day is ignored. Returns: - tuple[DateTime, DateTime]: Start and end datetimes for the time window, - in the same timezone as ``reference_date``. + A tuple containing the start datetime and exclusive end datetime. """ - # 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( + start_day = reference_date.start_of("day") + + start = start_day.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. + duration_end = start + self.duration - ``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. + if self.end_time is None: + return start, duration_end + + explicit_end = start_day.replace( + hour=self.end_time.hour, + minute=self.end_time.minute, + second=self.end_time.second, + microsecond=self.end_time.microsecond, + ) + + start_clock = ( + self.start_time.hour, + self.start_time.minute, + self.start_time.second, + self.start_time.microsecond, + ) + end_clock = ( + self.end_time.hour, + self.end_time.minute, + self.end_time.second, + self.end_time.microsecond, + ) + + # An end time at or before the start time belongs to the next day. + if end_clock <= start_clock: + explicit_end = explicit_end.add(days=1) + + return start, max(duration_end, explicit_end) + + def _matches_start_date(self, start_date: DateTime) -> bool: + """Check whether a candidate window start date satisfies all restrictions. + + Date, weekday, and month restrictions apply to the calendar date on + which the window starts. They do not change when the window extends + into the following day. 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``. + start_date: A datetime identifying the candidate window's start date. + Its time of day is ignored. Returns: - bool: True if the datetime (and optional duration) is fully - contained in the time window, False otherwise. + True if all configured date restrictions match; otherwise False. """ - # 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: + if self.date is not None and start_date.date() != self.date: return False - if self.day_of_week is not None and date_time.day_of_week != self.day_of_week: + if self.day_of_week is not None and start_date.day_of_week != self.day_of_week: return False - start, end = self._window_start_end(date_time) - - if not (start <= date_time < end): + if self.month is not None and start_date.month != self.month: return False - if duration is not None: - return date_time + duration <= end - return True + def contains(self, date_time: DateTime, duration: Optional[Duration] = None) -> bool: + """Check whether a datetime and optional duration fit within a window. + + Date, weekday, and month restrictions apply to the candidate window's + start date. This allows an overnight window to remain applicable after + midnight, even when the new calendar date does not satisfy those + restrictions. + + The window's end boundary is exclusive. + + Args: + date_time: The datetime to test. Its timezone is used to interpret + the configured wall-clock times. + duration: Optional duration that must fit entirely within the window. + + Returns: + True if the datetime and optional duration fit within an applicable + window; otherwise False. + """ + current_day = date_time.start_of("day") + + # Check both today's window and a window that began yesterday. + # The previous-day candidate is necessary for overnight windows. + for start_day in ( + current_day, + current_day.subtract(days=1), + ): + if not self._matches_start_date(start_day): + continue + + start, end = self._window_start_end(start_day) + + if not (start <= date_time < end): + continue + + if duration is not None and date_time + duration > end: + continue + + return True + + return False + def earliest_start_time( self, duration: Duration, reference_date: Optional[DateTime] = None ) -> Optional[DateTime]: @@ -339,16 +420,14 @@ class TimeWindow(SettingsBaseModel): if reference_date is None: reference_date = pendulum.today() - if self.date and reference_date.date() != self.date: + if not self._matches_start_date(reference_date): return None - if self.day_of_week is not None and reference_date.day_of_week != self.day_of_week: + window_start, window_end = self._window_start_end(reference_date) + + if duration > window_end - window_start: return None - if duration > self.duration: - return None - - window_start, _ = self._window_start_end(reference_date) return window_start def latest_start_time( @@ -366,16 +445,14 @@ class TimeWindow(SettingsBaseModel): 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: + if not self._matches_start_date(reference_date): return None window_start, window_end = self._window_start_end(reference_date) + + if duration > window_end - window_start: + return None + latest_start = window_end - duration if latest_start < window_start: @@ -409,13 +486,11 @@ class TimeWindow(SettingsBaseModel): if reference_date is None: reference_date = pendulum.today() - if self.date and reference_date.date() != self.date: + if not self._matches_start_date(reference_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 + window_start, window_end = self._window_start_end(reference_date) + return window_end - window_start TimeWindowT = TypeVar("TimeWindowT", bound=TimeWindow) diff --git a/src/akkudoktoreos/devices/genetic/homeappliance.py b/src/akkudoktoreos/devices/genetic/homeappliance.py index 8d1f8dbb..5781687b 100644 --- a/src/akkudoktoreos/devices/genetic/homeappliance.py +++ b/src/akkudoktoreos/devices/genetic/homeappliance.py @@ -579,6 +579,8 @@ class HomeAppliance: continue if window.day_of_week is not None and anchor.day_of_week != window.day_of_week: continue + if window.month is not None and anchor.month != window.month: + continue opening, closing = window._window_start_end(anchor) if opening <= start and run_end <= closing: return True diff --git a/src/akkudoktoreos/server/dash/components.py b/src/akkudoktoreos/server/dash/components.py index 2b171d6f..eb323652 100644 --- a/src/akkudoktoreos/server/dash/components.py +++ b/src/akkudoktoreos/server/dash/components.py @@ -226,6 +226,23 @@ def UpdateError(error_text: str) -> Alert: ) +def Badge(icon: str, text: str, tone: str = "muted") -> Div: + """A small pill with an icon and a label. + + tone: 'muted' (neutral), 'accent' (highlighted), 'warn' (attention). + """ + palette = { + "muted": "bg-muted text-muted-foreground border-border", + "accent": "bg-primary/10 text-primary border-primary/30", + "warn": "bg-destructive/10 text-destructive border-destructive/30", + }[tone] + return DivLAligned( + UkIcon(icon, cls="w-3.5 h-3.5 shrink-0"), + P(text, cls="text-xs font-mono leading-none"), + cls=f"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 {palette}", + ) + + def make_config_update_form() -> Callable[[str, str], Grid]: """Factory for a form that sets a single configuration value. @@ -632,6 +649,8 @@ def make_config_update_time_windows_windows_form( "6 – Sunday", ] + DOW_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"] + def _dow_select(name: str, current: Optional[int]) -> Select: """Render a day-of-week dropdown pre-selected to *current*.""" return Select( @@ -644,21 +663,91 @@ def make_config_update_time_windows_windows_form( cls="border rounded px-2 py-1 text-sm", ) - def _window_summary(win: dict) -> str: - """One-line human-readable label for an existing window.""" - parts = [win.get("start_time", ""), win.get("duration", "")] + MONTH_LABELS = [ + "1 – January", + "2 – February", + "3 – March", + "4 – April", + "5 – May", + "6 – June", + "7 – July", + "8 – August", + "9 – September", + "10 – October", + "11 – November", + "12 – December", + ] + + MONTH_SHORT = [ + "Jan", + "Feb", + "Mar", + "Apr", + "May", + "Jun", + "Jul", + "Aug", + "Sep", + "Oct", + "Nov", + "Dec", + ] + + def _month_select(name: str, current: Optional[int]) -> Select: + """Render a month dropdown pre-selected to *current*.""" + return Select( + Option("— any month —", value="", selected=(current is None)), + *[ + Option(lbl, value=str(i), selected=(current == i)) + for i, lbl in enumerate(MONTH_LABELS, start=1) + ], + name=name, + cls="border rounded px-2 py-1 text-sm", + ) + + def _window_summary(win: dict) -> Div: + """Render a window as a compact row of attribute pills.""" + pills: list[Any] = [] + + # Time range: "08:00 → 8 hours" (or "→ 16:00" if end_time given) + start = win.get("start_time") or "?" + dur = win.get("duration") or None + end_time = win.get("end_time") or None + if dur and end_time: + time_text = f"{start} · +{dur} or until {end_time}" + elif end_time: + time_text = f"{start} → {end_time}" + else: + time_text = f"{start} + {dur or '?'}" + pills.append(Badge("clock", time_text, tone="accent")) + + # Value (only if the form has a value field) if value_description is not None: - parts.append(str(win.get("value", ""))) + val = win.get("value") + if val is not None: + pills.append(Badge("coins", str(val))) + + # Day of week dow = win.get("day_of_week") - if dow is not None: - parts.append(f"dow={dow}") + if dow is not None and 0 <= int(dow) < 7: + pills.append(Badge("calendar-days", DOW_SHORT[int(dow)])) + + # Month + month = win.get("month") + if month is not None and 1 <= int(month) <= 12: + pills.append(Badge("calendar", MONTH_SHORT[int(month) - 1])) + + # Exact date date_val = win.get("date") if date_val: - parts.append(f"date={date_val}") + pills.append(Badge("calendar-clock", str(date_val))) + + # Locale locale_val = win.get("locale") if locale_val: - parts.append(f"locale={locale_val}") - return " | ".join(p for p in parts if p) + pills.append(Badge("globe", str(locale_val))) + + return DivHStacked(*pills, cls="flex-wrap gap-1.5 ml-2") def ConfigUpdateTimeWindowsWindowsForm(config_name: str, value: str) -> Grid: config_id = config_name.lower().replace(".", "-") @@ -669,7 +758,7 @@ def make_config_update_time_windows_windows_form( except (json.JSONDecodeError, AttributeError): current_windows = [] - num_cols = 5 + (1 if value_description is not None else 0) + num_cols = 7 + (1 if value_description is not None else 0) # ---------------------------------------------------------------- # Existing window rows — each is a collapsible
with @@ -726,20 +815,24 @@ def make_config_update_time_windows_windows_form( action: "update", key: "{config_name}", value: (() => {{ - const start = document.querySelector("[name='{wid}_start_time']").value.trim(); - const dur = document.querySelector("[name='{wid}_duration']").value.trim(); + const start = document.querySelector("[name='{wid}_start_time']").value.trim(); + const dur = document.querySelector("[name='{wid}_duration']").value.trim(); + const end = document.querySelector("[name='{wid}_end_time']").value.trim(); {val_js_read} - const dowRaw = document.querySelector("[name='{wid}_dow']").value; - const date = document.querySelector("[name='{wid}_date']").value.trim(); - const locale = document.querySelector("[name='{wid}_locale']").value.trim(); + const dowRaw = document.querySelector("[name='{wid}_dow']").value; + const monthRaw = document.querySelector("[name='{wid}_month']").value; + const date = document.querySelector("[name='{wid}_date']").value.trim(); + const locale = document.querySelector("[name='{wid}_locale']").value.trim(); if (!start || !dur || {val_js_guard}) return {json.dumps(json.dumps(current_windows))}; const edited = {{ - start_time: start, - duration: dur, + start_time: start, + duration: dur, + end_time: end !== "" ? end : null, {val_js_field} - day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null, - date: date !== "" ? date : null, - locale: locale !== "" ? locale : null, + day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null, + month: monthRaw !== "" ? parseInt(monthRaw) : null, + date: date !== "" ? date : null, + locale: locale !== "" ? locale : null, }}; const updated = [...{before_json}, edited, ...{after_json}]; return JSON.stringify(updated); @@ -762,6 +855,12 @@ def make_config_update_time_windows_windows_form( placeholder="e.g. 8 hours", cls="border rounded px-2 py-1 text-sm", ), + Input( + value=win.get("end_time", "") or "", + name=f"{wid}_end_time", + placeholder="e.g. 06:00 (optional)", + cls="border rounded px-2 py-1 text-sm", + ), ] if value_description is not None: edit_cols.append( @@ -775,6 +874,7 @@ def make_config_update_time_windows_windows_form( ) edit_cols += [ _dow_select(f"{wid}_dow", win.get("day_of_week")), + _month_select(f"{wid}_month", win.get("month")), Input( value=win.get("date") or "", name=f"{wid}_date", @@ -794,7 +894,10 @@ def make_config_update_time_windows_windows_form( Summary( DivHStacked( delete_ctrl, - P(_window_summary(win), cls="ml-2 text-sm font-mono cursor-pointer"), + Div( + _window_summary(win), + cls="cursor-pointer", # keep the whole row clickable to expand + ), ), cls="list-none", ), @@ -813,8 +916,9 @@ def make_config_update_time_windows_windows_form( add_wid = f"{config_id}_new" header_cols = [ - P("start_time *", cls="text-xs text-muted-foreground font-semibold"), + P("start time *", cls="text-xs text-muted-foreground font-semibold"), P("duration *", cls="text-xs text-muted-foreground font-semibold"), + P("end time", cls="text-xs text-muted-foreground font-semibold"), ] add_input_cols = [ Input( @@ -827,6 +931,11 @@ def make_config_update_time_windows_windows_form( name=f"{add_wid}_duration", cls="border rounded px-2 py-1 text-sm", ), + Input( + placeholder="e.g. 06:00 (optional)", + name=f"{add_wid}_end_time", + cls="border rounded px-2 py-1 text-sm", + ), ] if value_description is not None: header_cols.append( @@ -842,12 +951,14 @@ def make_config_update_time_windows_windows_form( ) ) header_cols += [ - P("day_of_week", cls="text-xs text-muted-foreground font-semibold"), + P("day of week", cls="text-xs text-muted-foreground font-semibold"), + P("month", 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"), ] add_input_cols += [ _dow_select(f"{add_wid}_dow", None), + _month_select(f"{add_wid}_month", None), Input( placeholder="e.g. 2025-12-24", name=f"{add_wid}_date", @@ -882,20 +993,24 @@ def make_config_update_time_windows_windows_form( action: "update", key: "{config_name}", value: (() => {{ - const start = document.querySelector("[name='{add_wid}_start_time']").value.trim(); - const dur = document.querySelector("[name='{add_wid}_duration']").value.trim(); + const start = document.querySelector("[name='{add_wid}_start_time']").value.trim(); + const dur = document.querySelector("[name='{add_wid}_duration']").value.trim(); + const end = document.querySelector("[name='{add_wid}_end_time']").value.trim(); {add_val_js_read} - const dowRaw = document.querySelector("[name='{add_wid}_dow']").value; - const date = document.querySelector("[name='{add_wid}_date']").value.trim(); - const locale = document.querySelector("[name='{add_wid}_locale']").value.trim(); + const dowRaw = document.querySelector("[name='{add_wid}_dow']").value; + const monthRaw = document.querySelector("[name='{add_wid}_month']").value; + const date = document.querySelector("[name='{add_wid}_date']").value.trim(); + const locale = document.querySelector("[name='{add_wid}_locale']").value.trim(); if (!start || !dur || {add_val_js_guard}) return {current_json}; const newWin = {{ - start_time: start, - duration: dur, + start_time: start, + duration: dur, + end_time: end !== "" ? end : null, {add_val_js_field} - day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null, - date: date !== "" ? date : null, - locale: locale !== "" ? locale : null, + day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null, + month: monthRaw !== "" ? parseInt(monthRaw) : null, + date: date !== "" ? date : null, + locale: locale !== "" ? locale : null, }}; const existing = {json.dumps(current_windows)}; existing.push(newWin); @@ -914,7 +1029,8 @@ def make_config_update_time_windows_windows_form( *window_rows, P("Add new window", cls="text-sm font-semibold mt-3 mb-1"), P( - "* required | day_of_week overridden by date if both set", + "* required | date, day_of_week and month apply to the start date. " + "Overnight windows remain applicable after midnight.", cls="text-xs text-muted-foreground mb-1", ), add_section, diff --git a/tests/test_configabc.py b/tests/test_configabc.py index 2ffeac50..2163cc58 100644 --- a/tests/test_configabc.py +++ b/tests/test_configabc.py @@ -38,7 +38,7 @@ from akkudoktoreos.config.configabc import ( # noqa — ensure Time is importab ValueTimeWindow, ValueTimeWindowSequence, ) -from akkudoktoreos.utils.datetimeutil import Time +from akkudoktoreos.utils.datetimeutil import Time, to_datetime, to_duration # =========================================================================== # Helpers @@ -64,6 +64,19 @@ def make_window(start_h, duration_h, **kwargs): **kwargs, )) +def make_tw(**kwargs) -> TimeWindow: + """Construct a TimeWindow from raw dict values (str, int, etc.). + + Uses ``model_validate`` so pydantic performs coercion — mypy is happy + because the input is ``Any`` rather than the model's parsed field types. + """ + return TimeWindow.model_validate(kwargs) + + +def make_vtw(**kwargs) -> ValueTimeWindow: + """Construct a ValueTimeWindow from raw dict values.""" + return ValueTimeWindow.model_validate(kwargs) + # =========================================================================== # Construction / validation @@ -151,6 +164,141 @@ class TestWindowStartEnd: assert end.hour == 0 # midnight next day assert str(start.timezone) == "US/Eastern" + @pytest.mark.parametrize( + ("start_time", "end_time", "duration", "expected_end"), + [ + ("18:00:00", "00:00:00", "2 hours", "2026-10-24T00:00:00"), + ("00:00:00", "00:00:00", "2 hours", "2026-10-24T00:00:00"), + ("10:00:00", "10:00:00", "2 hours", "2026-10-24T10:00:00"), + ("18:00:00", "00:00:00", "10 hours", "2026-10-24T04:00:00"), + ("08:00:00", "12:00:00", "2 hours", "2026-10-23T12:00:00"), + ], + ) + def test_window_start_end( + self, + start_time: str, + end_time: str, + duration: str, + expected_end: str, + ) -> None: + window = make_tw( + start_time=start_time, + end_time=end_time, + duration=duration, + ) + reference_date = pendulum.datetime(2026, 10, 23) + + _, end = window._window_start_end(reference_date) + + assert end == pendulum.parse(expected_end) + + + @pytest.mark.parametrize( + ("duration", "end_time", "expected_end"), + [ + # Duration determines the end when it extends beyond end_time. + ("3 hours", "10:00:00", "11:00:00"), + # end_time extends the window when duration is shorter. + ("1 hour", "10:00:00", "10:00:00"), + # Duration determines the end when end_time is omitted. + ("2 hours", None, "10:00:00"), + ], + ) + def test_effective_window_end( + self, + duration: str, + end_time: str | None, + expected_end: str, + ) -> None: + """The effective end respects both duration and end_time.""" + window = make_tw( + start_time="08:00:00", + duration=duration, + end_time=end_time, + ) + reference = pendulum.datetime(2026, 10, 26, 8, 0) + + start, end = window._window_start_end(reference) + + assert start == reference + assert end == pendulum.datetime( + 2026, 10, 26, + int(expected_end[:2]), + int(expected_end[3:5]), + int(expected_end[6:8]), + ) + + def test_end_time_extends_duration_for_latest_start(self) -> None: + """The latest start uses the effective window end.""" + window = make_tw( + start_time="08:00:00", + duration="1 hour", + end_time="12:00:00", + ) + reference = pendulum.datetime(2026, 10, 26, 8, 0) + + assert window.earliest_start_time( + to_duration("2 hours"), reference + ) == pendulum.datetime(2026, 10, 26, 8, 0) + + assert window.latest_start_time( + to_duration("2 hours"), reference + ) == pendulum.datetime(2026, 10, 26, 10, 0) + + @pytest.mark.parametrize( + ("start_time", "end_time", "duration", "expected_end"), + [ + # Midnight is the end of the day when used as an end boundary. + ("18:00", "00:00", "PT2H", "2026-01-16T00:00:00"), + + # Equal start and end clock times mean a full-day interval. + ("00:00", "00:00", "PT2H", "2026-01-16T00:00:00"), + ("10:00", "10:00", "PT2H", "2026-01-16T10:00:00"), + + # Duration extending beyond the explicit boundary takes precedence. + ("18:00", "00:00", "PT10H", "2026-01-16T04:00:00"), + + # An ordinary same-day boundary remains on the same day. + ("08:00", "12:00", "PT2H", "2026-01-15T12:00:00"), + + # An overnight boundary earlier than the start wraps to the next day. + ("22:00", "06:00", "PT2H", "2026-01-16T06:00:00"), + ], + ) + def test_window_end_boundary( + self, + start_time: str, + end_time: str, + duration: str, + expected_end: str, + ) -> None: + """Test explicit end boundaries, including midnight and overnight cases.""" + window = make_tw( + start_time=start_time, + end_time=end_time, + duration=to_duration(duration), + ) + reference_date = pendulum.datetime(2026, 1, 15) + + _, actual_end = window._window_start_end(reference_date) + + assert actual_end == pendulum.parse(expected_end) + + + def test_window_end_is_exclusive(self) -> None: + """The explicit end boundary itself is outside the window.""" + window = make_tw( + start_time="18:00", + end_time="00:00", + duration=to_duration("PT2H"), + ) + + before_midnight = pendulum.datetime(2026, 1, 15, 23, 59, 59) + at_midnight = pendulum.datetime(2026, 1, 16, 0, 0, 0) + + assert window.contains(before_midnight) + assert not window.contains(at_midnight) + # =========================================================================== # contains() — naive datetime @@ -238,10 +386,103 @@ class TestContainsAware: # =========================================================================== -# contains() — day_of_week and date constraints +# contains() — day_of_week, month, and date constraints # =========================================================================== class TestContainsConstraints: + + @pytest.mark.parametrize( + ("config", "timestamp", "expected"), + [ + # Friday window extends into Saturday. + ( + { + "start_time": "22:00:00", + "end_time": "06:00:00", + "duration": "2 hours", + "day_of_week": 4, + }, + "2026-10-23T23:00:00", + True, + ), + ( + { + "start_time": "22:00:00", + "end_time": "06:00:00", + "duration": "2 hours", + "day_of_week": 4, + }, + "2026-10-24T02:00:00", + True, + ), + # Saturday's date restriction must not invalidate Friday's window. + ( + { + "start_time": "22:00:00", + "end_time": "06:00:00", + "duration": "2 hours", + "date": "2026-10-23", + }, + "2026-10-24T02:00:00", + True, + ), + # A window restricted to Saturday must not match Friday night. + ( + { + "start_time": "22:00:00", + "end_time": "06:00:00", + "duration": "2 hours", + "date": "2026-10-24", + }, + "2026-10-24T02:00:00", + False, + ), + # The end boundary is exclusive. + ( + { + "start_time": "22:00:00", + "end_time": "06:00:00", + "duration": "2 hours", + "day_of_week": 4, + }, + "2026-10-24T06:00:00", + False, + ), + # October restriction applies to the start date, even in November. + ( + { + "start_time": "22:00:00", + "end_time": "06:00:00", + "duration": "2 hours", + "month": 10, + }, + "2026-11-01T02:00:00", + True, + ), + # A November start date does not satisfy the October restriction. + ( + { + "start_time": "22:00:00", + "end_time": "06:00:00", + "duration": "2 hours", + "month": 10, + }, + "2026-11-01T23:00:00", + False, + ), + ], + ) + def test_contains_uses_window_start_date( + self, + config: dict[str, object], + timestamp: str, + expected: bool, + ) -> None: + window = make_tw(**config) + date_time = to_datetime(timestamp) + + assert window.contains(date_time) is expected + def test_day_of_week_match_naive(self): # 2024-06-17 is a Monday (day_of_week == 0) w = make_window(8, 4, day_of_week=0) @@ -257,6 +498,46 @@ class TestContainsConstraints: dt = aware_dt(2024, 6, 17, 9, 0, 0, tz="Europe/Berlin") assert w.contains(dt) + @pytest.mark.parametrize("month", [1, 6, 12]) + def test_month_restriction_matches_month(self, month: int) -> None: + """The window applies only in its configured month.""" + window = make_tw( + start_time="08:00:00", + duration="2 hours", + month=month, + ) + + matching_date = pendulum.datetime(2026, month, 15, 9, 0) + assert window.contains(matching_date) + + available = window.available_duration(matching_date) + assert available is not None + assert available.total_seconds() == to_duration("2 hours").total_seconds() + + def test_month_restriction_rejects_other_month(self) -> None: + """The window does not apply in another month.""" + window = make_tw( + start_time="08:00:00", + duration="2 hours", + month=10, + ) + reference = pendulum.datetime(2026, 11, 15, 9, 0) + + assert not window.contains(reference) + assert window.available_duration(reference) is None + assert window.earliest_start_time(to_duration("1 hour"), reference) is None + + + @pytest.mark.parametrize("month", [0, 13]) + def test_month_restriction_rejects_invalid_month(self, month: int) -> None: + """Month restrictions must be between 1 and 12.""" + with pytest.raises(ValueError): + make_tw( + start_time="08:00:00", + duration="2 hours", + month=month, + ) + def test_date_constraint_match(self): w = make_window(8, 4, date=pendulum.date(2024, 6, 17)) assert w.contains(naive_dt(2024, 6, 17, 9, 0, 0))