Skip to content

Save and replay

A saved Study is one JSON file. Its bytes are exactly the canonical form of the record, so the SHA-256 of the file is stable, and the record’s own study_digest can be recomputed from it at any time.

study.save(path) writes the file and returns its path. For the Vikram minute-envelope run the file is byte for byte the study-minute.json committed with the run of 14 October 2026.

>>> import hashlib
>>> import lunartrace as lt
>>> study = lt.temporal.study(site="vikram", event="2023-08-23T12:33Z/12:35Z",
... products="LRO/LROC/CDRNAC4", archive="vikram-2026-10-14")
>>> path = study.save("vikram.study.json")
>>> hashlib.sha256(path.read_bytes()).hexdigest()[:16]
'a97a8044d2b52841'

load reads a saved file and checks it. verify() recomputes the digest from the record and returns True, or raises IntegrityError. Change one digit of a value and the check fails:

>>> import lunartrace as lt
>>> study = lt.temporal.study(site="vikram", event="vikram:minute-envelope",
... products="LRO/LROC/CDRNAC4", archive="vikram-2026-10-14")
>>> path = study.save("vikram.study.json")
>>> lt.temporal.load("vikram.study.json").verify()
True
>>> data = path.read_bytes().replace(b'"pre":30', b'"pre":31', 1)
>>> _ = path.with_name("edited.study.json").write_bytes(data)
>>> lt.temporal.load("edited.study.json")
Traceback (most recent call last):
...
lunartrace.temporal.IntegrityError: study_digest sha256:3d75a662ce75f00cd34dd9ba288012ad471c73b1e56ecf0abe36896d0b199dd0 does not match the record (sha256:...)

IntegrityError is a ValueError. The digest covers the whole record except the digest itself. It shows that the record is the one that was compiled. It does not show that the source is genuine; see the limits.

Replay recompiles the Study from the retained pages and gives you a new Study to compare. It needs the pages, so save with sources=True. That also writes a <stem>.sources/ directory beside the file, with the fetch receipt and every raw ODE response:

>>> import lunartrace as lt
>>> study = lt.temporal.study(site="vikram", event="vikram:minute-envelope",
... products="LRO/LROC/CDRNAC4", archive="vikram-2026-10-14")
>>> path = study.save("vikram.study.json", sources=True)
>>> import pathlib
>>> sorted(p.name for p in pathlib.Path("vikram.study.sources").iterdir())
['fetch-receipt.json', 'raw']
>>> sorted(p.name for p in pathlib.Path("vikram.study.sources/raw").iterdir())
['count-after.json', 'count.json', 'page-000.json', 'page-001.json']
>>> loaded = lt.temporal.load("vikram.study.json")
>>> again = loaded.replay()
>>> again.digest == loaded.digest == study.digest
True

load finds <stem>.sources/ on its own. Pass sources= to point at another directory. A file saved without sources cannot replay, and replay() says what is missing:

>>> import lunartrace as lt
>>> study = lt.temporal.study(site="vikram", event="vikram:minute-envelope",
... products="LRO/LROC/CDRNAC4", archive="vikram-2026-10-14")
>>> _ = study.save("bare.study.json")
>>> lt.temporal.load("bare.study.json").replay()
Traceback (most recent call last):
...
FileNotFoundError: no retained sources for this Study; save it with sources=True, or load it with a <stem>.sources/ directory beside the file

Before it compiles, replay checks every page against the SHA-256 in the receipt. A page that changed on disk raises ValueError naming the file.

study.anatomy() lays the record out as seven sections, in the order a reader asks the questions. It adds nothing to the record; each section is a view over keys the record already has.

>>> import lunartrace as lt
>>> study = lt.temporal.study(site="vikram", event="vikram:minute-envelope",
... products="LRO/LROC/CDRNAC4", archive="vikram-2026-10-14")
>>> list(study.anatomy())
['what_ran', 'from_which_bytes', 'around_when', 'on_what', 'why_these', 'where_it_stops', 'has_anything_moved']
>>> study.anatomy()["what_ran"]["method"]
'lunartrace.temporal_prepost/v1'
>>> study.anatomy()["has_anything_moved"] == study.digest
True

A study fetched live keeps its pages in memory. save(..., sources=True) writes them, and from then on it replays like any other:

import lunartrace as lt
live = lt.temporal.study(site=(-69.3741, 32.32), # no archive: fetches Lunar ODE now
event="2023-08-23T12:33Z/12:35Z",
products="LRO/LROC/CDRNAC4")
live.save("live.study.json", sources=True)
lt.temporal.load("live.study.json").replay().digest == live.digest # True

What replay shows is that the same bytes compile to the same record. It does not show that the archive still says the same thing today.