"""Reproduce the public LRO geometry excerpt from archived NAIF kernels. Requires SpiceyPy 8.3.0 and numpy. Kernel bytes are downloaded sequentially to an external cache; only calculated public geometry and provenance are shipped. No Horizons/SSD API is embedded or proxied by this application. """ from pathlib import Path import argparse import hashlib import json import math import sys import tempfile import urllib.request import spiceypy as spice ROOT = Path(__file__).resolve().parents[2] EXPECTED_KERNEL_SHA256 = {'naif0012.tls': '678e32bdb5a744117a467cd9601cd6b373f0e9bc9bbde1371d5eee39600a039b', 'de421.bsp': '7b69803b2001ad4790f819f988e1df877e81ad3373b1f2d1def98b58891424fe', 'lrorg_2018074_2018166_v01.bsp': 'af15f2545b42b5fc3fca9f710cdd13f1b5933eaf7666b412a3b534bc3e40c816', 'pck00011.tpc': '3dff7b1dbeceaa01f25467767d3fa25816051c85d162d1edf04acb310ee28bb1'} SOURCES = [ ('naif0012.tls', 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/lsk/naif0012.tls'), ('de421.bsp', 'https://naif.jpl.nasa.gov/pub/naif/pds/data/lro-l-spice-6-v1.0/lrosp_1000/data/spk/de421.bsp'), ('lrorg_2018074_2018166_v01.bsp', 'https://naif.jpl.nasa.gov/pub/naif/pds/data/lro-l-spice-6-v1.0/lrosp_1000/data/spk/lrorg_2018074_2018166_v01.bsp'), ('pck00011.tpc', 'https://naif.jpl.nasa.gov/pub/naif/generic_kernels/pck/pck00011.tpc'), ] def sha(data): return hashlib.sha256(data).hexdigest() def main(): parser = argparse.ArgumentParser() parser.add_argument('--cache-dir', type=Path, default=Path(tempfile.gettempdir()) / 'spacetrc-naif-kernels') args = parser.parse_args() args.cache_dir.mkdir(parents=True, exist_ok=True) if spice.__version__ != '8.3.0': raise ValueError('This reproducible edition requires SpiceyPy 8.3.0') kernels = [] spice.kclear() for name, url in SOURCES: path = args.cache_dir / name if not path.exists(): req = urllib.request.Request(url, headers={'User-Agent': 'SpaceTRC-science-acquisition/8.0 (+https://spacetrc.com/)'}) with urllib.request.urlopen(req, timeout=60) as response: data = response.read(50 * 1024 * 1024 + 1) if len(data) > 50 * 1024 * 1024: raise ValueError('Kernel exceeds acquisition limit') path.write_bytes(data) data = path.read_bytes() if sha(data) != EXPECTED_KERNEL_SHA256[name]: raise ValueError('Frozen kernel identity differs: ' + name) kernels.append({'file': name, 'url': url, 'bytes': len(data), 'sha256': sha(data)}) spice.furnsh(str(path)) print('Loaded ' + name + ' · ' + str(len(data)) + ' bytes', flush=True) radius_values = spice.bodvrd('MOON', 'RADII', 3)[1] radius = float(sum(radius_values) / 3) start = spice.str2et('2018 APR 01 00:00:00 UTC') rows = [] for minute in range(361): et = start + minute * 60.0 local, _ = spice.spkezr('-85', et, 'J2000', 'NONE', 'MOON') apparent, light_time = spice.spkezr('-85', et, 'J2000', 'LT+S', 'EARTH') earth, _ = spice.spkpos('EARTH', et, 'J2000', 'NONE', 'MOON') sun, _ = spice.spkpos('SUN', et, 'J2000', 'NONE', 'MOON') local_range = math.sqrt(sum(float(v) ** 2 for v in local[:3])) earth_range = math.sqrt(sum(float(v) ** 2 for v in apparent[:3])) sun_norm = math.sqrt(sum(float(v) ** 2 for v in sun)) rows.append({ 'utc': spice.et2utc(et, 'ISOC', 0) + 'Z', 'tdbSecondsJ2000': round(float(et), 6), 'moonCentredJ2000Km': [round(float(v), 7) for v in local[:3]], 'moonRangeKm': round(local_range, 7), 'sphericalAltitudeKm': round(local_range - radius, 7), 'earthApparentRangeKm': round(earth_range, 7), 'earthReceptionLightTimeS': round(float(light_time), 10), 'earthFromMoonJ2000Km': [round(float(v), 7) for v in earth], 'sunFromMoonJ2000Unit': [round(float(v) / sun_norm, 10) for v in sun], }) packet = { 'schema': 'org.spacetrc.mission-geometry', 'schemaVersion': 1, 'edition': '8.0.0', 'title': {'tr': 'LRO · 1 Nisan 2018 görev geometrisi', 'en': 'LRO · 1 April 2018 mission geometry'}, 'provider': 'NASA/JPL NAIF and LRO PDS SPICE archive', 'spacecraft': 'Lunar Reconnaissance Orbiter', 'naifSpacecraftId': -85, 'computedAt': '2026-10-24', 'frame': 'J2000', 'sampling': {'stepSeconds': 60, 'samples': len(rows), 'startUTC': rows[0]['utc'], 'endUTC': rows[-1]['utc']}, 'computation': {'library': 'SpiceyPy', 'version': spice.__version__, 'cspiceVersion': spice.tkvrsn('TOOLKIT'), 'localObserver': 'MOON', 'localAberration': 'NONE', 'earthObserver': 'EARTH', 'earthAberration': 'LT+S', 'timeArgument': 'TDB seconds past J2000', 'displayTime': 'UTC via naif0012.tls', 'speedOfLightKmS': float(spice.clight()), 'moonMeanReferenceRadiusKm': radius, 'moonReferenceRadiiKm': [float(x) for x in radius_values]}, 'scope': { 'tr': 'Arşiv çekirdeklerinden hesaplanan altı saatlik bir dakikalık örneklerdir; canlı telemetri değildir. Ara örnek yörünge yayılımı yapılmaz. Yükseklik, ortalama yarıçaplı küreye göredir; arazi/jeoit yüksekliği değildir. Yerel çizim geometrik Ay merkezli konumu; Dünya uzaklığı ve ışık zamanı ise LT+S alım geometrisini gösterir. Cihaz yönelimi ve yüzey hedefi hesaplanmaz. Uçuş operasyonu veya navigasyon doğrulaması olarak kullanılamaz.', 'en': 'A six-hour excerpt sampled every minute from archived kernels, not live telemetry. No propagation between samples. Altitude is relative to a mean-radius sphere, not terrain or a geoid. The local view uses geometric Moon-centred positions; Earth range and light time use LT+S reception geometry. Instrument pointing and surface targets are not calculated. Not a flight-operations or navigation-validation product.'}, 'kernels': kernels, 'sources': [ {'title': 'NAIF SPICE concept', 'url': 'https://naif.jpl.nasa.gov/naif/spiceconcept.html'}, {'title': 'NAIF use rules and attribution', 'url': 'https://naif.jpl.nasa.gov/naif/rules.html'}, {'title': 'LRO reconstructed trajectory label', 'url': SOURCES[2][1].replace('.bsp', '.lbl')}, {'title': 'JPL SSD API policy', 'url': 'https://ssd-api.jpl.nasa.gov/doc/'}, ], 'attribution': 'Geometry computed with NASA/JPL NAIF SPICE and archived LRO reconstructed SPK; SpaceTRC presentation and calculations. No institutional endorsement is implied.', 'rows': rows, } # A second independent arithmetic check catches unit/column mistakes. for row in rows: assert abs(math.dist(row['moonCentredJ2000Km'], [0, 0, 0]) - row['moonRangeKm']) < 1e-5 assert abs(row['moonRangeKm'] - radius - row['sphericalAltitudeKm']) < 1e-5 assert abs(row['earthApparentRangeKm'] / float(spice.clight()) - row['earthReceptionLightTimeS']) < 1e-6 assert 0 < row['sphericalAltitudeKm'] < 300 out = ROOT / 'dist/assets/geometry-8' out.mkdir(parents=True, exist_ok=True) raw = json.dumps(packet, ensure_ascii=False, separators=(',', ':'), allow_nan=False).encode() (out / 'lro-20180401.json').write_bytes(raw) provenance = {key: value for key, value in packet.items() if key != 'rows'} provenance.update(dataFile='lro-20180401.json', bytes=len(raw), sha256=sha(raw)) (Path(__file__).parent / 'provenance.json').write_text(json.dumps(provenance, ensure_ascii=False, indent=2)) spice.kclear() print(json.dumps({'samples': len(rows), 'bytes': len(raw), 'sha256': sha(raw), 'library': spice.__version__}), flush=True) if __name__ == '__main__': main()