Initial commit
This commit is contained in:
49
.gitignore
vendored
Normal file
49
.gitignore
vendored
Normal 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
52
CHANGELOG.md
Normal 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
29
LICENSE
@@ -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
89
PROJECT.md
Normal 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
|
||||||
11
PlanetaryTime.code-workspace
Normal file
11
PlanetaryTime.code-workspace
Normal 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
124
README.md
@@ -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
37
pyproject.toml
Normal 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)"
|
||||||
|
]
|
||||||
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
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
33
tests/test_body.py
Normal file
33
tests/test_body.py
Normal 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
114
tests/test_moon.py
Normal 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)
|
||||||
82
tests/test_planetary_time.py
Normal file
82
tests/test_planetary_time.py
Normal 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
|
||||||
Reference in New Issue
Block a user