From 872de743ad5ba13044ef51e84c30a3f264e0f0cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jan=20Doubravsk=C3=BD?= Date: Thu, 16 Apr 2026 17:52:59 +0200 Subject: [PATCH] Initial commit --- .gitignore | 49 +++++++++ CHANGELOG.md | 52 +++++++++ LICENSE | 29 ++--- PROJECT.md | 89 ++++++++++++++++ PlanetaryTime.code-workspace | 11 ++ README.md | 124 ++++++++++++++++++++++ pyproject.toml | 37 +++++++ src/planetarytime/__init__.py | 15 +++ src/planetarytime/body.py | 94 ++++++++++++++++ src/planetarytime/epoch.py | 61 +++++++++++ src/planetarytime/exceptions.py | 10 ++ src/planetarytime/moon.py | 159 ++++++++++++++++++++++++++++ src/planetarytime/planetary_time.py | 158 +++++++++++++++++++++++++++ src/planetarytime/py.typed | 0 tests/__init__.py | 0 tests/test_body.py | 33 ++++++ tests/test_moon.py | 114 ++++++++++++++++++++ tests/test_planetary_time.py | 82 ++++++++++++++ 18 files changed, 1104 insertions(+), 13 deletions(-) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 PROJECT.md create mode 100644 PlanetaryTime.code-workspace create mode 100644 pyproject.toml create mode 100644 src/planetarytime/__init__.py create mode 100644 src/planetarytime/body.py create mode 100644 src/planetarytime/epoch.py create mode 100644 src/planetarytime/exceptions.py create mode 100644 src/planetarytime/moon.py create mode 100644 src/planetarytime/planetary_time.py create mode 100644 src/planetarytime/py.typed create mode 100644 tests/__init__.py create mode 100644 tests/test_body.py create mode 100644 tests/test_moon.py create mode 100644 tests/test_planetary_time.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..caa2278 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c006410 --- /dev/null +++ b/CHANGELOG.md @@ -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` diff --git a/LICENSE b/LICENSE index 8fe4434..1c73953 100644 --- a/LICENSE +++ b/LICENSE @@ -1,18 +1,21 @@ 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 -associated documentation files (the "Software"), to deal in the Software without restriction, including -without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell -copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the -following conditions: +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +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 -portions of the Software. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT -LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO -EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 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. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +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. diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..a141eac --- /dev/null +++ b/PROJECT.md @@ -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 diff --git a/PlanetaryTime.code-workspace b/PlanetaryTime.code-workspace new file mode 100644 index 0000000..1364419 --- /dev/null +++ b/PlanetaryTime.code-workspace @@ -0,0 +1,11 @@ +{ + "folders": [ + { + "path": "." + } + ], + "settings": { + "python-envs.defaultEnvManager": "ms-python.python:poetry", + "python-envs.defaultPackageManager": "ms-python.python:poetry" + } +} \ No newline at end of file diff --git a/README.md b/README.md index fd3e430..8da4c84 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,126 @@ # 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.[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 diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..b11d542 --- /dev/null +++ b/pyproject.toml @@ -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)" +] diff --git a/src/planetarytime/__init__.py b/src/planetarytime/__init__.py new file mode 100644 index 0000000..b25e5dd --- /dev/null +++ b/src/planetarytime/__init__.py @@ -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", +] diff --git a/src/planetarytime/body.py b/src/planetarytime/body.py new file mode 100644 index 0000000..15ad98d --- /dev/null +++ b/src/planetarytime/body.py @@ -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], +} diff --git a/src/planetarytime/epoch.py b/src/planetarytime/epoch.py new file mode 100644 index 0000000..1010eb7 --- /dev/null +++ b/src/planetarytime/epoch.py @@ -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 diff --git a/src/planetarytime/exceptions.py b/src/planetarytime/exceptions.py new file mode 100644 index 0000000..2411509 --- /dev/null +++ b/src/planetarytime/exceptions.py @@ -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.""" diff --git a/src/planetarytime/moon.py b/src/planetarytime/moon.py new file mode 100644 index 0000000..dec612e --- /dev/null +++ b/src/planetarytime/moon.py @@ -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), +) diff --git a/src/planetarytime/planetary_time.py b/src/planetarytime/planetary_time.py new file mode 100644 index 0000000..e40b739 --- /dev/null +++ b/src/planetarytime/planetary_time.py @@ -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)" + ) diff --git a/src/planetarytime/py.typed b/src/planetarytime/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_body.py b/tests/test_body.py new file mode 100644 index 0000000..f44dede --- /dev/null +++ b/tests/test_body.py @@ -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) diff --git a/tests/test_moon.py b/tests/test_moon.py new file mode 100644 index 0000000..0557d62 --- /dev/null +++ b/tests/test_moon.py @@ -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) diff --git a/tests/test_planetary_time.py b/tests/test_planetary_time.py new file mode 100644 index 0000000..663c896 --- /dev/null +++ b/tests/test_planetary_time.py @@ -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