Skip to content

Commit

Permalink
Merge pull request #540 from TWilkin/333.schedule-direction
Browse files Browse the repository at this point in the history
#333 fix: Ensure schedule changes are going in the correct direction and add force option
  • Loading branch information
TWilkin authored Oct 30, 2024
2 parents a38abb8 + fd3412f commit ee9fed8
Show file tree
Hide file tree
Showing 8 changed files with 87 additions and 46 deletions.
2 changes: 1 addition & 1 deletion kubernetes/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ dependencies:
version: 0.1.10
condition: global.persistence
- name: scheduler
version: 0.2.11
version: 0.2.12
condition: global.scheduler
- name: smarter-device-manager
version: 0.1.2
Expand Down
4 changes: 2 additions & 2 deletions kubernetes/charts/scheduler/Chart.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@ apiVersion: v2
name: scheduler
description: A Helm chart for the PowerPi scheduler service
type: application
version: 0.2.11
appVersion: 1.3.3
version: 0.2.12
appVersion: 1.4.0
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,10 @@
"power": {
"description": "Whether to turn the device on when the schedule starts",
"type": "boolean"
},
"force": {
"description": "Whether to force the values when updating, which will provide a delta to ensure the value is where we expect to start",
"type": "boolean"
}
},
"required": ["between", "interval"],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ describe("Schedules", () => {
hue: [0, 360],
saturation: [0, 100],
power: true,
force: true,
},
],
};
Expand Down
7 changes: 3 additions & 4 deletions services/scheduler/Dockerfile
Original file line number Diff line number Diff line change
@@ -1,11 +1,10 @@
# create a common base image with updated setuptools
FROM python:3.11.8-slim-bookworm AS base-image
RUN pip3 install --prefer-binary setuptools==69.0.3

# create a base image for building
FROM base-image AS build-base-image
RUN pip3 install --prefer-binary poetry==1.7.1
RUN poetry config virtualenvs.in-project true
RUN pip3 install --prefer-binary poetry==1.7.1 \
&& poetry config virtualenvs.in-project true

# build the common library
FROM build-base-image AS build-common-image
Expand Down Expand Up @@ -43,4 +42,4 @@ COPY --chown=powerpi services/scheduler/pyproject.toml LICENSE ./
COPY --chown=powerpi services/scheduler/scheduler ./scheduler/

# start the application once the container is ready
CMD python -m scheduler
CMD ["python", "-m", "scheduler"]
2 changes: 1 addition & 1 deletion services/scheduler/pyproject.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[tool.poetry]
name = "scheduler"
version = "1.3.3"
version = "1.4.0"
description = "PowerPi Scheduler Service"
license = "GPL-3.0-only"
authors = ["TWilkin <4322355+TWilkin@users.noreply.github.com>"]
Expand Down
56 changes: 43 additions & 13 deletions services/scheduler/scheduler/services/device_schedule.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
from dataclasses import dataclass
from datetime import datetime, time, timedelta
from enum import IntEnum, StrEnum, unique
from typing import Any, Dict, List
from typing import Any, Dict, List, Tuple

import pytz
from apscheduler.schedulers.asyncio import AsyncIOScheduler
Expand Down Expand Up @@ -42,6 +42,10 @@ class DeltaRange:
start: float
end: float

@property
def increasing(self):
return self.start < self.end


class DeviceSchedule(LogMixin):
# pylint: disable=too-many-instance-attributes
Expand Down Expand Up @@ -134,6 +138,8 @@ async def execute(self, start_date: datetime, end_date: datetime):
def __parse(self, device_schedule: Dict[str, Any]):
self.__between: List[str] = device_schedule['between']
self.__interval = int(device_schedule['interval'])
self.__force = bool(
device_schedule['force']) if 'force' in device_schedule else False

self.__days = device_schedule['days'] if 'days' in device_schedule \
else None
Expand Down Expand Up @@ -245,23 +251,24 @@ def make_dates(start: datetime):

return (start_date, end_date)

def __calculate_delta(self, start_date: datetime, delta_range: DeltaRange):
def __calculate_delta(
self,
start_date: datetime,
delta_range: DeltaRange
) -> Tuple[float, float]:
(schedule_start_date, end_date) = self.__calculate_dates()

# how many seconds between the dates
seconds = end_date.timestamp() - schedule_start_date.timestamp()

# how many intervals will there be
intervals = seconds / self.__interval
intervals = seconds / self.__interval + 1

# work out how many more intervals need to be acted on
elapsed_seconds = datetime.now(pytz.UTC).timestamp() \
- start_date.timestamp()
remaining_intervals = intervals - \
(elapsed_seconds / self.__interval) + 1

if remaining_intervals <= 0:
remaining_intervals = 1
remaining_intervals = max(
min(intervals - (elapsed_seconds / self.__interval), intervals), 1)

