294 lines
10 KiB
Python
294 lines
10 KiB
Python
"""Cheap FITS header reads and file-integrity checks.
|
|
|
|
The archive's science keywords (``DEGRADED``, ``ECLIPSE``, ``IMG_MEAN``, ...) live in
|
|
the tile-compressed image extension's header, in the first ~18 KB of a 1.7 MB file.
|
|
Reading just those bytes and parsing the 80-column cards by hand is ~5x faster than
|
|
letting astropy open the file (4.1 ms vs 22.2 ms per file measured on this archive),
|
|
which is the difference between a two-hour and a ten-hour pass over the full set.
|
|
astropy is still used for pixel data, where it earns its keep.
|
|
|
|
Every function reports trouble as a structured error string rather than raising, so a
|
|
truncated or corrupt file is a *finding* the detector can score, not an exception that
|
|
kills a worker.
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
import warnings
|
|
|
|
import numpy as np
|
|
from astropy.io import fits
|
|
from astropy.utils.exceptions import AstropyUserWarning
|
|
|
|
from . import db
|
|
|
|
|
|
def quiet_astropy():
|
|
"""Silence astropy's per-file chatter about damaged files.
|
|
|
|
astropy reports conditions like "File may have been truncated" through both its
|
|
own logger and ``warnings``, either of which floods stderr when scanning
|
|
millions of files -- and damaged files are exactly what a bench run is full of.
|
|
Those conditions are already surfaced per-frame by ``read_header``,
|
|
``read_image`` and ``verify_datasums``, so nothing is lost by muting them.
|
|
|
|
Call once per process; worker pools must call it in each child.
|
|
"""
|
|
logging.getLogger("astropy").setLevel(logging.ERROR)
|
|
warnings.simplefilter("ignore", AstropyUserWarning)
|
|
|
|
#: FITS records are a whole number of 2880-byte blocks.
|
|
BLOCK = 2880
|
|
CARD = 80
|
|
#: How far into a file to look for the image HDU header. The real ones end by
|
|
#: ~17 KB; the cap stops a malformed file from pulling megabytes into memory.
|
|
MAX_HEADER_BYTES = BLOCK * 16
|
|
|
|
#: Sentinel some SUVI products use for undefined pixels.
|
|
ZBLANK = -999999
|
|
|
|
|
|
def _parse_value(raw):
|
|
"""Parse a FITS card value, honouring quoted strings and comment separators."""
|
|
raw = raw.strip()
|
|
if not raw:
|
|
return None
|
|
if raw[0] == "'":
|
|
# String value: scan for the closing quote, where '' is a literal quote.
|
|
out = []
|
|
i = 1
|
|
while i < len(raw):
|
|
if raw[i] == "'":
|
|
if i + 1 < len(raw) and raw[i + 1] == "'":
|
|
out.append("'")
|
|
i += 2
|
|
continue
|
|
break
|
|
out.append(raw[i])
|
|
i += 1
|
|
return "".join(out).strip()
|
|
|
|
# Non-string: everything before the comment separator.
|
|
value = raw.split("/", 1)[0].strip()
|
|
if value in ("T", "F"):
|
|
return value == "T"
|
|
if not value:
|
|
return None
|
|
try:
|
|
return int(value)
|
|
except ValueError:
|
|
pass
|
|
try:
|
|
# FITS permits Fortran-style 'D' exponents.
|
|
return float(value.replace("D", "E").replace("d", "e"))
|
|
except ValueError:
|
|
return value
|
|
|
|
|
|
def _parse_header_blocks(buf, pos):
|
|
"""Parse cards from `pos` until the END card.
|
|
|
|
Returns (cards, next_pos), or (None, reason) if END is not reached.
|
|
"""
|
|
cards = {}
|
|
while pos + BLOCK <= len(buf):
|
|
block = buf[pos : pos + BLOCK]
|
|
pos += BLOCK
|
|
for i in range(0, BLOCK, CARD):
|
|
card = block[i : i + CARD]
|
|
key = card[:8].rstrip()
|
|
if key == b"END":
|
|
return cards, pos
|
|
# Only fixed-format valued cards; COMMENT/HISTORY/CONTINUE lack the '= '.
|
|
if card[8:10] == b"= ":
|
|
try:
|
|
name = key.decode("ascii").strip()
|
|
value = _parse_value(card[10:].decode("ascii"))
|
|
except UnicodeDecodeError:
|
|
continue
|
|
if name and name not in cards:
|
|
cards[name] = value
|
|
return None, "header has no END card within the scanned region"
|
|
|
|
|
|
def _data_bytes(cards):
|
|
"""Size of an HDU's data unit, in bytes, rounded up to whole blocks."""
|
|
naxis = cards.get("NAXIS", 0)
|
|
if not isinstance(naxis, int) or naxis <= 0:
|
|
return 0
|
|
count = 1
|
|
for axis in range(1, naxis + 1):
|
|
length = cards.get(f"NAXIS{axis}")
|
|
if not isinstance(length, int) or length < 0:
|
|
return 0
|
|
count *= length
|
|
bitpix = cards.get("BITPIX", 8)
|
|
if not isinstance(bitpix, int):
|
|
return 0
|
|
size = count * abs(bitpix) // 8
|
|
size += cards.get("PCOUNT", 0) or 0
|
|
return ((size + BLOCK - 1) // BLOCK) * BLOCK
|
|
|
|
|
|
def _is_image_hdu(cards):
|
|
"""Whether this HDU carries the image we care about.
|
|
|
|
SUVI L2 stores the image tile-compressed inside a BINTABLE, flagged by ZIMAGE;
|
|
its real dimensions are in ZNAXIS, not NAXIS. A plain IMAGE extension or a
|
|
primary HDU with data also counts.
|
|
"""
|
|
if cards.get("ZIMAGE") is True:
|
|
return True
|
|
return isinstance(cards.get("NAXIS"), int) and cards["NAXIS"] > 0
|
|
|
|
|
|
def read_header(path, max_bytes=MAX_HEADER_BYTES):
|
|
"""Read the image HDU's header without decompressing pixels.
|
|
|
|
Returns (cards, error). On failure cards is whatever was parsed (possibly
|
|
empty) and error is a short human-readable reason.
|
|
"""
|
|
try:
|
|
size = os.path.getsize(path)
|
|
with open(path, "rb") as handle:
|
|
buf = handle.read(max_bytes)
|
|
except OSError as exc:
|
|
return {}, f"unreadable: {exc.strerror or exc}"
|
|
|
|
if size == 0:
|
|
return {}, "empty file"
|
|
if len(buf) < BLOCK:
|
|
return {}, f"truncated: {size} bytes is less than one {BLOCK}-byte block"
|
|
if not buf.startswith(b"SIMPLE ="):
|
|
return {}, "not a FITS file: missing SIMPLE keyword"
|
|
if size % BLOCK:
|
|
# Worth reporting even though the header may still parse: it means the file
|
|
# was cut short mid-transfer, which is exactly the download failure mode.
|
|
truncation = f"truncated: {size} bytes is not a multiple of {BLOCK}"
|
|
else:
|
|
truncation = None
|
|
|
|
pos = 0
|
|
while True:
|
|
cards, result = _parse_header_blocks(buf, pos)
|
|
if cards is None:
|
|
return {}, result
|
|
pos = result
|
|
if _is_image_hdu(cards):
|
|
return cards, truncation
|
|
skip = _data_bytes(cards)
|
|
if pos + skip + BLOCK > len(buf):
|
|
return cards, truncation or "no image HDU within the scanned region"
|
|
pos += skip
|
|
|
|
|
|
def header_metadata(cards):
|
|
"""Project parsed cards onto the ``header`` table's columns.
|
|
|
|
Booleans become 0/1 so they sort and filter naturally in SQL; anything of an
|
|
unexpected type is dropped rather than stored, so a corrupt card cannot poison
|
|
a numeric column.
|
|
"""
|
|
values = {}
|
|
for field, keyword, sqltype in db.HEADER_COLUMNS:
|
|
raw = cards.get(keyword)
|
|
if raw is None:
|
|
continue
|
|
if isinstance(raw, bool):
|
|
values[field] = int(raw)
|
|
elif sqltype == "TEXT":
|
|
values[field] = str(raw)
|
|
elif sqltype == "INTEGER":
|
|
values[field] = int(raw) if isinstance(raw, (int, float)) else None
|
|
elif isinstance(raw, (int, float)):
|
|
values[field] = float(raw)
|
|
return {k: v for k, v in values.items() if v is not None}
|
|
|
|
|
|
def scan_header(path):
|
|
"""Read and project a file's header in one step. Returns (values, error)."""
|
|
cards, error = read_header(path)
|
|
return header_metadata(cards), error
|
|
|
|
|
|
def read_image(path):
|
|
"""Read the decompressed image array. Returns (data, error).
|
|
|
|
Returns a float32 array with the ZBLANK sentinel converted to NaN, so callers
|
|
do not have to remember that -999999 means "no data" rather than a radiance.
|
|
|
|
Detects damage only insofar as astropy refuses to decode it; a file whose image
|
|
HDU survives intact reads fine even if later bytes are missing. Pair this with
|
|
``verify_datasums`` when integrity matters rather than availability.
|
|
"""
|
|
try:
|
|
with fits.open(path, memmap=False) as hdus:
|
|
data = None
|
|
for hdu in hdus:
|
|
if getattr(hdu, "data", None) is not None and hdu.data.ndim == 2:
|
|
data = hdu.data
|
|
break
|
|
if data is None:
|
|
# The blank/corrupt-HDU case filter_FITS.py caught as IndexError.
|
|
return None, "no 2-D image array in file"
|
|
except Exception as exc: # astropy raises a wide variety on malformed input
|
|
return None, f"{type(exc).__name__}: {exc}"
|
|
|
|
data = np.asarray(data, dtype=np.float32)
|
|
blank = data == ZBLANK
|
|
if blank.any():
|
|
data = data.copy()
|
|
data[blank] = np.nan
|
|
return data, None
|
|
|
|
|
|
def datasum(payload):
|
|
"""FITS-standard 32-bit ones-complement checksum of a data unit."""
|
|
if len(payload) % 4:
|
|
payload = payload + b"\x00" * (4 - len(payload) % 4)
|
|
total = int(np.frombuffer(payload, dtype=">u4").astype(np.uint64).sum())
|
|
while total >> 32: # fold the carries back in
|
|
total = (total & 0xFFFFFFFF) + (total >> 32)
|
|
return total
|
|
|
|
|
|
def verify_datasums(path):
|
|
"""Check every HDU's stored DATASUM against the bytes on disk.
|
|
|
|
Catches silent corruption that a size check misses. Computed over the raw file,
|
|
deliberately not via astropy: astropy re-serialises a tile-compressed HDU before
|
|
summing, so it reports a mismatch for every healthy SUVI file.
|
|
|
|
Returns an error string, or None if all present DATASUMs agree.
|
|
"""
|
|
try:
|
|
with open(path, "rb") as handle:
|
|
buf = handle.read()
|
|
except OSError as exc:
|
|
return f"unreadable: {exc.strerror or exc}"
|
|
|
|
if len(buf) < BLOCK or not buf.startswith(b"SIMPLE ="):
|
|
return "not a FITS file"
|
|
|
|
pos = 0
|
|
checked = 0
|
|
while pos < len(buf):
|
|
cards, result = _parse_header_blocks(buf, pos)
|
|
if cards is None:
|
|
return result
|
|
size = _data_bytes(cards)
|
|
if result + size > len(buf):
|
|
return "truncated: HDU data unit runs past end of file"
|
|
stored = cards.get("DATASUM")
|
|
if stored is not None:
|
|
try:
|
|
expected = int(str(stored).strip())
|
|
except ValueError:
|
|
return f"malformed DATASUM keyword: {stored!r}"
|
|
if datasum(buf[result : result + size]) != expected:
|
|
name = cards.get("EXTNAME") or ("PRIMARY" if pos == 0 else "extension")
|
|
return f"datasum mismatch in {name}"
|
|
checked += 1
|
|
pos = result + size
|
|
|
|
return None if checked else "no DATASUM keywords present"
|