Generic pipeline stage 1, and state files a cold session can resume from #1
1 changed files with 353 additions and 0 deletions
353
pipeline/session.py
Normal file
353
pipeline/session.py
Normal file
|
|
@ -0,0 +1,353 @@
|
||||||
|
"""What a session contains, decided by reading the frames rather than the names.
|
||||||
|
|
||||||
|
The first version of this pipeline parsed filenames: it expected
|
||||||
|
`calibrated-T32-...-Luminance-BIN2-W-300-001.fit` and broke on anything else.
|
||||||
|
That works for exactly one telescope, one binning and one exposure length. Every
|
||||||
|
other session in the archive - narrowband from 2024, an Ha-only run from 2021,
|
||||||
|
an RGB set with no luminance at all - fails that pattern.
|
||||||
|
|
||||||
|
So this module opens each frame and asks the header. FITS carries FILTER,
|
||||||
|
EXPTIME, XBINNING, OBJECT and DATE-OBS precisely so that software does not have
|
||||||
|
to guess from a filename, and iTelescope fills them in properly.
|
||||||
|
|
||||||
|
The unit of work is a *session*: one target, one telescope, one night, in one
|
||||||
|
directory. Everything else in the pipeline takes a Session and asks it what
|
||||||
|
filters exist and which frames belong to each.
|
||||||
|
"""
|
||||||
|
import glob
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import zipfile
|
||||||
|
from collections import defaultdict
|
||||||
|
|
||||||
|
from astropy.io import fits
|
||||||
|
|
||||||
|
import layout
|
||||||
|
|
||||||
|
# Filters that carry broadband colour, in the order a colour image wants them.
|
||||||
|
BROADBAND = ("Red", "Green", "Blue")
|
||||||
|
LUMINANCE = ("Luminance", "Clear", "Lum", "L")
|
||||||
|
# Narrowband, with the aliases different telescopes write into FILTER.
|
||||||
|
NARROWBAND = {
|
||||||
|
"Ha": ("Ha", "H-alpha", "Halpha", "HA", "H_Alpha"),
|
||||||
|
"OIII": ("OIII", "O3", "Oiii", "O-III"),
|
||||||
|
"SII": ("SII", "S2", "Sii", "S-II"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def canonical_filter(raw):
|
||||||
|
"""Map whatever the header says onto a name the pipeline knows."""
|
||||||
|
if raw is None:
|
||||||
|
return "Unknown"
|
||||||
|
name = str(raw).strip()
|
||||||
|
for canon, aliases in NARROWBAND.items():
|
||||||
|
if name in aliases or name.lower() == canon.lower():
|
||||||
|
return canon
|
||||||
|
for canon in BROADBAND:
|
||||||
|
if name.lower() == canon.lower() or name.lower() == canon[0].lower():
|
||||||
|
return canon
|
||||||
|
if name.lower() in [a.lower() for a in LUMINANCE]:
|
||||||
|
return "Luminance"
|
||||||
|
return name or "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
class Frame:
|
||||||
|
"""One light frame and the header facts the pipeline needs."""
|
||||||
|
|
||||||
|
__slots__ = ("path", "filter", "exptime", "binning", "object", "date_obs",
|
||||||
|
"telescope", "instrument", "shape", "scale", "calibrated")
|
||||||
|
|
||||||
|
def __init__(self, path, header, shape, calibrated=True):
|
||||||
|
self.calibrated = calibrated
|
||||||
|
self.path = path
|
||||||
|
self.filter = canonical_filter(header.get("FILTER"))
|
||||||
|
self.exptime = float(header.get("EXPTIME", header.get("EXPOSURE", 0)))
|
||||||
|
self.binning = int(header.get("XBINNING", 1))
|
||||||
|
self.object = str(header.get("OBJECT", "")).strip()
|
||||||
|
self.date_obs = str(header.get("DATE-OBS", ""))
|
||||||
|
self.telescope = str(header.get("TELESCOP", "")).strip()
|
||||||
|
self.instrument = str(header.get("INSTRUME", "")).strip()
|
||||||
|
self.shape = shape
|
||||||
|
# iTelescope writes the plate scale in; without it, solve.py estimates.
|
||||||
|
scale = header.get("HIERARCH iTelescopePlateScaleH")
|
||||||
|
try:
|
||||||
|
self.scale = float(scale) if scale is not None else None
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
self.scale = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def name(self):
|
||||||
|
return os.path.basename(self.path)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (f"<{self.filter} {self.exptime:.0f}s bin{self.binning} "
|
||||||
|
f"{self.name}>")
|
||||||
|
|
||||||
|
|
||||||
|
class Session:
|
||||||
|
"""One target, one telescope, one night."""
|
||||||
|
|
||||||
|
def __init__(self, root):
|
||||||
|
self.root = os.path.abspath(root)
|
||||||
|
self.frames = []
|
||||||
|
self._by_filter = {}
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- ingest
|
||||||
|
@staticmethod
|
||||||
|
def _is_mirror_junk(path):
|
||||||
|
"""True for artefacts a broken segmented download leaves behind.
|
||||||
|
|
||||||
|
Cyberduck writes a DIRECTORY named after the file it is fetching, and
|
||||||
|
leaves `.cyberducksegment` parts inside when interrupted. Those
|
||||||
|
directories are named `something.fit`, so anything that trusts the
|
||||||
|
extension will try to open a directory as a frame. A re-mirror over an
|
||||||
|
already-organised tree produces dozens of them.
|
||||||
|
"""
|
||||||
|
name = os.path.basename(path).lower()
|
||||||
|
if ".cyberducksegment" in name or name.endswith((".part", ".partial")):
|
||||||
|
return True
|
||||||
|
parent = os.path.basename(os.path.dirname(path)).lower()
|
||||||
|
return parent.endswith((".fit", ".fits", ".tif", ".tiff", ".zip"))
|
||||||
|
|
||||||
|
def ingest(self, verbose=True):
|
||||||
|
"""Put the session into the standard layout and uncompress the lights.
|
||||||
|
|
||||||
|
Handles the three states a downloaded session can be in: still zipped,
|
||||||
|
already extracted, or a mixture of both (which is what happens when
|
||||||
|
someone unzips a few by hand).
|
||||||
|
"""
|
||||||
|
raw = os.path.join(self.root, layout.RAW)
|
||||||
|
cal = os.path.join(self.root, layout.CALIBRATED)
|
||||||
|
os.makedirs(raw, exist_ok=True)
|
||||||
|
os.makedirs(cal, exist_ok=True)
|
||||||
|
|
||||||
|
moved = extracted = skipped = duplicates = 0
|
||||||
|
# Loose files at the session root get filed first.
|
||||||
|
for path in sorted(glob.glob(os.path.join(self.root, "*"))):
|
||||||
|
if os.path.isdir(path):
|
||||||
|
continue
|
||||||
|
name = os.path.basename(path)
|
||||||
|
low = name.lower()
|
||||||
|
if self._is_mirror_junk(path):
|
||||||
|
continue
|
||||||
|
if low.endswith(".zip") or (low.startswith("jpeg-") and
|
||||||
|
low.endswith(".jpg")):
|
||||||
|
dest = os.path.join(raw, name)
|
||||||
|
elif low.endswith((".fit", ".fits", ".tif", ".tiff")):
|
||||||
|
dest = os.path.join(cal, name)
|
||||||
|
else:
|
||||||
|
continue
|
||||||
|
if not os.path.exists(dest):
|
||||||
|
os.replace(path, dest)
|
||||||
|
moved += 1
|
||||||
|
elif os.path.getsize(dest) == os.path.getsize(path):
|
||||||
|
# Already filed, and the mirror has put an identical copy back.
|
||||||
|
os.remove(path)
|
||||||
|
duplicates += 1
|
||||||
|
|
||||||
|
# iTelescope ships each frame twice: 'calibrated-*' with bias, dark
|
||||||
|
# and flat applied, and 'raw-*' without. Extracting both put two copies
|
||||||
|
# of every frame into the stack. Only the calibrated set is extracted;
|
||||||
|
# the raw archives stay zipped in raw/ as the untouched originals. If a
|
||||||
|
# session has no calibrated archives at all, the raw ones are used and
|
||||||
|
# the fact is recorded.
|
||||||
|
archives = sorted(glob.glob(os.path.join(raw, "*.zip")))
|
||||||
|
calibrated_archives = [z for z in archives
|
||||||
|
if os.path.basename(z).lower().startswith(
|
||||||
|
"calibrated")]
|
||||||
|
self.used_raw = not calibrated_archives and bool(archives)
|
||||||
|
if self.used_raw:
|
||||||
|
print(" NOTE: no calibrated archives in this session - "
|
||||||
|
"extracting the raw frames instead. They have NOT had bias, "
|
||||||
|
"dark or flat correction applied.")
|
||||||
|
else:
|
||||||
|
archives = calibrated_archives
|
||||||
|
|
||||||
|
for zpath in archives:
|
||||||
|
try:
|
||||||
|
with zipfile.ZipFile(zpath) as zf:
|
||||||
|
for info in zf.infolist():
|
||||||
|
base = os.path.basename(info.filename)
|
||||||
|
if not base.lower().endswith((".fit", ".fits", ".tif",
|
||||||
|
".tiff")):
|
||||||
|
continue
|
||||||
|
target = os.path.join(cal, base)
|
||||||
|
if os.path.exists(target) and \
|
||||||
|
os.path.getsize(target) == info.file_size:
|
||||||
|
skipped += 1
|
||||||
|
continue
|
||||||
|
with zf.open(info) as src, open(target, "wb") as dst:
|
||||||
|
while chunk := src.read(1 << 20):
|
||||||
|
dst.write(chunk)
|
||||||
|
extracted += 1
|
||||||
|
except zipfile.BadZipFile:
|
||||||
|
print(f" WARNING: {os.path.basename(zpath)} is not a "
|
||||||
|
f"readable zip - skipped")
|
||||||
|
if verbose:
|
||||||
|
print(f" filed {moved}, extracted {extracted}, "
|
||||||
|
f"already present {skipped}, "
|
||||||
|
f"duplicate copies removed {duplicates}")
|
||||||
|
return dict(moved=moved, extracted=extracted, skipped=skipped,
|
||||||
|
duplicates=duplicates)
|
||||||
|
|
||||||
|
# ----------------------------------------------------------------- scan
|
||||||
|
def scan(self, verbose=True):
|
||||||
|
"""Read every calibrated light frame's header."""
|
||||||
|
cal = os.path.join(self.root, layout.CALIBRATED)
|
||||||
|
paths = sorted(p for p in glob.glob(os.path.join(cal, "*.fit")) +
|
||||||
|
glob.glob(os.path.join(cal, "*.fits"))
|
||||||
|
if os.path.isfile(p) and not self._is_mirror_junk(p))
|
||||||
|
self.frames = []
|
||||||
|
for path in paths:
|
||||||
|
try:
|
||||||
|
with fits.open(path, memmap=True) as hd:
|
||||||
|
hdr = hd[0].header
|
||||||
|
shape = hd[0].shape
|
||||||
|
except Exception as exc: # noqa: BLE001
|
||||||
|
print(f" WARNING: cannot read {os.path.basename(path)}: "
|
||||||
|
f"{exc}")
|
||||||
|
continue
|
||||||
|
# Skip anything that is not a light frame.
|
||||||
|
if str(hdr.get("IMAGETYP", "Light")).lower().startswith(
|
||||||
|
("dark", "bias", "flat")):
|
||||||
|
continue
|
||||||
|
self.frames.append(Frame(path, hdr,
|
||||||
|
shape, calibrated=bool(
|
||||||
|
str(hdr.get("CALSTAT", "")).strip())))
|
||||||
|
|
||||||
|
# If both calibrated and uncalibrated frames are present - which
|
||||||
|
# happens when a session was extracted before this rule existed - keep
|
||||||
|
# only the calibrated ones rather than stacking each frame twice.
|
||||||
|
if any(f.calibrated for f in self.frames):
|
||||||
|
dropped = [f for f in self.frames if not f.calibrated]
|
||||||
|
if dropped and verbose:
|
||||||
|
print(f" ignoring {len(dropped)} uncalibrated duplicate "
|
||||||
|
f"frames (no CALSTAT)")
|
||||||
|
self.frames = [f for f in self.frames if f.calibrated]
|
||||||
|
|
||||||
|
self._by_filter = defaultdict(list)
|
||||||
|
for f in self.frames:
|
||||||
|
self._by_filter[f.filter].append(f)
|
||||||
|
if verbose:
|
||||||
|
self.describe()
|
||||||
|
return self.frames
|
||||||
|
|
||||||
|
# ------------------------------------------------------------ properties
|
||||||
|
@property
|
||||||
|
def filters(self):
|
||||||
|
"""Filters present, deepest first - the deepest becomes the reference."""
|
||||||
|
return sorted(self._by_filter,
|
||||||
|
key=lambda f: -sum(x.exptime for x in self._by_filter[f]))
|
||||||
|
|
||||||
|
def by_filter(self, name):
|
||||||
|
return list(self._by_filter.get(name, []))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def target(self):
|
||||||
|
names = [f.object for f in self.frames if f.object]
|
||||||
|
return max(set(names), key=names.count) if names else \
|
||||||
|
os.path.basename(os.path.dirname(self.root))
|
||||||
|
|
||||||
|
@property
|
||||||
|
def telescope(self):
|
||||||
|
names = [f.telescope for f in self.frames if f.telescope]
|
||||||
|
return max(set(names), key=names.count) if names else "unknown"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def scale(self):
|
||||||
|
vals = [f.scale for f in self.frames if f.scale]
|
||||||
|
return sum(vals) / len(vals) if vals else None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def palette(self):
|
||||||
|
"""Which colour scheme this session's filters support."""
|
||||||
|
present = set(self._by_filter)
|
||||||
|
has_lum = "Luminance" in present
|
||||||
|
broad = [c for c in BROADBAND if c in present]
|
||||||
|
narrow = [n for n in ("SII", "Ha", "OIII") if n in present]
|
||||||
|
if len(broad) == 3 and has_lum:
|
||||||
|
return "LRGB"
|
||||||
|
if len(broad) == 3:
|
||||||
|
return "RGB"
|
||||||
|
if len(narrow) >= 3:
|
||||||
|
return "SHO"
|
||||||
|
if "Ha" in narrow and "OIII" in narrow:
|
||||||
|
return "HOO"
|
||||||
|
if len(present) == 1:
|
||||||
|
return "MONO"
|
||||||
|
if broad or narrow:
|
||||||
|
return "PARTIAL"
|
||||||
|
return "UNKNOWN"
|
||||||
|
|
||||||
|
def describe(self):
|
||||||
|
print(f" target {self.target}")
|
||||||
|
print(f" telescope {self.telescope}")
|
||||||
|
if self.scale:
|
||||||
|
print(f" plate scale {self.scale:.4f} arcsec/px")
|
||||||
|
print(f" palette {self.palette}")
|
||||||
|
for name in self.filters:
|
||||||
|
fs = self._by_filter[name]
|
||||||
|
exps = sorted({f.exptime for f in fs})
|
||||||
|
bins = sorted({f.binning for f in fs})
|
||||||
|
total = sum(f.exptime for f in fs) / 60.0
|
||||||
|
shapes = sorted({f.shape for f in fs})
|
||||||
|
shape_txt = "/".join(f"{s[1]}x{s[0]}" for s in shapes)
|
||||||
|
print(f" {name:10s} {len(fs):3d} frames "
|
||||||
|
f"{'/'.join(f'{e:.0f}' for e in exps):>12s} s "
|
||||||
|
f"bin{'/'.join(str(b) for b in bins)} "
|
||||||
|
f"{shape_txt:>11s} {total:6.1f} min")
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- manifest
|
||||||
|
def manifest(self):
|
||||||
|
return {
|
||||||
|
"root": self.root,
|
||||||
|
"target": self.target,
|
||||||
|
"telescope": self.telescope,
|
||||||
|
"plate_scale": self.scale,
|
||||||
|
"palette": self.palette,
|
||||||
|
"filters": {
|
||||||
|
name: {
|
||||||
|
"frames": len(self._by_filter[name]),
|
||||||
|
"total_exposure_s": sum(f.exptime
|
||||||
|
for f in self._by_filter[name]),
|
||||||
|
"exposures": sorted({f.exptime
|
||||||
|
for f in self._by_filter[name]}),
|
||||||
|
"binning": sorted({f.binning
|
||||||
|
for f in self._by_filter[name]}),
|
||||||
|
} for name in self.filters
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
def save_manifest(self):
|
||||||
|
path = os.path.join(self.root, "manifest.json")
|
||||||
|
with open(path, "w", encoding="utf-8") as fh:
|
||||||
|
json.dump(self.manifest(), fh, indent=2)
|
||||||
|
return path
|
||||||
|
|
||||||
|
|
||||||
|
def discover(root):
|
||||||
|
"""Find every session under a directory tree.
|
||||||
|
|
||||||
|
A session is any directory that directly contains light frames or their
|
||||||
|
archives, which matches how iTelescope's downloads are organised
|
||||||
|
(telescope / object / date).
|
||||||
|
"""
|
||||||
|
found = []
|
||||||
|
for dirpath, dirnames, filenames in os.walk(root):
|
||||||
|
base = os.path.basename(dirpath)
|
||||||
|
if base.lower().endswith((".fit", ".fits", ".tif", ".tiff", ".zip")) or ".cyberducksegment" in base.lower():
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
if base in (layout.RAW, layout.CALIBRATED, "intermediates",
|
||||||
|
"final", "renderings", "science", "stacks"):
|
||||||
|
dirnames[:] = []
|
||||||
|
continue
|
||||||
|
has_data = any(f.lower().endswith((".zip", ".fit", ".fits"))
|
||||||
|
for f in filenames)
|
||||||
|
has_layout = any(d in dirnames for d in (layout.RAW,
|
||||||
|
layout.CALIBRATED))
|
||||||
|
if has_data or has_layout:
|
||||||
|
found.append(dirpath)
|
||||||
|
dirnames[:] = [d for d in dirnames
|
||||||
|
if d in (layout.RAW, layout.CALIBRATED)]
|
||||||
|
return sorted(found)
|
||||||
Loading…
Add table
Add a link
Reference in a new issue