Merge pull request 'Generic pipeline stage 1, and state files a cold session can resume from' (#1) from generic-pipeline into main

This commit is contained in:
laurence 2026-07-21 17:32:27 +01:00
commit f32411970f
5 changed files with 656 additions and 106 deletions

353
pipeline/session.py Normal file
View 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)

View file

@ -3,14 +3,73 @@
> How the system is built and why. Update this when the structure changes; a change is > How the system is built and why. Update this when the structure changes; a change is
> not finished until this reflects it. > not finished until this reflects it.
A documentation-only repo; there is no code. Three parts, which is why the repository has three top-level content
directories. Until 2026-07-21 the first two lived in separate repositories.
- `TELESCOPES.md`: the deliverable. Per-observatory sections, one entry per telescope ## itelescope/ - the network and the campaign
(spec bullets then a review paragraph), a "choosing a telescope" table, and general
observations. Compiled from the sources below, dated in its header. Documentation and data, no code.
- `data/itelescope-telescopes.csv`: verbatim CSV export of iTelescope's maintained
specs Google Sheet. Treated as the source of truth for numbers; re-exported to - `TELESCOPES.md`: the deliverable. Per-observatory sections, one entry per
refresh (see state/NOTES.md). telescope (spec bullets then a review paragraph), a "choosing a telescope"
- `state/`: Default Workflow project memory. table, and general observations. Compiled from the sources below.
- `CLAUDE.md` + `docs/`: the Default Workflow itself, copied from the - `TARGETS.md`: southern targets invisible from the UK, matched to scopes and
Default-Workflow repo so the project is self-contained. to the time of year.
- `CAMPAIGN.md`: the points drain campaign - ledger, bookings, queue, rate
model. Updated after every session from the account's transaction history.
- `data/itelescope-telescopes.csv`: verbatim CSV export of iTelescope's
maintained specs sheet. The source of truth for numbers; re-exported to
refresh (see `state/NOTES.md`).
- `plans/`: ACP observing plans, one per booked run, uploaded to the scope.
## pipeline/ - the processing code
Python 3.12. The design rule is that **code and data are separate**: nothing
here knows an absolute path. A session is located at runtime through the
`ASTRO_SESSION` environment variable.
- `layout.py` maps a **filename** to the subdirectory it belongs in, using the
same rules the session directories are organised with. A script asks for
`master-Red.fit` or `_stars.npz` and gets the right path without any call
site knowing the structure. Unrecognised names resolve to the session root,
which is visible and correctable rather than silently wrong.
- `session.py` is the entry point for anything generic. It discovers sessions,
ingests and uncompresses them, reads every frame's header, groups frames by
filter, and derives the colour scheme (LRGB / RGB / SHO / HOO / MONO) from
what is actually present. **Header-driven, never filename-driven** - see
DECISIONS.
- `restructure.py` reorganises a flat session directory into the standard
layout. Idempotent, dry run by default.
- The remaining scripts are the NGC 5128 session as it was actually run:
`unzip -> analyse -> stack -> solve -> depth -> compose -> hdr/enhance/
starless/annotate -> final -> closeup -> triptych`, plus the analysis
families `gc-*` (globular clusters), `sb_*` (surface photometry) and `mo_*`
(moving objects and transients). They are kept as provenance for published
results and are being generalised stage by stage.
### Session layout on disk (not in this repo)
```
<session>/
METHODS.md what was done to this data and what was found
raw/ exactly what the telescope delivered
calibrated/ uncompressed calibrated subs
stacks/masters/ per-filter registered, combined, plate-solved
stacks/original/ alignment-only baseline, no other processing
final/ the deliverable renderings
renderings/ other finished images
science/figures|catalogues|data|notes
intermediates/ caches; deletable, regenerated by a re-run
```
## observing/ - in-person plans
Field plans worked out from real numbers, revised as a date approaches. The
reasoning is the part worth keeping, so plans record why a decision was made,
not only what was decided.
## The workflow itself
`CLAUDE.md` + `docs/`: the Default Workflow, copied in so the project is
self-contained. `state/`: project memory - objective, current work, decisions,
notes.

View file

