Skip to main content

Visibility Module

The Visibility module provides tools for planning astronomical observations—calculating airmass, transit times, rise/set events, and assessing overall target observability.

Overview

Planning a successful observation requires knowing:

  • When is the target above the horizon?
  • What's the best time to observe (lowest airmass)?
  • Will moonlight interfere?
  • Is it actually dark enough?

This module answers all these questions.

Quick Start

CLI Usage

# Target altitude now
starward vis altitude "12h00m00s" "+45d00m00s" --lat 51.5 --lon -0.1

# Current airmass
starward vis airmass "12h00m00s" "+45d00m00s" --lat 51.5 --lon -0.1

# Transit time
starward vis transit "12h00m00s" "+45d00m00s" --lat 51.5 --lon -0.1

# Rise and set times
starward vis riseset "12h00m00s" "+45d00m00s" --lat 51.5 --lon -0.1

# Moon separation
starward vis moonsep "12h00m00s" "+45d00m00s"

Python API

from starward.core.visibility import (
airmass, target_altitude, target_azimuth,
transit_time, transit_altitude_calc, target_rise_set,
moon_target_separation, is_night, compute_visibility
)
from starward.core.coords import ICRSCoord
from starward.core.observer import Observer
from starward.core.time import jd_now
from starward.core.angles import Angle

# Setup
m31 = ICRSCoord.parse("00h42m44s +41d16m09s") # Andromeda Galaxy
observer = Observer.from_degrees("Home", 40.7, -74.0)
jd = jd_now()

# Current visibility
alt = target_altitude(m31, observer, jd)
az = target_azimuth(m31, observer, jd)
X = airmass(alt)

print(f"Altitude: {alt.degrees:.1f}°")
print(f"Azimuth: {az.degrees:.1f}°")
print(f"Airmass: {X:.2f}")

# Best time to observe
transit = transit_time(m31, observer, jd)
max_alt = transit_altitude_calc(m31, observer)
print(f"Transit: {transit.to_datetime()}")
print(f"Max altitude: {max_alt.degrees:.1f}°")

Airmass

Airmass measures how much atmosphere light must traverse. Lower is better.

AirmassAltitudeQuality
1.090° (zenith)Excellent
1.256°Very good
1.542°Good
2.030°Acceptable
3.019°Poor
0° (horizon)Below horizon
from starward.core.visibility import airmass
from starward.core.angles import Angle

# From altitude
alt = Angle(degrees=45)
X = airmass(alt)
print(f"Airmass at 45°: {X:.2f}") # ~1.41

# From target
alt = target_altitude(target, observer, jd)
X = airmass(alt)

Airmass Limits

Most observations should stay below airmass 2.0:

def is_observable(target, observer, jd, max_airmass=2.0):
alt = target_altitude(target, observer, jd)
if alt.degrees <= 0:
return False
return airmass(alt) < max_airmass

Target Altitude and Azimuth

# Current position in local sky
alt = target_altitude(target, observer, jd)
az = target_azimuth(target, observer, jd)

print(f"Position: Alt {alt.degrees:.1f}°, Az {az.degrees:.1f}°")

# Azimuth convention: 0°=N, 90°=E, 180°=S, 270°=W

Transit Time

Transit is when an object crosses the local meridian (highest altitude):

# Time of next transit
transit = transit_time(target, observer, jd)
print(f"Transit at: {transit.to_datetime()}")

# Maximum altitude (achieved at transit)
max_alt = transit_altitude_calc(target, observer)
print(f"Maximum altitude: {max_alt.degrees:.1f}°")

# Minimum airmass
min_airmass = airmass(max_alt)
print(f"Best airmass: {min_airmass:.2f}")

Transit Altitude Formula

For any observer at latitude φ and target at declination δ:

max altitude=90°φδ\text{max altitude} = 90° - |φ - δ|

# A target at declination +40° viewed from latitude +40°
# transits at zenith (90° altitude)

Rise and Set Times

# Get rise and set times
rise, set_t = target_rise_set(target, observer, jd)

if rise and set_t:
print(f"Rises: {rise.to_datetime()}")
print(f"Sets: {set_t.to_datetime()}")

# Duration above horizon
hours_up = (set_t.jd - rise.jd) * 24
print(f"Above horizon: {hours_up:.1f} hours")

