Using Constraints
This example shows how to evaluate observational constraints against ephemeris data to determine when targets are visible.
Basic Constraint Evaluation
import datetime as dt
import rust_ephem as re
from rust_ephem.constraints import SunConstraint, MoonConstraint, EclipseConstraint
# Ensure planetary ephemeris is available for Sun/Moon positions
re.ensure_planetary_ephemeris()
# Create ephemeris
tle1 = "1 25544U 98067A 08264.51782528 -.00002182 00000-0 -11606-4 0 2927"
tle2 = "2 25544 51.6416 247.4627 0006703 130.5360 325.0288 15.72125391563537"
begin = dt.datetime(2024, 1, 1, tzinfo=dt.timezone.utc)
end = dt.datetime(2024, 1, 2, tzinfo=dt.timezone.utc)
ephem = re.TLEEphemeris(tle1, tle2, begin, end, 300)
# Target coordinates (Crab Nebula)
target_ra = 83.6333 # degrees
target_dec = 22.0145 # degrees
# Create and evaluate a single constraint
sun_constraint = SunConstraint(min_angle=45.0)
result = sun_constraint.evaluate(ephem, target_ra, target_dec)
print(f"All satisfied: {result.all_satisfied}")
print(f"Number of violations: {len(result.violations)}")
print(f"Total violation duration: {result.total_violation_duration()} seconds")
Combining Constraints
Use Python operators to combine constraints logically:
# Method 1: Using operators (recommended)
combined = (
SunConstraint(min_angle=45.0) & # AND
MoonConstraint(min_angle=10.0) & # AND
~EclipseConstraint(umbra_only=True) # NOT (avoid eclipses)
)
result = combined.evaluate(ephem, target_ra, target_dec)
# Equivalent explicit construction with named intermediate constraints
sun = SunConstraint(min_angle=45.0)
moon = MoonConstraint(min_angle=10.0)
eclipse = EclipseConstraint(umbra_only=True)
constraint = (
sun
& moon
& ~eclipse
)
result = constraint.evaluate(ephem, target_ra, target_dec)
Vectorized Batch Evaluation
Evaluate multiple targets efficiently using vectorized operations:
import numpy as np
# Create 100 random targets
target_ras = np.random.uniform(0, 360, 100) # degrees
target_decs = np.random.uniform(-90, 90, 100) # degrees
# Create constraint
constraint = SunConstraint(min_angle=45.0) & MoonConstraint(min_angle=10.0)
# Batch evaluate (returns 2D boolean array)
# Shape: (n_targets, n_times)
# True = constraint violated, False = satisfied
violations = constraint.in_constraint_batch(ephem, target_ras, target_decs)
print(f"Shape: {violations.shape}") # (100, n_times)
# Find targets that are always visible
always_visible = ~violations.any(axis=1) # No violations at any time
print(f"Always visible targets: {always_visible.sum()}")
# Find visibility fraction for each target
visibility_fraction = (~violations).sum(axis=1) / violations.shape[1]
print(f"Target 0 visibility: {visibility_fraction[0]*100:.1f}%")
Working with Results
result = constraint.evaluate(ephem, target_ra, target_dec)
# Access violations
for violation in result.violations:
print(f"Violation: {violation.start_time} to {violation.end_time}")
print(f" Severity: {violation.max_severity:.2f}")
print(f" Description: {violation.description}")
# Access visibility windows
for window in result.visibility:
print(f"Visible: {window.start_time} to {window.end_time}")
print(f" Duration: {window.duration_seconds:.0f} seconds")
# Check specific times efficiently
constraint_array = result.constraint_array # Boolean array (cached)
for i, is_satisfied in enumerate(constraint_array):
if is_satisfied:
print(f"Visible at {result.timestamp[i]}")
Available Constraint Types
Bright Star Avoidance
Use BrightStarConstraint to prevent bright
stars from entering the telescope field of view (e.g. stray-light or detector
saturation avoidance). Stars are supplied by the user as (ra_deg, dec_deg)
pairs; the get_bright_stars() helper fetches and caches the
Hipparcos catalog so you do not have to manage the catalog yourself.
Two FoV shapes are supported:
Circular — any star within
fov_radiusdegrees of the boresight violates the constraint. Roll is irrelevant.Polygon — a convex or non-convex polygon defined in instrument frame coordinates
(u_deg, v_deg). At roll = 0° the +v axis points north and the +u axis points east. The polygon rotates rigidly with spacecraft roll.
import rust_ephem as re
from rust_ephem import get_bright_stars, Constraint
# Fetch Hipparcos stars brighter than V = 7 (cached after first call)
stars = get_bright_stars(mag_limit=7.0)
# Circular FoV: violated if any star is within 0.5° of the boresight
c_circle = Constraint.bright_star(stars=stars, fov_radius=0.5)
result = c_circle.evaluate(ephem, target_ra, target_dec)
# Rectangular detector FoV (0.5° × 0.3°), checking all roll angles
c_poly = Constraint.bright_star(
stars=stars,
fov_polygon=[(-0.25, -0.15), (0.25, -0.15), (0.25, 0.15), (-0.25, 0.15)],
)
result = c_poly.evaluate(ephem, target_ra, target_dec)
# Evaluate at a specific spacecraft roll (position angle in degrees, east of north)
result = c_poly.evaluate(ephem, target_ra, target_dec, target_roll=45.0)
When target_roll is omitted (or None) for a polygon FoV, the evaluator
sweeps 72 roll angles (5° resolution) across [0°, 360°). The constraint is
violated only when every roll has at least one star inside the polygon — i.e.
it returns False as soon as a clear roll exists. This answers the scheduling
question “is there any valid roll for this pointing?”.
For a broader catalog that serves multiple magnitude limits without re-downloading:
# Download all stars brighter than V = 8 once; return the V < 6 subset now
stars_tight = get_bright_stars(mag_limit=6.0, cache_mag_limit=8.0)
# Later calls for any mag_limit ≤ 8 reuse the on-disk cache instantly
stars_7 = get_bright_stars(mag_limit=7.0) # no network call
The cache is stored as a numpy array in the rust_ephem cache directory
(rust_ephem.get_cache_dir()), keyed by magnitude limit. Pass
refresh=True to force a re-download.
Proximity Constraints
# Sun proximity (min/max angles in degrees)
sun = SunConstraint(min_angle=45.0, max_angle=135.0)
# Moon proximity
moon = MoonConstraint(min_angle=10.0)
# Generic body proximity (requires planetary ephemeris)
from rust_ephem.constraints import BodyConstraint
mars = BodyConstraint(body="Mars", min_angle=15.0)
Earth Limb Constraint
from rust_ephem.constraints import EarthLimbConstraint
# Basic earth limb avoidance
earth_limb = EarthLimbConstraint(min_angle=28.0)
# With atmospheric refraction (for ground observers)
earth_limb_refracted = EarthLimbConstraint(
min_angle=28.0,
include_refraction=True,
horizon_dip=True
)
Eclipse Constraint
# Avoid umbra only
eclipse_umbra = EclipseConstraint(umbra_only=True)
# Avoid umbra and penumbra
eclipse_both = EclipseConstraint(umbra_only=False)
Logical Combinations
from rust_ephem.constraints import (
SunConstraint, MoonConstraint, EclipseConstraint,
AndConstraint, OrConstraint, NotConstraint, XorConstraint, AtLeastConstraint
)
# Using operators
combined = SunConstraint(min_angle=45) & MoonConstraint(min_angle=10)
either = SunConstraint(min_angle=45) | MoonConstraint(min_angle=10)
not_eclipse = ~EclipseConstraint()
# Using explicit classes
combined_explicit = AndConstraint(constraints=[
SunConstraint(min_angle=45),
MoonConstraint(min_angle=10)
])
# Threshold: violated when at least k sub-constraints are violated
k_of_n = AtLeastConstraint(
min_violated=2,
constraints=[
SunConstraint(min_angle=45),
MoonConstraint(min_angle=10),
EclipseConstraint(umbra_only=True),
],
)
# Convenience helper from any constraint instance
k_of_n_helper = SunConstraint(min_angle=45).at_least(
2,
MoonConstraint(min_angle=10),
EclipseConstraint(umbra_only=True),
)
Threshold semantics:
Constraints evaluate to
Truewhen blocked/not visible.min_violated=1is equivalent to OR over violations.min_violated=len(constraints)is equivalent to AND over violations.
Instantaneous Field of Regard (steradians)
For any constraint (single or combined), you can compute instantaneous visible sky area (solid angle) at one timestamp.
The result is returned in steradians and always lies in [0, 4π].
from rust_ephem.constraints import SunConstraint, MoonConstraint, DEFAULT_N_POINTS
constraint = SunConstraint(min_angle=45.0) | MoonConstraint(min_angle=12.0)
# Fastest path: evaluate at an ephemeris index
field_sr = constraint.instantaneous_field_of_regard(
ephemeris=ephem,
index=0,
n_points=DEFAULT_N_POINTS,
)
visible_fraction = field_sr / (4.0 * 3.141592653589793)
print(f"Field of regard: {field_sr:.3f} sr ({visible_fraction:.2%} of full sky)")
You can also evaluate by datetime:
t0 = ephem.timestamp[0]
field_sr = constraint.instantaneous_field_of_regard(
ephemeris=ephem,
time=t0,
n_points=DEFAULT_N_POINTS,
)
Notes:
Exactly one of
timeorindexmust be provided.n_pointscontrols integration accuracy vs speed (higher = more accurate, slower).n_roll_samplescontrols how finely spacecraft roll is swept whentarget_rollis not specified (defaultDEFAULT_N_ROLL_SAMPLES= 360 ≈ 1° resolution). Reduce to speed up at the cost of accuracy; ignored whentarget_rollis given or when no pitch/yaw offset is present.Constraints are
Truewhen blocked/not visible, so field of regard integrates where constraint isFalse.For boresight-offset constraints with non-zero pitch/yaw, the sky is sampled at
n_roll_samplesevenly-spaced spacecraft roll angles whentarget_rollis not specified. A direction is counted accessible if any roll angle satisfies the inner constraint, modelling a spacecraft that can rotate about its pointing axis. The evaluation scales withn_roll_samples; the default 72 is ~72× slower than a single-roll evaluation at the samen_points. Passtarget_rollto pin spacecraft roll and recover the faster single-pass evaluation.
JSON Serialization
Constraints can be serialized to/from JSON for configuration files:
# Serialize to JSON
constraint = SunConstraint(min_angle=45.0) & MoonConstraint(min_angle=10.0)
json_str = constraint.model_dump_json()
print(json_str)
# {"type": "and", "constraints": [{"type": "sun", "min_angle": 45.0, ...}, ...]}
# Load from JSON
rust_constraint = re.Constraint.from_json(json_str)
result = rust_constraint.evaluate(ephem, target_ra, target_dec)
Performance Tips
Use batch evaluation for multiple targets — 3-50x faster than loops
Reuse constraint objects — they cache internal Rust objects
Access ``constraint_array`` property for efficient iteration over times
Use ``times`` or ``indices`` parameters to evaluate only specific times
# Evaluate only at specific times
specific_times = [
dt.datetime(2024, 1, 1, 12, 0, tzinfo=dt.timezone.utc),
dt.datetime(2024, 1, 1, 18, 0, tzinfo=dt.timezone.utc)
]
result = constraint.evaluate(ephem, ra, dec, times=specific_times)
# Or use indices
result = constraint.evaluate(ephem, ra, dec, indices=[0, 10, 20])
Tracking Moving Bodies with Horizons
Use the Constraint.evaluate_moving_body() method to track solar system bodies (asteroids,
comets, spacecraft) with automatic JPL Horizons fallback:
# Constraint for observation planning
constraint = SunConstraint(min_angle=30) & MoonConstraint(min_angle=15)
# Track Ceres (asteroid 1)
result = constraint.evaluate_moving_body(
ephemeris=ephem,
body="1", # Ceres
use_horizons=True # Enable JPL Horizons fallback
)
print(f"Visibility windows: {len(result.visibility)}")
for window in result.visibility:
duration = (window.end_time - window.start_time).total_seconds()
print(f" {window.start_time} to {window.end_time} ({duration:.0f}s)")
The use_horizons=True flag enables automatic fallback to NASA’s JPL Horizons
system when a body is not found in local SPICE kernels. This allows tracking of
asteroids, comets, and spacecraft without requiring additional configuration.
Key Features:
SPICE-first lookup — Uses fast cached SPICE kernels when available
Automatic fallback — Queries JPL Horizons only when SPICE lacks the body
Constraint integration — Works with all constraint types and combinations
Full accuracy — Returns observer-relative positions with proper frame conversions
For detailed Horizons documentation including asteroid tracking examples, constraint combinations, and troubleshooting, see JPL Horizons Integration.