Skip to content

Conversation

@lsowa
Copy link

@lsowa lsowa commented Dec 16, 2025

The Timer plugin enforces a recurring, time-of-day schedule for the demand of a target pool (see doc).

Implemented:

  • Timer module
  • pytests
  • documentation
  • tested in seven day long test set up

Copy link
Member

@maxfischer2781 maxfischer2781 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the PR, this looks like a useful tool.

There are quite a lot of comments from me on the implementation since it can be done much more efficiently. Please take them into account unless you have a good reason not to.
That said, I want to point out that your code was pretty good to understand; all these comments are there because you made it clear what you want to do.

Let's look at the docs and tests once the code has settled.

Comment on lines +16 to +23
def str_to_time(value: str) -> datetime_time:
"""Convert a HH:MM string into a :class:`datetime.time` object."""
try:
hours_str, minutes_str = value.strip().split(":", 1)
hours, minutes = int(hours_str), int(minutes_str)
except ValueError as exc:
raise ValueError(f"invalid time specification {value}")
return datetime_time(hour=hours, minute=minutes)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unless I am missing some extra feature here, please use datetime's native parsing. The equivalent of this function is datetime.strptime(value, '%H:%M').time().

Comment on lines +61 to +62
assert interval > 0, "Interval must be a positive integer."
self.interval = interval
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are working on a schedule, there is no need to have a polling interval. You can simply asyncio.sleep until the next schedule time.

I recommend to just remove it. See comments on the run method for auxiliary changes.

Suggested change
assert interval > 0, "Interval must be a positive integer."
self.interval = interval


schedule = {str_to_time(key): value for key,value in schedule.items()}
self.schedule = schedule
self.latest_sched_demand = self._refresh_demand()
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This attribute doesn't seem to be used outside of a redundant setter. I recommend to just remove it.

Suggested change
self.latest_sched_demand = self._refresh_demand()

self,
target: Pool,
schedule: Schedule,
interval: int = 300,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

See first comment on self.interval.

Suggested change
interval: int = 300,

@demand.setter
def demand(self, value: float) -> None:
# Ignore user supplied demand and always enforce the scheduled value.
self.target.demand = self.latest_sched_demand
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

self.target.demand should already equal self.latest_sched_demand. You don't have to do anything here.

Suggested change
self.target.demand = self.latest_sched_demand
pass



@service(flavour=asyncio)
class Timer(PoolDecorator):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe this should be a Controller instead. Since it doesn't allow setting the demand, there seems to be no point adding further Decorators or Controllers around it.

Comment on lines +64 to +65
schedule = {str_to_time(key): value for key,value in schedule.items()}
self.schedule = schedule
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In _refresh_demand is an assertion that the value here is positive. Please do the validation here already. Since the data must be sorted for various algorithms, either checking or enforcing this here seems prudent.
I also recommend to rename the key/value to what they actually represent.

Suggested change
schedule = {str_to_time(key): value for key,value in schedule.items()}
self.schedule = schedule
schedule = {str_to_time(time): demand for time, demand in schedule.items()}
assert all(demand >= 0 for demand in schedule.values())
self.schedule = dict(sorted(schedule.items()))

Comment on lines +79 to +92
while True:
self._refresh_demand()
await asyncio.sleep(self.interval)

def _refresh_demand(self) -> float:
"""Look up the demand that should currently be active."""
latest_sched_time = latest_timestamp(self.schedule.keys())
self.latest_sched_demand = self.schedule[latest_sched_time]
self.target.demand = self.latest_sched_demand

assert self.latest_sched_demand >= 0.0
assert self.target.demand >= 0.0

return self.latest_sched_demand
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since you are operating on a fixed schedule, there is no need to poll and fetch the current time slot again and again. Instead, I recommend to endlessly loop through the schedule and sleep precisely until you have to take action.

Suggested change
while True:
self._refresh_demand()
await asyncio.sleep(self.interval)
def _refresh_demand(self) -> float:
"""Look up the demand that should currently be active."""
latest_sched_time = latest_timestamp(self.schedule.keys())
self.latest_sched_demand = self.schedule[latest_sched_time]
self.target.demand = self.latest_sched_demand
assert self.latest_sched_demand >= 0.0
assert self.target.demand >= 0.0
return self.latest_sched_demand
# repeatedly sleep until the next scheduled change of demand
# this automatically fast-forwards through past events, meaning
# no special handling for the first day is needed
today = date.today()
while True:
for start_time, demand in schedule.items():
start_delta = datetime.combine(today, start_time) - datetime.now()
await asyncio.sleep(start_delta.total_seconds())
self.target.demand = demand
today += timedelta(days=1)

The timedelta must also be imported from datetime.

Comment on lines +12 to +13
TimeInput = Union[str, datetime_time]
Schedule = Mapping[TimeInput, float]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The initializer always calls str_to_time on the Schedule key, meaning that only str keys are acceptable.

Comment on lines +26 to +39
def latest_timestamp(
times: Iterable[datetime_time],
*,
reference: datetime_time | None = None, # easier to test with
) -> datetime_time:
"""Return the latest timestamp with respect to the current time."""
ordered_times = sorted(times) # ascending
current_time = datetime.now().time() if reference is None else reference
for candidate in reversed(ordered_times): # descending
if candidate <= current_time:
return candidate
# All configured times are in the future relative to ``current_time``.
# return latest timestamp
return ordered_times[-1]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is probably no longer needed, see later comments. If you want to keep it:

  • current_timestamp or recent_timestamp is probably a better name (without knowing the implementation, "latest" is likely to be read as "most late" instead of "most recent").
  • ordering should already be a prerequisite and established outside this function. Sorting the input again and again is a waste since a) the actual user input is likely sorted anyways and b) it's simple to sort once when reading the input.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants