diff --git a/CHANGELOG.md b/CHANGELOG.md index 08fae1b..a4c60cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.3.0] - 2026-04-21 + +### Added + +- `scripts/refresh_data.py` — fetches rotation periods, orbital periods, and discovery/contact dates from Wikidata SPARQL and regenerates `src/planetarytime/_data.py`; supports `--dry-run`; not part of the distributed package + +### Changed + +- Planetary and moon data extracted into `src/planetarytime/_data.py` (`PLANET_ROWS`, `MOON_ROWS`) — single source of truth for all numerical constants +- `body.py`, `moon.py`, `epoch.py` now derive their data dictionaries from `_data.py` instead of hardcoding values inline +- `README.md` — updated usage examples with current output values, added Exceptions section, added Refreshing data section + ## [1.2.0] - 2026-04-16 ### Added diff --git a/README.md b/README.md index 960e9b5..723b7c1 100644 --- a/README.md +++ b/README.md @@ -60,34 +60,37 @@ 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) +# Year 217, Sol 579, 11:00:00 (Mars / discovery epoch) -print(pt.year) # 415 -print(pt.sol) # 668 -print(pt.hour) # 14 -print(pt.minute) # 22 -print(pt.second) # 7 -print(pt.time) # "14:22:07" -print(pt.date) # "Year 415, Sol 668" +print(pt.year) # 217 +print(pt.sol) # 579 +print(pt.hour) # 11 +print(pt.minute) # 0 +print(pt.second) # 0 +print(pt.time) # "11:00:00" +print(pt.date) # "Year 217, Sol 579" # 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) +# Year 26, Sol 25, 15:00:00 (Mars / contact epoch) ``` ### Moons ```python -# Titan time since discovery (Huygens, 1655) +# Titan (Saturn's largest moon) — accessible via Body.SATURN[0] titan = Body.SATURN[0] + +# Time since discovery (Christiaan Huygens, 1655) pt = PlanetaryTime.from_earth(now, titan, EpochType.DISCOVERY) print(pt) -# Year 1, Sol 0, 08:11:45 (Titan / discovery epoch) +# Year 8492, Sol 0, 344:00:00 (Titan / discovery epoch) -# Titan time since Huygens probe landing (2005) +# Time since Huygens probe landing (2005-01-14) pt = PlanetaryTime.from_earth(now, titan, EpochType.CONTACT) print(pt) +# Year 486, Sol 0, 282:00:00 (Titan / contact epoch) # Check if a moon is tidally locked print(titan.is_tidally_locked) # True @@ -95,13 +98,33 @@ print(titan.is_tidally_locked) # True ### Epochs -| EpochType | Meaning | -|--------------------|----------------------------------------------| +| 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. +### Exceptions + +```python +from planetarytime import EpochType, PlanetaryTime, Body +from planetarytime.exceptions import EpochUnavailableError, DatetimePrecedesEpochError + +# Body with no contact yet +try: + PlanetaryTime.from_earth(now, Body.JUPITER, EpochType.CONTACT) +except EpochUnavailableError as e: + print(e) # No contact with Jupiter has occurred — contact epoch is unavailable. + +# Datetime before the epoch +from datetime import datetime, timezone +try: + PlanetaryTime.from_earth(datetime(1600, 1, 1, tzinfo=timezone.utc), Body.MARS, EpochType.DISCOVERY) +except DatetimePrecedesEpochError as e: + print(e) +``` + ## Logging This library uses [loguru](https://github.com/Delgan/loguru) for internal logging. @@ -123,6 +146,17 @@ import sys logger.add(sys.stderr, filter={"planetarytime": "WARNING"}) ``` +## Refreshing data + +Rotation periods, orbital periods, and discovery/contact dates are stored in [`src/planetarytime/_data.py`](src/planetarytime/_data.py). To regenerate this file from Wikidata: + +```bash +python scripts/refresh_data.py # fetch and write _data.py +python scripts/refresh_data.py --dry-run # preview without writing +``` + +The script requires only the Python standard library. Run your test suite afterwards to verify the updated values. + ## License MIT diff --git a/pyproject.toml b/pyproject.toml index 2206df7..77feec7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "planetarytime" -version = "1.2.1" +version = "1.3.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"} diff --git a/scripts/refresh_data.py b/scripts/refresh_data.py new file mode 100644 index 0000000..05ac27c --- /dev/null +++ b/scripts/refresh_data.py @@ -0,0 +1,339 @@ +"""refresh_data.py — fetch planetary/moon data from Wikidata and regenerate _data.py. + +Usage: + python scripts/refresh_data.py [--dry-run] + +Writes: + src/planetarytime/_data.py + +Requires only the Python standard library (urllib). +""" + +from __future__ import annotations + +import argparse +import json +import sys +import urllib.parse +import urllib.request +from dataclasses import dataclass +from datetime import date, datetime +from pathlib import Path + +# --------------------------------------------------------------------------- +# Paths +# --------------------------------------------------------------------------- + +REPO_ROOT = Path(__file__).resolve().parent.parent +DATA_FILE = REPO_ROOT / "src" / "planetarytime" / "_data.py" + +# --------------------------------------------------------------------------- +# Wikidata SPARQL helpers +# --------------------------------------------------------------------------- + +SPARQL_ENDPOINT = "https://query.wikidata.org/sparql" +USER_AGENT = "planetarytime-refresh/1.0 (https://github.com/jan-doubravsky/planetarytime)" + + +def sparql_query(query: str) -> list[dict]: + """Execute a SPARQL SELECT query against Wikidata and return the bindings.""" + params = urllib.parse.urlencode({"query": query, "format": "json"}) + url = f"{SPARQL_ENDPOINT}?{params}" + req = urllib.request.Request(url, headers={ + "User-Agent": USER_AGENT, + "Accept": "application/sparql-results+json", + }) + with urllib.request.urlopen(req, timeout=30) as resp: + data = json.loads(resp.read().decode()) + return data["results"]["bindings"] + + +def _float(binding: dict, key: str) -> float | None: + v = binding.get(key, {}).get("value") + return float(v) if v is not None else None + + +def _date(binding: dict, key: str) -> date | None: + v = binding.get(key, {}).get("value") + if v is None: + return None + v = v.lstrip("+") + try: + return datetime.fromisoformat(v.replace("Z", "+00:00")).date() + except ValueError: + return None + + +# --------------------------------------------------------------------------- +# Planet data +# --------------------------------------------------------------------------- + +PLANET_QUERY = """\ +SELECT ?name ?rotationHours ?orbitalDays ?discoveryDate WHERE { + VALUES ?item { + wd:Q308 # Mercury + wd:Q313 # Venus + wd:Q111 # Mars + wd:Q319 # Jupiter + wd:Q193 # Saturn + wd:Q324 # Uranus + wd:Q332 # Neptune + } + ?item rdfs:label ?name FILTER(LANG(?name) = "en"). + OPTIONAL { + ?item p:P2386 ?rotStmt. + ?rotStmt ps:P2386 ?rotationHours. + ?rotStmt psv:P2386/wikibase:quantityUnit wd:Q7727. + } + OPTIONAL { + ?item p:P2257 ?orbStmt. + ?orbStmt ps:P2257 ?orbitalDays. + ?orbStmt psv:P2257/wikibase:quantityUnit wd:Q573. + } + OPTIONAL { ?item wdt:P575 ?discoveryDate. } +} +""" + +CONTACT_QUERY = """\ +SELECT ?name ?contactDate WHERE { + VALUES (?probe ?planet) { + (wd:Q1573 wd:Q308) # MESSENGER — Mercury + (wd:Q170 wd:Q313) # Venera 7 — Venus + (wd:Q160102 wd:Q111) # Viking 1 — Mars + } + ?planet rdfs:label ?name FILTER(LANG(?name) = "en"). + ?probe wdt:P619 ?contactDate. +} +""" + + +@dataclass +class PlanetData: + name: str + rotation_hours: float + orbital_hours: float + discovery_date: date + contact_date: date | None = None + + +_FALLBACK_PLANETS: list[PlanetData] = [ + PlanetData("Mercury", 1407.6, 87.97 * 24, date(1631, 11, 7), date(2011, 3, 18)), + PlanetData("Venus", 5832.5, 224.70 * 24, date(1610, 1, 1), date(1970, 12, 15)), + PlanetData("Mars", 24.6, 686.97 * 24, date(1610, 1, 1), date(1976, 7, 20)), + PlanetData("Jupiter", 9.9, 4332.59 * 24, date(1610, 1, 7), None), + PlanetData("Saturn", 10.7, 10759.22 * 24, date(1610, 7, 25), None), + PlanetData("Uranus", 17.2, 30688.50 * 24, date(1781, 3, 13), None), + PlanetData("Neptune", 16.1, 60182.00 * 24, date(1846, 9, 23), None), +] + +_PLANET_ORDER = [p.name for p in _FALLBACK_PLANETS] + + +def fetch_planets() -> list[PlanetData]: + print("Fetching planet data from Wikidata…") + try: + rows = sparql_query(PLANET_QUERY) + except Exception as exc: + print(f" WARNING: planet query failed ({exc}), using fallback data.", file=sys.stderr) + return _FALLBACK_PLANETS + + by_name: dict[str, PlanetData] = {} + for row in rows: + name = row["name"]["value"] + rot = _float(row, "rotationHours") + orb_days = _float(row, "orbitalDays") + disc = _date(row, "discoveryDate") + if rot is None or orb_days is None or disc is None: + continue + by_name[name] = PlanetData(name, abs(rot), orb_days * 24, disc) + + try: + for row in sparql_query(CONTACT_QUERY): + name = row["name"]["value"] + d = _date(row, "contactDate") + if name in by_name and d is not None: + by_name[name].contact_date = d + except Exception as exc: + print(f" WARNING: contact query failed ({exc}).", file=sys.stderr) + + fallback_map = {p.name: p for p in _FALLBACK_PLANETS} + result: list[PlanetData] = [] + for name in _PLANET_ORDER: + if name in by_name: + result.append(by_name[name]) + else: + print(f" WARNING: {name} missing from Wikidata, using fallback.", file=sys.stderr) + result.append(fallback_map[name]) + + print(f" Planets: {', '.join(p.name for p in result)}") + return result + + +# --------------------------------------------------------------------------- +# Moon data +# --------------------------------------------------------------------------- + +MOON_QUERY = """\ +SELECT ?moonLabel ?rotationHours ?orbitalHours ?discoveryDate ?contactDate WHERE { + VALUES ?moon { + wd:Q40 # Phobos + wd:Q39 # Deimos + wd:Q36236 # Io + wd:Q36712 # Europa + wd:Q44537 # Ganymede + wd:Q44523 # Callisto + wd:Q2565 # Titan + wd:Q3532 # Enceladus + wd:Q3552 # Miranda + wd:Q3551 # Ariel + wd:Q3543 # Umbriel + wd:Q3555 # Titania + wd:Q3547 # Oberon + wd:Q3561 # Triton + } + ?moon rdfs:label ?moonLabel FILTER(LANG(?moonLabel) = "en"). + OPTIONAL { + ?moon p:P2386 ?rotStmt. + ?rotStmt ps:P2386 ?rotationHours. + ?rotStmt psv:P2386/wikibase:quantityUnit wd:Q7727. + } + OPTIONAL { + ?moon p:P2257 ?orbStmt. + ?orbStmt ps:P2257 ?orbDays. + ?orbStmt psv:P2257/wikibase:quantityUnit wd:Q573. + BIND(?orbDays * 24.0 AS ?orbitalHours) + } + OPTIONAL { ?moon wdt:P575 ?discoveryDate. } + OPTIONAL { ?moon wdt:P619 ?contactDate. } +} +""" + + +@dataclass +class MoonData: + name: str + rotation_hours: float + orbital_hours: float + is_tidally_locked: bool + discovery_date: date + contact_date: date | None = None + + +_FALLBACK_MOONS: list[MoonData] = [ + MoonData("Phobos", 7.653, 7.653, True, date(1877, 8, 18)), + MoonData("Deimos", 30.312, 30.312, True, date(1877, 8, 12)), + MoonData("Io", 42.456, 42.456, True, date(1610, 1, 8)), + MoonData("Europa", 85.228, 85.228, True, date(1610, 1, 8)), + MoonData("Ganymede", 171.709, 171.709, True, date(1610, 1, 7)), + MoonData("Callisto", 400.535, 400.535, True, date(1610, 1, 7)), + MoonData("Titan", 382.690, 382.690, True, date(1655, 3, 25), date(2005, 1, 14)), + MoonData("Enceladus", 32.923, 32.923, True, date(1789, 8, 28)), + MoonData("Miranda", 33.923, 33.923, True, date(1948, 2, 16)), + MoonData("Ariel", 60.489, 60.489, True, date(1851, 10, 24)), + MoonData("Umbriel", 99.460, 99.460, True, date(1851, 10, 24)), + MoonData("Titania", 208.940, 208.940, True, date(1787, 1, 11)), + MoonData("Oberon", 323.117, 323.117, True, date(1787, 1, 11)), + MoonData("Triton", 141.045, 141.045, True, date(1846, 10, 10)), +] + +_MOON_ORDER = [m.name for m in _FALLBACK_MOONS] +_TIDAL_THRESHOLD = 0.01 + + +def fetch_moons() -> list[MoonData]: + print("Fetching moon data from Wikidata…") + try: + rows = sparql_query(MOON_QUERY) + except Exception as exc: + print(f" WARNING: moon query failed ({exc}), using fallback data.", file=sys.stderr) + return _FALLBACK_MOONS + + by_name: dict[str, MoonData] = {} + for row in rows: + name = row["moonLabel"]["value"] + rot = _float(row, "rotationHours") + orb = _float(row, "orbitalHours") + disc = _date(row, "discoveryDate") + if rot is None or orb is None or disc is None: + continue + rot, orb = abs(rot), abs(orb) + locked = abs(rot - orb) / max(rot, orb) < _TIDAL_THRESHOLD + by_name[name] = MoonData(name, rot, orb, locked, disc, _date(row, "contactDate")) + + fallback_map = {m.name: m for m in _FALLBACK_MOONS} + result: list[MoonData] = [] + for name in _MOON_ORDER: + if name in by_name: + result.append(by_name[name]) + else: + print(f" WARNING: {name} missing from Wikidata, using fallback.", file=sys.stderr) + result.append(fallback_map[name]) + + print(f" Moons: {', '.join(m.name for m in result)}") + return result + + +# --------------------------------------------------------------------------- +# Code generation — writes only _data.py +# --------------------------------------------------------------------------- + +def _dr(d: date) -> str: + return f"date({d.year}, {d.month:2d}, {d.day:2d})" + + +def _dr_opt(d: date | None) -> str: + return "None" if d is None else _dr(d) + + +def generate_data_py(planets: list[PlanetData], moons: list[MoonData]) -> str: + lines = [ + "# AUTO-GENERATED by scripts/refresh_data.py — do not edit by hand.", + "from datetime import date", + "", + "# (name, rotation_hours, orbital_hours, discovery_date, contact_date | None)", + "PLANET_ROWS: list[tuple[str, float, float, date, date | None]] = [", + ] + for p in planets: + lines.append( + f" ({p.name!r:12s}, {p.rotation_hours:10.3f}, {p.orbital_hours:12.4f}," + f" {_dr(p.discovery_date)}, {_dr_opt(p.contact_date)})," + ) + lines += [ + "]", + "", + "# (name, rotation_hours, orbital_hours, is_tidally_locked, discovery_date, contact_date | None)", + "MOON_ROWS: list[tuple[str, float, float, bool, date, date | None]] = [", + ] + for m in moons: + lines.append( + f" ({m.name!r:12s}, {m.rotation_hours:8.3f}, {m.orbital_hours:8.3f}," + f" {str(m.is_tidally_locked):5s}, {_dr(m.discovery_date)}, {_dr_opt(m.contact_date)})," + ) + lines += ["]", ""] + return "\n".join(lines) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser(description="Refresh planetary data from Wikidata.") + parser.add_argument("--dry-run", action="store_true", help="Print generated _data.py without writing.") + args = parser.parse_args() + + planets = fetch_planets() + moons = fetch_moons() + content = generate_data_py(planets, moons) + + if args.dry_run: + print(f"\n# {DATA_FILE}\n{'=' * 60}") + print(content) + else: + DATA_FILE.write_text(content, encoding="utf-8") + print(f"\nWritten: {DATA_FILE}") + print("Run your test suite to verify the updated data.") + + +if __name__ == "__main__": + main() diff --git a/src/planetarytime/_data.py b/src/planetarytime/_data.py new file mode 100644 index 0000000..2883e0e --- /dev/null +++ b/src/planetarytime/_data.py @@ -0,0 +1,31 @@ +# AUTO-GENERATED by scripts/refresh_data.py — do not edit by hand. +from datetime import date + +# (name, rotation_hours, orbital_hours, discovery_date, contact_date | None) +PLANET_ROWS: list[tuple[str, float, float, date, date | None]] = [ + ('Mercury' , 1407.600, 2111.2800, date(1631, 11, 7), date(2011, 3, 18)), + ('Venus' , 5832.500, 5392.8000, date(1610, 1, 1), date(1970, 12, 15)), + ('Mars' , 24.600, 16487.2800, date(1610, 1, 1), date(1976, 7, 20)), + ('Jupiter' , 9.900, 103982.1600, date(1610, 1, 7), None), + ('Saturn' , 10.700, 258221.2800, date(1610, 7, 25), None), + ('Uranus' , 17.200, 736524.0000, date(1781, 3, 13), None), + ('Neptune' , 16.100, 1444368.0000, date(1846, 9, 23), None), +] + +# (name, rotation_hours, orbital_hours, is_tidally_locked, discovery_date, contact_date | None) +MOON_ROWS: list[tuple[str, float, float, bool, date, date | None]] = [ + ('Phobos' , 7.653, 7.653, True , date(1877, 8, 18), None), + ('Deimos' , 30.312, 30.312, True , date(1877, 8, 12), None), + ('Io' , 42.456, 42.456, True , date(1610, 1, 8), None), + ('Europa' , 85.228, 85.228, True , date(1610, 1, 8), None), + ('Ganymede' , 171.709, 171.709, True , date(1610, 1, 7), None), + ('Callisto' , 400.535, 400.535, True , date(1610, 1, 7), None), + ('Titan' , 382.690, 382.690, True , date(1655, 3, 25), date(2005, 1, 14)), + ('Enceladus' , 32.923, 32.923, True , date(1789, 8, 28), None), + ('Miranda' , 33.923, 33.923, True , date(1948, 2, 16), None), + ('Ariel' , 60.489, 60.489, True , date(1851, 10, 24), None), + ('Umbriel' , 99.460, 99.460, True , date(1851, 10, 24), None), + ('Titania' , 208.940, 208.940, True , date(1787, 1, 11), None), + ('Oberon' , 323.117, 323.117, True , date(1787, 1, 11), None), + ('Triton' , 141.045, 141.045, True , date(1846, 10, 10), None), +] diff --git a/src/planetarytime/body.py b/src/planetarytime/body.py index 15ad98d..add1ec4 100644 --- a/src/planetarytime/body.py +++ b/src/planetarytime/body.py @@ -2,6 +2,7 @@ from __future__ import annotations from enum import Enum +from planetarytime._data import PLANET_ROWS from planetarytime.moon import ( ARIEL, CALLISTO, @@ -62,24 +63,11 @@ class Body(Enum): _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, + Body(row[0]): row[1] for row in PLANET_ROWS } -# 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, + Body(row[0]): row[2] for row in PLANET_ROWS } # Moons per body, ordered by orbital distance from the planet diff --git a/src/planetarytime/epoch.py b/src/planetarytime/epoch.py index 1010eb7..3846283 100644 --- a/src/planetarytime/epoch.py +++ b/src/planetarytime/epoch.py @@ -3,6 +3,7 @@ from __future__ import annotations from datetime import datetime, timezone from enum import Enum +from planetarytime._data import PLANET_ROWS from planetarytime.body import Body from planetarytime.exceptions import EpochUnavailableError from planetarytime.moon import Moon @@ -15,27 +16,22 @@ class EpochType(Enum): CONTACT = "contact" -# Discovery dates for Solar System bodies (UTC midnight). +def _dt(d: object) -> datetime: + from datetime import date as _date + assert isinstance(d, _date) + return datetime(d.year, d.month, d.day, tzinfo=timezone.utc) + + +def _dt_opt(d: object) -> datetime | None: + return None if d is None else _dt(d) + + _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 + Body(row[0]): _dt(row[3]) for row in PLANET_ROWS } -# 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, + Body(row[0]): _dt_opt(row[4]) for row in PLANET_ROWS } diff --git a/src/planetarytime/moon.py b/src/planetarytime/moon.py index dec612e..aaa8fc5 100644 --- a/src/planetarytime/moon.py +++ b/src/planetarytime/moon.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone +from planetarytime._data import MOON_ROWS + @dataclass(frozen=True) class Moon: @@ -35,125 +37,36 @@ class Moon: return self.name -# ── Mars ────────────────────────────────────────────────────────────────────── +def _make_moon(name: str, rot: float, orb: float, locked: bool, + disc: object, contact: object) -> Moon: + from datetime import date as _date + def _dt(d: object) -> datetime | None: + if d is None: + return None + assert isinstance(d, _date) + return datetime(d.year, d.month, d.day, tzinfo=timezone.utc) + disc_dt = _dt(disc) + assert disc_dt is not None + return Moon(name=name, rotation_hours=rot, orbital_hours=orb, + is_tidally_locked=locked, discovery_date=disc_dt, + contact_date=_dt(contact)) -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), -) +_MOONS_BY_NAME: dict[str, Moon] = { + row[0]: _make_moon(*row) for row in MOON_ROWS +} -# ── 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), -) +PHOBOS = _MOONS_BY_NAME["Phobos"] +DEIMOS = _MOONS_BY_NAME["Deimos"] +IO = _MOONS_BY_NAME["Io"] +EUROPA = _MOONS_BY_NAME["Europa"] +GANYMEDE = _MOONS_BY_NAME["Ganymede"] +CALLISTO = _MOONS_BY_NAME["Callisto"] +TITAN = _MOONS_BY_NAME["Titan"] +ENCELADUS = _MOONS_BY_NAME["Enceladus"] +MIRANDA = _MOONS_BY_NAME["Miranda"] +ARIEL = _MOONS_BY_NAME["Ariel"] +UMBRIEL = _MOONS_BY_NAME["Umbriel"] +TITANIA = _MOONS_BY_NAME["Titania"] +OBERON = _MOONS_BY_NAME["Oberon"] +TRITON = _MOONS_BY_NAME["Triton"]