noaa-goes-visualization/suvi/fitsio.py

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"