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.
This commit is contained in:
laurence 2026-07-21 15:29:49 +01:00
commit 5286a2e81b
53 changed files with 8820 additions and 0 deletions

191
session-scripts/mo_mpc.py Normal file
View file

@ -0,0 +1,191 @@
#!/usr/bin/env python3
"""
mo_mpc.py -- "was there a known minor planet in the frame?"
Purpose
-------
The NGC 5128 (Centaurus A) LRGB session of 2026-07-21 was searched for moving
objects (see _mo_dets.npz / _mo_tracklets.npy). Before any detection can be
called a *new* object, we must rule out that it is simply a catalogued minor
planet that happened to drift through the field. This script asks the
authoritative services what known small bodies were inside the field of view at
the epoch of the exposures.
Field / epoch
-------------
Field centre : RA 13 25 27.37, Dec -43 01 10.9 (J2000)
= 201.36404 deg, -43.01969 deg
Field size : 42.9' x 28.6' (half-diagonal ~25.8')
Search radius: 30 arcmin (0.5 deg) -- comfortably encloses the whole frame
Epoch : 2026-07-21 09:22 UTC (= 2026 07 21.39), mid-luminance-sequence
(luminance ran 08:56-10:00 UTC, session ended ~11:16 UTC)
Observatory : Q62, iTelescope / Siding Spring Observatory
Services queried
----------------
1. JPL Small-Body Identification API
https://ssd-api.jpl.nasa.gov/sb_ident.api
Primary source. Well documented, accepts arbitrary (including future) epochs
because it propagates orbits rather than serving a precomputed ephemeris.
IMPORTANT UNITS GOTCHA: despite some documentation wording, fov-ra-hwidth and
fov-dec-hwidth are interpreted in DEGREES, not arcminutes. Passing "30"
yields a 30-degree half-width box (the API echoes the box it used in
["observer"]["fov_offset"], which is worth checking every run).
Queried twice: sb-kind=a (asteroids) and sb-kind=c (comets).
2. MPC "MPChecker" / minor planet checker
https://minorplanetcenter.net/cgi-bin/mpcheck.cgi (classic HTTP POST form)
https://www.minorplanetcenter.net/cgi-bin/checkmp.cgi
Cross-check. Kept in the script even if it fails, so the log records whether
the MPC endpoint was reachable and what it said.
Output
------
Raw responses (JSON + HTML) and a human-readable summary are appended to
intermediates/_mpc_query.txt
Nothing is fabricated: if a service errors or refuses the epoch, the error text
itself is written to the log.
"""
import json
import math
import sys
import datetime
import requests
import layout
# ---------------------------------------------------------------- field/epoch
RA_SEX = "13-25-27.37"
DEC_SEX = "M43-01-10.9" # JPL uses a leading "M" for a negative Dec
RA_DEG = 201.36404
DEC_DEG = -43.01969
OBS_TIME = "2026-07-21_09:22:00"
MPC_CODE = "Q62" # iTelescope, Siding Spring
RADIUS_ARCMIN = 30.0
HWIDTH_DEG = RADIUS_ARCMIN / 60.0 # = 0.5 deg; API wants DEGREES here
VMAG_LIM = 22.0
OUT = layout.path("_mpc_query.txt")
log_lines = []
def log(s=""):
print(s)
log_lines.append(str(s))
# ------------------------------------------------------------------ JPL query
def jpl(sb_kind):
"""Query ssd-api.jpl.nasa.gov/sb_ident.api for one small-body class."""
params = {
"sb-kind": sb_kind, # 'a' = asteroid, 'c' = comet
"mpc-code": MPC_CODE,
"obs-time": OBS_TIME,
"fov-ra-center": RA_SEX,
"fov-dec-center": DEC_SEX,
"fov-ra-hwidth": f"{HWIDTH_DEG}",
"fov-dec-hwidth": f"{HWIDTH_DEG}",
"mag-required": "true",
"two-pass": "true",
"vmag-lim": f"{VMAG_LIM}",
}
r = requests.get("https://ssd-api.jpl.nasa.gov/sb_ident.api",
params=params, timeout=180)
log(f"--- JPL sb_ident sb-kind={sb_kind} -> HTTP {r.status_code}")
log(f" URL: {r.url}")
if r.status_code != 200:
log(" RAW: " + r.text[:2000])
return None
d = r.json()
log(" observer : " + json.dumps(d.get("observer", {})))
log(" n_first_pass : " + str(d.get("n_first_pass")))
log(" n_second_pass: " + str(d.get("n_second_pass")))
return d
def show(d, label):
"""Print the refined (second-pass) hits, falling back to first pass."""
if d is None:
return
rows = d.get("data_second_pass") or []
fields = d.get("fields_second") or []
if not rows:
rows = d.get("data_first_pass") or []
fields = d.get("fields_first") or []
log(f" [{label}] no second-pass data; showing first pass")
log(f" fields: {fields}")
if not rows:
log(f" [{label}] NO OBJECTS in field.")
return
for row in rows:
log(" " + " | ".join(str(x) for x in row))
# ------------------------------------------------------------------ MPC query
def mpchecker():
"""Classic MPChecker POST form. Recorded even if it fails."""
data = {
"year": "2026", "month": "07", "day": "21.39",
"which": "pos",
"ra": "13 25 27.37", "decl": "-43 01 10.9",
"TextArea": "",
"radius": str(int(RADIUS_ARCMIN)),
"limit": f"{VMAG_LIM}",
"oc": MPC_CODE,
"sort": "d", "mot": "h", "tmot": "s",
"pdes": "u", "needed": "f", "ps": "n", "type": "p",
}
for url in ("https://minorplanetcenter.net/cgi-bin/mpcheck.cgi",
"https://www.minorplanetcenter.net/cgi-bin/checkmp.cgi"):
try:
r = requests.post(url, data=data, timeout=120,
headers={"User-Agent": "Mozilla/5.0 (mo_mpc.py)"})
log(f"--- MPC POST {url} -> HTTP {r.status_code}, {len(r.text)} bytes")
log(r.text[:8000])
except Exception as e: # noqa: BLE001
log(f"--- MPC POST {url} -> EXCEPTION {type(e).__name__}: {e}")
# ----------------------------------------------------------------------- main
if __name__ == "__main__":
log("MPC / minor-planet field check")
log("run at " + datetime.datetime.utcnow().isoformat() + "Z")
log(f"field centre {RA_SEX} {DEC_SEX} (J2000) = {RA_DEG}, {DEC_DEG}")
log(f"epoch {OBS_TIME} UTC, obs code {MPC_CODE}, "
f"radius {RADIUS_ARCMIN}' , V<{VMAG_LIM}")
log()
for kind, label in (("a", "asteroids"), ("c", "comets")):
try:
show(jpl(kind), label)
except Exception as e: # noqa: BLE001
log(f" JPL {label} EXCEPTION {type(e).__name__}: {e}")
log()
mpchecker()
# --- derived summary: total sky motion and position angle for JPL hits ---
log()
log("=== derived summary (from JPL second pass, sb-kind=a) ===")
try:
d = jpl("a")
for row in (d.get("data_second_pass") or []):
name, ra, dec = row[0], row[1], row[2]
norm = float(row[5])
vmag = row[6]
dra, ddec = float(row[7]), float(row[8]) # "/h, RA rate has cos(dec)
tot = math.hypot(dra, ddec)
pa = math.degrees(math.atan2(dra, ddec)) % 360.0 # N through E
log(f"{name}: RA {ra} Dec {dec} V={vmag} "
f"{norm:.0f}\" ({norm/60:.1f}') from centre "
f"motion {tot:.1f}\"/hr at PA {pa:.1f} deg "
f"(dRA*cos(dec)={dra:.1f}, dDec={ddec:.1f} \"/hr)")
except Exception as e: # noqa: BLE001
log(f"summary EXCEPTION {type(e).__name__}: {e}")
with open(OUT, "w", encoding="utf-8") as fh:
fh.write("\n".join(log_lines) + "\n")
print("\nwrote " + OUT)