Initial commit

This commit is contained in:
2026-04-16 17:52:59 +02:00
parent 14b92848c7
commit 872de743ad
18 changed files with 1104 additions and 13 deletions

View File

@@ -0,0 +1,15 @@
from planetarytime.body import Body
from planetarytime.epoch import EpochType
from planetarytime.exceptions import DatetimePrecedesEpochError, EpochUnavailableError, PlanetaryTimeError
from planetarytime.moon import Moon
from planetarytime.planetary_time import PlanetaryTime
__all__ = [
"Body",
"EpochType",
"Moon",
"PlanetaryTime",
"PlanetaryTimeError",
"EpochUnavailableError",
"DatetimePrecedesEpochError",
]

94
src/planetarytime/body.py Normal file
View File

@@ -0,0 +1,94 @@
from __future__ import annotations
from enum import Enum
from planetarytime.moon import (
ARIEL,
CALLISTO,
DEIMOS,
ENCELADUS,
EUROPA,
GANYMEDE,
IO,
MIRANDA,
Moon,
OBERON,
PHOBOS,
TITAN,
TITANIA,
TRITON,
UMBRIEL,
)
class Body(Enum):
"""Planetary body in the Solar System."""
MERCURY = "Mercury"
VENUS = "Venus"
MARS = "Mars"
JUPITER = "Jupiter"
SATURN = "Saturn"
URANUS = "Uranus"
NEPTUNE = "Neptune"
@property
def rotation_hours(self) -> float:
"""Sidereal rotation period in Earth hours."""
return _ROTATION_HOURS[self]
@property
def orbital_hours(self) -> float:
"""Orbital period around the Sun in Earth hours."""
return _ORBITAL_HOURS[self]
@property
def hours_per_sol(self) -> int:
"""Number of hours in one sol (rotation period rounded to nearest hour)."""
return round(self.rotation_hours)
@property
def sols_per_year(self) -> int:
"""Number of sols in one planetary year (orbital period / rotation period, rounded)."""
return round(self.orbital_hours / self.rotation_hours)
@property
def display_name(self) -> str:
return self.value
def __getitem__(self, index: int) -> Moon:
"""Return the moon at the given index for this body."""
return _MOONS[self][index]
_ROTATION_HOURS: dict[Body, float] = {
Body.MERCURY: 1407.6,
Body.VENUS: 5832.5,
Body.MARS: 24.6,
Body.JUPITER: 9.9,
Body.SATURN: 10.7,
Body.URANUS: 17.2,
Body.NEPTUNE: 16.1,
}
# Orbital periods in Earth hours
_ORBITAL_HOURS: dict[Body, float] = {
Body.MERCURY: 87.97 * 24,
Body.VENUS: 224.70 * 24,
Body.MARS: 686.97 * 24,
Body.JUPITER: 4332.59 * 24,
Body.SATURN: 10759.22 * 24,
Body.URANUS: 30688.50 * 24,
Body.NEPTUNE: 60182.00 * 24,
}
# Moons per body, ordered by orbital distance from the planet
_MOONS: dict[Body, list[Moon]] = {
Body.MERCURY: [],
Body.VENUS: [],
Body.MARS: [PHOBOS, DEIMOS],
Body.JUPITER: [IO, EUROPA, GANYMEDE, CALLISTO],
Body.SATURN: [TITAN, ENCELADUS],
Body.URANUS: [MIRANDA, ARIEL, UMBRIEL, TITANIA, OBERON],
Body.NEPTUNE: [TRITON],
}

View File

