Observation Plans ================= The ``plan`` module lets you send multi-target observation plans to the Seestar. The Seestar will autonomously slew to each target at the scheduled time, plate-solve, and begin stacking — just like the official app's "Plan" feature. Quick example ------------- .. code-block:: python from seestarpy import plan my_plan = { "plan_name": "Evening Session", "update_time_seestar": "2026.02.28", "list": [ { "target_id": 100000001, "target_name": "M42", "alias_name": "Orion Nebula", "target_ra_dec": [5.588, -5.39], "lp_filter": True, "start_min": 1350, # 22:30 "duration_min": 30, }, { "target_id": 100000002, "target_name": "M31", "alias_name": "Andromeda Galaxy", "target_ra_dec": [0.712, 41.27], "lp_filter": False, "start_min": 1380, # 23:00 "duration_min": 45, }, ], } plan.set_view_plan(my_plan) That's it — the Seestar will wait until 22:30 local time, slew to M42, and start imaging. At 23:00 it will move to M31 automatically. Building a plan dictionary -------------------------- A plan dictionary has three required keys: ``plan_name`` (str) A human-readable name for the plan. ``update_time_seestar`` (str) The date string in ``"yyyy.MM.dd"`` format (e.g. ``"2026.02.28"``). ``list`` (list of dicts) One dictionary per target, with the following fields: .. list-table:: :header-rows: 1 :widths: 20 10 70 * - Field - Type - Description * - ``target_id`` - int - A unique 9-digit integer identifier for each target. * - ``target_name`` - str - Display name (e.g. ``"M 81"``). * - ``alias_name`` - str - An alias, or ``""`` if unused. * - ``target_ra_dec`` - list - ``[RA, Dec]`` — RA in decimal hours [0, 24), Dec in degrees [-90, 90]. * - ``lp_filter`` - bool - ``True`` to use the light-pollution filter for this target. * - ``start_min`` - int - Start time as **minutes since local midnight** (see below). * - ``duration_min`` - int - Observation duration in minutes. Understanding ``start_min`` --------------------------- ``start_min`` is the number of minutes after local midnight (00:00) on the Seestar's internal clock. It is **not** relative to when the plan starts. Some examples: - 21:00 → ``1260`` - 22:30 → ``1350`` - 23:00 → ``1380`` - 00:00 (midnight) → ``1440`` - 01:30 AM → ``1530`` A helper to convert a ``(hours, minutes)`` tuple: .. code-block:: python def to_start_min(hour, minute=0): """Convert a 24h time to start_min for a plan target.""" return hour * 60 + minute to_start_min(22, 30) # 1350 to_start_min(1, 30) # 90 — but for post-midnight, add 1440: 1440 + to_start_min(1, 30) # 1530 .. note:: For targets scheduled after midnight, use values above 1440. The Seestar's clock is synced to the phone/host local time via ``raw.pi_set_time()``. Monitoring a running plan ------------------------- Use :func:`~seestarpy.plan.get_running_plan` to check the status of the currently executing plan: .. code-block:: python from seestarpy import plan vp = plan.get_running_plan() if vp is None: print("No plan is running") else: print(f"State: {vp['state']}") # "working", "cancel", etc. print(f"Plan: {vp['plan']['plan_name']}") # Per-target status (added by firmware) for target in vp["plan"]["list"]: name = target["target_name"] state = target.get("state", "pending") skip = target.get("skip", False) print(f" {name}: {state} (skip={skip})") The firmware adds ``state``, ``lapse_ms``, and ``skip`` fields to each target as the plan progresses. Stopping a plan --------------- .. code-block:: python plan.stop_view_plan() This sends the same ``stop_func`` command that the official app uses. After stopping, ``get_running_plan()`` will show the plan with ``state: "cancel"``. Full workflow example --------------------- A complete script that sets up the Seestar, submits a plan, monitors progress, and then parks: .. code-block:: python import time from seestarpy import raw, plan # Sync time and open the arm raw.pi_set_time() raw.scope_move_to_horizon() # Define a two-target plan tonight = { "plan_name": "Friday Night", "update_time_seestar": "2026.02.28", "list": [ { "target_id": 200000001, "target_name": "M 81", "alias_name": "Bode's Galaxy", "target_ra_dec": [9.926, 69.065], "lp_filter": False, "start_min": 1260, # 21:00 "duration_min": 60, }, { "target_id": 200000002, "target_name": "M 51", "alias_name": "Whirlpool Galaxy", "target_ra_dec": [13.498, 47.195], "lp_filter": False, "start_min": 1320, # 22:00 "duration_min": 90, }, ], } # Send and start the plan plan.set_view_plan(tonight) # Poll until finished or cancelled while True: vp = plan.get_running_plan() if vp is None: break if vp["state"] != "working": print(f"Plan ended with state: {vp['state']}") break # Show progress for t in vp["plan"]["list"]: print(f" {t['target_name']}: {t.get('state', 'pending')}") time.sleep(60) # Park when done raw.iscope_stop_view() raw.scope_park(True) Mosaic plans ------------ Extended objects like the North America Nebula (NGC 7000) are larger than the Seestar's ~0.75 x 1.33 degree field of view. :func:`~seestarpy.plan.create_mosaic_plan` generates a multi-panel observation plan that tiles a rectangular sky region: .. code-block:: python from seestarpy import plan mosaic = plan.create_mosaic_plan( plan_name="NGC 7000 Mosaic", center_ra=20.99, # ~20h 59m center_dec=44.53, width=3.0, # 3° wide height=2.0, # 2° tall delta_ra=1.0, # 1° panel spacing in RA delta_dec=1.0, # 1° panel spacing in Dec t_total=180, # 3 hours total start_min=1320, # 22:00 lp_filter=True, ) print(f"{len(mosaic['list'])} panels, " f"{mosaic['list'][0]['duration_min']} min each") # 6 panels, 30 min each # Inspect the grid before sending for p in mosaic["list"]: ra, dec = p["target_ra_dec"] print(f" {p['target_name']}: RA={ra:.3f}h Dec={dec:.2f}°") # Send to the Seestar plan.set_view_plan(mosaic) Panels are traversed in **boustrophedon** (snake) order — even rows go left-to-right in RA, odd rows go right-to-left — to minimise slew time between consecutive panels. RA spacing is automatically corrected for ``cos(dec)`` so panels cover equal angular extents on the sky. Polygon plans ------------- :func:`~seestarpy.plan.create_polygon_plan` generalises the mosaic concept to **arbitrary sky regions**. Instead of an axis-aligned rectangle, you define the boundary with 3 or more RA/Dec corner points (ordered clockwise or counter-clockwise, non-self-intersecting). The function uses a gnomonic tangent-plane projection internally, so ``cos(dec)`` correction and RA wraparound near 0 h/24 h are handled automatically. **Quadrilateral** — a tilted rectangle that doesn't align with the equatorial grid: .. code-block:: python from seestarpy import plan quad = plan.create_polygon_plan( plan_name="Veil Nebula", corners=[ (20.75, 30.0), # bottom-left (20.95, 30.0), # bottom-right (20.95, 32.0), # top-right (20.75, 32.0), # top-left ], delta_ra=0.5, # grid spacing in degrees delta_dec=0.5, t_total=120, # 2 hours total start_min=1320, # 22:00 ) plan.set_view_plan(quad) **Triangle** — useful for irregularly shaped nebulae: .. code-block:: python tri = plan.create_polygon_plan( plan_name="Triangular Region", corners=[ (12.0, 0.0), (12.2, 0.0), (12.1, 1.5), ], delta_ra=0.4, delta_dec=0.4, t_total=90, start_min=1380, # 23:00 ) **Pentagon or more** — any simple polygon works: .. code-block:: python penta = plan.create_polygon_plan( plan_name="Five-sided region", corners=[ (12.0, 0.0), (12.1, -0.5), (12.2, 0.0), (12.15, 0.5), (12.05, 0.5), ], delta_ra=0.3, delta_dec=0.3, t_total=90, start_min=1320, ) Only grid points that fall **inside** the polygon boundary are included in the plan. If the grid spacing is larger than the entire region, a single pointing at the centroid is generated. The panels use the same boustrophedon traversal and ``cos(dec)``-aware spacing as :func:`~seestarpy.plan.create_mosaic_plan`. .. note:: ``create_quadrilateral_plan`` is available as an alias for ``create_polygon_plan``. Named plans ----------- :func:`~seestarpy.plan.create_named_plan` is the quickest way to build a plan — just list your targets by name and start time: .. code-block:: python from seestarpy import plan p = plan.create_named_plan( plan_name="Friday Night", targets=[ ("M42", "21:00"), ("NGC 884", "22:30"), ("M31", "23:45"), ], end_time="01:00", ) plan.set_view_plan(p) Each target observes from its start time until the next target begins. The last target (M31) observes until ``end_time`` (01:00). In this example, M42 gets 90 min, NGC 884 gets 75 min, and M31 gets 75 min. Target coordinates are resolved automatically using the `CDS Sesame `_ name resolver, which queries SIMBAD, NED, and VizieR. Standard designations all work: Messier (M42), NGC/IC (NGC 884), HD (HD 126675), HIP, Bayer names, etc. An internet connection is required for name resolution. Mixing names and coordinates ^^^^^^^^^^^^^^^^^^^^^^^^^^^^ The target specifier can be either a **name string** or an explicit **(ra_hours, dec_deg) pair**. You can freely mix both in the same plan: .. code-block:: python p = plan.create_named_plan( plan_name="Mixed Session", targets=[ ("M42", "21:00"), # resolved via Sesame ((0.712, 41.27), "22:30"), # explicit RA/Dec ("NGC 7000", "23:30", True), # with LP filter ], end_time="01:00", ) Explicit coordinates skip the network call entirely, which is useful for custom pointings or when working offline. Per-target light-pollution filter ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Each target tuple can optionally include a third element to control the LP filter individually. Targets without the third element use the function-level ``lp_filter`` default (``False``): .. code-block:: python p = plan.create_named_plan( plan_name="LP Filter Mix", targets=[ ("M42", "21:00", True), # LP filter ON ("M31", "22:30", False), # LP filter OFF ("M51", "23:30"), # uses default (False) ], end_time="01:00", lp_filter=False, # default for targets without override ) Midnight wraparound ^^^^^^^^^^^^^^^^^^^^ Times are expected in 24-hour ``"hh:mm"`` format. If a start time is earlier than the previous target's, it is automatically treated as post-midnight (next calendar day): .. code-block:: python p = plan.create_named_plan( plan_name="Late Night", targets=[ ("M42", "23:00"), # 23:00 tonight ("M31", "01:30"), # 01:30 tomorrow — auto-detected ], end_time="03:00", # 03:00 tomorrow ) # M42: start_min=1380, duration=150 min # M31: start_min=1530, duration=90 min Resolving a single target ^^^^^^^^^^^^^^^^^^^^^^^^^^ You can also use the name resolver standalone with :func:`~seestarpy.plan.resolve_name`: .. code-block:: python from seestarpy.plan import resolve_name ra, dec = resolve_name("M42") print(f"RA = {ra:.4f} h, Dec = {dec:.2f}°") # RA = 5.5881 h, Dec = -5.39° This returns ``(ra_hours, dec_deg)`` — the same convention used throughout seestarpy. Visualising plans ----------------- All plan types (mosaic, polygon, named) produce the same plan dictionary format, so :func:`~seestarpy.plan.plot_mosaic_plan` works with any of them: .. code-block:: python plan.plot_mosaic_plan(quad) # polygon plan plan.plot_mosaic_plan(p) # named plan This shows panel footprints on a Mollweide projection with RA/Dec grid lines. Requires ``matplotlib`` and ``numpy``.