2026-04-16 17:52:59 +02:00
|
|
|
import pytest
|
|
|
|
|
from datetime import datetime, timezone, timedelta
|
|
|
|
|
|
|
|
|
|
from planetarytime import Body, EpochType, PlanetaryTime
|
|
|
|
|
from planetarytime.exceptions import DatetimePrecedesEpochError, EpochUnavailableError
|
|
|
|
|
from planetarytime.epoch import get_epoch_date
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_from_earth_at_epoch_is_year_zero_sol_zero() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt = PlanetaryTime.from_earth(epoch_dt, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.year == 0
|
|
|
|
|
assert pt.sol == 0
|
|
|
|
|
assert pt.hour == 0
|
|
|
|
|
assert pt.minute == 0
|
|
|
|
|
assert pt.second == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_from_earth_one_sol_later() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
one_sol_later = epoch_dt + timedelta(hours=Body.MARS.hours_per_sol)
|
|
|
|
|
pt = PlanetaryTime.from_earth(one_sol_later, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.year == 0
|
|
|
|
|
assert pt.sol == 1
|
|
|
|
|
assert pt.hour == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_from_earth_one_year_later() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
one_year_seconds = Body.MARS.sols_per_year * Body.MARS.hours_per_sol * 3600
|
|
|
|
|
one_year_later = epoch_dt + timedelta(seconds=one_year_seconds)
|
|
|
|
|
pt = PlanetaryTime.from_earth(one_year_later, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.year == 1
|
|
|
|
|
assert pt.sol == 0
|
|
|
|
|
assert pt.hour == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_from_earth_one_hour_later() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
one_hour_later = epoch_dt + timedelta(hours=1)
|
|
|
|
|
pt = PlanetaryTime.from_earth(one_hour_later, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.year == 0
|
|
|
|
|
assert pt.sol == 0
|
|
|
|
|
assert pt.hour == 1
|
|
|
|
|
assert pt.minute == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_from_earth_naive_datetime_treated_as_utc() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
naive = epoch_dt.replace(tzinfo=None)
|
|
|
|
|
pt = PlanetaryTime.from_earth(naive, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.year == 0
|
|
|
|
|
assert pt.sol == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_from_earth_before_epoch_raises() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
before_epoch = epoch_dt - timedelta(days=1)
|
|
|
|
|
with pytest.raises(DatetimePrecedesEpochError):
|
|
|
|
|
PlanetaryTime.from_earth(before_epoch, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_contact_epoch_unavailable_raises() -> None:
|
|
|
|
|
with pytest.raises(EpochUnavailableError):
|
|
|
|
|
PlanetaryTime.from_earth(datetime(2024, 1, 1, tzinfo=timezone.utc), Body.JUPITER, EpochType.CONTACT)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_str_contains_body_name_and_sol() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt = PlanetaryTime.from_earth(epoch_dt, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert "Mars" in str(pt)
|
|
|
|
|
assert "Sol" in str(pt)
|
|
|
|
|
assert "Year" in str(pt)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_repr_contains_year_and_sol() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt = PlanetaryTime.from_earth(epoch_dt, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
r = repr(pt)
|
|
|
|
|
assert "PlanetaryTime(" in r
|
|
|
|
|
assert "year=" in r
|
|
|
|
|
assert "sol=" in r
|
2026-04-16 19:40:46 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Conversion accuracy
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_conversion_accuracy_sol_hour_minute() -> None:
|
|
|
|
|
"""26h 30m after epoch on Mars: sol 1, hour 1, minute 30."""
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
# Mars sol = 25 h; 26h 30m = 1 sol + 1h 30m
|
|
|
|
|
pt = PlanetaryTime.from_earth(epoch_dt + timedelta(hours=26, minutes=30), Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.year == 0
|
|
|
|
|
assert pt.sol == 1
|
|
|
|
|
assert pt.hour == 1
|
|
|
|
|
assert pt.minute == 30
|
|
|
|
|
assert pt.second == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_conversion_accuracy_seconds() -> None:
|
|
|
|
|
"""45 seconds after epoch: only second counter advances."""
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt = PlanetaryTime.from_earth(epoch_dt + timedelta(seconds=45), Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.year == 0
|
|
|
|
|
assert pt.sol == 0
|
|
|
|
|
assert pt.hour == 0
|
|
|
|
|
assert pt.minute == 0
|
|
|
|
|
assert pt.second == 45
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_conversion_accuracy_year_boundary() -> None:
|
|
|
|
|
"""Exactly one Mars year after epoch lands on year 1, sol 0, 00:00:00."""
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
one_year_seconds = Body.MARS.sols_per_year * Body.MARS.hours_per_sol * 3600
|
|
|
|
|
pt = PlanetaryTime.from_earth(epoch_dt + timedelta(seconds=one_year_seconds), Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.year == 1
|
|
|
|
|
assert pt.sol == 0
|
|
|
|
|
assert pt.hour == 0
|
|
|
|
|
assert pt.minute == 0
|
|
|
|
|
assert pt.second == 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# Epoch switching
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_epoch_switching_discovery_vs_contact_differ() -> None:
|
|
|
|
|
"""Discovery (1610) and contact (1976) epochs give significantly different years."""
|
|
|
|
|
dt = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
pt_disc = PlanetaryTime.from_earth(dt, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt_cont = PlanetaryTime.from_earth(dt, Body.MARS, EpochType.CONTACT)
|
|
|
|
|
assert pt_disc.year > pt_cont.year
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_epoch_switching_preserves_body_and_epoch_type() -> None:
|
|
|
|
|
"""Switching epoch type is reflected in the epoch_type property; body stays the same."""
|
|
|
|
|
dt = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
pt_disc = PlanetaryTime.from_earth(dt, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt_cont = PlanetaryTime.from_earth(dt, Body.MARS, EpochType.CONTACT)
|
|
|
|
|
assert pt_disc.body is Body.MARS
|
|
|
|
|
assert pt_cont.body is Body.MARS
|
|
|
|
|
assert pt_disc.epoch_type is EpochType.DISCOVERY
|
|
|
|
|
assert pt_cont.epoch_type is EpochType.CONTACT
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_epoch_switching_contact_sol_count_is_smaller() -> None:
|
|
|
|
|
"""Contact epoch is later, so sol count from contact is smaller than from discovery."""
|
|
|
|
|
dt = datetime(2024, 1, 1, tzinfo=timezone.utc)
|
|
|
|
|
pt_disc = PlanetaryTime.from_earth(dt, Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt_cont = PlanetaryTime.from_earth(dt, Body.MARS, EpochType.CONTACT)
|
|
|
|
|
total_sols_disc = pt_disc.year * Body.MARS.sols_per_year + pt_disc.sol
|
|
|
|
|
total_sols_cont = pt_cont.year * Body.MARS.sols_per_year + pt_cont.sol
|
|
|
|
|
assert total_sols_disc > total_sols_cont
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
# time and date properties
|
|
|
|
|
# ------------------------------------------------------------------
|
|
|
|
|
|
|
|
|
|
def test_time_property_format() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt = PlanetaryTime.from_earth(epoch_dt + timedelta(hours=3, minutes=7, seconds=9), Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.time == "03:07:09"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_date_property_format() -> None:
|
|
|
|
|
epoch_dt = get_epoch_date(Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
pt = PlanetaryTime.from_earth(epoch_dt + timedelta(hours=Body.MARS.hours_per_sol + 1), Body.MARS, EpochType.DISCOVERY)
|
|
|
|
|
assert pt.date == "Year 0, Sol 1"
|