Greedy observing-calendar scheduler for radial-velocity follow-up campaigns
Project description
rvcadence
Greedy observing-calendar scheduler for radial-velocity follow-up campaigns.
Given a number of observations to schedule, one or more planets' orbital
periods (and, optionally, the star's rotation period), and a set of
visibility windows, rvcadence picks the calendar dates that best cover all
phase cycles while keeping observations temporally spread out. Optionally
excludes nights where the Moon is too close to the target for
high-resolution spectroscopy.
Full-quality version: examples/explainer/CadenceGreedyExplainer.mp4
Install
pip install rvcadence # core scheduler, zero dependencies
pip install rvcadence[astro] # + name/site resolution, visibility, lunar pollution avoidance (astropy)
Quickstart
from datetime import date
from rvcadence import plan_calendar
result = plan_calendar(
n_obs=20,
periods_d=9.53,
season_start=date(2026, 5, 1),
season_end=date(2027, 4, 30),
rotation_period_d=12.45,
windows="2026-05-01 to 2026-05-19; 2026-07-29 to 2027-04-30",
)
print(result.dates)
print(f"median gap: {result.median_gap_d} d, mean gap: {result.mean_gap_d} d")
Or let rvcadence compute visibility itself, from a target name and site,
instead of hand-typing windows (requires pip install rvcadence[astro]):
result = plan_calendar(
n_obs=20,
periods_d=9.53,
season_start=date(2026, 5, 1),
season_end=date(2027, 4, 30),
target_coord="K2-182",
observer_location="Paranal Observatory",
min_altitude_deg=30.0,
)
For a multi-planet system, pass a list of periods — coverage is optimized for the worst-covered planet at each step, not an average:
result = plan_calendar(
n_obs=20,
periods_d=[9.53, 21.7], # two known/candidate planets
season_start=date(2026, 5, 1),
season_end=date(2027, 4, 30),
)
Composable constraints
windows text, lunar avoidance, and astropy-computed visibility are
independent, composable constraints — any subset may be supplied, and
supplied constraints intersect:
windows: manually-specified date ranges (string or pre-parsed list).min_moon_sep_deg(needstarget_coord+observer_location): excludes nights where the Moon is within this separation of the target, evaluated at local solar midnight. Default 30°, a widely-used rule-of-thumb avoidance radius against lunar scattered-light contamination in high-resolution spectroscopy; passNoneto disable even when a target is given. Nights are sunset-labeled (the night of dateDruns from sunset onDto sunrise onD+1).min_altitude_deg(needstarget_coord+observer_location): excludes nights where the target doesn't clear this altitude, AND the Sun isn't belowtwilight_sun_alt_deg(default -18°, astronomical twilight), both evaluated at the target's transit (culmination) time that night — not local midnight. This deliberately differs from the moon check: for the Moon, midnight-vs-transit is negligible, but for target altitude it would be a real correctness bug (a target transiting at 9pm could be wrongly excluded by a midnight-only check on a night it's perfectly observable). Off by default (None) — there is no universally correct altitude.
target_coord/observer_location accept either already-resolved astropy
objects (SkyCoord/EarthLocation) or plain name strings — a string is
resolved once per call (CDS Sesame / astropy's site registry) and the
resolved value is printed for confirmation.
import astropy.units as u
from astropy.coordinates import EarthLocation, SkyCoord
paranal = EarthLocation(lat=-24.6272 * u.deg, lon=-70.4039 * u.deg, height=2635 * u.m)
target = SkyCoord(ra=123.45 * u.deg, dec=-12.3 * u.deg)
result = plan_calendar(
n_obs=20,
periods_d=9.53,
season_start=date(2026, 5, 1),
season_end=date(2027, 4, 30),
target_coord=target,
observer_location=paranal,
min_moon_sep_deg=30.0,
min_altitude_deg=30.0,
)
How it works
Starting from the first and last available dates, the algorithm repeatedly adds the candidate date that best fills gaps in planet-orbital-phase coverage (and stellar-rotation-phase coverage, if known), weighted against how far it is in time from already-selected dates. For multiple planets, phase coverage is the worst-case across all periods — a candidate only scores well if it improves coverage for whichever planet is currently least-covered, so one well-phased planet can't mask a poorly-phased one:
score = 0.55 · d_planet_phase + 0.30 · d_rotation_phase + 0.15 · d_time_spread (rotation period known)
score = 0.80 · d_planet_phase + 0.20 · d_time_spread (rotation period unknown)
See examples/explainer/ for the full animated walkthrough (manim source +
rendered mp4) — the example imports its scoring logic directly from this
package (see tests/test_explainer_example.py), so it can't drift out of
sync with the real algorithm.
Development
git clone <repo-url>
cd rvcadence
pip install -e ".[dev,astro]"
pytest
License
MIT
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file rvcadence-0.2.0.tar.gz.
File metadata
- Download URL: rvcadence-0.2.0.tar.gz
- Upload date:
- Size: 3.4 MB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
6d72d20083c5ec34358a4ef8e0111b614b84f5fb9b813f6dfbf4eda6fc50068d
|
|
| MD5 |
03ff4afcabc6e27fbef06693fde39c17
|
|
| BLAKE2b-256 |
c7797fbe455f9bc501d064c5729fade4d6b83f757f2abb4e6c2598649ac80775
|
Provenance
The following attestation bundles were made for rvcadence-0.2.0.tar.gz:
Publisher:
publish.yml on pierrotlamontagne/rvcadence
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rvcadence-0.2.0.tar.gz -
Subject digest:
6d72d20083c5ec34358a4ef8e0111b614b84f5fb9b813f6dfbf4eda6fc50068d - Sigstore transparency entry: 2139901269
- Sigstore integration time:
-
Permalink:
pierrotlamontagne/rvcadence@4c8c7e42cfb0ca69745d23ab6c4ecaf09cb57685 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/pierrotlamontagne
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4c8c7e42cfb0ca69745d23ab6c4ecaf09cb57685 -
Trigger Event:
release
-
Statement type:
File details
Details for the file rvcadence-0.2.0-py3-none-any.whl.
File metadata
- Download URL: rvcadence-0.2.0-py3-none-any.whl
- Upload date:
- Size: 12.8 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
f5ca527d5bde4dbf31c297f7081f522a6ab1ae8584b2e1ba2e78924ee3373747
|
|
| MD5 |
c91fba27b811c744ac17e14bd5a71fc7
|
|
| BLAKE2b-256 |
ab44f5dd5a6276e30bf7ed51804ca8c59061294bbbb4ad691c3d50b34b569aba
|
Provenance
The following attestation bundles were made for rvcadence-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on pierrotlamontagne/rvcadence
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
rvcadence-0.2.0-py3-none-any.whl -
Subject digest:
f5ca527d5bde4dbf31c297f7081f522a6ab1ae8584b2e1ba2e78924ee3373747 - Sigstore transparency entry: 2139901380
- Sigstore integration time:
-
Permalink:
pierrotlamontagne/rvcadence@4c8c7e42cfb0ca69745d23ab6c4ecaf09cb57685 -
Branch / Tag:
refs/tags/v0.2.0 - Owner: https://github.com/pierrotlamontagne
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@4c8c7e42cfb0ca69745d23ab6c4ecaf09cb57685 -
Trigger Event:
release
-
Statement type: