Plan an imaging session in Python

Deep-sky imaging is won before the telescope comes out: you want the darkest moonless window of the coming weeks, and you want your target near its highest while that window is open. About thirty lines of Python answer both, with the thresholds stated instead of guessed.

What you need

Step 1: what the two calls answer

/v2/dark-window ranks up to 62 nights by genuinely dark, moonless minutes: dark means the Sun's center at or below -18 degrees, moonless means the Moon below the horizon, and every boundary is root-found rather than sampled. /v2/rise-set then takes your target as plain coordinates, radec:<RA hours>,<Dec degrees>, and returns its rise, upper transit and set, with the altitude at each. The upper transit is the moment your target is highest, which is where the least air sits between you and it.

Step 2: the script

Save this as imaging_plan.py. It was run as written before being published here.

# imaging_plan.py: pick the best night, then check your target rides high in it.
# Python 3.11 or newer, one dependency: pip install requests
from datetime import datetime
import requests

LAT, LON, TZ = 38.72, -9.14, "Europe/Lisbon"   # Lisbon; use your own site
TARGET = "radec:0.712,41.269"                   # M31: RA in hours, Dec in degrees
API = "https://www.cyclecalcs.com/v2"

# 1. The best of the next 14 nights: the longest genuinely dark, moonless window.
dw = requests.get(API + "/dark-window", params={
    "lat": LAT, "lon": LON, "tz": TZ, "nights": 14}, timeout=30).json()["data"]
best = dw["nights"][dw["best_night_index"]]
window = best["usable_intervals"][0]
start = datetime.fromisoformat(window["start"])
end = datetime.fromisoformat(window["end"])
print(best["label"] + ": " + best["verdict"])
print("Moonless dark: " + window["start"] + " to " + window["end"]
      + " (" + str(round(best["usable_minutes"])) + " minutes)")

# 2. Where the target is that night. The API's day runs solar midnight to
# solar midnight, and a night straddles that boundary, so anchor the query
# at the middle of the dark window rather than at the night's date.
mid = start + (end - start) / 2
rs = requests.get(API + "/rise-set", params={
    "body": TARGET, "lat": LAT, "lon": LON, "tz": TZ,
    "at": mid.isoformat()}, timeout=30).json()["data"]
for ev in rs["body"]["rise_set"]["events"]:
    if ev["kind"] == "upper_transit":
        t = datetime.fromisoformat(ev["instant"])
        where = "inside" if start <= t <= end else "outside"
        print("Target transits at " + ev["instant"] + " at "
              + str(round(ev["altitude_deg"])) + " degrees altitude, "
              + where + " the moonless window")

Run at 10:48 UTC on 2026-08-13, it printed:

$ python imaging_plan.py
Night of Saturday 15 August 2026: excellent
Moonless dark: 2026-08-15T22:09:58.296+01:00 to 2026-08-16T05:12:23.444+01:00 (422 minutes)
Target transits at 2026-08-16T04:42:32.003+01:00 at 87 degrees altitude, inside the moonless window

That is a complete plan in three lines: which night, when the sky is genuinely yours, and that M31 crosses the meridian at 87 degrees before the window closes.

Why the script anchors at the window's middle

One subtlety in the code is worth understanding, because it will bite any astronomy script eventually: the API's day runs from solar midnight to solar midnight (the Sun's lower transit), and an observing night straddles that boundary by construction. Ask for your target on "the night's date" and you get the transit from the wrong side of the boundary: the first draft of this very script reported a transit eighteen hours before the window it had just chosen. Anchoring the query at the middle of the dark window puts it on the right side, every time, at any longitude.

The same plan, with the package

There is also a client on PyPI. It carries no dependencies of its own, so it costs your lockfile nothing beyond itself, and it turns the two calls above into two method calls with the query building, the encoding and the error handling already done.

pip install cyclecalcs
# imaging_plan.py, the same plan with the package doing the plumbing.
# pip install cyclecalcs
from datetime import datetime

from cyclecalcs import CycleCalcs

LAT, LON, TZ = 38.72, -9.14, "Europe/Lisbon"   # Lisbon; use your own site
TARGET = "radec:0.712,41.269"                   # M31: RA in hours, Dec in degrees

cc = CycleCalcs()

# 1. The best of the next 14 nights: the longest genuinely dark, moonless window.
dw = cc.dark_window(lat=LAT, lon=LON, tz=TZ, nights=14).data
best = dw["nights"][dw["best_night_index"]]
window = best["usable_intervals"][0]
start = datetime.fromisoformat(window["start"])
end = datetime.fromisoformat(window["end"])
print(best["label"] + ": " + best["verdict"])
print("Moonless dark: " + window["start"] + " to " + window["end"]
      + " (" + str(round(best["usable_minutes"])) + " minutes)")

# 2. Where the target is that night. The middle of the dark window is an aware
# datetime, and the client sends an aware datetime as the instant it names, so
# the query lands inside the window rather than on the wrong side of midnight.
mid = start + (end - start) / 2
rs = cc.rise_set(body=TARGET, lat=LAT, lon=LON, tz=TZ, at=mid).data
for ev in rs["body"]["rise_set"]["events"]:
    if ev["kind"] == "upper_transit":
        t = datetime.fromisoformat(ev["instant"])
        where = "inside" if start <= t <= end else "outside"
        print("Target transits at " + ev["instant"] + " at "
              + str(round(ev["altitude_deg"])) + " degrees altitude, "
              + where + " the moonless window")

Run at 20:04 UTC on 2026-08-13, it printed the same three lines as the version above, to the character. Which one you use is a matter of taste: the requests version has nothing to install and shows you the wire, the package gives you typed methods, an exception per failure with the API's own error code on it, and paging that follows itself. Both talk to the same endpoints and get the same answers.

Step 3: go further

Stuck, or found an error in this guide? Contact us at info@cyclecalcs.com.

More build guides