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

49
.gitignore vendored Normal file
View File

@@ -0,0 +1,49 @@
# Virtual environment
.venv/
# Python bytecode
__pycache__/
*.pyc
*.pyo
*.pyd
# Distribution / packaging
dist/
build/
*.egg-info/
# Poetry lock file (library — consumers pin their own dependencies)
poetry.lock
# Environment and secrets
.env
.env.*
!.env.example
# Type checking
.mypy_cache/
# Linting
.ruff_cache/
# Testing
.pytest_cache/
.coverage
htmlcov/
# IDE
.vscode/
.idea/
# AI assistant docs (not for version control)
DESIGN_DOCUMENT*.md
AGENTS.md
.claudeignore
CLAUDE.md
# OS
.DS_Store
Thumbs.db
zkouska.py
.python-version

52
CHANGELOG.md Normal file
View File

@@ -0,0 +1,52 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.1.0] - 2026-04-16
### Added
- `Moon` dataclass representing a natural satellite with `rotation_hours`, `orbital_hours`, `hours_per_sol`, `sols_per_year`, `is_tidally_locked`, `discovery_date`, `contact_date`
- 14 moons across 5 planets: Phobos, Deimos (Mars); Io, Europa, Ganymede, Callisto (Jupiter); Titan, Enceladus (Saturn); Miranda, Ariel, Umbriel, Titania, Oberon (Uranus); Triton (Neptune)
- `Body.__getitem__` — access moons by index, e.g. `Body.MARS[0]` returns Phobos
- Tidally locked moons have `sols_per_year == 1` (one sol = one orbit around parent planet)
- `Body.display_name` property for consistent name access across `Body` and `Moon`
- `get_epoch_date` extended to handle `Moon` (reads `discovery_date` / `contact_date` directly from the dataclass)
- `PlanetaryTime.from_earth` and all internals accept `Body | Moon`
- Titan has contact epoch (Huygens probe, 2005-01-14)
- `py.typed` marker — package is now PEP 561 compliant
- Full pytest test suite for `Moon` and `PlanetaryTime` with moons
### Changed
- `PlanetaryTime.body` return type widened to `Body | Moon`
- `__str__` and `__repr__` use `display_name` instead of `.value` to support both planets and moons
- `pyproject.toml` — added `description`, `license`, `keywords`, `classifiers`
- `README.md` — rewritten with full usage examples, body tables, and logging docs
- `LICENSE` — MIT license file added
- `poetry.lock` added to `.gitignore` (library — consumers pin their own dependencies)
## [1.0.0] - 2026-04-16
### Added
- `Body` enum with all 7 classic Solar System planets (Mercury, Venus, Mars, Jupiter, Saturn, Uranus, Neptune)
- `Body.rotation_hours` — sidereal rotation period in Earth hours
- `Body.orbital_hours` — orbital period around the Sun in Earth hours
- `Body.hours_per_sol` — rotation period rounded to nearest Earth hour
- `Body.sols_per_year` — number of sols in one planetary year
- `EpochType` enum with two modes: `DISCOVERY` and `CONTACT`
- Discovery dates for all 7 bodies (first telescopic observation or transit)
- Contact dates for Mercury (MESSENGER 2011), Venus (Venera 7 1970), Mars (Viking 1 1976); remaining bodies have no contact yet
- `PlanetaryTime` class representing a moment in time on a specific planetary body
- `PlanetaryTime.from_earth(earth_dt, body, epoch_type)` factory method
- Properties: `year`, `sol`, `hour`, `minute`, `second`, `body`, `epoch_type`
- `__str__` output format: `Year {y}, Sol {s}, HH:MM:SS ({Body} / {epoch} epoch)`
- `__repr__` for debugging
- Exception hierarchy: `PlanetaryTimeError` (base), `EpochUnavailableError`, `DatetimePrecedesEpochError`
- Naive datetimes are treated as UTC
- Loguru-based debug/info logging
- Full pytest test suite for `Body` and `PlanetaryTime`

29
LICENSE
View File

@@ -1,18 +1,21 @@
MIT License MIT License
Copyright (c) 2026 Honza Copyright (c) 2026 Jan Doubravský
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and Permission is hereby granted, free of charge, to any person obtaining a copy
associated documentation files (the "Software"), to deal in the Software without restriction, including of this software and associated documentation files (the "Software"), to deal
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell in the Software without restriction, including without limitation the rights
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
following conditions: copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial The above copyright notice and this permission notice shall be included in all
portions of the Software. copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
USE OR OTHER DEALINGS IN THE SOFTWARE. LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

89
PROJECT.md Normal file
View File