@@ -0,0 +1,61 @@
from __future__ import annotations
from datetime import datetime, timezone
from enum import Enum
from planetarytime.body import Body
from planetarytime.exceptions import EpochUnavailableError
from planetarytime.moon import Moon
class EpochType(Enum):
"""How the starting day is determined."""
DISCOVERY = "discovery"
CONTACT = "contact"
# Discovery dates for Solar System bodies (UTC midnight).
_DISCOVERY_DATES: dict[Body, datetime] = {
Body.MERCURY: datetime(1631, 11, 7, tzinfo=timezone.utc), # first recorded transit (Gassendi)
Body.VENUS: datetime(1610, 1, 1, tzinfo=timezone.utc), # telescopic observation (Galileo)
Body.MARS: datetime(1610, 1, 1, tzinfo=timezone.utc), # telescopic observation (Galileo)
Body.JUPITER: datetime(1610, 1, 7, tzinfo=timezone.utc), # moons discovered (Galileo)
Body.SATURN: datetime(1610, 7, 25, tzinfo=timezone.utc), # rings observed (Galileo)
Body.URANUS: datetime(1781, 3, 13, tzinfo=timezone.utc), # Herschel
Body.NEPTUNE: datetime(1846, 9, 23, tzinfo=timezone.utc), # Le Verrier / Galle
}
# First contact dates — automated probe landing or crewed landing.
# None means no contact has occurred yet.
_CONTACT_DATES: dict[Body, datetime | None] = {
Body.MERCURY: datetime(2011, 3, 18, tzinfo=timezone.utc), # MESSENGER orbit insertion (closest approach)
Body.VENUS: datetime(1970, 12, 15, tzinfo=timezone.utc), # Venera 7 — first soft landing
Body.MARS: datetime(1976, 7, 20, tzinfo=timezone.utc), # Viking 1 — first soft landing
Body.JUPITER: None,
Body.SATURN: None,
Body.URANUS: None,
Body.NEPTUNE: None,
}
def get_epoch_date(body: Body | Moon, epoch_type: EpochType) -> datetime:
"""Return the epoch datetime for a given body and epoch type.
Raises:
EpochUnavailableError: if contact epoch is requested but no contact has occurred.
"""
if isinstance(body, Moon):
if epoch_type is EpochType.DISCOVERY:
return body.discovery_date
if body.contact_date is None:
raise EpochUnavailableError(f"No contact with {body.name} has occurred — contact epoch is unavailable.")
return body.contact_date
if epoch_type is EpochType.DISCOVERY:
return _DISCOVERY_DATES[body]
contact = _CONTACT_DATES[body]
if contact is None:
raise EpochUnavailableError(f"No contact with {body.value} has occurred — contact epoch is unavailable.")
return contact

View File

@@ -0,0 +1,10 @@
class PlanetaryTimeError(Exception):
"""Base exception for all planetarytime errors."""
class EpochUnavailableError(PlanetaryTimeError):
"""Raised when a requested epoch has not occurred yet for a given body."""
class DatetimePrecedesEpochError(PlanetaryTimeError):
"""Raised when the provided datetime is earlier than the body's epoch."""

159
src/planetarytime/moon.py Normal file
View File

