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
|
||||
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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