Circumpolar Objects

Objects that never set (circumpolar) or never rise:

rise, set_t = target_rise_set(target, observer, jd)

if rise is None and set_t is None:
max_alt = transit_altitude_calc(target, observer)
if max_alt.degrees > 0:
print("Circumpolar - always above horizon")
else:
print("Never rises at this latitude")

Moon Separation

For deep-sky imaging, moonlight is the enemy:

from starward.core.visibility import moon_target_separation

sep = moon_target_separation(target, jd)
print(f"Moon separation: {sep.degrees:.1f}°")

# Rule of thumb
if sep.degrees < 30:
print("⚠️ Moon nearby - may affect observations")
elif sep.degrees < 60:
print("Moon moderately close")
else:
print("Good moon separation")

Night Check

Is it actually dark?

from starward.core.visibility import is_night

if is_night(observer, jd):
print("It's nighttime!")
else:
print("Sun is up")

For astronomical darkness (Sun below -18°), combine with twilight:

from starward.core.sun import astronomical_twilight

astro_start, astro_end = astronomical_twilight(observer, jd)
# True darkness is between these times

Comprehensive Visibility

The compute_visibility() function provides a complete assessment:

from starward.core.visibility import compute_visibility

vis = compute_visibility(target, observer, jd)

# vis is a dictionary containing:
# - altitude: current altitude
# - azimuth: current azimuth
# - airmass: current airmass
# - transit_time: time of transit
# - transit_altitude: maximum altitude
# - rise_time: rise time
# - set_time: set time
# - moon_separation: angular distance from Moon
# - is_night: whether Sun is down
# - is_observable: overall assessment

Planning an Observing Session

Example workflow for planning observations:

from starward.core.visibility import *
from starward.core.sun import sunset, astronomical_twilight
from starward.core.moon import moon_phase

# Tonight's observation planning
observer = Observer.from_degrees("Home", 40.7, -74.0)
jd_start = JulianDate.from_calendar(2024, 3, 15, 20, 0, 0) # 8 PM local

# When does it get dark?
astro_start, astro_end = astronomical_twilight(observer, jd_start)
print(f"Astronomical darkness: {astro_start.to_datetime()} to {astro_end.to_datetime()}")

# Moon situation
phase = moon_phase(jd_start)
print(f"Moon phase: {phase.name} ({phase.percent_illuminated:.0f}%)")

# Target visibility
targets = [
("M31", ICRSCoord.parse("00h42m44s +41d16m09s")),
("M42", ICRSCoord.parse("05h35m17s -05d23m28s")),
("M51", ICRSCoord.parse("13h29m53s +47d11m43s")),
]

print("\nTarget Visibility Tonight:")
for name, coord in targets:
vis = compute_visibility(coord, observer, jd_start)
print(f"\n{name}:")
print(f" Transit: {vis['transit_time'].to_datetime().strftime('%H:%M')}")
print(f" Max alt: {vis['transit_altitude'].degrees:.0f}°")
print(f" Moon sep: {vis['moon_separation'].degrees:.0f}°")

Altitude Over Time

Track altitude throughout the night:

import matplotlib.pyplot as plt

times = []
altitudes = []

# Sample every 30 minutes for 12 hours
for i in range(25):
jd_sample = JulianDate(jd_start.jd + i * 0.5/24)
alt = target_altitude(target, observer, jd_sample)
times.append(i * 0.5)
altitudes.append(alt.degrees)

plt.plot(times, altitudes)
plt.xlabel("Hours from start")
plt.ylabel("Altitude (degrees)")
plt.axhline(y=30, color='r', linestyle='--', label='30° limit')
plt.title("Target Altitude Tonight")
plt.legend()
plt.show()

CLI Reference

# Altitude
starward vis altitude RA DEC --lat LAT --lon LON [--jd JD] [--json]

# Airmass
starward vis airmass RA DEC --lat LAT --lon LON [--jd JD]

# Transit
starward vis transit RA DEC --lat LAT --lon LON [--jd JD]

# Rise/Set
starward vis riseset RA DEC --lat LAT --lon LON [--jd JD]

# Moon separation
starward vis moonsep RA DEC [--jd JD]

# Full visibility report
starward vis report RA DEC --lat LAT --lon LON [--jd JD] [--json]

See Also