Setting up a minimal robotic observation system
This recipe shows how to configure a self-contained robotic observation system using only pyobs-core. By the end you will have a working setup where:
tasks are defined as YAML files on disk,
the scheduler calculates which task to run next based on constraints and merits,
the mastermind executes each task in sequence,
and a custom script does the actual observing work.
The recipe deliberately avoids external services (no HTTP backend, no LCO portal) so you can run it on a single machine with only a simulated telescope and camera.
Note
This recipe builds on the simulation setup in Setting up a pyobs system with simulated telescope and camera. Make sure you have a working
telescope.yaml and camera.yaml before continuing.
Overview
A robotic pyobs system has five moving parts:
Component |
Role |
|---|---|
TaskArchive |
Reads the pool of available tasks (from files, a database, or a remote service). |
ObservationArchive |
Persists the computed schedule and tracks which observations are pending, running, or done. |
Scheduler module |
Runs the |
Mastermind module |
Watches the |
Script |
The actual observing logic: move telescope, expose camera, save image. |
Step 1 — Write a task YAML file
Tasks are the unit of work in the robotic system. Each task carries scheduling metadata
(constraints, merits, target) and a script block that defines what to do when it runs.
Create a directory /opt/pyobs/robotic/tasks/ and save this as observe_m51.yaml:
name: Observe M51
duration: 120.0
priority: 1.0
target:
class: pyobs.robotic.scheduler.targets.SiderealTarget
ra: 202.47
dec: 47.20
constraints:
- class: pyobs.robotic.scheduler.constraints.AirmassConstraint
max_airmass: 2.0
merits:
- class: pyobs.robotic.scheduler.merits.ConstantMerit
merit: 1.0
script:
class: myobs.scripts.ObserveScript
camera: camera
telescope: telescope
exposure_time: 30.0
num_exposures: 3
duration is in seconds and tells the scheduler how long to reserve for this task.
constraints define when the task may run; merits influence which task among eligible
ones is chosen first. Both accept lists so multiple can be stacked.
What a frame was asked for
The Mastermind writes the priorities and the constraints of the running
task into the FITS header of every frame, next to TASK, REQNUM, PROJECT and OBSNUM. The
values are those of the task when the run started, so they stay available after the task has been edited:
REQ MERIT is the value the scheduler ranked the slot by when it planned it: the merit of the task times
its priority times the priority of its project, evaluated for the planned start. It is stored with the
observation (Observation.priority) and can differ from what the scheduler would compute at the actual start
of the run, because the task may start a little later, or priorities may have been edited since. Only
schedulers that fill Observation.priority produce it, today the OnDemandScheduler; a scheduler of your
own has to set it, otherwise the card is missing. The portal keeps it from the first pyobs-portal release after 2.5.5 on (older portals
drop it).
The cards for Moon distance, Moon illumination and Sun elevation use the same names as the values measured
by the telescope (MOONDIST, MOONFRAC, SUNALT), so a frame can be compared with the limit it ran
under. If a task has several constraints of one type, the later ones get a number on the second word:
HIERARCH REQ SUNALT2 MAX, HIERARCH REQ TIME2 START. Cards are only written for what exists: a task
without constraints has none, and a task without a priority has no REQ PRIORITY.
Solar elevation bounds that impose no limit (minimum -90 degrees or maximum +90 degrees)
are omitted. Effective default limits, such as the maximum of -18 degrees, are still written.
A constraint of your own writes nothing until it declares a req_token (the second word of its cards,
e.g. "SEEING") and a req_fields() method returning the values by name ({"MAX": (2.5, "arcsec")}).
The whole card must fit into 80 characters, so keep comments short.
Observing periods for night and solar telescopes
FollowMerit and PerNightMerit use the same observing-history period. On
OnDemandScheduler, mode: night (the default) resets history at sunrise; mode: day
resets at sunset. These event-to-event cycles keep an evening-to-morning night session or a
morning-to-evening solar session together, even when it crosses UTC midnight. Task constraints
still determine when observing is allowed.
For a solar telescope, set the mode on the planner, for example:
scheduler:
class: pyobs.robotic.scheduler.OnDemandScheduler
mode: day
This setting is independent of an observation archive’s own mode. If a filesystem archive
uses day mode for its files, configure the planner to match. Portal archive mode alone does not
select the planner’s history cycle.
A completed observation belongs to the period in which it started: the opening event is included, the closing event is excluded. The next sunrise resets night-mode prerequisites and counts; the next sunset resets day-mode prerequisites and counts. A night calibration after sunrise cannot follow a prerequisite from before sunrise; similarly, solar follow-up work after sunset belongs to a new period. Sites without the required daily sunrise/sunset receive an explicit error; polar observing periods are not inferred.
Custom planners using DataProvider or ObservationArchiveEvolution should pass
mode="day" for solar work. The compatibility method DataProvider.night(time) now
returns the UTC date of the selected period’s opening event. It is a history key, not a UTC
calendar-day query. Archive reads must still finish before freezing the scheduler history for
worker-thread merit evaluation.
Step 2 — Write a Script
A Script is a pydantic model that implements the actual
observing logic. Create myobs/scripts.py:
import logging
from contextlib import AsyncExitStack
from typing import TYPE_CHECKING
from pyobs.interfaces import ICamera, IPointingRaDec
from pyobs.robotic.scripts import Script
if TYPE_CHECKING:
from pyobs.robotic.task import TaskData
log = logging.getLogger(__name__)
class ObserveScript(Script):
camera: str = "camera"
telescope: str = "telescope"
exposure_time: float = 30.0
num_exposures: int = 1
async def can_run(self, data: TaskData | None) -> bool:
return await self.comm.has_proxy(self.camera, ICamera) and await self.comm.has_proxy(
self.telescope, IPointingRaDec
)
async def run(self, data: TaskData | None) -> None:
if data is None or data.task.target is None:
raise ValueError("No target.")
async with AsyncExitStack() as stack:
camera = await stack.enter_async_context(self.comm.proxy(self.camera, ICamera))
telescope = await stack.enter_async_context(self.comm.proxy(self.telescope, IPointingRaDec))
from pyobs.utils.time import Time
target = data.task.target.coordinates(Time.now())
log.info("Moving telescope to %s...", data.task.target.name)
await telescope.move_radec(target.ra.deg, target.dec.deg)
for i in range(self.num_exposures):
log.info("Taking exposure %d/%d...", i + 1, self.num_exposures)
await camera.set_exposure_time(self.exposure_time)
await camera.grab_data(broadcast=True)
log.info("Done.")
Two things worth noting:
can_runis called by the scheduler before each scheduling cycle to determine whether the task is currently executable. ReturnFalseif required hardware is unavailable.runhas access to the fullTaskDataincluding theObservationArchiveandTaskArchive, if needed.
Step 3 — Configure the Scheduler module
The Scheduler module runs the scheduling loop. Save as
scheduler.yaml:
{include _environment.yaml}
class: pyobs.modules.robotic.Scheduler
comm:
class: pyobs.comm.xmpp.XmppComm
jid: scheduler@my.observatory.org
vfs:
class: pyobs.vfs.VirtualFileSystem
roots:
robotic:
class: pyobs.vfs.LocalFile
root: /opt/pyobs/robotic/
scheduler:
class: pyobs.robotic.scheduler.OnDemandScheduler
twilight: astronomical
constraints:
- class: pyobs.robotic.scheduler.constraints.SolarElevationConstraint
max_solar_elevation: -12.0
tasks:
class: pyobs.robotic.storage.filesystem.YamlTaskArchive
path: /robotic/tasks/
schedule:
class: pyobs.robotic.storage.filesystem.YamlObservationArchive
path: /opt/pyobs/robotic/observations/
schedule_range: 8.0
safety_time: 60
The constraints block on OnDemandScheduler defines global constraints that apply to
every task, in addition to each task’s own constraints. Here we require the sun to be at least
12° below the horizon. schedule_range limits how far into the future the scheduler plans
(in hours).
Step 4 — Configure the Mastermind module
The Mastermind module watches the schedule and runs each
observation when its time comes. Save as mastermind.yaml:
{include _environment.yaml}
class: pyobs.modules.robotic.Mastermind
comm:
class: pyobs.comm.xmpp.XmppComm
jid: mastermind@my.observatory.org
vfs:
class: pyobs.vfs.VirtualFileSystem
roots:
robotic:
class: pyobs.vfs.LocalFile
root: /opt/pyobs/robotic/
schedule:
class: pyobs.robotic.storage.filesystem.YamlObservationArchive
path: /opt/pyobs/robotic/observations/
runner:
class: pyobs.robotic.TaskRunner
tasks:
class: pyobs.robotic.storage.filesystem.YamlTaskArchive
path: /robotic/tasks/
allowed_late_start: 120
allowed_overrun: 60
allowed_late_start is how many seconds past a scheduled start time the mastermind will still
attempt to run a task. allowed_overrun is how far past the scheduled end time a running task
is allowed to continue before being aborted.
Step 5 — Run the system
Start all five modules, each in its own terminal:
pyobs telescope.yaml
pyobs camera.yaml
pyobs scheduler.yaml
pyobs mastermind.yaml
Once the scheduler has run, check /opt/pyobs/robotic/observations/ for a YAML file containing
the computed schedule. The mastermind will pick it up and begin executing tasks at the right time.
To trigger an immediate reschedule (e.g. after adding a new task file), call the scheduler’s
run method from the GUI or via the CLI. The mastermind also asks for one automatically when it
has to skip a task whose start window is already more than allowed_late_start seconds in the
past: it emits a TaskSkippedEvent, which the scheduler turns into a
re-plan.
Where to go next
Add more task YAML files to
/robotic/tasks/to build up an observing queue.Implement more
Scriptsubclasses for different observation types (flat fields, focus runs, spectroscopy) — see Scripts (pyobs.robotic.scripts) for the full list of built-in scripts and the writing guide.Replace
YamlTaskArchiveandYamlObservationArchivewithPortalTaskArchiveandPortalObservationArchiveto use the pyobs-portal web service for multi-telescope coordination — see Scheduling (pyobs.robotic.scheduler).Add
TransitMeritorTimeWindowMeritto the task YAML files for more sophisticated scheduling — see Scheduling (pyobs.robotic.scheduler) for the full constraint and merit reference.Read Serialization (pyobs.utils.serialization) to understand how pydantic models and the
class:key work together, which is useful when writing customScriptorConstraintsubclasses.