@@ -0,0 +1,159 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass(frozen=True)
class Moon:
"""A natural satellite of a Solar System planet.
For tidally locked moons, rotation_hours == orbital_hours and sols_per_year == 1.
One sol = one rotation around its own axis.
One year = one full orbit around the parent planet.
"""
name: str
rotation_hours: float
orbital_hours: float
is_tidally_locked: bool
discovery_date: datetime
contact_date: datetime | None = None
@property
def hours_per_sol(self) -> int:
"""Rotation period rounded to nearest Earth hour."""
return round(self.rotation_hours)
@property
def sols_per_year(self) -> int:
"""Number of sols in one orbit around the parent planet."""
return round(self.orbital_hours / self.rotation_hours)
@property
def display_name(self) -> str:
return self.name
# ── Mars ──────────────────────────────────────────────────────────────────────
PHOBOS = Moon(
name="Phobos",
rotation_hours=7.653,
orbital_hours=7.653,
is_tidally_locked=True,
discovery_date=datetime(1877, 8, 18, tzinfo=timezone.utc),
)
DEIMOS = Moon(
name="Deimos",
rotation_hours=30.312,
orbital_hours=30.312,
is_tidally_locked=True,
discovery_date=datetime(1877, 8, 12, tzinfo=timezone.utc),
)
# ── Jupiter (Galilean moons) ──────────────────────────────────────────────────
IO = Moon(
name="Io",
rotation_hours=42.456,
orbital_hours=42.456,
is_tidally_locked=True,
discovery_date=datetime(1610, 1, 8, tzinfo=timezone.utc),
)
EUROPA = Moon(
name="Europa",
rotation_hours=85.228,
orbital_hours=85.228,
is_tidally_locked=True,
discovery_date=datetime(1610, 1, 8, tzinfo=timezone.utc),
)
GANYMEDE = Moon(
name="Ganymede",
rotation_hours=171.709,
orbital_hours=171.709,
is_tidally_locked=True,
discovery_date=datetime(1610, 1, 7, tzinfo=timezone.utc),
)
CALLISTO = Moon(
name="Callisto",
rotation_hours=400.535,
orbital_hours=400.535,
is_tidally_locked=True,
discovery_date=datetime(1610, 1, 7, tzinfo=timezone.utc),
)
# ── Saturn ────────────────────────────────────────────────────────────────────
TITAN = Moon(
name="Titan",
rotation_hours=382.690,
orbital_hours=382.690,
is_tidally_locked=True,
discovery_date=datetime(1655, 3, 25, tzinfo=timezone.utc),
contact_date=datetime(2005, 1, 14, tzinfo=timezone.utc), # Huygens probe
)
ENCELADUS = Moon(
name="Enceladus",
rotation_hours=32.923,
orbital_hours=32.923,
is_tidally_locked=True,
discovery_date=datetime(1789, 8, 28, tzinfo=timezone.utc),
)
# ── Uranus ────────────────────────────────────────────────────────────────────
MIRANDA = Moon(
name="Miranda",
rotation_hours=33.923,
orbital_hours=33.923,
is_tidally_locked=True,
discovery_date=datetime(1948, 2, 16, tzinfo=timezone.utc),
)
ARIEL = Moon(
name="Ariel",
rotation_hours=60.489,
orbital_hours=60.489,
is_tidally_locked=True,
discovery_date=datetime(1851, 10, 24, tzinfo=timezone.utc),
)
UMBRIEL = Moon(
name="Umbriel",
rotation_hours=99.460,
orbital_hours=99.460,
is_tidally_locked=True,
discovery_date=datetime(1851, 10, 24, tzinfo=timezone.utc),
)
TITANIA = Moon(
name="Titania",
rotation_hours=208.940,
orbital_hours=208.940,
is_tidally_locked=True,
discovery_date=datetime(1787, 1, 11, tzinfo=timezone.utc),
)
OBERON = Moon(
name="Oberon",
rotation_hours=323.117,
orbital_hours=323.117,
is_tidally_locked=True,
discovery_date=datetime(1787, 1, 11, tzinfo=timezone.utc),
)
# ── Neptune ───────────────────────────────────────────────────────────────────
TRITON = Moon(
name="Triton",
rotation_hours=141.045,
orbital_hours=141.045,
is_tidally_locked=True,
discovery_date=datetime(1846, 10, 10, tzinfo=timezone.utc),
)

View File