device = self.__variable_manager.get_device(
self.__device
Expand All @@ -275,17 +282,37 @@ def __calculate_delta(self, start_date: datetime, delta_range: DeltaRange):
start = delta_range.start

# what is the delta
delta = (delta_range.end - start) / remaining_intervals
if self.__force:
# when we're forcing use the range ignoring what value it already has
delta = (delta_range.end - delta_range.start) / intervals
delta *= (intervals - remaining_intervals + 1)

# but we need to take the disparity into account
delta += delta_range.start - start
else:
delta = (delta_range.end - start) / remaining_intervals

return (DeltaRange(delta_range.type, start, delta_range.end), delta)
return (start, delta)

def __calculate_new_value(self, start_date: datetime, delta_range: DeltaRange):
(delta_range, delta) = self.__calculate_delta(start_date, delta_range)
(start, delta) = self.__calculate_delta(start_date, delta_range)

new_value = delta_range.start + delta
new_value = start + delta

# ensure the new value is in the correct direction
if not self.__force and \
((new_value > start and not delta_range.increasing)
or (new_value < start and delta_range.increasing)
or (new_value > delta_range.end and delta_range.increasing)
or (new_value < delta_range.end and not delta_range.increasing)):
new_value = start

new_value = round_for_type(delta_range.type, new_value)

return new_value

new_value = round_for_type(delta_range.type, new_value)
if delta > 0:
if delta_range.increasing:
new_value = min(max(new_value, delta_range.start), delta_range.end)
else:
new_value = max(min(new_value, delta_range.start), delta_range.end)
Expand All @@ -312,6 +339,9 @@ def __str__(self):
for device_type, delta in self.__delta.items():
builder += f', {device_type} between {delta.start} and {delta.end}'

if self.__force:
builder += ' forcing'

if self.__power:
builder += ' and turn it on'

Expand Down
57 changes: 32 additions & 25 deletions services/scheduler/tests/services/test_device_schedule.py
Original file line number Diff line number Diff line change
Expand Up @@ -213,8 +213,8 @@ async def test_execute(

@pytest.mark.asyncio
@pytest.mark.parametrize('brightness,current,expected', [
([0, 100], 1.111, 1.11 + 1.65),
([100, 0], 100 - 1.111, 100 - 1.11 - 1.65)
([0, 100], 1.111, 1.11 + 1.94),
([100, 0], 100 - 1.111, 100 - 1.11 - 1.94)
])
async def test_execute_round2dp(
self,
Expand Down Expand Up @@ -258,8 +258,8 @@ async def test_execute_round2dp(

@pytest.mark.asyncio
@pytest.mark.parametrize('hue,current,expected', [
([0, 360], 1.111, 1 + 6),
([360, 0], 360 - 1.111, 360 - 1 - 6)
([0, 360], 1.111, 2 + 6),
([360, 0], 360 - 1.111, 360 - 2 - 6)
])
async def test_execute_round0dp(
self,
Expand Down Expand Up @@ -342,15 +342,21 @@ async def test_execute_schedule_next(
assert job[2][1] == job[1].end_date

@pytest.mark.asyncio
@pytest.mark.parametrize('brightness,current,expected,next_expected', [
([0, 100], 50, 50 + 1.67, 50 + 1.67 * 2),
([100, 0], 50, 50 - 1.67, 50 - 1.67 * 2),
([0, 100], 0, 3.33, 3.33 * 2),
([100, 0], 100, 100 - 3.33, 100 - 3.33 * 2),
([0, 100], 100, 100, 100),
([100, 0], 0, 0, 0),
([10, 60], 10 + 20, 10 + 21, 10 + 22),
([60, 10], 60 - 20, 60 - 21, 60 - 22)
@pytest.mark.parametrize('brightness,current,expected,force', [
([0, 100], 50, [60, 70, 80, 90, 100], False),
([100, 0], 50, [40, 30, 20, 10, 0], False),
([0, 100], 0, [20, 40, 60, 80, 100], False),
([100, 0], 100, [80, 60, 40, 20, 0], False),
([0, 100], 100, [100, 100, 100, 100, 100], False),
([100, 0], 0, [0, 0, 0, 0, 0], False),
([10, 50], 25, [30, 35, 40, 45, 50], False),
([80, 50], 75, [70, 65, 60, 55, 50], False),
([20, 30], 31, [31, 31, 31, 31, 31], False), # not increasing
([30, 20], 19, [19, 19, 19, 19, 19], False), # not decreasing
# force
([70, 20], 100, [60, 50, 40, 30, 20], True),
([0, 50], 50, [10, 20, 30, 40, 50], True),
([50, 0], 0, [40, 30, 20, 10, 0], True),
])
async def test_execute_current_value(
self,
Expand All @@ -360,8 +366,8 @@ async def test_execute_current_value(
mocker: MockerFixture,
brightness: List[int],
current: float,
expected: float,
next_expected: float
expected: List[float],
force: bool
):
# pylint: disable=too-many-arguments,too-many-locals

Expand All @@ -370,34 +376,35 @@ async def test_execute_current_value(

subject = subject_builder({
'device': 'SomeDevice',
'between': ['09:10:00', '10:00:00'],
'between': ['09:11:00', '09:15:00'],
'interval': 60,
'brightness': brightness
'brightness': brightness,
'force': force
})

async def execute(minutes: float, current: float, expected: float):
variable.get_additional_state_for_scene = lambda _: {
'brightness': current
}

with patch_datetime(datetime(2023, 3, 1, 9, minutes, tzinfo=pytz.UTC)):
start_date = datetime(2023, 3, 1, 9, 10)
end_date = datetime(2023, 3, 1, 10, 0, tzinfo=pytz.UTC)
start_date = datetime(2023, 3, 1, 9, 11)
end_date = datetime(2023, 3, 1, 9, 15, tzinfo=pytz.UTC)

with patch_datetime(datetime(2023, 3, 1, 9, minutes, tzinfo=pytz.UTC)):
await subject.execute(start_date, end_date)

message = {'brightness': expected}
powerpi_mqtt_producer.assert_called_once_with(
self.__expected_topic, message
)

# run it once
await execute(31, current, expected)
powerpi_mqtt_producer.reset_mock()

# run it again for the next interval
powerpi_mqtt_producer.reset_mock()
previous = current
for i, expected_value in enumerate(expected):
await execute(11 + i, previous, expected_value)

await execute(32, expected, next_expected)
previous = expected_value

@pytest.mark.asyncio
async def test_execute_current_value_scenes(
Expand Down

0 comments on commit ee9fed8

Please sign in to comment.