@ -3,6 +3,33 @@
> A dated, append only log of decisions and their rationale. Newest at the top. Never > A dated, append only log of decisions and their rationale. Newest at the top. Never
> rewrite past entries; if a decision is reversed, add a new entry that says so. > rewrite past entries; if a decision is reversed, add a new entry that says so.
## 2026-07-21: the pipeline reads headers, not filenames
The first pipeline parsed `calibrated-T32-...-Luminance-BIN2-W-300-001.fit` to
learn a frame's filter, binning and exposure. That works for one telescope, one
binning and one exposure length. Three of the four archived sessions have no
luminance channel at all and one is from 2021 with different naming, so the
approach could not survive contact with the archive.
FITS headers carry FILTER, EXPTIME, XBINNING, OBJECT and DATE-OBS precisely so
software does not have to guess, and iTelescope fills them in properly. The
pipeline opens each frame and asks. Filter aliases are mapped, so H-alpha,
Halpha and HA all resolve to Ha.
The colour scheme is then derived from what is present rather than assumed:
LRGB, RGB without luminance, SHO, HOO, or mono. That is what lets one command
handle a galaxy in LRGB and a nebula in narrowband without being told which is
which.
## 2026-07-21: push-button imaging, hand-driven science
The imaging half - unzip, measure, register, stack, plate solve, compose - is
mechanical and should run unattended on any session. The analyses are not: a
globular cluster survey suits an elliptical galaxy and is meaningless for an
emission nebula, and much of the value in the NGC 5128 work came from noticing
when a result was wrong rather than from running the code. So the pipeline is
being built to be push-button, and the science stays step by step.
## 2026-07-21: merge itelescope and astro-pipeline into one repository ## 2026-07-21: merge itelescope and astro-pipeline into one repository
The two were split by accident of chronology rather than by design. `itelescope` The two were split by accident of chronology rather than by design. `itelescope`

View file

@ -78,3 +78,72 @@ Paid imaging needs no reservation when a scope is idle: same walk-in ACP flow
as the free scopes, points just deduct. Reservations (to guarantee a slot) go as the free scopes, points just deduct. Reservations (to guarantee a slot) go
through lookup.itelescope.online, which is Cloudflare-protected: Playwright through lookup.itelescope.online, which is Cloudflare-protected: Playwright
needed. needed.
## Processing the downloaded archive (2026-07-21)
Gotchas found by running code against the real archive rather than reasoning
about it. Each one produced a plausible-looking wrong answer, not an error.
- **Every frame is delivered twice.** iTelescope ships `calibrated-*` (bias,
dark and flat applied) and `raw-*` (not). Extracting both puts two copies of
every frame into the stack, and the counts look merely generous rather than
wrong: NGC 5128 scanned as 24 luminance frames when it has 12. `CALSTAT` in
the header is the reliable discriminator - calibrated frames carry it, raw
frames do not. Only the calibrated archives are extracted; the raw archives
stay zipped as the untouched originals.
- **An interrupted segmented download leaves DIRECTORIES named like files.**
Cyberduck creates a directory called `something.fit` while fetching, with
`.cyberducksegment` parts inside. Anything that trusts a file extension will
try to open a directory as a frame. After a re-mirror over an already
organised tree there were 28 of them. Discovery, ingest and scan all skip
them explicitly.
- **A re-mirror duplicates rather than replaces.** Mirroring on top of an
organised tree restored ~1 GB of files to session roots that had already been
filed into `raw/`. Ingest now removes byte-identical copies as it refiles.
- **Two T20 archives are genuinely corrupt** and cannot be extracted
(`ic1805-...-Ha-...-015` and `-016`). That is real data loss, not a bug: the
session has 14 of 16 frames. The warning is printed rather than swallowed.
- **Extraction triples the disk footprint.** The archive went from 6.1 GB of
zips to 14 GB extracted, which filled the drive. The TIFFs are 1.69 GB of
that and the pipeline never reads them - it only uses the FITS.
- **Channels do not always share a pixel grid.** NGC 6744 was shot with
luminance at bin1 (4096x4096) and colour at bin2 (2048x2048). Registration
has to resample onto a common grid; code that assumes a single frame shape
will fail on this session and only this session.
## Processing lessons from NGC 5128 (2026-07-21)
Kept because each is a way to get a confident wrong answer:
- **Measure a core with the stars filtered out.** Taking the maximum pixel in a
box around the frame centre found a foreground star, not the galaxy, and led
to booking telescope time to fix a saturation problem that did not exist.
- **Registration makes sensor defects look like perfect asteroids.** Holding
the sky still drags anything detector-fixed across the frame on a dead
straight, constant-rate, constant-brightness track. Hot pixels pass every
standard quality cut. Requiring the motion to be real in DETECTOR coordinates
took 141 confident detections to zero.
- **Never compare an aperture magnitude to a point-source catalogue without
checking the source is a point.** A resolved object looks exactly like a
2.8 magnitude outburst. Carry r50/psf through to the cross-match.
- **Never fit a sky background to a field the target fills.** A plane fitted
around a large galaxy absorbs its halo, measured at -17.9 ADU/px. Fit the
background and a source model together.
- **"No Gaia counterpart" is not a star-rejection test** at extragalactic
distances: half of Centaurus A's catalogued globular clusters have a Gaia
source, because a cluster at 3.8 Mpc is a point source. Reject on parallax
and proper motion instead.
- **A coarse background mesh fails in the direction that flatters you.** It
destroys detection near a bright galaxy while leaving the outer field intact,
manufacturing a falsely flat radial profile.

