From 4a7ef6cac11a27de0e61e4d224e0dbfaf77c452c Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 18 Oct 2025 19:37:46 +0200 Subject: [PATCH 01/57] Let's see where this takes us... --- pyobs/utils/scheduler/__init__.py | 0 pyobs/utils/scheduler/merits/__init__.py | 0 pyobs/utils/scheduler/merits/merit.py | 26 ++++++++++++++++++++++++ 3 files changed, 26 insertions(+) create mode 100644 pyobs/utils/scheduler/__init__.py create mode 100644 pyobs/utils/scheduler/merits/__init__.py create mode 100644 pyobs/utils/scheduler/merits/merit.py diff --git a/pyobs/utils/scheduler/__init__.py b/pyobs/utils/scheduler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyobs/utils/scheduler/merits/__init__.py b/pyobs/utils/scheduler/merits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyobs/utils/scheduler/merits/merit.py b/pyobs/utils/scheduler/merits/merit.py new file mode 100644 index 000000000..d72864d07 --- /dev/null +++ b/pyobs/utils/scheduler/merits/merit.py @@ -0,0 +1,26 @@ +from abc import ABCMeta, abstractmethod + +from astroplan import Observer + + +class Merit(metaclass=ABCMeta): + """Merit class.""" + + def __init__(self, observer: Observer): + self.observer = observer + + @abstractmethod + def __call__(self, merit: float) -> float: ... + + @abstractmethod + def _calculate_merit(self) -> float: ... + + +class MultiplicativeMerit(Merit): + """Merit with multiple apertures.""" + + def __call__(self, merit: float) -> float: + return merit * self._calculate_merit() + + +__all__ = ["Merit"] From f01b445640532cd337179df8ee240180ee352b8c Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 20 Oct 2025 16:40:32 +0200 Subject: [PATCH 02/57] working on scheduler --- pyobs/utils/scheduler/dataprovider.py | 41 ++++++++++++++++++++++++ pyobs/utils/scheduler/merit.py | 15 +++++++++ pyobs/utils/scheduler/merits/constant.py | 20 ++++++++++++ pyobs/utils/scheduler/merits/merit.py | 26 --------------- pyobs/utils/scheduler/merits/pernight.py | 37 +++++++++++++++++++++ 5 files changed, 113 insertions(+), 26 deletions(-) create mode 100644 pyobs/utils/scheduler/dataprovider.py create mode 100644 pyobs/utils/scheduler/merit.py create mode 100644 pyobs/utils/scheduler/merits/constant.py delete mode 100644 pyobs/utils/scheduler/merits/merit.py create mode 100644 pyobs/utils/scheduler/merits/pernight.py diff --git a/pyobs/utils/scheduler/dataprovider.py b/pyobs/utils/scheduler/dataprovider.py new file mode 100644 index 000000000..a53618b84 --- /dev/null +++ b/pyobs/utils/scheduler/dataprovider.py @@ -0,0 +1,41 @@ +from dataclasses import dataclass +from astropy.time import Time + +from pyobs.robotic import Task + + +@dataclass +class TaskSuccess: + date: Time + night: Time + + +class DataProvider: + """Data provider for Merit classes.""" + + def __init__(self) -> None: + self._task_success: dict[Task, list[TaskSuccess]] = {} + + def reset(self) -> None: + """Reset data provider.""" + self._task_success.clear() + + def get_night(self) -> Time: + """Returns the current night.""" + return Time.now() + + def get_task_success_count(self, task: Task) -> int: + """Return the number of successful runs for task.""" + if task not in self._task_success: + return 0 + return len(self._task_success[task]) + + def get_task_success(self, task: Task, number: int = -1) -> TaskSuccess | None: + """Return the number of successful runs for task.""" + try: + return self._task_success[task][number] + except IndexError: + return None + + +__all__ = ["DataProvider"] diff --git a/pyobs/utils/scheduler/merit.py b/pyobs/utils/scheduler/merit.py new file mode 100644 index 000000000..47833fa0f --- /dev/null +++ b/pyobs/utils/scheduler/merit.py @@ -0,0 +1,15 @@ +from abc import ABCMeta, abstractmethod +from astropy.time import Time + +from .dataprovider import DataProvider +from ...robotic import Task + + +class Merit(metaclass=ABCMeta): + """Merit class.""" + + @abstractmethod + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: ... + + +__all__ = ["Merit"] diff --git a/pyobs/utils/scheduler/merits/constant.py b/pyobs/utils/scheduler/merits/constant.py new file mode 100644 index 000000000..4f802ecf5 --- /dev/null +++ b/pyobs/utils/scheduler/merits/constant.py @@ -0,0 +1,20 @@ +from typing import Any + +from astropy.time import Time + +from pyobs.robotic import Task +from ..dataprovider import DataProvider +from ..merit import Merit + + +class ConstantMerit(Merit): + """Merit function that returns a constant value.""" + + def __init__(self, merit: float, **kwargs: Any): + self._merit = merit + + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + return self._merit + + +__all__ = ["ConstantMerit"] diff --git a/pyobs/utils/scheduler/merits/merit.py b/pyobs/utils/scheduler/merits/merit.py deleted file mode 100644 index d72864d07..000000000 --- a/pyobs/utils/scheduler/merits/merit.py +++ /dev/null @@ -1,26 +0,0 @@ -from abc import ABCMeta, abstractmethod - -from astroplan import Observer - - -class Merit(metaclass=ABCMeta): - """Merit class.""" - - def __init__(self, observer: Observer): - self.observer = observer - - @abstractmethod - def __call__(self, merit: float) -> float: ... - - @abstractmethod - def _calculate_merit(self) -> float: ... - - -class MultiplicativeMerit(Merit): - """Merit with multiple apertures.""" - - def __call__(self, merit: float) -> float: - return merit * self._calculate_merit() - - -__all__ = ["Merit"] diff --git a/pyobs/utils/scheduler/merits/pernight.py b/pyobs/utils/scheduler/merits/pernight.py new file mode 100644 index 000000000..b21a24678 --- /dev/null +++ b/pyobs/utils/scheduler/merits/pernight.py @@ -0,0 +1,37 @@ +from typing import Any + +from astropy.time import Time + +from pyobs.robotic import Task +from ..dataprovider import DataProvider +from ..merit import Merit + + +class PerNightMerit(Merit): + """Merit functions for defining a max number of observations per night.""" + + def __init__(self, count: int, **kwargs: Any): + self._count = count + + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + # get number of successful task runs + successes = data.get_task_success_count(task) + + # if we have less successful runs than request, merit goes to one + if successes <= self._count: + return 1.0 + + # get old task run + ts = data.get_task_success(task, -self._count) + if ts is None: + return 1.0 + + # if the (-count)th run is not from tonight, we can also run + if ts.night != data.get_night(): + return 1.0 + + # guess, we can't run + return 0.0 + + +__all__ = ["PerNightMerit"] From 8564d0260aacb03d112823c37a01953178b3a083 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 24 Oct 2025 14:03:18 +0200 Subject: [PATCH 03/57] testing --- pyobs/utils/scheduler/dataprovider.py | 38 +++++++++++++++------------ 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/pyobs/utils/scheduler/dataprovider.py b/pyobs/utils/scheduler/dataprovider.py index a53618b84..fe4f7c1c4 100644 --- a/pyobs/utils/scheduler/dataprovider.py +++ b/pyobs/utils/scheduler/dataprovider.py @@ -1,5 +1,8 @@ from dataclasses import dataclass -from astropy.time import Time +from functools import cache +from astroplan import Observer +from astropy.time import Time, TimeDelta +import astropy.units as u from pyobs.robotic import Task @@ -13,29 +16,30 @@ class TaskSuccess: class DataProvider: """Data provider for Merit classes.""" - def __init__(self) -> None: - self._task_success: dict[Task, list[TaskSuccess]] = {} + def __init__(self, observer: Observer): + self.observer = observer def reset(self) -> None: """Reset data provider.""" - self._task_success.clear() - - def get_night(self) -> Time: - """Returns the current night.""" - return Time.now() - - def get_task_success_count(self, task: Task) -> int: + ... + + @cache + def get_night(self, time: Time) -> Time: + """Returns the night for the given time.""" + night = Time(f"{time.year}-{time.month:02d}-{time.day:02d} 00:00:00") + if time.hour < 12: + night -= TimeDelta(1.0 * u.day) + return night + + @cache + def get_task_success_count(self, time: Time, task: Task) -> int: """Return the number of successful runs for task.""" - if task not in self._task_success: - return 0 - return len(self._task_success[task]) + return 0 + @cache def get_task_success(self, task: Task, number: int = -1) -> TaskSuccess | None: """Return the number of successful runs for task.""" - try: - return self._task_success[task][number] - except IndexError: - return None + return None __all__ = ["DataProvider"] From b950efafce5d17632e8c71d9017fda7f42357a1e Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 31 Oct 2025 12:45:26 +0100 Subject: [PATCH 04/57] introduced ScheduledTask --- pyobs/robotic/__init__.py | 2 +- pyobs/robotic/lco/taskschedule.py | 52 +++++++++++++++-------------- pyobs/robotic/scheduler/__init__.py | 0 pyobs/robotic/task.py | 50 ++++++++++++++++----------- pyobs/robotic/taskschedule.py | 12 +++---- 5 files changed, 65 insertions(+), 51 deletions(-) create mode 100644 pyobs/robotic/scheduler/__init__.py diff --git a/pyobs/robotic/__init__.py b/pyobs/robotic/__init__.py index ba8c4f3ed..84fe3ca7f 100644 --- a/pyobs/robotic/__init__.py +++ b/pyobs/robotic/__init__.py @@ -1,4 +1,4 @@ from .taskschedule import TaskSchedule -from .task import Task +from .task import Task, ScheduledTask from .taskarchive import TaskArchive from .taskrunner import TaskRunner diff --git a/pyobs/robotic/lco/taskschedule.py b/pyobs/robotic/lco/taskschedule.py index 34220f482..b30e46671 100644 --- a/pyobs/robotic/lco/taskschedule.py +++ b/pyobs/robotic/lco/taskschedule.py @@ -2,13 +2,13 @@ import asyncio.exceptions from urllib.parse import urljoin import logging -from typing import Any, cast +from typing import Any import aiohttp as aiohttp from astroplan import ObservingBlock from astropy.time import TimeDelta import astropy.units as u -from pyobs.robotic.task import Task +from pyobs.robotic.task import Task, ScheduledTask from pyobs.utils.time import Time from pyobs.robotic.taskschedule import TaskSchedule from .portal import Portal @@ -76,7 +76,7 @@ def __init__( self._header = {"Authorization": "Token " + token} # task list - self._tasks: dict[str, LcoTask] = {} + self._scheduled_tasks: list[ScheduledTask] = [] # error logging for regular updates self._update_error_log = ResolvableErrorLogger(log, error_level=logging.WARNING) @@ -164,7 +164,7 @@ async def update_now(self, force: bool = False) -> None: # need update! try: - tasks = await self._get_schedule(end_after=now, start_before=now + TimeDelta(24 * u.hour)) + scheduled_tasks = await self._get_schedule(end_after=now, start_before=now + TimeDelta(24 * u.hour)) self._update_error_log.resolve("Successfully updated schedule.") except TimeoutError: self._update_error_log.error("Request for updating schedule timed out.") @@ -175,13 +175,15 @@ async def update_now(self, force: bool = False) -> None: return # any changes? - if sorted(tasks) != sorted(self._tasks): - log.info("Task list changed, found %d task(s) to run.", len(tasks)) - for task_id, task in sorted(tasks.items(), key=lambda x: x[1].start): - log.info(f" - {task.start} to {task.end}: {task.name} (#{task_id})") + if sorted(scheduled_tasks, key=lambda x: x.start) != sorted(self._scheduled_tasks, key=lambda x: x.start): + log.info("Task list changed, found %d task(s) to run.", len(scheduled_tasks)) + for scheduled_task in sorted(scheduled_tasks, key=lambda x: x.start): + log.info( + f" - {scheduled_task.start} to {scheduled_task.end}: {scheduled_task.task.name} (#{scheduled_task.task.id})" + ) # update - self._tasks = cast(dict[str, LcoTask], tasks) + self._scheduled_tasks = scheduled_tasks # finished self._last_schedule_time = now @@ -190,7 +192,7 @@ async def update_now(self, force: bool = False) -> None: # release lock self._update_lock.release() - async def get_schedule(self) -> dict[str, Task]: + async def get_schedule(self) -> list[ScheduledTask]: """Fetch schedule from portal. Returns: @@ -200,18 +202,17 @@ async def get_schedule(self) -> dict[str, Task]: Timeout: If request timed out. ValueError: If something goes wrong. """ - return cast(dict[str, Task], self._tasks) + return self._scheduled_tasks - async def _get_schedule(self, start_before: Time, end_after: Time) -> dict[str, Task]: + async def _get_schedule(self, start_before: Time, end_after: Time) -> list[ScheduledTask]: """Fetch schedule from portal. Args: start_before: Task must start before this time. end_after: Task must end after this time. - include_running: Whether to include a currently running task. Returns: - Dictionary with tasks. + List with tasks. Raises: Timeout: If request timed out. @@ -242,20 +243,21 @@ async def _get_schedule(self, start_before: Time, end_after: Time) -> dict[str, schedules = data["results"] # create tasks - tasks = {} + scheduled_tasks: list[ScheduledTask] = [] for sched in schedules: - # parse start and end - sched["start"] = Time(sched["start"]) - sched["end"] = Time(sched["end"]) - # create task task = self._create_task(LcoTask, config=sched) - tasks[sched["request"]["id"]] = task + + # create scheduled task + scheduled_task = ScheduledTask(task=task, start=Time(sched["start"]), end=Time(sched["end"])) + + # add it + scheduled_tasks.append(scheduled_task) # finished - return tasks + return scheduled_tasks - async def get_task(self, time: Time) -> LcoTask | None: + async def get_task(self, time: Time) -> Task | None: """Returns the active task at the given time. Args: @@ -266,10 +268,10 @@ async def get_task(self, time: Time) -> LcoTask | None: """ # loop all tasks - for task in self._tasks.values(): + for scheduled_task in self._scheduled_tasks: # running now? - if task.start <= time < task.end and not task.is_finished(): - return task + if scheduled_task.start <= time < scheduled_task.end and not scheduled_task.task.is_finished(): + return scheduled_task.task # nothing found return None diff --git a/pyobs/robotic/scheduler/__init__.py b/pyobs/robotic/scheduler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pyobs/robotic/task.py b/pyobs/robotic/task.py index 2cee7d615..1c0de9762 100644 --- a/pyobs/robotic/task.py +++ b/pyobs/robotic/task.py @@ -1,6 +1,6 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Tuple, TYPE_CHECKING, Any, Optional, List, Dict +from typing import TYPE_CHECKING, Any from pyobs.object import Object from pyobs.robotic.scripts import Script @@ -31,20 +31,8 @@ def duration(self) -> float: """Returns estimated duration of task in seconds.""" ... - @property - @abstractmethod - def start(self) -> Time: - """Start time for task""" - ... - - @property @abstractmethod - def end(self) -> Time: - """End time for task""" - ... - - @abstractmethod - async def can_run(self, scripts: Optional[Dict[str, Script]] = None) -> bool: + async def can_run(self, scripts: dict[str, Script] | None = None) -> bool: """Checks, whether this task could run now. Returns: @@ -66,9 +54,9 @@ def can_start_late(self) -> bool: async def run( self, task_runner: TaskRunner, - task_schedule: Optional[TaskSchedule] = None, - task_archive: Optional[TaskArchive] = None, - scripts: Optional[Dict[str, Script]] = None, + task_schedule: TaskSchedule | None = None, + task_archive: TaskArchive | None = None, + scripts: dict[str, Script] | None = None, ) -> None: """Run a task""" ... @@ -78,7 +66,7 @@ def is_finished(self) -> bool: """Whether task is finished.""" ... - def get_fits_headers(self, namespaces: Optional[List[str]] = None) -> Dict[str, Tuple[Any, str]]: + def get_fits_headers(self, namespaces: list[str] | None = None) -> dict[str, tuple[Any, str]]: """Returns FITS header for the current status of this module. Args: @@ -90,4 +78,28 @@ def get_fits_headers(self, namespaces: Optional[List[str]] = None) -> Dict[str, return {} -__all__ = ["Task"] +class ScheduledTask: + """A scheduled task.""" + + def __init__(self, task: Task, start: Time, end: Time): + self._task = task + self._start = start + self._end = end + + @property + def task(self) -> Task: + """Returns the task.""" + return self._task + + @property + def start(self) -> Time: + """Start time for task""" + return self._start + + @property + def end(self) -> Time: + """End time for task""" + return self._end + + +__all__ = ["Task", "ScheduledTask"] diff --git a/pyobs/robotic/taskschedule.py b/pyobs/robotic/taskschedule.py index 473540a34..efb04f5a2 100644 --- a/pyobs/robotic/taskschedule.py +++ b/pyobs/robotic/taskschedule.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from typing import Optional, Any, List, Dict, Type +from typing import Any, Type from astroplan import ObservingBlock from pyobs.utils.time import Time -from .task import Task +from .task import Task, ScheduledTask from pyobs.object import Object @@ -12,7 +12,7 @@ def __init__(self, **kwargs: Any): Object.__init__(self, **kwargs) @abstractmethod - async def set_schedule(self, blocks: List[ObservingBlock], start_time: Time) -> None: + async def set_schedule(self, blocks: list[ObservingBlock], start_time: Time) -> None: """Set the list of scheduled blocks. Args: @@ -22,12 +22,12 @@ async def set_schedule(self, blocks: List[ObservingBlock], start_time: Time) -> ... @abstractmethod - async def last_scheduled(self) -> Optional[Time]: + async def last_scheduled(self) -> Time | None: """Returns time of last scheduler run.""" ... @abstractmethod - async def get_schedule(self) -> Dict[str, Task]: + async def get_schedule(self) -> list[ScheduledTask]: """Fetch schedule from portal. Returns: @@ -40,7 +40,7 @@ async def get_schedule(self) -> Dict[str, Task]: ... @abstractmethod - async def get_task(self, time: Time) -> Optional[Task]: + async def get_task(self, time: Time) -> Task | None: """Returns the active task at the given time. Args: From 15dd1a6570d6e2a875663e22e5297901d70a0cc3 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 31 Oct 2025 15:04:01 +0100 Subject: [PATCH 05/57] working on abstracting schedulers --- pyobs/modules/robotic/scheduler.py | 236 ++++------------------- pyobs/robotic/lco/taskarchive.py | 15 +- pyobs/robotic/lco/taskschedule.py | 25 ++- pyobs/robotic/scheduler/__init__.py | 1 + pyobs/robotic/scheduler/astroplan.py | 123 ++++++++++++ pyobs/robotic/scheduler/taskscheduler.py | 18 ++ pyobs/robotic/taskarchive.py | 14 +- pyobs/robotic/taskschedule.py | 7 +- tests/modules/robotic/test_scheduler.py | 8 +- 9 files changed, 217 insertions(+), 230 deletions(-) create mode 100644 pyobs/robotic/scheduler/astroplan.py create mode 100644 pyobs/robotic/scheduler/taskscheduler.py diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 183bd44ec..8eabf3237 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -1,24 +1,18 @@ from __future__ import annotations import asyncio -import copy import json import logging -import multiprocessing as mp import time -from typing import Union, List, Tuple, Any, Optional, Dict -import astroplan -from astroplan import ObservingBlock -from astropy.time import TimeDelta -import astropy.units as u +from typing import Union, Any, Dict from pyobs.events.taskfinished import TaskFinishedEvent from pyobs.events.taskstarted import TaskStartedEvent from pyobs.events import GoodWeatherEvent, Event +from pyobs.robotic.scheduler import TaskScheduler from pyobs.utils.time import Time from pyobs.interfaces import IStartStop, IRunnable from pyobs.modules import Module -from pyobs.robotic import TaskArchive, TaskSchedule - +from pyobs.robotic import TaskArchive, TaskSchedule, ScheduledTask, Task log = logging.getLogger(__name__) @@ -30,11 +24,9 @@ class Scheduler(Module, IStartStop, IRunnable): def __init__( self, + scheduler: dict[str, Any] | TaskScheduler, tasks: Union[Dict[str, Any], TaskArchive], schedule: Union[Dict[str, Any], TaskSchedule], - schedule_range: int = 24, - safety_time: float = 60, - twilight: str = "astronomical", trigger_on_task_started: bool = False, trigger_on_task_finished: bool = False, **kwargs: Any, @@ -54,13 +46,11 @@ def __init__( Module.__init__(self, **kwargs) # get scheduler + self._scheduler = self.add_child_object(scheduler, TaskScheduler) self._task_archive = self.add_child_object(tasks, TaskArchive) self._schedule = self.add_child_object(schedule, TaskSchedule) # store - self._schedule_range = schedule_range - self._safety_time = safety_time - self._twilight = twilight self._running = True self._initial_update_done = False self._need_update = False @@ -68,14 +58,14 @@ def __init__( self._trigger_on_task_finished = trigger_on_task_finished # time to start next schedule from - self._schedule_start: Optional[Time] = None + self._schedule_start: Time = Time.now() # ID of currently running task, and current (or last if finished) block self._current_task_id = None self._last_task_id = None - # blocks - self._blocks: List[ObservingBlock] = [] + # tasks + self._tasks: list[Task] = [] # update thread self.add_background_task(self._schedule_worker) @@ -110,23 +100,23 @@ async def _update_worker(self) -> None: # run forever while True: # not running? - if self._running is False: + if not self._running: await asyncio.sleep(1) continue # got new time of last change? t = await self._task_archive.last_changed() if last_change is None or last_change < t: - # get schedulable blocks and sort them - log.info("Found update in schedulable block, downloading them...") - blocks = sorted( - await self._task_archive.get_schedulable_blocks(), - key=lambda x: json.dumps(x.configuration, sort_keys=True), + # get schedulable tasks and sort them + log.info("Found update in schedulable tasks, downloading them...") + tasks = sorted( + await self._task_archive.get_schedulable_tasks(), + key=lambda x: json.dumps(x.id, sort_keys=True), ) - log.info("Downloaded %d schedulable block(s).", len(blocks)) + log.info("Downloaded %d schedulable tasks(s).", len(tasks)) # compare new and old lists - removed, added = self._compare_block_lists(self._blocks, blocks) + removed, added = self._compare_task_lists(self._tasks, tasks) # schedule update self._need_update = True @@ -142,11 +132,11 @@ async def _update_worker(self) -> None: if len(removed) == 1: log.info( "Found 1 removed block with ID %d. Last task ID was %s, current is %s.", - removed[0].target.name, + removed[0].id, str(self._last_task_id), str(self._current_task_id), ) - if len(removed) == 1 and len(added) == 0 and removed[0].target.name == self._last_task_id: + if len(removed) == 1 and len(added) == 0 and removed[0].id == self._last_task_id: # no need to re-schedule log.info("Only one removed block detected, which is the one currently running.") self._need_update = False @@ -160,7 +150,7 @@ async def _update_worker(self) -> None: self._need_update = False # store blocks - self._blocks = blocks + self._tasks = tasks # schedule update if self._need_update: @@ -174,25 +164,23 @@ async def _update_worker(self) -> None: await asyncio.sleep(5) @staticmethod - def _compare_block_lists( - blocks1: List[ObservingBlock], blocks2: List[ObservingBlock] - ) -> Tuple[List[ObservingBlock], List[ObservingBlock]]: - """Compares two lists of ObservingBlocks and returns two lists, containing those that are missing in list 1 + def _compare_task_lists(tasks1: list[Task], tasks2: list[Task]) -> tuple[list[Task], list[Task]]: + """Compares two lists of tasks and returns two lists, containing those that are missing in list 1 and list 2, respectively. Args: - blocks1: First list of blocks. - blocks2: Second list of blocks. + tasks1: First list of tasks. + tasks2: Second list of tasks. Returns: (tuple): Tuple containing: - unique1: Blocks that exist in blocks1, but not in blocks2. - unique2: Blocks that exist in blocks2, but not in blocks1. + unique1: Blocks that exist in tasks1, but not in tasks2. + unique2: Blocks that exist in tasks2, but not in tasks1. """ # get dictionaries with block names - names1 = {b.target.name: b for b in blocks1} - names2 = {b.target.name: b for b in blocks2} + names1 = {t.id: t for t in tasks1} + names2 = {t.id: t for t in tasks2} # find elements in names1 that are missing in names2 and vice versa additional1 = set(names1.keys()).difference(names2.keys()) @@ -212,17 +200,15 @@ async def _schedule_worker(self) -> None: self._need_update = False try: + # start time start_time = time.time() - # prepare scheduler - blocks, start, end, constraints = await self._prepare_schedule() - # schedule - scheduled_blocks = await self._schedule_blocks(blocks, start, end, constraints) + scheduled_tasks = await self._scheduler.schedule(self._tasks, self._schedule_start) - # finish schedule - await self._finish_schedule(scheduled_blocks, blocks, start) + # upload schedule + await self._finish_schedule(scheduled_tasks) # set new safety_time as duration + 20% self._safety_time = (time.time() - start_time) * 1.2 @@ -233,167 +219,29 @@ async def _schedule_worker(self) -> None: # sleep a little await asyncio.sleep(1) - async def _prepare_schedule(self) -> Tuple[List[ObservingBlock], Time, Time, List[Any]]: - """TaskSchedule blocks.""" - - # only global constraint is the night - if self._twilight == "astronomical": - constraints = [astroplan.AtNightConstraint.twilight_astronomical()] - elif self._twilight == "nautical": - constraints = [astroplan.AtNightConstraint.twilight_nautical()] - else: - raise ValueError("Unknown twilight type.") - - # make shallow copies of all blocks and loop them - copied_blocks = [copy.copy(block) for block in self._blocks] - for block in copied_blocks: - # astroplan's PriorityScheduler expects lower priorities to be more important, so calculate - # 1000 - priority - block.priority = 1000.0 - block.priority - if block.priority < 0: - block.priority = 0 - - # it also doesn't match the requested observing windows exactly, so we make them a little smaller. - for constraint in block.constraints: - if isinstance(constraint, astroplan.TimeConstraint): - constraint.min += 30 * u.second - constraint.max -= 30 * u.second - - # get start time for scheduler - start = self._schedule_start - now_plus_safety = Time.now() + self._safety_time * u.second - if start is None or start < now_plus_safety: - # if no ETA exists or is in the past, use safety time - start = now_plus_safety - - # get running scheduled block, if any - if self._current_task_id is None: - log.info("No running block found.") - running_task = None - else: - # get running task from archive - log.info("Trying to find running block in current schedule...") - tasks = await self._schedule.get_schedule() - if self._current_task_id in tasks: - running_task = tasks[self._current_task_id] - else: - log.info("Running block not found in last schedule.") - running_task = None - - # if start is before end time of currently running block, change that - if running_task is not None: - log.info("Found running block that ends at %s.", running_task.end) - - # get block end plus some safety - block_end = running_task.end + 10.0 * u.second - if start < block_end: - start = block_end - log.info("Start time would be within currently running block, shifting to %s.", start.isot) - - # calculate end time - end = start + TimeDelta(self._schedule_range * u.hour) - - # remove currently running block and filter by start time - blocks: List[ObservingBlock] = [] - for b in filter(lambda x: x.configuration["request"]["id"] != self._current_task_id, copied_blocks): - time_constraint_found = False - # loop all constraints - for c in b.constraints: - if isinstance(c, astroplan.TimeConstraint): - # we found a time constraint - time_constraint_found = True - - # does the window start before the end of the scheduling range? - if c.min < end: - # yes, store block and break loop - blocks.append(b) - break - else: - # loop has finished without breaking - # if no time constraint has been found, we still take the block - if time_constraint_found is False: - blocks.append(b) - - # if need new update, skip here - if self._need_update: - raise ValueError("Not running scheduler, since update was requested.") - - # no blocks found? - if len(blocks) == 0: - await self._schedule.set_schedule([], start) - raise ValueError("No blocks left for scheduling.") - - # return all - return blocks, start, end, constraints - - async def _schedule_blocks( - self, blocks: List[ObservingBlock], start: Time, end: Time, constraints: List[Any] - ) -> List[ObservingBlock]: - - # run actual scheduler in separate process and wait for it - queue_out: mp.Queue[ObservingBlock] = mp.Queue() - p = mp.Process(target=self._schedule_process, args=(blocks, start, end, constraints, queue_out)) - p.start() - - # wait for process to finish - # note that the process only finishes, when the queue is empty! so we have to poll the queue first - # and then the process. - loop = asyncio.get_running_loop() - scheduled_blocks: List[ObservingBlock] = await loop.run_in_executor(None, queue_out.get, True) - await loop.run_in_executor(None, p.join) - return scheduled_blocks - - async def _finish_schedule( - self, scheduled_blocks: List[ObservingBlock], blocks: List[ObservingBlock], start: Time - ) -> None: + async def _finish_schedule(self, scheduled_tasks: list[ScheduledTask]) -> None: # if need new update, skip here if self._need_update: log.info("Not using scheduler results, since update was requested.") return # update - await self._schedule.set_schedule(scheduled_blocks, start) - if len(scheduled_blocks) > 0: - log.info("Finished calculating schedule for %d block(s):", len(scheduled_blocks)) - for i, block in enumerate(scheduled_blocks, 1): + # await self._schedule.set_schedule(scheduled_tasks, start) + + # log + if len(scheduled_tasks) > 0: + log.info("Finished calculating schedule for %d block(s):", len(scheduled_tasks)) + for i, scheduled_task in enumerate(scheduled_tasks, 1): log.info( " - %s to %s: %s (%d)", - block.start_time.strftime("%H:%M:%S"), - block.end_time.strftime("%H:%M:%S"), - block.name, - block.configuration["request"]["id"], + scheduled_task.start.strftime("%H:%M:%S"), + scheduled_task.end.strftime("%H:%M:%S"), + scheduled_task.task.name, + scheduled_task.task.id, ) else: log.info("Finished calculating schedule for 0 blocks.") - def _schedule_process( - self, - blocks: List[ObservingBlock], - start: Time, - end: Time, - constraints: List[Any], - scheduled_blocks: mp.Queue[ObservingBlock], - ) -> None: - """Actually do the scheduling, usually run in a separate process.""" - - # log it - log.info("Calculating schedule for %d schedulable block(s) starting at %s...", len(blocks), start) - - # we don't need any transitions - transitioner = astroplan.Transitioner() - - # create scheduler - scheduler = astroplan.PriorityScheduler(constraints, self.observer, transitioner=transitioner) - - # run scheduler - logging.disable(logging.WARNING) - time_range = astroplan.Schedule(start, end) - schedule = scheduler(blocks, time_range) - logging.disable(logging.NOTSET) - - # put scheduled blocks in queue - scheduled_blocks.put(schedule.scheduled_blocks) - async def run(self, **kwargs: Any) -> None: """Trigger a re-schedule.""" self._need_update = True diff --git a/pyobs/robotic/lco/taskarchive.py b/pyobs/robotic/lco/taskarchive.py index 6f18be5d0..30afe62dc 100644 --- a/pyobs/robotic/lco/taskarchive.py +++ b/pyobs/robotic/lco/taskarchive.py @@ -1,5 +1,5 @@ import logging -from typing import List, Dict, Optional, Any +from typing import Dict, Optional, Any from astroplan import ( TimeConstraint, AirmassConstraint, @@ -16,6 +16,7 @@ from pyobs.robotic.taskarchive import TaskArchive from .portal import Portal from .task import LcoTask +from .. import Task log = logging.getLogger(__name__) @@ -67,11 +68,11 @@ async def last_changed(self) -> Optional[Time]: # even in case of errors, return last time return self._last_changed - async def get_schedulable_blocks(self) -> List[ObservingBlock]: - """Returns list of schedulable blocks. + async def get_schedulable_tasks(self) -> list[Task]: + """Returns list of schedulable tasks. Returns: - List of schedulable blocks + List of schedulable tasks """ # get data @@ -82,7 +83,7 @@ async def get_schedulable_blocks(self) -> List[ObservingBlock]: tac_priorities = {p["id"]: p["tac_priority"] for p in proposals} # loop all request groups - blocks = [] + tasks = [] for group in schedulable: # get base priority, which is tac_priority * ipp_value proposal = group["proposal"] @@ -144,10 +145,10 @@ async def get_schedulable_blocks(self) -> List[ObservingBlock]: configuration={"request": req}, name=group["name"], ) - blocks.append(block) + tasks.append(block) # return blocks - return blocks + return tasks __all__ = ["LcoTaskArchive"] diff --git a/pyobs/robotic/lco/taskschedule.py b/pyobs/robotic/lco/taskschedule.py index b30e46671..73ba1e1c8 100644 --- a/pyobs/robotic/lco/taskschedule.py +++ b/pyobs/robotic/lco/taskschedule.py @@ -2,9 +2,8 @@ import asyncio.exceptions from urllib.parse import urljoin import logging -from typing import Any +from typing import Any, cast import aiohttp as aiohttp -from astroplan import ObservingBlock from astropy.time import TimeDelta import astropy.units as u @@ -316,16 +315,16 @@ async def _send_update_later(self, status_id: int, status: dict[str, Any], delay # re-send await self.send_update(status_id, status) - async def set_schedule(self, blocks: list[ObservingBlock], start_time: Time) -> None: - """Update the list of scheduled blocks. + async def set_schedule(self, tasks: list[ScheduledTask], start_time: Time) -> None: + """Set the list of scheduled tasks. Args: - blocks: Scheduled blocks. + tasks: Scheduled tasks. start_time: Start time for schedule. """ # create observations - observations = self._create_observations(blocks) + observations = self._create_observations(tasks) # cancel schedule await self._cancel_schedule(start_time) @@ -356,30 +355,30 @@ async def _cancel_schedule(self, now: Time) -> None: if response.status != 200: log.error("Could not cancel schedule: %s", await response.text()) - def _create_observations(self, blocks: list[ObservingBlock]) -> list[dict[str, Any]]: + def _create_observations(self, scheduled_tasks: list[ScheduledTask]) -> list[dict[str, Any]]: """Create observations from schedule. Args: - blocks: List of scheduled blocks + scheduled_tasks: List of scheduled tasks Returns: List with observations. """ - # loop blocks + # loop tasks # TODO: get site, enclosure, telescope and instrument from obsportal using the instrument type observations = [] - for block in blocks: + for scheduled_task in scheduled_tasks: # get request - request = block.configuration["request"] + request = cast(LcoTask, scheduled_task.task).config["request"] # create observation obs = { "site": self._site, "enclosure": self._enclosure, "telescope": self._telescope, - "start": block.start_time.isot, - "end": block.end_time.isot, + "start": scheduled_task.start.isot, + "end": scheduled_task.end.isot, "request": request["id"], "configuration_statuses": [], } diff --git a/pyobs/robotic/scheduler/__init__.py b/pyobs/robotic/scheduler/__init__.py index e69de29bb..fc8a2bfc3 100644 --- a/pyobs/robotic/scheduler/__init__.py +++ b/pyobs/robotic/scheduler/__init__.py @@ -0,0 +1 @@ +from .taskscheduler import TaskScheduler diff --git a/pyobs/robotic/scheduler/astroplan.py b/pyobs/robotic/scheduler/astroplan.py new file mode 100644 index 000000000..377f9844c --- /dev/null +++ b/pyobs/robotic/scheduler/astroplan.py @@ -0,0 +1,123 @@ +from __future__ import annotations +import asyncio +import logging +import multiprocessing as mp +from typing import List, Any +import astroplan +from astroplan import ObservingBlock + +from pyobs.object import Object +from pyobs.utils.time import Time +from pyobs.robotic import ScheduledTask, Task + +log = logging.getLogger(__name__) + + +class AstroplanScheduler(Object): + """Scheduler based on astroplan.""" + + __module__ = "pyobs.modules.robotic" + + def __init__( + self, + schedule_range: int = 24, + safety_time: float = 60, + twilight: str = "astronomical", + trigger_on_task_started: bool = False, + trigger_on_task_finished: bool = False, + **kwargs: Any, + ): + """Initialize a new scheduler. + + Args: + schedule_range: Number of hours to schedule into the future + safety_time: If no ETA for next task to start exists (from current task, weather became good, etc), use + this time in seconds to make sure that we don't schedule for a time when the scheduler is + still running + twilight: astronomical or nautical + trigger_on_task_started: Whether to trigger a re-calculation of schedule, when task has started. + trigger_on_task_finishes: Whether to trigger a re-calculation of schedule, when task has finished. + """ + Object.__init__(self, **kwargs) + + # store + self._schedule_range = schedule_range + self._safety_time = safety_time + self._twilight = twilight + self._running = True + self._initial_update_done = False + self._need_update = False + self._trigger_on_task_started = trigger_on_task_started + self._trigger_on_task_finished = trigger_on_task_finished + + async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: + # prepare scheduler + blocks, start, end, constraints = await self._prepare_schedule(tasks) + + # schedule + return await self._schedule_blocks(blocks, start, end, constraints) + + async def _prepare_schedule(self, tasks: list[Task]) -> tuple[list[ObservingBlock], Time, Time, List[Any]]: + """TaskSchedule blocks.""" + + # only global constraint is the night + if self._twilight == "astronomical": + constraints = [astroplan.AtNightConstraint.twilight_astronomical()] + elif self._twilight == "nautical": + constraints = [astroplan.AtNightConstraint.twilight_nautical()] + else: + raise ValueError("Unknown twilight type.") + + blocks: list[ObservingBlock] = [] + start = Time.now() + end = Time.now() + + return blocks, start, end, constraints + + async def _schedule_blocks( + self, blocks: list[ObservingBlock], start: Time, end: Time, constraints: list[Any] + ) -> list[ScheduledTask]: + + # run actual scheduler in separate process and wait for it + queue_out: mp.Queue[ObservingBlock] = mp.Queue() + p = mp.Process(target=self._schedule_process, args=(blocks, start, end, constraints, queue_out)) + p.start() + + # wait for process to finish + # note that the process only finishes, when the queue is empty! so we have to poll the queue first + # and then the process. + loop = asyncio.get_running_loop() + scheduled_blocks: List[ObservingBlock] = await loop.run_in_executor(None, queue_out.get, True) + await loop.run_in_executor(None, p.join) + return scheduled_blocks + + def _schedule_process( + self, + blocks: List[ObservingBlock], + start: Time, + end: Time, + constraints: List[Any], + scheduled_blocks: mp.Queue[ObservingBlock], + ) -> None: + """Actually do the scheduling, usually run in a separate process.""" + + # log it + log.info("Calculating schedule for %d schedulable block(s) starting at %s...", len(blocks), start) + + # we don't need any transitions + transitioner = astroplan.Transitioner() + + # create scheduler + scheduler = astroplan.PriorityScheduler(constraints, self.observer, transitioner=transitioner) + + # run scheduler + logging.disable(logging.WARNING) + time_range = astroplan.Schedule(start, end) + schedule = scheduler(blocks, time_range) + logging.disable(logging.NOTSET) + + # put scheduled blocks in queue + scheduled_blocks.put(schedule.scheduled_blocks) + + +__all__ = ["AstroplanScheduler"] diff --git a/pyobs/robotic/scheduler/taskscheduler.py b/pyobs/robotic/scheduler/taskscheduler.py new file mode 100644 index 000000000..c50db58f1 --- /dev/null +++ b/pyobs/robotic/scheduler/taskscheduler.py @@ -0,0 +1,18 @@ +import abc +import logging + +from pyobs.object import Object +from pyobs.robotic import Task, ScheduledTask +from pyobs.utils.time import Time + +log = logging.getLogger(__name__) + + +class TaskScheduler(Object, metaclass=abc.ABCMeta): + """Abstract base class for tasks scheduler.""" + + @abc.abstractmethod + async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: ... + + +__all__ = ["TaskScheduler"] diff --git a/pyobs/robotic/taskarchive.py b/pyobs/robotic/taskarchive.py index e2954fb7a..d492d2efa 100644 --- a/pyobs/robotic/taskarchive.py +++ b/pyobs/robotic/taskarchive.py @@ -1,9 +1,9 @@ from abc import ABCMeta, abstractmethod -from typing import Optional, Any, List -from astroplan import ObservingBlock +from typing import Any from pyobs.utils.time import Time from pyobs.object import Object +from .task import Task class TaskArchive(Object, metaclass=ABCMeta): @@ -11,16 +11,16 @@ def __init__(self, **kwargs: Any): Object.__init__(self, **kwargs) @abstractmethod - async def last_changed(self) -> Optional[Time]: - """Returns time when last time any blocks changed.""" + async def last_changed(self) -> Time | None: + """Returns time when last time any tasks changed.""" ... @abstractmethod - async def get_schedulable_blocks(self) -> List[ObservingBlock]: - """Returns list of schedulable blocks. + async def get_schedulable_tasks(self) -> list[Task]: + """Returns list of schedulable tasks. Returns: - List of schedulable blocks + List of schedulable tasks """ ... diff --git a/pyobs/robotic/taskschedule.py b/pyobs/robotic/taskschedule.py index efb04f5a2..b6e7710c9 100644 --- a/pyobs/robotic/taskschedule.py +++ b/pyobs/robotic/taskschedule.py @@ -1,6 +1,5 @@ from abc import ABCMeta, abstractmethod from typing import Any, Type -from astroplan import ObservingBlock from pyobs.utils.time import Time from .task import Task, ScheduledTask @@ -12,11 +11,11 @@ def __init__(self, **kwargs: Any): Object.__init__(self, **kwargs) @abstractmethod - async def set_schedule(self, blocks: list[ObservingBlock], start_time: Time) -> None: - """Set the list of scheduled blocks. + async def set_schedule(self, tasks: list[ScheduledTask], start_time: Time) -> None: + """Set the list of scheduled tasks. Args: - blocks: Scheduled blocks. + tasks: Scheduled tasks. start_time: Start time for schedule. """ ... diff --git a/tests/modules/robotic/test_scheduler.py b/tests/modules/robotic/test_scheduler.py index edc6e24bf..a3f856660 100644 --- a/tests/modules/robotic/test_scheduler.py +++ b/tests/modules/robotic/test_scheduler.py @@ -1,5 +1,3 @@ -import time - from astroplan import ObservingBlock, FixedTarget import astropy.units as u from astropy.coordinates import SkyCoord @@ -22,7 +20,7 @@ def test_compare_block_lists(): blocks2 = blocks[5:] # compare - unique1, unique2 = Scheduler._compare_block_lists(blocks1, blocks2) + unique1, unique2 = Scheduler._compare_task_lists(blocks1, blocks2) # get names names1 = [int(b.target.name) for b in unique1] @@ -39,7 +37,7 @@ def test_compare_block_lists(): blocks2 = blocks[5:] # compare - unique1, unique2 = Scheduler._compare_block_lists(blocks1, blocks2) + unique1, unique2 = Scheduler._compare_task_lists(blocks1, blocks2) # get names names1 = [int(b.target.name) for b in unique1] @@ -56,7 +54,7 @@ def test_compare_block_lists(): blocks2 = blocks # compare - unique1, unique2 = Scheduler._compare_block_lists(blocks1, blocks2) + unique1, unique2 = Scheduler._compare_task_lists(blocks1, blocks2) # get names names1 = [int(b.target.name) for b in unique1] From c2997c01e03b8cfaefcd12cfbf9e73adac710b36 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 31 Oct 2025 16:32:18 +0100 Subject: [PATCH 06/57] added own constraints and targets --- pyobs/modules/robotic/scheduler.py | 1 + pyobs/robotic/lco/task.py | 56 +++------------- pyobs/robotic/lco/taskarchive.py | 48 +++++++------- pyobs/robotic/scheduler/astroplan.py | 66 +++++++++++++------ .../robotic/scheduler/constraints/__init__.py | 7 ++ .../constraints/airmassconstraint.py | 18 +++++ .../scheduler/constraints/constraint.py | 10 +++ .../scheduler/constraints/moonconstraint.py | 27 ++++++++ .../constraints/moonilluminationconstraint.py | 19 ++++++ .../constraints/moonseparationconstraint.py | 18 +++++ .../constraints/solarelevationconstraint.py | 18 +++++ .../scheduler/constraints/timeconstraint.py | 20 ++++++ pyobs/robotic/scheduler/targets/__init__.py | 2 + .../scheduler/targets/siderealtarget.py | 21 ++++++ pyobs/robotic/scheduler/targets/target.py | 10 +++ pyobs/robotic/task.py | 51 ++++++++++++-- 16 files changed, 295 insertions(+), 97 deletions(-) create mode 100644 pyobs/robotic/scheduler/constraints/__init__.py create mode 100644 pyobs/robotic/scheduler/constraints/airmassconstraint.py create mode 100644 pyobs/robotic/scheduler/constraints/constraint.py create mode 100644 pyobs/robotic/scheduler/constraints/moonconstraint.py create mode 100644 pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py create mode 100644 pyobs/robotic/scheduler/constraints/moonseparationconstraint.py create mode 100644 pyobs/robotic/scheduler/constraints/solarelevationconstraint.py create mode 100644 pyobs/robotic/scheduler/constraints/timeconstraint.py create mode 100644 pyobs/robotic/scheduler/targets/__init__.py create mode 100644 pyobs/robotic/scheduler/targets/siderealtarget.py create mode 100644 pyobs/robotic/scheduler/targets/target.py diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 8eabf3237..d80ad0c9e 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -200,6 +200,7 @@ async def _schedule_worker(self) -> None: self._need_update = False try: + # TODO: add abort (see old robotic/scheduler.py) # start time start_time = time.time() diff --git a/pyobs/robotic/lco/task.py b/pyobs/robotic/lco/task.py index c04f44ab6..a71df6872 100644 --- a/pyobs/robotic/lco/task.py +++ b/pyobs/robotic/lco/task.py @@ -65,46 +65,24 @@ def to_json(self) -> Dict[str, Any]: class LcoTask(Task): """A task from the LCO portal.""" - def __init__(self, config: Dict[str, Any], **kwargs: Any): + def __init__(self, config: Dict[str, Any], priority: float, **kwargs: Any): """Init LCO task (called request there). Args: config: Configuration for task """ - Task.__init__(self, **kwargs) + Task.__init__( + self, + id=config["request"]["id"], + name=config["name"], + duration=float(config["request"]["duration"]), + priority=config["priority"], + **kwargs, + ) # store stuff - self.config = config self.cur_script: Optional[Script] = None - @property - def id(self) -> Any: - """ID of task.""" - if "request" in self.config and "id" in self.config["request"]: - return self.config["request"]["id"] - else: - raise ValueError("No id found in request.") - - @property - def name(self) -> str: - """Returns name of task.""" - if "name" in self.config and isinstance(self.config["name"], str): - return self.config["name"] - else: - raise ValueError("No name found in request group.") - - @property - def duration(self) -> float: - """Returns estimated duration of task in seconds.""" - if ( - "request" in self.config - and "duration" in self.config["request"] - and isinstance(self.config["request"]["duration"], int) - ): - return float(self.config["request"]["duration"]) - else: - raise ValueError("No duration found in request.") - def __eq__(self, other: object) -> bool: """Compares to tasks.""" if isinstance(other, LcoTask): @@ -112,22 +90,6 @@ def __eq__(self, other: object) -> bool: else: return False - @property - def start(self) -> Time: - """Start time for task""" - if "start" in self.config and isinstance(self.config["start"], Time): - return self.config["start"] - else: - raise ValueError("No start time found in request group.") - - @property - def end(self) -> Time: - """End time for task""" - if "end" in self.config and isinstance(self.config["end"], Time): - return self.config["end"] - else: - raise ValueError("No end time found in request group.") - @property def observation_type(self) -> str: """Returns observation_type of this task. diff --git a/pyobs/robotic/lco/taskarchive.py b/pyobs/robotic/lco/taskarchive.py index 30afe62dc..aa89410c8 100644 --- a/pyobs/robotic/lco/taskarchive.py +++ b/pyobs/robotic/lco/taskarchive.py @@ -1,14 +1,5 @@ import logging from typing import Dict, Optional, Any -from astroplan import ( - TimeConstraint, - AirmassConstraint, - ObservingBlock, - FixedTarget, - MoonSeparationConstraint, - MoonIlluminationConstraint, - AtNightConstraint, -) from astropy.coordinates import SkyCoord import astropy.units as u @@ -17,6 +8,15 @@ from .portal import Portal from .task import LcoTask from .. import Task +from ..scheduler.constraints import ( + Constraint, + AirmassConstraint, + MoonIlluminationConstraint, + MoonSeparationConstraint, + TimeConstraint, + SolarElevationConstraint, +) +from ..scheduler.targets import SideralTarget log = logging.getLogger(__name__) @@ -83,7 +83,7 @@ async def get_schedulable_tasks(self) -> list[Task]: tac_priorities = {p["id"]: p["tac_priority"] for p in proposals} # loop all request groups - tasks = [] + tasks: list[Task] = [] for group in schedulable: # get base priority, which is tac_priority * ipp_value proposal = group["proposal"] @@ -115,37 +115,39 @@ async def get_schedulable_tasks(self) -> list[Task]: t = cfg["target"] if "ra" in t and "dec" in t: target = SkyCoord(t["ra"] * u.deg, t["dec"] * u.deg, frame=t["type"].lower()) + target_name = t["name"] else: log.warning("Unsupported coordinate type.") continue # constraints c = cfg["constraints"] - constraints = [] + constraints: list[Constraint] = [] if "max_airmass" in c and c["max_airmass"] is not None: - constraints.append(AirmassConstraint(max=c["max_airmass"], boolean_constraint=False)) + constraints.append(AirmassConstraint(c["max_airmass"])) if "min_lunar_distance" in c and c["min_lunar_distance"] is not None: - constraints.append(MoonSeparationConstraint(min=c["min_lunar_distance"] * u.deg)) + constraints.append(MoonSeparationConstraint(c["min_lunar_distance"])) if "max_lunar_phase" in c and c["max_lunar_phase"] is not None: - constraints.append(MoonIlluminationConstraint(max=c["max_lunar_phase"])) + constraints.append(MoonIlluminationConstraint(c["max_lunar_phase"])) # if max lunar phase <= 0.4 (which would be DARK), we also enforce the sun to be <-18 degrees if c["max_lunar_phase"] <= 0.4: - constraints.append(AtNightConstraint.twilight_astronomical()) + constraints.append(SolarElevationConstraint(-18.0)) # priority is base_priority times duration in minutes # priority = base_priority * duration.value / 60. priority = base_priority - # create block - block = ObservingBlock( - FixedTarget(target, name=req["id"]), - duration, - priority, + # create task + task = LcoTask( + id=req["id"], + name=req["name"], + duration=duration, + priority=priority, constraints=[*constraints, *time_constraints], - configuration={"request": req}, - name=group["name"], + config={"request": req}, + target=SideralTarget(target_name, target), ) - tasks.append(block) + tasks.append(task) # return blocks return tasks diff --git a/pyobs/robotic/scheduler/astroplan.py b/pyobs/robotic/scheduler/astroplan.py index 377f9844c..ccd43e6f6 100644 --- a/pyobs/robotic/scheduler/astroplan.py +++ b/pyobs/robotic/scheduler/astroplan.py @@ -2,11 +2,14 @@ import asyncio import logging import multiprocessing as mp -from typing import List, Any +from typing import Any import astroplan -from astroplan import ObservingBlock +from astroplan import ObservingBlock, FixedTarget +from astropy.time import TimeDelta +import astropy.units as u from pyobs.object import Object +from pyobs.robotic.scheduler.targets import SiderealTarget from pyobs.utils.time import Time from pyobs.robotic import ScheduledTask, Task @@ -23,8 +26,6 @@ def __init__( schedule_range: int = 24, safety_time: float = 60, twilight: str = "astronomical", - trigger_on_task_started: bool = False, - trigger_on_task_finished: bool = False, **kwargs: Any, ): """Initialize a new scheduler. @@ -35,8 +36,6 @@ def __init__( this time in seconds to make sure that we don't schedule for a time when the scheduler is still running twilight: astronomical or nautical - trigger_on_task_started: Whether to trigger a re-calculation of schedule, when task has started. - trigger_on_task_finishes: Whether to trigger a re-calculation of schedule, when task has finished. """ Object.__init__(self, **kwargs) @@ -44,20 +43,22 @@ def __init__( self._schedule_range = schedule_range self._safety_time = safety_time self._twilight = twilight - self._running = True - self._initial_update_done = False - self._need_update = False - self._trigger_on_task_started = trigger_on_task_started - self._trigger_on_task_finished = trigger_on_task_finished async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: # prepare scheduler - blocks, start, end, constraints = await self._prepare_schedule(tasks) + blocks, start, end, constraints = await self._prepare_schedule(tasks, start) # schedule - return await self._schedule_blocks(blocks, start, end, constraints) + scheduled_blocks = await self._schedule_blocks(blocks, start, end, constraints) + scheduled_blocks.sort(key=lambda b: b.time) # remove - async def _prepare_schedule(self, tasks: list[Task]) -> tuple[list[ObservingBlock], Time, Time, List[Any]]: + # TODO: add abort (see old robotic/scheduler.py) + + return [] + + async def _prepare_schedule( + self, tasks: list[Task], start: Time + ) -> tuple[list[ObservingBlock], Time, Time, list[Any]]: """TaskSchedule blocks.""" # only global constraint is the night @@ -68,15 +69,38 @@ async def _prepare_schedule(self, tasks: list[Task]) -> tuple[list[ObservingBloc else: raise ValueError("Unknown twilight type.") - blocks: list[ObservingBlock] = [] - start = Time.now() - end = Time.now() + # calculate end time + end = start + TimeDelta(self._schedule_range * u.hour) + # create blocks from tasks + blocks: list[ObservingBlock] = [] + for task in tasks: + target = task.target + if not isinstance(target, SiderealTarget): + log.warning("Non-sidereal targets not supported.") + continue + + priority = 1000.0 - task.priority + if priority < 0: + priority = 0 + + blocks.append( + ObservingBlock( + FixedTarget(target.coord, name=target.name), + task.duration, + priority, + constraints=[c.to_astroplan() for c in task.constraints] if task.constraints else None, + configuration={"request": task.config}, + name=task.name, + ) + ) + + # return all return blocks, start, end, constraints async def _schedule_blocks( self, blocks: list[ObservingBlock], start: Time, end: Time, constraints: list[Any] - ) -> list[ScheduledTask]: + ) -> list[ObservingBlock]: # run actual scheduler in separate process and wait for it queue_out: mp.Queue[ObservingBlock] = mp.Queue() @@ -87,16 +111,16 @@ async def _schedule_blocks( # note that the process only finishes, when the queue is empty! so we have to poll the queue first # and then the process. loop = asyncio.get_running_loop() - scheduled_blocks: List[ObservingBlock] = await loop.run_in_executor(None, queue_out.get, True) + scheduled_blocks: list[ObservingBlock] = await loop.run_in_executor(None, queue_out.get, True) await loop.run_in_executor(None, p.join) return scheduled_blocks def _schedule_process( self, - blocks: List[ObservingBlock], + blocks: list[ObservingBlock], start: Time, end: Time, - constraints: List[Any], + constraints: list[Any], scheduled_blocks: mp.Queue[ObservingBlock], ) -> None: """Actually do the scheduling, usually run in a separate process.""" diff --git a/pyobs/robotic/scheduler/constraints/__init__.py b/pyobs/robotic/scheduler/constraints/__init__.py new file mode 100644 index 000000000..419653bed --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/__init__.py @@ -0,0 +1,7 @@ +from .airmassconstraint import AirmassConstraint +from .constraint import Constraint +from .moonconstraint import MoonCondition, MoonConstraint +from .moonilluminationconstraint import MoonIlluminationConstraint +from .moonseparationconstraint import MoonSeparationConstraint +from .solarelevationconstraint import SolarElevationConstraint +from .timeconstraint import TimeConstraint diff --git a/pyobs/robotic/scheduler/constraints/airmassconstraint.py b/pyobs/robotic/scheduler/constraints/airmassconstraint.py new file mode 100644 index 000000000..ebd5d35eb --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/airmassconstraint.py @@ -0,0 +1,18 @@ +from typing import Any +import astroplan + +from .constraint import Constraint + + +class AirmassConstraint(Constraint): + """Airmass constraint.""" + + def __init__(self, max_airmass: float, **kwargs: Any): + super().__init__(**kwargs) + self.max_airmass = max_airmass + + def to_astroplan(self) -> astroplan.AirmassConstraint: + return astroplan.AirmassConstraint(max=self.max_airmass) + + +__all__ = ["AirmassConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/constraint.py b/pyobs/robotic/scheduler/constraints/constraint.py new file mode 100644 index 000000000..632342ef0 --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/constraint.py @@ -0,0 +1,10 @@ +import abc +from abc import ABCMeta +import astroplan + +from pyobs.object import Object + + +class Constraint(Object, metaclass=ABCMeta): + @abc.abstractmethod + def to_astroplan(self) -> astroplan.Constraint: ... diff --git a/pyobs/robotic/scheduler/constraints/moonconstraint.py b/pyobs/robotic/scheduler/constraints/moonconstraint.py new file mode 100644 index 000000000..3ceb3f22e --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/moonconstraint.py @@ -0,0 +1,27 @@ +from enum import StrEnum # type: ignore +from typing import Any +import astroplan + +from .constraint import Constraint + + +class MoonCondition(StrEnum): # type: ignore + """Represents a moon condition.""" + + DARK = "dark" + GREY = "grey" + BRIGHT = "bright" + + +class MoonConstraint(Constraint): + """Airmass constraint.""" + + def __init__(self, moon: MoonCondition, **kwargs: Any): + super().__init__(**kwargs) + self.moon = moon + + def to_astroplan(self) -> astroplan.Constraint: + raise NotImplementedError() + + +__all__ = ["MoonCondition", "MoonConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py b/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py new file mode 100644 index 000000000..c378fbc32 --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py @@ -0,0 +1,19 @@ +from typing import Any + +import astroplan + +from .constraint import Constraint + + +class MoonIlluminationConstraint(Constraint): + """Moon illumination constraint.""" + + def __init__(self, max_phase: float, **kwargs: Any): + super().__init__(**kwargs) + self.max_phase = max_phase + + def to_astroplan(self) -> astroplan.MoonIlluminationConstraint: + return astroplan.MoonIlluminationConstraint(max=self.max_phase) + + +__all__ = ["MoonIlluminationConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py new file mode 100644 index 000000000..132c8d0dc --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py @@ -0,0 +1,18 @@ +from typing import Any +import astroplan + +from .constraint import Constraint + + +class MoonSeparationConstraint(Constraint): + """Moon separation constraint.""" + + def __init__(self, min_distance: float, **kwargs: Any): + super().__init__(**kwargs) + self.min_distance = min_distance + + def to_astroplan(self) -> astroplan.MoonSeparationConstraint: + return astroplan.MoonSeparationConstraint(min=self.min_distance) + + +__all__ = ["MoonSeparationConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py b/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py new file mode 100644 index 000000000..206e42ea4 --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py @@ -0,0 +1,18 @@ +from typing import Any +import astroplan + +from .constraint import Constraint + + +class SolarElevationConstraint(Constraint): + """Solar elevation constraint.""" + + def __init__(self, max_elevation: float, **kwargs: Any): + super().__init__(**kwargs) + self.max_elevation = max_elevation + + def to_astroplan(self) -> astroplan.AtNightConstraint: + return astroplan.AtNightConstraint(max_solar_altitude=self.max_elevation) + + +__all__ = ["SolarElevationConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/timeconstraint.py b/pyobs/robotic/scheduler/constraints/timeconstraint.py new file mode 100644 index 000000000..49637c974 --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/timeconstraint.py @@ -0,0 +1,20 @@ +from typing import Any +import astroplan + +from pyobs.utils.time import Time +from .constraint import Constraint + + +class TimeConstraint(Constraint): + """Time constraint.""" + + def __init__(self, start: Time, end: Time, **kwargs: Any): + super().__init__(**kwargs) + self.start = start + self.end = end + + def to_astroplan(self) -> astroplan.TimeConstraint: + return astroplan.TimeConstraint(min=self.start, max=self.end) + + +__all__ = ["TimeConstraint"] diff --git a/pyobs/robotic/scheduler/targets/__init__.py b/pyobs/robotic/scheduler/targets/__init__.py new file mode 100644 index 000000000..25ff244bb --- /dev/null +++ b/pyobs/robotic/scheduler/targets/__init__.py @@ -0,0 +1,2 @@ +from .target import Target +from .siderealtarget import SiderealTarget diff --git a/pyobs/robotic/scheduler/targets/siderealtarget.py b/pyobs/robotic/scheduler/targets/siderealtarget.py new file mode 100644 index 000000000..5590a2e23 --- /dev/null +++ b/pyobs/robotic/scheduler/targets/siderealtarget.py @@ -0,0 +1,21 @@ +from astropy.coordinates import SkyCoord + +from .target import Target + + +class SiderealTarget(Target): + def __init__(self, name: str, coord: SkyCoord): + super().__init__() + self._name = name + self._coord = coord + + @property + def name(self) -> str: + return self._name + + @property + def coord(self) -> SkyCoord: + return self._coord + + +__all__ = ["SiderealTarget"] diff --git a/pyobs/robotic/scheduler/targets/target.py b/pyobs/robotic/scheduler/targets/target.py new file mode 100644 index 000000000..51d34aedf --- /dev/null +++ b/pyobs/robotic/scheduler/targets/target.py @@ -0,0 +1,10 @@ +from abc import ABCMeta + +from pyobs.object import Object + + +class Target(Object, metaclass=ABCMeta): + pass + + +__all__ = ["Target"] diff --git a/pyobs/robotic/task.py b/pyobs/robotic/task.py index 1c0de9762..f8688e5ce 100644 --- a/pyobs/robotic/task.py +++ b/pyobs/robotic/task.py @@ -3,6 +3,8 @@ from typing import TYPE_CHECKING, Any from pyobs.object import Object +from pyobs.robotic.scheduler.constraints import Constraint +from pyobs.robotic.scheduler.targets import Target from pyobs.robotic.scripts import Script from pyobs.utils.time import Time @@ -13,23 +15,60 @@ class Task(Object, metaclass=ABCMeta): + def __init__( + self, + id: Any, + name: str, + duration: float, + priority: float | None = None, + config: dict[str, Any] | None = None, + constraints: list[Constraint] | None = None, + target: Target | None = None, + **kwargs: Any, + ): + super().__init__(**kwargs) + self._id = id + self._name = name + self._duration = duration + self._priority = priority + self._config = config + self._constraints = constraints + self._target = target + @property - @abstractmethod def id(self) -> Any: """ID of task.""" - ... + return self._id @property - @abstractmethod def name(self) -> str: """Returns name of task.""" - ... + return self._name @property - @abstractmethod def duration(self) -> float: """Returns estimated duration of task in seconds.""" - ... + return self._duration + + @property + def priority(self) -> float: + """Returns priority.""" + return self._priority if self._priority is not None else 0.0 + + @property + def config(self) -> dict[str, Any]: + """Returns configuration.""" + return self._config if self._config is not None else {} + + @property + def constraints(self) -> list[Constraint] | None: + """Returns constraints.""" + return self._constraints + + @property + def target(self) -> Target | None: + """Returns target.""" + return self.target @abstractmethod async def can_run(self, scripts: dict[str, Script] | None = None) -> bool: From 1fae24099c7413ce4b94fe52760bc613eb838892 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 31 Oct 2025 18:56:29 +0100 Subject: [PATCH 07/57] new scheduler structure seems to be working --- pyobs/modules/robotic/scheduler.py | 129 +++++++++--------- pyobs/robotic/lco/task.py | 25 +++- pyobs/robotic/lco/taskarchive.py | 6 +- pyobs/robotic/scheduler/__init__.py | 1 + pyobs/robotic/scheduler/astroplan.py | 67 +++++++-- .../constraints/moonseparationconstraint.py | 3 +- .../constraints/solarelevationconstraint.py | 3 +- pyobs/robotic/scheduler/taskscheduler.py | 9 +- pyobs/robotic/task.py | 4 +- 9 files changed, 157 insertions(+), 90 deletions(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index d80ad0c9e..1934f7b36 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -102,69 +102,75 @@ async def _update_worker(self) -> None: # not running? if not self._running: await asyncio.sleep(1) - continue + return # got new time of last change? t = await self._task_archive.last_changed() if last_change is None or last_change < t: - # get schedulable tasks and sort them - log.info("Found update in schedulable tasks, downloading them...") - tasks = sorted( - await self._task_archive.get_schedulable_tasks(), - key=lambda x: json.dumps(x.id, sort_keys=True), - ) - log.info("Downloaded %d schedulable tasks(s).", len(tasks)) - - # compare new and old lists - removed, added = self._compare_task_lists(self._tasks, tasks) - - # schedule update - self._need_update = True - - # no changes? - if len(removed) == 0 and len(added) == 0: - # no need to re-schedule - log.info("No change in list of blocks detected.") - self._need_update = False - - # has only the current block been removed? - log.info("Removed: %d, added: %d", len(removed), len(added)) - if len(removed) == 1: - log.info( - "Found 1 removed block with ID %d. Last task ID was %s, current is %s.", - removed[0].id, - str(self._last_task_id), - str(self._current_task_id), - ) - if len(removed) == 1 and len(added) == 0 and removed[0].id == self._last_task_id: - # no need to re-schedule - log.info("Only one removed block detected, which is the one currently running.") - self._need_update = False - - # check, if one of the removed blocks was actually in schedule - if len(removed) > 0 and self._need_update: - schedule = await self._schedule.get_schedule() - removed_from_schedule = [r for r in removed if r in schedule] - if len(removed_from_schedule) == 0: - log.info(f"Found {len(removed)} blocks, but none of them was scheduled.") - self._need_update = False - - # store blocks - self._tasks = tasks - - # schedule update - if self._need_update: - log.info("Triggering scheduler run...") - - # remember now - last_change = Time.now() - self._initial_update_done = True + try: + await self._update_schedule() + last_change = t + except: + log.exception("Something went wrong when updating schedule.") # sleep a little await asyncio.sleep(5) + async def _update_schedule(self) -> None: + # get schedulable tasks and sort them + log.info("Found update in schedulable tasks, downloading them...") + tasks = sorted( + await self._task_archive.get_schedulable_tasks(), + key=lambda x: json.dumps(x.id, sort_keys=True), + ) + log.info("Downloaded %d schedulable tasks(s).", len(tasks)) + + # compare new and old lists + removed, added = self._compare_task_lists(self._tasks, tasks) + + # schedule update + self._need_update = True + + # no changes? + if len(removed) == 0 and len(added) == 0: + # no need to re-schedule + log.info("No change in list of blocks detected.") + self._need_update = False + + # has only the current block been removed? + log.info("Removed: %d, added: %d", len(removed), len(added)) + if len(removed) == 1: + log.info( + "Found 1 removed block with ID %d. Last task ID was %s, current is %s.", + removed[0].id, + str(self._last_task_id), + str(self._current_task_id), + ) + if len(removed) == 1 and len(added) == 0 and removed[0].id == self._last_task_id: + # no need to re-schedule + log.info("Only one removed block detected, which is the one currently running.") + self._need_update = False + + # check, if one of the removed blocks was actually in schedule + if len(removed) > 0 and self._need_update: + schedule = await self._schedule.get_schedule() + removed_from_schedule = [s for s in schedule if s.task.id in removed] + if len(removed_from_schedule) == 0: + log.info(f"Found {len(removed)} tasks, but none of them was scheduled.") + self._need_update = False + + # store blocks + self._tasks = tasks + + # schedule update + if self._need_update: + log.info("Triggering scheduler run...") + + # remember now + self._initial_update_done = True + @staticmethod - def _compare_task_lists(tasks1: list[Task], tasks2: list[Task]) -> tuple[list[Task], list[Task]]: + def _compare_task_lists(tasks1: list[Task], tasks2: list[Task]) -> tuple[list[Any], list[Any]]: """Compares two lists of tasks and returns two lists, containing those that are missing in list 1 and list 2, respectively. @@ -183,13 +189,10 @@ def _compare_task_lists(tasks1: list[Task], tasks2: list[Task]) -> tuple[list[Ta names2 = {t.id: t for t in tasks2} # find elements in names1 that are missing in names2 and vice versa - additional1 = set(names1.keys()).difference(names2.keys()) - additional2 = set(names2.keys()).difference(names1.keys()) + additional1 = list(set(names1.keys()).difference(names2.keys())) + additional2 = list(set(names2.keys()).difference(names1.keys())) - # get blocks for names and return them - unique1 = [names1[n] for n in additional1] - unique2 = [names2[n] for n in additional2] - return unique1, unique2 + return additional1, additional2 async def _schedule_worker(self) -> None: # run forever @@ -209,7 +212,7 @@ async def _schedule_worker(self) -> None: scheduled_tasks = await self._scheduler.schedule(self._tasks, self._schedule_start) # upload schedule - await self._finish_schedule(scheduled_tasks) + await self._finish_schedule(scheduled_tasks, self._schedule_start) # set new safety_time as duration + 20% self._safety_time = (time.time() - start_time) * 1.2 @@ -220,14 +223,14 @@ async def _schedule_worker(self) -> None: # sleep a little await asyncio.sleep(1) - async def _finish_schedule(self, scheduled_tasks: list[ScheduledTask]) -> None: + async def _finish_schedule(self, scheduled_tasks: list[ScheduledTask], start: Time) -> None: # if need new update, skip here if self._need_update: log.info("Not using scheduler results, since update was requested.") return # update - # await self._schedule.set_schedule(scheduled_tasks, start) + await self._schedule.set_schedule(scheduled_tasks, start) # log if len(scheduled_tasks) > 0: diff --git a/pyobs/robotic/lco/task.py b/pyobs/robotic/lco/task.py index a71df6872..a9e582571 100644 --- a/pyobs/robotic/lco/task.py +++ b/pyobs/robotic/lco/task.py @@ -65,18 +65,33 @@ def to_json(self) -> Dict[str, Any]: class LcoTask(Task): """A task from the LCO portal.""" - def __init__(self, config: Dict[str, Any], priority: float, **kwargs: Any): + def __init__( + self, + config: dict[str, Any], + id: Any | None = None, + name: str | None = None, + duration: float | None = None, + **kwargs: Any, + ): """Init LCO task (called request there). Args: config: Configuration for task """ + + if id is None: + id = config["request"]["id"] + if name is None: + name = config["request"]["id"] + if duration is None: + duration = float(config["request"]["duration"]) + Task.__init__( self, - id=config["request"]["id"], - name=config["name"], - duration=float(config["request"]["duration"]), - priority=config["priority"], + id=id, + name=name, + duration=duration, + config=config, **kwargs, ) diff --git a/pyobs/robotic/lco/taskarchive.py b/pyobs/robotic/lco/taskarchive.py index aa89410c8..f5c0d00b3 100644 --- a/pyobs/robotic/lco/taskarchive.py +++ b/pyobs/robotic/lco/taskarchive.py @@ -16,7 +16,7 @@ TimeConstraint, SolarElevationConstraint, ) -from ..scheduler.targets import SideralTarget +from ..scheduler.targets import SiderealTarget log = logging.getLogger(__name__) @@ -140,12 +140,12 @@ async def get_schedulable_tasks(self) -> list[Task]: # create task task = LcoTask( id=req["id"], - name=req["name"], + name=group["name"], duration=duration, priority=priority, constraints=[*constraints, *time_constraints], config={"request": req}, - target=SideralTarget(target_name, target), + target=SiderealTarget(target_name, target), ) tasks.append(task) diff --git a/pyobs/robotic/scheduler/__init__.py b/pyobs/robotic/scheduler/__init__.py index fc8a2bfc3..7de30d988 100644 --- a/pyobs/robotic/scheduler/__init__.py +++ b/pyobs/robotic/scheduler/__init__.py @@ -1 +1,2 @@ from .taskscheduler import TaskScheduler +from .astroplan import AstroplanScheduler diff --git a/pyobs/robotic/scheduler/astroplan.py b/pyobs/robotic/scheduler/astroplan.py index ccd43e6f6..bb0096d99 100644 --- a/pyobs/robotic/scheduler/astroplan.py +++ b/pyobs/robotic/scheduler/astroplan.py @@ -2,21 +2,24 @@ import asyncio import logging import multiprocessing as mp -from typing import Any +from typing import Any, TYPE_CHECKING import astroplan from astroplan import ObservingBlock, FixedTarget from astropy.time import TimeDelta import astropy.units as u from pyobs.object import Object -from pyobs.robotic.scheduler.targets import SiderealTarget +from .taskscheduler import TaskScheduler +from .targets import SiderealTarget from pyobs.utils.time import Time -from pyobs.robotic import ScheduledTask, Task + +if TYPE_CHECKING: + from pyobs.robotic import ScheduledTask, Task log = logging.getLogger(__name__) -class AstroplanScheduler(Object): +class AstroplanScheduler(TaskScheduler): """Scheduler based on astroplan.""" __module__ = "pyobs.modules.robotic" @@ -43,18 +46,28 @@ def __init__( self._schedule_range = schedule_range self._safety_time = safety_time self._twilight = twilight + self._lock = asyncio.Lock() + self._abort: asyncio.Event = asyncio.Event() + self._is_running: bool = False async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: - # prepare scheduler - blocks, start, end, constraints = await self._prepare_schedule(tasks, start) + # is lock acquired? send abort signal + if self._lock.locked(): + await self.abort() + + # get lock + async with self._lock: + # prepare scheduler + blocks, start, end, constraints = await self._prepare_schedule(tasks, start) - # schedule - scheduled_blocks = await self._schedule_blocks(blocks, start, end, constraints) - scheduled_blocks.sort(key=lambda b: b.time) # remove + # schedule + scheduled_blocks = await self._schedule_blocks(blocks, start, end, constraints, self._abort) - # TODO: add abort (see old robotic/scheduler.py) + # convert + return await self._convert_blocks(scheduled_blocks, tasks) - return [] + async def abort(self) -> None: + self._abort.set() async def _prepare_schedule( self, tasks: list[Task], start: Time @@ -91,7 +104,7 @@ async def _prepare_schedule( priority, constraints=[c.to_astroplan() for c in task.constraints] if task.constraints else None, configuration={"request": task.config}, - name=task.name, + name=task.id, ) ) @@ -99,7 +112,7 @@ async def _prepare_schedule( return blocks, start, end, constraints async def _schedule_blocks( - self, blocks: list[ObservingBlock], start: Time, end: Time, constraints: list[Any] + self, blocks: list[ObservingBlock], start: Time, end: Time, constraints: list[Any], abort: asyncio.Event ) -> list[ObservingBlock]: # run actual scheduler in separate process and wait for it @@ -111,8 +124,16 @@ async def _schedule_blocks( # note that the process only finishes, when the queue is empty! so we have to poll the queue first # and then the process. loop = asyncio.get_running_loop() - scheduled_blocks: list[ObservingBlock] = await loop.run_in_executor(None, queue_out.get, True) + future = loop.run_in_executor(None, queue_out.get, True) + while not future.done(): + if abort.is_set(): + p.kill() + return [] + else: + await asyncio.sleep(0.1) + scheduled_blocks: list[ObservingBlock] = await future await loop.run_in_executor(None, p.join) + return scheduled_blocks def _schedule_process( @@ -143,5 +164,23 @@ def _schedule_process( # put scheduled blocks in queue scheduled_blocks.put(schedule.scheduled_blocks) + async def _convert_blocks(self, blocks: list[ObservingBlock], tasks: list[Task]) -> list[ScheduledTask]: + from pyobs.robotic import ScheduledTask + + scheduled_tasks: list[ScheduledTask] = [] + for block in blocks: + # find task + task_id = block.name + for task in tasks: + if task.id == task_id: + break + else: + raise ValueError(f"Could not find task with id '{task_id}'") + + # create scheduled task + scheduled_tasks.append(ScheduledTask(task, block.start_time, block.end_time)) + + return scheduled_tasks + __all__ = ["AstroplanScheduler"] diff --git a/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py index 132c8d0dc..30d852c38 100644 --- a/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py +++ b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py @@ -1,5 +1,6 @@ from typing import Any import astroplan +import astropy.units as u from .constraint import Constraint @@ -12,7 +13,7 @@ def __init__(self, min_distance: float, **kwargs: Any): self.min_distance = min_distance def to_astroplan(self) -> astroplan.MoonSeparationConstraint: - return astroplan.MoonSeparationConstraint(min=self.min_distance) + return astroplan.MoonSeparationConstraint(min=self.min_distance * u.deg) __all__ = ["MoonSeparationConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py b/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py index 206e42ea4..ddb882bfa 100644 --- a/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py +++ b/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py @@ -1,5 +1,6 @@ from typing import Any import astroplan +import astropy.units as u from .constraint import Constraint @@ -12,7 +13,7 @@ def __init__(self, max_elevation: float, **kwargs: Any): self.max_elevation = max_elevation def to_astroplan(self) -> astroplan.AtNightConstraint: - return astroplan.AtNightConstraint(max_solar_altitude=self.max_elevation) + return astroplan.AtNightConstraint(max_solar_altitude=self.max_elevation * u.deg) __all__ = ["SolarElevationConstraint"] diff --git a/pyobs/robotic/scheduler/taskscheduler.py b/pyobs/robotic/scheduler/taskscheduler.py index c50db58f1..1aa11e04d 100644 --- a/pyobs/robotic/scheduler/taskscheduler.py +++ b/pyobs/robotic/scheduler/taskscheduler.py @@ -1,10 +1,14 @@ +from __future__ import annotations import abc import logging +from typing import TYPE_CHECKING from pyobs.object import Object -from pyobs.robotic import Task, ScheduledTask from pyobs.utils.time import Time +if TYPE_CHECKING: + from pyobs.robotic import Task, ScheduledTask + log = logging.getLogger(__name__) @@ -14,5 +18,8 @@ class TaskScheduler(Object, metaclass=abc.ABCMeta): @abc.abstractmethod async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: ... + @abc.abstractmethod + async def abort(self) -> None: ... + __all__ = ["TaskScheduler"] diff --git a/pyobs/robotic/task.py b/pyobs/robotic/task.py index f8688e5ce..5b001407b 100644 --- a/pyobs/robotic/task.py +++ b/pyobs/robotic/task.py @@ -3,7 +3,6 @@ from typing import TYPE_CHECKING, Any from pyobs.object import Object -from pyobs.robotic.scheduler.constraints import Constraint from pyobs.robotic.scheduler.targets import Target from pyobs.robotic.scripts import Script from pyobs.utils.time import Time @@ -12,6 +11,7 @@ from pyobs.robotic.taskschedule import TaskSchedule from pyobs.robotic.taskrunner import TaskRunner from pyobs.robotic.taskarchive import TaskArchive + from pyobs.robotic.scheduler.constraints import Constraint class Task(Object, metaclass=ABCMeta): @@ -68,7 +68,7 @@ def constraints(self) -> list[Constraint] | None: @property def target(self) -> Target | None: """Returns target.""" - return self.target + return self._target @abstractmethod async def can_run(self, scripts: dict[str, Script] | None = None) -> bool: From 5f04d8d55a8cdd5dd47eadda05b6012abf05e4fb Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 31 Oct 2025 20:35:37 +0100 Subject: [PATCH 08/57] moved some files --- pyobs/robotic/scheduler/__init__.py | 1 + .../scheduler/dataprovider}/__init__.py | 0 .../scheduler/dataprovider}/dataprovider.py | 0 .../scheduler/merits/__init__.py | 0 .../scheduler/merits/constant.py | 6 +++--- pyobs/robotic/scheduler/merits/merit.py | 20 +++++++++++++++++++ .../scheduler/merits/pernight.py | 9 +++++---- pyobs/utils/scheduler/merit.py | 15 -------------- 8 files changed, 29 insertions(+), 22 deletions(-) rename pyobs/{utils/scheduler => robotic/scheduler/dataprovider}/__init__.py (100%) rename pyobs/{utils/scheduler => robotic/scheduler/dataprovider}/dataprovider.py (100%) rename pyobs/{utils => robotic}/scheduler/merits/__init__.py (100%) rename pyobs/{utils => robotic}/scheduler/merits/constant.py (68%) create mode 100644 pyobs/robotic/scheduler/merits/merit.py rename pyobs/{utils => robotic}/scheduler/merits/pernight.py (84%) delete mode 100644 pyobs/utils/scheduler/merit.py diff --git a/pyobs/robotic/scheduler/__init__.py b/pyobs/robotic/scheduler/__init__.py index 7de30d988..0f8f2f3a0 100644 --- a/pyobs/robotic/scheduler/__init__.py +++ b/pyobs/robotic/scheduler/__init__.py @@ -1,2 +1,3 @@ from .taskscheduler import TaskScheduler from .astroplan import AstroplanScheduler +from .dataprovider import DataProvider diff --git a/pyobs/utils/scheduler/__init__.py b/pyobs/robotic/scheduler/dataprovider/__init__.py similarity index 100% rename from pyobs/utils/scheduler/__init__.py rename to pyobs/robotic/scheduler/dataprovider/__init__.py diff --git a/pyobs/utils/scheduler/dataprovider.py b/pyobs/robotic/scheduler/dataprovider/dataprovider.py similarity index 100% rename from pyobs/utils/scheduler/dataprovider.py rename to pyobs/robotic/scheduler/dataprovider/dataprovider.py diff --git a/pyobs/utils/scheduler/merits/__init__.py b/pyobs/robotic/scheduler/merits/__init__.py similarity index 100% rename from pyobs/utils/scheduler/merits/__init__.py rename to pyobs/robotic/scheduler/merits/__init__.py diff --git a/pyobs/utils/scheduler/merits/constant.py b/pyobs/robotic/scheduler/merits/constant.py similarity index 68% rename from pyobs/utils/scheduler/merits/constant.py rename to pyobs/robotic/scheduler/merits/constant.py index 4f802ecf5..672173ab3 100644 --- a/pyobs/utils/scheduler/merits/constant.py +++ b/pyobs/robotic/scheduler/merits/constant.py @@ -3,17 +3,17 @@ from astropy.time import Time from pyobs.robotic import Task -from ..dataprovider import DataProvider -from ..merit import Merit +from .merit import Merit class ConstantMerit(Merit): """Merit function that returns a constant value.""" def __init__(self, merit: float, **kwargs: Any): + super().__init__(**kwargs) self._merit = merit - def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + def __call__(self, time: Time, task: Task) -> float: return self._merit diff --git a/pyobs/robotic/scheduler/merits/merit.py b/pyobs/robotic/scheduler/merits/merit.py new file mode 100644 index 000000000..2969ac5ee --- /dev/null +++ b/pyobs/robotic/scheduler/merits/merit.py @@ -0,0 +1,20 @@ +from abc import ABCMeta, abstractmethod +from typing import Any + +from astropy.time import Time + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic import Task + + +class Merit(metaclass=ABCMeta): + """Merit class.""" + + def __init__(self, data_provider: DataProvider, **kwargs: Any) -> None: + self._data_provider = data_provider + + @abstractmethod + def __call__(self, time: Time, task: Task) -> float: ... + + +__all__ = ["Merit"] diff --git a/pyobs/utils/scheduler/merits/pernight.py b/pyobs/robotic/scheduler/merits/pernight.py similarity index 84% rename from pyobs/utils/scheduler/merits/pernight.py rename to pyobs/robotic/scheduler/merits/pernight.py index b21a24678..9f577f084 100644 --- a/pyobs/utils/scheduler/merits/pernight.py +++ b/pyobs/robotic/scheduler/merits/pernight.py @@ -1,19 +1,20 @@ from typing import Any - from astropy.time import Time from pyobs.robotic import Task -from ..dataprovider import DataProvider -from ..merit import Merit +from .merit import Merit class PerNightMerit(Merit): """Merit functions for defining a max number of observations per night.""" def __init__(self, count: int, **kwargs: Any): + super().__init__(**kwargs) self._count = count - def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + def __call__(self, time: Time, task: Task) -> float: + data = self._data_provider + # get number of successful task runs successes = data.get_task_success_count(task) diff --git a/pyobs/utils/scheduler/merit.py b/pyobs/utils/scheduler/merit.py deleted file mode 100644 index 47833fa0f..000000000 --- a/pyobs/utils/scheduler/merit.py +++ /dev/null @@ -1,15 +0,0 @@ -from abc import ABCMeta, abstractmethod -from astropy.time import Time - -from .dataprovider import DataProvider -from ...robotic import Task - - -class Merit(metaclass=ABCMeta): - """Merit class.""" - - @abstractmethod - def __call__(self, time: Time, task: Task, data: DataProvider) -> float: ... - - -__all__ = ["Merit"] From 0e6c58c4588147d8e1d7456f8920981bad2440d9 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 31 Oct 2025 20:38:52 +0100 Subject: [PATCH 09/57] fixed inputs --- pyobs/robotic/scheduler/dataprovider/__init__.py | 1 + pyobs/robotic/scheduler/merits/merit.py | 3 +-- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pyobs/robotic/scheduler/dataprovider/__init__.py b/pyobs/robotic/scheduler/dataprovider/__init__.py index e69de29bb..f1631b168 100644 --- a/pyobs/robotic/scheduler/dataprovider/__init__.py +++ b/pyobs/robotic/scheduler/dataprovider/__init__.py @@ -0,0 +1 @@ +from .dataprovider import DataProvider diff --git a/pyobs/robotic/scheduler/merits/merit.py b/pyobs/robotic/scheduler/merits/merit.py index 2969ac5ee..90d0f4a5f 100644 --- a/pyobs/robotic/scheduler/merits/merit.py +++ b/pyobs/robotic/scheduler/merits/merit.py @@ -1,9 +1,8 @@ from abc import ABCMeta, abstractmethod from typing import Any - from astropy.time import Time -from pyobs.robotic.scheduler.dataprovider import DataProvider +from ..dataprovider import DataProvider from pyobs.robotic import Task From 89e14421aba35448da30da750aa41ba715cb1c4d Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 31 Oct 2025 20:39:21 +0100 Subject: [PATCH 10/57] added merit --- pyobs/robotic/scheduler/merits/__init__.py | 4 ++++ pyobs/robotic/scheduler/merits/random.py | 20 ++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 pyobs/robotic/scheduler/merits/random.py diff --git a/pyobs/robotic/scheduler/merits/__init__.py b/pyobs/robotic/scheduler/merits/__init__.py index e69de29bb..1a57fff41 100644 --- a/pyobs/robotic/scheduler/merits/__init__.py +++ b/pyobs/robotic/scheduler/merits/__init__.py @@ -0,0 +1,4 @@ +from .merit import Merit +from .constant import ConstantMerit +from .pernight import PerNightMerit +from .random import RandomMerit diff --git a/pyobs/robotic/scheduler/merits/random.py b/pyobs/robotic/scheduler/merits/random.py new file mode 100644 index 000000000..8bbc9bb2e --- /dev/null +++ b/pyobs/robotic/scheduler/merits/random.py @@ -0,0 +1,20 @@ +from typing import Any +from astropy.time import Time +import numpy as np + +from pyobs.robotic import Task +from .merit import Merit + + +class RandomMerit(Merit): + """Merit functions for a random normal-distributed number.""" + + def __init__(self, std: float = 1.0, **kwargs: Any): + super().__init__(**kwargs) + self._std = std + + def __call__(self, time: Time, task: Task) -> float: + return np.random.normal(0.0, self._std) + + +__all__ = ["RandomMerit"] From e6ec139f647ebfc186766e003599acf87eece5f8 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Fri, 31 Oct 2025 20:57:43 +0100 Subject: [PATCH 11/57] added moon avoidance merit --- .../scheduler/dataprovider/dataprovider.py | 12 ++++++ pyobs/robotic/scheduler/merits/avoidance.py | 39 +++++++++++++++++++ .../robotic/scheduler/merits/moonavoidance.py | 16 ++++++++ .../scheduler/targets/siderealtarget.py | 4 ++ pyobs/robotic/scheduler/targets/target.py | 7 +++- 5 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 pyobs/robotic/scheduler/merits/avoidance.py create mode 100644 pyobs/robotic/scheduler/merits/moonavoidance.py diff --git a/pyobs/robotic/scheduler/dataprovider/dataprovider.py b/pyobs/robotic/scheduler/dataprovider/dataprovider.py index fe4f7c1c4..e30a60f47 100644 --- a/pyobs/robotic/scheduler/dataprovider/dataprovider.py +++ b/pyobs/robotic/scheduler/dataprovider/dataprovider.py @@ -1,8 +1,12 @@ from dataclasses import dataclass from functools import cache + +import astropy from astroplan import Observer +from astropy.coordinates import SkyCoord from astropy.time import Time, TimeDelta import astropy.units as u +from astropy.units import Quantity from pyobs.robotic import Task @@ -41,5 +45,13 @@ def get_task_success(self, task: Task, number: int = -1) -> TaskSuccess | None: """Return the number of successful runs for task.""" return None + @cache + def get_distance(self, target: SkyCoord, avoid: SkyCoord) -> Quantity: + return target.distance_to(avoid) + + @cache + def get_moon(self, time: Time) -> SkyCoord: + return astropy.coordinates.get_body("moon", time, self.observer.location) + __all__ = ["DataProvider"] diff --git a/pyobs/robotic/scheduler/merits/avoidance.py b/pyobs/robotic/scheduler/merits/avoidance.py new file mode 100644 index 000000000..946dc67dc --- /dev/null +++ b/pyobs/robotic/scheduler/merits/avoidance.py @@ -0,0 +1,39 @@ +import abc +from typing import Any + +from astropy.coordinates import SkyCoord +from astropy.time import Time + +from pyobs.robotic import Task +from .merit import Merit + + +class AvoidanceMerit(Merit, metaclass=abc.ABCMeta): + """Base class for merit functions that work on the distance to a celestial object, e.g. sun or moon.""" + + def __init__(self, impact: float = 1.0, stretch: float = 2.0, **kwargs: Any): + super().__init__(**kwargs) + self._impact = impact + self._stretch = stretch + + def __call__(self, time: Time, task: Task) -> float: + if task.target is None: + return 1.0 + + # target position + target = task.target.coordinates(time) + + # position to avoid + avoid = self._avoidance_position(time) + + # calculate distance + dist = self._data_provider.get_distance(target, avoid) + + # calculate merit + return float(self._impact * dist.degree**self._stretch) + + @abc.abstractmethod + def _avoidance_position(self, time: Time) -> SkyCoord: ... + + +__all__ = ["AvoidanceMerit"] diff --git a/pyobs/robotic/scheduler/merits/moonavoidance.py b/pyobs/robotic/scheduler/merits/moonavoidance.py new file mode 100644 index 000000000..1145d7345 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/moonavoidance.py @@ -0,0 +1,16 @@ +from functools import cache +from astropy.coordinates import SkyCoord +from astropy.time import Time + +from .avoidance import AvoidanceMerit + + +class MoonAvoidanceMerit(AvoidanceMerit): + """Merit functions that works on the distance to the moon.""" + + @cache + def _avoidance_position(self, time: Time) -> SkyCoord: + return self._data_provider.get_moon(time) + + +__all__ = ["MoonAvoidanceMerit"] diff --git a/pyobs/robotic/scheduler/targets/siderealtarget.py b/pyobs/robotic/scheduler/targets/siderealtarget.py index 5590a2e23..40f2f3b13 100644 --- a/pyobs/robotic/scheduler/targets/siderealtarget.py +++ b/pyobs/robotic/scheduler/targets/siderealtarget.py @@ -1,5 +1,6 @@ from astropy.coordinates import SkyCoord +from pyobs.utils.time import Time from .target import Target @@ -17,5 +18,8 @@ def name(self) -> str: def coord(self) -> SkyCoord: return self._coord + def coordinates(self, time: Time) -> SkyCoord: + return self._coord + __all__ = ["SiderealTarget"] diff --git a/pyobs/robotic/scheduler/targets/target.py b/pyobs/robotic/scheduler/targets/target.py index 51d34aedf..f5a9cc6e9 100644 --- a/pyobs/robotic/scheduler/targets/target.py +++ b/pyobs/robotic/scheduler/targets/target.py @@ -1,10 +1,15 @@ +import abc from abc import ABCMeta +from astropy.coordinates import SkyCoord + from pyobs.object import Object +from pyobs.utils.time import Time class Target(Object, metaclass=ABCMeta): - pass + @abc.abstractmethod + def coordinates(self, time: Time) -> SkyCoord: ... __all__ = ["Target"] From d37c07b9a7326317863c266c2550cff074a1537e Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 14:42:59 +0100 Subject: [PATCH 12/57] added (empty) MeritScheduler --- pyobs/robotic/scheduler/__init__.py | 2 +- .../{astroplan.py => astroplanscheduler.py} | 0 pyobs/robotic/scheduler/meritscheduler.py | 52 +++++++++++++++++++ 3 files changed, 53 insertions(+), 1 deletion(-) rename pyobs/robotic/scheduler/{astroplan.py => astroplanscheduler.py} (100%) create mode 100644 pyobs/robotic/scheduler/meritscheduler.py diff --git a/pyobs/robotic/scheduler/__init__.py b/pyobs/robotic/scheduler/__init__.py index 0f8f2f3a0..d9a0db94f 100644 --- a/pyobs/robotic/scheduler/__init__.py +++ b/pyobs/robotic/scheduler/__init__.py @@ -1,3 +1,3 @@ from .taskscheduler import TaskScheduler -from .astroplan import AstroplanScheduler +from .astroplanscheduler import AstroplanScheduler from .dataprovider import DataProvider diff --git a/pyobs/robotic/scheduler/astroplan.py b/pyobs/robotic/scheduler/astroplanscheduler.py similarity index 100% rename from pyobs/robotic/scheduler/astroplan.py rename to pyobs/robotic/scheduler/astroplanscheduler.py diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py new file mode 100644 index 000000000..d4346eb8a --- /dev/null +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -0,0 +1,52 @@ +from __future__ import annotations +import asyncio +import logging +from typing import Any, TYPE_CHECKING + +from pyobs.object import Object +from .taskscheduler import TaskScheduler +from pyobs.utils.time import Time + +if TYPE_CHECKING: + from pyobs.robotic import ScheduledTask, Task + +log = logging.getLogger(__name__) + + +class MeritScheduler(TaskScheduler): + """Scheduler based on merits.""" + + __module__ = "pyobs.modules.robotic" + + def __init__( + self, + schedule_range: int = 24, + safety_time: float = 60, + twilight: str = "astronomical", + **kwargs: Any, + ): + """Initialize a new scheduler. + + Args: + schedule_range: Number of hours to schedule into the future + safety_time: If no ETA for next task to start exists (from current task, weather became good, etc), use + this time in seconds to make sure that we don't schedule for a time when the scheduler is + still running + twilight: astronomical or nautical + """ + Object.__init__(self, **kwargs) + + # store + self._schedule_range = schedule_range + self._safety_time = safety_time + self._twilight = twilight + self._abort: asyncio.Event = asyncio.Event() + + async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: + return [] + + async def abort(self) -> None: + self._abort.set() + + +__all__ = ["MeritScheduler"] From dea31d1b7f14f5d3cb30aafd6cb99cebe51bc79c Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 14:59:27 +0100 Subject: [PATCH 13/57] added (empty) MeritScheduler --- pyobs/modules/robotic/scheduler.py | 5 ++++- pyobs/robotic/scheduler/astroplanscheduler.py | 9 +++++++-- pyobs/robotic/scheduler/dataprovider/dataprovider.py | 6 ++++-- pyobs/robotic/scheduler/meritscheduler.py | 5 +++-- pyobs/robotic/scheduler/taskscheduler.py | 7 ++++++- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 1934f7b36..07fe9d5b8 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -209,7 +209,10 @@ async def _schedule_worker(self) -> None: start_time = time.time() # schedule - scheduled_tasks = await self._scheduler.schedule(self._tasks, self._schedule_start) + # TODO: process all scheduled tasks one by one + scheduled_tasks: list[ScheduledTask] = [] + async for scheduled_task in self._scheduler.schedule(self._tasks, self._schedule_start): + scheduled_tasks.append(scheduled_task) # upload schedule await self._finish_schedule(scheduled_tasks, self._schedule_start) diff --git a/pyobs/robotic/scheduler/astroplanscheduler.py b/pyobs/robotic/scheduler/astroplanscheduler.py index bb0096d99..9e3d7830e 100644 --- a/pyobs/robotic/scheduler/astroplanscheduler.py +++ b/pyobs/robotic/scheduler/astroplanscheduler.py @@ -3,6 +3,7 @@ import logging import multiprocessing as mp from typing import Any, TYPE_CHECKING +from collections.abc import AsyncIterator import astroplan from astroplan import ObservingBlock, FixedTarget from astropy.time import TimeDelta @@ -50,7 +51,7 @@ def __init__( self._abort: asyncio.Event = asyncio.Event() self._is_running: bool = False - async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: + async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: # is lock acquired? send abort signal if self._lock.locked(): await self.abort() @@ -64,7 +65,11 @@ async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: scheduled_blocks = await self._schedule_blocks(blocks, start, end, constraints, self._abort) # convert - return await self._convert_blocks(scheduled_blocks, tasks) + scheduled_tasks = await self._convert_blocks(scheduled_blocks, tasks) + + # yield them + for scheduled_task in scheduled_tasks: + yield scheduled_task async def abort(self) -> None: self._abort.set() diff --git a/pyobs/robotic/scheduler/dataprovider/dataprovider.py b/pyobs/robotic/scheduler/dataprovider/dataprovider.py index e30a60f47..c59fc3f6e 100644 --- a/pyobs/robotic/scheduler/dataprovider/dataprovider.py +++ b/pyobs/robotic/scheduler/dataprovider/dataprovider.py @@ -1,6 +1,7 @@ +from __future__ import annotations from dataclasses import dataclass from functools import cache - +from typing import TYPE_CHECKING import astropy from astroplan import Observer from astropy.coordinates import SkyCoord @@ -8,7 +9,8 @@ import astropy.units as u from astropy.units import Quantity -from pyobs.robotic import Task +if TYPE_CHECKING: + from pyobs.robotic import Task @dataclass diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index d4346eb8a..2f3d9a1ff 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -2,6 +2,7 @@ import asyncio import logging from typing import Any, TYPE_CHECKING +from collections.abc import AsyncIterator from pyobs.object import Object from .taskscheduler import TaskScheduler @@ -42,8 +43,8 @@ def __init__( self._twilight = twilight self._abort: asyncio.Event = asyncio.Event() - async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: - return [] + async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: + yield ScheduledTask(tasks[0], Time.now(), Time.now()) async def abort(self) -> None: self._abort.set() diff --git a/pyobs/robotic/scheduler/taskscheduler.py b/pyobs/robotic/scheduler/taskscheduler.py index 1aa11e04d..832d2194b 100644 --- a/pyobs/robotic/scheduler/taskscheduler.py +++ b/pyobs/robotic/scheduler/taskscheduler.py @@ -1,6 +1,7 @@ from __future__ import annotations import abc import logging +from collections.abc import AsyncIterator from typing import TYPE_CHECKING from pyobs.object import Object @@ -16,7 +17,11 @@ class TaskScheduler(Object, metaclass=abc.ABCMeta): """Abstract base class for tasks scheduler.""" @abc.abstractmethod - async def schedule(self, tasks: list[Task], start: Time) -> list[ScheduledTask]: ... + async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: + # if we don't yield once here, mypy doesn't like this, see: + # https://github.com/python/mypy/issues/5385 + # https://github.com/python/mypy/issues/5070 + yield ScheduledTask(tasks[0], Time.now(), Time.now()) @abc.abstractmethod async def abort(self) -> None: ... From 477b70240d1e96841fe4232c897d8d8c4741fd12 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 16:56:36 +0100 Subject: [PATCH 14/57] processing scheduled tasks one by one --- pyobs/modules/robotic/scheduler.py | 57 +++++++++++++++++------------- pyobs/robotic/lco/taskschedule.py | 22 ++++++------ pyobs/robotic/taskschedule.py | 14 ++++++-- 3 files changed, 54 insertions(+), 39 deletions(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 07fe9d5b8..4d44b7493 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -209,13 +209,33 @@ async def _schedule_worker(self) -> None: start_time = time.time() # schedule - # TODO: process all scheduled tasks one by one scheduled_tasks: list[ScheduledTask] = [] + first = True async for scheduled_task in self._scheduler.schedule(self._tasks, self._schedule_start): + # remember for later scheduled_tasks.append(scheduled_task) - # upload schedule - await self._finish_schedule(scheduled_tasks, self._schedule_start) + if self._need_update: + log.info("Not using scheduler results, since update was requested.") + break + + # on first task, we have to clear the schedule + if first: + log.info("Finished calculating next task:") + self._log_scheduled_task([scheduled_task]) + await self._schedule.clear_schedule(self._schedule_start) + first = False + + # submit it + await self._schedule.add_schedule([scheduled_task]) + + if self._need_update: + log.info("Not using scheduler results, since update was requested.") + continue + + # log it + log.info("Finished calculating schedule for %d block(s):", len(scheduled_tasks)) + self._log_scheduled_task(scheduled_tasks) # set new safety_time as duration + 20% self._safety_time = (time.time() - start_time) * 1.2 @@ -226,28 +246,15 @@ async def _schedule_worker(self) -> None: # sleep a little await asyncio.sleep(1) - async def _finish_schedule(self, scheduled_tasks: list[ScheduledTask], start: Time) -> None: - # if need new update, skip here - if self._need_update: - log.info("Not using scheduler results, since update was requested.") - return - - # update - await self._schedule.set_schedule(scheduled_tasks, start) - - # log - if len(scheduled_tasks) > 0: - log.info("Finished calculating schedule for %d block(s):", len(scheduled_tasks)) - for i, scheduled_task in enumerate(scheduled_tasks, 1): - log.info( - " - %s to %s: %s (%d)", - scheduled_task.start.strftime("%H:%M:%S"), - scheduled_task.end.strftime("%H:%M:%S"), - scheduled_task.task.name, - scheduled_task.task.id, - ) - else: - log.info("Finished calculating schedule for 0 blocks.") + def _log_scheduled_task(self, scheduled_tasks: list[ScheduledTask]) -> None: + for scheduled_task in scheduled_tasks: + log.info( + " - %s to %s: %s (%d)", + scheduled_task.start.strftime("%H:%M:%S"), + scheduled_task.end.strftime("%H:%M:%S"), + scheduled_task.task.name, + scheduled_task.task.id, + ) async def run(self, **kwargs: Any) -> None: """Trigger a re-schedule.""" diff --git a/pyobs/robotic/lco/taskschedule.py b/pyobs/robotic/lco/taskschedule.py index 73ba1e1c8..86dd9ff5d 100644 --- a/pyobs/robotic/lco/taskschedule.py +++ b/pyobs/robotic/lco/taskschedule.py @@ -315,33 +315,33 @@ async def _send_update_later(self, status_id: int, status: dict[str, Any], delay # re-send await self.send_update(status_id, status) - async def set_schedule(self, tasks: list[ScheduledTask], start_time: Time) -> None: - """Set the list of scheduled tasks. + async def add_schedule(self, tasks: list[ScheduledTask]) -> None: + """Add the list of scheduled tasks to the schedule. Args: tasks: Scheduled tasks. - start_time: Start time for schedule. """ # create observations observations = self._create_observations(tasks) - # cancel schedule - await self._cancel_schedule(start_time) - # send new schedule await self._submit_observations(observations) - async def _cancel_schedule(self, now: Time) -> None: - """Cancel future schedule.""" + async def clear_schedule(self, start_time: Time) -> None: + """Clear schedule after given start time. + + Args: + start_time: Start time to clear from. + """ # define parameters params = { "site": self._site, "enclosure": self._enclosure, "telescope": self._telescope, - "start": now.isot, - "end": (now + self._period).isot, + "start": start_time.isot, + "end": (start_time + self._period).isot, } # url and headers @@ -349,7 +349,7 @@ async def _cancel_schedule(self, now: Time) -> None: headers = {"Authorization": "Token " + self._token, "Content-Type": "application/json; charset=utf8"} # cancel schedule - log.info("Deleting all scheduled tasks after %s...", now.isot) + log.info("Deleting all scheduled tasks after %s...", start_time.isot) async with aiohttp.ClientSession() as session: async with session.post(url, json=params, headers=headers, timeout=10) as response: if response.status != 200: diff --git a/pyobs/robotic/taskschedule.py b/pyobs/robotic/taskschedule.py index b6e7710c9..fbbf747ef 100644 --- a/pyobs/robotic/taskschedule.py +++ b/pyobs/robotic/taskschedule.py @@ -11,12 +11,20 @@ def __init__(self, **kwargs: Any): Object.__init__(self, **kwargs) @abstractmethod - async def set_schedule(self, tasks: list[ScheduledTask], start_time: Time) -> None: - """Set the list of scheduled tasks. + async def add_schedule(self, tasks: list[ScheduledTask]) -> None: + """Add the list of scheduled tasks to the schedule. Args: tasks: Scheduled tasks. - start_time: Start time for schedule. + """ + ... + + @abstractmethod + async def clear_schedule(self, start_time: Time) -> None: + """Clear schedule after given start time. + + Args: + start_time: Start time to clear from. """ ... From 93e5ca5362a5964503276aaef2deab5459fdad72 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 18:05:43 +0100 Subject: [PATCH 15/57] extracted create_constraints_for_configuration and create task with only one config --- pyobs/robotic/lco/taskarchive.py | 42 +++++++++++++++++++++----------- 1 file changed, 28 insertions(+), 14 deletions(-) diff --git a/pyobs/robotic/lco/taskarchive.py b/pyobs/robotic/lco/taskarchive.py index f5c0d00b3..8ec8de79e 100644 --- a/pyobs/robotic/lco/taskarchive.py +++ b/pyobs/robotic/lco/taskarchive.py @@ -1,3 +1,4 @@ +import copy import logging from typing import Dict, Optional, Any from astropy.coordinates import SkyCoord @@ -68,6 +69,25 @@ async def last_changed(self) -> Optional[Time]: # even in case of errors, return last time return self._last_changed + @staticmethod + def create_constraints_for_configuration(config: dict[str, Any]) -> list[Constraint]: + # time constraints + constraints: list[Constraint] = [] + + # constraints + c = config["constraints"] + if "max_airmass" in c and c["max_airmass"] is not None: + constraints.append(AirmassConstraint(c["max_airmass"])) + if "min_lunar_distance" in c and c["min_lunar_distance"] is not None: + constraints.append(MoonSeparationConstraint(c["min_lunar_distance"])) + if "max_lunar_phase" in c and c["max_lunar_phase"] is not None: + constraints.append(MoonIlluminationConstraint(c["max_lunar_phase"])) + # if max lunar phase <= 0.4 (which would be DARK), we also enforce the sun to be <-18 degrees + if c["max_lunar_phase"] <= 0.4: + constraints.append(SolarElevationConstraint(-18.0)) + + return constraints + async def get_schedulable_tasks(self) -> list[Task]: """Returns list of schedulable tasks. @@ -101,7 +121,7 @@ async def get_schedulable_tasks(self) -> list[Task]: # duration duration = req["duration"] * u.second - # time constraints + # get constraints time_constraints = [TimeConstraint(Time(wnd["start"]), Time(wnd["end"])) for wnd in req["windows"]] # loop configs @@ -121,30 +141,24 @@ async def get_schedulable_tasks(self) -> list[Task]: continue # constraints - c = cfg["constraints"] - constraints: list[Constraint] = [] - if "max_airmass" in c and c["max_airmass"] is not None: - constraints.append(AirmassConstraint(c["max_airmass"])) - if "min_lunar_distance" in c and c["min_lunar_distance"] is not None: - constraints.append(MoonSeparationConstraint(c["min_lunar_distance"])) - if "max_lunar_phase" in c and c["max_lunar_phase"] is not None: - constraints.append(MoonIlluminationConstraint(c["max_lunar_phase"])) - # if max lunar phase <= 0.4 (which would be DARK), we also enforce the sun to be <-18 degrees - if c["max_lunar_phase"] <= 0.4: - constraints.append(SolarElevationConstraint(-18.0)) + constraints = self.create_constraints_for_configuration(cfg) + time_constraints # priority is base_priority times duration in minutes # priority = base_priority * duration.value / 60. priority = base_priority + # create request with only this config + new_req = copy.deepcopy(req) + new_req["configurations"] = [cfg] + # create task task = LcoTask( id=req["id"], name=group["name"], duration=duration, priority=priority, - constraints=[*constraints, *time_constraints], - config={"request": req}, + constraints=constraints, + config={"request": new_req}, target=SiderealTarget(target_name, target), ) tasks.append(task) From 52c62003de6ba4c398a8d44fd2ce01b3b2d1d2f8 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 18:10:01 +0100 Subject: [PATCH 16/57] moved data parameter back into __call__ --- pyobs/robotic/scheduler/merits/avoidance.py | 4 ++-- pyobs/robotic/scheduler/merits/constant.py | 4 ++-- pyobs/robotic/scheduler/merits/merit.py | 2 +- pyobs/robotic/scheduler/merits/pernight.py | 5 ++--- pyobs/robotic/scheduler/merits/random.py | 3 ++- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/pyobs/robotic/scheduler/merits/avoidance.py b/pyobs/robotic/scheduler/merits/avoidance.py index 946dc67dc..013b7bacb 100644 --- a/pyobs/robotic/scheduler/merits/avoidance.py +++ b/pyobs/robotic/scheduler/merits/avoidance.py @@ -1,11 +1,11 @@ import abc from typing import Any - from astropy.coordinates import SkyCoord from astropy.time import Time from pyobs.robotic import Task from .merit import Merit +from ..dataprovider import DataProvider class AvoidanceMerit(Merit, metaclass=abc.ABCMeta): @@ -16,7 +16,7 @@ def __init__(self, impact: float = 1.0, stretch: float = 2.0, **kwargs: Any): self._impact = impact self._stretch = stretch - def __call__(self, time: Time, task: Task) -> float: + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: if task.target is None: return 1.0 diff --git a/pyobs/robotic/scheduler/merits/constant.py b/pyobs/robotic/scheduler/merits/constant.py index 672173ab3..3dd5194ec 100644 --- a/pyobs/robotic/scheduler/merits/constant.py +++ b/pyobs/robotic/scheduler/merits/constant.py @@ -1,9 +1,9 @@ from typing import Any - from astropy.time import Time from pyobs.robotic import Task from .merit import Merit +from ..dataprovider import DataProvider class ConstantMerit(Merit): @@ -13,7 +13,7 @@ def __init__(self, merit: float, **kwargs: Any): super().__init__(**kwargs) self._merit = merit - def __call__(self, time: Time, task: Task) -> float: + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: return self._merit diff --git a/pyobs/robotic/scheduler/merits/merit.py b/pyobs/robotic/scheduler/merits/merit.py index 90d0f4a5f..e93255056 100644 --- a/pyobs/robotic/scheduler/merits/merit.py +++ b/pyobs/robotic/scheduler/merits/merit.py @@ -13,7 +13,7 @@ def __init__(self, data_provider: DataProvider, **kwargs: Any) -> None: self._data_provider = data_provider @abstractmethod - def __call__(self, time: Time, task: Task) -> float: ... + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: ... __all__ = ["Merit"] diff --git a/pyobs/robotic/scheduler/merits/pernight.py b/pyobs/robotic/scheduler/merits/pernight.py index 9f577f084..b251ee3e4 100644 --- a/pyobs/robotic/scheduler/merits/pernight.py +++ b/pyobs/robotic/scheduler/merits/pernight.py @@ -3,6 +3,7 @@ from pyobs.robotic import Task from .merit import Merit +from ..dataprovider import DataProvider class PerNightMerit(Merit): @@ -12,9 +13,7 @@ def __init__(self, count: int, **kwargs: Any): super().__init__(**kwargs) self._count = count - def __call__(self, time: Time, task: Task) -> float: - data = self._data_provider - + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: # get number of successful task runs successes = data.get_task_success_count(task) diff --git a/pyobs/robotic/scheduler/merits/random.py b/pyobs/robotic/scheduler/merits/random.py index 8bbc9bb2e..cd0607bf1 100644 --- a/pyobs/robotic/scheduler/merits/random.py +++ b/pyobs/robotic/scheduler/merits/random.py @@ -4,6 +4,7 @@ from pyobs.robotic import Task from .merit import Merit +from ..dataprovider import DataProvider class RandomMerit(Merit): @@ -13,7 +14,7 @@ def __init__(self, std: float = 1.0, **kwargs: Any): super().__init__(**kwargs) self._std = std - def __call__(self, time: Time, task: Task) -> float: + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: return np.random.normal(0.0, self._std) From 7a22dcad3bdde3a1d4ebbc37fca9c8474dc8e3fc Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 18:22:23 +0100 Subject: [PATCH 17/57] added merits to tasks --- pyobs/robotic/lco/taskarchive.py | 5 +++++ pyobs/robotic/task.py | 13 +++++++++++-- 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/pyobs/robotic/lco/taskarchive.py b/pyobs/robotic/lco/taskarchive.py index 8ec8de79e..0402cd5ac 100644 --- a/pyobs/robotic/lco/taskarchive.py +++ b/pyobs/robotic/lco/taskarchive.py @@ -17,6 +17,7 @@ TimeConstraint, SolarElevationConstraint, ) +from ..scheduler.merits import Merit from ..scheduler.targets import SiderealTarget log = logging.getLogger(__name__) @@ -143,6 +144,9 @@ async def get_schedulable_tasks(self) -> list[Task]: # constraints constraints = self.create_constraints_for_configuration(cfg) + time_constraints + # merits + merits: list[Merit] = [self.get_object(m) for m in cfg["merits"]] if "merits" in cfg else [] + # priority is base_priority times duration in minutes # priority = base_priority * duration.value / 60. priority = base_priority @@ -158,6 +162,7 @@ async def get_schedulable_tasks(self) -> list[Task]: duration=duration, priority=priority, constraints=constraints, + merits=merits, config={"request": new_req}, target=SiderealTarget(target_name, target), ) diff --git a/pyobs/robotic/task.py b/pyobs/robotic/task.py index 5b001407b..a14fb7cb7 100644 --- a/pyobs/robotic/task.py +++ b/pyobs/robotic/task.py @@ -12,9 +12,11 @@ from pyobs.robotic.taskrunner import TaskRunner from pyobs.robotic.taskarchive import TaskArchive from pyobs.robotic.scheduler.constraints import Constraint + from pyobs.robotic.scheduler.merits import Merit class Task(Object, metaclass=ABCMeta): + def __init__( self, id: Any, @@ -23,6 +25,7 @@ def __init__( priority: float | None = None, config: dict[str, Any] | None = None, constraints: list[Constraint] | None = None, + merits: list[Merit] | None = None, target: Target | None = None, **kwargs: Any, ): @@ -33,6 +36,7 @@ def __init__( self._priority = priority self._config = config self._constraints = constraints + self._merits = merits self._target = target @property @@ -61,9 +65,14 @@ def config(self) -> dict[str, Any]: return self._config if self._config is not None else {} @property - def constraints(self) -> list[Constraint] | None: + def constraints(self) -> list[Constraint]: """Returns constraints.""" - return self._constraints + return self._constraints if self._constraints is not None else [] + + @property + def merits(self) -> list[Merit]: + """Returns merits.""" + return self._merits if self._merits is not None else [] @property def target(self) -> Target | None: From 07b63044736b4dd1c70078b2d95bf6bf87e49403 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 18:22:46 +0100 Subject: [PATCH 18/57] working on merit scheduler --- pyobs/robotic/scheduler/meritscheduler.py | 20 +++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index 2f3d9a1ff..5d7769a87 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -3,8 +3,12 @@ import logging from typing import Any, TYPE_CHECKING from collections.abc import AsyncIterator +import numpy as np +from astropy.time import TimeDelta +import astropy.units as u from pyobs.object import Object +from . import DataProvider from .taskscheduler import TaskScheduler from pyobs.utils.time import Time @@ -44,7 +48,21 @@ def __init__( self._abort: asyncio.Event = asyncio.Event() async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: - yield ScheduledTask(tasks[0], Time.now(), Time.now()) + data = DataProvider(self.observer) + task = await self._find_next_best_task(tasks, start, data) + yield task + + async def _find_next_best_task(self, tasks: list[Task], time: Time, data: DataProvider) -> ScheduledTask: + # evaluate all merit functions at given time + merits: list[float] = [] + for task in tasks: + merit = float(np.prod([m(time, task, data) for m in task.merits])) + merits.append(merit) + + # find max one + idx = np.argmax(merits) + task = tasks[idx] + return ScheduledTask(task, time, time + TimeDelta(task.duration * u.sec)) async def abort(self) -> None: self._abort.set() From 340138e64c123b198a03efc6bf433464b425e85f Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 19:17:27 +0100 Subject: [PATCH 19/57] fixed test --- pyobs/modules/robotic/scheduler.py | 18 +++--- tests/modules/robotic/test_scheduler.py | 81 +++++++++++++------------ 2 files changed, 51 insertions(+), 48 deletions(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 4d44b7493..07dbb1f79 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -184,15 +184,15 @@ def _compare_task_lists(tasks1: list[Task], tasks2: list[Task]) -> tuple[list[An unique2: Blocks that exist in tasks2, but not in tasks1. """ - # get dictionaries with block names - names1 = {t.id: t for t in tasks1} - names2 = {t.id: t for t in tasks2} + # get dictionaries with block ids + ids1 = {t.id: t for t in tasks1} + ids2 = {t.id: t for t in tasks2} - # find elements in names1 that are missing in names2 and vice versa - additional1 = list(set(names1.keys()).difference(names2.keys())) - additional2 = list(set(names2.keys()).difference(names1.keys())) + # find elements in ids1 that are missing in ids2 and vice versa + additional1 = list(set(ids1.keys()).difference(ids2.keys())) + additional2 = list(set(ids2.keys()).difference(ids1.keys())) - return additional1, additional2 + return sorted(additional1), sorted(additional2) async def _schedule_worker(self) -> None: # run forever @@ -223,11 +223,11 @@ async def _schedule_worker(self) -> None: if first: log.info("Finished calculating next task:") self._log_scheduled_task([scheduled_task]) - await self._schedule.clear_schedule(self._schedule_start) + # await self._schedule.clear_schedule(self._schedule_start) first = False # submit it - await self._schedule.add_schedule([scheduled_task]) + # await self._schedule.add_schedule([scheduled_task]) if self._need_update: log.info("Not using scheduler results, since update was requested.") diff --git a/tests/modules/robotic/test_scheduler.py b/tests/modules/robotic/test_scheduler.py index a3f856660..9b43f1883 100644 --- a/tests/modules/robotic/test_scheduler.py +++ b/tests/modules/robotic/test_scheduler.py @@ -1,65 +1,68 @@ -from astroplan import ObservingBlock, FixedTarget -import astropy.units as u -from astropy.coordinates import SkyCoord - from pyobs.modules.robotic import Scheduler +from pyobs.robotic import Task, TaskRunner, TaskSchedule, TaskArchive +from pyobs.robotic.scripts import Script + + +class TestTask(Task): + async def can_run(self, scripts: dict[str, Script] | None = None) -> bool: + return True + + @property + def can_start_late(self) -> bool: + return False + + async def run( + self, + task_runner: TaskRunner, + task_schedule: TaskSchedule | None = None, + task_archive: TaskArchive | None = None, + scripts: dict[str, Script] | None = None, + ) -> None: + pass + def is_finished(self) -> bool: + return False -def test_compare_block_lists(): - # create lists of blocks - blocks = [] + +def test_compare_block_lists() -> None: + # create lists of tasks + tasks: list[Task] = [] for i in range(10): - blocks.append( - ObservingBlock( - FixedTarget(SkyCoord(0.0 * u.deg, 0.0 * u.deg, frame="icrs"), name=str(i)), 10 * u.minute, 10 - ) - ) + tasks.append(TestTask(i, str(i), 100)) # create two lists from these with some overlap - blocks1 = blocks[:7] - blocks2 = blocks[5:] + tasks1 = tasks[:7] + tasks2 = tasks[5:] # compare - unique1, unique2 = Scheduler._compare_task_lists(blocks1, blocks2) - - # get names - names1 = [int(b.target.name) for b in unique1] - names2 = [int(b.target.name) for b in unique2] + unique1, unique2 = Scheduler._compare_task_lists(tasks1, tasks2) # names1 should contain 0, 1, 2, 3, 4 - assert set(names1) == {0, 1, 2, 3, 4} + assert set(unique1) == {0, 1, 2, 3, 4} # names2 should contain 7, 8, 9 - assert set(names2) == {7, 8, 9} + assert set(unique2) == {7, 8, 9} # create two lists from these with no overlap - blocks1 = blocks[:5] - blocks2 = blocks[5:] + tasks1 = tasks[:5] + tasks2 = tasks[5:] # compare - unique1, unique2 = Scheduler._compare_task_lists(blocks1, blocks2) - - # get names - names1 = [int(b.target.name) for b in unique1] - names2 = [int(b.target.name) for b in unique2] + unique1, unique2 = Scheduler._compare_task_lists(tasks1, tasks2) # names1 should contain 0, 1, 2, 3, 4 - assert set(names1) == {0, 1, 2, 3, 4} + assert set(unique1) == {0, 1, 2, 3, 4} # names2 should contain 5, 6, 7, 8, 9 - assert set(names2) == {5, 6, 7, 8, 9} + assert set(unique2) == {5, 6, 7, 8, 9} # create two identical lists - blocks1 = blocks - blocks2 = blocks + tasks1 = tasks + tasks2 = tasks # compare - unique1, unique2 = Scheduler._compare_task_lists(blocks1, blocks2) - - # get names - names1 = [int(b.target.name) for b in unique1] - names2 = [int(b.target.name) for b in unique2] + unique1, unique2 = Scheduler._compare_task_lists(tasks1, tasks2) # both lists should be empty - assert len(names1) == 0 - assert len(names2) == 0 + assert len(unique1) == 0 + assert len(unique2) == 0 From 3c447bd15c3c8f4d40266fe08b7b3457a0c18cbc Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 19:27:09 +0100 Subject: [PATCH 20/57] fixed imports --- pyobs/robotic/scheduler/merits/avoidance.py | 20 ++++++++++--------- pyobs/robotic/scheduler/merits/constant.py | 12 ++++++----- pyobs/robotic/scheduler/merits/merit.py | 13 ++++++------ .../robotic/scheduler/merits/moonavoidance.py | 14 ++++++++----- pyobs/robotic/scheduler/merits/pernight.py | 12 ++++++----- pyobs/robotic/scheduler/merits/random.py | 12 ++++++----- 6 files changed, 47 insertions(+), 36 deletions(-) diff --git a/pyobs/robotic/scheduler/merits/avoidance.py b/pyobs/robotic/scheduler/merits/avoidance.py index 013b7bacb..bdec1edd8 100644 --- a/pyobs/robotic/scheduler/merits/avoidance.py +++ b/pyobs/robotic/scheduler/merits/avoidance.py @@ -1,11 +1,13 @@ +from __future__ import annotations import abc -from typing import Any -from astropy.coordinates import SkyCoord -from astropy.time import Time - -from pyobs.robotic import Task +from typing import Any, TYPE_CHECKING from .merit import Merit -from ..dataprovider import DataProvider + +if TYPE_CHECKING: + from astropy.coordinates import SkyCoord + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider class AvoidanceMerit(Merit, metaclass=abc.ABCMeta): @@ -24,16 +26,16 @@ def __call__(self, time: Time, task: Task, data: DataProvider) -> float: target = task.target.coordinates(time) # position to avoid - avoid = self._avoidance_position(time) + avoid = self._avoidance_position(time, data) # calculate distance - dist = self._data_provider.get_distance(target, avoid) + dist = data.get_distance(target, avoid) # calculate merit return float(self._impact * dist.degree**self._stretch) @abc.abstractmethod - def _avoidance_position(self, time: Time) -> SkyCoord: ... + def _avoidance_position(self, time: Time, data: DataProvider) -> SkyCoord: ... __all__ = ["AvoidanceMerit"] diff --git a/pyobs/robotic/scheduler/merits/constant.py b/pyobs/robotic/scheduler/merits/constant.py index 3dd5194ec..a613a58f9 100644 --- a/pyobs/robotic/scheduler/merits/constant.py +++ b/pyobs/robotic/scheduler/merits/constant.py @@ -1,9 +1,11 @@ -from typing import Any -from astropy.time import Time - -from pyobs.robotic import Task +from __future__ import annotations +from typing import Any, TYPE_CHECKING from .merit import Merit -from ..dataprovider import DataProvider + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider class ConstantMerit(Merit): diff --git a/pyobs/robotic/scheduler/merits/merit.py b/pyobs/robotic/scheduler/merits/merit.py index e93255056..1d28bda69 100644 --- a/pyobs/robotic/scheduler/merits/merit.py +++ b/pyobs/robotic/scheduler/merits/merit.py @@ -1,17 +1,16 @@ +from __future__ import annotations from abc import ABCMeta, abstractmethod -from typing import Any -from astropy.time import Time +from typing import TYPE_CHECKING -from ..dataprovider import DataProvider -from pyobs.robotic import Task +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task class Merit(metaclass=ABCMeta): """Merit class.""" - def __init__(self, data_provider: DataProvider, **kwargs: Any) -> None: - self._data_provider = data_provider - @abstractmethod def __call__(self, time: Time, task: Task, data: DataProvider) -> float: ... diff --git a/pyobs/robotic/scheduler/merits/moonavoidance.py b/pyobs/robotic/scheduler/merits/moonavoidance.py index 1145d7345..25995b4cd 100644 --- a/pyobs/robotic/scheduler/merits/moonavoidance.py +++ b/pyobs/robotic/scheduler/merits/moonavoidance.py @@ -1,16 +1,20 @@ +from __future__ import annotations from functools import cache -from astropy.coordinates import SkyCoord -from astropy.time import Time - +from typing import TYPE_CHECKING from .avoidance import AvoidanceMerit +if TYPE_CHECKING: + from astropy.coordinates import SkyCoord + from astropy.time import Time + from .. import DataProvider + class MoonAvoidanceMerit(AvoidanceMerit): """Merit functions that works on the distance to the moon.""" @cache - def _avoidance_position(self, time: Time) -> SkyCoord: - return self._data_provider.get_moon(time) + def _avoidance_position(self, time: Time, data: DataProvider) -> SkyCoord: + return data.get_moon(time) __all__ = ["MoonAvoidanceMerit"] diff --git a/pyobs/robotic/scheduler/merits/pernight.py b/pyobs/robotic/scheduler/merits/pernight.py index b251ee3e4..68b85d584 100644 --- a/pyobs/robotic/scheduler/merits/pernight.py +++ b/pyobs/robotic/scheduler/merits/pernight.py @@ -1,9 +1,11 @@ -from typing import Any -from astropy.time import Time - -from pyobs.robotic import Task +from __future__ import annotations +from typing import Any, TYPE_CHECKING from .merit import Merit -from ..dataprovider import DataProvider + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider class PerNightMerit(Merit): diff --git a/pyobs/robotic/scheduler/merits/random.py b/pyobs/robotic/scheduler/merits/random.py index cd0607bf1..a8e39b499 100644 --- a/pyobs/robotic/scheduler/merits/random.py +++ b/pyobs/robotic/scheduler/merits/random.py @@ -1,10 +1,12 @@ -from typing import Any -from astropy.time import Time +from __future__ import annotations +from typing import Any, TYPE_CHECKING import numpy as np - -from pyobs.robotic import Task from .merit import Merit -from ..dataprovider import DataProvider + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider class RandomMerit(Merit): From 33c296c7090cefa3e482af121aaf33690639a39d Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sat, 1 Nov 2025 19:27:45 +0100 Subject: [PATCH 21/57] working on merit scheduler --- pyobs/robotic/scheduler/meritscheduler.py | 30 +++++++------ tests/robotic/scheduler/__init__.py | 0 .../robotic/scheduler/test_meritscheduler.py | 44 +++++++++++++++++++ 3 files changed, 60 insertions(+), 14 deletions(-) create mode 100644 tests/robotic/scheduler/__init__.py create mode 100644 tests/robotic/scheduler/test_meritscheduler.py diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index 5d7769a87..7fa5c3dca 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -11,9 +11,10 @@ from . import DataProvider from .taskscheduler import TaskScheduler from pyobs.utils.time import Time +from pyobs.robotic import ScheduledTask if TYPE_CHECKING: - from pyobs.robotic import ScheduledTask, Task + from pyobs.robotic import Task log = logging.getLogger(__name__) @@ -49,23 +50,24 @@ def __init__( async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: data = DataProvider(self.observer) - task = await self._find_next_best_task(tasks, start, data) + task = await find_next_best_task(tasks, start, data) yield task - async def _find_next_best_task(self, tasks: list[Task], time: Time, data: DataProvider) -> ScheduledTask: - # evaluate all merit functions at given time - merits: list[float] = [] - for task in tasks: - merit = float(np.prod([m(time, task, data) for m in task.merits])) - merits.append(merit) - - # find max one - idx = np.argmax(merits) - task = tasks[idx] - return ScheduledTask(task, time, time + TimeDelta(task.duration * u.sec)) - async def abort(self) -> None: self._abort.set() +async def find_next_best_task(tasks: list[Task], time: Time, data: DataProvider) -> ScheduledTask: + # evaluate all merit functions at given time + merits: list[float] = [] + for task in tasks: + merit = float(np.prod([m(time, task, data) for m in task.merits])) + merits.append(merit) + + # find max one + idx = np.argmax(merits) + task = tasks[idx] + return ScheduledTask(task, time, time + TimeDelta(task.duration * u.second)) + + __all__ = ["MeritScheduler"] diff --git a/tests/robotic/scheduler/__init__.py b/tests/robotic/scheduler/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py new file mode 100644 index 000000000..ef763db3a --- /dev/null +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -0,0 +1,44 @@ +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic import Task, TaskRunner, TaskSchedule, TaskArchive +from pyobs.robotic.scheduler import DataProvider +from pyobs.robotic.scheduler.merits import ConstantMerit +from pyobs.robotic.scheduler.meritscheduler import find_next_best_task +from pyobs.robotic.scripts import Script +from pyobs.utils.time import Time + + +class TestTask(Task): + async def can_run(self, scripts: dict[str, Script] | None = None) -> bool: + return True + + @property + def can_start_late(self) -> bool: + return False + + async def run( + self, + task_runner: TaskRunner, + task_schedule: TaskSchedule | None = None, + task_archive: TaskArchive | None = None, + scripts: dict[str, Script] | None = None, + ) -> None: + pass + + def is_finished(self) -> bool: + return False + + +@pytest.mark.asyncio +async def test_next_best_task() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + tasks: list[Task] = [ + TestTask(1, "1", 100, merits=[ConstantMerit(10)]), + TestTask(1, "1", 100, merits=[ConstantMerit(5)]), + ] + best = await find_next_best_task(tasks, Time.now(), data) + + assert best.task == tasks[0] From cb12db2a6830cddd64f484d8f74325872153df09 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 14:15:29 +0100 Subject: [PATCH 22/57] added TimeWindowMerit --- pyobs/robotic/scheduler/merits/__init__.py | 1 + pyobs/robotic/scheduler/merits/timewindow.py | 40 ++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 pyobs/robotic/scheduler/merits/timewindow.py diff --git a/pyobs/robotic/scheduler/merits/__init__.py b/pyobs/robotic/scheduler/merits/__init__.py index 1a57fff41..582d01b45 100644 --- a/pyobs/robotic/scheduler/merits/__init__.py +++ b/pyobs/robotic/scheduler/merits/__init__.py @@ -2,3 +2,4 @@ from .constant import ConstantMerit from .pernight import PerNightMerit from .random import RandomMerit +from .timewindow import TimeWindowMerit diff --git a/pyobs/robotic/scheduler/merits/timewindow.py b/pyobs/robotic/scheduler/merits/timewindow.py new file mode 100644 index 000000000..a0c2ad0d3 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/timewindow.py @@ -0,0 +1,40 @@ +from __future__ import annotations +from typing import Any, TYPE_CHECKING, TypedDict +from .merit import Merit + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider + + +class TimeWindow(TypedDict): + start: str + end: int + + +class TimeWindowMerit(Merit): + """Merit function that uses time windows.""" + + def __init__(self, windows: list[TimeWindow], inverse: bool = False, **kwargs: Any): + super().__init__(**kwargs) + self._windows = windows + self._inverse = inverse + + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + # is time in any of the windows? + in_window = False + for window in self._windows: + if window["start"] <= time <= window["end"]: + in_window = True + + print(time, in_window, self._windows) + + # invert? + if not self._inverse: + return 1.0 if in_window else 0.0 + else: + return 0.0 if in_window else 1.0 + + +__all__ = ["TimeWindowMerit"] From a1ddd5c682d134021515d0d5fba5d8eb267ab2fe Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 14:15:39 +0100 Subject: [PATCH 23/57] working on merit scheduler --- pyobs/robotic/scheduler/meritscheduler.py | 40 ++++++++++++++++++++--- 1 file changed, 36 insertions(+), 4 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index 7fa5c3dca..3d7e4fb5e 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -50,24 +50,56 @@ def __init__( async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: data = DataProvider(self.observer) - task = await find_next_best_task(tasks, start, data) - yield task + + # find current best task + task, merit = find_next_best_task(tasks, start, data) + + if task is not None and merit is not None: + # check, whether there is another task within its duration that will have a higher merit + better_task, better_time = check_for_better_task(task, merit, tasks, start, data) + + # if better_task is not None and better_time is not None: + + yield create_scheduled_task(task, start) async def abort(self) -> None: self._abort.set() -async def find_next_best_task(tasks: list[Task], time: Time, data: DataProvider) -> ScheduledTask: +def create_scheduled_task(task: Task, time: Time) -> ScheduledTask: + return ScheduledTask(task, time, time + TimeDelta(task.duration * u.second)) + + +def evaluate_merits(tasks: list[Task], time: Time, data: DataProvider) -> list[float]: # evaluate all merit functions at given time merits: list[float] = [] for task in tasks: merit = float(np.prod([m(time, task, data) for m in task.merits])) merits.append(merit) + return merits + + +def find_next_best_task(tasks: list[Task], time: Time, data: DataProvider) -> tuple[Task, float]: + # evaluate all merit functions at given time + merits = evaluate_merits(tasks, time, data) # find max one idx = np.argmax(merits) task = tasks[idx] - return ScheduledTask(task, time, time + TimeDelta(task.duration * u.second)) + return task, merits[idx] + + +def check_for_better_task( + task: Task, merit: float, tasks: list[Task], time: Time, data: DataProvider, step: float = 300 +) -> tuple[Task | None, Time | None]: + t = time + TimeDelta(step * u.second) + while t < time + TimeDelta(task.duration * u.second): + merits = evaluate_merits(tasks, t, data) + for i, m in enumerate(merits): + if m > merit: + return tasks[i], t + t += TimeDelta(step * u.second) + return None, None __all__ = ["MeritScheduler"] From a0d14fcb49168d1455195d83425c8f02676bc72c Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 14:15:48 +0100 Subject: [PATCH 24/57] added tests --- .../robotic/scheduler/test_meritscheduler.py | 65 +++++++++++++++++-- 1 file changed, 61 insertions(+), 4 deletions(-) diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py index ef763db3a..5922515ef 100644 --- a/tests/robotic/scheduler/test_meritscheduler.py +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -1,11 +1,13 @@ import pytest from astroplan import Observer from astropy.coordinates import EarthLocation +from astropy.time import TimeDelta +import astropy.units as u from pyobs.robotic import Task, TaskRunner, TaskSchedule, TaskArchive from pyobs.robotic.scheduler import DataProvider -from pyobs.robotic.scheduler.merits import ConstantMerit -from pyobs.robotic.scheduler.meritscheduler import find_next_best_task +from pyobs.robotic.scheduler.merits import ConstantMerit, TimeWindowMerit +from pyobs.robotic.scheduler.meritscheduler import find_next_best_task, evaluate_merits, check_for_better_task from pyobs.robotic.scripts import Script from pyobs.utils.time import Time @@ -31,14 +33,69 @@ def is_finished(self) -> bool: return False +def test_evaluate_merits() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + tasks: list[Task] = [ + TestTask(1, "1", 100, merits=[ConstantMerit(10)]), + TestTask(1, "1", 100, merits=[ConstantMerit(5)]), + ] + merits = evaluate_merits(tasks, Time.now(), data) + + assert merits == [10.0, 5.0] + + @pytest.mark.asyncio async def test_next_best_task() -> None: observer = Observer(location=EarthLocation.of_site("SAAO")) data = DataProvider(observer) + now = Time.now() + + # two constant merits tasks: list[Task] = [ TestTask(1, "1", 100, merits=[ConstantMerit(10)]), TestTask(1, "1", 100, merits=[ConstantMerit(5)]), ] - best = await find_next_best_task(tasks, Time.now(), data) - + best, merit = find_next_best_task(tasks, now, data) assert best.task == tasks[0] + assert merit == 10.0 + + # one merit will increase and beat the first best + tasks = [ + TestTask( + 1, + "1", + 4000, + merits=[ + ConstantMerit(10), + TimeWindowMerit([{"start": now + TimeDelta(1000 * u.second), "end": now + TimeDelta(2000 * u.second)}]), + ], + ), + TestTask(1, "1", 4000, merits=[ConstantMerit(5)]), + ] + best, merit = find_next_best_task(tasks, Time.now(), data) + assert best.task == tasks[1] + assert merit == 5.0 + + +def test_check_for_better_task() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + now = Time.now() + + # at the beginning, tasks[1] will be better (5), but after 1000 seconds tasks[0] will beat it (10) + tasks: list[Task] = [ + TestTask( + 1, + "1", + 4000, + merits=[ + ConstantMerit(10), + TimeWindowMerit([{"start": now + TimeDelta(1000 * u.second), "end": now + TimeDelta(2000 * u.second)}]), + ], + ), + TestTask(1, "1", 4000, merits=[ConstantMerit(5)]), + ] + better, time = check_for_better_task(tasks[1], 5.0, tasks, now, data) + assert better == tasks[0] + assert time >= now + TimeDelta(1000 * u.second) From 164b5b930fd364ca1d56ef4d391b56b5a75c4e92 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 17:31:05 +0100 Subject: [PATCH 25/57] moved schedule_range and safety_time to module --- pyobs/modules/robotic/scheduler.py | 23 ++++++++++--- pyobs/robotic/scheduler/astroplanscheduler.py | 19 ++--------- pyobs/robotic/scheduler/meritscheduler.py | 34 +++++++++---------- pyobs/robotic/scheduler/taskscheduler.py | 4 +-- 4 files changed, 39 insertions(+), 41 deletions(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 07dbb1f79..d5c51722a 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -4,6 +4,8 @@ import logging import time from typing import Union, Any, Dict +import astropy.units as u +from astropy.time import TimeDelta from pyobs.events.taskfinished import TaskFinishedEvent from pyobs.events.taskstarted import TaskStartedEvent @@ -29,19 +31,22 @@ def __init__( schedule: Union[Dict[str, Any], TaskSchedule], trigger_on_task_started: bool = False, trigger_on_task_finished: bool = False, + schedule_range: float = 24.0, + safety_time: float = 300, **kwargs: Any, ): """Initialize a new scheduler. Args: - scheduler: Scheduler to use + scheduler: Scheduler to use. + tasks: Task archive to use. + schedule: Task schedule to use. + trigger_on_task_started: Whether to trigger a re-calculation of schedule, when task has started. + trigger_on_task_finishes: Whether to trigger a re-calculation of schedule, when task has finished. schedule_range: Number of hours to schedule into the future safety_time: If no ETA for next task to start exists (from current task, weather became good, etc), use this time in seconds to make sure that we don't schedule for a time when the scheduler is still running - twilight: astronomical or nautical - trigger_on_task_started: Whether to trigger a re-calculation of schedule, when task has started. - trigger_on_task_finishes: Whether to trigger a re-calculation of schedule, when task has finished. """ Module.__init__(self, **kwargs) @@ -56,6 +61,8 @@ def __init__( self._need_update = False self._trigger_on_task_started = trigger_on_task_started self._trigger_on_task_finished = trigger_on_task_finished + self._schedule_range = schedule_range * u.hour + self._safety_time = safety_time * u.second # time to start next schedule from self._schedule_start: Time = Time.now() @@ -208,10 +215,16 @@ async def _schedule_worker(self) -> None: # start time start_time = time.time() + # schedule start must be at least safety_time in the future + start = self._schedule_start + if start - Time.now() < self._safety_time: + start = Time.now() + TimeDelta(self._safety_time) + end = start + TimeDelta(self._schedule_range) + # schedule scheduled_tasks: list[ScheduledTask] = [] first = True - async for scheduled_task in self._scheduler.schedule(self._tasks, self._schedule_start): + async for scheduled_task in self._scheduler.schedule(self._tasks, start, end): # remember for later scheduled_tasks.append(scheduled_task) diff --git a/pyobs/robotic/scheduler/astroplanscheduler.py b/pyobs/robotic/scheduler/astroplanscheduler.py index 9e3d7830e..f3ae5230d 100644 --- a/pyobs/robotic/scheduler/astroplanscheduler.py +++ b/pyobs/robotic/scheduler/astroplanscheduler.py @@ -6,8 +6,6 @@ from collections.abc import AsyncIterator import astroplan from astroplan import ObservingBlock, FixedTarget -from astropy.time import TimeDelta -import astropy.units as u from pyobs.object import Object from .taskscheduler import TaskScheduler @@ -27,31 +25,23 @@ class AstroplanScheduler(TaskScheduler): def __init__( self, - schedule_range: int = 24, - safety_time: float = 60, twilight: str = "astronomical", **kwargs: Any, ): """Initialize a new scheduler. Args: - schedule_range: Number of hours to schedule into the future - safety_time: If no ETA for next task to start exists (from current task, weather became good, etc), use - this time in seconds to make sure that we don't schedule for a time when the scheduler is - still running twilight: astronomical or nautical """ Object.__init__(self, **kwargs) # store - self._schedule_range = schedule_range - self._safety_time = safety_time self._twilight = twilight self._lock = asyncio.Lock() self._abort: asyncio.Event = asyncio.Event() self._is_running: bool = False - async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: + async def schedule(self, tasks: list[Task], start: Time, end: Time) -> AsyncIterator[ScheduledTask]: # is lock acquired? send abort signal if self._lock.locked(): await self.abort() @@ -59,7 +49,7 @@ async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[Schedu # get lock async with self._lock: # prepare scheduler - blocks, start, end, constraints = await self._prepare_schedule(tasks, start) + blocks, start, end, constraints = await self._prepare_schedule(tasks, start, end) # schedule scheduled_blocks = await self._schedule_blocks(blocks, start, end, constraints, self._abort) @@ -75,7 +65,7 @@ async def abort(self) -> None: self._abort.set() async def _prepare_schedule( - self, tasks: list[Task], start: Time + self, tasks: list[Task], start: Time, end: Time ) -> tuple[list[ObservingBlock], Time, Time, list[Any]]: """TaskSchedule blocks.""" @@ -87,9 +77,6 @@ async def _prepare_schedule( else: raise ValueError("Unknown twilight type.") - # calculate end time - end = start + TimeDelta(self._schedule_range * u.hour) - # create blocks from tasks blocks: list[ObservingBlock] = [] for task in tasks: diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index 3d7e4fb5e..b0bc61594 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -26,44 +26,42 @@ class MeritScheduler(TaskScheduler): def __init__( self, - schedule_range: int = 24, - safety_time: float = 60, twilight: str = "astronomical", **kwargs: Any, ): """Initialize a new scheduler. Args: - schedule_range: Number of hours to schedule into the future - safety_time: If no ETA for next task to start exists (from current task, weather became good, etc), use - this time in seconds to make sure that we don't schedule for a time when the scheduler is - still running twilight: astronomical or nautical """ Object.__init__(self, **kwargs) # store - self._schedule_range = schedule_range - self._safety_time = safety_time self._twilight = twilight self._abort: asyncio.Event = asyncio.Event() - async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: + async def schedule(self, tasks: list[Task], start: Time, end: Time) -> AsyncIterator[ScheduledTask]: data = DataProvider(self.observer) - # find current best task - task, merit = find_next_best_task(tasks, start, data) + # schedule from + async for task in schedule_until(tasks, start, end, data): + yield task - if task is not None and merit is not None: - # check, whether there is another task within its duration that will have a higher merit - better_task, better_time = check_for_better_task(task, merit, tasks, start, data) + async def abort(self) -> None: + self._abort.set() - # if better_task is not None and better_time is not None: - yield create_scheduled_task(task, start) +async def schedule_until(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> AsyncIterator[ScheduledTask]: + # find current best task + task, merit = find_next_best_task(tasks, start, data) - async def abort(self) -> None: - self._abort.set() + if task is not None and merit is not None: + # check, whether there is another task within its duration that will have a higher merit + better_task, better_time = check_for_better_task(task, merit, tasks, start, data) + + # if better_task is not None and better_time is not None: + + yield create_scheduled_task(task, start) def create_scheduled_task(task: Task, time: Time) -> ScheduledTask: diff --git a/pyobs/robotic/scheduler/taskscheduler.py b/pyobs/robotic/scheduler/taskscheduler.py index 832d2194b..4d43cf8c3 100644 --- a/pyobs/robotic/scheduler/taskscheduler.py +++ b/pyobs/robotic/scheduler/taskscheduler.py @@ -17,11 +17,11 @@ class TaskScheduler(Object, metaclass=abc.ABCMeta): """Abstract base class for tasks scheduler.""" @abc.abstractmethod - async def schedule(self, tasks: list[Task], start: Time) -> AsyncIterator[ScheduledTask]: + async def schedule(self, tasks: list[Task], start: Time, end: Time) -> AsyncIterator[ScheduledTask]: # if we don't yield once here, mypy doesn't like this, see: # https://github.com/python/mypy/issues/5385 # https://github.com/python/mypy/issues/5070 - yield ScheduledTask(tasks[0], Time.now(), Time.now()) + yield ScheduledTask(tasks[0], start, end) @abc.abstractmethod async def abort(self) -> None: ... From d18860d813aacb409037bdf1e66dff4284cec0cd Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 17:40:29 +0100 Subject: [PATCH 26/57] always schedule intervals --- pyobs/robotic/scheduler/meritscheduler.py | 27 +++++++++------- .../robotic/scheduler/test_meritscheduler.py | 31 ++++++++++++------- 2 files changed, 36 insertions(+), 22 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index b0bc61594..f78803343 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -53,13 +53,14 @@ async def abort(self) -> None: async def schedule_until(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> AsyncIterator[ScheduledTask]: # find current best task - task, merit = find_next_best_task(tasks, start, data) + task, merit = find_next_best_task(tasks, start, end, data) if task is not None and merit is not None: # check, whether there is another task within its duration that will have a higher merit - better_task, better_time = check_for_better_task(task, merit, tasks, start, data) + better_task, better_time = check_for_better_task(task, merit, tasks, start, end, data) - # if better_task is not None and better_time is not None: + if better_task is not None and better_time is not None: + pass yield create_scheduled_task(task, start) @@ -68,18 +69,22 @@ def create_scheduled_task(task: Task, time: Time) -> ScheduledTask: return ScheduledTask(task, time, time + TimeDelta(task.duration * u.second)) -def evaluate_merits(tasks: list[Task], time: Time, data: DataProvider) -> list[float]: +def evaluate_merits(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> list[float]: # evaluate all merit functions at given time merits: list[float] = [] for task in tasks: - merit = float(np.prod([m(time, task, data) for m in task.merits])) + # if task is too long for the given slot, we evaluate its merits to zero + if start + TimeDelta(task.duration * u.second) > end: + merit = 0.0 + else: + merit = float(np.prod([m(start, task, data) for m in task.merits])) merits.append(merit) return merits -def find_next_best_task(tasks: list[Task], time: Time, data: DataProvider) -> tuple[Task, float]: +def find_next_best_task(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> tuple[Task, float]: # evaluate all merit functions at given time - merits = evaluate_merits(tasks, time, data) + merits = evaluate_merits(tasks, start, end, data) # find max one idx = np.argmax(merits) @@ -88,11 +93,11 @@ def find_next_best_task(tasks: list[Task], time: Time, data: DataProvider) -> tu def check_for_better_task( - task: Task, merit: float, tasks: list[Task], time: Time, data: DataProvider, step: float = 300 + task: Task, merit: float, tasks: list[Task], start: Time, end: Time, data: DataProvider, step: float = 300 ) -> tuple[Task | None, Time | None]: - t = time + TimeDelta(step * u.second) - while t < time + TimeDelta(task.duration * u.second): - merits = evaluate_merits(tasks, t, data) + t = start + TimeDelta(step * u.second) + while t < start + TimeDelta(task.duration * u.second): + merits = evaluate_merits(tasks, t, end, data) for i, m in enumerate(merits): if m > merit: return tasks[i], t diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py index 5922515ef..a07cbb24c 100644 --- a/tests/robotic/scheduler/test_meritscheduler.py +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -36,11 +36,14 @@ def is_finished(self) -> bool: def test_evaluate_merits() -> None: observer = Observer(location=EarthLocation.of_site("SAAO")) data = DataProvider(observer) + start = Time.now() + end = start + TimeDelta(5000) + tasks: list[Task] = [ TestTask(1, "1", 100, merits=[ConstantMerit(10)]), TestTask(1, "1", 100, merits=[ConstantMerit(5)]), ] - merits = evaluate_merits(tasks, Time.now(), data) + merits = evaluate_merits(tasks, start, end, data) assert merits == [10.0, 5.0] @@ -49,15 +52,16 @@ def test_evaluate_merits() -> None: async def test_next_best_task() -> None: observer = Observer(location=EarthLocation.of_site("SAAO")) data = DataProvider(observer) - now = Time.now() + start = Time.now() + end = start + TimeDelta(5000) # two constant merits tasks: list[Task] = [ TestTask(1, "1", 100, merits=[ConstantMerit(10)]), TestTask(1, "1", 100, merits=[ConstantMerit(5)]), ] - best, merit = find_next_best_task(tasks, now, data) - assert best.task == tasks[0] + best, merit = find_next_best_task(tasks, start, end, data) + assert best == tasks[0] assert merit == 10.0 # one merit will increase and beat the first best @@ -68,20 +72,23 @@ async def test_next_best_task() -> None: 4000, merits=[ ConstantMerit(10), - TimeWindowMerit([{"start": now + TimeDelta(1000 * u.second), "end": now + TimeDelta(2000 * u.second)}]), + TimeWindowMerit( + [{"start": start + TimeDelta(1000 * u.second), "end": start + TimeDelta(2000 * u.second)}] + ), ], ), TestTask(1, "1", 4000, merits=[ConstantMerit(5)]), ] - best, merit = find_next_best_task(tasks, Time.now(), data) - assert best.task == tasks[1] + best, merit = find_next_best_task(tasks, start, end, data) + assert best == tasks[1] assert merit == 5.0 def test_check_for_better_task() -> None: observer = Observer(location=EarthLocation.of_site("SAAO")) data = DataProvider(observer) - now = Time.now() + start = Time.now() + end = start + TimeDelta(5000) # at the beginning, tasks[1] will be better (5), but after 1000 seconds tasks[0] will beat it (10) tasks: list[Task] = [ @@ -91,11 +98,13 @@ def test_check_for_better_task() -> None: 4000, merits=[ ConstantMerit(10), - TimeWindowMerit([{"start": now + TimeDelta(1000 * u.second), "end": now + TimeDelta(2000 * u.second)}]), + TimeWindowMerit( + [{"start": start + TimeDelta(1000 * u.second), "end": start + TimeDelta(2000 * u.second)}] + ), ], ), TestTask(1, "1", 4000, merits=[ConstantMerit(5)]), ] - better, time = check_for_better_task(tasks[1], 5.0, tasks, now, data) + better, time = check_for_better_task(tasks[1], 5.0, tasks, start, end, data) assert better == tasks[0] - assert time >= now + TimeDelta(1000 * u.second) + assert time >= start + TimeDelta(1000 * u.second) From ec922f69dddc8ae17ea853a0c3a7dcfc7eecff74 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 17:44:03 +0100 Subject: [PATCH 27/57] schedule_in_interval --- pyobs/robotic/scheduler/meritscheduler.py | 17 +++++++++++++---- 1 file changed, 13 insertions(+), 4 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index f78803343..9872e05d8 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -44,14 +44,16 @@ async def schedule(self, tasks: list[Task], start: Time, end: Time) -> AsyncIter data = DataProvider(self.observer) # schedule from - async for task in schedule_until(tasks, start, end, data): + async for task in schedule_in_interval(tasks, start, end, data): yield task async def abort(self) -> None: self._abort.set() -async def schedule_until(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> AsyncIterator[ScheduledTask]: +async def schedule_in_interval( + tasks: list[Task], start: Time, end: Time, data: DataProvider +) -> AsyncIterator[ScheduledTask]: # find current best task task, merit = find_next_best_task(tasks, start, end, data) @@ -60,9 +62,16 @@ async def schedule_until(tasks: list[Task], start: Time, end: Time, data: DataPr better_task, better_time = check_for_better_task(task, merit, tasks, start, end, data) if better_task is not None and better_time is not None: - pass + # we found a better task, so we can just schedule it + yield create_scheduled_task(better_task, better_time) - yield create_scheduled_task(task, start) + # and find other tasks for in between + async for between_task in schedule_in_interval(tasks, start, better_time, data): + yield between_task + + else: + # this seems to be the best task for now, schedule it + yield create_scheduled_task(task, start) def create_scheduled_task(task: Task, time: Time) -> ScheduledTask: From 094185f260c11d7361b467f79ceb8679dd1740a0 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 19:42:56 +0100 Subject: [PATCH 28/57] removed MoonAvoidanceMerit --- pyobs/robotic/scheduler/merits/avoidance.py | 41 ------------------- .../robotic/scheduler/merits/moonavoidance.py | 20 --------- 2 files changed, 61 deletions(-) delete mode 100644 pyobs/robotic/scheduler/merits/avoidance.py delete mode 100644 pyobs/robotic/scheduler/merits/moonavoidance.py diff --git a/pyobs/robotic/scheduler/merits/avoidance.py b/pyobs/robotic/scheduler/merits/avoidance.py deleted file mode 100644 index bdec1edd8..000000000 --- a/pyobs/robotic/scheduler/merits/avoidance.py +++ /dev/null @@ -1,41 +0,0 @@ -from __future__ import annotations -import abc -from typing import Any, TYPE_CHECKING -from .merit import Merit - -if TYPE_CHECKING: - from astropy.coordinates import SkyCoord - from astropy.time import Time - from pyobs.robotic import Task - from ..dataprovider import DataProvider - - -class AvoidanceMerit(Merit, metaclass=abc.ABCMeta): - """Base class for merit functions that work on the distance to a celestial object, e.g. sun or moon.""" - - def __init__(self, impact: float = 1.0, stretch: float = 2.0, **kwargs: Any): - super().__init__(**kwargs) - self._impact = impact - self._stretch = stretch - - def __call__(self, time: Time, task: Task, data: DataProvider) -> float: - if task.target is None: - return 1.0 - - # target position - target = task.target.coordinates(time) - - # position to avoid - avoid = self._avoidance_position(time, data) - - # calculate distance - dist = data.get_distance(target, avoid) - - # calculate merit - return float(self._impact * dist.degree**self._stretch) - - @abc.abstractmethod - def _avoidance_position(self, time: Time, data: DataProvider) -> SkyCoord: ... - - -__all__ = ["AvoidanceMerit"] diff --git a/pyobs/robotic/scheduler/merits/moonavoidance.py b/pyobs/robotic/scheduler/merits/moonavoidance.py deleted file mode 100644 index 25995b4cd..000000000 --- a/pyobs/robotic/scheduler/merits/moonavoidance.py +++ /dev/null @@ -1,20 +0,0 @@ -from __future__ import annotations -from functools import cache -from typing import TYPE_CHECKING -from .avoidance import AvoidanceMerit - -if TYPE_CHECKING: - from astropy.coordinates import SkyCoord - from astropy.time import Time - from .. import DataProvider - - -class MoonAvoidanceMerit(AvoidanceMerit): - """Merit functions that works on the distance to the moon.""" - - @cache - def _avoidance_position(self, time: Time, data: DataProvider) -> SkyCoord: - return data.get_moon(time) - - -__all__ = ["MoonAvoidanceMerit"] From f2f50465c470f14e4c32569176b3d3b392cf3d2d Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 19:43:25 +0100 Subject: [PATCH 29/57] tests for merits --- .../scheduler/dataprovider/dataprovider.py | 4 +-- tests/robotic/scheduler/merits/__init__.py | 0 .../robotic/scheduler/merits/test_constant.py | 18 ++++++++++++ tests/robotic/scheduler/merits/test_random.py | 19 ++++++++++++ .../scheduler/merits/test_timewindow.py | 29 +++++++++++++++++++ tests/robotic/scheduler/task.py | 29 +++++++++++++++++++ .../robotic/scheduler/test_meritscheduler.py | 25 ++-------------- 7 files changed, 99 insertions(+), 25 deletions(-) create mode 100644 tests/robotic/scheduler/merits/__init__.py create mode 100644 tests/robotic/scheduler/merits/test_constant.py create mode 100644 tests/robotic/scheduler/merits/test_random.py create mode 100644 tests/robotic/scheduler/merits/test_timewindow.py create mode 100644 tests/robotic/scheduler/task.py diff --git a/pyobs/robotic/scheduler/dataprovider/dataprovider.py b/pyobs/robotic/scheduler/dataprovider/dataprovider.py index c59fc3f6e..ab9b3cac5 100644 --- a/pyobs/robotic/scheduler/dataprovider/dataprovider.py +++ b/pyobs/robotic/scheduler/dataprovider/dataprovider.py @@ -47,9 +47,9 @@ def get_task_success(self, task: Task, number: int = -1) -> TaskSuccess | None: """Return the number of successful runs for task.""" return None - @cache def get_distance(self, target: SkyCoord, avoid: SkyCoord) -> Quantity: - return target.distance_to(avoid) + # TODO: figure out something else, since SkyCoord is not hashable, so @cache doesn't work + return target.separation(avoid) @cache def get_moon(self, time: Time) -> SkyCoord: diff --git a/tests/robotic/scheduler/merits/__init__.py b/tests/robotic/scheduler/merits/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/robotic/scheduler/merits/test_constant.py b/tests/robotic/scheduler/merits/test_constant.py new file mode 100644 index 000000000..f1d6cbf37 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_constant.py @@ -0,0 +1,18 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import ConstantMerit +from ..task import TestTask +from astropy.time import Time + + +def test_constant_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + task = TestTask(1, "1", 100) + + merit = ConstantMerit(10) + assert merit(time, task, data) == 10 diff --git a/tests/robotic/scheduler/merits/test_random.py b/tests/robotic/scheduler/merits/test_random.py new file mode 100644 index 000000000..0b167c8d1 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_random.py @@ -0,0 +1,19 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import RandomMerit +from ..task import TestTask +from astropy.time import Time + + +def test_random_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + task = TestTask(1, "1", 100) + + # let somebody have fun when this fails + merit = RandomMerit() + assert -100.0 <= merit(time, task, data) <= 100.0 diff --git a/tests/robotic/scheduler/merits/test_timewindow.py b/tests/robotic/scheduler/merits/test_timewindow.py new file mode 100644 index 000000000..3e563f557 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_timewindow.py @@ -0,0 +1,29 @@ +from __future__ import annotations + +from astroplan import Observer +from astropy.coordinates import EarthLocation +from astropy.time import Time, TimeDelta +import astropy.units as u + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import TimeWindowMerit +from ..task import TestTask + + +def test_timewindow_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + time2 = time + TimeDelta(1.0 * u.hour) + min5 = TimeDelta(5.0 * u.minute) + task = TestTask(1, "1", 100) + + merit = TimeWindowMerit([{"start": time - min5, "end": time + min5}, {"start": time2 - min5, "end": time2 + min5}]) + assert merit(time, task, data) == 1.0 + assert merit(time + min5 + min5, task, data) == 0.0 + + merit = TimeWindowMerit( + [{"start": time - min5, "end": time + min5}, {"start": time2 - min5, "end": time2 + min5}], inverse=True + ) + assert merit(time, task, data) == 0.0 + assert merit(time + min5 + min5, task, data) == 1.0 diff --git a/tests/robotic/scheduler/task.py b/tests/robotic/scheduler/task.py new file mode 100644 index 000000000..1a6bec1cc --- /dev/null +++ b/tests/robotic/scheduler/task.py @@ -0,0 +1,29 @@ +from __future__ import annotations +from typing import TYPE_CHECKING + +from pyobs.robotic import Task + +if TYPE_CHECKING: + from pyobs.robotic import TaskRunner, TaskSchedule, TaskArchive + from pyobs.robotic.scripts import Script + + +class TestTask(Task): + async def can_run(self, scripts: dict[str, Script] | None = None) -> bool: + return True + + @property + def can_start_late(self) -> bool: + return False + + async def run( + self, + task_runner: TaskRunner, + task_schedule: TaskSchedule | None = None, + task_archive: TaskArchive | None = None, + scripts: dict[str, Script] | None = None, + ) -> None: + pass + + def is_finished(self) -> bool: + return False diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py index a07cbb24c..8c0b3e37d 100644 --- a/tests/robotic/scheduler/test_meritscheduler.py +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -4,33 +4,12 @@ from astropy.time import TimeDelta import astropy.units as u -from pyobs.robotic import Task, TaskRunner, TaskSchedule, TaskArchive +from pyobs.robotic import Task from pyobs.robotic.scheduler import DataProvider from pyobs.robotic.scheduler.merits import ConstantMerit, TimeWindowMerit from pyobs.robotic.scheduler.meritscheduler import find_next_best_task, evaluate_merits, check_for_better_task -from pyobs.robotic.scripts import Script from pyobs.utils.time import Time - - -class TestTask(Task): - async def can_run(self, scripts: dict[str, Script] | None = None) -> bool: - return True - - @property - def can_start_late(self) -> bool: - return False - - async def run( - self, - task_runner: TaskRunner, - task_schedule: TaskSchedule | None = None, - task_archive: TaskArchive | None = None, - scripts: dict[str, Script] | None = None, - ) -> None: - pass - - def is_finished(self) -> bool: - return False +from .task import TestTask def test_evaluate_merits() -> None: From 7b4ef15baf3d657726789dc5870ca7858a92dfe2 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 19:44:48 +0100 Subject: [PATCH 30/57] added comment --- pyobs/robotic/scheduler/meritscheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index 9872e05d8..bb38f4db0 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -65,7 +65,7 @@ async def schedule_in_interval( # we found a better task, so we can just schedule it yield create_scheduled_task(better_task, better_time) - # and find other tasks for in between + # and find other tasks for in between, new end time is better_time async for between_task in schedule_in_interval(tasks, start, better_time, data): yield between_task From 3b5f71b752c146251ceb039fe90914776e27b6c7 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 21:43:14 +0100 Subject: [PATCH 31/57] added BeforeTime and AfterTime merits and tests --- pyobs/robotic/scheduler/merits/__init__.py | 2 ++ pyobs/robotic/scheduler/merits/aftertime.py | 22 ++++++++++++++++ pyobs/robotic/scheduler/merits/beforetime.py | 22 ++++++++++++++++ .../scheduler/merits/test_aftertime.py | 25 +++++++++++++++++++ .../scheduler/merits/test_beforetime.py | 25 +++++++++++++++++++ 5 files changed, 96 insertions(+) create mode 100644 pyobs/robotic/scheduler/merits/aftertime.py create mode 100644 pyobs/robotic/scheduler/merits/beforetime.py create mode 100644 tests/robotic/scheduler/merits/test_aftertime.py create mode 100644 tests/robotic/scheduler/merits/test_beforetime.py diff --git a/pyobs/robotic/scheduler/merits/__init__.py b/pyobs/robotic/scheduler/merits/__init__.py index 582d01b45..eddb1cc32 100644 --- a/pyobs/robotic/scheduler/merits/__init__.py +++ b/pyobs/robotic/scheduler/merits/__init__.py @@ -1,4 +1,6 @@ from .merit import Merit +from .aftertime import AfterTimeMerit +from .beforetime import BeforeTimeMerit from .constant import ConstantMerit from .pernight import PerNightMerit from .random import RandomMerit diff --git a/pyobs/robotic/scheduler/merits/aftertime.py b/pyobs/robotic/scheduler/merits/aftertime.py new file mode 100644 index 000000000..fa4ffda05 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/aftertime.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from typing import Any, TYPE_CHECKING +from .merit import Merit + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider + + +class AfterTimeMerit(Merit): + """Merit function that gives 1 after a given time.""" + + def __init__(self, after: Time, **kwargs: Any): + super().__init__(**kwargs) + self._after = after + + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + return 1.0 if time >= self._after else 0.0 + + +__all__ = ["AfterTimeMerit"] diff --git a/pyobs/robotic/scheduler/merits/beforetime.py b/pyobs/robotic/scheduler/merits/beforetime.py new file mode 100644 index 000000000..f5c6ddebe --- /dev/null +++ b/pyobs/robotic/scheduler/merits/beforetime.py @@ -0,0 +1,22 @@ +from __future__ import annotations +from typing import Any, TYPE_CHECKING +from .merit import Merit + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider + + +class BeforeTimeMerit(Merit): + """Merit function that gives 1 before a given time.""" + + def __init__(self, before: Time, **kwargs: Any): + super().__init__(**kwargs) + self._before = before + + def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + return 1.0 if time <= self._before else 0.0 + + +__all__ = ["BeforeTimeMerit"] diff --git a/tests/robotic/scheduler/merits/test_aftertime.py b/tests/robotic/scheduler/merits/test_aftertime.py new file mode 100644 index 000000000..86058f06d --- /dev/null +++ b/tests/robotic/scheduler/merits/test_aftertime.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation +import astropy.units as u + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import AfterTimeMerit +from ..task import TestTask +from astropy.time import Time, TimeDelta + + +def test_aftertime_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + task = TestTask(1, "1", 100) + + merit = AfterTimeMerit(time) + assert merit(time, task, data) == 1.0 + + merit = AfterTimeMerit(time) + assert merit(time - TimeDelta(5.0 * u.second), task, data) == 0.0 + + merit = AfterTimeMerit(time) + assert merit(time + TimeDelta(5.0 * u.second), task, data) == 1.0 diff --git a/tests/robotic/scheduler/merits/test_beforetime.py b/tests/robotic/scheduler/merits/test_beforetime.py new file mode 100644 index 000000000..5a2977492 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_beforetime.py @@ -0,0 +1,25 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation +import astropy.units as u + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import BeforeTimeMerit +from ..task import TestTask +from astropy.time import Time, TimeDelta + + +def test_beforetime_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + task = TestTask(1, "1", 100) + + merit = BeforeTimeMerit(time) + assert merit(time, task, data) == 1.0 + + merit = BeforeTimeMerit(time) + assert merit(time - TimeDelta(5.0 * u.second), task, data) == 1.0 + + merit = BeforeTimeMerit(time) + assert merit(time + TimeDelta(5.0 * u.second), task, data) == 0.0 From 8b72edaa410045a97393c9c164986cf344ab6c82 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 2 Nov 2025 22:17:24 +0100 Subject: [PATCH 32/57] filling holes seems to work --- pyobs/robotic/scheduler/merits/timewindow.py | 2 - pyobs/robotic/scheduler/meritscheduler.py | 10 +++-- .../robotic/scheduler/test_meritscheduler.py | 39 ++++++++++++++++++- 3 files changed, 43 insertions(+), 8 deletions(-) diff --git a/pyobs/robotic/scheduler/merits/timewindow.py b/pyobs/robotic/scheduler/merits/timewindow.py index a0c2ad0d3..a9efcd7c7 100644 --- a/pyobs/robotic/scheduler/merits/timewindow.py +++ b/pyobs/robotic/scheduler/merits/timewindow.py @@ -28,8 +28,6 @@ def __call__(self, time: Time, task: Task, data: DataProvider) -> float: if window["start"] <= time <= window["end"]: in_window = True - print(time, in_window, self._windows) - # invert? if not self._inverse: return 1.0 if in_window else 0.0 diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index bb38f4db0..dcdde6426 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -52,14 +52,14 @@ async def abort(self) -> None: async def schedule_in_interval( - tasks: list[Task], start: Time, end: Time, data: DataProvider + tasks: list[Task], start: Time, end: Time, data: DataProvider, step: float = 300 ) -> AsyncIterator[ScheduledTask]: # find current best task task, merit = find_next_best_task(tasks, start, end, data) if task is not None and merit is not None: # check, whether there is another task within its duration that will have a higher merit - better_task, better_time = check_for_better_task(task, merit, tasks, start, end, data) + better_task, better_time = check_for_better_task(task, merit, tasks, start, end, data, step=step) if better_task is not None and better_time is not None: # we found a better task, so we can just schedule it @@ -91,14 +91,16 @@ def evaluate_merits(tasks: list[Task], start: Time, end: Time, data: DataProvide return merits -def find_next_best_task(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> tuple[Task, float]: +def find_next_best_task(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> tuple[Task | None, float]: # evaluate all merit functions at given time merits = evaluate_merits(tasks, start, end, data) # find max one idx = np.argmax(merits) task = tasks[idx] - return task, merits[idx] + + # if merit is zero, return nothing + return None if merits[idx] == 0.0 else task, merits[idx] def check_for_better_task( diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py index 8c0b3e37d..8cefb04a0 100644 --- a/tests/robotic/scheduler/test_meritscheduler.py +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -7,7 +7,12 @@ from pyobs.robotic import Task from pyobs.robotic.scheduler import DataProvider from pyobs.robotic.scheduler.merits import ConstantMerit, TimeWindowMerit -from pyobs.robotic.scheduler.meritscheduler import find_next_best_task, evaluate_merits, check_for_better_task +from pyobs.robotic.scheduler.meritscheduler import ( + find_next_best_task, + evaluate_merits, + check_for_better_task, + schedule_in_interval, +) from pyobs.utils.time import Time from .task import TestTask @@ -16,7 +21,7 @@ def test_evaluate_merits() -> None: observer = Observer(location=EarthLocation.of_site("SAAO")) data = DataProvider(observer) start = Time.now() - end = start + TimeDelta(5000) + end = start + TimeDelta(5000 * u.second) tasks: list[Task] = [ TestTask(1, "1", 100, merits=[ConstantMerit(10)]), @@ -87,3 +92,33 @@ def test_check_for_better_task() -> None: better, time = check_for_better_task(tasks[1], 5.0, tasks, start, end, data) assert better == tasks[0] assert time >= start + TimeDelta(1000 * u.second) + + +@pytest.mark.asyncio +async def test_fill_for_better_task() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + start = Time("2025-11-01 00:00:00") + end = start + TimeDelta(3600 * u.second) + after_start = start + TimeDelta(600 * u.second) + after_end = start + TimeDelta(900 * u.second) + + # at the beginning, tasks 2 will be better (5), but after 600 seconds tasks 1 will beat it (10) + # then the scheduler tries to fill the hole and should schedule task 3 first + # task 2 will only be scheduled afterwards + tasks: list[Task] = [ + TestTask(1, "1", 1800, merits=[ConstantMerit(10), TimeWindowMerit([{"start": after_start, "end": after_end}])]), + TestTask(2, "2", 1800, merits=[ConstantMerit(5)]), + TestTask(3, "3", 300, merits=[ConstantMerit(1)]), + ] + + # note that task 1 will not be scheduled exactly at its start time + schedule = schedule_in_interval(tasks, start, end, data, step=10) + scheduled_task = await anext(schedule) + assert scheduled_task.task.id == 1 + assert scheduled_task.start >= after_start + + # task 3 fills the hole before task 1 + scheduled_task = await anext(schedule) + assert scheduled_task.task.id == 3 + assert scheduled_task.start == start From a7072be4647734add7263e70bafea1ed1a60ddbd Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 3 Nov 2025 17:47:41 +0100 Subject: [PATCH 33/57] scheduler can now just postpone a better task until the other one is finished, if its merit doesn't decrease then --- pyobs/robotic/scheduler/meritscheduler.py | 53 +++++++++++++------ pyobs/robotic/task.py | 6 ++- .../robotic/scheduler/test_meritscheduler.py | 40 +++++++++++++- 3 files changed, 80 insertions(+), 19 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index dcdde6426..4e6e3953b 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -59,15 +59,22 @@ async def schedule_in_interval( if task is not None and merit is not None: # check, whether there is another task within its duration that will have a higher merit - better_task, better_time = check_for_better_task(task, merit, tasks, start, end, data, step=step) - - if better_task is not None and better_time is not None: - # we found a better task, so we can just schedule it - yield create_scheduled_task(better_task, better_time) - - # and find other tasks for in between, new end time is better_time - async for between_task in schedule_in_interval(tasks, start, better_time, data): - yield between_task + better_task, better_time, better_merit = check_for_better_task(task, merit, tasks, start, end, data, step=step) + + if better_task is not None and better_time is not None and better_merit is not None: + # can we maybe postpone the better task to run both? + postpone_time = can_postpone_task(task, better_task, better_merit, start, end, data) + if postpone_time is not None: + # yes, we can! schedule both + yield create_scheduled_task(task, start) + yield create_scheduled_task(better_task, postpone_time) + else: + # just schedule better_task + yield create_scheduled_task(better_task, better_time) + + # and find other tasks for in between, new end time is better_time + async for between_task in schedule_in_interval(tasks, start, better_time, data): + yield between_task else: # this seems to be the best task for now, schedule it @@ -75,7 +82,7 @@ async def schedule_in_interval( def create_scheduled_task(task: Task, time: Time) -> ScheduledTask: - return ScheduledTask(task, time, time + TimeDelta(task.duration * u.second)) + return ScheduledTask(task, time, time + TimeDelta(task.duration)) def evaluate_merits(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> list[float]: @@ -83,7 +90,7 @@ def evaluate_merits(tasks: list[Task], start: Time, end: Time, data: DataProvide merits: list[float] = [] for task in tasks: # if task is too long for the given slot, we evaluate its merits to zero - if start + TimeDelta(task.duration * u.second) > end: + if start + TimeDelta(task.duration) > end: merit = 0.0 else: merit = float(np.prod([m(start, task, data) for m in task.merits])) @@ -105,15 +112,31 @@ def find_next_best_task(tasks: list[Task], start: Time, end: Time, data: DataPro def check_for_better_task( task: Task, merit: float, tasks: list[Task], start: Time, end: Time, data: DataProvider, step: float = 300 -) -> tuple[Task | None, Time | None]: +) -> tuple[Task | None, Time | None, float | None]: t = start + TimeDelta(step * u.second) - while t < start + TimeDelta(task.duration * u.second): + while t < start + TimeDelta(task.duration): merits = evaluate_merits(tasks, t, end, data) for i, m in enumerate(merits): if m > merit: - return tasks[i], t + return tasks[i], t, m t += TimeDelta(step * u.second) - return None, None + return None, None, None + + +def can_postpone_task( + task: Task, better_task: Task, better_merit: float, start: Time, end: Time, data: DataProvider +) -> Time | None: + # new start time of better_task would be after the execution of task + better_start: Time = start + TimeDelta(task.duration) + + # evaluate merit of better_task at new start time + merit = evaluate_merits([better_task], better_start, end, data)[0] + + # if it got better, return it, otherwise return Nones + if merit >= better_merit: + return better_start + else: + return None __all__ = ["MeritScheduler"] diff --git a/pyobs/robotic/task.py b/pyobs/robotic/task.py index a14fb7cb7..7c87d97ef 100644 --- a/pyobs/robotic/task.py +++ b/pyobs/robotic/task.py @@ -1,6 +1,8 @@ from __future__ import annotations from abc import ABCMeta, abstractmethod from typing import TYPE_CHECKING, Any +from astropy.units import Quantity +import astropy.units as u from pyobs.object import Object from pyobs.robotic.scheduler.targets import Target @@ -50,9 +52,9 @@ def name(self) -> str: return self._name @property - def duration(self) -> float: + def duration(self) -> Quantity: """Returns estimated duration of task in seconds.""" - return self._duration + return self._duration * u.second @property def priority(self) -> float: diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py index 8cefb04a0..a63f00ac4 100644 --- a/tests/robotic/scheduler/test_meritscheduler.py +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -89,9 +89,10 @@ def test_check_for_better_task() -> None: ), TestTask(1, "1", 4000, merits=[ConstantMerit(5)]), ] - better, time = check_for_better_task(tasks[1], 5.0, tasks, start, end, data) + better, time, merit = check_for_better_task(tasks[1], 5.0, tasks, start, end, data) assert better == tasks[0] assert time >= start + TimeDelta(1000 * u.second) + assert merit == 10.0 @pytest.mark.asyncio @@ -105,7 +106,7 @@ async def test_fill_for_better_task() -> None: # at the beginning, tasks 2 will be better (5), but after 600 seconds tasks 1 will beat it (10) # then the scheduler tries to fill the hole and should schedule task 3 first - # task 2 will only be scheduled afterwards + # task 2 will only be scheduled afterward tasks: list[Task] = [ TestTask(1, "1", 1800, merits=[ConstantMerit(10), TimeWindowMerit([{"start": after_start, "end": after_end}])]), TestTask(2, "2", 1800, merits=[ConstantMerit(5)]), @@ -122,3 +123,38 @@ async def test_fill_for_better_task() -> None: scheduled_task = await anext(schedule) assert scheduled_task.task.id == 3 assert scheduled_task.start == start + + +@pytest.mark.asyncio +async def test_postpone_task() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + start = Time("2025-11-01 00:00:00") + end = start + TimeDelta(3600 * u.second) + after_start = start + TimeDelta(600 * u.second) + after_end = start + TimeDelta(1800 * u.second) + + # at the beginning, tasks 2 will be better (5), but after 600 seconds tasks 1 will beat it (10) + # in contrast to test_fill_for_better_task the after_end time here is longer, so the scheduler should just + # postpone task 1 by a bit, then schedule task 2 afterward + tasks: list[Task] = [ + TestTask(1, "1", 1800, merits=[ConstantMerit(10), TimeWindowMerit([{"start": after_start, "end": after_end}])]), + TestTask(2, "2", 1800, merits=[ConstantMerit(5)]), + TestTask(3, "3", 300, merits=[ConstantMerit(1)]), + ] + schedule = schedule_in_interval(tasks, start, end, data, step=10) + + # task 2 will be scheduled exactly at its start time + scheduled_task = await anext(schedule) + assert scheduled_task.task.id == 2 + assert scheduled_task.start == start + + # task 1 after that + scheduled_task = await anext(schedule) + assert scheduled_task.task.id == 1 + assert scheduled_task.start >= after_start + + # let's try this again with a sorted list + schedule2 = sorted([i async for i in schedule_in_interval(tasks, start, end, data, step=10)], key=lambda x: x.start) + assert schedule2[0].task.id == 2 + assert schedule2[1].task.id == 1 From ab632c1f14789e4507aa6ce187da2fcc53b7a293 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 3 Nov 2025 17:48:51 +0100 Subject: [PATCH 34/57] un-outcommented code --- pyobs/modules/robotic/scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index d5c51722a..515eafab9 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -236,11 +236,11 @@ async def _schedule_worker(self) -> None: if first: log.info("Finished calculating next task:") self._log_scheduled_task([scheduled_task]) - # await self._schedule.clear_schedule(self._schedule_start) + await self._schedule.clear_schedule(self._schedule_start) first = False # submit it - # await self._schedule.add_schedule([scheduled_task]) + await self._schedule.add_schedule([scheduled_task]) if self._need_update: log.info("Not using scheduler results, since update was requested.") From 443a05875c040d3ccc67ef8eb4e3a3e42c851883 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 3 Nov 2025 18:00:45 +0100 Subject: [PATCH 35/57] evaluate merits 1 if no merits exist --- pyobs/robotic/scheduler/meritscheduler.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index 4e6e3953b..a9e6a3e27 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -93,7 +93,11 @@ def evaluate_merits(tasks: list[Task], start: Time, end: Time, data: DataProvide if start + TimeDelta(task.duration) > end: merit = 0.0 else: - merit = float(np.prod([m(start, task, data) for m in task.merits])) + # if no merits are present, we evaluate it to 1 + if len(task.merits) == 0: + merit = 1.0 + else: + merit = float(np.prod([m(start, task, data) for m in task.merits])) merits.append(merit) return merits From f11ee54b3dddb6a455d9d46c76335cb1364bece2 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 3 Nov 2025 18:15:52 +0100 Subject: [PATCH 36/57] renamed schedule_in_interval to schedule_first_in_interval --- pyobs/robotic/scheduler/meritscheduler.py | 6 +++--- tests/robotic/scheduler/test_meritscheduler.py | 10 ++++++---- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index a9e6a3e27..eb0cb2e83 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -44,14 +44,14 @@ async def schedule(self, tasks: list[Task], start: Time, end: Time) -> AsyncIter data = DataProvider(self.observer) # schedule from - async for task in schedule_in_interval(tasks, start, end, data): + async for task in schedule_first_in_interval(tasks, start, end, data): yield task async def abort(self) -> None: self._abort.set() -async def schedule_in_interval( +async def schedule_first_in_interval( tasks: list[Task], start: Time, end: Time, data: DataProvider, step: float = 300 ) -> AsyncIterator[ScheduledTask]: # find current best task @@ -73,7 +73,7 @@ async def schedule_in_interval( yield create_scheduled_task(better_task, better_time) # and find other tasks for in between, new end time is better_time - async for between_task in schedule_in_interval(tasks, start, better_time, data): + async for between_task in schedule_first_in_interval(tasks, start, better_time, data): yield between_task else: diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py index a63f00ac4..beb6cf4c5 100644 --- a/tests/robotic/scheduler/test_meritscheduler.py +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -11,7 +11,7 @@ find_next_best_task, evaluate_merits, check_for_better_task, - schedule_in_interval, + schedule_first_in_interval, ) from pyobs.utils.time import Time from .task import TestTask @@ -114,7 +114,7 @@ async def test_fill_for_better_task() -> None: ] # note that task 1 will not be scheduled exactly at its start time - schedule = schedule_in_interval(tasks, start, end, data, step=10) + schedule = schedule_first_in_interval(tasks, start, end, data, step=10) scheduled_task = await anext(schedule) assert scheduled_task.task.id == 1 assert scheduled_task.start >= after_start @@ -142,7 +142,7 @@ async def test_postpone_task() -> None: TestTask(2, "2", 1800, merits=[ConstantMerit(5)]), TestTask(3, "3", 300, merits=[ConstantMerit(1)]), ] - schedule = schedule_in_interval(tasks, start, end, data, step=10) + schedule = schedule_first_in_interval(tasks, start, end, data, step=10) # task 2 will be scheduled exactly at its start time scheduled_task = await anext(schedule) @@ -155,6 +155,8 @@ async def test_postpone_task() -> None: assert scheduled_task.start >= after_start # let's try this again with a sorted list - schedule2 = sorted([i async for i in schedule_in_interval(tasks, start, end, data, step=10)], key=lambda x: x.start) + schedule2 = sorted( + [i async for i in schedule_first_in_interval(tasks, start, end, data, step=10)], key=lambda x: x.start + ) assert schedule2[0].task.id == 2 assert schedule2[1].task.id == 1 From 51f217a244938f1606ecd032194b9e7cbcf87848 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 3 Nov 2025 18:23:51 +0100 Subject: [PATCH 37/57] can fill time window with schedule --- pyobs/robotic/scheduler/meritscheduler.py | 28 +++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index eb0cb2e83..78216e584 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -44,13 +44,37 @@ async def schedule(self, tasks: list[Task], start: Time, end: Time) -> AsyncIter data = DataProvider(self.observer) # schedule from - async for task in schedule_first_in_interval(tasks, start, end, data): + async for task in schedule_in_interval(tasks, start, end, data): yield task async def abort(self) -> None: self._abort.set() +async def schedule_in_interval( + tasks: list[Task], start: Time, end: Time, data: DataProvider, step: float = 300 +) -> AsyncIterator[ScheduledTask]: + time = start + while time < end: + latest_end = start + + # schedule first in this interval, could be one or two + async for scheduled_task in schedule_first_in_interval(tasks, time, end, data): + # yield it to caller + yield scheduled_task + + # check end + if scheduled_task.end > latest_end: + latest_end = scheduled_task.end + + if latest_end == start: + # no task found, so we're finished + return + + # set new time + time = latest_end + + async def schedule_first_in_interval( tasks: list[Task], start: Time, end: Time, data: DataProvider, step: float = 300 ) -> AsyncIterator[ScheduledTask]: @@ -73,7 +97,7 @@ async def schedule_first_in_interval( yield create_scheduled_task(better_task, better_time) # and find other tasks for in between, new end time is better_time - async for between_task in schedule_first_in_interval(tasks, start, better_time, data): + async for between_task in schedule_in_interval(tasks, start, better_time, data): yield between_task else: From 7fa032634d0775f2774e36c2e04746301261cbc2 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 3 Nov 2025 19:22:34 +0100 Subject: [PATCH 38/57] renamed evaluate_merits to evaluate_constraints_and_merits --- pyobs/robotic/scheduler/meritscheduler.py | 8 ++++---- tests/robotic/scheduler/test_meritscheduler.py | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index 78216e584..fe362092c 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -109,7 +109,7 @@ def create_scheduled_task(task: Task, time: Time) -> ScheduledTask: return ScheduledTask(task, time, time + TimeDelta(task.duration)) -def evaluate_merits(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> list[float]: +def evaluate_constraints_and_merits(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> list[float]: # evaluate all merit functions at given time merits: list[float] = [] for task in tasks: @@ -128,7 +128,7 @@ def evaluate_merits(tasks: list[Task], start: Time, end: Time, data: DataProvide def find_next_best_task(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> tuple[Task | None, float]: # evaluate all merit functions at given time - merits = evaluate_merits(tasks, start, end, data) + merits = evaluate_constraints_and_merits(tasks, start, end, data) # find max one idx = np.argmax(merits) @@ -143,7 +143,7 @@ def check_for_better_task( ) -> tuple[Task | None, Time | None, float | None]: t = start + TimeDelta(step * u.second) while t < start + TimeDelta(task.duration): - merits = evaluate_merits(tasks, t, end, data) + merits = evaluate_constraints_and_merits(tasks, t, end, data) for i, m in enumerate(merits): if m > merit: return tasks[i], t, m @@ -158,7 +158,7 @@ def can_postpone_task( better_start: Time = start + TimeDelta(task.duration) # evaluate merit of better_task at new start time - merit = evaluate_merits([better_task], better_start, end, data)[0] + merit = evaluate_constraints_and_merits([better_task], better_start, end, data)[0] # if it got better, return it, otherwise return Nones if merit >= better_merit: diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py index beb6cf4c5..1f15b510a 100644 --- a/tests/robotic/scheduler/test_meritscheduler.py +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -9,7 +9,7 @@ from pyobs.robotic.scheduler.merits import ConstantMerit, TimeWindowMerit from pyobs.robotic.scheduler.meritscheduler import ( find_next_best_task, - evaluate_merits, + evaluate_constraints_and_merits, check_for_better_task, schedule_first_in_interval, ) @@ -27,7 +27,7 @@ def test_evaluate_merits() -> None: TestTask(1, "1", 100, merits=[ConstantMerit(10)]), TestTask(1, "1", 100, merits=[ConstantMerit(5)]), ] - merits = evaluate_merits(tasks, start, end, data) + merits = evaluate_constraints_and_merits(tasks, start, end, data) assert merits == [10.0, 5.0] From 0ff2ffe2de6be7d44cd4be12beb948205b0b9682 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 3 Nov 2025 20:10:44 +0100 Subject: [PATCH 39/57] implemented Constraints and tests --- .../robotic/scheduler/constraints/__init__.py | 1 - .../constraints/airmassconstraint.py | 13 ++++++-- .../scheduler/constraints/constraint.py | 17 +++++++--- .../scheduler/constraints/moonconstraint.py | 27 --------------- .../constraints/moonilluminationconstraint.py | 14 ++++++-- .../constraints/moonseparationconstraint.py | 17 ++++++++-- .../constraints/solarelevationconstraint.py | 13 ++++++-- .../scheduler/constraints/timeconstraint.py | 13 ++++++-- .../scheduler/dataprovider/dataprovider.py | 4 +++ .../robotic/scheduler/constraints/__init__.py | 0 .../scheduler/constraints/test_airmass.py | 33 +++++++++++++++++++ .../constraints/test_moonillumination.py | 27 +++++++++++++++ .../constraints/test_moonseparation.py | 30 +++++++++++++++++ .../constraints/test_solarelevation.py | 28 ++++++++++++++++ .../scheduler/constraints/test_time.py | 30 +++++++++++++++++ 15 files changed, 222 insertions(+), 45 deletions(-) delete mode 100644 pyobs/robotic/scheduler/constraints/moonconstraint.py create mode 100644 tests/robotic/scheduler/constraints/__init__.py create mode 100644 tests/robotic/scheduler/constraints/test_airmass.py create mode 100644 tests/robotic/scheduler/constraints/test_moonillumination.py create mode 100644 tests/robotic/scheduler/constraints/test_moonseparation.py create mode 100644 tests/robotic/scheduler/constraints/test_solarelevation.py create mode 100644 tests/robotic/scheduler/constraints/test_time.py diff --git a/pyobs/robotic/scheduler/constraints/__init__.py b/pyobs/robotic/scheduler/constraints/__init__.py index 419653bed..27dd55c8c 100644 --- a/pyobs/robotic/scheduler/constraints/__init__.py +++ b/pyobs/robotic/scheduler/constraints/__init__.py @@ -1,6 +1,5 @@ from .airmassconstraint import AirmassConstraint from .constraint import Constraint -from .moonconstraint import MoonCondition, MoonConstraint from .moonilluminationconstraint import MoonIlluminationConstraint from .moonseparationconstraint import MoonSeparationConstraint from .solarelevationconstraint import SolarElevationConstraint diff --git a/pyobs/robotic/scheduler/constraints/airmassconstraint.py b/pyobs/robotic/scheduler/constraints/airmassconstraint.py index ebd5d35eb..a10d07b7b 100644 --- a/pyobs/robotic/scheduler/constraints/airmassconstraint.py +++ b/pyobs/robotic/scheduler/constraints/airmassconstraint.py @@ -1,8 +1,13 @@ -from typing import Any +from __future__ import annotations +from typing import Any, TYPE_CHECKING import astroplan - from .constraint import Constraint +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task + class AirmassConstraint(Constraint): """Airmass constraint.""" @@ -14,5 +19,9 @@ def __init__(self, max_airmass: float, **kwargs: Any): def to_astroplan(self) -> astroplan.AirmassConstraint: return astroplan.AirmassConstraint(max=self.max_airmass) + def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: + airmass = float(data.observer.altaz(time, task.target).secz) + return airmass <= self.max_airmass + __all__ = ["AirmassConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/constraint.py b/pyobs/robotic/scheduler/constraints/constraint.py index 632342ef0..5b80e2123 100644 --- a/pyobs/robotic/scheduler/constraints/constraint.py +++ b/pyobs/robotic/scheduler/constraints/constraint.py @@ -1,10 +1,17 @@ -import abc -from abc import ABCMeta +from __future__ import annotations +from abc import ABCMeta, abstractmethod import astroplan +from typing import TYPE_CHECKING -from pyobs.object import Object +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task -class Constraint(Object, metaclass=ABCMeta): - @abc.abstractmethod +class Constraint(metaclass=ABCMeta): + @abstractmethod def to_astroplan(self) -> astroplan.Constraint: ... + + @abstractmethod + def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: ... diff --git a/pyobs/robotic/scheduler/constraints/moonconstraint.py b/pyobs/robotic/scheduler/constraints/moonconstraint.py deleted file mode 100644 index 3ceb3f22e..000000000 --- a/pyobs/robotic/scheduler/constraints/moonconstraint.py +++ /dev/null @@ -1,27 +0,0 @@ -from enum import StrEnum # type: ignore -from typing import Any -import astroplan - -from .constraint import Constraint - - -class MoonCondition(StrEnum): # type: ignore - """Represents a moon condition.""" - - DARK = "dark" - GREY = "grey" - BRIGHT = "bright" - - -class MoonConstraint(Constraint): - """Airmass constraint.""" - - def __init__(self, moon: MoonCondition, **kwargs: Any): - super().__init__(**kwargs) - self.moon = moon - - def to_astroplan(self) -> astroplan.Constraint: - raise NotImplementedError() - - -__all__ = ["MoonCondition", "MoonConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py b/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py index c378fbc32..cde19b635 100644 --- a/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py +++ b/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py @@ -1,9 +1,13 @@ -from typing import Any - +from __future__ import annotations +from typing import Any, TYPE_CHECKING import astroplan - from .constraint import Constraint +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task + class MoonIlluminationConstraint(Constraint): """Moon illumination constraint.""" @@ -15,5 +19,9 @@ def __init__(self, max_phase: float, **kwargs: Any): def to_astroplan(self) -> astroplan.MoonIlluminationConstraint: return astroplan.MoonIlluminationConstraint(max=self.max_phase) + def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: + moon_illumination = float(data.observer.moon_illumination(time)) + return moon_illumination <= self.max_phase + __all__ = ["MoonIlluminationConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py index 30d852c38..f108aab91 100644 --- a/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py +++ b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py @@ -1,9 +1,15 @@ -from typing import Any +from __future__ import annotations +from typing import Any, TYPE_CHECKING import astroplan import astropy.units as u - +import astropy.coordinates from .constraint import Constraint +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task + class MoonSeparationConstraint(Constraint): """Moon separation constraint.""" @@ -15,5 +21,12 @@ def __init__(self, min_distance: float, **kwargs: Any): def to_astroplan(self) -> astroplan.MoonSeparationConstraint: return astroplan.MoonSeparationConstraint(min=self.min_distance * u.deg) + def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: + target = task.target + if target is None: + return True + moon_separation = astropy.coordinates.get_body("moon", time).separation(target.coordinates(time)) + return float(moon_separation.degree) >= self.min_distance + __all__ = ["MoonSeparationConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py b/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py index ddb882bfa..3dda5b702 100644 --- a/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py +++ b/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py @@ -1,9 +1,14 @@ -from typing import Any +from __future__ import annotations +from typing import Any, TYPE_CHECKING import astroplan import astropy.units as u - from .constraint import Constraint +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task + class SolarElevationConstraint(Constraint): """Solar elevation constraint.""" @@ -15,5 +20,9 @@ def __init__(self, max_elevation: float, **kwargs: Any): def to_astroplan(self) -> astroplan.AtNightConstraint: return astroplan.AtNightConstraint(max_solar_altitude=self.max_elevation * u.deg) + def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: + sun = data.observer.sun_altaz(time) + return float(sun.alt.degree) <= self.max_elevation + __all__ = ["SolarElevationConstraint"] diff --git a/pyobs/robotic/scheduler/constraints/timeconstraint.py b/pyobs/robotic/scheduler/constraints/timeconstraint.py index 49637c974..4956e0d62 100644 --- a/pyobs/robotic/scheduler/constraints/timeconstraint.py +++ b/pyobs/robotic/scheduler/constraints/timeconstraint.py @@ -1,9 +1,13 @@ -from typing import Any +from __future__ import annotations +from typing import Any, TYPE_CHECKING import astroplan - -from pyobs.utils.time import Time from .constraint import Constraint +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task + class TimeConstraint(Constraint): """Time constraint.""" @@ -16,5 +20,8 @@ def __init__(self, start: Time, end: Time, **kwargs: Any): def to_astroplan(self) -> astroplan.TimeConstraint: return astroplan.TimeConstraint(min=self.start, max=self.end) + def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: + return bool(self.start <= time <= self.end) + __all__ = ["TimeConstraint"] diff --git a/pyobs/robotic/scheduler/dataprovider/dataprovider.py b/pyobs/robotic/scheduler/dataprovider/dataprovider.py index ab9b3cac5..db7c27a45 100644 --- a/pyobs/robotic/scheduler/dataprovider/dataprovider.py +++ b/pyobs/robotic/scheduler/dataprovider/dataprovider.py @@ -55,5 +55,9 @@ def get_distance(self, target: SkyCoord, avoid: SkyCoord) -> Quantity: def get_moon(self, time: Time) -> SkyCoord: return astropy.coordinates.get_body("moon", time, self.observer.location) + @cache + def get_sun(self, time: Time) -> SkyCoord: + return astropy.coordinates.get_body("sun", time, self.observer.location) + __all__ = ["DataProvider"] diff --git a/tests/robotic/scheduler/constraints/__init__.py b/tests/robotic/scheduler/constraints/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/robotic/scheduler/constraints/test_airmass.py b/tests/robotic/scheduler/constraints/test_airmass.py new file mode 100644 index 000000000..c98bfd016 --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_airmass.py @@ -0,0 +1,33 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation, SkyCoord + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import AirmassConstraint +from pyobs.robotic.scheduler.targets import SiderealTarget +from ..task import TestTask +from astropy.time import Time + + +def test_airmass_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = TestTask(1, "Canopus", 100) + task._target = SiderealTarget("Canopus", SkyCoord("6h23m58.2s -52d41m27.2s", frame="icrs")) + + constraint = AirmassConstraint(1.3) + + time = Time("2025-11-03T17:00:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-03T19:00:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-03T21:00:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-03T23:00:00", scale="utc") + assert constraint(time, task, data) is True + + time = Time("2025-11-04T01:00:00", scale="utc") + assert constraint(time, task, data) is True diff --git a/tests/robotic/scheduler/constraints/test_moonillumination.py b/tests/robotic/scheduler/constraints/test_moonillumination.py new file mode 100644 index 000000000..1a38bf74d --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_moonillumination.py @@ -0,0 +1,27 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import MoonIlluminationConstraint +from ..task import TestTask +from astropy.time import Time + + +def test_moonillumination_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = TestTask(1, "1", 100) + constraint = MoonIlluminationConstraint(0.5) + + time = Time("2025-11-05T13:00:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-12T05:00:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-12T08:00:00", scale="utc") + assert constraint(time, task, data) is True + + time = Time("2025-11-13T0:00:00", scale="utc") + assert constraint(time, task, data) is True diff --git a/tests/robotic/scheduler/constraints/test_moonseparation.py b/tests/robotic/scheduler/constraints/test_moonseparation.py new file mode 100644 index 000000000..7fcf2141e --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_moonseparation.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation, SkyCoord + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import MoonSeparationConstraint +from pyobs.robotic.scheduler.targets import SiderealTarget +from ..task import TestTask +from astropy.time import Time + + +def test_moonseparation_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = TestTask(1, "Antares", 100) + task._target = SiderealTarget("Antares", SkyCoord("16h29m22.94s -26d25m53.0s", frame="icrs")) + + constraint = MoonSeparationConstraint(20.0) + + time = Time("2025-11-18T15:00:00", scale="utc") + assert constraint(time, task, data) is True + + time = Time("2025-11-19T11:00:00", scale="utc") + assert constraint(time, task, data) is True + + time = Time("2025-11-19T17:00:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-19T23:00:00", scale="utc") + assert constraint(time, task, data) is False diff --git a/tests/robotic/scheduler/constraints/test_solarelevation.py b/tests/robotic/scheduler/constraints/test_solarelevation.py new file mode 100644 index 000000000..3496bae5b --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_solarelevation.py @@ -0,0 +1,28 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import SolarElevationConstraint +from ..task import TestTask +from astropy.time import Time + + +def test_solarelevation_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = TestTask(1, "1", 100) + + constraint = SolarElevationConstraint(-18.0) + + time = Time("2025-11-03T16:00:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-03T18:30:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-03T18:35:00", scale="utc") + assert constraint(time, task, data) is True + + time = Time("2025-11-03T20:00:00", scale="utc") + assert constraint(time, task, data) is True diff --git a/tests/robotic/scheduler/constraints/test_time.py b/tests/robotic/scheduler/constraints/test_time.py new file mode 100644 index 000000000..7397e8675 --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_time.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from astroplan import Observer +from astropy.coordinates import EarthLocation, SkyCoord + +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import TimeConstraint +from pyobs.robotic.scheduler.targets import SiderealTarget +from ..task import TestTask +from astropy.time import Time + + +def test_time_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = TestTask(1, "Canopus", 100) + task._target = SiderealTarget("Canopus", SkyCoord("6h23m58.2s -52d41m27.2s", frame="icrs")) + + constraint = TimeConstraint(Time("2025-11-03T20:00:00", scale="utc"), Time("2025-11-03T23:00:00", scale="utc")) + + time = Time("2025-11-03T19:30:00", scale="utc") + assert constraint(time, task, data) is False + + time = Time("2025-11-03T20:30:00", scale="utc") + assert constraint(time, task, data) is True + + time = Time("2025-11-03T22:30:00", scale="utc") + assert constraint(time, task, data) is True + + time = Time("2025-11-03T23:30:00", scale="utc") + assert constraint(time, task, data) is False From 464861e37365f191d15def674fd681bbb5cd6153 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 3 Nov 2025 20:19:34 +0100 Subject: [PATCH 40/57] evaluate constraints --- pyobs/robotic/scheduler/meritscheduler.py | 67 +++++++++++++++++++++-- 1 file changed, 61 insertions(+), 6 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index fe362092c..d20bfb2d2 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -109,20 +109,75 @@ def create_scheduled_task(task: Task, time: Time) -> ScheduledTask: return ScheduledTask(task, time, time + TimeDelta(task.duration)) +async def evaluate_constraints(task: Task, start: Time, end: Time, data: DataProvider) -> bool: + """Loops all constraints. If any evaluates to False, return False. Otherwise, return True. + + Args: + task: Task to evaluate. + start: Start time. + end: End time. + data: Data provider. + + Returns: + True if all constraints evaluate True, False otherwise. + """ + for constraint in task.constraints: + if not constraint(start, task, data): + return False + return True + + +def evaluate_merits(task: Task, start: Time, end: Time, data: DataProvider) -> float: + """Loop all merits, evaluate them and multiply the results. If any evaluates to 0, abort and return 0. + + Args: + task: Task to evaluate. + start: Start time. + end: End time. + data: Data provider. + + Returns: + The final merit for this task. + """ + + # loop merits + total_merit = 1.0 + for merit in task.merits: + total_merit *= merit(start, task, data) + + # if zero, abort and return it + if total_merit == 0.0: + return 0.0 + + # done + return total_merit + + def evaluate_constraints_and_merits(tasks: list[Task], start: Time, end: Time, data: DataProvider) -> list[float]: # evaluate all merit functions at given time merits: list[float] = [] for task in tasks: - # if task is too long for the given slot, we evaluate its merits to zero - if start + TimeDelta(task.duration) > end: + if len(task.merits) == 0: + # no merits? evaluate to 1 + merit = 1.0 + + elif start + TimeDelta(task.duration) > end: + # if task is too long for the given slot, we evaluate its merits to zero merit = 0.0 + else: - # if no merits are present, we evaluate it to 1 - if len(task.merits) == 0: - merit = 1.0 + # evaluate constraints + if evaluate_constraints(task, start, end, data): + # now we can evaluate the merits + merit = evaluate_merits(task, start, end, data) + else: - merit = float(np.prod([m(start, task, data) for m in task.merits])) + # some constraint failed... + merit = 0.0 + + # store it merits.append(merit) + return merits From a16c6db22263ef174880a36a4e7f4da499e44532 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 4 Nov 2025 18:16:08 +0100 Subject: [PATCH 41/57] create merits, constraints, target, and duration in LcoTask c'tor --- pyobs/robotic/lco/task.py | 99 ++++++++++++++++++++++++------ pyobs/robotic/lco/taskarchive.py | 100 +++++++------------------------ 2 files changed, 101 insertions(+), 98 deletions(-) diff --git a/pyobs/robotic/lco/task.py b/pyobs/robotic/lco/task.py index a9e582571..b858591b0 100644 --- a/pyobs/robotic/lco/task.py +++ b/pyobs/robotic/lco/task.py @@ -1,8 +1,20 @@ from __future__ import annotations import logging -from typing import Union, Dict, Tuple, Optional, List, Any, TYPE_CHECKING, cast +from typing import Any, TYPE_CHECKING, cast +from astropy.coordinates import SkyCoord +import astropy.units as u from pyobs.object import get_object +from pyobs.robotic.scheduler.constraints import ( + TimeConstraint, + Constraint, + AirmassConstraint, + MoonSeparationConstraint, + MoonIlluminationConstraint, + SolarElevationConstraint, +) +from pyobs.robotic.scheduler.merits import Merit +from pyobs.robotic.scheduler.targets import Target, SiderealTarget from pyobs.robotic.scripts import Script from pyobs.robotic.task import Task from pyobs.utils.logger import DuplicateFilter @@ -25,13 +37,13 @@ class ConfigStatus: def __init__(self, state: str = "ATTEMPTED", reason: str = ""): """Initializes a new Status with an ATTEMPTED.""" self.start: Time = Time.now() - self.end: Optional[Time] = None + self.end: Time | None = None self.state: str = state self.reason: str = reason self.time_completed: float = 0.0 def finish( - self, state: Optional[str] = None, reason: Optional[str] = None, time_completed: float = 0.0 + self, state: str | None = None, reason: str | None = None, time_completed: float = 0.0 ) -> "ConfigStatus": """Finish this status with the given values and the current time. @@ -48,7 +60,7 @@ def finish( self.end = Time.now() return self - def to_json(self) -> Dict[str, Any]: + def to_json(self) -> dict[str, Any]: """Convert status to JSON for sending to portal.""" return { "state": self.state, @@ -79,12 +91,20 @@ def __init__( config: Configuration for task """ + req = config["request"] if id is None: - id = config["request"]["id"] + id = req["id"] if name is None: - name = config["request"]["id"] + name = req["id"] if duration is None: - duration = float(config["request"]["duration"]) + duration = float(req["duration"]) + + if "constraints" not in kwargs: + kwargs["constraints"] = self._create_constraints(req) + if "merits" not in kwargs: + kwargs["merits"] = self._create_merits(req) + if "target" not in kwargs: + kwargs["target"] = self._create_target(req) Task.__init__( self, @@ -96,7 +116,50 @@ def __init__( ) # store stuff - self.cur_script: Optional[Script] = None + self.cur_script: Script | None = None + + @staticmethod + def _create_constraints(req: dict[str, Any]) -> list[Constraint]: + # get constraints + constraints: list[Constraint] = [] + + # time constraints? + if "windows" in req: + constraints.extend([TimeConstraint(Time(wnd["start"]), Time(wnd["end"])) for wnd in req["windows"]]) + + # take first config + cfg = req["configurations"][0] + + # constraints + if "constraints" in cfg: + c = cfg["constraints"] + if "max_airmass" in c and c["max_airmass"] is not None: + constraints.append(AirmassConstraint(c["max_airmass"])) + if "min_lunar_distance" in c and c["min_lunar_distance"] is not None: + constraints.append(MoonSeparationConstraint(c["min_lunar_distance"])) + if "max_lunar_phase" in c and c["max_lunar_phase"] is not None: + constraints.append(MoonIlluminationConstraint(c["max_lunar_phase"])) + # if max lunar phase <= 0.4 (which would be DARK), we also enforce the sun to be <-18 degrees + if c["max_lunar_phase"] <= 0.4: + constraints.append(SolarElevationConstraint(-18.0)) + + return constraints + + def _create_merits(self, req: dict[str, Any]) -> list[Merit]: + # take merits from first config + cfg = req["configurations"][0] + return [self.get_object(m) for m in cfg["merits"]] if "merits" in cfg else [] + + def _create_target(self, req: dict[str, Any]) -> Target | None: + # target + target = req["configurations"][0]["target"] + if "ra" in target and "dec" in target: + coord = SkyCoord(target["ra"] * u.deg, target["dec"] * u.deg, frame=target["type"].lower()) + name = target["name"] + return SiderealTarget(name, coord) + else: + log.warning("Unsupported coordinate type.") + return None def __eq__(self, other: object) -> bool: """Compares to tasks.""" @@ -126,7 +189,7 @@ def can_start_late(self) -> bool: """ return self.observation_type == "DIRECT" - def _get_config_script(self, config: Dict[str, Any], scripts: Optional[Dict[str, Script]] = None) -> Script: + def _get_config_script(self, config: dict[str, Any], scripts: dict[str, Script] | None = None) -> Script: """Get config script for given configuration. Args: @@ -153,7 +216,7 @@ def _get_config_script(self, config: Dict[str, Any], scripts: Optional[Dict[str, observer=self.observer, ) - async def can_run(self, scripts: Optional[Dict[str, Script]] = None) -> bool: + async def can_run(self, scripts: dict[str, Script] | None = None) -> bool: """Checks, whether this task could run now. Returns: @@ -183,9 +246,9 @@ async def can_run(self, scripts: Optional[Dict[str, Script]] = None) -> bool: async def run( self, task_runner: TaskRunner, - task_schedule: Optional[TaskSchedule] = None, - task_archive: Optional[TaskArchive] = None, - scripts: Optional[Dict[str, Script]] = None, + task_schedule: TaskSchedule | None = None, + task_archive: TaskArchive | None = None, + scripts: dict[str, Script] | None = None, ) -> None: """Run a task""" from pyobs.robotic.lco import LcoTaskSchedule @@ -194,7 +257,7 @@ async def run( req = self.config["request"] # loop configurations - status: Optional[ConfigStatus] + status: ConfigStatus | None for config in req["configurations"]: # send an ATTEMPTED status if isinstance(task_schedule, LcoTaskSchedule): @@ -230,9 +293,9 @@ async def _run_script( self, script: Script, task_runner: TaskRunner, - task_schedule: Optional[TaskSchedule] = None, - task_archive: Optional[TaskArchive] = None, - ) -> Union[ConfigStatus, None]: + task_schedule: TaskSchedule | None = None, + task_archive: TaskArchive | None = None, + ) -> ConfigStatus | None: """Run a config Args: @@ -285,7 +348,7 @@ def is_finished(self) -> bool: else: return False - def get_fits_headers(self, namespaces: Optional[List[str]] = None) -> Dict[str, Tuple[Any, str]]: + def get_fits_headers(self, namespaces: list[str] | None = None) -> dict[str, tuple[Any, str]]: """Returns FITS header for the current status of this module. Args: diff --git a/pyobs/robotic/lco/taskarchive.py b/pyobs/robotic/lco/taskarchive.py index 0402cd5ac..64e7fc5e7 100644 --- a/pyobs/robotic/lco/taskarchive.py +++ b/pyobs/robotic/lco/taskarchive.py @@ -1,24 +1,11 @@ -import copy import logging from typing import Dict, Optional, Any -from astropy.coordinates import SkyCoord -import astropy.units as u from pyobs.utils.time import Time from pyobs.robotic.taskarchive import TaskArchive from .portal import Portal from .task import LcoTask from .. import Task -from ..scheduler.constraints import ( - Constraint, - AirmassConstraint, - MoonIlluminationConstraint, - MoonSeparationConstraint, - TimeConstraint, - SolarElevationConstraint, -) -from ..scheduler.merits import Merit -from ..scheduler.targets import SiderealTarget log = logging.getLogger(__name__) @@ -70,25 +57,6 @@ async def last_changed(self) -> Optional[Time]: # even in case of errors, return last time return self._last_changed - @staticmethod - def create_constraints_for_configuration(config: dict[str, Any]) -> list[Constraint]: - # time constraints - constraints: list[Constraint] = [] - - # constraints - c = config["constraints"] - if "max_airmass" in c and c["max_airmass"] is not None: - constraints.append(AirmassConstraint(c["max_airmass"])) - if "min_lunar_distance" in c and c["min_lunar_distance"] is not None: - constraints.append(MoonSeparationConstraint(c["min_lunar_distance"])) - if "max_lunar_phase" in c and c["max_lunar_phase"] is not None: - constraints.append(MoonIlluminationConstraint(c["max_lunar_phase"])) - # if max lunar phase <= 0.4 (which would be DARK), we also enforce the sun to be <-18 degrees - if c["max_lunar_phase"] <= 0.4: - constraints.append(SolarElevationConstraint(-18.0)) - - return constraints - async def get_schedulable_tasks(self) -> list[Task]: """Returns list of schedulable tasks. @@ -119,54 +87,26 @@ async def get_schedulable_tasks(self) -> list[Task]: if req["state"] != "PENDING": continue - # duration - duration = req["duration"] * u.second - - # get constraints - time_constraints = [TimeConstraint(Time(wnd["start"]), Time(wnd["end"])) for wnd in req["windows"]] - - # loop configs - for cfg in req["configurations"]: - # get instrument and check, whether we schedule it - instrument = cfg["instrument_type"] - if instrument.lower() not in self._instrument_type: - continue - - # target - t = cfg["target"] - if "ra" in t and "dec" in t: - target = SkyCoord(t["ra"] * u.deg, t["dec"] * u.deg, frame=t["type"].lower()) - target_name = t["name"] - else: - log.warning("Unsupported coordinate type.") - continue - - # constraints - constraints = self.create_constraints_for_configuration(cfg) + time_constraints - - # merits - merits: list[Merit] = [self.get_object(m) for m in cfg["merits"]] if "merits" in cfg else [] - - # priority is base_priority times duration in minutes - # priority = base_priority * duration.value / 60. - priority = base_priority - - # create request with only this config - new_req = copy.deepcopy(req) - new_req["configurations"] = [cfg] - - # create task - task = LcoTask( - id=req["id"], - name=group["name"], - duration=duration, - priority=priority, - constraints=constraints, - merits=merits, - config={"request": new_req}, - target=SiderealTarget(target_name, target), - ) - tasks.append(task) + # just take first config and ignore the rest + cfg = req["configurations"][0] + + # get instrument and check, whether we schedule it + instrument = cfg["instrument_type"] + if instrument.lower() not in self._instrument_type: + continue + + # priority is base_priority times duration in minutes + # priority = base_priority * duration.value / 60. + priority = base_priority + + # create task + task = LcoTask( + id=req["id"], + name=group["name"], + priority=priority, + config={"request": req}, + ) + tasks.append(task) # return blocks return tasks From e4adcbdd84b8de411ea0b2897b42db75e36c922b Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 4 Nov 2025 18:16:18 +0100 Subject: [PATCH 42/57] fixed airmass<0 --- pyobs/robotic/scheduler/constraints/airmassconstraint.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyobs/robotic/scheduler/constraints/airmassconstraint.py b/pyobs/robotic/scheduler/constraints/airmassconstraint.py index a10d07b7b..834490554 100644 --- a/pyobs/robotic/scheduler/constraints/airmassconstraint.py +++ b/pyobs/robotic/scheduler/constraints/airmassconstraint.py @@ -21,7 +21,7 @@ def to_astroplan(self) -> astroplan.AirmassConstraint: def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: airmass = float(data.observer.altaz(time, task.target).secz) - return airmass <= self.max_airmass + return 0.0 < airmass <= self.max_airmass __all__ = ["AirmassConstraint"] From d213cb3f9b587849c76a67b72b005b9ab9c0d774 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 4 Nov 2025 18:16:35 +0100 Subject: [PATCH 43/57] fixed order of constraint/merit evaluation --- pyobs/robotic/scheduler/meritscheduler.py | 30 +++++++++++------------ 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index d20bfb2d2..af8d02237 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -109,7 +109,7 @@ def create_scheduled_task(task: Task, time: Time) -> ScheduledTask: return ScheduledTask(task, time, time + TimeDelta(task.duration)) -async def evaluate_constraints(task: Task, start: Time, end: Time, data: DataProvider) -> bool: +def evaluate_constraints(task: Task, start: Time, end: Time, data: DataProvider) -> bool: """Loops all constraints. If any evaluates to False, return False. Otherwise, return True. Args: @@ -157,23 +157,23 @@ def evaluate_constraints_and_merits(tasks: list[Task], start: Time, end: Time, d # evaluate all merit functions at given time merits: list[float] = [] for task in tasks: - if len(task.merits) == 0: - # no merits? evaluate to 1 - merit = 1.0 - - elif start + TimeDelta(task.duration) > end: - # if task is too long for the given slot, we evaluate its merits to zero - merit = 0.0 + # evaluate constraints + if evaluate_constraints(task, start, end, data): + # now we can evaluate the merits + if len(task.merits) == 0: + # no merits? evaluate to 1 + merit = 1.0 + + elif start + TimeDelta(task.duration) > end: + # if task is too long for the given slot, we evaluate its merits to zero + merit = 0.0 - else: - # evaluate constraints - if evaluate_constraints(task, start, end, data): - # now we can evaluate the merits + else: merit = evaluate_merits(task, start, end, data) - else: - # some constraint failed... - merit = 0.0 + else: + # some constraint failed... + merit = 0.0 # store it merits.append(merit) From e3b3ea75d49380a1b7e6292957c0fc2bbdfd86f3 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 4 Nov 2025 18:18:48 +0100 Subject: [PATCH 44/57] added origin_mismatch="ignore" to remove warnings --- .../robotic/scheduler/constraints/moonseparationconstraint.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py index f108aab91..588e4de28 100644 --- a/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py +++ b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py @@ -25,7 +25,9 @@ def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: target = task.target if target is None: return True - moon_separation = astropy.coordinates.get_body("moon", time).separation(target.coordinates(time)) + moon_separation = astropy.coordinates.get_body("moon", time).separation( + target.coordinates(time), origin_mismatch="ignore" + ) return float(moon_separation.degree) >= self.min_distance From d90c12e5ab911bc090015fa4e511e577f82420f5 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 4 Nov 2025 19:32:57 +0100 Subject: [PATCH 45/57] schedule into the future, even if currently no task was found --- pyobs/robotic/scheduler/meritscheduler.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py index af8d02237..ed16b7c16 100644 --- a/pyobs/robotic/scheduler/meritscheduler.py +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -68,11 +68,11 @@ async def schedule_in_interval( latest_end = scheduled_task.end if latest_end == start: - # no task found, so we're finished - return - - # set new time - time = latest_end + # no task found, try 5 minutes later + time += TimeDelta(step * u.second) + else: + # set new time from scheduled task + time = latest_end async def schedule_first_in_interval( From 775e5bec463eeeb304f7d955dd3391c65deb94ae Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 6 Jan 2026 13:15:54 +0100 Subject: [PATCH 46/57] added __eq__ for comparison --- pyobs/robotic/task.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/pyobs/robotic/task.py b/pyobs/robotic/task.py index 7c87d97ef..5425c1657 100644 --- a/pyobs/robotic/task.py +++ b/pyobs/robotic/task.py @@ -151,5 +151,8 @@ def end(self) -> Time: """End time for task""" return self._end + def __eq__(self, other: ScheduledTask) -> bool: + return self.task.id == other.task.id and self.start == other.start and self.end == other.end + __all__ = ["Task", "ScheduledTask"] From b31ea8f961cf552375dda50684c9a52de9a4d72c Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 6 Jan 2026 13:21:43 +0100 Subject: [PATCH 47/57] added comparison methods for ScheduledTask --- pyobs/robotic/lco/taskschedule.py | 2 +- pyobs/robotic/task.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/pyobs/robotic/lco/taskschedule.py b/pyobs/robotic/lco/taskschedule.py index 86dd9ff5d..6d1e51b5d 100644 --- a/pyobs/robotic/lco/taskschedule.py +++ b/pyobs/robotic/lco/taskschedule.py @@ -174,7 +174,7 @@ async def update_now(self, force: bool = False) -> None: return # any changes? - if sorted(scheduled_tasks, key=lambda x: x.start) != sorted(self._scheduled_tasks, key=lambda x: x.start): + if sorted(scheduled_tasks) != sorted(self._scheduled_tasks): log.info("Task list changed, found %d task(s) to run.", len(scheduled_tasks)) for scheduled_task in sorted(scheduled_tasks, key=lambda x: x.start): log.info( diff --git a/pyobs/robotic/task.py b/pyobs/robotic/task.py index 5425c1657..846d5fba4 100644 --- a/pyobs/robotic/task.py +++ b/pyobs/robotic/task.py @@ -154,5 +154,20 @@ def end(self) -> Time: def __eq__(self, other: ScheduledTask) -> bool: return self.task.id == other.task.id and self.start == other.start and self.end == other.end + def __ne__(self, other: ScheduledTask) -> bool: + return self.task.id != other.task.id or self.start != other.start or self.end != other.end + + def __lt__(self, other: ScheduledTask) -> bool: + return self.start < other.start + + def __gt__(self, other: ScheduledTask) -> bool: + return self.start > other.start + + def __le__(self, other: ScheduledTask) -> bool: + return self.start <= other.start + + def __ge__(self, other: ScheduledTask) -> bool: + return self.start >= other.start + __all__ = ["Task", "ScheduledTask"] From 470df9cc949468024655f5aac8e5c367825f75ce Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 6 Jan 2026 14:38:20 +0100 Subject: [PATCH 48/57] fixed bug that removed is now a list of ints --- pyobs/modules/robotic/scheduler.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 515eafab9..b2ca6c9bd 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -149,11 +149,11 @@ async def _update_schedule(self) -> None: if len(removed) == 1: log.info( "Found 1 removed block with ID %d. Last task ID was %s, current is %s.", - removed[0].id, + removed[0], str(self._last_task_id), str(self._current_task_id), ) - if len(removed) == 1 and len(added) == 0 and removed[0].id == self._last_task_id: + if len(removed) == 1 and len(added) == 0 and removed[0] == self._last_task_id: # no need to re-schedule log.info("Only one removed block detected, which is the one currently running.") self._need_update = False From 977c69b32639bc11bb15ce1e496e384856290c6b Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Tue, 6 Jan 2026 14:38:36 +0100 Subject: [PATCH 49/57] make safety_time a second again... --- pyobs/modules/robotic/scheduler.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index b2ca6c9bd..e487a66c9 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -251,7 +251,7 @@ async def _schedule_worker(self) -> None: self._log_scheduled_task(scheduled_tasks) # set new safety_time as duration + 20% - self._safety_time = (time.time() - start_time) * 1.2 + self._safety_time = (time.time() - start_time) * 1.2 * u.second except: log.exception("Something went wrong") From 7d3bd75da7fcc3b8463496623c4f97532c96d939 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Wed, 7 Jan 2026 18:50:04 +0100 Subject: [PATCH 50/57] removed "* u.second", since duration already returns seconds --- pyobs/modules/robotic/mastermind.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyobs/modules/robotic/mastermind.py b/pyobs/modules/robotic/mastermind.py index 71419dab1..7f13a9a5c 100644 --- a/pyobs/modules/robotic/mastermind.py +++ b/pyobs/modules/robotic/mastermind.py @@ -126,7 +126,7 @@ async def _run_thread(self) -> None: self._task = task # ETA - eta = now + self._task.duration * u.second + eta = now + self._task.duration # send event await self.comm.send_event(TaskStartedEvent(name=self._task.name, id=self._task.id, eta=eta)) From 682be6803a200d107d9a5d3e31614095a7593d3d Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Sun, 11 Jan 2026 14:14:52 +0100 Subject: [PATCH 51/57] get_task now returns a ScheduledTask --- pyobs/modules/robotic/mastermind.py | 12 ++++++------ pyobs/robotic/lco/taskschedule.py | 10 +++++----- pyobs/robotic/taskschedule.py | 6 +++--- 3 files changed, 14 insertions(+), 14 deletions(-) diff --git a/pyobs/modules/robotic/mastermind.py b/pyobs/modules/robotic/mastermind.py index 7f13a9a5c..a52be4ed3 100644 --- a/pyobs/modules/robotic/mastermind.py +++ b/pyobs/modules/robotic/mastermind.py @@ -7,7 +7,7 @@ from pyobs.events.taskfinished import TaskFinishedEvent from pyobs.events.taskstarted import TaskStartedEvent from pyobs.interfaces import IFitsHeaderBefore, IAutonomous -from pyobs.robotic.task import Task +from pyobs.robotic.task import Task, ScheduledTask from pyobs.utils.time import Time from pyobs.robotic import TaskRunner, TaskSchedule @@ -96,15 +96,15 @@ async def _run_thread(self) -> None: now = Time.now() # find task that we want to run now - task: Task | None = await self._task_schedule.get_task(now) - if task is None or not await self._task_runner.can_run(task): + scheduled_task: ScheduledTask | None = await self._task_schedule.get_task(now) + if scheduled_task is None or not await self._task_runner.can_run(scheduled_task.task): # no task found await asyncio.sleep(10) continue # starting too late? - if not task.can_start_late: - late_start = now - task.start + if not scheduled_task.task.can_start_late: + late_start = now - scheduled_task.start if late_start > self._allowed_late_start * u.second: # only warn once if first_late_start_warning: @@ -123,7 +123,7 @@ async def _run_thread(self) -> None: first_late_start_warning = True # task is definitely not None here - self._task = task + self._task = scheduled_task.task # ETA eta = now + self._task.duration diff --git a/pyobs/robotic/lco/taskschedule.py b/pyobs/robotic/lco/taskschedule.py index 2eeb65238..091825afc 100644 --- a/pyobs/robotic/lco/taskschedule.py +++ b/pyobs/robotic/lco/taskschedule.py @@ -7,7 +7,7 @@ from astropy.time import TimeDelta import astropy.units as u -from pyobs.robotic.task import Task, ScheduledTask +from pyobs.robotic.task import ScheduledTask from pyobs.utils.time import Time from pyobs.robotic.taskschedule import TaskSchedule from .portal import Portal @@ -259,21 +259,21 @@ async def _get_schedule(self, start_before: Time, end_after: Time) -> list[Sched # finished return scheduled_tasks - async def get_task(self, time: Time) -> Task | None: - """Returns the active task at the given time. + async def get_task(self, time: Time) -> ScheduledTask | None: + """Returns the active scheduled task at the given time. Args: time: Time to return task for. Returns: - Task at the given time or None. + Scheduled task at the given time. """ # loop all tasks for scheduled_task in self._scheduled_tasks: # running now? if scheduled_task.start <= time < scheduled_task.end and not scheduled_task.task.is_finished(): - return scheduled_task.task + return scheduled_task # nothing found return None diff --git a/pyobs/robotic/taskschedule.py b/pyobs/robotic/taskschedule.py index fbbf747ef..533a63026 100644 --- a/pyobs/robotic/taskschedule.py +++ b/pyobs/robotic/taskschedule.py @@ -47,14 +47,14 @@ async def get_schedule(self) -> list[ScheduledTask]: ... @abstractmethod - async def get_task(self, time: Time) -> Task | None: - """Returns the active task at the given time. + async def get_task(self, time: Time) -> ScheduledTask | None: + """Returns the active scheduled task at the given time. Args: time: Time to return task for. Returns: - Task at the given time. + Scheduled task at the given time. """ ... From c0a21f881f3198c66fdf3f470609e5d4cac5bd0c Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 12 Jan 2026 13:22:37 +0100 Subject: [PATCH 52/57] added pydantic and astropydantic --- pyproject.toml | 2 + uv.lock | 150 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 152 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index abc7d511d..0cf8ca445 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,6 +24,8 @@ dependencies = [ "aiohttp>=3.11.18,<4", "colour>=0.1.5,<0.2", "dacite>=1.9.2", + "pydantic>=2.12.5", + "astropydantic>=0.0.5", ] [project.optional-dependencies] diff --git a/uv.lock b/uv.lock index 0a5a73a57..7a9ff3c07 100644 --- a/uv.lock +++ b/uv.lock @@ -165,6 +165,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/b3/6b4067be973ae96ba0d615946e314c5ae35f9f993eca561b356540bb0c2b/alabaster-1.0.0-py3-none-any.whl", hash = "sha256:fc6786402dc3fcb2de3cabd5fe455a2db534b371124f1f21de8731783dec828b", size = 13929, upload-time = "2024-07-26T18:15:02.05Z" }, ] +[[package]] +name = "annotated-types" +version = "0.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, +] + [[package]] name = "anyio" version = "4.9.0" @@ -264,6 +273,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/09/7f/ba056c9d42d8c495c36f33ec72a893de9df860ecfcba3e7640db874a7411/astropy_iers_data-0.2025.7.7.0.39.39-py3-none-any.whl", hash = "sha256:770221844e56c4cafba8c65659438bde5c96041d246dc4eb13cbc63890e33299", size = 1956686, upload-time = "2025-07-07T00:40:19.368Z" }, ] +[[package]] +name = "astropydantic" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "astropy" }, + { name = "pydantic" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/14/62/9deebe98af11f697e29684b9b7eddf984a5eac04ce9971eca43f72ed4c05/astropydantic-0.0.5.tar.gz", hash = "sha256:e0376f9d6c93b31d3fe7498ad6e92971fd4fbca5c66cdb2913954eec198f6fa6", size = 7995, upload-time = "2025-11-13T18:19:52.101Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6c/11/d54594b2be5ea68a1c8c67567278f15a190bcbde743eef041d49dd39c542/astropydantic-0.0.5-py3-none-any.whl", hash = "sha256:2bcb2d4ee96fa0032332737625480099c9ece8754465c2ed9f9db8f11e3da38c", size = 8093, upload-time = "2025-11-13T18:19:50.822Z" }, +] + [[package]] name = "astroquery" version = "0.4.10" @@ -2046,6 +2068,118 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/3d/f9441a0d798bf2b1e645adc3265e55706aead1255ccdad3856dbdcffec14/pycryptodome-3.23.0-cp37-abi3-win_arm64.whl", hash = "sha256:11eeeb6917903876f134b56ba11abe95c0b0fd5e3330def218083c7d98bbcb3c", size = 1703675, upload-time = "2025-05-17T17:21:13.146Z" }, ] +[[package]] +name = "pydantic" +version = "2.12.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/69/44/36f1a6e523abc58ae5f928898e4aca2e0ea509b5aa6f6f392a5d882be928/pydantic-2.12.5.tar.gz", hash = "sha256:4d351024c75c0f085a9febbb665ce8c0c6ec5d30e903bdb6394b7ede26aebb49", size = 821591, upload-time = "2025-11-26T15:11:46.471Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5a/87/b70ad306ebb6f9b585f114d0ac2137d792b48be34d732d60e597c2f8465a/pydantic-2.12.5-py3-none-any.whl", hash = "sha256:e561593fccf61e8a20fc46dfc2dfe075b8be7d0188df33f221ad1f0139180f9d", size = 463580, upload-time = "2025-11-26T15:11:44.605Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.41.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/71/70/23b021c950c2addd24ec408e9ab05d59b035b39d97cdc1130e1bce647bb6/pydantic_core-2.41.5.tar.gz", hash = "sha256:08daa51ea16ad373ffd5e7606252cc32f07bc72b28284b6bc9c6df804816476e", size = 460952, upload-time = "2025-11-04T13:43:49.098Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e8/72/74a989dd9f2084b3d9530b0915fdda64ac48831c30dbf7c72a41a5232db8/pydantic_core-2.41.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a3a52f6156e73e7ccb0f8cced536adccb7042be67cb45f9562e12b319c119da6", size = 2105873, upload-time = "2025-11-04T13:39:31.373Z" }, + { url = "https://files.pythonhosted.org/packages/12/44/37e403fd9455708b3b942949e1d7febc02167662bf1a7da5b78ee1ea2842/pydantic_core-2.41.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:7f3bf998340c6d4b0c9a2f02d6a400e51f123b59565d74dc60d252ce888c260b", size = 1899826, upload-time = "2025-11-04T13:39:32.897Z" }, + { url = "https://files.pythonhosted.org/packages/33/7f/1d5cab3ccf44c1935a359d51a8a2a9e1a654b744b5e7f80d41b88d501eec/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:378bec5c66998815d224c9ca994f1e14c0c21cb95d2f52b6021cc0b2a58f2a5a", size = 1917869, upload-time = "2025-11-04T13:39:34.469Z" }, + { url = "https://files.pythonhosted.org/packages/6e/6a/30d94a9674a7fe4f4744052ed6c5e083424510be1e93da5bc47569d11810/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b576130c69225432866fe2f4a469a85a54ade141d96fd396dffcf607b558f8", size = 2063890, upload-time = "2025-11-04T13:39:36.053Z" }, + { url = "https://files.pythonhosted.org/packages/50/be/76e5d46203fcb2750e542f32e6c371ffa9b8ad17364cf94bb0818dbfb50c/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6cb58b9c66f7e4179a2d5e0f849c48eff5c1fca560994d6eb6543abf955a149e", size = 2229740, upload-time = "2025-11-04T13:39:37.753Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ee/fed784df0144793489f87db310a6bbf8118d7b630ed07aa180d6067e653a/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:88942d3a3dff3afc8288c21e565e476fc278902ae4d6d134f1eeda118cc830b1", size = 2350021, upload-time = "2025-11-04T13:39:40.94Z" }, + { url = "https://files.pythonhosted.org/packages/c8/be/8fed28dd0a180dca19e72c233cbf58efa36df055e5b9d90d64fd1740b828/pydantic_core-2.41.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f31d95a179f8d64d90f6831d71fa93290893a33148d890ba15de25642c5d075b", size = 2066378, upload-time = "2025-11-04T13:39:42.523Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3b/698cf8ae1d536a010e05121b4958b1257f0b5522085e335360e53a6b1c8b/pydantic_core-2.41.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c1df3d34aced70add6f867a8cf413e299177e0c22660cc767218373d0779487b", size = 2175761, upload-time = "2025-11-04T13:39:44.553Z" }, + { url = "https://files.pythonhosted.org/packages/b8/ba/15d537423939553116dea94ce02f9c31be0fa9d0b806d427e0308ec17145/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:4009935984bd36bd2c774e13f9a09563ce8de4abaa7226f5108262fa3e637284", size = 2146303, upload-time = "2025-11-04T13:39:46.238Z" }, + { url = "https://files.pythonhosted.org/packages/58/7f/0de669bf37d206723795f9c90c82966726a2ab06c336deba4735b55af431/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:34a64bc3441dc1213096a20fe27e8e128bd3ff89921706e83c0b1ac971276594", size = 2340355, upload-time = "2025-11-04T13:39:48.002Z" }, + { url = "https://files.pythonhosted.org/packages/e5/de/e7482c435b83d7e3c3ee5ee4451f6e8973cff0eb6007d2872ce6383f6398/pydantic_core-2.41.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:c9e19dd6e28fdcaa5a1de679aec4141f691023916427ef9bae8584f9c2fb3b0e", size = 2319875, upload-time = "2025-11-04T13:39:49.705Z" }, + { url = "https://files.pythonhosted.org/packages/fe/e6/8c9e81bb6dd7560e33b9053351c29f30c8194b72f2d6932888581f503482/pydantic_core-2.41.5-cp311-cp311-win32.whl", hash = "sha256:2c010c6ded393148374c0f6f0bf89d206bf3217f201faa0635dcd56bd1520f6b", size = 1987549, upload-time = "2025-11-04T13:39:51.842Z" }, + { url = "https://files.pythonhosted.org/packages/11/66/f14d1d978ea94d1bc21fc98fcf570f9542fe55bfcc40269d4e1a21c19bf7/pydantic_core-2.41.5-cp311-cp311-win_amd64.whl", hash = "sha256:76ee27c6e9c7f16f47db7a94157112a2f3a00e958bc626e2f4ee8bec5c328fbe", size = 2011305, upload-time = "2025-11-04T13:39:53.485Z" }, + { url = "https://files.pythonhosted.org/packages/56/d8/0e271434e8efd03186c5386671328154ee349ff0354d83c74f5caaf096ed/pydantic_core-2.41.5-cp311-cp311-win_arm64.whl", hash = "sha256:4bc36bbc0b7584de96561184ad7f012478987882ebf9f9c389b23f432ea3d90f", size = 1972902, upload-time = "2025-11-04T13:39:56.488Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5d/5f6c63eebb5afee93bcaae4ce9a898f3373ca23df3ccaef086d0233a35a7/pydantic_core-2.41.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:f41a7489d32336dbf2199c8c0a215390a751c5b014c2c1c5366e817202e9cdf7", size = 2110990, upload-time = "2025-11-04T13:39:58.079Z" }, + { url = "https://files.pythonhosted.org/packages/aa/32/9c2e8ccb57c01111e0fd091f236c7b371c1bccea0fa85247ac55b1e2b6b6/pydantic_core-2.41.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:070259a8818988b9a84a449a2a7337c7f430a22acc0859c6b110aa7212a6d9c0", size = 1896003, upload-time = "2025-11-04T13:39:59.956Z" }, + { url = "https://files.pythonhosted.org/packages/68/b8/a01b53cb0e59139fbc9e4fda3e9724ede8de279097179be4ff31f1abb65a/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e96cea19e34778f8d59fe40775a7a574d95816eb150850a85a7a4c8f4b94ac69", size = 1919200, upload-time = "2025-11-04T13:40:02.241Z" }, + { url = "https://files.pythonhosted.org/packages/38/de/8c36b5198a29bdaade07b5985e80a233a5ac27137846f3bc2d3b40a47360/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ed2e99c456e3fadd05c991f8f437ef902e00eedf34320ba2b0842bd1c3ca3a75", size = 2052578, upload-time = "2025-11-04T13:40:04.401Z" }, + { url = "https://files.pythonhosted.org/packages/00/b5/0e8e4b5b081eac6cb3dbb7e60a65907549a1ce035a724368c330112adfdd/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:65840751b72fbfd82c3c640cff9284545342a4f1eb1586ad0636955b261b0b05", size = 2208504, upload-time = "2025-11-04T13:40:06.072Z" }, + { url = "https://files.pythonhosted.org/packages/77/56/87a61aad59c7c5b9dc8caad5a41a5545cba3810c3e828708b3d7404f6cef/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e536c98a7626a98feb2d3eaf75944ef6f3dbee447e1f841eae16f2f0a72d8ddc", size = 2335816, upload-time = "2025-11-04T13:40:07.835Z" }, + { url = "https://files.pythonhosted.org/packages/0d/76/941cc9f73529988688a665a5c0ecff1112b3d95ab48f81db5f7606f522d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eceb81a8d74f9267ef4081e246ffd6d129da5d87e37a77c9bde550cb04870c1c", size = 2075366, upload-time = "2025-11-04T13:40:09.804Z" }, + { url = "https://files.pythonhosted.org/packages/d3/43/ebef01f69baa07a482844faaa0a591bad1ef129253ffd0cdaa9d8a7f72d3/pydantic_core-2.41.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d38548150c39b74aeeb0ce8ee1d8e82696f4a4e16ddc6de7b1d8823f7de4b9b5", size = 2171698, upload-time = "2025-11-04T13:40:12.004Z" }, + { url = "https://files.pythonhosted.org/packages/b1/87/41f3202e4193e3bacfc2c065fab7706ebe81af46a83d3e27605029c1f5a6/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:c23e27686783f60290e36827f9c626e63154b82b116d7fe9adba1fda36da706c", size = 2132603, upload-time = "2025-11-04T13:40:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/49/7d/4c00df99cb12070b6bccdef4a195255e6020a550d572768d92cc54dba91a/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:482c982f814460eabe1d3bb0adfdc583387bd4691ef00b90575ca0d2b6fe2294", size = 2329591, upload-time = "2025-11-04T13:40:15.672Z" }, + { url = "https://files.pythonhosted.org/packages/cc/6a/ebf4b1d65d458f3cda6a7335d141305dfa19bdc61140a884d165a8a1bbc7/pydantic_core-2.41.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:bfea2a5f0b4d8d43adf9d7b8bf019fb46fdd10a2e5cde477fbcb9d1fa08c68e1", size = 2319068, upload-time = "2025-11-04T13:40:17.532Z" }, + { url = "https://files.pythonhosted.org/packages/49/3b/774f2b5cd4192d5ab75870ce4381fd89cf218af999515baf07e7206753f0/pydantic_core-2.41.5-cp312-cp312-win32.whl", hash = "sha256:b74557b16e390ec12dca509bce9264c3bbd128f8a2c376eaa68003d7f327276d", size = 1985908, upload-time = "2025-11-04T13:40:19.309Z" }, + { url = "https://files.pythonhosted.org/packages/86/45/00173a033c801cacf67c190fef088789394feaf88a98a7035b0e40d53dc9/pydantic_core-2.41.5-cp312-cp312-win_amd64.whl", hash = "sha256:1962293292865bca8e54702b08a4f26da73adc83dd1fcf26fbc875b35d81c815", size = 2020145, upload-time = "2025-11-04T13:40:21.548Z" }, + { url = "https://files.pythonhosted.org/packages/f9/22/91fbc821fa6d261b376a3f73809f907cec5ca6025642c463d3488aad22fb/pydantic_core-2.41.5-cp312-cp312-win_arm64.whl", hash = "sha256:1746d4a3d9a794cacae06a5eaaccb4b8643a131d45fbc9af23e353dc0a5ba5c3", size = 1976179, upload-time = "2025-11-04T13:40:23.393Z" }, + { url = "https://files.pythonhosted.org/packages/87/06/8806241ff1f70d9939f9af039c6c35f2360cf16e93c2ca76f184e76b1564/pydantic_core-2.41.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:941103c9be18ac8daf7b7adca8228f8ed6bb7a1849020f643b3a14d15b1924d9", size = 2120403, upload-time = "2025-11-04T13:40:25.248Z" }, + { url = "https://files.pythonhosted.org/packages/94/02/abfa0e0bda67faa65fef1c84971c7e45928e108fe24333c81f3bfe35d5f5/pydantic_core-2.41.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:112e305c3314f40c93998e567879e887a3160bb8689ef3d2c04b6cc62c33ac34", size = 1896206, upload-time = "2025-11-04T13:40:27.099Z" }, + { url = "https://files.pythonhosted.org/packages/15/df/a4c740c0943e93e6500f9eb23f4ca7ec9bf71b19e608ae5b579678c8d02f/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0cbaad15cb0c90aa221d43c00e77bb33c93e8d36e0bf74760cd00e732d10a6a0", size = 1919307, upload-time = "2025-11-04T13:40:29.806Z" }, + { url = "https://files.pythonhosted.org/packages/9a/e3/6324802931ae1d123528988e0e86587c2072ac2e5394b4bc2bc34b61ff6e/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:03ca43e12fab6023fc79d28ca6b39b05f794ad08ec2feccc59a339b02f2b3d33", size = 2063258, upload-time = "2025-11-04T13:40:33.544Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d4/2230d7151d4957dd79c3044ea26346c148c98fbf0ee6ebd41056f2d62ab5/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dc799088c08fa04e43144b164feb0c13f9a0bc40503f8df3e9fde58a3c0c101e", size = 2214917, upload-time = "2025-11-04T13:40:35.479Z" }, + { url = "https://files.pythonhosted.org/packages/e6/9f/eaac5df17a3672fef0081b6c1bb0b82b33ee89aa5cec0d7b05f52fd4a1fa/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:97aeba56665b4c3235a0e52b2c2f5ae9cd071b8a8310ad27bddb3f7fb30e9aa2", size = 2332186, upload-time = "2025-11-04T13:40:37.436Z" }, + { url = "https://files.pythonhosted.org/packages/cf/4e/35a80cae583a37cf15604b44240e45c05e04e86f9cfd766623149297e971/pydantic_core-2.41.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:406bf18d345822d6c21366031003612b9c77b3e29ffdb0f612367352aab7d586", size = 2073164, upload-time = "2025-11-04T13:40:40.289Z" }, + { url = "https://files.pythonhosted.org/packages/bf/e3/f6e262673c6140dd3305d144d032f7bd5f7497d3871c1428521f19f9efa2/pydantic_core-2.41.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b93590ae81f7010dbe380cdeab6f515902ebcbefe0b9327cc4804d74e93ae69d", size = 2179146, upload-time = "2025-11-04T13:40:42.809Z" }, + { url = "https://files.pythonhosted.org/packages/75/c7/20bd7fc05f0c6ea2056a4565c6f36f8968c0924f19b7d97bbfea55780e73/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:01a3d0ab748ee531f4ea6c3e48ad9dac84ddba4b0d82291f87248f2f9de8d740", size = 2137788, upload-time = "2025-11-04T13:40:44.752Z" }, + { url = "https://files.pythonhosted.org/packages/3a/8d/34318ef985c45196e004bc46c6eab2eda437e744c124ef0dbe1ff2c9d06b/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:6561e94ba9dacc9c61bce40e2d6bdc3bfaa0259d3ff36ace3b1e6901936d2e3e", size = 2340133, upload-time = "2025-11-04T13:40:46.66Z" }, + { url = "https://files.pythonhosted.org/packages/9c/59/013626bf8c78a5a5d9350d12e7697d3d4de951a75565496abd40ccd46bee/pydantic_core-2.41.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:915c3d10f81bec3a74fbd4faebe8391013ba61e5a1a8d48c4455b923bdda7858", size = 2324852, upload-time = "2025-11-04T13:40:48.575Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d9/c248c103856f807ef70c18a4f986693a46a8ffe1602e5d361485da502d20/pydantic_core-2.41.5-cp313-cp313-win32.whl", hash = "sha256:650ae77860b45cfa6e2cdafc42618ceafab3a2d9a3811fcfbd3bbf8ac3c40d36", size = 1994679, upload-time = "2025-11-04T13:40:50.619Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8b/341991b158ddab181cff136acd2552c9f35bd30380422a639c0671e99a91/pydantic_core-2.41.5-cp313-cp313-win_amd64.whl", hash = "sha256:79ec52ec461e99e13791ec6508c722742ad745571f234ea6255bed38c6480f11", size = 2019766, upload-time = "2025-11-04T13:40:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/73/7d/f2f9db34af103bea3e09735bb40b021788a5e834c81eedb541991badf8f5/pydantic_core-2.41.5-cp313-cp313-win_arm64.whl", hash = "sha256:3f84d5c1b4ab906093bdc1ff10484838aca54ef08de4afa9de0f5f14d69639cd", size = 1981005, upload-time = "2025-11-04T13:40:54.734Z" }, + { url = "https://files.pythonhosted.org/packages/ea/28/46b7c5c9635ae96ea0fbb779e271a38129df2550f763937659ee6c5dbc65/pydantic_core-2.41.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:3f37a19d7ebcdd20b96485056ba9e8b304e27d9904d233d7b1015db320e51f0a", size = 2119622, upload-time = "2025-11-04T13:40:56.68Z" }, + { url = "https://files.pythonhosted.org/packages/74/1a/145646e5687e8d9a1e8d09acb278c8535ebe9e972e1f162ed338a622f193/pydantic_core-2.41.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1d1d9764366c73f996edd17abb6d9d7649a7eb690006ab6adbda117717099b14", size = 1891725, upload-time = "2025-11-04T13:40:58.807Z" }, + { url = "https://files.pythonhosted.org/packages/23/04/e89c29e267b8060b40dca97bfc64a19b2a3cf99018167ea1677d96368273/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:25e1c2af0fce638d5f1988b686f3b3ea8cd7de5f244ca147c777769e798a9cd1", size = 1915040, upload-time = "2025-11-04T13:41:00.853Z" }, + { url = "https://files.pythonhosted.org/packages/84/a3/15a82ac7bd97992a82257f777b3583d3e84bdb06ba6858f745daa2ec8a85/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:506d766a8727beef16b7adaeb8ee6217c64fc813646b424d0804d67c16eddb66", size = 2063691, upload-time = "2025-11-04T13:41:03.504Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/0046701313c6ef08c0c1cf0e028c67c770a4e1275ca73131563c5f2a310a/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4819fa52133c9aa3c387b3328f25c1facc356491e6135b459f1de698ff64d869", size = 2213897, upload-time = "2025-11-04T13:41:05.804Z" }, + { url = "https://files.pythonhosted.org/packages/8a/cd/6bac76ecd1b27e75a95ca3a9a559c643b3afcd2dd62086d4b7a32a18b169/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2b761d210c9ea91feda40d25b4efe82a1707da2ef62901466a42492c028553a2", size = 2333302, upload-time = "2025-11-04T13:41:07.809Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d2/ef2074dc020dd6e109611a8be4449b98cd25e1b9b8a303c2f0fca2f2bcf7/pydantic_core-2.41.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:22f0fb8c1c583a3b6f24df2470833b40207e907b90c928cc8d3594b76f874375", size = 2064877, upload-time = "2025-11-04T13:41:09.827Z" }, + { url = "https://files.pythonhosted.org/packages/18/66/e9db17a9a763d72f03de903883c057b2592c09509ccfe468187f2a2eef29/pydantic_core-2.41.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c870e99878c634505236d81e5443092fba820f0373997ff75f90f68cd553", size = 2180680, upload-time = "2025-11-04T13:41:12.379Z" }, + { url = "https://files.pythonhosted.org/packages/d3/9e/3ce66cebb929f3ced22be85d4c2399b8e85b622db77dad36b73c5387f8f8/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:0177272f88ab8312479336e1d777f6b124537d47f2123f89cb37e0accea97f90", size = 2138960, upload-time = "2025-11-04T13:41:14.627Z" }, + { url = "https://files.pythonhosted.org/packages/a6/62/205a998f4327d2079326b01abee48e502ea739d174f0a89295c481a2272e/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:63510af5e38f8955b8ee5687740d6ebf7c2a0886d15a6d65c32814613681bc07", size = 2339102, upload-time = "2025-11-04T13:41:16.868Z" }, + { url = "https://files.pythonhosted.org/packages/3c/0d/f05e79471e889d74d3d88f5bd20d0ed189ad94c2423d81ff8d0000aab4ff/pydantic_core-2.41.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:e56ba91f47764cc14f1daacd723e3e82d1a89d783f0f5afe9c364b8bb491ccdb", size = 2326039, upload-time = "2025-11-04T13:41:18.934Z" }, + { url = "https://files.pythonhosted.org/packages/ec/e1/e08a6208bb100da7e0c4b288eed624a703f4d129bde2da475721a80cab32/pydantic_core-2.41.5-cp314-cp314-win32.whl", hash = "sha256:aec5cf2fd867b4ff45b9959f8b20ea3993fc93e63c7363fe6851424c8a7e7c23", size = 1995126, upload-time = "2025-11-04T13:41:21.418Z" }, + { url = "https://files.pythonhosted.org/packages/48/5d/56ba7b24e9557f99c9237e29f5c09913c81eeb2f3217e40e922353668092/pydantic_core-2.41.5-cp314-cp314-win_amd64.whl", hash = "sha256:8e7c86f27c585ef37c35e56a96363ab8de4e549a95512445b85c96d3e2f7c1bf", size = 2015489, upload-time = "2025-11-04T13:41:24.076Z" }, + { url = "https://files.pythonhosted.org/packages/4e/bb/f7a190991ec9e3e0ba22e4993d8755bbc4a32925c0b5b42775c03e8148f9/pydantic_core-2.41.5-cp314-cp314-win_arm64.whl", hash = "sha256:e672ba74fbc2dc8eea59fb6d4aed6845e6905fc2a8afe93175d94a83ba2a01a0", size = 1977288, upload-time = "2025-11-04T13:41:26.33Z" }, + { url = "https://files.pythonhosted.org/packages/92/ed/77542d0c51538e32e15afe7899d79efce4b81eee631d99850edc2f5e9349/pydantic_core-2.41.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8566def80554c3faa0e65ac30ab0932b9e3a5cd7f8323764303d468e5c37595a", size = 2120255, upload-time = "2025-11-04T13:41:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/bb/3d/6913dde84d5be21e284439676168b28d8bbba5600d838b9dca99de0fad71/pydantic_core-2.41.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b80aa5095cd3109962a298ce14110ae16b8c1aece8b72f9dafe81cf597ad80b3", size = 1863760, upload-time = "2025-11-04T13:41:31.055Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f0/e5e6b99d4191da102f2b0eb9687aaa7f5bea5d9964071a84effc3e40f997/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3006c3dd9ba34b0c094c544c6006cc79e87d8612999f1a5d43b769b89181f23c", size = 1878092, upload-time = "2025-11-04T13:41:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/71/48/36fb760642d568925953bcc8116455513d6e34c4beaa37544118c36aba6d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:72f6c8b11857a856bcfa48c86f5368439f74453563f951e473514579d44aa612", size = 2053385, upload-time = "2025-11-04T13:41:35.508Z" }, + { url = "https://files.pythonhosted.org/packages/20/25/92dc684dd8eb75a234bc1c764b4210cf2646479d54b47bf46061657292a8/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5cb1b2f9742240e4bb26b652a5aeb840aa4b417c7748b6f8387927bc6e45e40d", size = 2218832, upload-time = "2025-11-04T13:41:37.732Z" }, + { url = "https://files.pythonhosted.org/packages/e2/09/f53e0b05023d3e30357d82eb35835d0f6340ca344720a4599cd663dca599/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:bd3d54f38609ff308209bd43acea66061494157703364ae40c951f83ba99a1a9", size = 2327585, upload-time = "2025-11-04T13:41:40Z" }, + { url = "https://files.pythonhosted.org/packages/aa/4e/2ae1aa85d6af35a39b236b1b1641de73f5a6ac4d5a7509f77b814885760c/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2ff4321e56e879ee8d2a879501c8e469414d948f4aba74a2d4593184eb326660", size = 2041078, upload-time = "2025-11-04T13:41:42.323Z" }, + { url = "https://files.pythonhosted.org/packages/cd/13/2e215f17f0ef326fc72afe94776edb77525142c693767fc347ed6288728d/pydantic_core-2.41.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:d0d2568a8c11bf8225044aa94409e21da0cb09dcdafe9ecd10250b2baad531a9", size = 2173914, upload-time = "2025-11-04T13:41:45.221Z" }, + { url = "https://files.pythonhosted.org/packages/02/7a/f999a6dcbcd0e5660bc348a3991c8915ce6599f4f2c6ac22f01d7a10816c/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:a39455728aabd58ceabb03c90e12f71fd30fa69615760a075b9fec596456ccc3", size = 2129560, upload-time = "2025-11-04T13:41:47.474Z" }, + { url = "https://files.pythonhosted.org/packages/3a/b1/6c990ac65e3b4c079a4fb9f5b05f5b013afa0f4ed6780a3dd236d2cbdc64/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:239edca560d05757817c13dc17c50766136d21f7cd0fac50295499ae24f90fdf", size = 2329244, upload-time = "2025-11-04T13:41:49.992Z" }, + { url = "https://files.pythonhosted.org/packages/d9/02/3c562f3a51afd4d88fff8dffb1771b30cfdfd79befd9883ee094f5b6c0d8/pydantic_core-2.41.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:2a5e06546e19f24c6a96a129142a75cee553cc018ffee48a460059b1185f4470", size = 2331955, upload-time = "2025-11-04T13:41:54.079Z" }, + { url = "https://files.pythonhosted.org/packages/5c/96/5fb7d8c3c17bc8c62fdb031c47d77a1af698f1d7a406b0f79aaa1338f9ad/pydantic_core-2.41.5-cp314-cp314t-win32.whl", hash = "sha256:b4ececa40ac28afa90871c2cc2b9ffd2ff0bf749380fbdf57d165fd23da353aa", size = 1988906, upload-time = "2025-11-04T13:41:56.606Z" }, + { url = "https://files.pythonhosted.org/packages/22/ed/182129d83032702912c2e2d8bbe33c036f342cc735737064668585dac28f/pydantic_core-2.41.5-cp314-cp314t-win_amd64.whl", hash = "sha256:80aa89cad80b32a912a65332f64a4450ed00966111b6615ca6816153d3585a8c", size = 1981607, upload-time = "2025-11-04T13:41:58.889Z" }, + { url = "https://files.pythonhosted.org/packages/9f/ed/068e41660b832bb0b1aa5b58011dea2a3fe0ba7861ff38c4d4904c1c1a99/pydantic_core-2.41.5-cp314-cp314t-win_arm64.whl", hash = "sha256:35b44f37a3199f771c3eaa53051bc8a70cd7b54f333531c59e29fd4db5d15008", size = 1974769, upload-time = "2025-11-04T13:42:01.186Z" }, + { url = "https://files.pythonhosted.org/packages/11/72/90fda5ee3b97e51c494938a4a44c3a35a9c96c19bba12372fb9c634d6f57/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:b96d5f26b05d03cc60f11a7761a5ded1741da411e7fe0909e27a5e6a0cb7b034", size = 2115441, upload-time = "2025-11-04T13:42:39.557Z" }, + { url = "https://files.pythonhosted.org/packages/1f/53/8942f884fa33f50794f119012dc6a1a02ac43a56407adaac20463df8e98f/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:634e8609e89ceecea15e2d61bc9ac3718caaaa71963717bf3c8f38bfde64242c", size = 1930291, upload-time = "2025-11-04T13:42:42.169Z" }, + { url = "https://files.pythonhosted.org/packages/79/c8/ecb9ed9cd942bce09fc888ee960b52654fbdbede4ba6c2d6e0d3b1d8b49c/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:93e8740d7503eb008aa2df04d3b9735f845d43ae845e6dcd2be0b55a2da43cd2", size = 1948632, upload-time = "2025-11-04T13:42:44.564Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1b/687711069de7efa6af934e74f601e2a4307365e8fdc404703afc453eab26/pydantic_core-2.41.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f15489ba13d61f670dcc96772e733aad1a6f9c429cc27574c6cdaed82d0146ad", size = 2138905, upload-time = "2025-11-04T13:42:47.156Z" }, + { url = "https://files.pythonhosted.org/packages/09/32/59b0c7e63e277fa7911c2fc70ccfb45ce4b98991e7ef37110663437005af/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:7da7087d756b19037bc2c06edc6c170eeef3c3bafcb8f532ff17d64dc427adfd", size = 2110495, upload-time = "2025-11-04T13:42:49.689Z" }, + { url = "https://files.pythonhosted.org/packages/aa/81/05e400037eaf55ad400bcd318c05bb345b57e708887f07ddb2d20e3f0e98/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:aabf5777b5c8ca26f7824cb4a120a740c9588ed58df9b2d196ce92fba42ff8dc", size = 1915388, upload-time = "2025-11-04T13:42:52.215Z" }, + { url = "https://files.pythonhosted.org/packages/6e/0d/e3549b2399f71d56476b77dbf3cf8937cec5cd70536bdc0e374a421d0599/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c007fe8a43d43b3969e8469004e9845944f1a80e6acd47c150856bb87f230c56", size = 1942879, upload-time = "2025-11-04T13:42:56.483Z" }, + { url = "https://files.pythonhosted.org/packages/f7/07/34573da085946b6a313d7c42f82f16e8920bfd730665de2d11c0c37a74b5/pydantic_core-2.41.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:76d0819de158cd855d1cbb8fcafdf6f5cf1eb8e470abe056d5d161106e38062b", size = 2139017, upload-time = "2025-11-04T13:42:59.471Z" }, + { url = "https://files.pythonhosted.org/packages/5f/9b/1b3f0e9f9305839d7e84912f9e8bfbd191ed1b1ef48083609f0dabde978c/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:b2379fa7ed44ddecb5bfe4e48577d752db9fc10be00a6b7446e9663ba143de26", size = 2101980, upload-time = "2025-11-04T13:43:25.97Z" }, + { url = "https://files.pythonhosted.org/packages/a4/ed/d71fefcb4263df0da6a85b5d8a7508360f2f2e9b3bf5814be9c8bccdccc1/pydantic_core-2.41.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:266fb4cbf5e3cbd0b53669a6d1b039c45e3ce651fd5442eff4d07c2cc8d66808", size = 1923865, upload-time = "2025-11-04T13:43:28.763Z" }, + { url = "https://files.pythonhosted.org/packages/ce/3a/626b38db460d675f873e4444b4bb030453bbe7b4ba55df821d026a0493c4/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:58133647260ea01e4d0500089a8c4f07bd7aa6ce109682b1426394988d8aaacc", size = 2134256, upload-time = "2025-11-04T13:43:31.71Z" }, + { url = "https://files.pythonhosted.org/packages/83/d9/8412d7f06f616bbc053d30cb4e5f76786af3221462ad5eee1f202021eb4e/pydantic_core-2.41.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:287dad91cfb551c363dc62899a80e9e14da1f0e2b6ebde82c806612ca2a13ef1", size = 2174762, upload-time = "2025-11-04T13:43:34.744Z" }, + { url = "https://files.pythonhosted.org/packages/55/4c/162d906b8e3ba3a99354e20faa1b49a85206c47de97a639510a0e673f5da/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:03b77d184b9eb40240ae9fd676ca364ce1085f203e1b1256f8ab9984dca80a84", size = 2143141, upload-time = "2025-11-04T13:43:37.701Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f2/f11dd73284122713f5f89fc940f370d035fa8e1e078d446b3313955157fe/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:a668ce24de96165bb239160b3d854943128f4334822900534f2fe947930e5770", size = 2330317, upload-time = "2025-11-04T13:43:40.406Z" }, + { url = "https://files.pythonhosted.org/packages/88/9d/b06ca6acfe4abb296110fb1273a4d848a0bfb2ff65f3ee92127b3244e16b/pydantic_core-2.41.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:f14f8f046c14563f8eb3f45f499cc658ab8d10072961e07225e507adb700e93f", size = 2316992, upload-time = "2025-11-04T13:43:43.602Z" }, + { url = "https://files.pythonhosted.org/packages/36/c7/cfc8e811f061c841d7990b0201912c3556bfeb99cdcb7ed24adc8d6f8704/pydantic_core-2.41.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:56121965f7a4dc965bff783d70b907ddf3d57f6eba29b6d2e5dabfaf07799c51", size = 2145302, upload-time = "2025-11-04T13:43:46.64Z" }, +] + [[package]] name = "pyerfa" version = "2.0.1.5" @@ -2110,6 +2244,7 @@ dependencies = [ { name = "aiohttp" }, { name = "astroplan" }, { name = "astropy" }, + { name = "astropydantic" }, { name = "astroquery" }, { name = "colour" }, { name = "dacite" }, @@ -2118,6 +2253,7 @@ dependencies = [ { name = "pandas" }, { name = "paramiko" }, { name = "py-expression-eval" }, + { name = "pydantic" }, { name = "pytz" }, { name = "pyyaml" }, { name = "requests" }, @@ -2174,6 +2310,7 @@ requires-dist = [ { name = "aiohttp", specifier = ">=3.11.18,<4" }, { name = "astroplan", specifier = ">=0.10.1,<0.11" }, { name = "astropy", specifier = ">=7.0.1,<8" }, + { name = "astropydantic", specifier = ">=0.0.5" }, { name = "astroquery", specifier = ">=0.4.10,<0.5" }, { name = "asyncinotify", marker = "extra == 'full'", specifier = ">=4.2.1,<5" }, { name = "ccdproc", marker = "extra == 'full'", specifier = ">=2.4.3,<3" }, @@ -2189,6 +2326,7 @@ requires-dist = [ { name = "photutils", marker = "extra == 'full'", specifier = ">=2.2.0,<3" }, { name = "pillow", marker = "extra == 'full'", specifier = ">=11.3.0" }, { name = "py-expression-eval", specifier = ">=0.3.14,<0.4" }, + { name = "pydantic", specifier = ">=2.12.5" }, { name = "python-daemon", marker = "extra == 'full'", specifier = ">=3.1.2,<4" }, { name = "python-telegram-bot", marker = "extra == 'full'", specifier = "~=22.0" }, { name = "pytz", specifier = "~=2025.2" }, @@ -3070,6 +3208,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/00/d631e67a838026495268c2f6884f3711a15a9a2a96cd244fdaea53b823fb/typing_extensions-4.14.1-py3-none-any.whl", hash = "sha256:d1e1e3b58374dc93031d6eda2420a48ea44a36c2b4766a4fdeb3710755731d76", size = 43906, upload-time = "2025-07-04T13:28:32.743Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "tzdata" version = "2025.2" From c9d87aee961aa4c1989649195d1452886c49ba22 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 12 Jan 2026 13:23:11 +0100 Subject: [PATCH 53/57] added method for retrieving observations --- pyobs/robotic/lco/portal.py | 29 ++++++++++++++++++++++++++++- 1 file changed, 28 insertions(+), 1 deletion(-) diff --git a/pyobs/robotic/lco/portal.py b/pyobs/robotic/lco/portal.py index 4d163c2e5..c1c34370d 100644 --- a/pyobs/robotic/lco/portal.py +++ b/pyobs/robotic/lco/portal.py @@ -1,11 +1,34 @@ from typing import Any, Dict, List, cast, Tuple, Optional from urllib.parse import urljoin - +from pydantic import BaseModel +from astropydantic import AstroPydanticTime # type: ignore import aiohttp from pyobs.utils.time import Time +class ConfigurationStatus(BaseModel): + id: int + configuration: int + instrument_name: str + guide_camera_name: str + state: str + summary: dict[str, str] + + +class Observation(BaseModel): + id: int + request: int + site: str + enclosure: str + telescope: str + start: AstroPydanticTime + end: AstroPydanticTime + priority: int + state: str + configuration_statuses: list[ConfigurationStatus] + + class Portal: def __init__(self, url: str, token: str): self.url = url @@ -77,3 +100,7 @@ async def _proposals(self, offset: int, limit: int) -> Tuple[List[Dict[str, Any] async def instruments(self) -> Dict[str, Any]: req = await self._get("/api/instruments/") return cast(Dict[str, Any], req) + + async def observations(self, request_id: int) -> list[Observation]: + req = await self._get(f"/api/requests/{request_id}/observations/") + return [Observation.model_validate(r) for r in req] From 7c2680b51e2080397450c36375cf5e6656e122d0 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 12 Jan 2026 13:23:21 +0100 Subject: [PATCH 54/57] added Observation and ObservationArchive --- pyobs/robotic/lco/observationarchive.py | 58 +++++++++++++++++++++++++ pyobs/robotic/observation.py | 24 ++++++++++ pyobs/robotic/observationarchive.py | 26 +++++++++++ 3 files changed, 108 insertions(+) create mode 100644 pyobs/robotic/lco/observationarchive.py create mode 100644 pyobs/robotic/observation.py create mode 100644 pyobs/robotic/observationarchive.py diff --git a/pyobs/robotic/lco/observationarchive.py b/pyobs/robotic/lco/observationarchive.py new file mode 100644 index 000000000..5b820cbfa --- /dev/null +++ b/pyobs/robotic/lco/observationarchive.py @@ -0,0 +1,58 @@ +from typing import Any + +from .portal import Portal +from .task import LcoTask +from ..task import Task +from ..observation import Observation, ObservationState +from ..observationarchive import ObservationArchive + + +STATE_MAP = { + "CANCELED": ObservationState.CANCELED, + "COMPLETED": ObservationState.COMPLETED, + "PENDING": ObservationState.PENDING, +} + + +class LcoObservationArchive(ObservationArchive): + def __init__(self, url: str, token: str, **kwargs: Any): + """Creates a new LCO observation archive. + + Args: + url: URL to portal + token: Authorization token for portal + """ + ObservationArchive.__init__(self, **kwargs) + + # portal + self._portal = Portal(url, token) + + async def observations(self, task: Task) -> list[Observation]: + """Returns list of observations for the given task. + + Args: + task: Task to get observations for. + + Returns: + List of observations for the given task. + """ + + if not isinstance(task, LcoTask): + raise TypeError("Task must be of type LcoTask.") + + portal_observations = await self._portal.observations(task.id) + observations: list[Observation] = [] + for obs in portal_observations: + observations.append( + Observation( + id=obs.id, + task_id=obs.request, + start=obs.start, + end=obs.end, + state=STATE_MAP[obs.state], + ) + ) + return observations + + +__all__ = ["LcoObservationArchive"] diff --git a/pyobs/robotic/observation.py b/pyobs/robotic/observation.py new file mode 100644 index 000000000..3e1234d29 --- /dev/null +++ b/pyobs/robotic/observation.py @@ -0,0 +1,24 @@ +from __future__ import annotations +from enum import Enum +from typing import Any +from pydantic import BaseModel +from astropydantic import AstroPydanticTime # type: ignore + + +class ObservationState(str, Enum): + PENDING = "pending" + COMPLETED = "completed" + IN_PROGRESS = "in_progress" + ABORTED = "aborted" + CANCELED = "canceled" + + +class Observation(BaseModel): + id: Any + task_id: Any + start: AstroPydanticTime + end: AstroPydanticTime + state: ObservationState + + +__all__ = ["Observation", "ObservationState"] diff --git a/pyobs/robotic/observationarchive.py b/pyobs/robotic/observationarchive.py new file mode 100644 index 000000000..7d5065025 --- /dev/null +++ b/pyobs/robotic/observationarchive.py @@ -0,0 +1,26 @@ +from abc import ABCMeta, abstractmethod +from typing import Any + +from pyobs.object import Object +from .task import Task +from .observation import Observation + + +class ObservationArchive(Object, metaclass=ABCMeta): + def __init__(self, **kwargs: Any): + Object.__init__(self, **kwargs) + + @abstractmethod + async def observations(self, task: Task) -> list[Observation]: + """Returns list of observations for the given task. + + Args: + task: Task to get observations for. + + Returns: + List of observations for the given task. + """ + ... + + +__all__ = ["ObservationArchive"] From 58bda87287f923410447e97f6851d8a5489c20a4 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 12 Jan 2026 14:11:30 +0100 Subject: [PATCH 55/57] removed output --- pyobs/modules/robotic/scheduler.py | 1 - 1 file changed, 1 deletion(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index e487a66c9..e4872ebb5 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -125,7 +125,6 @@ async def _update_worker(self) -> None: async def _update_schedule(self) -> None: # get schedulable tasks and sort them - log.info("Found update in schedulable tasks, downloading them...") tasks = sorted( await self._task_archive.get_schedulable_tasks(), key=lambda x: json.dumps(x.id, sort_keys=True), From 36671c43819b15c88552dd8fcafa7307afad361e Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 12 Jan 2026 15:59:57 +0100 Subject: [PATCH 56/57] explicitly del some objects --- pyobs/modules/robotic/scheduler.py | 3 +++ pyobs/robotic/scheduler/astroplanscheduler.py | 6 ++++++ 2 files changed, 9 insertions(+) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index e4872ebb5..6afa665bd 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -249,6 +249,9 @@ async def _schedule_worker(self) -> None: log.info("Finished calculating schedule for %d block(s):", len(scheduled_tasks)) self._log_scheduled_task(scheduled_tasks) + # clean up + del scheduled_tasks + # set new safety_time as duration + 20% self._safety_time = (time.time() - start_time) * 1.2 * u.second diff --git a/pyobs/robotic/scheduler/astroplanscheduler.py b/pyobs/robotic/scheduler/astroplanscheduler.py index f3ae5230d..5925bb844 100644 --- a/pyobs/robotic/scheduler/astroplanscheduler.py +++ b/pyobs/robotic/scheduler/astroplanscheduler.py @@ -61,6 +61,9 @@ async def schedule(self, tasks: list[Task], start: Time, end: Time) -> AsyncIter for scheduled_task in scheduled_tasks: yield scheduled_task + # clean up + del blocks, constraints, scheduled_blocks, scheduled_tasks + async def abort(self) -> None: self._abort.set() @@ -156,6 +159,9 @@ def _schedule_process( # put scheduled blocks in queue scheduled_blocks.put(schedule.scheduled_blocks) + # clean up + del transitioner, scheduler, schedule + async def _convert_blocks(self, blocks: list[ObservingBlock], tasks: list[Task]) -> list[ScheduledTask]: from pyobs.robotic import ScheduledTask From a21ac9ac556940c53879c0686e1d11b3e2c9e1a1 Mon Sep 17 00:00:00 2001 From: Tim-Oliver Husser Date: Mon, 12 Jan 2026 16:03:48 +0100 Subject: [PATCH 57/57] changed order of execution --- pyobs/modules/robotic/scheduler.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 6afa665bd..9827d1825 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -115,8 +115,8 @@ async def _update_worker(self) -> None: t = await self._task_archive.last_changed() if last_change is None or last_change < t: try: - await self._update_schedule() last_change = t + await self._update_schedule() except: log.exception("Something went wrong when updating schedule.") @@ -125,6 +125,7 @@ async def _update_worker(self) -> None: async def _update_schedule(self) -> None: # get schedulable tasks and sort them + log.info("Found update in schedulable block, downloading them...") tasks = sorted( await self._task_archive.get_schedulable_tasks(), key=lambda x: json.dumps(x.id, sort_keys=True),