diff --git a/pyobs/cli/pyobsd.py b/pyobs/cli/pyobsd.py index 3171d60d6..15fe42054 100755 --- a/pyobs/cli/pyobsd.py +++ b/pyobs/cli/pyobsd.py @@ -5,7 +5,7 @@ import subprocess import sys import time -from typing import Optional, List +from typing import Optional, List, Any from ._cli import CLI @@ -91,6 +91,7 @@ def __init__( chuid: Optional[str] = None, start_stop_daemon: str = "start-stop-daemon", verbose: bool = False, + **kwargs: Any, ): self._config_path = config_path self._run_path = run_path diff --git a/pyobs/modules/robotic/mastermind.py b/pyobs/modules/robotic/mastermind.py index 71419dab1..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,10 +123,10 @@ 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 * 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)) diff --git a/pyobs/modules/robotic/scheduler.py b/pyobs/modules/robotic/scheduler.py index 183bd44ec..9827d1825 100644 --- a/pyobs/modules/robotic/scheduler.py +++ b/pyobs/modules/robotic/scheduler.py @@ -1,24 +1,20 @@ 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 +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 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,52 +26,53 @@ 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, + 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) # 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 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: 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,98 +107,99 @@ 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 + 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 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), - ) - log.info("Downloaded %d schedulable block(s).", len(blocks)) - - # compare new and old lists - removed, added = self._compare_block_lists(self._blocks, blocks) - - # 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].target.name, - 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: - # 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._blocks = blocks - - # schedule update - if self._need_update: - log.info("Triggering scheduler run...") - - # remember now - last_change = Time.now() - self._initial_update_done = True + try: + last_change = t + await self._update_schedule() + 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 block, 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], + str(self._last_task_id), + str(self._current_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 + + # 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_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[Any], list[Any]]: + """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} + # 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 = set(names1.keys()).difference(names2.keys()) - additional2 = 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())) - # 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 sorted(additional1), sorted(additional2) async def _schedule_worker(self) -> None: # run forever @@ -212,20 +210,51 @@ 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() - # prepare scheduler - blocks, start, end, constraints = await self._prepare_schedule() + # 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_blocks = await self._schedule_blocks(blocks, start, end, constraints) + scheduled_tasks: list[ScheduledTask] = [] + first = True + async for scheduled_task in self._scheduler.schedule(self._tasks, start, end): + # remember for later + scheduled_tasks.append(scheduled_task) + + if self._need_update: + log.info("Not using scheduler results, since update was requested.") + break - # finish schedule - await self._finish_schedule(scheduled_blocks, blocks, start) + # 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) + + # clean up + del 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") @@ -233,166 +262,15 @@ 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: - # 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): - 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"], - ) - 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) + 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/__init__.py b/pyobs/robotic/__init__.py index ba8c4f3ed..98bda2440 100644 --- a/pyobs/robotic/__init__.py +++ b/pyobs/robotic/__init__.py @@ -1,4 +1,5 @@ from .taskschedule import TaskSchedule -from .task import Task +from .task import Task, ScheduledTask +from .observation import Observation from .taskarchive import TaskArchive from .taskrunner import TaskRunner diff --git a/pyobs/robotic/lco/observationarchive.py b/pyobs/robotic/lco/observationarchive.py new file mode 100644 index 000000000..7f170eff4 --- /dev/null +++ b/pyobs/robotic/lco/observationarchive.py @@ -0,0 +1,70 @@ +import datetime +from typing import Any + +from .portal import Portal +from .task import LcoTask +from ..task import Task +from ..observation import Observation, ObservationState, ObservationList +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_for_task(self, task: Task) -> ObservationList: + """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 ObservationList(observations) + + async def observations_for_night(self, date: datetime.date) -> ObservationList: + """Returns list of observations for the given task. + + Args: + date: Date of night to get observations for. + + Returns: + List of observations for the given task. + """ + return ObservationList() + + +__all__ = ["LcoObservationArchive"] 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] diff --git a/pyobs/robotic/lco/task.py b/pyobs/robotic/lco/task.py index c04f44ab6..21aad925b 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, @@ -65,45 +77,96 @@ 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], + 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 """ - Task.__init__(self, **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.") + req = config["request"] + if id is None: + id = req["id"] + if name is None: + name = req["id"] + if duration is None: + 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, + id=id, + name=name, + duration=duration, + config=config, + **kwargs, + ) - @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"]) + # store stuff + 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] + merits: list[Merit] = [] + if "merits" in cfg: + for merit in cfg["merits"]: + config = {"class": merit["type"]} + if "params" in merit: + config.update(**merit["params"]) + merits.append(Merit.create(config)) + return merits + + 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: - raise ValueError("No duration found in request.") + log.warning("Unsupported coordinate type.") + return None def __eq__(self, other: object) -> bool: """Compares to tasks.""" @@ -112,22 +175,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. @@ -149,7 +196,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: @@ -176,7 +223,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: @@ -206,9 +253,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 @@ -217,7 +264,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): @@ -253,9 +300,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: @@ -308,7 +355,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 6f18be5d0..64e7fc5e7 100644 --- a/pyobs/robotic/lco/taskarchive.py +++ b/pyobs/robotic/lco/taskarchive.py @@ -1,21 +1,11 @@ import logging -from typing import List, Dict, Optional, Any -from astroplan import ( - TimeConstraint, - AirmassConstraint, - ObservingBlock, - FixedTarget, - MoonSeparationConstraint, - MoonIlluminationConstraint, - AtNightConstraint, -) -from astropy.coordinates import SkyCoord -import astropy.units as u +from typing import Dict, Optional, Any from pyobs.utils.time import Time from pyobs.robotic.taskarchive import TaskArchive from .portal import Portal from .task import LcoTask +from .. import Task log = logging.getLogger(__name__) @@ -67,11 +57,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 +72,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: list[Task] = [] for group in schedulable: # get base priority, which is tac_priority * ipp_value proposal = group["proposal"] @@ -97,57 +87,29 @@ async def get_schedulable_blocks(self) -> List[ObservingBlock]: if req["state"] != "PENDING": continue - # duration - duration = req["duration"] * u.second - - # time 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()) - else: - log.warning("Unsupported coordinate type.") - continue - - # constraints - c = cfg["constraints"] - constraints = [] - if "max_airmass" in c and c["max_airmass"] is not None: - constraints.append(AirmassConstraint(max=c["max_airmass"], boolean_constraint=False)) - if "min_lunar_distance" in c and c["min_lunar_distance"] is not None: - constraints.append(MoonSeparationConstraint(min=c["min_lunar_distance"] * u.deg)) - if "max_lunar_phase" in c and c["max_lunar_phase"] is not None: - constraints.append(MoonIlluminationConstraint(max=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()) - - # 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, - constraints=[*constraints, *time_constraints], - configuration={"request": req}, - name=group["name"], - ) - blocks.append(block) + # 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 blocks + return tasks __all__ = ["LcoTaskArchive"] diff --git a/pyobs/robotic/lco/taskschedule.py b/pyobs/robotic/lco/taskschedule.py index 5b0ed143e..6d903a20b 100644 --- a/pyobs/robotic/lco/taskschedule.py +++ b/pyobs/robotic/lco/taskschedule.py @@ -4,11 +4,10 @@ import logging from typing import Any, cast 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 ScheduledTask from pyobs.utils.time import Time from pyobs.robotic.taskschedule import TaskSchedule from .portal import Portal @@ -76,7 +75,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) @@ -167,7 +166,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.") @@ -178,13 +177,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) != 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( + 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 @@ -193,7 +194,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: @@ -203,18 +204,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. @@ -228,6 +228,7 @@ async def _get_schedule(self, start_before: Time, end_after: Time) -> dict[str, url = urljoin(self._url, "/api/observations/") params = { "site": self._site, + "telescope": self._telescope, "end_after": end_after.isot, "start_before": start_before.isot, "state": states, @@ -245,34 +246,38 @@ 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: - """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. """ + # update schedule + await self.update_now() + # 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 # nothing found return None @@ -317,33 +322,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, blocks: list[ObservingBlock], start_time: Time) -> None: - """Update the list of scheduled blocks. + async def add_schedule(self, tasks: list[ScheduledTask]) -> None: + """Add the list of scheduled tasks to the schedule. Args: - blocks: Scheduled blocks. - start_time: Start time for schedule. + tasks: Scheduled tasks. """ # create observations - observations = self._create_observations(blocks) - - # cancel schedule - await self._cancel_schedule(start_time) + observations = self._create_observations(tasks) # 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 @@ -351,36 +356,36 @@ 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: 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/observation.py b/pyobs/robotic/observation.py new file mode 100644 index 000000000..cc97ab391 --- /dev/null +++ b/pyobs/robotic/observation.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +from collections import UserList +from enum import Enum +from typing import Any +from pydantic import BaseModel +from astropydantic import AstroPydanticTime # type: ignore + +from pyobs.utils.time import Time + + +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 + + +class ObservationList(UserList[Observation]): + def __init__(self, observations: list[Observation] | None = None): + UserList.__init__(self, observations) + + def filter( + self, state: ObservationState | None = None, task_id: int | None = None, after: Time | None = None + ) -> ObservationList: + new_list = self.data + if state is not None: + new_list = [obs for obs in new_list if obs.state == state] + if task_id is not None: + new_list = [obs for obs in new_list if obs.task_id == task_id] + if after is not None: + new_list = [obs for obs in new_list if obs.start >= after] + return ObservationList(new_list) + + +__all__ = ["Observation", "ObservationState", "ObservationList"] diff --git a/pyobs/robotic/observationarchive.py b/pyobs/robotic/observationarchive.py new file mode 100644 index 000000000..bdbea845d --- /dev/null +++ b/pyobs/robotic/observationarchive.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from abc import ABCMeta, abstractmethod +import datetime +from typing import Any, TYPE_CHECKING + +from pyobs.object import Object + +if TYPE_CHECKING: + from .task import Task + from .observation import ObservationList + + +class ObservationArchive(Object, metaclass=ABCMeta): + def __init__(self, **kwargs: Any): + Object.__init__(self, **kwargs) + + @abstractmethod + async def observations_for_task(self, task: Task) -> ObservationList: + """Returns list of observations for the given task. + + Args: + task: Task to get observations for. + + Returns: + List of observations for the given task. + """ + ... + + @abstractmethod + async def observations_for_night(self, date: datetime.date) -> ObservationList: + """Returns list of observations for the given task. + + Args: + date: Date of night to get observations for. + + Returns: + List of observations for the given task. + """ + ... + + +__all__ = ["ObservationArchive"] diff --git a/pyobs/robotic/scheduler/__init__.py b/pyobs/robotic/scheduler/__init__.py new file mode 100644 index 000000000..7235a07d2 --- /dev/null +++ b/pyobs/robotic/scheduler/__init__.py @@ -0,0 +1,4 @@ +from .taskscheduler import TaskScheduler +from .astroplanscheduler import AstroplanScheduler +from .dataprovider import DataProvider +from .meritscheduler import MeritScheduler diff --git a/pyobs/robotic/scheduler/astroplanscheduler.py b/pyobs/robotic/scheduler/astroplanscheduler.py new file mode 100644 index 000000000..5925bb844 --- /dev/null +++ b/pyobs/robotic/scheduler/astroplanscheduler.py @@ -0,0 +1,184 @@ +from __future__ import annotations +import asyncio +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 pyobs.object import Object +from .taskscheduler import TaskScheduler +from .targets import SiderealTarget +from pyobs.utils.time import Time + +if TYPE_CHECKING: + from pyobs.robotic import ScheduledTask, Task + +log = logging.getLogger(__name__) + + +class AstroplanScheduler(TaskScheduler): + """Scheduler based on astroplan.""" + + __module__ = "pyobs.modules.robotic" + + def __init__( + self, + twilight: str = "astronomical", + **kwargs: Any, + ): + """Initialize a new scheduler. + + Args: + twilight: astronomical or nautical + """ + Object.__init__(self, **kwargs) + + # store + 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, end: Time) -> AsyncIterator[ScheduledTask]: + # 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, end) + + # schedule + scheduled_blocks = await self._schedule_blocks(blocks, start, end, constraints, self._abort) + + # convert + scheduled_tasks = await self._convert_blocks(scheduled_blocks, tasks) + + # yield them + 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() + + async def _prepare_schedule( + self, tasks: list[Task], start: Time, end: Time + ) -> 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.") + + # 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.id, + ) + ) + + # return all + return blocks, start, end, constraints + + async def _schedule_blocks( + 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 + 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() + 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( + 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) + + # clean up + del transitioner, scheduler, schedule + + 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/__init__.py b/pyobs/robotic/scheduler/constraints/__init__.py new file mode 100644 index 000000000..891e540a0 --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/__init__.py @@ -0,0 +1,15 @@ +from .airmassconstraint import AirmassConstraint +from .constraint import Constraint +from .moonilluminationconstraint import MoonIlluminationConstraint +from .moonseparationconstraint import MoonSeparationConstraint +from .solarelevationconstraint import SolarElevationConstraint +from .timeconstraint import TimeConstraint + +__all__ = [ + "Constraint", + "AirmassConstraint", + "MoonIlluminationConstraint", + "MoonSeparationConstraint", + "SolarElevationConstraint", + "TimeConstraint", +] diff --git a/pyobs/robotic/scheduler/constraints/airmassconstraint.py b/pyobs/robotic/scheduler/constraints/airmassconstraint.py new file mode 100644 index 000000000..3b964f1bc --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/airmassconstraint.py @@ -0,0 +1,27 @@ +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.""" + + def __init__(self, max_airmass: float, **kwargs: Any): + super().__init__() + self.max_airmass = max_airmass + + def to_astroplan(self) -> astroplan.AirmassConstraint: + return astroplan.AirmassConstraint(max=self.max_airmass) + + async def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: + airmass = float(data.observer.altaz(time, task.target).secz) + return 0.0 < airmass <= 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..a8a877add --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/constraint.py @@ -0,0 +1,39 @@ +from __future__ import annotations +from abc import ABCMeta, abstractmethod +import astroplan +from typing import TYPE_CHECKING, Any + +from pyobs.object import create_object + +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task + + +class Constraint(metaclass=ABCMeta): + @abstractmethod + def to_astroplan(self) -> astroplan.Constraint: ... + + @abstractmethod + async def __call__(self, time: Time, task: Task, data: DataProvider) -> bool: ... + + @staticmethod + def create(config: Constraint | dict[str, Any]) -> Constraint: + if isinstance(config, Constraint): + return config + else: + from . import __all__ as constraints + + constraints_lower = [c.lower() for c in constraints] + try: + idx = constraints_lower.index(config["type"].lower() + "constraint") + except ValueError: + raise ValueError(f"Invalid constraint type: {config['type']}") + + config["class"] = f"pyobs.robotic.scheduler.constraints.{constraints[idx]}" + obj = create_object(config) + if isinstance(obj, Constraint): + return obj + else: + raise ValueError(f"Invalid constraint config: {config}") diff --git a/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py b/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py new file mode 100644 index 000000000..b0fb98f0a --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/moonilluminationconstraint.py @@ -0,0 +1,27 @@ +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.""" + + def __init__(self, max_phase: float, **kwargs: Any): + super().__init__() + self.max_phase = max_phase + + def to_astroplan(self) -> astroplan.MoonIlluminationConstraint: + return astroplan.MoonIlluminationConstraint(max=self.max_phase) + + async 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 new file mode 100644 index 000000000..fe20d96ba --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/moonseparationconstraint.py @@ -0,0 +1,34 @@ +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.""" + + def __init__(self, min_distance: float, **kwargs: Any): + super().__init__() + self.min_distance = min_distance + + def to_astroplan(self) -> astroplan.MoonSeparationConstraint: + return astroplan.MoonSeparationConstraint(min=self.min_distance * u.deg) + + async 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), origin_mismatch="ignore" + ) + 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 new file mode 100644 index 000000000..cbfe1cad5 --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/solarelevationconstraint.py @@ -0,0 +1,28 @@ +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.""" + + def __init__(self, max_elevation: float, **kwargs: Any): + super().__init__() + self.max_elevation = max_elevation + + def to_astroplan(self) -> astroplan.AtNightConstraint: + return astroplan.AtNightConstraint(max_solar_altitude=self.max_elevation * u.deg) + + async 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 new file mode 100644 index 000000000..4607e4d6b --- /dev/null +++ b/pyobs/robotic/scheduler/constraints/timeconstraint.py @@ -0,0 +1,27 @@ +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 TimeConstraint(Constraint): + """Time constraint.""" + + def __init__(self, start: Time, end: Time, **kwargs: Any): + super().__init__() + self.start = start + self.end = end + + def to_astroplan(self) -> astroplan.TimeConstraint: + return astroplan.TimeConstraint(min=self.start, max=self.end) + + async 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.py b/pyobs/robotic/scheduler/dataprovider.py new file mode 100644 index 000000000..b65cda229 --- /dev/null +++ b/pyobs/robotic/scheduler/dataprovider.py @@ -0,0 +1,38 @@ +from __future__ import annotations +import datetime +from dataclasses import dataclass +from functools import cache +from astroplan import Observer +from astropy.time import Time + +from pyobs.robotic.scheduler.observationarchiveevolution import ObservationArchiveEvolution + + +@dataclass +class TaskSuccess: + date: Time + night: Time + + +class DataProvider: + """Data provider for Merit classes.""" + + def __init__(self, observer: Observer, archive: ObservationArchiveEvolution | None = None): + self.observer = observer + self.archive = archive if archive else ObservationArchiveEvolution(observer) + + @cache + def last_sunset(self, time: Time) -> Time: + """Returns the time of the last sunset.""" + + # get last sunset + return self.observer.sun_set_time(time, which="previous") + + @cache + def night(self, time: Time) -> datetime.date: + """Returns the time of the last sunset.""" + sunset = self.last_sunset(time) + return sunset.to_datetime().date() # type: ignore + + +__all__ = ["DataProvider"] diff --git a/pyobs/robotic/scheduler/merits/__init__.py b/pyobs/robotic/scheduler/merits/__init__.py new file mode 100644 index 000000000..6afdfe194 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/__init__.py @@ -0,0 +1,20 @@ +from .merit import Merit +from .aftertime import AfterTimeMerit +from .beforetime import BeforeTimeMerit +from .constant import ConstantMerit +from .interval import IntervalMerit +from .pernight import PerNightMerit +from .random import RandomMerit +from .timewindow import TimeWindowMerit + + +__all__ = [ + "Merit", + "AfterTimeMerit", + "BeforeTimeMerit", + "ConstantMerit", + "IntervalMerit", + "PerNightMerit", + "RandomMerit", + "TimeWindowMerit", +] diff --git a/pyobs/robotic/scheduler/merits/aftertime.py b/pyobs/robotic/scheduler/merits/aftertime.py new file mode 100644 index 000000000..08018a59d --- /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__() + self._after = after + + async 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..d1ad72e0b --- /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__() + self._before = before + + async 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/pyobs/robotic/scheduler/merits/constant.py b/pyobs/robotic/scheduler/merits/constant.py new file mode 100644 index 000000000..858b922a3 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/constant.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 ConstantMerit(Merit): + """Merit function that returns a constant value.""" + + def __init__(self, merit: float, **kwargs: Any): + super().__init__() + self._merit = merit + + async def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + return self._merit + + +__all__ = ["ConstantMerit"] diff --git a/pyobs/robotic/scheduler/merits/follow.py b/pyobs/robotic/scheduler/merits/follow.py new file mode 100644 index 000000000..fddedb3e8 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/follow.py @@ -0,0 +1,31 @@ +from __future__ import annotations +from typing import Any, TYPE_CHECKING +from .merit import Merit +from ...observation import ObservationState + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider + + +class FollowMerit(Merit): + """Merit functions that only returns after another given task has run this night.""" + + def __init__(self, task_id: Any, **kwargs: Any): + super().__init__() + self._task_id = task_id + + async def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + # get all observations for tonight + night = data.night(time) + observations = await data.archive.observations_for_night(night) + + # filter for successful ones of the given task + observations = observations.filter(task_id=self._task_id, state=ObservationState.COMPLETED) + + # compare to count + return 1.0 if len(observations) > 0 else 0.0 + + +__all__ = ["FollowMerit"] diff --git a/pyobs/robotic/scheduler/merits/interval.py b/pyobs/robotic/scheduler/merits/interval.py new file mode 100644 index 000000000..57267ea80 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/interval.py @@ -0,0 +1,32 @@ +from __future__ import annotations +from typing import Any, TYPE_CHECKING +from astropy.time import Time, TimeDelta +import astropy.units as u + +from .merit import Merit +from ...observation import ObservationState + +if TYPE_CHECKING: + from pyobs.robotic import Task + from ..dataprovider import DataProvider + + +class IntervalMerit(Merit): + """Merit function that enforces an interval between observations.""" + + def __init__(self, interval: float, **kwargs: Any): + super().__init__() + self._interval = interval * u.minute + + async def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + # get all observations for task + observations = await data.archive.observations_for_task(task) + + # filter for those in the given interval that were successful + observations = observations.filter(after=time - TimeDelta(self._interval), state=ObservationState.COMPLETED) + + # if there is an observation in the given interval, return 0.0 + return 0.0 if len(observations) > 0 else 1.0 + + +__all__ = ["IntervalMerit"] diff --git a/pyobs/robotic/scheduler/merits/merit.py b/pyobs/robotic/scheduler/merits/merit.py new file mode 100644 index 000000000..b262f8c7f --- /dev/null +++ b/pyobs/robotic/scheduler/merits/merit.py @@ -0,0 +1,42 @@ +from __future__ import annotations +from abc import ABCMeta, abstractmethod +from typing import TYPE_CHECKING, Any + +from pyobs.object import create_object + +if TYPE_CHECKING: + from astropy.time import Time + from ..dataprovider import DataProvider + from pyobs.robotic import Task + + +class Merit(metaclass=ABCMeta): + """Merit class.""" + + @abstractmethod + async def __call__(self, time: Time, task: Task, data: DataProvider) -> float: ... + + @staticmethod + def create(config: Merit | dict[str, Any]) -> Merit: + if isinstance(config, Merit): + return config + else: + from . import __all__ as constraints + + if "." not in config["class"]: + constraints_lower = [c.lower() for c in constraints] + try: + idx = constraints_lower.index(config["class"].lower() + "merit") + except ValueError: + raise ValueError(f"Invalid merit type: {config['class']}") + + config["class"] = f"pyobs.robotic.scheduler.merits.{constraints[idx]}" + + obj = create_object(config) + if isinstance(obj, Merit): + return obj + else: + raise ValueError(f"Invalid merit config: {config}") + + +__all__ = ["Merit"] diff --git a/pyobs/robotic/scheduler/merits/pernight.py b/pyobs/robotic/scheduler/merits/pernight.py new file mode 100644 index 000000000..5d5b5960c --- /dev/null +++ b/pyobs/robotic/scheduler/merits/pernight.py @@ -0,0 +1,30 @@ +from __future__ import annotations +from typing import Any, TYPE_CHECKING +from .merit import Merit +from ...observation import ObservationState + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider + + +class PerNightMerit(Merit): + """Merit functions for defining a max number of observations per night.""" + + def __init__(self, count: int, **kwargs: Any): + super().__init__() + self._count = count + + async def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + # get all observations for task + observations = await data.archive.observations_for_task(task) + + # filter for those after last sunset that were successful + observations = observations.filter(after=data.last_sunset(time), state=ObservationState.COMPLETED) + + # compare to count + return 1.0 if len(observations) < self._count else 0.0 + + +__all__ = ["PerNightMerit"] diff --git a/pyobs/robotic/scheduler/merits/random.py b/pyobs/robotic/scheduler/merits/random.py new file mode 100644 index 000000000..ebcaaca95 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/random.py @@ -0,0 +1,23 @@ +from __future__ import annotations +from typing import Any, TYPE_CHECKING +import numpy as np +from .merit import Merit + +if TYPE_CHECKING: + from astropy.time import Time + from pyobs.robotic import Task + from ..dataprovider import DataProvider + + +class RandomMerit(Merit): + """Merit functions for a random normal-distributed number.""" + + def __init__(self, std: float = 1.0, **kwargs: Any): + super().__init__() + self._std = std + + async def __call__(self, time: Time, task: Task, data: DataProvider) -> float: + return np.random.normal(0.0, self._std) + + +__all__ = ["RandomMerit"] diff --git a/pyobs/robotic/scheduler/merits/timewindow.py b/pyobs/robotic/scheduler/merits/timewindow.py new file mode 100644 index 000000000..d000752e7 --- /dev/null +++ b/pyobs/robotic/scheduler/merits/timewindow.py @@ -0,0 +1,38 @@ +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__() + self._windows = windows + self._inverse = inverse + + async 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 + + # 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"] diff --git a/pyobs/robotic/scheduler/meritscheduler.py b/pyobs/robotic/scheduler/meritscheduler.py new file mode 100644 index 000000000..c0347766b --- /dev/null +++ b/pyobs/robotic/scheduler/meritscheduler.py @@ -0,0 +1,243 @@ +from __future__ import annotations +import asyncio +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 .constraints import Constraint +from .taskscheduler import TaskScheduler +from pyobs.utils.time import Time +from ..observationarchive import ObservationArchive +from .observationarchiveevolution import ObservationArchiveEvolution + +if TYPE_CHECKING: + from pyobs.robotic import Task + from pyobs.robotic import ScheduledTask + +log = logging.getLogger(__name__) + + +class MeritScheduler(TaskScheduler): + """Scheduler based on merits.""" + + __module__ = "pyobs.modules.robotic" + + def __init__( + self, + twilight: str = "astronomical", + observation_archive: ObservationArchive | dict[str, Any] | None = None, + constraints: list[Constraint] | list[dict[str, Any]] | None = None, + **kwargs: Any, + ): + """Initialize a new scheduler. + + Args: + twilight: astronomical or nautical + """ + Object.__init__(self, **kwargs) + + # get obs archive + self._obs_archive = ( + self.add_child_object(observation_archive, ObservationArchive) if observation_archive is not None else None + ) + + # store + self._twilight = twilight + self._abort: asyncio.Event = asyncio.Event() + + # global constraints + constraints = constraints or [] + self._global_constraints: list[Constraint] = [Constraint.create(c) for c in constraints] + + async def schedule(self, tasks: list[Task], start: Time, end: Time) -> AsyncIterator[ScheduledTask]: + archive = ObservationArchiveEvolution(self.observer, self._obs_archive) + data = DataProvider(self.observer, archive) + + # schedule from start to end + async for task in self.schedule_in_interval(tasks, start, end, data): + # evolve archive + await data.archive.evolve(task) + + # yield to caller + yield task + + async def abort(self) -> None: + self._abort.set() + + async def schedule_in_interval( + self, 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 self.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, 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( + self, tasks: list[Task], start: Time, end: Time, data: DataProvider, step: float = 300 + ) -> AsyncIterator[ScheduledTask]: + # find current best task + task, merit = await self.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, better_merit = await self.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 = await self.can_postpone_task(task, better_task, better_merit, start, end, data) + if postpone_time is not None: + # yes, we can! schedule both + yield self.create_scheduled_task(task, start) + yield self.create_scheduled_task(better_task, postpone_time) + else: + # just schedule better_task + yield self.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 self.schedule_in_interval(tasks, start, better_time, data): + yield between_task + + else: + # this seems to be the best task for now, schedule it + yield self.create_scheduled_task(task, start) + + def create_scheduled_task(self, task: Task, time: Time) -> ScheduledTask: + from pyobs.robotic import ScheduledTask + + return ScheduledTask(task, time, time + TimeDelta(task.duration)) + + async def evaluate_constraints(self, 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 self._global_constraints + task.constraints: + if not await constraint(start, task, data): + return False + return True + + async def evaluate_merits(self, 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 *= await merit(start, task, data) + + # if zero, abort and return it + if total_merit == 0.0: + return 0.0 + + # done + return total_merit + + async def evaluate_constraints_and_merits( + self, 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: + # evaluate constraints + if await self.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: + merit = await self.evaluate_merits(task, start, end, data) + + else: + # some constraint failed... + merit = 0.0 + + # store it + merits.append(merit) + + return merits + + async def find_next_best_task( + self, tasks: list[Task], start: Time, end: Time, data: DataProvider + ) -> tuple[Task | None, float]: + # evaluate all merit functions at given time + merits = await self.evaluate_constraints_and_merits(tasks, start, end, data) + + # find max one + idx = np.argmax(merits) + task = tasks[idx] + + # if merit is zero, return nothing + return None if merits[idx] == 0.0 else task, merits[idx] + + async def check_for_better_task( + self, task: Task, merit: float, tasks: list[Task], start: Time, end: Time, data: DataProvider, step: float = 300 + ) -> tuple[Task | None, Time | None, float | None]: + t = start + TimeDelta(step * u.second) + while t < start + TimeDelta(task.duration): + merits = await self.evaluate_constraints_and_merits(tasks, t, end, data) + for i, m in enumerate(merits): + if m > merit: + return tasks[i], t, m + t += TimeDelta(step * u.second) + return None, None, None + + async def can_postpone_task( + self, 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 = (await self.evaluate_constraints_and_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/scheduler/observationarchiveevolution.py b/pyobs/robotic/scheduler/observationarchiveevolution.py new file mode 100644 index 000000000..f6983e762 --- /dev/null +++ b/pyobs/robotic/scheduler/observationarchiveevolution.py @@ -0,0 +1,63 @@ +from __future__ import annotations +import datetime +from typing import TYPE_CHECKING, Any +from uuid import uuid4 +from astroplan import Observer + +from pyobs.robotic.observation import ObservationState, ObservationList +from ...utils.time import Time + +if TYPE_CHECKING: + from pyobs.robotic import ScheduledTask, Task + from pyobs.robotic.observationarchive import ObservationArchive + + +class ObservationArchiveEvolution: + def __init__(self, observer: Observer, obs_archive: ObservationArchive | None = None): + self._obs_archive = obs_archive + self._obs_for_task: dict[Any, ObservationList] = {} + self._obs_for_night: dict[datetime.date, ObservationList] = {} + self._observer = observer + + async def evolve(self, scheduled_task: ScheduledTask) -> None: + from pyobs.robotic import Observation + + obs = Observation( + id=str(uuid4()), + task_id=scheduled_task.task.id, + start=scheduled_task.start, + end=scheduled_task.end, + state=ObservationState.COMPLETED, + ) + + await self.observations_for_task(scheduled_task.task) + self._obs_for_task[scheduled_task.task.id].append(obs) + + night = Time.now().night_obs(self._observer) + await self.observations_for_night(night) + self._obs_for_night[night].append(obs) + + async def observations_for_task(self, task: Task) -> ObservationList: + if task.id not in self._obs_for_task: + self._obs_for_task[task.id] = ( + ObservationList() if self._obs_archive is None else await self._obs_archive.observations_for_task(task) + ) + return self._obs_for_task[task.id] + + async def observations_for_night(self, date: datetime.date) -> ObservationList: + """Returns list of observations for the given task. + + Args: + date: Date of night to get observations for. + + Returns: + List of observations for the given task. + """ + if date not in self._obs_for_night: + self._obs_for_night[date] = ( + ObservationList() if self._obs_archive is None else await self._obs_archive.observations_for_night(date) + ) + return self._obs_for_night[date] + + +__all__ = ["ObservationArchiveEvolution"] 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..40f2f3b13 --- /dev/null +++ b/pyobs/robotic/scheduler/targets/siderealtarget.py @@ -0,0 +1,25 @@ +from astropy.coordinates import SkyCoord + +from pyobs.utils.time import Time +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 + + 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 new file mode 100644 index 000000000..f5a9cc6e9 --- /dev/null +++ b/pyobs/robotic/scheduler/targets/target.py @@ -0,0 +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): + @abc.abstractmethod + def coordinates(self, time: Time) -> SkyCoord: ... + + +__all__ = ["Target"] diff --git a/pyobs/robotic/scheduler/taskscheduler.py b/pyobs/robotic/scheduler/taskscheduler.py new file mode 100644 index 000000000..4d43cf8c3 --- /dev/null +++ b/pyobs/robotic/scheduler/taskscheduler.py @@ -0,0 +1,30 @@ +from __future__ import annotations +import abc +import logging +from collections.abc import AsyncIterator +from typing import TYPE_CHECKING + +from pyobs.object import Object +from pyobs.utils.time import Time + +if TYPE_CHECKING: + from pyobs.robotic import Task, ScheduledTask + +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, 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], start, end) + + @abc.abstractmethod + async def abort(self) -> None: ... + + +__all__ = ["TaskScheduler"] diff --git a/pyobs/robotic/task.py b/pyobs/robotic/task.py index 2cee7d615..fc9e7c712 100644 --- a/pyobs/robotic/task.py +++ b/pyobs/robotic/task.py @@ -1,8 +1,10 @@ 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 astropy.units import Quantity +import astropy.units as u from pyobs.object import Object +from pyobs.robotic.scheduler.targets import Target from pyobs.robotic.scripts import Script from pyobs.utils.time import Time @@ -10,75 +12,106 @@ 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 + from pyobs.robotic.scheduler.merits import Merit -class Task(Object, metaclass=ABCMeta): +class Task(Object): + + def __init__( + self, + id: Any, + name: str, + duration: float, + 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, + ): + super().__init__(**kwargs) + self._id = id + self._name = name + self._duration = duration + self._priority = priority + self._config = config + self._constraints = constraints + self._merits = merits + 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: + def duration(self) -> Quantity: """Returns estimated duration of task in seconds.""" - ... + return self._duration * u.second @property - @abstractmethod - def start(self) -> Time: - """Start time for task""" - ... + def priority(self) -> float: + """Returns priority.""" + return self._priority if self._priority is not None else 0.0 @property - @abstractmethod - def end(self) -> Time: - """End time for task""" - ... + def config(self) -> dict[str, Any]: + """Returns configuration.""" + return self._config if self._config is not None else {} + + @property + def constraints(self) -> list[Constraint]: + """Returns 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: + """Returns target.""" + return self._target - @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: True, if task can run now. """ - ... + return True @property - @abstractmethod def can_start_late(self) -> bool: """Whether this tasks is allowed to start later than the user-set time, e.g. for flatfields. Returns: True, if task can start late. """ - ... + return False - @abstractmethod 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""" ... - @abstractmethod def is_finished(self) -> bool: """Whether task is finished.""" - ... + 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: @@ -90,4 +123,58 @@ 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 + + def __eq__(self, other: object) -> bool: + if isinstance(other, ScheduledTask): + return self.task.id == other.task.id and self.start == other.start and self.end == other.end + return super().__eq__(other) + + def __ne__(self, other: object) -> bool: + if isinstance(other, ScheduledTask): + return self.task.id != other.task.id or self.start != other.start or self.end != other.end + return super().__ne__(other) + + def __lt__(self, other: object) -> bool: + if isinstance(other, ScheduledTask): + return self.start < other.start + raise NotImplementedError + + def __gt__(self, other: object) -> bool: + if isinstance(other, ScheduledTask): + return self.start > other.start + raise NotImplementedError + + def __le__(self, other: object) -> bool: + if isinstance(other, ScheduledTask): + return self.start <= other.start + raise NotImplementedError + + def __ge__(self, other: object) -> bool: + if isinstance(other, ScheduledTask): + return self.start >= other.start + raise NotImplementedError + + +__all__ = ["Task", "ScheduledTask"] 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 473540a34..533a63026 100644 --- a/pyobs/robotic/taskschedule.py +++ b/pyobs/robotic/taskschedule.py @@ -1,9 +1,8 @@ from abc import ABCMeta, abstractmethod -from typing import Optional, Any, List, Dict, Type -from astroplan import ObservingBlock +from typing import Any, Type from pyobs.utils.time import Time -from .task import Task +from .task import Task, ScheduledTask from pyobs.object import Object @@ -12,22 +11,30 @@ 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 add_schedule(self, tasks: list[ScheduledTask]) -> None: + """Add the list of scheduled tasks to the schedule. Args: - blocks: Scheduled blocks. - start_time: Start time for schedule. + tasks: Scheduled tasks. """ ... @abstractmethod - async def last_scheduled(self) -> Optional[Time]: + async def clear_schedule(self, start_time: Time) -> None: + """Clear schedule after given start time. + + Args: + start_time: Start time to clear from. + """ + ... + + @abstractmethod + 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,14 +47,14 @@ async def get_schedule(self) -> Dict[str, Task]: ... @abstractmethod - async def get_task(self, time: Time) -> Optional[Task]: - """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. """ ... diff --git a/pyobs/utils/time.py b/pyobs/utils/time.py index a8d61c871..08c29d1dd 100644 --- a/pyobs/utils/time.py +++ b/pyobs/utils/time.py @@ -4,12 +4,11 @@ __title__ = "Time" -from datetime import datetime, timezone, date, timedelta +from datetime import datetime, timezone, date from typing import cast import astropy.time import astropy.units as u -import pytz from astroplan import Observer @@ -57,17 +56,9 @@ def night_obs(self, observer: Observer) -> date: Night for this time. """ - # convert to datetime - time = self.datetime - - # get local datetime - utc_dt = pytz.utc.localize(time) - loc_dt = utc_dt.astimezone(observer.timezone) - - # get night - if loc_dt.hour < 15: - loc_dt += timedelta(days=-1) - return loc_dt.date() + # get closest sunset + sunset = observer.sun_set_time(self, which="nearest") + return sunset.to_datetime().date() # type: ignore __all__ = ["Time"] diff --git a/pyproject.toml b/pyproject.toml index ae46173b9..7925b560a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "pyobs-core" -version = "1.38.3" +version = "1.38.2" description = "robotic telescope software" authors = [{ name = "Tim-Oliver Husser", email = "thusser@uni-goettingen.de" }] requires-python = ">=3.11" @@ -25,6 +25,8 @@ dependencies = [ "colour>=0.1.5,<0.2", "dacite>=1.9.2", "influxdb-client>=1.49.0", + "pydantic>=2.12.5", + "astropydantic>=0.0.5", ] [project.optional-dependencies] diff --git a/tests/modules/robotic/test_scheduler.py b/tests/modules/robotic/test_scheduler.py index edc6e24bf..9b43f1883 100644 --- a/tests/modules/robotic/test_scheduler.py +++ b/tests/modules/robotic/test_scheduler.py @@ -1,67 +1,68 @@ -import time +from pyobs.modules.robotic import Scheduler +from pyobs.robotic import Task, TaskRunner, TaskSchedule, TaskArchive +from pyobs.robotic.scripts import Script -from astroplan import ObservingBlock, FixedTarget -import astropy.units as u -from astropy.coordinates import SkyCoord -from pyobs.modules.robotic import Scheduler +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_block_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_block_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_block_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 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/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..e4c61f36d --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_airmass.py @@ -0,0 +1,35 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation, SkyCoord + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import AirmassConstraint +from pyobs.robotic.scheduler.targets import SiderealTarget +from astropy.time import Time + + +@pytest.mark.asyncio +async def test_airmass_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = Task(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 await constraint(time, task, data) is False + + time = Time("2025-11-03T19:00:00", scale="utc") + assert await constraint(time, task, data) is False + + time = Time("2025-11-03T21:00:00", scale="utc") + assert await constraint(time, task, data) is False + + time = Time("2025-11-03T23:00:00", scale="utc") + assert await constraint(time, task, data) is True + + time = Time("2025-11-04T01:00:00", scale="utc") + assert await 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..dc1d2c268 --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_moonillumination.py @@ -0,0 +1,29 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import MoonIlluminationConstraint +from astropy.time import Time + + +@pytest.mark.asyncio +async def test_moonillumination_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = Task(1, "1", 100) + constraint = MoonIlluminationConstraint(0.5) + + time = Time("2025-11-05T13:00:00", scale="utc") + assert await constraint(time, task, data) is False + + time = Time("2025-11-12T05:00:00", scale="utc") + assert await constraint(time, task, data) is False + + time = Time("2025-11-12T08:00:00", scale="utc") + assert await constraint(time, task, data) is True + + time = Time("2025-11-13T0:00:00", scale="utc") + assert await 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..210779632 --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_moonseparation.py @@ -0,0 +1,32 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation, SkyCoord + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import MoonSeparationConstraint +from pyobs.robotic.scheduler.targets import SiderealTarget +from astropy.time import Time + + +@pytest.mark.asyncio +async def test_moonseparation_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = Task(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 await constraint(time, task, data) is True + + time = Time("2025-11-19T11:00:00", scale="utc") + assert await constraint(time, task, data) is True + + time = Time("2025-11-19T17:00:00", scale="utc") + assert await constraint(time, task, data) is False + + time = Time("2025-11-19T23:00:00", scale="utc") + assert await 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..bac0fd77b --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_solarelevation.py @@ -0,0 +1,30 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import SolarElevationConstraint +from astropy.time import Time + + +@pytest.mark.asyncio +async def test_solarelevation_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = Task(1, "1", 100) + + constraint = SolarElevationConstraint(-18.0) + + time = Time("2025-11-03T16:00:00", scale="utc") + assert await constraint(time, task, data) is False + + time = Time("2025-11-03T18:30:00", scale="utc") + assert await constraint(time, task, data) is False + + time = Time("2025-11-03T18:35:00", scale="utc") + assert await constraint(time, task, data) is True + + time = Time("2025-11-03T20:00:00", scale="utc") + assert await 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..6de7b849a --- /dev/null +++ b/tests/robotic/scheduler/constraints/test_time.py @@ -0,0 +1,32 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation, SkyCoord + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.constraints import TimeConstraint +from pyobs.robotic.scheduler.targets import SiderealTarget +from astropy.time import Time + + +@pytest.mark.asyncio +async def test_time_constraint() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + task = Task(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 await constraint(time, task, data) is False + + time = Time("2025-11-03T20:30:00", scale="utc") + assert await constraint(time, task, data) is True + + time = Time("2025-11-03T22:30:00", scale="utc") + assert await constraint(time, task, data) is True + + time = Time("2025-11-03T23:30:00", scale="utc") + assert await constraint(time, task, data) is False 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_aftertime.py b/tests/robotic/scheduler/merits/test_aftertime.py new file mode 100644 index 000000000..567d24d64 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_aftertime.py @@ -0,0 +1,27 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation +import astropy.units as u + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import AfterTimeMerit +from astropy.time import Time, TimeDelta + + +@pytest.mark.asyncio +async def test_aftertime_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + task = Task(1, "1", 100) + + merit = AfterTimeMerit(time) + assert await merit(time, task, data) == 1.0 + + merit = AfterTimeMerit(time) + assert await merit(time - TimeDelta(5.0 * u.second), task, data) == 0.0 + + merit = AfterTimeMerit(time) + assert await 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..89efefcc3 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_beforetime.py @@ -0,0 +1,27 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation +import astropy.units as u + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import BeforeTimeMerit +from astropy.time import Time, TimeDelta + + +@pytest.mark.asyncio +async def test_beforetime_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + task = Task(1, "1", 100) + + merit = BeforeTimeMerit(time) + assert await merit(time, task, data) == 1.0 + + merit = BeforeTimeMerit(time) + assert await merit(time - TimeDelta(5.0 * u.second), task, data) == 1.0 + + merit = BeforeTimeMerit(time) + assert await merit(time + TimeDelta(5.0 * u.second), task, data) == 0.0 diff --git a/tests/robotic/scheduler/merits/test_constant.py b/tests/robotic/scheduler/merits/test_constant.py new file mode 100644 index 000000000..5ee8cc574 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_constant.py @@ -0,0 +1,20 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import ConstantMerit +from astropy.time import Time + + +@pytest.mark.asyncio +async def test_constant_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + task = Task(1, "1", 100) + + merit = ConstantMerit(10) + assert await merit(time, task, data) == 10 diff --git a/tests/robotic/scheduler/merits/test_pernight.py b/tests/robotic/scheduler/merits/test_pernight.py new file mode 100644 index 000000000..20067cf76 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_pernight.py @@ -0,0 +1,33 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation +from astropy.time import Time, TimeDelta +import astropy.units as u + +from pyobs.robotic import Task, ScheduledTask +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import PerNightMerit +from pyobs.robotic.scheduler.observationarchiveevolution import ObservationArchiveEvolution + + +@pytest.mark.asyncio +async def test_pernight_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + archive = ObservationArchiveEvolution(observer) + data = DataProvider(observer, archive) + time = Time.now() + task = Task(1, "1", 100) + scheduled_task = ScheduledTask(task, time, time + TimeDelta(5.0 * u.minute)) + + merit = PerNightMerit(2) + assert await merit(time, task, data) == 1.0 + + await archive.evolve(scheduled_task) + assert await merit(time, task, data) == 1.0 + + await archive.evolve(scheduled_task) + assert await merit(time, task, data) == 0.0 + + await archive.evolve(scheduled_task) + assert await merit(time, task, data) == 0.0 diff --git a/tests/robotic/scheduler/merits/test_random.py b/tests/robotic/scheduler/merits/test_random.py new file mode 100644 index 000000000..93621ca17 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_random.py @@ -0,0 +1,21 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import RandomMerit +from astropy.time import Time + + +@pytest.mark.asyncio +async def test_random_merit() -> None: + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + time = Time.now() + task = Task(1, "1", 100) + + # let somebody have fun when this fails + merit = RandomMerit() + assert -100.0 <= await 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..0a6bebc82 --- /dev/null +++ b/tests/robotic/scheduler/merits/test_timewindow.py @@ -0,0 +1,30 @@ +from __future__ import annotations +import pytest +from astroplan import Observer +from astropy.coordinates import EarthLocation +from astropy.time import Time, TimeDelta +import astropy.units as u + +from pyobs.robotic import Task +from pyobs.robotic.scheduler.dataprovider import DataProvider +from pyobs.robotic.scheduler.merits import TimeWindowMerit + + +@pytest.mark.asyncio +async 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 = Task(1, "1", 100) + + merit = TimeWindowMerit([{"start": time - min5, "end": time + min5}, {"start": time2 - min5, "end": time2 + min5}]) + assert await merit(time, task, data) == 1.0 + assert await 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 await merit(time, task, data) == 0.0 + assert await merit(time + min5 + min5, task, data) == 1.0 diff --git a/tests/robotic/scheduler/test_meritscheduler.py b/tests/robotic/scheduler/test_meritscheduler.py new file mode 100644 index 000000000..3018924c5 --- /dev/null +++ b/tests/robotic/scheduler/test_meritscheduler.py @@ -0,0 +1,163 @@ +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 +from pyobs.robotic.scheduler import DataProvider +from pyobs.robotic.scheduler.merits import ConstantMerit, TimeWindowMerit +from pyobs.robotic.scheduler.meritscheduler import MeritScheduler +from pyobs.utils.time import Time + + +@pytest.mark.asyncio +async def test_evaluate_merits() -> None: + scheduler = MeritScheduler() + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + start = Time.now() + end = start + TimeDelta(5000 * u.second) + + tasks: list[Task] = [ + Task(1, "1", 100, merits=[ConstantMerit(10)]), + Task(1, "1", 100, merits=[ConstantMerit(5)]), + ] + merits = await scheduler.evaluate_constraints_and_merits(tasks, start, end, data) + + assert merits == [10.0, 5.0] + + +@pytest.mark.asyncio +async def test_next_best_task() -> None: + scheduler = MeritScheduler() + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + start = Time.now() + end = start + TimeDelta(5000) + + # two constant merits + tasks: list[Task] = [ + Task(1, "1", 100, merits=[ConstantMerit(10)]), + Task(1, "1", 100, merits=[ConstantMerit(5)]), + ] + best, merit = await scheduler.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 + tasks = [ + Task( + 1, + "1", + 4000, + merits=[ + ConstantMerit(10), + TimeWindowMerit( + [{"start": start + TimeDelta(1000 * u.second), "end": start + TimeDelta(2000 * u.second)}] + ), + ], + ), + Task(1, "1", 4000, merits=[ConstantMerit(5)]), + ] + best, merit = await scheduler.find_next_best_task(tasks, start, end, data) + assert best == tasks[1] + assert merit == 5.0 + + +@pytest.mark.asyncio +async def test_check_for_better_task() -> None: + scheduler = MeritScheduler() + observer = Observer(location=EarthLocation.of_site("SAAO")) + data = DataProvider(observer) + 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] = [ + Task( + 1, + "1", + 4000, + merits=[ + ConstantMerit(10), + TimeWindowMerit( + [{"start": start + TimeDelta(1000 * u.second), "end": start + TimeDelta(2000 * u.second)}] + ), + ], + ), + Task(1, "1", 4000, merits=[ConstantMerit(5)]), + ] + better, time, merit = await scheduler.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 +async def test_fill_for_better_task() -> None: + scheduler = MeritScheduler() + 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 afterward + tasks: list[Task] = [ + Task(1, "1", 1800, merits=[ConstantMerit(10), TimeWindowMerit([{"start": after_start, "end": after_end}])]), + Task(2, "2", 1800, merits=[ConstantMerit(5)]), + Task(3, "3", 300, merits=[ConstantMerit(1)]), + ] + + # note that task 1 will not be scheduled exactly at its start time + schedule = scheduler.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 + + # task 3 fills the hole before task 1 + 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: + scheduler = MeritScheduler() + 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] = [ + Task(1, "1", 1800, merits=[ConstantMerit(10), TimeWindowMerit([{"start": after_start, "end": after_end}])]), + Task(2, "2", 1800, merits=[ConstantMerit(5)]), + Task(3, "3", 300, merits=[ConstantMerit(1)]), + ] + schedule = scheduler.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) + 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 scheduler.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 diff --git a/uv.lock b/uv.lock index f58cc10f0..cd53e5c36 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" @@ -2062,6 +2084,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" @@ -2126,6 +2260,7 @@ dependencies = [ { name = "aiohttp" }, { name = "astroplan" }, { name = "astropy" }, + { name = "astropydantic" }, { name = "astroquery" }, { name = "colour" }, { name = "dacite" }, @@ -2135,6 +2270,7 @@ dependencies = [ { name = "pandas" }, { name = "paramiko" }, { name = "py-expression-eval" }, + { name = "pydantic" }, { name = "pytz" }, { name = "pyyaml" }, { name = "requests" }, @@ -2191,6 +2327,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" }, @@ -2207,6 +2344,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" }, @@ -3109,6 +3247,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"