@@ -0,0 +1,158 @@
from __future__ import annotations
from datetime import datetime, timezone
from loguru import logger
from planetarytime.body import Body
from planetarytime.epoch import EpochType, get_epoch_date
from planetarytime.exceptions import DatetimePrecedesEpochError
from planetarytime.moon import Moon
class PlanetaryTime:
"""Represents a point in time expressed on a specific planetary body.
Time is anchored to an epoch (discovery or first contact). The clock is
scaled so that one planetary hour is as close to one Earth hour as possible:
- hours_per_sol = rotation period rounded to nearest Earth hour
- sols_per_year = orbital period / rotation period, rounded
The unit "sol" is used for all non-Earth bodies instead of "day".
"""
def __init__(
self,
body: Body | Moon,
epoch_type: EpochType,
total_seconds_since_epoch: float,
) -> None:
self._body = body
self._epoch_type = epoch_type
self._total_seconds = total_seconds_since_epoch
seconds_per_hour: float = 3600.0
seconds_per_sol: float = body.hours_per_sol * seconds_per_hour
seconds_per_year: float = body.sols_per_year * seconds_per_sol
total_seconds = max(0.0, total_seconds_since_epoch)
self._year: int = int(total_seconds // seconds_per_year)
remainder = total_seconds % seconds_per_year
self._sol: int = int(remainder // seconds_per_sol)
remainder %= seconds_per_sol
self._hour: int = int(remainder // seconds_per_hour)
remainder %= seconds_per_hour
self._minute: int = int(remainder // 60)
self._second: int = int(remainder % 60)
logger.debug(
"PlanetaryTime constructed: body={} epoch={} year={} sol={} {:02d}:{:02d}:{:02d}",
body.display_name,
epoch_type.value,
self._year,
self._sol,
self._hour,
self._minute,
self._second,
)
# ------------------------------------------------------------------
# Factory
# ------------------------------------------------------------------
@classmethod
def from_earth(
cls,
earth_dt: datetime,
body: Body | Moon,
epoch_type: EpochType = EpochType.DISCOVERY,
) -> PlanetaryTime:
"""Create a PlanetaryTime from an Earth datetime.
Args:
earth_dt: Earth datetime (timezone-aware or naive UTC).
body: Target planetary body.
epoch_type: Which epoch to use as year 0, sol 0.
Returns:
PlanetaryTime instance for the given moment on the target body.
Raises:
DatetimePrecedesEpochError: if the datetime precedes the epoch.
EpochUnavailableError: if contact epoch is requested but no contact has occurred.
"""
if earth_dt.tzinfo is None:
earth_dt = earth_dt.replace(tzinfo=timezone.utc)
epoch_dt = get_epoch_date(body, epoch_type)
delta = earth_dt - epoch_dt
total_seconds = delta.total_seconds()
logger.info("Converting Earth datetime {} to {} time (epoch={})", earth_dt, body.display_name, epoch_type.value)
if total_seconds < 0:
raise DatetimePrecedesEpochError(
f"Earth datetime {earth_dt} precedes the {epoch_type.value} epoch "
f"for {body.display_name} ({epoch_dt})."
)
return cls(body, epoch_type, total_seconds)
# ------------------------------------------------------------------
# Properties
# ------------------------------------------------------------------
@property
def body(self) -> Body | Moon:
return self._body
@property
def epoch_type(self) -> EpochType:
return self._epoch_type
@property
def year(self) -> int:
"""Year number since epoch (0-indexed)."""
return self._year
@property
def sol(self) -> int:
"""Sol number within the current year (0-indexed)."""
return self._sol
@property
def hour(self) -> int:
"""Hour within the current sol (0-indexed)."""
return self._hour
@property
def minute(self) -> int:
"""Minute within the current hour (0-indexed)."""
return self._minute
@property
def second(self) -> int:
"""Second within the current minute (0-indexed)."""
return self._second
# ------------------------------------------------------------------
# String representation
# ------------------------------------------------------------------
def __repr__(self) -> str:
return (
f"PlanetaryTime(body={self._body.display_name!r}, epoch={self._epoch_type.value!r}, "
f"year={self._year}, sol={self._sol}, "
f"time={self._hour:02d}:{self._minute:02d}:{self._second:02d})"
)
def __str__(self) -> str:
return (
f"Year {self._year}, Sol {self._sol}, "
f"{self._hour:02d}:{self._minute:02d}:{self._second:02d} "
f"({self._body.display_name} / {self._epoch_type.value} epoch)"
)

View File