Coverage for core / src / sensorkit / auto / scheduler.py: 76%
84 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-09-02 00:03 +0000
1# SPDX-License-Identifier: Apache-2.0
2from __future__ import annotations
4from collections.abc import Iterable, Sequence
5from dataclasses import dataclass
6from datetime import UTC, datetime, timedelta
7from typing import Any, cast
9from intervaltree import Interval, IntervalTree
10from pydantic import BaseModel, Field
12from sensorkit.auto.mode import Mode
13from sensorkit.common.interval import (
14 combine_interval_trees,
15 print_interval_tree,
16 stack_interval_trees,
17 stamp_interval_trees,
18)
19from sensorkit.common.logging import limited_logger
20from sensorkit.core.program import OfferInterval
21from sensorkit.core.task import TaskContextOverlay
24class ProgramConfig(BaseModel):
25 """Scheduling configuration for a single Program within a Controller."""
26 program: str
27 priority: int = 5
28 interrupt: bool = False
29 contexts: TaskContextOverlay = Field(default_factory=TaskContextOverlay)
32class ScheduleInterval(Interval):
33 """A typed interval carrying a ScheduleIntent as its data payload."""
34 begin: datetime
35 end: datetime
36 data: ScheduleIntent | None
39@dataclass
40class ScheduleIntent:
41 """The intended operating mode and ordered program candidates for a schedule interval."""
42 mode: str
43 programs: tuple[str, ...] = tuple()
46type Schedule = Sequence[ScheduleInterval]
49class Scheduler:
50 """Combines user configuration and Program tasking availability into an operating schedule."""
52 def __init__(
53 self,
54 modes: Iterable[Mode],
55 program_configs: list[ProgramConfig],
56 ):
57 # Sort the input modes in priority order.
58 self.modes = sorted(modes, reverse=True)
59 self.program_configs = {config.program: config for config in program_configs}
60 self.combined_offers = IntervalTree()
61 self.intervals = IntervalTree()
62 self._offer_history = IntervalTree()
64 def get_schedule(self) -> Schedule:
65 """Returns a serializable copy of the current schedule."""
66 return tuple(self.intervals)
68 def get_intent(self, dt: datetime):
69 """Retrieves the scheduled intent at the given time, if any."""
70 ivs = self.intervals.at(dt)
72 if not ivs:
73 return None
75 assert len(ivs) == 1
76 return cast(ScheduleIntent, next(iter(ivs)).data)
78 def update(
79 self,
80 *,
81 offers_dict: dict[str, Sequence[OfferInterval]],
82 enabled_programs: set[str],
83 mode_context: dict[str, Any] | None = None
84 ):
85 """Updates the schedule."""
86 mode_context = mode_context.copy() if mode_context else {}
87 now = datetime.now(UTC)
89 # Snapshot the previous combined offers before rebuilding so we can
90 # detect offers that were removed mid-window (e.g. task finished early).
91 prev_combined = self.combined_offers
93 mode_context["time_ref"] = now
94 mode_context["scheduler_previous_combined"] = prev_combined
95 mode_context["scheduler_offer_history"] = self._offer_history
97 # Evaluate observing mode criteria. This gets us an IntervalTree for each configured
98 # observing mode.
99 evaluated_modes = []
101 for mode in self.modes:
102 try:
103 intervals = mode.evaluate(mode_context)
104 except Exception as e:
105 exc_name = type(e).__name__
106 limited_logger(f"{mode.name}-{exc_name}", interval=300).warning(
107 f"{exc_name} while evaluating mode {mode.name}: {e}"
108 )
109 intervals = IntervalTree()
111 evaluated_modes.append(intervals)
113 # Get all the program offers for this Controller and sort them by configured priority.
114 offers = sorted(
115 (it for it in offers_dict.items() if it[0] in enabled_programs),
116 key=lambda item: self.program_configs[item[0]].priority
117 )
119 # Combine into our updated schedule.
120 self.combined_offers, self.intervals = create_schedule(
121 self.modes,
122 evaluated_modes,
123 (it[0] for it in offers),
124 (it[1] for it in offers),
125 )
127 self._support_after_activity(now, prev_combined)
129 def _support_after_activity(self, now: datetime, prev_combined: IntervalTree):
130 # FIXME: Below only exists to support the `after_activity` mode criterion, which should
131 # instead use closed-loop feedback about task execution (including manual).
132 # Detect offers that disappeared while still in-progress, and inform schedule
133 for iv in prev_combined:
134 if iv.begin < now < iv.end and not self.combined_offers.overlaps(iv.begin, iv.end):
135 truncated_end = now
136 if not self._offer_history.containsi(iv.begin, truncated_end, iv.data):
137 self._offer_history.addi(iv.begin, truncated_end, iv.data)
139 # Merge current offers into the persistent history
140 for iv in self.combined_offers:
141 if not self._offer_history.containsi(iv.begin, iv.end, iv.data):
142 self._offer_history.addi(iv.begin, iv.end, iv.data)
144 # Prune historical entries
145 cutoff = now - timedelta(hours=1)
146 stale = [iv for iv in self._offer_history if iv.end <= cutoff]
147 for iv in stale:
148 self._offer_history.discard(iv)
151def debug_print_schedule(arg: Schedule | Scheduler, *, lookahead_mins=30, print_func=print):
152 """Print the schedule interval tree for debugging, showing the next *lookahead_mins* minutes."""
153 schedule = arg.intervals if isinstance(arg, Scheduler) else arg
154 now = datetime.now(UTC)
155 print_interval_tree(
156 schedule,
157 start_from=now,
158 end_at=now + timedelta(minutes=lookahead_mins),
159 key_func=lambda iv: iv.data.programs[0] if iv.data.programs else None,
160 print_func=print_func,
161 legend_show_count=True,
162 legend_show_keys=True,
163 )
166def create_schedule(
167 modes: Iterable[Mode],
168 evaluated_modes: Iterable[IntervalTree],
169 programs: Iterable[str],
170 offers: Iterable[Iterable[OfferInterval]],
171):
172 """Build a combined schedule from evaluated mode intervals and program offer windows.
174 Returns `(combined_offers, schedule)` where *schedule* is an IntervalTree whose
175 intervals carry `ScheduleIntent` objects with the active mode and candidate programs.
176 """
177 # Combine each set of intervals by stacking them in order, removing overlap regions.
178 # The result is that operate modes take precedence over standby modes. We also set the
179 # associated data with each interval to a new `ScheduleIntent` object.
180 schedule = stack_interval_trees(
181 *evaluated_modes,
182 data=(ScheduleIntent(mode.name) for mode in modes),
183 )
185 # Combine the tasking schedules for all associated Programs such that each interval contains
186 # a priority-ordered list of Program candidates.
187 combined_offers = combine_interval_trees(
188 *offers,
189 data=programs,
190 )
192 # Merge program offer intervals into the mode intervals. This is done by splitting
193 # the mode intervals on offer interval boundaries. The data fields (ScheduleIntent) are
194 # updated to reflect which program offer, if any, overlaps each interval.
195 def merge_func(target_data: ScheduleIntent, stamp_data: tuple[str, ...]):
196 return ScheduleIntent(target_data.mode, stamp_data)
198 stamp_interval_trees(schedule, stamp=combined_offers, merge_func=merge_func)
200 return combined_offers, schedule