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