astrophotography/session-scripts/restructure.py
laurence 5286a2e81b Processing and analysis code for remote-telescope imaging sessions
The scripts that processed the NGC 5128 session of 2026-07-21 previously
lived inside the data directory and addressed it with absolute paths.
Code and data are now separated: the code lives here, and a session is
located at runtime through the ASTRO_SESSION environment variable.

layout.py is what makes that work. It maps a FILENAME to the
subdirectory that file belongs in, using the same rules the session
directories are organised with, so a script can go on asking for
'master-Red.fit' or '_stars.npz' without any call site knowing the
directory structure. Anything unrecognised resolves to the session root,
which is visible and correctable rather than silently wrong.

restructure.py reorganises a flat session directory into that layout. It
is idempotent and dry-run by default.

The 50 session scripts are kept as they were run rather than tidied into
a library. They were written in sequence as the work went along, several
of them by parallel agents, and they show it - but they are the honest
provenance of a published set of results, and the productionised pipeline
should be able to reproduce those results exactly.

Verified before committing: all 51 files compile without warnings, and
verify_core.py, closeup.py and triptych.py were run end to end against
the reorganised session, correctly finding inputs across calibrated/,
stacks/masters/ and final/ and writing outputs back to the right places.
2026-07-21 15:29:49 +01:00

124 lines
4.6 KiB
Python

"""Reorganise the session directory so a file's kind is obvious from where it sits.
Placement is decided by filename rules rather than by hand, so the same layout
can be reproduced for any other session. Dry run by default; --apply to move.
raw/ exactly what iTelescope delivered: the zips and jpegs
calibrated/ the uncompressed calibrated subs
stacks/masters/ per-filter registered, plate-solved masters
stacks/original/ the alignment-only baseline stacks
final/ the four deliverable renderings + the comparison
renderings/ other finished images (LRGB, HDR, annotated, starless)
science/figures/ analysis plots
science/catalogues/ measured tables (CSV)
science/data/ models, masks and derived quantities
science/notes/ the analysis write-ups
intermediates/ caches and scratch that a re-run can regenerate
"""
import os
import shutil
import sys
ROOT = r"C:\Users\lhorrocks-barlow\Downloads\NGC5128\20260721"
STACKED = os.path.join(ROOT, "stacked")
APPLY = "--apply" in sys.argv
DIRS = ["raw", "calibrated", "stacks/masters", "stacks/original", "final",
"renderings", "science/figures", "science/catalogues", "science/data",
"science/notes", "intermediates"]
def destination(name, from_stacked):
"""Where a file belongs, decided by its name alone."""
low = name.lower()
if not from_stacked:
if low.endswith(".zip"):
return "raw"
if low.startswith("jpeg-") and low.endswith(".jpg"):
return "raw"
if low.startswith("calibrated-") and low.endswith((".fit", ".tif")):
return "calibrated"
return None
if name.startswith("_"):
return "intermediates"
if name.startswith("master-") and low.endswith(".fit"):
return "stacks/masters"
if name.startswith("notes-") and low.endswith(".md"):
return "science/notes"
if low.endswith(".csv"):
return "science/catalogues"
if name.startswith("sb-") and low.endswith((".fits", ".npy", ".txt")):
return "science/data"
if low.endswith(".png") and any(f"-{p}-" in name for p in ("gc", "sb", "mo")):
return "science/figures"
if name.startswith(("NGC5128-LRGB", "NGC5128-img-")):
return "renderings"
if name == "METHODS.md":
return "."
return None
def plan():
moves = []
for name in sorted(os.listdir(ROOT)):
path = os.path.join(ROOT, name)
if os.path.isfile(path):
d = destination(name, from_stacked=False)
if d:
moves.append((path, os.path.join(ROOT, d, name)))
for name in sorted(os.listdir(STACKED)):
path = os.path.join(STACKED, name)
if name in ("final", "original", "scripts"):
continue
if os.path.isdir(path):
if name.startswith("_"):
moves.append((path, os.path.join(ROOT, "intermediates", name)))
continue
d = destination(name, from_stacked=True)
if d:
target = ROOT if d == "." else os.path.join(ROOT, d)
moves.append((path, os.path.join(target, name)))
moves.append((os.path.join(STACKED, "final"), os.path.join(ROOT, "final")))
moves.append((os.path.join(STACKED, "original"),
os.path.join(ROOT, "stacks", "original")))
return moves
if __name__ == "__main__":
moves = plan()
counts = {}
for src, dst in moves:
rel = os.path.relpath(os.path.dirname(dst), ROOT)
counts[rel] = counts.get(rel, 0) + 1
print(f"{len(moves)} moves\n")
for d in sorted(counts):
print(f" {d + '/':24s} {counts[d]:3d}")
unplaced = []
for name in sorted(os.listdir(ROOT)):
if os.path.isfile(os.path.join(ROOT, name)) and \
not destination(name, False):
unplaced.append(name)
for name in sorted(os.listdir(STACKED)):
p = os.path.join(STACKED, name)
if os.path.isfile(p) and not destination(name, True):
unplaced.append("stacked/" + name)
if unplaced:
print(f"\nNOT PLACED by any rule ({len(unplaced)}):")
for n in unplaced:
print(f" {n}")
if not APPLY:
print("\nDRY RUN - nothing moved. Re-run with --apply")
sys.exit(0)
for d in DIRS:
os.makedirs(os.path.join(ROOT, d), exist_ok=True)
for src, dst in moves:
os.makedirs(os.path.dirname(dst), exist_ok=True)
if os.path.exists(dst):
print(f" SKIP (exists): {dst}")
continue
shutil.move(src, dst)
print(f"moved {len(moves)} items")