mirror of
https://github.com/Akkudoktor-EOS/EOS.git
synced 2026-10-29 09:11:57 +00:00
feat: Add month and end_time to time window configuration
Extend TimeWindow with two new optional fields: - month (1–12): restricts the window to a start date in the given calendar month. Useful for seasonal schedules. - end_time: explicit exclusive end-of-window clock time. The effective end is the later of end_time and start_time + duration. Primary motivation is DST handling — a wall-clock end_time stays correct across DST transitions, whereas a fixed duration does not. Rework window semantics so date, weekday, and month restrictions apply to the window's start date, not to every timestamp covered by the window. This makes overnight windows (e.g. Friday 22:00 → Saturday 06:00) behave intuitively under calendar constraints; contains() now checks both today's and yesterday's candidate start. duration becomes optional (default 0). All start/end/duration computations route through _window_start_end and a new _matches_start_date helper, so contains, earliest_start_time, latest_start_time, and available_duration stay consistent. Dashboard: add end_time and month inputs to the time-window editor, render the collapsed window summary as icons + pills. Docs, OpenAPI spec, and generated config docs are regenerated for the new fields. Tests cover month filtering, end_time boundary cases (incl. midnight and overnight), and start-date semantics for overnight windows. Bump version to support automatic updates in Home Assistant. Signed-off-by: Bobby Noelte <b0661n0e17e@gmail.com>
This commit is contained in:
@@ -11,7 +11,7 @@ DOCKER_COMPOSE_DATA_DIR=${HOME}/.local/share/net.akkudoktor.eos
|
|||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
# Image / build
|
# Image / build
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
VERSION=0.3.0.dev2610260646141478
|
VERSION=0.3.0.dev2610261491542381
|
||||||
PYTHON_VERSION=3.13.15
|
PYTHON_VERSION=3.13.15
|
||||||
|
|
||||||
# -----------------------------------------------------------------------------
|
# -----------------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
# the root directory (no add-on folder as usual).
|
# the root directory (no add-on folder as usual).
|
||||||
|
|
||||||
name: "Akkudoktor-EOS"
|
name: "Akkudoktor-EOS"
|
||||||
version: "0.3.0.dev2610260646141478"
|
version: "0.3.0.dev2610261491542381"
|
||||||
slug: "eos"
|
slug: "eos"
|
||||||
description: "Akkudoktor-EOS add-on"
|
description: "Akkudoktor-EOS add-on"
|
||||||
url: "https://github.com/Akkudoktor-EOS/EOS"
|
url: "https://github.com/Akkudoktor-EOS/EOS"
|
||||||
|
|||||||
@@ -124,10 +124,12 @@ This model extends `TimeWindow` by associating a value with the defined time int
|
|||||||
|
|
||||||
| Name | Type | Read-Only | Default | Description |
|
| 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. |
|
| 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 restriction. Can be specified as integer (0=Monday to 6=Sunday) or localized weekday name. If None, applies every day unless `date` is set. |
|
| 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` | `required` | Duration of the time window starting from `start_time`. |
|
| 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. |
|
| 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(). |
|
| 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. |
|
| 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",
|
"start_time": "00:00:00.000000",
|
||||||
"duration": "2 hours",
|
"duration": "2 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.288
|
"value": 0.288
|
||||||
}
|
}
|
||||||
@@ -230,16 +234,20 @@ fee applicable during that interval.
|
|||||||
{
|
{
|
||||||
"start_time": "00:00:00.000000",
|
"start_time": "00:00:00.000000",
|
||||||
"duration": "8 hours",
|
"duration": "8 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.00288
|
"value": 0.00288
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"start_time": "08:00:00.000000",
|
"start_time": "08:00:00.000000",
|
||||||
"duration": "16 hours",
|
"duration": "16 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.0034
|
"value": 0.0034
|
||||||
}
|
}
|
||||||
@@ -250,8 +258,10 @@ fee applicable during that interval.
|
|||||||
{
|
{
|
||||||
"start_time": "00:00:00.000000",
|
"start_time": "00:00:00.000000",
|
||||||
"duration": "1 day",
|
"duration": "1 day",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 19.0
|
"value": 19.0
|
||||||
}
|
}
|
||||||
@@ -262,16 +272,20 @@ fee applicable during that interval.
|
|||||||
{
|
{
|
||||||
"start_time": "00:00:00.000000",
|
"start_time": "00:00:00.000000",
|
||||||
"duration": "8 hours",
|
"duration": "8 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.00288
|
"value": 0.00288
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"start_time": "08:00:00.000000",
|
"start_time": "08:00:00.000000",
|
||||||
"duration": "16 hours",
|
"duration": "16 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.0034
|
"value": 0.0034
|
||||||
}
|
}
|
||||||
@@ -282,8 +296,10 @@ fee applicable during that interval.
|
|||||||
{
|
{
|
||||||
"start_time": "00:00:00.000000",
|
"start_time": "00:00:00.000000",
|
||||||
"duration": "1 day",
|
"duration": "1 day",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 19.0
|
"value": 19.0
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -249,16 +249,20 @@ price applicable during that interval.
|
|||||||
{
|
{
|
||||||
"start_time": "00:00:00.000000",
|
"start_time": "00:00:00.000000",
|
||||||
"duration": "8 hours",
|
"duration": "8 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.288
|
"value": 0.288
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"start_time": "08:00:00.000000",
|
"start_time": "08:00:00.000000",
|
||||||
"duration": "16 hours",
|
"duration": "16 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.34
|
"value": 0.34
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,16 +175,20 @@
|
|||||||
{
|
{
|
||||||
"start_time": "00:00:00.000000",
|
"start_time": "00:00:00.000000",
|
||||||
"duration": "8 hours",
|
"duration": "8 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.028
|
"value": 0.028
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"start_time": "08:00:00.000000",
|
"start_time": "08:00:00.000000",
|
||||||
"duration": "16 hours",
|
"duration": "16 hours",
|
||||||
|
"end_time": null,
|
||||||
"day_of_week": null,
|
"day_of_week": null,
|
||||||
"date": null,
|
"date": null,
|
||||||
|
"month": null,
|
||||||
"locale": null,
|
"locale": null,
|
||||||
"value": 0.034
|
"value": 0.034
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Akkudoktor-EOS
|
# Akkudoktor-EOS
|
||||||
|
|
||||||
**Version**: `v0.3.0.dev2610260646141478`
|
**Version**: `v0.3.0.dev2610261491542381`
|
||||||
|
|
||||||
<!-- pyml disable line-length -->
|
<!-- 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.
|
**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.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
+147
-17
@@ -8,7 +8,7 @@
|
|||||||
"name": "Apache 2.0",
|
"name": "Apache 2.0",
|
||||||
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
"url": "https://www.apache.org/licenses/LICENSE-2.0.html"
|
||||||
},
|
},
|
||||||
"version": "v0.3.0.dev2610260646141478"
|
"version": "v0.3.0.dev2610261491542381"
|
||||||
},
|
},
|
||||||
"paths": {
|
"paths": {
|
||||||
"/v1/measurement/battery-capacity/{battery_id}": {
|
"/v1/measurement/battery-capacity/{battery_id}": {
|
||||||
@@ -16246,10 +16246,25 @@
|
|||||||
"format": "duration",
|
"format": "duration",
|
||||||
"title": "Duration",
|
"title": "Duration",
|
||||||
"description": "Duration of the time window starting from `start_time`.",
|
"description": "Duration of the time window starting from `start_time`.",
|
||||||
|
"default": "P0D",
|
||||||
"examples": [
|
"examples": [
|
||||||
"2 hours"
|
"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": {
|
"day_of_week": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -16263,7 +16278,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Day Of Week",
|
"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": [
|
"examples": [
|
||||||
null
|
null
|
||||||
]
|
]
|
||||||
@@ -16279,11 +16294,29 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Date",
|
"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": [
|
"examples": [
|
||||||
null
|
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": {
|
"locale": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -16302,8 +16335,7 @@
|
|||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"start_time",
|
"start_time"
|
||||||
"duration"
|
|
||||||
],
|
],
|
||||||
"title": "TimeWindow",
|
"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."
|
"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"
|
"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": {
|
"day_of_week": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -16339,7 +16387,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Day Of Week",
|
"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": [
|
"examples": [
|
||||||
null
|
null
|
||||||
]
|
]
|
||||||
@@ -16355,11 +16403,29 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Date",
|
"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": [
|
"examples": [
|
||||||
null
|
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": {
|
"locale": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -16378,8 +16444,7 @@
|
|||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"start_time",
|
"start_time"
|
||||||
"duration"
|
|
||||||
],
|
],
|
||||||
"title": "TimeWindow",
|
"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."
|
"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",
|
"format": "duration",
|
||||||
"title": "Duration",
|
"title": "Duration",
|
||||||
"description": "Duration of the time window starting from `start_time`.",
|
"description": "Duration of the time window starting from `start_time`.",
|
||||||
|
"default": "P0D",
|
||||||
"examples": [
|
"examples": [
|
||||||
"2 hours"
|
"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": {
|
"day_of_week": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -16491,7 +16571,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Day Of Week",
|
"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": [
|
"examples": [
|
||||||
null
|
null
|
||||||
]
|
]
|
||||||
@@ -16507,11 +16587,29 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Date",
|
"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": [
|
"examples": [
|
||||||
null
|
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": {
|
"locale": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -16546,8 +16644,7 @@
|
|||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"start_time",
|
"start_time"
|
||||||
"duration"
|
|
||||||
],
|
],
|
||||||
"title": "ValueTimeWindow",
|
"title": "ValueTimeWindow",
|
||||||
"description": "Value applicable during a specific time window.\n\nThis model extends `TimeWindow` by associating a value with the defined time interval."
|
"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"
|
"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": {
|
"day_of_week": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -16583,7 +16696,7 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Day Of Week",
|
"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": [
|
"examples": [
|
||||||
null
|
null
|
||||||
]
|
]
|
||||||
@@ -16599,11 +16712,29 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"title": "Date",
|
"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": [
|
"examples": [
|
||||||
null
|
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": {
|
"locale": {
|
||||||
"anyOf": [
|
"anyOf": [
|
||||||
{
|
{
|
||||||
@@ -16638,8 +16769,7 @@
|
|||||||
},
|
},
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"required": [
|
"required": [
|
||||||
"start_time",
|
"start_time"
|
||||||
"duration"
|
|
||||||
],
|
],
|
||||||
"title": "ValueTimeWindow",
|
"title": "ValueTimeWindow",
|
||||||
"description": "Value applicable during a specific time window.\n\nThis model extends `TimeWindow` by associating a value with the defined time interval."
|
"description": "Value applicable during a specific time window.\n\nThis model extends `TimeWindow` by associating a value with the defined time interval."
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import numpy as np
|
|||||||
import pandas as pd
|
import pandas as pd
|
||||||
import pendulum
|
import pendulum
|
||||||
from babel.dates import get_day_names
|
from babel.dates import get_day_names
|
||||||
|
from loguru import logger
|
||||||
from pydantic import Field, field_serializer, field_validator, model_validator
|
from pydantic import Field, field_serializer, field_validator, model_validator
|
||||||
|
|
||||||
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
from akkudoktoreos.core.pydantic import PydanticBaseModel
|
||||||
@@ -111,7 +112,7 @@ class TimeWindow(SettingsBaseModel):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
duration: Duration = Field(
|
duration: Duration = Field(
|
||||||
...,
|
default=Duration(0),
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": "Duration of the time window starting from `start_time`.",
|
"description": "Duration of the time window starting from `start_time`.",
|
||||||
"examples": [
|
"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(
|
day_of_week: Optional[Union[int, str]] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": (
|
"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. "
|
"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": [
|
"examples": [
|
||||||
None,
|
None,
|
||||||
@@ -136,15 +149,28 @@ class TimeWindow(SettingsBaseModel):
|
|||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={
|
json_schema_extra={
|
||||||
"description": (
|
"description": (
|
||||||
"Optional specific calendar date for the time window. "
|
"Optional specific calendar date on which the time window must start. "
|
||||||
"Naive — matched against the local date of the datetime passed to contains(). "
|
"For overnight windows, this restriction applies to the start date, "
|
||||||
"Overrides `day_of_week` if set."
|
"not the date of each datetime tested by contains()."
|
||||||
),
|
),
|
||||||
"examples": [
|
"examples": [
|
||||||
None,
|
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(
|
locale: Optional[str] = Field(
|
||||||
default=None,
|
default=None,
|
||||||
json_schema_extra={
|
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
|
@classmethod
|
||||||
def require_naive_start_time(cls, value: Time) -> Time:
|
def require_naive_time(cls, value: Optional[Time]) -> Optional[Time]:
|
||||||
"""Strip timezone from ``start_time`` if present, emitting a debug message.
|
"""Ensure configured times are naive wall-clock times.
|
||||||
|
|
||||||
``start_time`` must be naive: it is interpreted as wall-clock time in
|
Times are interpreted in the timezone of the supplied reference
|
||||||
the timezone of the ``date_time`` / ``reference_date`` supplied at call
|
datetime. If a timezone is present, it is removed while preserving
|
||||||
time. The project's ``to_time`` helper may silently attach a timezone
|
the hour, minute, second, and microsecond.
|
||||||
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:
|
Args:
|
||||||
value: The ``Time`` value to validate.
|
value: The time to validate, or ``None`` for an optional field.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A naive ``Time`` with the same hour / minute / second / microsecond
|
The naive time, or ``None`` if no time was configured.
|
||||||
but no ``tzinfo``.
|
|
||||||
"""
|
"""
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
if value.tzinfo is not None:
|
if value.tzinfo is not None:
|
||||||
import logging
|
logger.debug(
|
||||||
|
f"TimeWindow.start_time received an aware Time ({value}); "
|
||||||
logging.getLogger(__name__).debug(
|
f"stripping timezone '{value.tzinfo}'. start_time or end_time is always interpreted "
|
||||||
"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 "
|
"as wall-clock time in the timezone of the datetime passed "
|
||||||
"to contains() / earliest_start_time() / latest_start_time().",
|
"to contains() / earliest_start_time() / latest_start_time()."
|
||||||
value,
|
|
||||||
value.tzinfo,
|
|
||||||
)
|
)
|
||||||
value = value.replace(tzinfo=None)
|
value = value.replace(tzinfo=None)
|
||||||
return value
|
return value
|
||||||
@@ -262,68 +282,129 @@ class TimeWindow(SettingsBaseModel):
|
|||||||
return str(value)
|
return str(value)
|
||||||
|
|
||||||
def _window_start_end(self, reference_date: DateTime) -> tuple[DateTime, DateTime]:
|
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 explicit end time is interpreted as the first occurrence of that
|
||||||
the timezone of ``reference_date``. When ``reference_date`` is
|
wall-clock time strictly after the window start. Consequently, an end
|
||||||
timezone-aware the resulting window boundaries carry the same timezone;
|
time less than or equal to the start time refers to the following day.
|
||||||
when it is naive the arithmetic is performed without timezone conversion.
|
|
||||||
|
The effective end is the later of the explicit end boundary and the
|
||||||
|
end implied by the configured duration.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
reference_date: The reference date on which to calculate the window.
|
reference_date: Datetime identifying the calendar date on which
|
||||||
May be timezone-aware or naive.
|
the window starts. Its time of day is ignored.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
tuple[DateTime, DateTime]: Start and end datetimes for the time window,
|
A tuple containing the start datetime and exclusive end datetime.
|
||||||
in the same timezone as ``reference_date``.
|
|
||||||
"""
|
"""
|
||||||
# start_time is always naive: just replace the time components on
|
start_day = reference_date.start_of("day")
|
||||||
# reference_date directly. The result inherits reference_date's timezone
|
|
||||||
# (or lack thereof) automatically.
|
start = start_day.replace(
|
||||||
start = reference_date.replace(
|
|
||||||
hour=self.start_time.hour,
|
hour=self.start_time.hour,
|
||||||
minute=self.start_time.minute,
|
minute=self.start_time.minute,
|
||||||
second=self.start_time.second,
|
second=self.start_time.second,
|
||||||
microsecond=self.start_time.microsecond,
|
microsecond=self.start_time.microsecond,
|
||||||
)
|
)
|
||||||
end = start + self.duration
|
|
||||||
return start, end
|
|
||||||
|
|
||||||
def contains(self, date_time: DateTime, duration: Optional[Duration] = None) -> bool:
|
duration_end = start + self.duration
|
||||||
"""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
|
if self.end_time is None:
|
||||||
timezone of ``date_time``. Day-of-week and date constraints are
|
return start, duration_end
|
||||||
evaluated against ``date_time`` after any timezone conversion has
|
|
||||||
been applied.
|
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:
|
Args:
|
||||||
date_time: The datetime to test. May be timezone-aware or naive.
|
start_date: A datetime identifying the candidate window's start date.
|
||||||
duration: An optional duration that must fit entirely within the
|
Its time of day is ignored.
|
||||||
time window starting from ``date_time``.
|
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
bool: True if the datetime (and optional duration) is fully
|
True if all configured date restrictions match; otherwise False.
|
||||||
contained in the time window, False otherwise.
|
|
||||||
"""
|
"""
|
||||||
# Date and weekday constraints are checked against date_time as-is;
|
if self.date is not None and start_date.date() != self.date:
|
||||||
# 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
|
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
|
return False
|
||||||
|
|
||||||
start, end = self._window_start_end(date_time)
|
if self.month is not None and start_date.month != self.month:
|
||||||
|
|
||||||
if not (start <= date_time < end):
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
if duration is not None:
|
|
||||||
return date_time + duration <= end
|
|
||||||
|
|
||||||
return True
|
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(
|
def earliest_start_time(
|
||||||
self, duration: Duration, reference_date: Optional[DateTime] = None
|
self, duration: Duration, reference_date: Optional[DateTime] = None
|
||||||
) -> Optional[DateTime]:
|
) -> Optional[DateTime]:
|
||||||
@@ -339,16 +420,14 @@ class TimeWindow(SettingsBaseModel):
|
|||||||
if reference_date is None:
|
if reference_date is None:
|
||||||
reference_date = pendulum.today()
|
reference_date = pendulum.today()
|
||||||
|
|
||||||
if self.date and reference_date.date() != self.date:
|
if not self._matches_start_date(reference_date):
|
||||||
return None
|
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
|
return None
|
||||||
|
|
||||||
if duration > self.duration:
|
|
||||||
return None
|
|
||||||
|
|
||||||
window_start, _ = self._window_start_end(reference_date)
|
|
||||||
return window_start
|
return window_start
|
||||||
|
|
||||||
def latest_start_time(
|
def latest_start_time(
|
||||||
@@ -366,16 +445,14 @@ class TimeWindow(SettingsBaseModel):
|
|||||||
if reference_date is None:
|
if reference_date is None:
|
||||||
reference_date = pendulum.today()
|
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
|
|
||||||
|
|
||||||
if duration > self.duration:
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
window_start, window_end = self._window_start_end(reference_date)
|
window_start, window_end = self._window_start_end(reference_date)
|
||||||
|
|
||||||
|
if duration > window_end - window_start:
|
||||||
|
return None
|
||||||
|
|
||||||
latest_start = window_end - duration
|
latest_start = window_end - duration
|
||||||
|
|
||||||
if latest_start < window_start:
|
if latest_start < window_start:
|
||||||
@@ -409,13 +486,11 @@ class TimeWindow(SettingsBaseModel):
|
|||||||
if reference_date is None:
|
if reference_date is None:
|
||||||
reference_date = pendulum.today()
|
reference_date = pendulum.today()
|
||||||
|
|
||||||
if self.date and reference_date.date() != self.date:
|
if not self._matches_start_date(reference_date):
|
||||||
return None
|
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)
|
||||||
return None
|
return window_end - window_start
|
||||||
|
|
||||||
return self.duration
|
|
||||||
|
|
||||||
|
|
||||||
TimeWindowT = TypeVar("TimeWindowT", bound=TimeWindow)
|
TimeWindowT = TypeVar("TimeWindowT", bound=TimeWindow)
|
||||||
|
|||||||
@@ -579,6 +579,8 @@ class HomeAppliance:
|
|||||||
continue
|
continue
|
||||||
if window.day_of_week is not None and anchor.day_of_week != window.day_of_week:
|
if window.day_of_week is not None and anchor.day_of_week != window.day_of_week:
|
||||||
continue
|
continue
|
||||||
|
if window.month is not None and anchor.month != window.month:
|
||||||
|
continue
|
||||||
opening, closing = window._window_start_end(anchor)
|
opening, closing = window._window_start_end(anchor)
|
||||||
if opening <= start and run_end <= closing:
|
if opening <= start and run_end <= closing:
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -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]:
|
def make_config_update_form() -> Callable[[str, str], Grid]:
|
||||||
"""Factory for a form that sets a single configuration value.
|
"""Factory for a form that sets a single configuration value.
|
||||||
|
|
||||||
@@ -632,6 +649,8 @@ def make_config_update_time_windows_windows_form(
|
|||||||
"6 – Sunday",
|
"6 – Sunday",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
DOW_SHORT = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"]
|
||||||
|
|
||||||
def _dow_select(name: str, current: Optional[int]) -> Select:
|
def _dow_select(name: str, current: Optional[int]) -> Select:
|
||||||
"""Render a day-of-week dropdown pre-selected to *current*."""
|
"""Render a day-of-week dropdown pre-selected to *current*."""
|
||||||
return Select(
|
return Select(
|
||||||
@@ -644,21 +663,91 @@ def make_config_update_time_windows_windows_form(
|
|||||||
cls="border rounded px-2 py-1 text-sm",
|
cls="border rounded px-2 py-1 text-sm",
|
||||||
)
|
)
|
||||||
|
|
||||||
def _window_summary(win: dict) -> str:
|
MONTH_LABELS = [
|
||||||
"""One-line human-readable label for an existing window."""
|
"1 – January",
|
||||||
parts = [win.get("start_time", ""), win.get("duration", "")]
|
"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:
|
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")
|
dow = win.get("day_of_week")
|
||||||
if dow is not None:
|
if dow is not None and 0 <= int(dow) < 7:
|
||||||
parts.append(f"dow={dow}")
|
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")
|
date_val = win.get("date")
|
||||||
if date_val:
|
if date_val:
|
||||||
parts.append(f"date={date_val}")
|
pills.append(Badge("calendar-clock", str(date_val)))
|
||||||
|
|
||||||
|
# Locale
|
||||||
locale_val = win.get("locale")
|
locale_val = win.get("locale")
|
||||||
if locale_val:
|
if locale_val:
|
||||||
parts.append(f"locale={locale_val}")
|
pills.append(Badge("globe", str(locale_val)))
|
||||||
return " | ".join(p for p in parts if p)
|
|
||||||
|
return DivHStacked(*pills, cls="flex-wrap gap-1.5 ml-2")
|
||||||
|
|
||||||
def ConfigUpdateTimeWindowsWindowsForm(config_name: str, value: str) -> Grid:
|
def ConfigUpdateTimeWindowsWindowsForm(config_name: str, value: str) -> Grid:
|
||||||
config_id = config_name.lower().replace(".", "-")
|
config_id = config_name.lower().replace(".", "-")
|
||||||
@@ -669,7 +758,7 @@ def make_config_update_time_windows_windows_form(
|
|||||||
except (json.JSONDecodeError, AttributeError):
|
except (json.JSONDecodeError, AttributeError):
|
||||||
current_windows = []
|
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 <details> with
|
# Existing window rows — each is a collapsible <details> with
|
||||||
@@ -728,16 +817,20 @@ def make_config_update_time_windows_windows_form(
|
|||||||
value: (() => {{
|
value: (() => {{
|
||||||
const start = document.querySelector("[name='{wid}_start_time']").value.trim();
|
const start = document.querySelector("[name='{wid}_start_time']").value.trim();
|
||||||
const dur = document.querySelector("[name='{wid}_duration']").value.trim();
|
const dur = document.querySelector("[name='{wid}_duration']").value.trim();
|
||||||
|
const end = document.querySelector("[name='{wid}_end_time']").value.trim();
|
||||||
{val_js_read}
|
{val_js_read}
|
||||||
const dowRaw = document.querySelector("[name='{wid}_dow']").value;
|
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 date = document.querySelector("[name='{wid}_date']").value.trim();
|
||||||
const locale = document.querySelector("[name='{wid}_locale']").value.trim();
|
const locale = document.querySelector("[name='{wid}_locale']").value.trim();
|
||||||
if (!start || !dur || {val_js_guard}) return {json.dumps(json.dumps(current_windows))};
|
if (!start || !dur || {val_js_guard}) return {json.dumps(json.dumps(current_windows))};
|
||||||
const edited = {{
|
const edited = {{
|
||||||
start_time: start,
|
start_time: start,
|
||||||
duration: dur,
|
duration: dur,
|
||||||
|
end_time: end !== "" ? end : null,
|
||||||
{val_js_field}
|
{val_js_field}
|
||||||
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
|
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
|
||||||
|
month: monthRaw !== "" ? parseInt(monthRaw) : null,
|
||||||
date: date !== "" ? date : null,
|
date: date !== "" ? date : null,
|
||||||
locale: locale !== "" ? locale : null,
|
locale: locale !== "" ? locale : null,
|
||||||
}};
|
}};
|
||||||
@@ -762,6 +855,12 @@ def make_config_update_time_windows_windows_form(
|
|||||||
placeholder="e.g. 8 hours",
|
placeholder="e.g. 8 hours",
|
||||||
cls="border rounded px-2 py-1 text-sm",
|
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:
|
if value_description is not None:
|
||||||
edit_cols.append(
|
edit_cols.append(
|
||||||
@@ -775,6 +874,7 @@ def make_config_update_time_windows_windows_form(
|
|||||||
)
|
)
|
||||||
edit_cols += [
|
edit_cols += [
|
||||||
_dow_select(f"{wid}_dow", win.get("day_of_week")),
|
_dow_select(f"{wid}_dow", win.get("day_of_week")),
|
||||||
|
_month_select(f"{wid}_month", win.get("month")),
|
||||||
Input(
|
Input(
|
||||||
value=win.get("date") or "",
|
value=win.get("date") or "",
|
||||||
name=f"{wid}_date",
|
name=f"{wid}_date",
|
||||||
@@ -794,7 +894,10 @@ def make_config_update_time_windows_windows_form(
|
|||||||
Summary(
|
Summary(
|
||||||
DivHStacked(
|
DivHStacked(
|
||||||
delete_ctrl,
|
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",
|
cls="list-none",
|
||||||
),
|
),
|
||||||
@@ -813,8 +916,9 @@ def make_config_update_time_windows_windows_form(
|
|||||||
add_wid = f"{config_id}_new"
|
add_wid = f"{config_id}_new"
|
||||||
|
|
||||||
header_cols = [
|
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("duration *", cls="text-xs text-muted-foreground font-semibold"),
|
||||||
|
P("end time", cls="text-xs text-muted-foreground font-semibold"),
|
||||||
]
|
]
|
||||||
add_input_cols = [
|
add_input_cols = [
|
||||||
Input(
|
Input(
|
||||||
@@ -827,6 +931,11 @@ def make_config_update_time_windows_windows_form(
|
|||||||
name=f"{add_wid}_duration",
|
name=f"{add_wid}_duration",
|
||||||
cls="border rounded px-2 py-1 text-sm",
|
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:
|
if value_description is not None:
|
||||||
header_cols.append(
|
header_cols.append(
|
||||||
@@ -842,12 +951,14 @@ def make_config_update_time_windows_windows_form(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
header_cols += [
|
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("date (YYYY-MM-DD)", cls="text-xs text-muted-foreground font-semibold"),
|
||||||
P("locale", cls="text-xs text-muted-foreground font-semibold"),
|
P("locale", cls="text-xs text-muted-foreground font-semibold"),
|
||||||
]
|
]
|
||||||
add_input_cols += [
|
add_input_cols += [
|
||||||
_dow_select(f"{add_wid}_dow", None),
|
_dow_select(f"{add_wid}_dow", None),
|
||||||
|
_month_select(f"{add_wid}_month", None),
|
||||||
Input(
|
Input(
|
||||||
placeholder="e.g. 2025-12-24",
|
placeholder="e.g. 2025-12-24",
|
||||||
name=f"{add_wid}_date",
|
name=f"{add_wid}_date",
|
||||||
@@ -884,16 +995,20 @@ def make_config_update_time_windows_windows_form(
|
|||||||
value: (() => {{
|
value: (() => {{
|
||||||
const start = document.querySelector("[name='{add_wid}_start_time']").value.trim();
|
const start = document.querySelector("[name='{add_wid}_start_time']").value.trim();
|
||||||
const dur = document.querySelector("[name='{add_wid}_duration']").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}
|
{add_val_js_read}
|
||||||
const dowRaw = document.querySelector("[name='{add_wid}_dow']").value;
|
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 date = document.querySelector("[name='{add_wid}_date']").value.trim();
|
||||||
const locale = document.querySelector("[name='{add_wid}_locale']").value.trim();
|
const locale = document.querySelector("[name='{add_wid}_locale']").value.trim();
|
||||||
if (!start || !dur || {add_val_js_guard}) return {current_json};
|
if (!start || !dur || {add_val_js_guard}) return {current_json};
|
||||||
const newWin = {{
|
const newWin = {{
|
||||||
start_time: start,
|
start_time: start,
|
||||||
duration: dur,
|
duration: dur,
|
||||||
|
end_time: end !== "" ? end : null,
|
||||||
{add_val_js_field}
|
{add_val_js_field}
|
||||||
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
|
day_of_week: dowRaw !== "" ? parseInt(dowRaw) : null,
|
||||||
|
month: monthRaw !== "" ? parseInt(monthRaw) : null,
|
||||||
date: date !== "" ? date : null,
|
date: date !== "" ? date : null,
|
||||||
locale: locale !== "" ? locale : null,
|
locale: locale !== "" ? locale : null,
|
||||||
}};
|
}};
|
||||||
@@ -914,7 +1029,8 @@ def make_config_update_time_windows_windows_form(
|
|||||||
*window_rows,
|
*window_rows,
|
||||||
P("Add new window", cls="text-sm font-semibold mt-3 mb-1"),
|
P("Add new window", cls="text-sm font-semibold mt-3 mb-1"),
|
||||||
P(
|
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",
|
cls="text-xs text-muted-foreground mb-1",
|
||||||
),
|
),
|
||||||
add_section,
|
add_section,
|
||||||
|
|||||||
+283
-2
@@ -38,7 +38,7 @@ from akkudoktoreos.config.configabc import ( # noqa — ensure Time is importab
|
|||||||
ValueTimeWindow,
|
ValueTimeWindow,
|
||||||
ValueTimeWindowSequence,
|
ValueTimeWindowSequence,
|
||||||
)
|
)
|
||||||
from akkudoktoreos.utils.datetimeutil import Time
|
from akkudoktoreos.utils.datetimeutil import Time, to_datetime, to_duration
|
||||||
|
|
||||||
# ===========================================================================
|
# ===========================================================================
|
||||||
# Helpers
|
# Helpers
|
||||||
@@ -64,6 +64,19 @@ def make_window(start_h, duration_h, **kwargs):
|
|||||||
**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
|
# Construction / validation
|
||||||
@@ -151,6 +164,141 @@ class TestWindowStartEnd:
|
|||||||
assert end.hour == 0 # midnight next day
|
assert end.hour == 0 # midnight next day
|
||||||
assert str(start.timezone) == "US/Eastern"
|
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
|
# 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:
|
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):
|
def test_day_of_week_match_naive(self):
|
||||||
# 2024-06-17 is a Monday (day_of_week == 0)
|
# 2024-06-17 is a Monday (day_of_week == 0)
|
||||||
w = make_window(8, 4, 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")
|
dt = aware_dt(2024, 6, 17, 9, 0, 0, tz="Europe/Berlin")
|
||||||
assert w.contains(dt)
|
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):
|
def test_date_constraint_match(self):
|
||||||
w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
|
w = make_window(8, 4, date=pendulum.date(2024, 6, 17))
|
||||||
assert w.contains(naive_dt(2024, 6, 17, 9, 0, 0))
|
assert w.contains(naive_dt(2024, 6, 17, 9, 0, 0))
|
||||||
|
|||||||
Reference in New Issue
Block a user