Initial commit
This commit is contained in:
15
src/planetarytime/__init__.py
Normal file
15
src/planetarytime/__init__.py
Normal 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
94
src/planetarytime/body.py
Normal 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],
|
||||
}
|
||||
61
src/planetarytime/epoch.py
Normal file
61
src/planetarytime/epoch.py
Normal 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
|
||||
10
src/planetarytime/exceptions.py
Normal file
10
src/planetarytime/exceptions.py
Normal 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
159
src/planetarytime/moon.py
Normal 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),
|
||||
)
|
||||
158
src/planetarytime/planetary_time.py
Normal file
158
src/planetarytime/planetary_time.py
Normal 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)"
|
||||
)
|
||||
0
src/planetarytime/py.typed
Normal file
0
src/planetarytime/py.typed
Normal file
Reference in New Issue
Block a user