@@ -0,0 +1,89 @@
# PROJECT.md
## Project Overview
**PlanetaryTime** is a Python library for representing and working with time on other bodies in the Solar System, similar in spirit to the standard `datetime` module.
---
## Core Concept
Time on a given planetary body is expressed using a human-readable clock where **the hour is preserved as close to one Earth hour as possible**.
The number of hours in a planetary day is derived from the length of that body's sidereal rotation period expressed in Earth hours, rounded to the nearest integer:
```
hours_per_day = round(rotation_period_in_earth_hours)
```
Example: if a body rotates once every 24.6 Earth hours, its day has 25 hours, each approximately 1 Earth hour long.
---
## Epoch
The starting point for counting days (sol 0 / day 0) is configurable per body and per use case:
- **Discovery epoch** — the date the body was officially discovered
- **Contact epoch** — the date of first contact with the body:
- landing of an automated probe, or
- landing of a crewed mission
The epoch is set when constructing a planetary time object and determines what "day 1" means.
---
## Planned Public API
```python
from planetarytime import PlanetaryTime, Body, Epoch
# construct from Earth datetime + target body + epoch choice
pt = PlanetaryTime.from_earth(datetime.utcnow(), body=Body.MARS, epoch=Epoch.DISCOVERY)
pt.day # integer day number since epoch
pt.hour # hour within the current day (0-indexed)
pt.minute # minute within the current hour
pt.second # second within the current minute
pt.body # the planetary body
pt.epoch # the epoch in use
str(pt) # human-readable: e.g. "Sol 142, 09:35:22 (Mars / Discovery epoch)"
```
---
## Supported Bodies
Initial target: planets and major bodies of the Solar System with known rotation periods.
| Body | Rotation period (Earth h) | Hours per day |
|---------|--------------------------|---------------|
| Mercury | 1407.6 | 1408 |
| Venus | 5832.5 (retrograde) | 5833 |
| Mars | 24.6 | 25 |
| Jupiter | 9.9 | 10 |
| Saturn | 10.7 | 11 |
| Uranus | 17.2 | 17 |
| Neptune | 16.1 | 16 |
Moon and dwarf planets (Pluto, Ceres, Eris) may be added later.
---
## Current State
- Project scaffolded (Poetry, src layout, tests directory)
- No implementation yet
## TODO
- Define `Body` enum with rotation periods
- Define `Epoch` enum and epoch registry
- Implement core `PlanetaryTime` class
- Implement conversion from Earth `datetime`
- Implement `__str__` / `__repr__`
- Write tests for conversion accuracy
- Write tests for epoch switching
- Populate README with usage examples
- Implement `scripts/refresh_data.py` — fetches rotation periods, orbital periods and discovery dates from Wikidata SPARQL endpoint and regenerates hardcoded data in `body.py`, `moon.py` and `epoch.py`; script is not part of the distributed package

View File

@@ -0,0 +1,11 @@
{
"folders": [
{
"path": "."
}
],
"settings": {
"python-envs.defaultEnvManager": "ms-python.python:poetry",
"python-envs.defaultPackageManager": "ms-python.python:poetry"
}
}

124
README.md
View File

