Spaces:
Sleeping
Sleeping
| """ | |
| AsteroidNET Time Utilities (utils.time_utils) | |
| Handles the TAI vs UTC mismatch between Pan-STARRS and ZTF — the most | |
| critical gotcha when processing real survey data. | |
| Key facts: | |
| - Pan-STARRS MJD-OBS is in TAI (full skycell images MISSING 'TIMESYS' header) | |
| - ZTF OBSJD / OBSMJD / DATE-OBS are in UTC | |
| - TAI is ahead of UTC by 37 seconds (as of 2017, PS1 DR2 era) | |
| - 37 seconds = ~0.5–2 arcseconds of asteroid apparent motion | |
| (enough to put a predicted position outside the detection aperture) | |
| - For asteroid ephemeris queries, JPL Horizons expects UTC | |
| - Mid-exposure time = start_time + 0.5 * EXPTIME | |
| """ | |
| from __future__ import annotations | |
| import logging | |
| from typing import Optional | |
| from astropy.time import Time | |
| import astropy.units as u | |
| logger = logging.getLogger(__name__) | |
| # TAI is ahead of UTC by this many seconds during the PS1 observation period. | |
| # Updated per IERS bulletins — 37 seconds since 2017-01-01. | |
| _PS1_TAI_UTC_OFFSET_S = 37.0 | |
| # Survey-specific time scale lookup | |
| _SURVEY_TIMESCALE = { | |
| "ps1": "tai", # Pan-STARRS — TAI (header often silent about this) | |
| "ztf": "utc", # ZTF — UTC | |
| "catalina": "utc", | |
| "atlas": "utc", | |
| "generic": "utc", | |
| } | |
| def parse_fits_time( | |
| header, | |
| survey: str = "generic", | |
| exptime_key: str = "EXPTIME", | |
| obs_time_key: Optional[str] = None, | |
| return_midexp: bool = True, | |
| ) -> Time: | |
| """ | |
| Parse observation time from a FITS header, correctly handling survey-specific | |
| time scale conventions (most importantly Pan-STARRS TAI vs ZTF UTC). | |
| Parameters | |
| ---------- | |
| header : fits.Header or dict | |
| FITS header containing time keywords. | |
| survey : str | |
| Survey identifier: 'ps1', 'ztf', 'catalina', 'atlas', 'generic'. | |
| Controls which time scale is assumed. | |
| exptime_key : str | |
| Header keyword for exposure time in seconds. | |
| obs_time_key : str, optional | |
| Override the time keyword to read. If None, auto-detected from survey. | |
| return_midexp : bool | |
| If True, add half the exposure time to get mid-exposure time. | |
| Returns | |
| ------- | |
| Time | |
| Observation time in UTC scale, at mid-exposure if return_midexp=True. | |
| """ | |
| survey = survey.lower() | |
| scale = _SURVEY_TIMESCALE.get(survey, "utc") | |
| # Determine which header keyword holds the observation time | |
| if obs_time_key is not None: | |
| time_key = obs_time_key | |
| elif survey == "ps1": | |
| # Pan-STARRS: MJD-OBS is TAI (even if TIMESYS is absent/wrong) | |
| time_key = "MJD-OBS" | |
| elif survey == "ztf": | |
| # ZTF: prefer OBSMJD (MJD), fall back to DATE-OBS (ISO) | |
| time_key = "OBSMJD" if "OBSMJD" in header else "DATE-OBS" | |
| else: | |
| # Generic: try common keywords in order | |
| for k in ("DATE-OBS", "MJD-OBS", "OBSMJD", "OBSJD"): | |
| if k in header: | |
| time_key = k | |
| break | |
| else: | |
| raise KeyError(f"No recognized time keyword in header. Keys: {list(header.keys())[:20]}") | |
| raw_time = header[time_key] | |
| # Parse according to format | |
| if isinstance(raw_time, (int, float)): | |
| # Floating-point MJD or JD | |
| fmt = "jd" if raw_time > 2400000 else "mjd" | |
| t = Time(raw_time, format=fmt, scale=scale) | |
| else: | |
| # ISO string | |
| t = Time(str(raw_time), format="isot", scale=scale) | |
| # Convert to UTC (all downstream code expects UTC) | |
| t_utc = t.utc | |
| # Add half exposure time to get mid-exposure | |
| if return_midexp and exptime_key in header: | |
| exptime_s = float(header[exptime_key]) | |
| t_utc = t_utc + (exptime_s / 2.0) * u.s | |
| logger.debug( | |
| "Mid-exposure time (%s, scale=%s→utc): %s (exptime=%.1fs)", | |
| survey, scale, t_utc.isot, exptime_s | |
| ) | |
| else: | |
| logger.debug("Start-of-exposure time (%s→utc): %s", survey, t_utc.isot) | |
| return t_utc | |
| def detect_survey_from_header(header) -> str: | |
| """ | |
| Attempt to identify the survey from FITS header keywords. | |
| Returns one of: 'ps1', 'ztf', 'catalina', 'atlas', 'generic' | |
| """ | |
| telescop = str(header.get("TELESCOP", "")).lower() | |
| instrume = str(header.get("INSTRUME", "")).lower() | |
| origin = str(header.get("ORIGIN", "")).lower() | |
| if any(x in telescop for x in ("ps1", "panstarrs", "pan-starrs", "haleakala", "p60")): | |
| return "ps1" | |
| if any(x in telescop for x in ("p48", "palomar48", "ztf")): | |
| return "ztf" | |
| if "catalina" in telescop or "css" in instrume: | |
| return "catalina" | |
| if "atlas" in telescop: | |
| return "atlas" | |
| if "ps1" in str(header.get("FILENAME", "")).lower(): | |
| return "ps1" | |
| if "ztf_" in str(header.get("FILENAME", "")).lower(): | |
| return "ztf" | |
| return "generic" | |
| def fix_ps1_header(header) -> None: | |
| """ | |
| In-place fix for Pan-STARRS full skycell FITS headers. | |
| Known PS1 DR2 issues (from STScI documentation): | |
| 1. TIMESYS keyword absent — should be 'TAI' | |
| 2. RADESYS keyword absent — should be 'FK5' | |
| 3. WCS uses obsolete PC001001 naming instead of PC1_1 | |
| The fitscut.cgi service corrects these, but full skycell downloads don't. | |
| """ | |
| if "TIMESYS" not in header: | |
| header["TIMESYS"] = ("TAI", "Time system [added by AsteroidNET]") | |
| logger.debug("Added missing TIMESYS=TAI to PS1 header") | |
| if "RADESYS" not in header: | |
| header["RADESYS"] = ("FK5", "Celestial coordinate system [added by AsteroidNET]") | |
| logger.debug("Added missing RADESYS=FK5 to PS1 header") | |
| # Fix obsolete PC matrix naming (PC001001 → PC1_1 etc.) | |
| for old, new in [("PC001001", "PC1_1"), ("PC001002", "PC1_2"), | |
| ("PC002001", "PC2_1"), ("PC002002", "PC2_2")]: | |
| if old in header and new not in header: | |
| header[new] = header[old] | |
| logger.debug("Renamed WCS keyword %s → %s", old, new) | |