"""ZTF IRSA metadata queries, URL construction, FITS/PSF download with retry."""
from __future__ import annotations
import random
import time
from pathlib import Path
import pandas as pd
import requests
from astropy.io import fits
from ztfquery import buildurl
from ztfquery import query as zquery
from ._constants import DEFAULT_CUTOUT_SIZE_ARCMIN
from .config import ZTForceConfig
from .exceptions import FITSDownloadError, NoImagesFoundError
[docs]
_IRSA_BASE = "https://irsa.ipac.caltech.edu/ibe/data/ztf/products"
[docs]
_DOWNLOAD_TIMEOUT_SEC = 120
[docs]
_BAND_TO_FILTERCODE = {"g": "zg", "r": "zr", "i": "zi"}
[docs]
def build_sci_url(
row: pd.Series,
ra: float,
dec: float,
suffix: str = "sciimg.fits",
cutout_size_arcmin: float = DEFAULT_CUTOUT_SIZE_ARCMIN,
) -> str:
"""Construct the IRSA IBE URL for a science image product.
For FITS files a cutout query is appended so that only a
``cutout_size_arcmin`` × ``cutout_size_arcmin`` region centred on
``(ra, dec)`` is downloaded. PSF sidecar files are returned in full
(they are small text files that describe the whole quadrant).
"""
ff = str(int(row["filefracday"]))
year, month, day, fracday = ff[:4], ff[4:6], ff[6:8], ff[8:]
paddedfield = str(int(row["field"])).zfill(6)
filtercode = row["filtercode"]
paddedccdid = str(int(row["ccdid"])).zfill(2)
qid = str(int(row["qid"]))
base = buildurl.science_path(
year=year,
month=month,
day=day,
fracday=fracday,
paddedfield=paddedfield,
filtercode=filtercode,
paddedccdid=paddedccdid,
qid=qid,
suffix=suffix,
source=_IRSA_BASE,
)
if suffix.endswith(".fits"):
size_arcsec = cutout_size_arcmin * 60.0
return f"{base}?center={ra},{dec}&size={size_arcsec}arcsec"
return base
[docs]
def _validate_fits(path: Path) -> bool:
"""Return True if the FITS file opens without error."""
try:
with fits.open(str(path), checksum=True) as hdul:
_ = hdul[0].data
return True
except Exception:
return False
[docs]
def _download_with_retry(
url: str,
dest: Path,
config: ZTForceConfig,
validate: bool = True,
) -> Path:
"""Download *url* to *dest*, retrying on failure with exponential backoff."""
for attempt in range(config.max_retries):
try:
resp = requests.get(
url,
auth=(config.irsa_user, config.irsa_pass),
stream=True,
timeout=_DOWNLOAD_TIMEOUT_SEC,
)
resp.raise_for_status()
dest.write_bytes(resp.content)
if not validate or _validate_fits(dest):
return dest
dest.unlink(missing_ok=True)
except Exception:
pass
delay = config.retry_base_delay * (2**attempt) + random.uniform(0, config.retry_jitter)
time.sleep(delay)
raise FITSDownloadError(f"Failed to download {url} after {config.max_retries} attempts.")
[docs]
def download_fits(url: str, dest: Path, config: ZTForceConfig) -> Path:
"""Download a FITS cutout to dest."""
dest.parent.mkdir(parents=True, exist_ok=True)
return _download_with_retry(url, dest, config, validate=True)
[docs]
def download_psf_sidecar(url: str, dest: Path, config: ZTForceConfig) -> Path:
"""Download a DAOPhot PSF sidecar (.psf) file to dest."""
dest.parent.mkdir(parents=True, exist_ok=True)
return _download_with_retry(url, dest, config, validate=False)