@@ -1,2 +1,126 @@
# PlanetaryTime # PlanetaryTime
Python library for representing and working with time on other bodies in the Solar System — similar in spirit to the standard `datetime` module.
Time on a given body is expressed using a human-readable clock where **one hour is as close to one Earth hour as possible**. The number of hours per sol (day) is derived from the body's sidereal rotation period rounded to the nearest integer.
## Installation
```bash
pip install planetarytime
```
## Supported bodies
### Planets
| Body | Hours per sol | Sols per year |
|---------|--------------|---------------|
| Mercury | 1408 | 2 |
| Venus | 5833 | 1 |
| Mars | 25 | 670 |
| Jupiter | 10 | 10476 |
| Saturn | 11 | 24491 |
| Uranus | 17 | 42718 |
| Neptune | 16 | 89667 |
### Moons
Accessible via `Body.<PLANET>[index]`, ordered by distance from the planet.
| Planet | Index | Moon | Hours per sol | Tidally locked |
|---------|-------|-----------|--------------|----------------|
| Mars | 0 | Phobos | 8 | yes |
| Mars | 1 | Deimos | 30 | yes |
| Jupiter | 0 | Io | 42 | yes |
| Jupiter | 1 | Europa | 85 | yes |
| Jupiter | 2 | Ganymede | 172 | yes |
| Jupiter | 3 | Callisto | 401 | yes |
| Saturn | 0 | Titan | 383 | yes |
| Saturn | 1 | Enceladus | 33 | yes |
| Uranus | 0 | Miranda | 34 | yes |
| Uranus | 1 | Ariel | 60 | yes |
| Uranus | 2 | Umbriel | 99 | yes |
| Uranus | 3 | Titania | 209 | yes |
| Uranus | 4 | Oberon | 323 | yes |
| Neptune | 0 | Triton | 141 | yes |
For tidally locked moons, one sol equals one year (one orbit around the parent planet).
## Usage
### Planets
```python
from datetime import datetime, timezone
from planetarytime import Body, EpochType, PlanetaryTime
now = datetime.now(timezone.utc)
# Mars time since discovery (Galileo, 1610)
pt = PlanetaryTime.from_earth(now, Body.MARS, EpochType.DISCOVERY)
print(pt)
# Year 415, Sol 668, 14:22:07 (Mars / discovery epoch)
print(pt.year) # 415
print(pt.sol) # 668
print(pt.hour) # 14
print(pt.minute) # 22
print(pt.second) # 7
# Mars time since first contact (Viking 1, 1976)
pt = PlanetaryTime.from_earth(now, Body.MARS, EpochType.CONTACT)
print(pt)
# Year 25, Sol 23, 14:22:07 (Mars / contact epoch)
```
### Moons
```python
# Titan time since discovery (Huygens, 1655)
titan = Body.SATURN[0]
pt = PlanetaryTime.from_earth(now, titan, EpochType.DISCOVERY)
print(pt)
# Year 1, Sol 0, 08:11:45 (Titan / discovery epoch)
# Titan time since Huygens probe landing (2005)
pt = PlanetaryTime.from_earth(now, titan, EpochType.CONTACT)
print(pt)
# Check if a moon is tidally locked
print(titan.is_tidally_locked) # True
```
### Epochs
| EpochType | Meaning |
|--------------------|----------------------------------------------|
| `EpochType.DISCOVERY` | First recorded observation of the body |
| `EpochType.CONTACT` | First probe landing or crewed landing |
`EpochUnavailableError` is raised when `CONTACT` is requested for a body that has not been visited yet.
## Logging
This library uses [loguru](https://github.com/Delgan/loguru) for internal logging.
By default, loguru has a sink to `stderr` enabled. If your application also uses loguru, library logs appear in your configured sinks automatically.
### Suppress library logs
```python
from loguru import logger
logger.disable("planetarytime")
```
### Filter by level
```python
from loguru import logger
import sys
logger.add(sys.stderr, filter={"planetarytime": "WARNING"})
```
## License
MIT

37
pyproject.toml Normal file
View File

@@ -0,0 +1,37 @@
[project]
name = "planetarytime"
version = "1.1.0"
description = "Python library for representing and working with time on other bodies in the Solar System"
authors = [
{name = "Jan Doubravský", email = "jan.doubravsky@gmail.com"}
]
readme = "README.md"
license = {text = "MIT"}
requires-python = ">=3.14,<4.0"
keywords = ["astronomy", "planetary", "time", "solar system", "datetime", "mars", "space"]
classifiers = [
"License :: OSI Approved :: MIT License",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.14",
"Topic :: Scientific/Engineering :: Astronomy",
"Intended Audience :: Developers",
"Intended Audience :: Science/Research",
"Typing :: Typed",
]
dependencies = [
"loguru (>=0.7.3,<0.8.0)"
]
[tool.poetry]
packages = [{include = "planetarytime", from = "src"}]
[build-system]
requires = ["poetry-core>=2.0.0,<3.0.0"]
build-backend = "poetry.core.masonry.api"
[dependency-groups]
dev = [
"pytest (>=9.0.3,<10.0.0)",
"mypy (>=1.20.1,<2.0.0)",
"ruff (>=0.15.10,<0.16.0)"
]

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

0
tests/__init__.py Normal file
View File

33
tests/test_body.py Normal file
View File

@@ -0,0 +1,33 @@
from planetarytime import Body
def test_mars_hours_per_sol() -> None:
assert Body.MARS.hours_per_sol == 25
def test_jupiter_hours_per_sol() -> None:
assert Body.JUPITER.hours_per_sol == 10
def test_hours_per_sol_equals_rounded_rotation() -> None:
for body in Body:
assert body.hours_per_sol == round(body.rotation_hours)
def test_all_bodies_have_positive_rotation() -> None:
for body in Body:
assert body.rotation_hours > 0
def test_mars_sols_per_year() -> None:
assert Body.MARS.sols_per_year == 670
def test_all_bodies_have_positive_sols_per_year() -> None:
for body in Body:
assert body.sols_per_year > 0
def test_sols_per_year_derived_from_orbital_and_rotation() -> None:
for body in Body:
assert body.sols_per_year == round(body.orbital_hours / body.rotation_hours)

114
tests/test_moon.py Normal file
View File

@@ -0,0 +1,114 @@
import pytest
from datetime import datetime, timezone, timedelta
from planetarytime import Body, EpochType, Moon, PlanetaryTime
from planetarytime.exceptions import EpochUnavailableError
from planetarytime.epoch import get_epoch_date
# ── Moon dataclass ─────────────────────────────────────────────────────────────
def test_body_getitem_returns_moon() -> None:
assert isinstance(Body.MARS[0], Moon)
def test_mars_first_moon_is_phobos() -> None:
assert Body.MARS[0].name == "Phobos"
def test_mars_second_moon_is_deimos() -> None:
assert Body.MARS[1].name == "Deimos"
def test_mars_index_out_of_range_raises() -> None:
with pytest.raises(IndexError):
Body.MARS[99]
def test_mercury_has_no_moons() -> None:
with pytest.raises(IndexError):
Body.MERCURY[0]
def test_tidally_locked_sols_per_year_is_one() -> None:
phobos = Body.MARS[0]
assert phobos.is_tidally_locked is True
assert phobos.sols_per_year == 1
def test_moon_hours_per_sol_equals_rounded_rotation() -> None:
phobos = Body.MARS[0]
assert phobos.hours_per_sol == round(phobos.rotation_hours)
def test_titan_has_contact_date() -> None:
titan = Body.SATURN[0]
assert titan.name == "Titan"
assert titan.contact_date is not None
def test_display_name_returns_moon_name() -> None:
assert Body.MARS[0].display_name == "Phobos"
# ── Epoch ──────────────────────────────────────────────────────────────────────
def test_moon_discovery_epoch_date() -> None:
phobos = Body.MARS[0]
assert get_epoch_date(phobos, EpochType.DISCOVERY) == datetime(1877, 8, 18, tzinfo=timezone.utc)
def test_moon_contact_epoch_date() -> None:
titan = Body.SATURN[0]
assert get_epoch_date(titan, EpochType.CONTACT) == datetime(2005, 1, 14, tzinfo=timezone.utc)
def test_moon_contact_epoch_unavailable_raises() -> None:
phobos = Body.MARS[0]
with pytest.raises(EpochUnavailableError):
get_epoch_date(phobos, EpochType.CONTACT)
# ── PlanetaryTime with Moon ────────────────────────────────────────────────────
def test_planetary_time_from_earth_with_moon() -> None:
phobos = Body.MARS[0]
epoch_dt = get_epoch_date(phobos, EpochType.DISCOVERY)
pt = PlanetaryTime.from_earth(epoch_dt, phobos, EpochType.DISCOVERY)
assert pt.year == 0
assert pt.sol == 0
assert pt.hour == 0
def test_planetary_time_moon_one_sol_later_tidally_locked() -> None:
# Phobos is tidally locked: sols_per_year == 1, so after 1 sol the year rolls over.
phobos = Body.MARS[0]
assert phobos.sols_per_year == 1
epoch_dt = get_epoch_date(phobos, EpochType.DISCOVERY)
one_sol_later = epoch_dt + timedelta(hours=phobos.hours_per_sol)
pt = PlanetaryTime.from_earth(one_sol_later, phobos, EpochType.DISCOVERY)
assert pt.year == 1
assert pt.sol == 0
def test_planetary_time_moon_one_year_later() -> None:
phobos = Body.MARS[0]
epoch_dt = get_epoch_date(phobos, EpochType.DISCOVERY)
one_year_seconds = phobos.sols_per_year * phobos.hours_per_sol * 3600
pt = PlanetaryTime.from_earth(epoch_dt + timedelta(seconds=one_year_seconds), phobos, EpochType.DISCOVERY)
assert pt.year == 1
assert pt.sol == 0
def test_str_contains_moon_name() -> None:
phobos = Body.MARS[0]
epoch_dt = get_epoch_date(phobos, EpochType.DISCOVERY)
pt = PlanetaryTime.from_earth(epoch_dt, phobos, EpochType.DISCOVERY)
assert "Phobos" in str(pt)
def test_repr_contains_moon_name() -> None:
phobos = Body.MARS[0]
epoch_dt = get_epoch_date(phobos, EpochType.DISCOVERY)
pt = PlanetaryTime.from_earth(epoch_dt, phobos, EpochType.DISCOVERY)
assert "Phobos" in repr(pt)

View File

@@ -0,0 +1,82 @@
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