View file

@ -1,121 +1,163 @@
# TODO # TODO
> The current state of play. Keep it honest and current; this is what the next session > The current state of play. Keep it honest and current; this is what the next
> reads to know what to do. Move items between sections as they progress. > session reads to know what to do. Move items between sections as they progress.
## Done ---
- 2026-07-17: repo created on git.discworld.casa, Default Workflow scaffold copied in. ## RESUME HERE - 2026-07-21, end of session
- 2026-07-17: specs sheet snapshotted to data/itelescope-telescopes.csv (24 scopes).
- 2026-07-17: TELESCOPES.md written: per-scope reviews for all 23 active telescopes
plus a choosing guide (T74 noted as in commissioning, no specs published).
- 2026-07-18: TARGETS.md written: southern targets invisible from the UK (dec tiers
from lat 51.5°N), matched to Q62/X07 scopes, seasonal booking calendar, and a
July-August starter campaign (T33 free, T71 narrowband, T73 galaxy, T8 LMC later).
## Done (recent) Three strands are live. Each has a clear next action.
- 2026-07-20: NGC 6744 on T59 SUCCEEDED (reservation 749227, txn 851148): full ### 1. Generic pipeline - MID-BUILD, this is the active work
LRGB dataset on the scope server (L 15x300s BIN1, RGB 7x300s each BIN2),
previews verified clean. Billed 381 pts (est was ~240: see the rate
correction in NOTES.md). Balance now 2,257. Ledger updated in CAMPAIGN.md.
## Done (recent) Building a push-button pipeline that processes any session, not just LRGB from
one telescope. Branch `generic-pipeline`.
- 2026-07-21 ~08:55 UTC: free T33 47 Tuc retry booked as reservation 749314 **Done:** `pipeline/session.py` - discovery, ingest, header-driven scanning,
(grid-verified), third attempt at the first-light test, runs 14:05-14:50 UTC filter grouping, palette detection (LRGB / RGB / SHO / HOO / MONO), manifest
21 Jul, costs 0. writing. Tested against all five archived sessions.
## Done (recent) **Next:** `measure` -> `register/stack` -> `solve` -> `compose` -> `report`,
then a `run.py` orchestrator with resumable stages.
- 2026-07-21 11:40 UTC: Cen A on T32 verified COMPLETE (billed 160, NGC5128 **The blocker before any of that:** the disk is full (see below).
images on the scope server), and queue #2 (Corona Australis on T8) booked for
the night of 22 Jul, 21:45-00:45 SSO local, which brackets the target's
culmination instead of chasing it down the western sky. Rate model rebuilt
from two actual bills. Details in the CAMPAIGN.md session note.
## In progress **The known hard part:** NGC 6744 mixes bin1 luminance at 4096x4096 with bin2
colour at 2048x2048. Registration must resample onto a common grid rather than
assume every frame shares a shape. Nothing else in the archive does this, so it
is easy to forget and it will fail loudly when it happens.
- T33 first-light test on its THIRD booking (749314), which had NOT yet run as of ### 2. Three decisions waiting on the user
11:40 UTC 21 Jul: it starts 14:05 UTC. Verify after 14:50 UTC (images and logs
on t33.itelescope.net:8033 were still empty/stale at 11:40, as expected). The - **Where should the image archive live?** `C:` has ~2.6 GB free and the
two prior free reservations (749228, 749229) expired unused with reschedule=No; archive is 14 GB after extraction. ~2.4 GB is safely reclaimable (1.69 GB of
if 749314 also fails to run, stop rebooking and either walk it in (POST extracted TIFFs the pipeline never reads, 0.74 GB of mirror-junk duplicates)
plan.asp per plans/README.md) or drop the free objective. but that only buys headroom, it does not fix it. The pipeline is location
- The T32 HDR core set (plans/hdrcore-t32.txt) was booked and then CANCELLED on independent via `ASTRO_SESSION`, so moving the tree costs nothing but a copy.
2026-07-21 without running: the Cen A nucleus is not saturated, the earlier **Do not start batch processing until this is resolved - it will fail
claim having measured a foreground star. See the correction in CAMPAIGN.md. partway through.**
Zero points spent. The plan file remains available on T32 if a genuinely - **NGC 6744 already has outputs** from a different session on 2026-07-20, in
saturated target turns up. `T59/NGC6744/20260719/stacked/` under a different naming convention
- The 21 Jul Cen A data has been fully processed off-repo, in the user's (`NGC6744_LRGB_final.png`, `master_Blue_NGC6744_20260719.fit`). Re-process
Downloads\NGC5128\20260721\stacked folder: masters, plate solution (0.27 into the new structure, or leave it alone?
arcsec on 237 stars), an LRGB composite, a globular cluster survey (289 - **T08 NGC 2070 has one frame per filter.** No stacking, no outlier rejection
candidates, 62% catalogue-confirmed, density falling 10.5x outward), surface possible. Worth an SHO composite anyway, or skip?
photometry (Sersic n = 4.27, Re = 400 arcsec, total G = 6.56, which matches
the catalogued brightness and so validates the photometric chain), and a ### 3. Drain campaign - runs itself, but needs checking daily
transient/moving-object search (both null, with measured limits). METHODS.md
there documents the lot. Nothing from that analysis needs to come back into See `itelescope/CAMPAIGN.md`. Next checkpoints below under "iTelescope".
this repo, but two results affect FUTURE sessions and are recorded here:
**(a)** measure saturation properly before buying short subs, and **(b)** the ---
faint outer halo in that stack is limited by the sky-plane subtraction
(-17.9 ADU/px of real halo light absorbed), not by exposure time, so more ## The archive being processed
integration alone would not have gone deeper.
- CrA on T8 runs 11:45-14:45 UTC 22 Jul. Verify after 14:45 UTC: images under `Downloads\itelescope\` - five sessions, four telescopes, 2021 to 2026. Frame
/images/qisback/T8/ on t8.itelescope.net:8008, then the actual bill from counts below are AFTER removing the raw/calibrated duplication (see NOTES).
account/history.aspx into the ledger. This is the first T8 bill, so it also
fixes T8's effective rate (card says 130/hr; trust nothing until observed). | Session | Palette | Frames | Note |
- DRAIN CAMPAIGN ACTIVE (see CAMPAIGN.md): spend the full balance by 10 Aug, |---|---|---|---|
then cancel before the 12 Aug renewal (user confirmation required for the | T32 NGC 5128 | LRGB | 12/4/4/4 | matches the hand-processed run exactly: a working regression test |
cancel itself). 541 of 2,638 spent; 2,097 remain. The x1.6 estimate multiplier | T59 NGC 6744 | LRGB | 15/7/7/7 | **L bin1 4096², RGB bin2 2048²** - mixed grids |
is WITHDRAWN: billing is on plan imaging minutes, and per-scope rates must come | T20 IC 1805 | Ha only | 14 | mono; **2 frames lost to corrupt archives** |
from actual bills (T59 127/hr, T32 80/hr observed; the NOTES rate card is | T17 NGC 2030 | RGB, no L | 3/3/3 | only 6 min per channel |
unreliable). On current numbers the campaign UNDER-spends by ~800: plan extra | T08 NGC 2070 | SHO | 1/1/1 | one 60 s frame per filter |
or longer runs, do not trim. Reschedule flag never sticks (grid always shows
No): treat every reservation as one-shot and verify the morning after. ---
Download the NGC 6744 and NGC 5128 data from data.itelescope.net before the
10 Aug cancellation sweep. Update the CAMPAIGN.md ledger after every session ## Done - 2026-07-21
from account/history.aspx.
### Repositories
- **Merged `itelescope` + `astro-pipeline` into `astrophotography`**, both
histories preserved (45 commits; `git log --follow` traces files across the
move). Old repos carry redirect notices in README and CLAUDE.md; **not
archived**, because the campaign is live and other sessions work in
`itelescope` most nights. Archive after 12 Aug.
### NGC 5128 processed end to end (the reference session)
Lives in `Downloads\NGC5128\20260721\`, organised into `raw/ calibrated/
stacks/ final/ renderings/ science/ intermediates/` with a `METHODS.md` and a
README in each subdirectory. Results:
- Plate solve: 237 stars, **0.27 arcsec residual**, 0.5376 arcsec/px
- Photometry: zero point G = -2.5 log10(flux) + 27.941, scatter 0.164 mag,
limiting **G ~ 20.7** at SNR 5
- **Four deliverable images** in `final/`, from raw stack to science-informed,
plus a close-up framed by the galaxy's measured extent
- **Globular clusters:** 289 candidates, 179 (62%) catalogue-confirmed, density
falling **10.5x** outward. A real detection.
- **Surface photometry:** Sersic n = 4.27, Re = 400 arcsec, total G = 6.56
which matches the catalogued value and validates the whole calibration chain.
No shells, and the data could never have shown them (systematics-limited,
3 mag short).
- **Transients/moving objects:** two clean nulls with measured limits.
### Corrections made (both were Claude's errors - do not repeat them)
- **The Cen A nucleus was never saturated.** A foreground star 69 arcsec from
the nucleus was measured instead of the galaxy. A T32 booking made to "fix"
it was cancelled before running, costing nothing. Always filter stars out
before measuring a core.
- **NGC 6744 HAD already been processed** by another session on 20 Jul, and the
user was told otherwise. Check for existing outputs before asserting none
exist.
### Eclipse plan written
`observing/eclipse-2026-menorca/PLAN.md` - full field plan.
---
## Pending - pipeline ## Pending - pipeline
- Productionise the processing code: a staged CLI - Finish the stages: measure, register/stack, solve, compose, report, run.py.
(ingest -> calibrate -> measure -> register -> stack -> solve -> compose -> - Then productionise: staged CLI, each stage resumable, packaged as an
analyse), each stage resumable, packaged as an Apptainer image. Slurm array **Apptainer image**. **Slurm array jobs only for the expensive stages** -
jobs afterwards and ONLY for the expensive stages: measured on the NGC 5128 measured on NGC 5128, per-frame work is 2.9 s and all 24 frames take 1.1 min
session, per-frame work is 2.9 s and all 24 frames take 1.1 min single-threaded, so a scheduler buys nothing on stacking. It pays for the
single-threaded, so a scheduler buys nothing there. It pays for the Monte Monte Carlo work (completeness and injection-recovery tests, difference
Carlo work (completeness and injection-recovery tests, difference imaging, imaging, shift-and-stack), which is where compute actually bit.
shift-and-stack), which is where compute actually bit.
- Must handle beyond mono LRGB: narrowband palettes, one-shot colour with - Must handle beyond mono LRGB: narrowband palettes, one-shot colour with
debayering, other observatories' header conventions, and full calibration debayering, other observatories' header conventions, and full calibration
from bias/dark/flat for sources that do not pre-calibrate. from bias/dark/flat for sources that do not pre-calibrate.
- Three findings from the first session are requirements, not options: vet - **Three findings from the first session are requirements, not options:**
moving objects in DETECTOR coordinates; carry r50/psf into any catalogue vet moving objects in DETECTOR coordinates; carry r50/psf into any catalogue
cross-match; never fit a sky background to a field the target fills. cross-match; never fit a sky background to a field the target fills.
- Object-prefixed output naming should be derived from the OBJECT header, not
typed in. On a cluster, `final-3-best.png` is ambiguous the moment there is a
second dataset.
- Emit the alignment-only stack as a standard artefact of every run. It costs
almost nothing and it is the reference every later stage can be diffed
against when a processed result looks wrong.
## Pending - science (deliberately after the pipeline)
The analyses are per-object and need judgement; they are not push-button. A
globular cluster survey suits Centaurus A and is meaningless for the Heart
Nebula. Approach these one at a time once the imaging half runs unattended.
## Pending - observing ## Pending - observing
- Total solar eclipse, 12 Aug 2026, Menorca (`observing/eclipse-2026-menorca/`). - **Total solar eclipse, 12 Aug 2026, Menorca.** Totality 1m12s at Ciutadella,
Totality 1m12s at Ciutadella with the Sun at 2 degrees, azimuth 288. Sun at **2 degrees**, azimuth 288, sunset 20:42.
**Order the certified solar filter - it is the only item with no substitute `observing/eclipse-2026-menorca/PLAN.md` has the full plan.
and there are three weeks left.** Then practise on a low sunset to calibrate - **ORDER THE CERTIFIED SOLAR FILTER.** Only item with no substitute.
exposures for the extreme atmospheric extinction. - Practise on a low sunset to calibrate exposures for the extreme extinction.
- Outstanding: tailor the exposure table once the A7 body and lenses are known.
## Pending - iTelescope ## Pending - iTelescope
- Automation: map the ACP web endpoints on a per-scope server (start with free - **CrA on T8** ran 11:45-14:45 UTC 22 Jul. Verify images under
T33) for plan submission and status; explore lookup.itelescope.online (the new `/images/qisback/T8/` on t8.itelescope.net:8008, then put the actual bill from
planner); enumerate more /DataService.svc methods. Recon notes in NOTES.md. account/history.aspx into the ledger. **First T8 bill, so it fixes T8's
- Campaign impact (status seen 2026-07-18): T71 and T73 are offline effective rate** (card says 130/hr; trust nothing until observed).
(development/mount testing), as are T2, T17, T18, T20, T25, T30, T72, T74, - **T33 first-light test** on its third booking (749314, ran 14:05 UTC 21 Jul).
T75, T80. T8, T33, T32, T59 at Siding Spring are operational. Re-plan the If it failed again, stop rebooking: either walk it in (POST plan.asp per
July-August campaign around T33 free time and Siding Spring scopes until the `itelescope/plans/README.md`) or drop the free-imaging objective.
Chile rebuild finishes; re-check status before any booking. - **Campaign:** 541 of 2,638 points spent, 2,097 remain, all to be gone by
10 Aug with cancellation before the 12 Aug renewal (**user confirmation
- Fill in T74 when iTelescope publishes its specs. required for the cancel itself**). On current numbers the campaign
- Reconcile the retired scopes (T9, T19, T31, T69): the support article still lists **under-spends by ~800** - plan extra or longer runs, do not trim.
them but the maintained sheet does not; confirm status and note it. - Download all data from data.itelescope.net before the 10 Aug sweep.
- Optional: per-telescope detail pages on support.itelescope.net (links in - Automation: map ACP endpoints per scope; explore lookup.itelescope.online;
DECISIONS.md) hold photos and operational history if deeper entries are ever wanted. enumerate more /DataService.svc methods.
- Periodic refresh: re-export the Google Sheet and diff against data/ to catch new or - Fill in T74 when specs are published; reconcile retired scopes T9, T19, T31,
changed systems. T69; periodically re-export the specs sheet and diff against
`itelescope/data/`.