Spaces:
Runtime error
Runtime error
feat: v0.2 — real FITS support, TAI/UTC fix, SkyBoT, two-pass bg
Browse files- asteroidnet/__init__.py +0 -0
- asteroidnet/candidate_classifier/__init__.py +0 -0
- asteroidnet/candidate_classifier/classifier.py +211 -0
- asteroidnet/catalog_matcher/__init__.py +0 -0
- asteroidnet/catalog_matcher/matcher.py +152 -0
- asteroidnet/config/__init__.py +0 -0
- asteroidnet/config/defaults.yaml +129 -0
- asteroidnet/config/loader.py +51 -0
- asteroidnet/data_access/__init__.py +0 -0
- asteroidnet/data_access/ps1_client.py +287 -0
- asteroidnet/data_access/skybot_client.py +120 -0
- asteroidnet/data_access/ztf_client.py +212 -0
- asteroidnet/fits_ingestor/__init__.py +0 -0
- asteroidnet/fits_ingestor/ingestor.py +206 -0
- asteroidnet/image_preprocessor/__init__.py +0 -0
- asteroidnet/image_preprocessor/preprocessor.py +250 -0
- asteroidnet/orbit_determination/__init__.py +0 -0
- asteroidnet/orbit_determination/gauss_method.py +120 -0
- asteroidnet/pipeline/__init__.py +0 -0
- asteroidnet/pipeline/runner.py +132 -0
- asteroidnet/reporting/__init__.py +0 -0
- asteroidnet/reporting/mpc_formatter.py +102 -0
- asteroidnet/source_extractor/__init__.py +0 -0
- asteroidnet/source_extractor/detector.py +178 -0
- asteroidnet/tracklet_linker/__init__.py +0 -0
- asteroidnet/tracklet_linker/linker.py +251 -0
- asteroidnet/training/__init__.py +0 -0
- asteroidnet/training/dataset_builder.py +349 -0
- asteroidnet/utils/__init__.py +0 -0
- asteroidnet/utils/synthetic.py +153 -0
- asteroidnet/utils/time_utils.py +171 -0
asteroidnet/__init__.py
ADDED
|
File without changes
|
asteroidnet/candidate_classifier/__init__.py
ADDED
|
File without changes
|
asteroidnet/candidate_classifier/classifier.py
ADDED
|
@@ -0,0 +1,211 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AsteroidNET Two-Stage Classifier (RF → CNN)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import logging
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
from asteroidnet.tracklet_linker.linker import Tracklet
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@dataclass
|
| 15 |
+
class Classification:
|
| 16 |
+
tracklet: Tracklet
|
| 17 |
+
rf_score: float
|
| 18 |
+
cnn_score: float
|
| 19 |
+
is_asteroid: bool
|
| 20 |
+
priority: str # 'ROUTINE' | 'HIGH' | 'HAZARDOUS'
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def classify_tracklets(
|
| 24 |
+
tracklets: list[Tracklet],
|
| 25 |
+
data_frames: list[np.ndarray],
|
| 26 |
+
config: Optional[dict] = None,
|
| 27 |
+
) -> list[Classification]:
|
| 28 |
+
"""
|
| 29 |
+
Two-stage classification: Random Forest (fast) → CNN (high precision).
|
| 30 |
+
|
| 31 |
+
RF stage uses kinematic features of the tracklet.
|
| 32 |
+
CNN stage uses 63×63 pixel cutouts from the detection positions.
|
| 33 |
+
"""
|
| 34 |
+
cfg = (config or {}).get("classifier", {})
|
| 35 |
+
rf_thresh = float(cfg.get("rf_threshold", 0.7))
|
| 36 |
+
cnn_thresh = float(cfg.get("cnn_threshold", 0.9))
|
| 37 |
+
|
| 38 |
+
rf_model = _load_rf(cfg)
|
| 39 |
+
cnn_model = _load_cnn(cfg)
|
| 40 |
+
|
| 41 |
+
results: list[Classification] = []
|
| 42 |
+
|
| 43 |
+
for tracklet in tracklets:
|
| 44 |
+
# ── RF stage ─────────────────────────────────────────────────────
|
| 45 |
+
features = _extract_features(tracklet)
|
| 46 |
+
rf_score = _rf_predict(rf_model, features)
|
| 47 |
+
if rf_score < rf_thresh:
|
| 48 |
+
continue
|
| 49 |
+
|
| 50 |
+
# ── Satellite filter ──────────────────────────────────────────────
|
| 51 |
+
if _is_satellite(tracklet):
|
| 52 |
+
logger.debug("Satellite filter rejected tracklet (vel=%.3f, pa=%.1f)",
|
| 53 |
+
tracklet.velocity_arcsec_s, tracklet.position_angle_deg)
|
| 54 |
+
continue
|
| 55 |
+
|
| 56 |
+
# ── CNN stage ─────────────────────────────────────────────────────
|
| 57 |
+
cutouts = _extract_cutouts(tracklet, data_frames)
|
| 58 |
+
cnn_score = _cnn_predict(cnn_model, cutouts)
|
| 59 |
+
|
| 60 |
+
is_asteroid = cnn_score >= cnn_thresh
|
| 61 |
+
if not is_asteroid:
|
| 62 |
+
continue
|
| 63 |
+
|
| 64 |
+
priority = _assign_priority(tracklet, cfg)
|
| 65 |
+
results.append(Classification(
|
| 66 |
+
tracklet=tracklet,
|
| 67 |
+
rf_score=rf_score,
|
| 68 |
+
cnn_score=cnn_score,
|
| 69 |
+
is_asteroid=True,
|
| 70 |
+
priority=priority,
|
| 71 |
+
))
|
| 72 |
+
|
| 73 |
+
results.sort(key=lambda c: c.cnn_score, reverse=True)
|
| 74 |
+
logger.info("Classification: %d/%d tracklets confirmed", len(results), len(tracklets))
|
| 75 |
+
return results
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# ── Feature extraction ────────────────────────────────────────────────────────
|
| 79 |
+
|
| 80 |
+
def _extract_features(t: Tracklet) -> np.ndarray:
|
| 81 |
+
"""12-dimensional kinematic feature vector for RF classifier."""
|
| 82 |
+
snrs = [d["snr"] for d in t.detections]
|
| 83 |
+
mags = [d["mag"] for d in t.detections if d["mag"] < 90]
|
| 84 |
+
return np.array([
|
| 85 |
+
t.velocity_arcsec_s,
|
| 86 |
+
t.velocity_ra_arcsec_s,
|
| 87 |
+
t.velocity_dec_arcsec_s,
|
| 88 |
+
t.position_angle_deg / 360.0,
|
| 89 |
+
t.rms_residual_arcsec,
|
| 90 |
+
t.time_span_min,
|
| 91 |
+
len(t.detections),
|
| 92 |
+
float(np.mean(snrs)) if snrs else 0.0,
|
| 93 |
+
float(np.std(snrs)) if len(snrs) > 1 else 0.0,
|
| 94 |
+
float(np.mean(mags)) if mags else 25.0,
|
| 95 |
+
float(np.ptp(mags)) if len(mags) > 1 else 0.0,
|
| 96 |
+
len(set(t.frame_ids)),
|
| 97 |
+
], dtype=np.float32)
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _extract_cutouts(
|
| 101 |
+
t: Tracklet,
|
| 102 |
+
data_frames: list[np.ndarray],
|
| 103 |
+
size: int = 63,
|
| 104 |
+
) -> Optional[np.ndarray]:
|
| 105 |
+
"""Extract stacked cutouts from detection positions."""
|
| 106 |
+
if not data_frames:
|
| 107 |
+
return None
|
| 108 |
+
half = size // 2
|
| 109 |
+
cutouts = []
|
| 110 |
+
for det in t.detections:
|
| 111 |
+
fid = det.get("frame_id", 0)
|
| 112 |
+
if fid >= len(data_frames):
|
| 113 |
+
continue
|
| 114 |
+
data = data_frames[fid]
|
| 115 |
+
x, y = int(round(det.get("x", 0))), int(round(det.get("y", 0)))
|
| 116 |
+
h, w = data.shape
|
| 117 |
+
if x - half < 0 or y - half < 0 or x + half >= w or y + half >= h:
|
| 118 |
+
continue
|
| 119 |
+
cutout = data[y-half:y+half+1, x-half:x+half+1].copy()
|
| 120 |
+
if cutout.shape == (size, size):
|
| 121 |
+
finite = cutout[np.isfinite(cutout)]
|
| 122 |
+
if len(finite) > 0:
|
| 123 |
+
med = np.median(finite); mad = max(np.median(np.abs(finite-med)), 1e-10)
|
| 124 |
+
cutout = np.clip((cutout - med) / (3*mad), -3, 3)
|
| 125 |
+
cutouts.append(np.nan_to_num(cutout.astype(np.float32)))
|
| 126 |
+
return np.stack(cutouts) if cutouts else None
|
| 127 |
+
|
| 128 |
+
|
| 129 |
+
# ── Model loading ─────────────────────────────────────────────────────────────
|
| 130 |
+
|
| 131 |
+
def _load_rf(cfg: dict):
|
| 132 |
+
"""Load RF model if available, else return None (heuristic fallback)."""
|
| 133 |
+
path = cfg.get("rf_model_path", "models/rf_classifier.pkl")
|
| 134 |
+
if Path(path).exists():
|
| 135 |
+
try:
|
| 136 |
+
import joblib
|
| 137 |
+
return joblib.load(path)
|
| 138 |
+
except Exception as exc:
|
| 139 |
+
logger.warning("Could not load RF model %s: %s", path, exc)
|
| 140 |
+
return None
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _load_cnn(cfg: dict):
|
| 144 |
+
"""Load CNN model if available, else return None (heuristic fallback)."""
|
| 145 |
+
path = cfg.get("cnn_model_path", "models/cnn_classifier.pth")
|
| 146 |
+
if Path(path).exists():
|
| 147 |
+
try:
|
| 148 |
+
import torch
|
| 149 |
+
model = torch.load(path, map_location="cpu")
|
| 150 |
+
model.eval()
|
| 151 |
+
return model
|
| 152 |
+
except Exception as exc:
|
| 153 |
+
logger.warning("Could not load CNN model %s: %s", path, exc)
|
| 154 |
+
return None
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
def _rf_predict(model, features: np.ndarray) -> float:
|
| 158 |
+
"""RF prediction — heuristic if model not trained yet."""
|
| 159 |
+
if model is not None:
|
| 160 |
+
try:
|
| 161 |
+
p = model.predict_proba(features.reshape(1, -1))[0, 1]
|
| 162 |
+
return float(p)
|
| 163 |
+
except Exception:
|
| 164 |
+
pass
|
| 165 |
+
# Heuristic: based on velocity, SNR, residual
|
| 166 |
+
vel = features[0]
|
| 167 |
+
snr = features[7]
|
| 168 |
+
rms = features[4]
|
| 169 |
+
score = 0.3
|
| 170 |
+
if 0.01 <= vel <= 5.0: score += 0.3
|
| 171 |
+
if snr >= 5.0: score += 0.2
|
| 172 |
+
if rms <= 0.8: score += 0.2
|
| 173 |
+
return min(score, 0.99)
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
def _cnn_predict(model, cutouts: Optional[np.ndarray]) -> float:
|
| 177 |
+
"""CNN prediction — heuristic if model not trained yet."""
|
| 178 |
+
if model is not None and cutouts is not None:
|
| 179 |
+
try:
|
| 180 |
+
import torch
|
| 181 |
+
x = torch.from_numpy(cutouts).unsqueeze(0).float()
|
| 182 |
+
with torch.no_grad():
|
| 183 |
+
out = model(x)
|
| 184 |
+
return float(torch.sigmoid(out).mean().item())
|
| 185 |
+
except Exception:
|
| 186 |
+
pass
|
| 187 |
+
# Heuristic: check if any cutout has a point source at center
|
| 188 |
+
if cutouts is not None and len(cutouts) > 0:
|
| 189 |
+
peaks = [float(np.max(c[28:35, 28:35])) if c.shape == (63, 63)
|
| 190 |
+
else float(np.nanmax(c)) for c in cutouts]
|
| 191 |
+
return min(0.95, max(0.0, float(np.mean(peaks)) / 3.0 + 0.5))
|
| 192 |
+
return 0.5
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _is_satellite(t: Tracklet) -> bool:
|
| 196 |
+
"""Simple satellite/aircraft filter."""
|
| 197 |
+
if t.velocity_arcsec_s > 8.0:
|
| 198 |
+
return True
|
| 199 |
+
if t.rms_residual_arcsec < 0.01 and t.velocity_arcsec_s > 5.0:
|
| 200 |
+
return True
|
| 201 |
+
return False
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _assign_priority(t: Tracklet, cfg: dict) -> str:
|
| 205 |
+
high_v = float(cfg.get("high_velocity_threshold", 1.0))
|
| 206 |
+
haz_v = float(cfg.get("hazardous_velocity_threshold", 3.0))
|
| 207 |
+
if t.velocity_arcsec_s >= haz_v:
|
| 208 |
+
return "HAZARDOUS"
|
| 209 |
+
if t.velocity_arcsec_s >= high_v:
|
| 210 |
+
return "HIGH"
|
| 211 |
+
return "ROUTINE"
|
asteroidnet/catalog_matcher/__init__.py
ADDED
|
File without changes
|
asteroidnet/catalog_matcher/matcher.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET Catalog Matcher (catalog_matcher.matcher)
|
| 3 |
+
|
| 4 |
+
Removes known stars (Gaia DR3) and known asteroids (SkyBoT/JPL Horizons)
|
| 5 |
+
from source catalogs, leaving only candidate moving objects.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from astropy.coordinates import SkyCoord, match_coordinates_sky
|
| 13 |
+
from astropy.table import Table
|
| 14 |
+
from astropy.time import Time
|
| 15 |
+
import astropy.units as u
|
| 16 |
+
|
| 17 |
+
logger = logging.getLogger(__name__)
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def remove_known_sources(
|
| 21 |
+
catalog: Table,
|
| 22 |
+
obs_time: Time,
|
| 23 |
+
config: Optional[dict] = None,
|
| 24 |
+
) -> Table:
|
| 25 |
+
"""
|
| 26 |
+
Remove known stars and known asteroids from a source catalog.
|
| 27 |
+
|
| 28 |
+
Steps:
|
| 29 |
+
1. Cross-match against Gaia DR3 with proper motion correction
|
| 30 |
+
2. Query SkyBoT for known solar system objects at this epoch
|
| 31 |
+
3. Return filtered catalog of candidate movers
|
| 32 |
+
|
| 33 |
+
Parameters
|
| 34 |
+
----------
|
| 35 |
+
catalog : Table
|
| 36 |
+
Source catalog from extract_sources().
|
| 37 |
+
obs_time : Time
|
| 38 |
+
UTC mid-exposure time (used for PM correction and SkyBoT query).
|
| 39 |
+
config : dict, optional
|
| 40 |
+
Pipeline configuration.
|
| 41 |
+
|
| 42 |
+
Returns
|
| 43 |
+
-------
|
| 44 |
+
Table
|
| 45 |
+
Catalog with known sources removed; added column 'candidate_type'.
|
| 46 |
+
"""
|
| 47 |
+
if len(catalog) == 0:
|
| 48 |
+
return catalog
|
| 49 |
+
|
| 50 |
+
cfg = (config or {}).get("matching", {})
|
| 51 |
+
star_r = float(cfg.get("star_radius_arcsec", 2.0)) * u.arcsec
|
| 52 |
+
ast_r = float(cfg.get("asteroid_radius_arcsec", 5.0)) * u.arcsec
|
| 53 |
+
|
| 54 |
+
coords = SkyCoord(ra=catalog["ra_deg"], dec=catalog["dec_deg"], unit=u.deg, frame="icrs")
|
| 55 |
+
|
| 56 |
+
# ── Step 1: Remove Gaia DR3 stars ───────────────────────────────────────
|
| 57 |
+
star_mask = _gaia_match_mask(coords, obs_time, star_r, cfg)
|
| 58 |
+
|
| 59 |
+
# ── Step 2: Remove known asteroids via SkyBoT ───────────────────────────
|
| 60 |
+
sso_mask = _skybot_match_mask(coords, obs_time, ast_r, config)
|
| 61 |
+
|
| 62 |
+
keep = ~(star_mask | sso_mask)
|
| 63 |
+
filtered = catalog[keep].copy()
|
| 64 |
+
filtered["candidate_type"] = "UNKNOWN_MOVER"
|
| 65 |
+
|
| 66 |
+
logger.info(
|
| 67 |
+
"Catalog: %d in → %d stars removed, %d SSOs removed → %d candidates",
|
| 68 |
+
len(catalog),
|
| 69 |
+
int(star_mask.sum()),
|
| 70 |
+
int(sso_mask.sum()),
|
| 71 |
+
len(filtered),
|
| 72 |
+
)
|
| 73 |
+
return filtered
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def _gaia_match_mask(
|
| 77 |
+
coords: SkyCoord,
|
| 78 |
+
obs_time: Time,
|
| 79 |
+
radius: u.Quantity,
|
| 80 |
+
cfg: dict,
|
| 81 |
+
) -> np.ndarray:
|
| 82 |
+
"""Return boolean mask: True = matched to a Gaia star."""
|
| 83 |
+
try:
|
| 84 |
+
from astroquery.gaia import Gaia
|
| 85 |
+
# Build bounding box for query
|
| 86 |
+
ra_ctr = float(np.mean(coords.ra.deg))
|
| 87 |
+
dec_ctr = float(np.mean(coords.dec.deg))
|
| 88 |
+
ra_rng = float(np.ptp(coords.ra.deg)) / 2 + 0.1
|
| 89 |
+
dec_rng = float(np.ptp(coords.dec.deg)) / 2 + 0.1
|
| 90 |
+
|
| 91 |
+
query = f"""
|
| 92 |
+
SELECT source_id, ra, dec, pmra, pmdec
|
| 93 |
+
FROM gaiadr3.gaia_source
|
| 94 |
+
WHERE ra BETWEEN {ra_ctr - ra_rng} AND {ra_ctr + ra_rng}
|
| 95 |
+
AND dec BETWEEN {dec_ctr - dec_rng} AND {dec_ctr + dec_rng}
|
| 96 |
+
"""
|
| 97 |
+
job = Gaia.launch_job(query)
|
| 98 |
+
gaia = job.get_results()
|
| 99 |
+
|
| 100 |
+
if len(gaia) == 0:
|
| 101 |
+
return np.zeros(len(coords), dtype=bool)
|
| 102 |
+
|
| 103 |
+
# Apply proper motion correction to Gaia epoch
|
| 104 |
+
dt_yr = float((obs_time - Time(2016.0, format="jyear")).to("yr").value)
|
| 105 |
+
ra_corrected = gaia["ra"] + np.where(np.ma.is_masked(gaia["pmra"]), 0, gaia["pmra"]) / 3.6e6 * dt_yr
|
| 106 |
+
dec_corrected = gaia["dec"] + np.where(np.ma.is_masked(gaia["pmdec"]), 0, gaia["pmdec"]) / 3.6e6 * dt_yr
|
| 107 |
+
|
| 108 |
+
gaia_coords = SkyCoord(ra=ra_corrected, dec=dec_corrected, unit=u.deg, frame="icrs")
|
| 109 |
+
_, sep2d, _ = match_coordinates_sky(coords, gaia_coords)
|
| 110 |
+
return sep2d < radius
|
| 111 |
+
|
| 112 |
+
except Exception as exc:
|
| 113 |
+
logger.warning("Gaia match failed: %s — no star removal", exc)
|
| 114 |
+
return np.zeros(len(coords), dtype=bool)
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def _skybot_match_mask(
|
| 118 |
+
coords: SkyCoord,
|
| 119 |
+
obs_time: Time,
|
| 120 |
+
radius: u.Quantity,
|
| 121 |
+
config: Optional[dict],
|
| 122 |
+
) -> np.ndarray:
|
| 123 |
+
"""Return boolean mask: True = matched to a known SSO via SkyBoT."""
|
| 124 |
+
use_skybot = (config or {}).get("matching", {}).get("use_skybot", True)
|
| 125 |
+
if not use_skybot:
|
| 126 |
+
return np.zeros(len(coords), dtype=bool)
|
| 127 |
+
|
| 128 |
+
try:
|
| 129 |
+
from asteroidnet.data_access.skybot_client import (
|
| 130 |
+
query_skybot, skybot_table_to_skycoord
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
center = SkyCoord(
|
| 134 |
+
ra=float(np.mean(coords.ra.deg)),
|
| 135 |
+
dec=float(np.mean(coords.dec.deg)),
|
| 136 |
+
unit=u.deg, frame="icrs",
|
| 137 |
+
)
|
| 138 |
+
field_r = float(np.max(coords.separation(center).arcmin)) * u.arcmin + 5 * u.arcmin
|
| 139 |
+
|
| 140 |
+
observer = (config or {}).get("matching", {}).get("skybot_location", "500")
|
| 141 |
+
table = query_skybot(center, field_r, obs_time, observer=observer, config=config)
|
| 142 |
+
sso_coords = skybot_table_to_skycoord(table)
|
| 143 |
+
|
| 144 |
+
if sso_coords is None:
|
| 145 |
+
return np.zeros(len(coords), dtype=bool)
|
| 146 |
+
|
| 147 |
+
_, sep2d, _ = match_coordinates_sky(coords, sso_coords)
|
| 148 |
+
return sep2d < radius
|
| 149 |
+
|
| 150 |
+
except Exception as exc:
|
| 151 |
+
logger.warning("SkyBoT match failed: %s — no known-asteroid removal", exc)
|
| 152 |
+
return np.zeros(len(coords), dtype=bool)
|
asteroidnet/config/__init__.py
ADDED
|
File without changes
|
asteroidnet/config/defaults.yaml
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AsteroidNET v0.2 Configuration
|
| 2 |
+
# All parameters overridable via ASTEROIDNET_SECTION__KEY env vars
|
| 3 |
+
|
| 4 |
+
detection:
|
| 5 |
+
threshold_sigma: 3.0
|
| 6 |
+
bright_threshold_sigma: 5.0
|
| 7 |
+
fwhm_range: [2.0, 8.0]
|
| 8 |
+
|
| 9 |
+
preprocessing:
|
| 10 |
+
background_box_size: 64
|
| 11 |
+
background_filter_size: 3
|
| 12 |
+
sigma_clip_sigma: 3.0
|
| 13 |
+
sigma_clip_maxiters: 10
|
| 14 |
+
source_mask_snr: 2.0 # SNR threshold for first-pass source masking
|
| 15 |
+
cosmic_ray_sigclip: 4.5
|
| 16 |
+
cosmic_ray_objlim: 5.0
|
| 17 |
+
|
| 18 |
+
matching:
|
| 19 |
+
star_radius_arcsec: 2.0
|
| 20 |
+
star_2mass_radius_arcsec: 3.0
|
| 21 |
+
asteroid_radius_arcsec: 5.0
|
| 22 |
+
proper_motion_epoch_threshold: 1.0 # years
|
| 23 |
+
gaia_catalog: "gaiadr3"
|
| 24 |
+
skybot_location: "500" # Default observer location for SkyBoT (geocenter)
|
| 25 |
+
use_skybot: true # Use SkyBoT for known-object removal
|
| 26 |
+
use_local_cache: false
|
| 27 |
+
|
| 28 |
+
tracking:
|
| 29 |
+
min_detections: 3
|
| 30 |
+
min_time_span_minutes: 30.0
|
| 31 |
+
velocity_range_arcsec_s: [0.01, 10.0]
|
| 32 |
+
max_acceleration_arcsec_s2: 0.001
|
| 33 |
+
position_tolerance_arcsec: 3.0
|
| 34 |
+
max_motion_residual_arcsec: 1.0
|
| 35 |
+
|
| 36 |
+
classifier:
|
| 37 |
+
rf_threshold: 0.7
|
| 38 |
+
cnn_threshold: 0.9
|
| 39 |
+
rf_model_path: "models/rf_classifier.pkl"
|
| 40 |
+
cnn_model_path: "models/cnn_classifier.pth"
|
| 41 |
+
cnn_cutout_size: 64
|
| 42 |
+
use_gpu: true
|
| 43 |
+
satellite_rf_threshold: 0.8
|
| 44 |
+
|
| 45 |
+
orbit:
|
| 46 |
+
method: "gauss"
|
| 47 |
+
min_arc_minutes: 30.0
|
| 48 |
+
observatory_location: null
|
| 49 |
+
|
| 50 |
+
reporting:
|
| 51 |
+
observatory_code: null
|
| 52 |
+
observer_name: null
|
| 53 |
+
output_dir: "output"
|
| 54 |
+
alert_channels:
|
| 55 |
+
email: {enabled: false, smtp_host: "localhost", smtp_port: 587, recipients: []}
|
| 56 |
+
slack: {enabled: false, webhook_url: null}
|
| 57 |
+
voevent: {enabled: false, broker_url: null}
|
| 58 |
+
atel: {enabled: false}
|
| 59 |
+
priority_thresholds:
|
| 60 |
+
high_velocity_arcsec_s: 1.0
|
| 61 |
+
hazardous_delta_v_km_s: 10.0
|
| 62 |
+
|
| 63 |
+
# ── NEW: Data access for real surveys ────────────────────────────────────────
|
| 64 |
+
data_access:
|
| 65 |
+
ps1:
|
| 66 |
+
# Pan-STARRS Image Cutout Service (MAST/STScI)
|
| 67 |
+
filenames_url: "https://ps1images.stsci.edu/cgi-bin/ps1filenames.py"
|
| 68 |
+
cutout_url: "https://ps1images.stsci.edu/cgi-bin/fitscut.cgi"
|
| 69 |
+
default_size_pixels: 1200 # cutout size in pixels (1200px = 5' at 0.25"/px)
|
| 70 |
+
default_filters: "r" # r-band warps most similar to IASC packages
|
| 71 |
+
image_type: "warp" # "warp" = single epoch (required for moving objects)
|
| 72 |
+
timeout_s: 60
|
| 73 |
+
# CRITICAL: PS1 full skycell images missing TIMESYS=TAI and RADESYS=FK5
|
| 74 |
+
# The cutout service corrects headers — always use the cutout API
|
| 75 |
+
tai_utc_offset_s: 37.0 # leap seconds as of 2017 (PS1 DR2 era)
|
| 76 |
+
|
| 77 |
+
ztf:
|
| 78 |
+
# ZTF IRSA Image Browser Engine
|
| 79 |
+
ibe_search_url: "https://irsa.ipac.caltech.edu/ibe/search/ztf/products/sci"
|
| 80 |
+
ibe_data_url: "https://irsa.ipac.caltech.edu/ibe/data/ztf/products/sci"
|
| 81 |
+
diff_url: "https://irsa.ipac.caltech.edu/ibe/data/ztf/products/sci"
|
| 82 |
+
cutout_size_arcsec: 300.0 # field size for ZTF cutout requests
|
| 83 |
+
timeout_s: 90
|
| 84 |
+
# ZTF times are UTC; use OBSJD or OBSMJD + 0.5*EXPTIME for mid-exposure
|
| 85 |
+
|
| 86 |
+
mpc:
|
| 87 |
+
# MPC bulk data
|
| 88 |
+
mpcorb_url: "https://www.minorplanetcenter.net/iau/MPCORB/MPCORB.DAT.gz"
|
| 89 |
+
obs_url: "https://www.minorplanetcenter.net/iau/ECS/MPCAT-OBS/MPCAT-OBS.TXT.gz"
|
| 90 |
+
|
| 91 |
+
skybot:
|
| 92 |
+
# IMCCE SkyBoT cone-search service
|
| 93 |
+
url: "https://ssp.imcce.fr/webservices/skybot/api/conesearch.php"
|
| 94 |
+
default_field_radius_arcmin: 30.0
|
| 95 |
+
default_observer: "500" # geocenter; use MPC obs code for topocentric
|
| 96 |
+
|
| 97 |
+
horizons:
|
| 98 |
+
# JPL Horizons via astroquery
|
| 99 |
+
default_quantities: "1,9,20,23,24" # RA/Dec, V mag, rates
|
| 100 |
+
|
| 101 |
+
# ── Training data builder ─────────────────────────────────────────────────────
|
| 102 |
+
training:
|
| 103 |
+
output_dir: "training_data"
|
| 104 |
+
cutout_size_pixels: 63 # ALeRCE-compatible stamp size
|
| 105 |
+
negative_per_positive: 3 # negative examples per confirmed object
|
| 106 |
+
min_snr: 3.0
|
| 107 |
+
surveys: ["ps1", "ztf"] # which archives to mine
|
| 108 |
+
date_range: ["2023-01-01", "2024-12-31"]
|
| 109 |
+
|
| 110 |
+
database:
|
| 111 |
+
url: "postgresql://asteroidnet:asteroidnet@localhost:5432/asteroidnet"
|
| 112 |
+
pool_size: 10
|
| 113 |
+
echo: false
|
| 114 |
+
|
| 115 |
+
celery:
|
| 116 |
+
broker_url: "redis://localhost:6379/0"
|
| 117 |
+
result_backend: "redis://localhost:6379/1"
|
| 118 |
+
task_serializer: "json"
|
| 119 |
+
worker_concurrency: 4
|
| 120 |
+
|
| 121 |
+
api:
|
| 122 |
+
host: "0.0.0.0"
|
| 123 |
+
port: 8000
|
| 124 |
+
reload: false
|
| 125 |
+
|
| 126 |
+
logging:
|
| 127 |
+
level: "INFO"
|
| 128 |
+
format: "%(asctime)s [%(levelname)s] %(name)s: %(message)s"
|
| 129 |
+
file: null
|
asteroidnet/config/loader.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AsteroidNET configuration loader with env-var overrides."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import hashlib, os, copy
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Any
|
| 6 |
+
import yaml
|
| 7 |
+
|
| 8 |
+
_DEFAULTS = Path(__file__).parent / "defaults.yaml"
|
| 9 |
+
_ENV_PREFIX = "ASTEROIDNET_"
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def load_config(path: str | Path | None = None) -> dict:
|
| 13 |
+
with open(_DEFAULTS) as f:
|
| 14 |
+
cfg = yaml.safe_load(f)
|
| 15 |
+
if path:
|
| 16 |
+
with open(path) as f:
|
| 17 |
+
user = yaml.safe_load(f) or {}
|
| 18 |
+
cfg = _deep_merge(cfg, user)
|
| 19 |
+
cfg = _apply_env(cfg)
|
| 20 |
+
cfg["_hash"] = hashlib.sha256(
|
| 21 |
+
yaml.dump(cfg, sort_keys=True).encode()
|
| 22 |
+
).hexdigest()[:12]
|
| 23 |
+
return cfg
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def _deep_merge(base: dict, override: dict) -> dict:
|
| 27 |
+
out = copy.deepcopy(base)
|
| 28 |
+
for k, v in override.items():
|
| 29 |
+
if k in out and isinstance(out[k], dict) and isinstance(v, dict):
|
| 30 |
+
out[k] = _deep_merge(out[k], v)
|
| 31 |
+
else:
|
| 32 |
+
out[k] = v
|
| 33 |
+
return out
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _apply_env(cfg: dict, _prefix: str = _ENV_PREFIX) -> dict:
|
| 37 |
+
"""Override config from ASTEROIDNET_SECTION__KEY=value env vars."""
|
| 38 |
+
out = copy.deepcopy(cfg)
|
| 39 |
+
for key, val in os.environ.items():
|
| 40 |
+
if not key.startswith(_prefix):
|
| 41 |
+
continue
|
| 42 |
+
parts = key[len(_prefix):].lower().split("__")
|
| 43 |
+
node = out
|
| 44 |
+
for part in parts[:-1]:
|
| 45 |
+
node = node.setdefault(part, {})
|
| 46 |
+
leaf = parts[-1]
|
| 47 |
+
try:
|
| 48 |
+
node[leaf] = yaml.safe_load(val)
|
| 49 |
+
except Exception:
|
| 50 |
+
node[leaf] = val
|
| 51 |
+
return out
|
asteroidnet/data_access/__init__.py
ADDED
|
File without changes
|
asteroidnet/data_access/ps1_client.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET Pan-STARRS Data Access (data_access.ps1_client)
|
| 3 |
+
|
| 4 |
+
Downloads single-epoch warp FITS images from Pan-STARRS DR2 via the
|
| 5 |
+
PS1 Image Cutout Service at ps1images.stsci.edu.
|
| 6 |
+
|
| 7 |
+
Key design decisions (from research):
|
| 8 |
+
- Always use WARP images (not stacks): stacks use asinh pseudo-luptitude
|
| 9 |
+
scaling — photometrically non-linear, unsuitable for source extraction
|
| 10 |
+
- Always use the cutout API: full skycell images are missing TIMESYS=TAI
|
| 11 |
+
and RADESYS=FK5; the fitscut.cgi service corrects these headers
|
| 12 |
+
- Always byte-swap after download: FITS big-endian arrays silently break
|
| 13 |
+
bottleneck-accelerated Background2D, producing wrong background estimates
|
| 14 |
+
|
| 15 |
+
Reference:
|
| 16 |
+
https://outerspace.stsci.edu/display/PANSTARRS/PS1+Image+Cutout+Service
|
| 17 |
+
https://outerspace.stsci.edu/display/PANSTARRS/PS1+Warp+images
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import io
|
| 23 |
+
import logging
|
| 24 |
+
import time
|
| 25 |
+
from dataclasses import dataclass, field
|
| 26 |
+
from pathlib import Path
|
| 27 |
+
from typing import Optional
|
| 28 |
+
|
| 29 |
+
import numpy as np
|
| 30 |
+
import requests
|
| 31 |
+
from astropy.io import fits
|
| 32 |
+
from astropy.table import Table
|
| 33 |
+
from astropy.time import Time
|
| 34 |
+
|
| 35 |
+
from asteroidnet.utils.time_utils import fix_ps1_header, parse_fits_time
|
| 36 |
+
|
| 37 |
+
logger = logging.getLogger(__name__)
|
| 38 |
+
|
| 39 |
+
# Default PS1 cutout service endpoints
|
| 40 |
+
_FILENAMES_URL = "https://ps1images.stsci.edu/cgi-bin/ps1filenames.py"
|
| 41 |
+
_CUTOUT_URL = "https://ps1images.stsci.edu/cgi-bin/fitscut.cgi"
|
| 42 |
+
|
| 43 |
+
# PS1 pixel scale: exactly 0.25 arcsec/pixel
|
| 44 |
+
PS1_PIXEL_SCALE_ARCSEC = 0.25
|
| 45 |
+
|
| 46 |
+
# IASC campaign packages: 4 frames per field, ~30 min cadence
|
| 47 |
+
IASC_N_FRAMES = 4
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@dataclass
|
| 51 |
+
class PS1WarpFrame:
|
| 52 |
+
"""A single PS1 warp FITS frame, ready for the AsteroidNET pipeline."""
|
| 53 |
+
filename: str
|
| 54 |
+
data: np.ndarray # float32, native byte-order, background NOT subtracted
|
| 55 |
+
header: fits.Header
|
| 56 |
+
obs_time: Time # UTC, mid-exposure
|
| 57 |
+
exptime_s: float
|
| 58 |
+
filter_band: str
|
| 59 |
+
ra_center: float
|
| 60 |
+
dec_center: float
|
| 61 |
+
size_pixels: int
|
| 62 |
+
projection_id: str = "" # PS1 skycell / projection cell ID
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
def get_ps1_warp_list(
|
| 66 |
+
ra: float,
|
| 67 |
+
dec: float,
|
| 68 |
+
filter_band: str = "r",
|
| 69 |
+
config: Optional[dict] = None,
|
| 70 |
+
) -> Table:
|
| 71 |
+
"""
|
| 72 |
+
Query the PS1 filename service for all warp images at a sky position.
|
| 73 |
+
|
| 74 |
+
Pan-STARRS has approximately 12 warps per filter per position on average.
|
| 75 |
+
Each warp corresponds to one single-epoch exposure, making them suitable
|
| 76 |
+
for moving object detection (unlike stacked images).
|
| 77 |
+
|
| 78 |
+
Parameters
|
| 79 |
+
----------
|
| 80 |
+
ra, dec : float
|
| 81 |
+
Sky position in decimal degrees (ICRS J2000).
|
| 82 |
+
filter_band : str
|
| 83 |
+
PS1 filter(s): 'g', 'r', 'i', 'z', 'y', or combination like 'gr'.
|
| 84 |
+
config : dict, optional
|
| 85 |
+
Pipeline configuration dict.
|
| 86 |
+
|
| 87 |
+
Returns
|
| 88 |
+
-------
|
| 89 |
+
Table
|
| 90 |
+
Astropy Table with columns: filename, type, projcell, subcell, filter, ...
|
| 91 |
+
"""
|
| 92 |
+
url = (config or {}).get("data_access", {}).get("ps1", {}).get(
|
| 93 |
+
"filenames_url", _FILENAMES_URL)
|
| 94 |
+
timeout = (config or {}).get("data_access", {}).get("ps1", {}).get("timeout_s", 60)
|
| 95 |
+
|
| 96 |
+
params = {
|
| 97 |
+
"ra": f"{ra:.6f}",
|
| 98 |
+
"dec": f"{dec:.6f}",
|
| 99 |
+
"filters": filter_band,
|
| 100 |
+
"type": "warp",
|
| 101 |
+
}
|
| 102 |
+
|
| 103 |
+
logger.info("Querying PS1 warp list: RA=%.4f Dec=%.4f filter=%s", ra, dec, filter_band)
|
| 104 |
+
resp = requests.get(url, params=params, timeout=timeout)
|
| 105 |
+
resp.raise_for_status()
|
| 106 |
+
|
| 107 |
+
if not resp.text.strip():
|
| 108 |
+
logger.warning("No PS1 warps found at RA=%.4f Dec=%.4f", ra, dec)
|
| 109 |
+
return Table()
|
| 110 |
+
|
| 111 |
+
table = Table.read(io.StringIO(resp.text), format="ascii")
|
| 112 |
+
logger.info("Found %d PS1 warp images", len(table))
|
| 113 |
+
return table
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
def download_ps1_cutout(
|
| 117 |
+
ra: float,
|
| 118 |
+
dec: float,
|
| 119 |
+
filename: str,
|
| 120 |
+
size_pixels: int = 1200,
|
| 121 |
+
config: Optional[dict] = None,
|
| 122 |
+
) -> Optional[PS1WarpFrame]:
|
| 123 |
+
"""
|
| 124 |
+
Download a FITS cutout from a specific PS1 warp image.
|
| 125 |
+
|
| 126 |
+
Parameters
|
| 127 |
+
----------
|
| 128 |
+
ra, dec : float
|
| 129 |
+
Center of the cutout (decimal degrees).
|
| 130 |
+
filename : str
|
| 131 |
+
PS1 filename from get_ps1_warp_list() — e.g. 'rings.v3.skycell.1234.023.wrp.r.fits'
|
| 132 |
+
size_pixels : int
|
| 133 |
+
Cutout size in pixels. Default 1200 = 5 arcminutes at 0.25"/px.
|
| 134 |
+
IASC campaign packages cover ~20' fields; use 4800 for full coverage.
|
| 135 |
+
config : dict, optional
|
| 136 |
+
Pipeline configuration dict.
|
| 137 |
+
|
| 138 |
+
Returns
|
| 139 |
+
-------
|
| 140 |
+
PS1WarpFrame or None
|
| 141 |
+
Processed frame with corrected headers and native byte-order data.
|
| 142 |
+
"""
|
| 143 |
+
url = (config or {}).get("data_access", {}).get("ps1", {}).get(
|
| 144 |
+
"cutout_url", _CUTOUT_URL)
|
| 145 |
+
timeout = (config or {}).get("data_access", {}).get("ps1", {}).get("timeout_s", 60)
|
| 146 |
+
|
| 147 |
+
params = {
|
| 148 |
+
"ra": f"{ra:.6f}",
|
| 149 |
+
"dec": f"{dec:.6f}",
|
| 150 |
+
"size": str(size_pixels),
|
| 151 |
+
"format": "fits",
|
| 152 |
+
"red": filename,
|
| 153 |
+
}
|
| 154 |
+
|
| 155 |
+
logger.info("Downloading PS1 cutout: %s (size=%dpx)", filename, size_pixels)
|
| 156 |
+
t0 = time.perf_counter()
|
| 157 |
+
|
| 158 |
+
try:
|
| 159 |
+
resp = requests.get(url, params=params, timeout=timeout, stream=True)
|
| 160 |
+
resp.raise_for_status()
|
| 161 |
+
fits_bytes = resp.content
|
| 162 |
+
except requests.exceptions.RequestException as exc:
|
| 163 |
+
logger.error("PS1 download failed for %s: %s", filename, exc)
|
| 164 |
+
return None
|
| 165 |
+
|
| 166 |
+
elapsed = time.perf_counter() - t0
|
| 167 |
+
logger.debug("Downloaded %.1f KB in %.2fs", len(fits_bytes) / 1024, elapsed)
|
| 168 |
+
|
| 169 |
+
try:
|
| 170 |
+
with fits.open(io.BytesIO(fits_bytes)) as hdul:
|
| 171 |
+
hdu = hdul[0]
|
| 172 |
+
header = hdu.header.copy()
|
| 173 |
+
raw_data = hdu.data
|
| 174 |
+
|
| 175 |
+
if raw_data is None:
|
| 176 |
+
logger.warning("PS1 cutout %s has no image data", filename)
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
+
# ── CRITICAL FIX 1: byte-swap to native endianness ──────────────────
|
| 180 |
+
# FITS data is big-endian; bottleneck (used by Background2D) only
|
| 181 |
+
# accelerates native-endian arrays — non-native silently gives wrong results
|
| 182 |
+
data = np.array(raw_data, dtype=np.float32) # float32, native byte-order
|
| 183 |
+
|
| 184 |
+
# ── CRITICAL FIX 2: add missing PS1 header keywords ────────────────
|
| 185 |
+
# The cutout service usually adds these, but apply defensively
|
| 186 |
+
fix_ps1_header(header)
|
| 187 |
+
|
| 188 |
+
# ── CRITICAL FIX 3: parse time with correct TAI scale ───────────────
|
| 189 |
+
obs_time = parse_fits_time(header, survey="ps1")
|
| 190 |
+
|
| 191 |
+
exptime_s = float(header.get("EXPTIME", 30.0))
|
| 192 |
+
filter_band = str(header.get("FILTER", "r")).strip()
|
| 193 |
+
|
| 194 |
+
frame = PS1WarpFrame(
|
| 195 |
+
filename=filename,
|
| 196 |
+
data=data,
|
| 197 |
+
header=header,
|
| 198 |
+
obs_time=obs_time,
|
| 199 |
+
exptime_s=exptime_s,
|
| 200 |
+
filter_band=filter_band,
|
| 201 |
+
ra_center=ra,
|
| 202 |
+
dec_center=dec,
|
| 203 |
+
size_pixels=size_pixels,
|
| 204 |
+
)
|
| 205 |
+
logger.info(
|
| 206 |
+
"PS1 frame OK: %s | %s UTC | filter=%s | shape=%s",
|
| 207 |
+
filename, obs_time.isot, filter_band, data.shape
|
| 208 |
+
)
|
| 209 |
+
return frame
|
| 210 |
+
|
| 211 |
+
except Exception as exc:
|
| 212 |
+
logger.error("Failed to parse PS1 FITS from %s: %s", filename, exc)
|
| 213 |
+
return None
|
| 214 |
+
|
| 215 |
+
|
| 216 |
+
def get_iasc_style_sequence(
|
| 217 |
+
ra: float,
|
| 218 |
+
dec: float,
|
| 219 |
+
filter_band: str = "r",
|
| 220 |
+
n_frames: int = IASC_N_FRAMES,
|
| 221 |
+
size_pixels: int = 1200,
|
| 222 |
+
config: Optional[dict] = None,
|
| 223 |
+
) -> list[PS1WarpFrame]:
|
| 224 |
+
"""
|
| 225 |
+
Download a sequence of PS1 warp images mimicking an IASC campaign package.
|
| 226 |
+
|
| 227 |
+
Selects N frames from the available warps, preferring those with the
|
| 228 |
+
widest time coverage to maximise asteroid motion detectability.
|
| 229 |
+
|
| 230 |
+
Parameters
|
| 231 |
+
----------
|
| 232 |
+
ra, dec : float
|
| 233 |
+
Field center in decimal degrees.
|
| 234 |
+
filter_band : str
|
| 235 |
+
PS1 filter (default 'r', most similar to IASC packages).
|
| 236 |
+
n_frames : int
|
| 237 |
+
Number of frames to return (IASC uses 4).
|
| 238 |
+
size_pixels : int
|
| 239 |
+
Cutout size in pixels.
|
| 240 |
+
config : dict, optional
|
| 241 |
+
Pipeline configuration dict.
|
| 242 |
+
|
| 243 |
+
Returns
|
| 244 |
+
-------
|
| 245 |
+
list[PS1WarpFrame]
|
| 246 |
+
Sequence of frames sorted by observation time, suitable for pipeline input.
|
| 247 |
+
"""
|
| 248 |
+
warp_table = get_ps1_warp_list(ra, dec, filter_band, config)
|
| 249 |
+
|
| 250 |
+
if len(warp_table) == 0:
|
| 251 |
+
logger.warning("No PS1 warps available for RA=%.4f Dec=%.4f", ra, dec)
|
| 252 |
+
return []
|
| 253 |
+
|
| 254 |
+
# Select frames to maximise time span: take first, last, and middle ones
|
| 255 |
+
indices = _select_frame_indices(len(warp_table), n_frames)
|
| 256 |
+
selected = warp_table[indices]
|
| 257 |
+
|
| 258 |
+
logger.info("Downloading %d/%d PS1 warps (IASC-style sequence)", n_frames, len(warp_table))
|
| 259 |
+
|
| 260 |
+
frames: list[PS1WarpFrame] = []
|
| 261 |
+
for row in selected:
|
| 262 |
+
frame = download_ps1_cutout(ra, dec, row["filename"], size_pixels, config)
|
| 263 |
+
if frame is not None:
|
| 264 |
+
frames.append(frame)
|
| 265 |
+
|
| 266 |
+
# Sort by observation time
|
| 267 |
+
frames.sort(key=lambda f: f.obs_time.unix)
|
| 268 |
+
|
| 269 |
+
if len(frames) >= 2:
|
| 270 |
+
dt_min = (frames[-1].obs_time - frames[0].obs_time).to("min").value
|
| 271 |
+
logger.info(
|
| 272 |
+
"Sequence: %d frames, time span=%.1f min (target: ~30 min)",
|
| 273 |
+
len(frames), dt_min
|
| 274 |
+
)
|
| 275 |
+
|
| 276 |
+
return frames
|
| 277 |
+
|
| 278 |
+
|
| 279 |
+
def _select_frame_indices(n_available: int, n_select: int) -> list[int]:
|
| 280 |
+
"""Select n_select indices from n_available, spread to maximise time coverage."""
|
| 281 |
+
if n_available <= n_select:
|
| 282 |
+
return list(range(n_available))
|
| 283 |
+
# Always include first and last; fill middle evenly
|
| 284 |
+
if n_select == 1:
|
| 285 |
+
return [0]
|
| 286 |
+
step = (n_available - 1) / (n_select - 1)
|
| 287 |
+
return [round(i * step) for i in range(n_select)]
|
asteroidnet/data_access/skybot_client.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET SkyBoT Client (data_access.skybot_client)
|
| 3 |
+
|
| 4 |
+
Queries the IMCCE SkyBoT service to find all known solar system objects
|
| 5 |
+
in a given sky field at a given epoch. Used both for:
|
| 6 |
+
1. Removing known asteroids from candidate lists (catalog_matcher)
|
| 7 |
+
2. Labeling archival FITS frames for ML training (training.dataset_builder)
|
| 8 |
+
|
| 9 |
+
SkyBoT covers ephemerides for all known SSOs from 1889 to 2060.
|
| 10 |
+
|
| 11 |
+
Reference: https://ssp.imcce.fr/webservices/skybot/
|
| 12 |
+
"""
|
| 13 |
+
from __future__ import annotations
|
| 14 |
+
|
| 15 |
+
import logging
|
| 16 |
+
from typing import Optional
|
| 17 |
+
|
| 18 |
+
import requests
|
| 19 |
+
from astropy.coordinates import SkyCoord
|
| 20 |
+
from astropy.table import Table
|
| 21 |
+
from astropy.time import Time
|
| 22 |
+
import astropy.units as u
|
| 23 |
+
|
| 24 |
+
logger = logging.getLogger(__name__)
|
| 25 |
+
|
| 26 |
+
_SKYBOT_URL = "https://ssp.imcce.fr/webservices/skybot/api/conesearch.php"
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def query_skybot(
|
| 30 |
+
center: SkyCoord,
|
| 31 |
+
radius: u.Quantity,
|
| 32 |
+
epoch: Time,
|
| 33 |
+
observer: str = "500",
|
| 34 |
+
config: Optional[dict] = None,
|
| 35 |
+
) -> Table:
|
| 36 |
+
"""
|
| 37 |
+
Cone-search the SkyBoT service for known solar system objects.
|
| 38 |
+
|
| 39 |
+
Parameters
|
| 40 |
+
----------
|
| 41 |
+
center : SkyCoord
|
| 42 |
+
Field center.
|
| 43 |
+
radius : Quantity
|
| 44 |
+
Search radius (e.g. 30*u.arcmin).
|
| 45 |
+
epoch : Time
|
| 46 |
+
UTC observation epoch (use mid-exposure time).
|
| 47 |
+
observer : str
|
| 48 |
+
MPC observatory code. '500' = geocenter.
|
| 49 |
+
Use 'F51' for Pan-STARRS, '695' for Palomar.
|
| 50 |
+
config : dict, optional
|
| 51 |
+
Pipeline configuration dict.
|
| 52 |
+
|
| 53 |
+
Returns
|
| 54 |
+
-------
|
| 55 |
+
Table
|
| 56 |
+
Astropy Table with columns: Number, Name, RA(h), DE(deg),
|
| 57 |
+
Type, Mv, posunc, ErrRA, ErrDE, d, dRA, dDE, Rgeo, Rhel
|
| 58 |
+
Returns empty Table if no objects found or service unavailable.
|
| 59 |
+
"""
|
| 60 |
+
url = (config or {}).get("data_access", {}).get("skybot", {}).get("url", _SKYBOT_URL)
|
| 61 |
+
observer = (config or {}).get("data_access", {}).get("skybot", {}).get(
|
| 62 |
+
"default_observer", observer)
|
| 63 |
+
|
| 64 |
+
# SkyBoT expects RA in degrees, Dec in degrees, epoch as JD (UTC)
|
| 65 |
+
ra_deg = center.icrs.ra.deg
|
| 66 |
+
dec_deg = center.icrs.dec.deg
|
| 67 |
+
radius_deg = radius.to(u.deg).value
|
| 68 |
+
jd_utc = epoch.utc.jd
|
| 69 |
+
|
| 70 |
+
params = {
|
| 71 |
+
"EPOCH": f"{jd_utc:.6f}",
|
| 72 |
+
"-ra": f"{ra_deg:.6f}",
|
| 73 |
+
"-dec": f"{dec_deg:.6f}",
|
| 74 |
+
"-bd": f"{radius_deg:.4f}",
|
| 75 |
+
"-loc": observer,
|
| 76 |
+
"-mime": "votable",
|
| 77 |
+
"-filter": "0", # 0 = all object types
|
| 78 |
+
"-refsys": "EQJ2000",
|
| 79 |
+
}
|
| 80 |
+
|
| 81 |
+
logger.info(
|
| 82 |
+
"SkyBoT query: RA=%.4f Dec=%.4f r=%.2f' epoch=%s obs=%s",
|
| 83 |
+
ra_deg, dec_deg, radius.to(u.arcmin).value, epoch.utc.isot, observer
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
try:
|
| 87 |
+
resp = requests.get(url, params=params, timeout=30)
|
| 88 |
+
resp.raise_for_status()
|
| 89 |
+
|
| 90 |
+
if "No solar system object" in resp.text or len(resp.text) < 100:
|
| 91 |
+
logger.debug("SkyBoT: no known SSOs in field")
|
| 92 |
+
return Table()
|
| 93 |
+
|
| 94 |
+
from astropy.io.votable import parse_single_table
|
| 95 |
+
import io
|
| 96 |
+
votable = parse_single_table(io.BytesIO(resp.content))
|
| 97 |
+
table = votable.to_table()
|
| 98 |
+
logger.info("SkyBoT: found %d known SSOs in field", len(table))
|
| 99 |
+
return table
|
| 100 |
+
|
| 101 |
+
except requests.exceptions.RequestException as exc:
|
| 102 |
+
logger.warning("SkyBoT query failed (network): %s — proceeding without known-object removal", exc)
|
| 103 |
+
return Table()
|
| 104 |
+
except Exception as exc:
|
| 105 |
+
logger.warning("SkyBoT parse error: %s — proceeding without known-object removal", exc)
|
| 106 |
+
return Table()
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def skybot_table_to_skycoord(table: Table) -> Optional[SkyCoord]:
|
| 110 |
+
"""Convert SkyBoT result table to SkyCoord array for cross-matching."""
|
| 111 |
+
if len(table) == 0:
|
| 112 |
+
return None
|
| 113 |
+
ra_col = "RA(h)" if "RA(h)" in table.colnames else "RA"
|
| 114 |
+
dec_col = "DE(deg)" if "DE(deg)" in table.colnames else "Dec"
|
| 115 |
+
return SkyCoord(
|
| 116 |
+
ra=table[ra_col],
|
| 117 |
+
dec=table[dec_col],
|
| 118 |
+
unit=(u.hourangle if "h" in ra_col else u.deg, u.deg),
|
| 119 |
+
frame="icrs",
|
| 120 |
+
)
|
asteroidnet/data_access/ztf_client.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET ZTF Data Access (data_access.ztf_client)
|
| 3 |
+
|
| 4 |
+
Downloads multi-epoch science FITS images from the Zwicky Transient Facility
|
| 5 |
+
via IRSA's Image Browser Engine (IBE).
|
| 6 |
+
|
| 7 |
+
Key facts:
|
| 8 |
+
- ZTF pixel scale: 1.012 arcsec/pixel
|
| 9 |
+
- ZTF times: UTC (OBSJD, OBSMJD, DATE-OBS)
|
| 10 |
+
- 16 CCDs × 4 quadrants = 64 quadrant images per exposure
|
| 11 |
+
- Three filters: g (1), r (2), i (3)
|
| 12 |
+
- Science images: sciimg.fits; difference images: scimrefdiffimg.fits.fz
|
| 13 |
+
- Photometric ZP varies by quadrant — always use MAGZP header keyword
|
| 14 |
+
|
| 15 |
+
Reference:
|
| 16 |
+
https://irsa.ipac.caltech.edu/docs/program_interface/ztf_api.html
|
| 17 |
+
"""
|
| 18 |
+
from __future__ import annotations
|
| 19 |
+
|
| 20 |
+
import io, logging, time
|
| 21 |
+
from dataclasses import dataclass
|
| 22 |
+
from typing import Optional
|
| 23 |
+
|
| 24 |
+
import numpy as np
|
| 25 |
+
import requests
|
| 26 |
+
from astropy.io import fits
|
| 27 |
+
from astropy.time import Time
|
| 28 |
+
|
| 29 |
+
from asteroidnet.utils.time_utils import parse_fits_time
|
| 30 |
+
|
| 31 |
+
logger = logging.getLogger(__name__)
|
| 32 |
+
|
| 33 |
+
_IBE_SEARCH = "https://irsa.ipac.caltech.edu/ibe/search/ztf/products/sci"
|
| 34 |
+
_IBE_DATA = "https://irsa.ipac.caltech.edu/ibe/data/ztf/products/sci"
|
| 35 |
+
|
| 36 |
+
ZTF_PIXEL_SCALE_ARCSEC = 1.012
|
| 37 |
+
ZTF_FILTERS = {"g": 1, "r": 2, "i": 3}
|
| 38 |
+
|
| 39 |
+
|
| 40 |
+
@dataclass
|
| 41 |
+
class ZTFFrame:
|
| 42 |
+
"""A single ZTF science FITS frame ready for the pipeline."""
|
| 43 |
+
filename: str
|
| 44 |
+
data: np.ndarray # float32, native byte-order
|
| 45 |
+
header: fits.Header
|
| 46 |
+
obs_time: Time # UTC mid-exposure
|
| 47 |
+
exptime_s: float
|
| 48 |
+
filter_band: str
|
| 49 |
+
ra_center: float
|
| 50 |
+
dec_center: float
|
| 51 |
+
field_id: int
|
| 52 |
+
ccd_id: int
|
| 53 |
+
quad_id: int
|
| 54 |
+
mag_zp: float # photometric zero point (varies per quadrant)
|
| 55 |
+
mag_zp_rms: float
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def search_ztf_images(
|
| 59 |
+
ra: float,
|
| 60 |
+
dec: float,
|
| 61 |
+
filter_band: str = "r",
|
| 62 |
+
size_deg: float = 0.08,
|
| 63 |
+
config: Optional[dict] = None,
|
| 64 |
+
) -> list[dict]:
|
| 65 |
+
"""
|
| 66 |
+
Search for ZTF science images covering a sky position.
|
| 67 |
+
|
| 68 |
+
Returns list of metadata dicts usable with download_ztf_cutout().
|
| 69 |
+
"""
|
| 70 |
+
url = (config or {}).get("data_access", {}).get("ztf", {}).get(
|
| 71 |
+
"ibe_search_url", _IBE_SEARCH)
|
| 72 |
+
timeout = (config or {}).get("data_access", {}).get("ztf", {}).get("timeout_s", 90)
|
| 73 |
+
|
| 74 |
+
params = {
|
| 75 |
+
"POS": f"{ra},{dec}",
|
| 76 |
+
"SIZE": str(size_deg),
|
| 77 |
+
"ct": "csv",
|
| 78 |
+
}
|
| 79 |
+
if filter_band:
|
| 80 |
+
params["WHERE"] = f"filtercode='z{filter_band}'"
|
| 81 |
+
|
| 82 |
+
logger.info("Searching ZTF images: RA=%.4f Dec=%.4f filter=z%s", ra, dec, filter_band)
|
| 83 |
+
resp = requests.get(url, params=params, timeout=timeout)
|
| 84 |
+
resp.raise_for_status()
|
| 85 |
+
|
| 86 |
+
if not resp.text.strip() or resp.text.strip().startswith("Error"):
|
| 87 |
+
logger.warning("No ZTF images found at RA=%.4f Dec=%.4f", ra, dec)
|
| 88 |
+
return []
|
| 89 |
+
|
| 90 |
+
import csv
|
| 91 |
+
reader = csv.DictReader(io.StringIO(resp.text))
|
| 92 |
+
rows = list(reader)
|
| 93 |
+
logger.info("Found %d ZTF images", len(rows))
|
| 94 |
+
return rows
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def download_ztf_cutout(
|
| 98 |
+
ra: float,
|
| 99 |
+
dec: float,
|
| 100 |
+
metadata: dict,
|
| 101 |
+
size_arcsec: float = 300.0,
|
| 102 |
+
config: Optional[dict] = None,
|
| 103 |
+
) -> Optional[ZTFFrame]:
|
| 104 |
+
"""
|
| 105 |
+
Download a cutout from a ZTF science image using IBE.
|
| 106 |
+
|
| 107 |
+
Parameters
|
| 108 |
+
----------
|
| 109 |
+
metadata : dict
|
| 110 |
+
Row from search_ztf_images() — contains filefracday, field, ccdid, qid, filtercode.
|
| 111 |
+
size_arcsec : float
|
| 112 |
+
Cutout size in arcseconds.
|
| 113 |
+
"""
|
| 114 |
+
timeout = (config or {}).get("data_access", {}).get("ztf", {}).get("timeout_s", 90)
|
| 115 |
+
base = (config or {}).get("data_access", {}).get("ztf", {}).get("ibe_data_url", _IBE_DATA)
|
| 116 |
+
|
| 117 |
+
# Construct ZTF file path from metadata
|
| 118 |
+
try:
|
| 119 |
+
filefracday = metadata["filefracday"]
|
| 120 |
+
year = filefracday[:4]
|
| 121 |
+
monthday = filefracday[4:8]
|
| 122 |
+
fracday = filefracday[8:]
|
| 123 |
+
field = metadata.get("field", "").zfill(6)
|
| 124 |
+
filt = metadata.get("filtercode", "zr")
|
| 125 |
+
ccd = metadata.get("ccdid", "01").zfill(2)
|
| 126 |
+
q = metadata.get("qid", "1")
|
| 127 |
+
fname = f"ztf_{filefracday}_{field}_{filt}_c{ccd}_o_q{q}_sciimg.fits"
|
| 128 |
+
path = f"{year}/{monthday}/{fracday}/{fname}"
|
| 129 |
+
except KeyError as exc:
|
| 130 |
+
logger.error("ZTF metadata missing key: %s", exc)
|
| 131 |
+
return None
|
| 132 |
+
|
| 133 |
+
url = f"{base}/{path}"
|
| 134 |
+
params = {
|
| 135 |
+
"center": f"{ra},{dec}",
|
| 136 |
+
"size": f"{size_arcsec}arcsec",
|
| 137 |
+
"gzip": "false",
|
| 138 |
+
}
|
| 139 |
+
|
| 140 |
+
logger.info("Downloading ZTF cutout: %s", fname)
|
| 141 |
+
t0 = time.perf_counter()
|
| 142 |
+
try:
|
| 143 |
+
resp = requests.get(url, params=params, timeout=timeout, stream=True)
|
| 144 |
+
resp.raise_for_status()
|
| 145 |
+
fits_bytes = resp.content
|
| 146 |
+
except requests.exceptions.RequestException as exc:
|
| 147 |
+
logger.error("ZTF download failed for %s: %s", fname, exc)
|
| 148 |
+
return None
|
| 149 |
+
|
| 150 |
+
logger.debug("Downloaded %.1f KB in %.2fs", len(fits_bytes) / 1024, time.perf_counter() - t0)
|
| 151 |
+
|
| 152 |
+
try:
|
| 153 |
+
with fits.open(io.BytesIO(fits_bytes)) as hdul:
|
| 154 |
+
hdu = hdul[0]
|
| 155 |
+
header = hdu.header.copy()
|
| 156 |
+
raw_data = hdu.data
|
| 157 |
+
|
| 158 |
+
if raw_data is None:
|
| 159 |
+
return None
|
| 160 |
+
|
| 161 |
+
# Byte-swap to native endianness (FITS is big-endian; bottleneck needs native)
|
| 162 |
+
data = np.array(raw_data, dtype=np.float32)
|
| 163 |
+
|
| 164 |
+
obs_time = parse_fits_time(header, survey="ztf")
|
| 165 |
+
exptime_s = float(header.get("EXPTIME", 30.0))
|
| 166 |
+
filter_band = str(header.get("FILTERID", header.get("FILTER", "r")))
|
| 167 |
+
if filter_band.isdigit():
|
| 168 |
+
filter_band = {1: "g", 2: "r", 3: "i"}.get(int(filter_band), "r")
|
| 169 |
+
|
| 170 |
+
return ZTFFrame(
|
| 171 |
+
filename=fname,
|
| 172 |
+
data=data,
|
| 173 |
+
header=header,
|
| 174 |
+
obs_time=obs_time,
|
| 175 |
+
exptime_s=exptime_s,
|
| 176 |
+
filter_band=filter_band,
|
| 177 |
+
ra_center=ra,
|
| 178 |
+
dec_center=dec,
|
| 179 |
+
field_id=int(field),
|
| 180 |
+
ccd_id=int(ccd),
|
| 181 |
+
quad_id=int(q),
|
| 182 |
+
mag_zp=float(header.get("MAGZP", 26.0)),
|
| 183 |
+
mag_zp_rms=float(header.get("MAGZPRMS", 0.0)),
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
except Exception as exc:
|
| 187 |
+
logger.error("Failed to parse ZTF FITS %s: %s", fname, exc)
|
| 188 |
+
return None
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
def get_ztf_sequence(
|
| 192 |
+
ra: float, dec: float,
|
| 193 |
+
filter_band: str = "r",
|
| 194 |
+
n_frames: int = 4,
|
| 195 |
+
size_arcsec: float = 300.0,
|
| 196 |
+
config: Optional[dict] = None,
|
| 197 |
+
) -> list[ZTFFrame]:
|
| 198 |
+
"""Download N ZTF frames covering a field, sorted by time."""
|
| 199 |
+
rows = search_ztf_images(ra, dec, filter_band, config=config)
|
| 200 |
+
if not rows:
|
| 201 |
+
return []
|
| 202 |
+
|
| 203 |
+
# Select spread across available epochs
|
| 204 |
+
from asteroidnet.data_access.ps1_client import _select_frame_indices
|
| 205 |
+
indices = _select_frame_indices(len(rows), n_frames)
|
| 206 |
+
frames = []
|
| 207 |
+
for i in indices:
|
| 208 |
+
f = download_ztf_cutout(ra, dec, rows[i], size_arcsec, config)
|
| 209 |
+
if f is not None:
|
| 210 |
+
frames.append(f)
|
| 211 |
+
frames.sort(key=lambda x: x.obs_time.unix)
|
| 212 |
+
return frames
|
asteroidnet/fits_ingestor/__init__.py
ADDED
|
File without changes
|
asteroidnet/fits_ingestor/ingestor.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET FITS Ingestor (fits_ingestor.ingestor)
|
| 3 |
+
|
| 4 |
+
Ingests FITS files from disk (IASC packages, PS1 downloads, ZTF downloads)
|
| 5 |
+
and returns validated FITSFrame objects ready for the preprocessing stage.
|
| 6 |
+
|
| 7 |
+
Critical real-data fixes applied here:
|
| 8 |
+
1. Byte-swap to native float32 — FITS big-endian silently breaks bottleneck
|
| 9 |
+
2. Survey auto-detection from headers
|
| 10 |
+
3. TAI/UTC time scale correct parsing (PS1 vs ZTF)
|
| 11 |
+
4. Mask application (DQ arrays, NaN pixels)
|
| 12 |
+
5. Header validation with graceful fallbacks for missing keywords
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import warnings
|
| 18 |
+
from dataclasses import dataclass, field
|
| 19 |
+
from pathlib import Path
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
import numpy as np
|
| 23 |
+
from astropy.io import fits
|
| 24 |
+
from astropy.time import Time
|
| 25 |
+
from astropy.wcs import WCS, FITSFixedWarning
|
| 26 |
+
|
| 27 |
+
from asteroidnet.utils.time_utils import (
|
| 28 |
+
detect_survey_from_header,
|
| 29 |
+
fix_ps1_header,
|
| 30 |
+
parse_fits_time,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
MANDATORY_SOFT = ["DATE-OBS", "EXPTIME", "FILTER", "TELESCOP"]
|
| 36 |
+
MANDATORY_HARD = [] # none — be maximally lenient with real data
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class FITSFrame:
|
| 41 |
+
path: Path
|
| 42 |
+
data: np.ndarray # float32, native byte-order, NaN where masked
|
| 43 |
+
header: fits.Header
|
| 44 |
+
wcs: WCS
|
| 45 |
+
obs_time: Time # UTC mid-exposure
|
| 46 |
+
exptime_s: float
|
| 47 |
+
filter_band: str
|
| 48 |
+
telescope: str
|
| 49 |
+
instrument: str
|
| 50 |
+
survey: str # 'ps1' | 'ztf' | 'generic'
|
| 51 |
+
pixel_scale_arcsec: float
|
| 52 |
+
warnings: list[str] = field(default_factory=list)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def ingest_fits_file(
|
| 56 |
+
path: str | Path,
|
| 57 |
+
config: Optional[dict] = None,
|
| 58 |
+
) -> FITSFrame:
|
| 59 |
+
"""
|
| 60 |
+
Load and validate a FITS file, applying all survey-specific fixes.
|
| 61 |
+
|
| 62 |
+
Parameters
|
| 63 |
+
----------
|
| 64 |
+
path : str or Path
|
| 65 |
+
Path to FITS file (may be .fits or .fits.fz for fpack-compressed).
|
| 66 |
+
config : dict, optional
|
| 67 |
+
Pipeline configuration.
|
| 68 |
+
|
| 69 |
+
Returns
|
| 70 |
+
-------
|
| 71 |
+
FITSFrame
|
| 72 |
+
Validated frame. Emits warnings for missing-but-recoverable keywords.
|
| 73 |
+
|
| 74 |
+
Raises
|
| 75 |
+
------
|
| 76 |
+
ValueError
|
| 77 |
+
If the file cannot be read or has critically bad data.
|
| 78 |
+
"""
|
| 79 |
+
path = Path(path)
|
| 80 |
+
if not path.exists():
|
| 81 |
+
raise FileNotFoundError(f"FITS file not found: {path}")
|
| 82 |
+
|
| 83 |
+
w_list: list[str] = []
|
| 84 |
+
|
| 85 |
+
with warnings.catch_warnings():
|
| 86 |
+
warnings.simplefilter("ignore", FITSFixedWarning)
|
| 87 |
+
with fits.open(path, memmap=True) as hdul:
|
| 88 |
+
# Find the primary image extension
|
| 89 |
+
hdu = _find_image_hdu(hdul)
|
| 90 |
+
header = hdu.header.copy()
|
| 91 |
+
raw_data = hdu.data
|
| 92 |
+
mask_data = _find_mask_hdu(hdul)
|
| 93 |
+
|
| 94 |
+
if raw_data is None:
|
| 95 |
+
raise ValueError(f"No image data in {path}")
|
| 96 |
+
|
| 97 |
+
# ── FIX 1: byte-swap + float32 ──────────────────────────────────────────
|
| 98 |
+
data = np.array(raw_data, dtype=np.float32)
|
| 99 |
+
|
| 100 |
+
# ── FIX 2: apply mask (NaN masked pixels) ───────────────────────────────
|
| 101 |
+
if mask_data is not None:
|
| 102 |
+
data[mask_data.astype(bool)] = np.nan
|
| 103 |
+
|
| 104 |
+
# Replace Inf with NaN
|
| 105 |
+
data[~np.isfinite(data)] = np.nan
|
| 106 |
+
|
| 107 |
+
# ── FIX 3: survey detection and header corrections ───────────────────────
|
| 108 |
+
survey = detect_survey_from_header(header)
|
| 109 |
+
if survey == "ps1":
|
| 110 |
+
fix_ps1_header(header)
|
| 111 |
+
|
| 112 |
+
# ── FIX 4: WCS ───────────────────────────────────────────────────────────
|
| 113 |
+
with warnings.catch_warnings():
|
| 114 |
+
warnings.simplefilter("ignore", FITSFixedWarning)
|
| 115 |
+
wcs = WCS(header, naxis=2)
|
| 116 |
+
|
| 117 |
+
# ── FIX 5: time parsing with correct scale ───────────────────────────────
|
| 118 |
+
try:
|
| 119 |
+
obs_time = parse_fits_time(header, survey=survey)
|
| 120 |
+
except (KeyError, ValueError) as exc:
|
| 121 |
+
w_list.append(f"Could not parse observation time: {exc}. Using J2000.0")
|
| 122 |
+
obs_time = Time("2000-01-01T00:00:00", scale="utc")
|
| 123 |
+
|
| 124 |
+
# ── Soft keyword validation ──────────────────────────────────────────────
|
| 125 |
+
for kw in MANDATORY_SOFT:
|
| 126 |
+
if kw not in header:
|
| 127 |
+
w_list.append(f"Missing optional keyword: {kw}")
|
| 128 |
+
|
| 129 |
+
exptime_s = float(header.get("EXPTIME", 30.0))
|
| 130 |
+
filter_band = str(header.get("FILTER", header.get("FILTERID", "?"))).strip()
|
| 131 |
+
telescope = str(header.get("TELESCOP", "?")).strip()
|
| 132 |
+
instrument = str(header.get("INSTRUME", "?")).strip()
|
| 133 |
+
|
| 134 |
+
# Estimate pixel scale from WCS if CDELT keywords missing
|
| 135 |
+
pixel_scale_arcsec = _estimate_pixel_scale(wcs, survey)
|
| 136 |
+
|
| 137 |
+
if w_list:
|
| 138 |
+
logger.warning("Ingested %s with %d warning(s): %s", path.name, len(w_list), "; ".join(w_list))
|
| 139 |
+
else:
|
| 140 |
+
logger.info(
|
| 141 |
+
"Ingested %s | %s | survey=%s | filter=%s | scale=%.3f\"/px",
|
| 142 |
+
path.name, obs_time.isot, survey, filter_band, pixel_scale_arcsec
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
return FITSFrame(
|
| 146 |
+
path=path,
|
| 147 |
+
data=data,
|
| 148 |
+
header=header,
|
| 149 |
+
wcs=wcs,
|
| 150 |
+
obs_time=obs_time,
|
| 151 |
+
exptime_s=exptime_s,
|
| 152 |
+
filter_band=filter_band,
|
| 153 |
+
telescope=telescope,
|
| 154 |
+
instrument=instrument,
|
| 155 |
+
survey=survey,
|
| 156 |
+
pixel_scale_arcsec=pixel_scale_arcsec,
|
| 157 |
+
warnings=w_list,
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
def ingest_fits_sequence(
|
| 162 |
+
paths: list[str | Path],
|
| 163 |
+
config: Optional[dict] = None,
|
| 164 |
+
) -> list[FITSFrame]:
|
| 165 |
+
"""Ingest multiple FITS files, sort by observation time."""
|
| 166 |
+
frames = []
|
| 167 |
+
for p in paths:
|
| 168 |
+
try:
|
| 169 |
+
frames.append(ingest_fits_file(p, config))
|
| 170 |
+
except Exception as exc:
|
| 171 |
+
logger.error("Failed to ingest %s: %s", p, exc)
|
| 172 |
+
frames.sort(key=lambda f: f.obs_time.unix)
|
| 173 |
+
logger.info("Ingested %d/%d frames", len(frames), len(paths))
|
| 174 |
+
return frames
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def _find_image_hdu(hdul: fits.HDUList) -> fits.ImageHDU:
|
| 178 |
+
"""Return the first HDU containing a 2D image array."""
|
| 179 |
+
for hdu in hdul:
|
| 180 |
+
if isinstance(hdu, (fits.PrimaryHDU, fits.ImageHDU, fits.CompImageHDU)):
|
| 181 |
+
if hdu.data is not None and hdu.data.ndim >= 2:
|
| 182 |
+
return hdu
|
| 183 |
+
return hdul[0]
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
def _find_mask_hdu(hdul: fits.HDUList) -> Optional[np.ndarray]:
|
| 187 |
+
"""Return mask array if a mask/DQ extension is present."""
|
| 188 |
+
for hdu in hdul:
|
| 189 |
+
extname = str(hdu.header.get("EXTNAME", "")).upper()
|
| 190 |
+
if extname in ("MASK", "DQ", "FLAGS", "BPM") and hdu.data is not None:
|
| 191 |
+
return hdu.data
|
| 192 |
+
return None
|
| 193 |
+
|
| 194 |
+
|
| 195 |
+
def _estimate_pixel_scale(wcs: WCS, survey: str) -> float:
|
| 196 |
+
"""Estimate pixel scale in arcsec/pixel from WCS or survey defaults."""
|
| 197 |
+
if survey == "ps1":
|
| 198 |
+
return 0.25
|
| 199 |
+
if survey == "ztf":
|
| 200 |
+
return 1.012
|
| 201 |
+
try:
|
| 202 |
+
from astropy.wcs.utils import proj_plane_pixel_scales
|
| 203 |
+
scales = proj_plane_pixel_scales(wcs)
|
| 204 |
+
return float(np.mean(scales) * 3600.0)
|
| 205 |
+
except Exception:
|
| 206 |
+
return 1.0
|
asteroidnet/image_preprocessor/__init__.py
ADDED
|
File without changes
|
asteroidnet/image_preprocessor/preprocessor.py
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET Image Preprocessor (image_preprocessor.preprocessor)
|
| 3 |
+
|
| 4 |
+
Two-pass background subtraction with source masking, cosmic-ray rejection,
|
| 5 |
+
and multi-frame WCS alignment.
|
| 6 |
+
|
| 7 |
+
Two-pass background is CRITICAL for real data:
|
| 8 |
+
- First pass gives rough background → build source mask
|
| 9 |
+
- Second pass with mask gives unbiased background (sources don't inflate estimate)
|
| 10 |
+
- This matters especially in crowded fields near galactic plane
|
| 11 |
+
|
| 12 |
+
Byte-order note: data must already be float32 native (done in ingestor).
|
| 13 |
+
"""
|
| 14 |
+
from __future__ import annotations
|
| 15 |
+
|
| 16 |
+
import logging
|
| 17 |
+
import warnings
|
| 18 |
+
from typing import Optional
|
| 19 |
+
|
| 20 |
+
import numpy as np
|
| 21 |
+
from astropy.io import fits
|
| 22 |
+
from astropy.stats import SigmaClip
|
| 23 |
+
from astropy.wcs import FITSFixedWarning
|
| 24 |
+
|
| 25 |
+
from asteroidnet.fits_ingestor.ingestor import FITSFrame
|
| 26 |
+
|
| 27 |
+
logger = logging.getLogger(__name__)
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def preprocess_frame(
|
| 31 |
+
frame: FITSFrame,
|
| 32 |
+
config: Optional[dict] = None,
|
| 33 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 34 |
+
"""
|
| 35 |
+
Full preprocessing pipeline for a single frame.
|
| 36 |
+
|
| 37 |
+
Stages:
|
| 38 |
+
1. Cosmic-ray rejection (astroscrappy L.A.Cosmic)
|
| 39 |
+
2. Two-pass background subtraction with source masking
|
| 40 |
+
3. Returns (background-subtracted data, background RMS map)
|
| 41 |
+
|
| 42 |
+
Parameters
|
| 43 |
+
----------
|
| 44 |
+
frame : FITSFrame
|
| 45 |
+
Ingested frame with float32 native data.
|
| 46 |
+
config : dict, optional
|
| 47 |
+
Pipeline configuration.
|
| 48 |
+
|
| 49 |
+
Returns
|
| 50 |
+
-------
|
| 51 |
+
data_sub : ndarray
|
| 52 |
+
Background-subtracted data (NaN where masked).
|
| 53 |
+
bkg_rms : ndarray
|
| 54 |
+
Per-pixel background RMS (for SNR threshold computation).
|
| 55 |
+
"""
|
| 56 |
+
cfg = (config or {}).get("preprocessing", {})
|
| 57 |
+
data = frame.data.copy()
|
| 58 |
+
|
| 59 |
+
# ── Stage 1: Cosmic ray rejection ───────────────────────────────────────
|
| 60 |
+
data, cr_mask = _reject_cosmic_rays(data, cfg, frame.exptime_s)
|
| 61 |
+
|
| 62 |
+
# ── Stage 2: Two-pass background subtraction ─────────────────────────────
|
| 63 |
+
data_sub, bkg_rms = _subtract_background(data, cfg)
|
| 64 |
+
|
| 65 |
+
logger.debug(
|
| 66 |
+
"Preprocessed %s: CR_mask=%.3f%%, bkg_median=%.2f, bkg_rms_median=%.2f",
|
| 67 |
+
frame.path.name,
|
| 68 |
+
100 * cr_mask.sum() / cr_mask.size,
|
| 69 |
+
float(np.nanmedian(data_sub + bkg_rms)), # approx background level
|
| 70 |
+
float(np.nanmedian(bkg_rms)),
|
| 71 |
+
)
|
| 72 |
+
return data_sub, bkg_rms
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def align_frames(
|
| 76 |
+
frames: list[FITSFrame],
|
| 77 |
+
data_list: list[np.ndarray],
|
| 78 |
+
config: Optional[dict] = None,
|
| 79 |
+
) -> list[np.ndarray]:
|
| 80 |
+
"""
|
| 81 |
+
Reproject all frames to the WCS of the first frame.
|
| 82 |
+
|
| 83 |
+
Uses reproject_adaptive with conserve_flux=True — the recommended
|
| 84 |
+
general-purpose algorithm that handles pixel scale differences.
|
| 85 |
+
|
| 86 |
+
Returns aligned data arrays (same WCS as frames[0]).
|
| 87 |
+
"""
|
| 88 |
+
if len(frames) < 2:
|
| 89 |
+
return data_list
|
| 90 |
+
|
| 91 |
+
try:
|
| 92 |
+
from reproject import reproject_adaptive
|
| 93 |
+
except ImportError:
|
| 94 |
+
logger.warning("reproject not installed — skipping alignment")
|
| 95 |
+
return data_list
|
| 96 |
+
|
| 97 |
+
ref_header = frames[0].header
|
| 98 |
+
aligned = [data_list[0]]
|
| 99 |
+
|
| 100 |
+
for i, (frame, data) in enumerate(zip(frames[1:], data_list[1:]), 1):
|
| 101 |
+
with warnings.catch_warnings():
|
| 102 |
+
warnings.simplefilter("ignore", FITSFixedWarning)
|
| 103 |
+
try:
|
| 104 |
+
reprojected, footprint = reproject_adaptive(
|
| 105 |
+
(data, frame.header),
|
| 106 |
+
ref_header,
|
| 107 |
+
conserve_flux=True,
|
| 108 |
+
kernel="gaussian",
|
| 109 |
+
)
|
| 110 |
+
# Mask pixels outside footprint
|
| 111 |
+
reprojected[footprint < 0.5] = np.nan
|
| 112 |
+
aligned.append(reprojected.astype(np.float32))
|
| 113 |
+
logger.debug("Aligned frame %d/%d to reference WCS", i, len(frames) - 1)
|
| 114 |
+
except Exception as exc:
|
| 115 |
+
logger.warning("Frame %d alignment failed: %s — using unaligned", i, exc)
|
| 116 |
+
aligned.append(data)
|
| 117 |
+
|
| 118 |
+
return aligned
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
# ── Private helpers ──────────────────────────────────────────────────────────
|
| 122 |
+
|
| 123 |
+
def _reject_cosmic_rays(
|
| 124 |
+
data: np.ndarray,
|
| 125 |
+
cfg: dict,
|
| 126 |
+
exptime_s: float,
|
| 127 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 128 |
+
"""Apply L.A.Cosmic cosmic-ray rejection via astroscrappy."""
|
| 129 |
+
try:
|
| 130 |
+
import astroscrappy
|
| 131 |
+
sigclip = float(cfg.get("cosmic_ray_sigclip", 4.5))
|
| 132 |
+
objlim = float(cfg.get("cosmic_ray_objlim", 5.0))
|
| 133 |
+
|
| 134 |
+
# Readnoise from config or typical survey default
|
| 135 |
+
readnoise = float(cfg.get("readnoise_e", 10.0))
|
| 136 |
+
|
| 137 |
+
# astroscrappy requires no NaN — replace with median
|
| 138 |
+
nan_mask = ~np.isfinite(data)
|
| 139 |
+
fill_val = float(np.nanmedian(data))
|
| 140 |
+
data_fill = np.where(nan_mask, fill_val, data).astype(np.float32)
|
| 141 |
+
|
| 142 |
+
cr_mask, cleaned = astroscrappy.detect_cosmics(
|
| 143 |
+
data_fill,
|
| 144 |
+
sigclip=sigclip,
|
| 145 |
+
sigfrac=0.3,
|
| 146 |
+
objlim=objlim,
|
| 147 |
+
readnoise=readnoise,
|
| 148 |
+
gain=1.0,
|
| 149 |
+
verbose=False,
|
| 150 |
+
)
|
| 151 |
+
# Restore original NaN positions
|
| 152 |
+
cleaned[nan_mask] = np.nan
|
| 153 |
+
cleaned = cleaned.astype(np.float32)
|
| 154 |
+
cr_mask |= nan_mask
|
| 155 |
+
|
| 156 |
+
n_cr = int(cr_mask.sum()) - int(nan_mask.sum())
|
| 157 |
+
if n_cr > 0:
|
| 158 |
+
logger.debug("Rejected %d cosmic rays", n_cr)
|
| 159 |
+
return cleaned, cr_mask
|
| 160 |
+
|
| 161 |
+
except ImportError:
|
| 162 |
+
logger.debug("astroscrappy not installed — skipping CR rejection")
|
| 163 |
+
nan_mask = ~np.isfinite(data)
|
| 164 |
+
return data, nan_mask
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _subtract_background(
|
| 168 |
+
data: np.ndarray,
|
| 169 |
+
cfg: dict,
|
| 170 |
+
) -> tuple[np.ndarray, np.ndarray]:
|
| 171 |
+
"""
|
| 172 |
+
Two-pass sigma-clipped 2D background subtraction with source masking.
|
| 173 |
+
|
| 174 |
+
Pass 1: rough background → detect sources → build mask
|
| 175 |
+
Pass 2: re-estimate background with masked sources → final subtraction
|
| 176 |
+
|
| 177 |
+
The two-pass approach is critical for crowded fields: sources bias the
|
| 178 |
+
background estimate upward, causing under-subtraction and spurious detections.
|
| 179 |
+
"""
|
| 180 |
+
try:
|
| 181 |
+
from photutils.background import Background2D, SExtractorBackground
|
| 182 |
+
from photutils.segmentation import detect_sources
|
| 183 |
+
except ImportError:
|
| 184 |
+
logger.warning("photutils not installed — using sigma-clipped median background")
|
| 185 |
+
from astropy.stats import sigma_clipped_stats
|
| 186 |
+
_, med, std = sigma_clipped_stats(data[np.isfinite(data)])
|
| 187 |
+
return (data - med).astype(np.float32), np.full_like(data, std)
|
| 188 |
+
|
| 189 |
+
box_size = int(cfg.get("background_box_size", 64))
|
| 190 |
+
filter_size = int(cfg.get("background_filter_size", 3))
|
| 191 |
+
sigma = float(cfg.get("sigma_clip_sigma", 3.0))
|
| 192 |
+
maxiters = int(cfg.get("sigma_clip_maxiters", 10))
|
| 193 |
+
mask_snr = float(cfg.get("source_mask_snr", 2.0))
|
| 194 |
+
|
| 195 |
+
sc = SigmaClip(sigma=sigma, maxiters=maxiters)
|
| 196 |
+
nan_mask = ~np.isfinite(data)
|
| 197 |
+
|
| 198 |
+
# Combine NaN mask with user mask
|
| 199 |
+
edge_mask = nan_mask.copy()
|
| 200 |
+
|
| 201 |
+
# ── Pass 1: rough background ──────────────────────────────────────────
|
| 202 |
+
try:
|
| 203 |
+
bkg1 = Background2D(
|
| 204 |
+
data,
|
| 205 |
+
box_size=box_size,
|
| 206 |
+
filter_size=filter_size,
|
| 207 |
+
sigma_clip=sc,
|
| 208 |
+
bkg_estimator=SExtractorBackground(),
|
| 209 |
+
mask=edge_mask,
|
| 210 |
+
fill_value=0.0,
|
| 211 |
+
)
|
| 212 |
+
rough_sub = data - bkg1.background
|
| 213 |
+
|
| 214 |
+
# Build source mask from first-pass subtraction
|
| 215 |
+
threshold1 = mask_snr * bkg1.background_rms
|
| 216 |
+
source_mask = np.zeros_like(data, dtype=bool)
|
| 217 |
+
try:
|
| 218 |
+
segm = detect_sources(rough_sub, threshold1, npixels=5)
|
| 219 |
+
if segm is not None:
|
| 220 |
+
source_mask = segm.data > 0
|
| 221 |
+
except Exception:
|
| 222 |
+
pass
|
| 223 |
+
combined_mask = edge_mask | source_mask
|
| 224 |
+
|
| 225 |
+
# ── Pass 2: refined background with source mask ──────────────────
|
| 226 |
+
bkg2 = Background2D(
|
| 227 |
+
data,
|
| 228 |
+
box_size=box_size,
|
| 229 |
+
filter_size=filter_size,
|
| 230 |
+
sigma_clip=sc,
|
| 231 |
+
bkg_estimator=SExtractorBackground(),
|
| 232 |
+
mask=combined_mask,
|
| 233 |
+
fill_value=0.0,
|
| 234 |
+
)
|
| 235 |
+
data_sub = (data - bkg2.background).astype(np.float32)
|
| 236 |
+
data_sub[nan_mask] = np.nan
|
| 237 |
+
bkg_rms = bkg2.background_rms.astype(np.float32)
|
| 238 |
+
|
| 239 |
+
logger.debug(
|
| 240 |
+
"Two-pass background: src_mask=%.2f%%, bkg_rms_median=%.3f",
|
| 241 |
+
100 * source_mask.sum() / source_mask.size,
|
| 242 |
+
float(np.nanmedian(bkg_rms)),
|
| 243 |
+
)
|
| 244 |
+
return data_sub, bkg_rms
|
| 245 |
+
|
| 246 |
+
except Exception as exc:
|
| 247 |
+
logger.warning("Background2D failed (%s) — falling back to constant", exc)
|
| 248 |
+
from astropy.stats import sigma_clipped_stats
|
| 249 |
+
_, med, std = sigma_clipped_stats(data[np.isfinite(data)])
|
| 250 |
+
return (data - med).astype(np.float32), np.full_like(data, std, dtype=np.float32)
|
asteroidnet/orbit_determination/__init__.py
ADDED
|
File without changes
|
asteroidnet/orbit_determination/gauss_method.py
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AsteroidNET Orbit Determination (orbit_determination.gauss_method)."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import logging, math
|
| 4 |
+
from dataclasses import dataclass
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from astropy.time import Time
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@dataclass
|
| 14 |
+
class OrbitalElements:
|
| 15 |
+
a_au: float
|
| 16 |
+
e: float
|
| 17 |
+
i_deg: float
|
| 18 |
+
omega_deg: float
|
| 19 |
+
Omega_deg: float
|
| 20 |
+
M_deg: float
|
| 21 |
+
epoch: Time
|
| 22 |
+
sigma_a: float = 0.0
|
| 23 |
+
sigma_e: float = 0.0
|
| 24 |
+
sigma_i: float = 0.0
|
| 25 |
+
arc_hours: float = 0.0
|
| 26 |
+
method: str = "gauss"
|
| 27 |
+
object_class: str = "UNKNOWN"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def determine_orbit(
|
| 31 |
+
tracklet,
|
| 32 |
+
observatory_location: Optional[str] = None,
|
| 33 |
+
config: Optional[dict] = None,
|
| 34 |
+
) -> Optional[OrbitalElements]:
|
| 35 |
+
"""Attempt orbit determination for a confirmed tracklet."""
|
| 36 |
+
dets = tracklet.detections
|
| 37 |
+
if len(dets) < 3:
|
| 38 |
+
return _analytical_estimate(tracklet)
|
| 39 |
+
|
| 40 |
+
try:
|
| 41 |
+
from astroquery.jplhorizons import Horizons
|
| 42 |
+
result = _gauss_sbpy(tracklet, observatory_location)
|
| 43 |
+
if result is not None:
|
| 44 |
+
return result
|
| 45 |
+
except ImportError:
|
| 46 |
+
pass
|
| 47 |
+
|
| 48 |
+
return _analytical_estimate(tracklet)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def _analytical_estimate(tracklet) -> OrbitalElements:
|
| 52 |
+
"""Fast analytical estimate from velocity for single-night arcs."""
|
| 53 |
+
vel = tracklet.velocity_arcsec_s
|
| 54 |
+
arc_h = tracklet.time_span_min / 60.0
|
| 55 |
+
|
| 56 |
+
# Very rough: v ~ sqrt(GM/r) / delta; assume delta ~ 1 AU
|
| 57 |
+
# For 1 AU target, typical vel ~1-3 arcsec/s → a ~ 1-4 AU
|
| 58 |
+
if vel > 0.01:
|
| 59 |
+
a_est = max(0.5, min(6.0, 2.5 / (vel**0.3)))
|
| 60 |
+
else:
|
| 61 |
+
a_est = 3.0
|
| 62 |
+
|
| 63 |
+
e_est = 0.1 + 0.1 * (vel > 2.0)
|
| 64 |
+
i_est = 5.0 + 10.0 * (vel > 1.0)
|
| 65 |
+
|
| 66 |
+
# Uncertainty scales inversely with arc length
|
| 67 |
+
scale = max(1.0, 2.0 / max(arc_h, 0.1))
|
| 68 |
+
obj_class = _classify(a_est, e_est)
|
| 69 |
+
|
| 70 |
+
return OrbitalElements(
|
| 71 |
+
a_au=round(a_est, 4),
|
| 72 |
+
e=round(e_est, 4),
|
| 73 |
+
i_deg=round(i_est, 2),
|
| 74 |
+
omega_deg=0.0,
|
| 75 |
+
Omega_deg=0.0,
|
| 76 |
+
M_deg=0.0,
|
| 77 |
+
epoch=Time.now(),
|
| 78 |
+
sigma_a=round(0.05 * scale, 4),
|
| 79 |
+
sigma_e=round(0.05 * scale, 4),
|
| 80 |
+
sigma_i=round(2.0 * scale, 2),
|
| 81 |
+
arc_hours=arc_h,
|
| 82 |
+
method="analytical_estimate",
|
| 83 |
+
object_class=obj_class,
|
| 84 |
+
)
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _gauss_sbpy(tracklet, obs_location: Optional[str]) -> Optional[OrbitalElements]:
|
| 88 |
+
"""Full Gauss method via sbpy (requires ≥3 astrometric positions)."""
|
| 89 |
+
try:
|
| 90 |
+
import sbpy.data as sbd
|
| 91 |
+
from sbpy.orbit import Orbit
|
| 92 |
+
dets = sorted(tracklet.detections, key=lambda d: d["time_unix"])
|
| 93 |
+
# Build observation table
|
| 94 |
+
times = [Time(d["time_unix"], format="unix") for d in dets[:3]]
|
| 95 |
+
ras = [d["ra"] for d in dets[:3]]
|
| 96 |
+
decs = [d["dec"] for d in dets[:3]]
|
| 97 |
+
from astropy.coordinates import SkyCoord
|
| 98 |
+
import astropy.units as u
|
| 99 |
+
coords = SkyCoord(ra=ras, dec=decs, unit=u.deg)
|
| 100 |
+
# sbpy Gauss method — simplified call
|
| 101 |
+
# Full implementation requires topocentric correction and observer ephemeris
|
| 102 |
+
logger.debug("sbpy Gauss method not fully implemented — using analytical estimate")
|
| 103 |
+
return None
|
| 104 |
+
except Exception as exc:
|
| 105 |
+
logger.debug("sbpy Gauss failed: %s", exc)
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
def _classify(a: float, e: float) -> str:
|
| 110 |
+
if e >= 1.0:
|
| 111 |
+
return "HYPERBOLIC"
|
| 112 |
+
if a < 1.3:
|
| 113 |
+
return "NEO_APOLLO_ATEN_AMOR"
|
| 114 |
+
if a < 2.0:
|
| 115 |
+
return "MARS_CROSSER"
|
| 116 |
+
if a < 3.2:
|
| 117 |
+
return "MAIN_BELT"
|
| 118 |
+
if a < 5.2:
|
| 119 |
+
return "OUTER_BELT"
|
| 120 |
+
return "TROJAN_TNO"
|
asteroidnet/pipeline/__init__.py
ADDED
|
File without changes
|
asteroidnet/pipeline/runner.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AsteroidNET Pipeline Runner — orchestrates all 6 stages."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import logging, time
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from asteroidnet.config.loader import load_config
|
| 9 |
+
from asteroidnet.fits_ingestor.ingestor import ingest_fits_sequence, FITSFrame
|
| 10 |
+
from asteroidnet.image_preprocessor.preprocessor import preprocess_frame, align_frames
|
| 11 |
+
from asteroidnet.source_extractor.detector import extract_sources
|
| 12 |
+
from asteroidnet.catalog_matcher.matcher import remove_known_sources
|
| 13 |
+
from asteroidnet.tracklet_linker.linker import link_tracklets
|
| 14 |
+
from asteroidnet.candidate_classifier.classifier import classify_tracklets
|
| 15 |
+
from asteroidnet.orbit_determination.gauss_method import determine_orbit
|
| 16 |
+
from asteroidnet.reporting.mpc_formatter import format_tracklet_records
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class PipelineResult:
|
| 23 |
+
run_id: str
|
| 24 |
+
n_frames: int
|
| 25 |
+
n_candidates: int
|
| 26 |
+
n_confirmed: int
|
| 27 |
+
classifications: list
|
| 28 |
+
mpc_records: list[str]
|
| 29 |
+
elapsed_s: float
|
| 30 |
+
config_hash: str
|
| 31 |
+
warnings: list[str] = field(default_factory=list)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def run_pipeline(
|
| 35 |
+
fits_paths: list[str | Path],
|
| 36 |
+
config_path: Optional[str | Path] = None,
|
| 37 |
+
observatory_code: Optional[str] = None,
|
| 38 |
+
) -> PipelineResult:
|
| 39 |
+
"""
|
| 40 |
+
Run the complete AsteroidNET detection pipeline on a set of FITS files.
|
| 41 |
+
|
| 42 |
+
Parameters
|
| 43 |
+
----------
|
| 44 |
+
fits_paths : list
|
| 45 |
+
Paths to FITS files (4 frames recommended, as in IASC packages).
|
| 46 |
+
config_path : str or Path, optional
|
| 47 |
+
Path to YAML config file (uses defaults if None).
|
| 48 |
+
observatory_code : str, optional
|
| 49 |
+
MPC 3-char observatory code for report generation.
|
| 50 |
+
|
| 51 |
+
Returns
|
| 52 |
+
-------
|
| 53 |
+
PipelineResult
|
| 54 |
+
Complete results including classifications and MPC records.
|
| 55 |
+
"""
|
| 56 |
+
t_start = time.perf_counter()
|
| 57 |
+
cfg = load_config(config_path)
|
| 58 |
+
obs_code = observatory_code or cfg.get("reporting", {}).get("observatory_code", "???")
|
| 59 |
+
|
| 60 |
+
import hashlib, uuid
|
| 61 |
+
run_id = str(uuid.uuid4())[:8]
|
| 62 |
+
logger.info("=== AsteroidNET Pipeline Run %s ===", run_id)
|
| 63 |
+
|
| 64 |
+
# ── Stage 1: Ingest FITS ─────────────────────────────────────────────────
|
| 65 |
+
logger.info("Stage 1: Ingesting %d FITS frames", len(fits_paths))
|
| 66 |
+
frames: list[FITSFrame] = ingest_fits_sequence(fits_paths, cfg)
|
| 67 |
+
if len(frames) < 2:
|
| 68 |
+
return PipelineResult(run_id, len(frames), 0, 0, [], [],
|
| 69 |
+
time.perf_counter() - t_start, cfg["_hash"],
|
| 70 |
+
["Need ≥2 frames"])
|
| 71 |
+
|
| 72 |
+
# ── Stage 2: Preprocess ─────────────────────────────────────────────────
|
| 73 |
+
logger.info("Stage 2: Preprocessing %d frames", len(frames))
|
| 74 |
+
data_subs = []
|
| 75 |
+
bkg_rmss = []
|
| 76 |
+
for f in frames:
|
| 77 |
+
ds, br = preprocess_frame(f, cfg)
|
| 78 |
+
data_subs.append(ds)
|
| 79 |
+
bkg_rmss.append(br)
|
| 80 |
+
|
| 81 |
+
# Align frames to common WCS
|
| 82 |
+
data_aligned = align_frames(frames, data_subs, cfg)
|
| 83 |
+
|
| 84 |
+
# ── Stage 3: Source extraction ───────────────────────────────────────────
|
| 85 |
+
logger.info("Stage 3: Extracting sources from %d frames", len(frames))
|
| 86 |
+
catalogs = []
|
| 87 |
+
for frame, data, rms in zip(frames, data_aligned, bkg_rmss):
|
| 88 |
+
cat = extract_sources(data, rms, frame.wcs, cfg)
|
| 89 |
+
catalogs.append(cat)
|
| 90 |
+
|
| 91 |
+
# ── Stage 4: Catalog matching (remove stars + known SSOs) ────────────────
|
| 92 |
+
logger.info("Stage 4: Removing known sources from %d catalogs", len(catalogs))
|
| 93 |
+
filtered_catalogs = []
|
| 94 |
+
for frame, cat in zip(frames, catalogs):
|
| 95 |
+
filtered = remove_known_sources(cat, frame.obs_time, cfg)
|
| 96 |
+
filtered_catalogs.append(filtered)
|
| 97 |
+
|
| 98 |
+
# ── Stage 5: Tracklet linking ────────────────────────────────────────────
|
| 99 |
+
logger.info("Stage 5: Linking tracklets across %d frames", len(frames))
|
| 100 |
+
times = [f.obs_time for f in frames]
|
| 101 |
+
tracklets = link_tracklets(filtered_catalogs, times, cfg)
|
| 102 |
+
|
| 103 |
+
n_candidates = len(tracklets)
|
| 104 |
+
|
| 105 |
+
# ── Stage 6: Classification ──────────────────────────────────────────────
|
| 106 |
+
logger.info("Stage 6: Classifying %d tracklet candidates", n_candidates)
|
| 107 |
+
classifications = classify_tracklets(tracklets, data_aligned, cfg)
|
| 108 |
+
n_confirmed = len(classifications)
|
| 109 |
+
|
| 110 |
+
# ── Reporting ────────────────────────────────────────────────────────────
|
| 111 |
+
mpc_records = []
|
| 112 |
+
for cl in classifications:
|
| 113 |
+
records = format_tracklet_records(cl.tracklet, obs_code)
|
| 114 |
+
mpc_records.extend(records)
|
| 115 |
+
|
| 116 |
+
elapsed = time.perf_counter() - t_start
|
| 117 |
+
logger.info(
|
| 118 |
+
"=== Run %s complete: %d frames → %d candidates → %d confirmed "
|
| 119 |
+
"(%.2fs) ===",
|
| 120 |
+
run_id, len(frames), n_candidates, n_confirmed, elapsed
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return PipelineResult(
|
| 124 |
+
run_id=run_id,
|
| 125 |
+
n_frames=len(frames),
|
| 126 |
+
n_candidates=n_candidates,
|
| 127 |
+
n_confirmed=n_confirmed,
|
| 128 |
+
classifications=classifications,
|
| 129 |
+
mpc_records=mpc_records,
|
| 130 |
+
elapsed_s=elapsed,
|
| 131 |
+
config_hash=cfg["_hash"],
|
| 132 |
+
)
|
asteroidnet/reporting/__init__.py
ADDED
|
File without changes
|
asteroidnet/reporting/mpc_formatter.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AsteroidNET MPC Formatter — exact 80-column astrometric records."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import logging
|
| 4 |
+
from astropy.coordinates import SkyCoord
|
| 5 |
+
from astropy.time import Time
|
| 6 |
+
import astropy.units as u
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def format_mpc_record(
|
| 12 |
+
designation: str,
|
| 13 |
+
ra_deg: float,
|
| 14 |
+
dec_deg: float,
|
| 15 |
+
obs_time: Time,
|
| 16 |
+
magnitude: float,
|
| 17 |
+
filter_band: str,
|
| 18 |
+
observatory_code: str,
|
| 19 |
+
) -> str:
|
| 20 |
+
"""
|
| 21 |
+
Generate an exact 80-column MPC optical astrometry record.
|
| 22 |
+
|
| 23 |
+
Reference: https://www.minorplanetcenter.net/iau/info/OpticalObs.html
|
| 24 |
+
"""
|
| 25 |
+
observatory_code = observatory_code.strip()
|
| 26 |
+
if len(observatory_code) != 3:
|
| 27 |
+
raise ValueError(f"Observatory code must be exactly 3 chars, got: {repr(observatory_code)}")
|
| 28 |
+
|
| 29 |
+
coord = SkyCoord(ra=ra_deg * u.deg, dec=dec_deg * u.deg)
|
| 30 |
+
|
| 31 |
+
ra_h = int(coord.ra.hms.h)
|
| 32 |
+
ra_m = int(coord.ra.hms.m)
|
| 33 |
+
ra_s = coord.ra.hms.s
|
| 34 |
+
ra_str = f"{ra_h:02d} {ra_m:02d} {ra_s:05.2f}"
|
| 35 |
+
|
| 36 |
+
sign = "+" if coord.dec.deg >= 0 else "-"
|
| 37 |
+
d = abs(coord.dec.deg)
|
| 38 |
+
dd = int(d)
|
| 39 |
+
dm = int((d - dd) * 60)
|
| 40 |
+
ds = ((d - dd) * 60 - dm) * 60
|
| 41 |
+
dec_str = f"{sign}{dd:02d} {dm:02d} {ds:04.1f}"
|
| 42 |
+
|
| 43 |
+
# Date: YYYY MM DD.ddddd
|
| 44 |
+
t = obs_time.utc
|
| 45 |
+
frac_day = t.mjd - int(t.mjd)
|
| 46 |
+
year = int(t.strftime("%Y"))
|
| 47 |
+
month = int(t.strftime("%m"))
|
| 48 |
+
day = int(t.strftime("%d"))
|
| 49 |
+
date_str = f"{year:04d} {month:02d} {day + frac_day:08.5f}"
|
| 50 |
+
|
| 51 |
+
try:
|
| 52 |
+
mag_str = f"{float(magnitude):4.1f}"
|
| 53 |
+
except Exception:
|
| 54 |
+
mag_str = " "
|
| 55 |
+
|
| 56 |
+
band = (filter_band.strip()[0] if filter_band.strip() else "R")
|
| 57 |
+
|
| 58 |
+
# Build 80-byte buffer
|
| 59 |
+
buf = bytearray(b" " * 80)
|
| 60 |
+
|
| 61 |
+
def place(s: str, start: int, width: int) -> None:
|
| 62 |
+
enc = s[:width].ljust(width).encode("ascii", errors="replace")
|
| 63 |
+
buf[start:start + width] = enc
|
| 64 |
+
|
| 65 |
+
place(str(designation)[:5].ljust(5), 0, 5)
|
| 66 |
+
place("C", 8, 1) # Note 2: CCD observation
|
| 67 |
+
place(date_str[:8], 9, 8) # Date: YYYY MM
|
| 68 |
+
place(date_str[8:16], 17, 8) # Date: DD.ddddd
|
| 69 |
+
place(ra_str, 26, 11)
|
| 70 |
+
place(dec_str, 37, 11)
|
| 71 |
+
place(mag_str, 56, 4)
|
| 72 |
+
place(band, 61, 1)
|
| 73 |
+
place(observatory_code.rjust(3), 77, 3)
|
| 74 |
+
|
| 75 |
+
line = buf.decode("ascii")
|
| 76 |
+
assert len(line) == 80, f"MPC record length {len(line)} ≠ 80"
|
| 77 |
+
return line
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def format_tracklet_records(
|
| 81 |
+
tracklet,
|
| 82 |
+
observatory_code: str,
|
| 83 |
+
designation: str = " ",
|
| 84 |
+
) -> list[str]:
|
| 85 |
+
"""Generate one MPC record per detection in a tracklet."""
|
| 86 |
+
lines = []
|
| 87 |
+
for det in tracklet.detections:
|
| 88 |
+
t = Time(det["time_unix"], format="unix")
|
| 89 |
+
try:
|
| 90 |
+
line = format_mpc_record(
|
| 91 |
+
designation=designation,
|
| 92 |
+
ra_deg=det["ra"],
|
| 93 |
+
dec_deg=det["dec"],
|
| 94 |
+
obs_time=t,
|
| 95 |
+
magnitude=det.get("mag", 99.0),
|
| 96 |
+
filter_band=det.get("filter", "R"),
|
| 97 |
+
observatory_code=observatory_code,
|
| 98 |
+
)
|
| 99 |
+
lines.append(line)
|
| 100 |
+
except Exception as exc:
|
| 101 |
+
logger.warning("Could not format MPC record for detection: %s", exc)
|
| 102 |
+
return lines
|
asteroidnet/source_extractor/__init__.py
ADDED
|
File without changes
|
asteroidnet/source_extractor/detector.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET Source Extractor (source_extractor.detector)
|
| 3 |
+
|
| 4 |
+
Two-pass source detection: bright pass at 5σ to build PSF model,
|
| 5 |
+
faint pass at 3σ for asteroid candidates.
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Optional
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
from astropy.table import Table
|
| 13 |
+
from astropy.wcs import WCS
|
| 14 |
+
import astropy.units as u
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
_CATALOG_COLS = [
|
| 19 |
+
"x_pixel", "y_pixel", "ra_deg", "dec_deg",
|
| 20 |
+
"flux_adu", "flux_err", "mag", "snr",
|
| 21 |
+
"fwhm", "roundness", "sharpness",
|
| 22 |
+
]
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def extract_sources(
|
| 26 |
+
data_sub: np.ndarray,
|
| 27 |
+
bkg_rms: np.ndarray,
|
| 28 |
+
wcs: WCS,
|
| 29 |
+
config: Optional[dict] = None,
|
| 30 |
+
) -> Table:
|
| 31 |
+
"""
|
| 32 |
+
Detect sources in a background-subtracted image and return a catalog.
|
| 33 |
+
|
| 34 |
+
Parameters
|
| 35 |
+
----------
|
| 36 |
+
data_sub : ndarray
|
| 37 |
+
Background-subtracted image (float32, NaN where masked).
|
| 38 |
+
bkg_rms : ndarray
|
| 39 |
+
Per-pixel background RMS (for threshold computation).
|
| 40 |
+
wcs : WCS
|
| 41 |
+
Frame WCS for pixel→sky coordinate conversion.
|
| 42 |
+
config : dict, optional
|
| 43 |
+
Pipeline configuration.
|
| 44 |
+
|
| 45 |
+
Returns
|
| 46 |
+
-------
|
| 47 |
+
Table
|
| 48 |
+
Source catalog with standardized columns.
|
| 49 |
+
"""
|
| 50 |
+
cfg = (config or {}).get("detection", {})
|
| 51 |
+
thresh_hi = float(cfg.get("bright_threshold_sigma", 5.0))
|
| 52 |
+
thresh_lo = float(cfg.get("threshold_sigma", 3.0))
|
| 53 |
+
fwhm_lo, fwhm_hi = cfg.get("fwhm_range", [2.0, 8.0])
|
| 54 |
+
|
| 55 |
+
rms_median = float(np.nanmedian(bkg_rms))
|
| 56 |
+
if rms_median <= 0:
|
| 57 |
+
rms_median = float(np.nanstd(data_sub[np.isfinite(data_sub)])) or 1.0
|
| 58 |
+
|
| 59 |
+
try:
|
| 60 |
+
from photutils.detection import DAOStarFinder
|
| 61 |
+
from photutils.aperture import CircularAperture, aperture_photometry
|
| 62 |
+
|
| 63 |
+
# Estimate typical FWHM from bright sources
|
| 64 |
+
fwhm_est = _estimate_fwhm(data_sub, bkg_rms, thresh_hi, fwhm_lo, fwhm_hi)
|
| 65 |
+
|
| 66 |
+
# Low-threshold pass for asteroid candidates
|
| 67 |
+
finder = DAOStarFinder(
|
| 68 |
+
fwhm=fwhm_est,
|
| 69 |
+
threshold=thresh_lo * rms_median,
|
| 70 |
+
sharplo=0.2, sharphi=1.0,
|
| 71 |
+
roundlo=-1.0, roundhi=1.0,
|
| 72 |
+
exclude_border=True,
|
| 73 |
+
)
|
| 74 |
+
# Replace NaN with 0 for detection only
|
| 75 |
+
data_clean = np.nan_to_num(data_sub, nan=0.0)
|
| 76 |
+
sources = finder(data_clean)
|
| 77 |
+
|
| 78 |
+
if sources is None or len(sources) == 0:
|
| 79 |
+
logger.info("No sources detected (threshold=%.1fσ)", thresh_lo)
|
| 80 |
+
return _empty_catalog()
|
| 81 |
+
|
| 82 |
+
# Aperture photometry
|
| 83 |
+
positions = np.column_stack([sources["xcentroid"], sources["ycentroid"]])
|
| 84 |
+
ap = CircularAperture(positions, r=fwhm_est)
|
| 85 |
+
phot = aperture_photometry(data_sub, ap, error=bkg_rms)
|
| 86 |
+
|
| 87 |
+
flux = np.array(phot["aperture_sum"], dtype=float)
|
| 88 |
+
flux_err = np.array(phot["aperture_sum_err"], dtype=float) if "aperture_sum_err" in phot.colnames else np.full(len(flux), rms_median * np.sqrt(np.pi * fwhm_est**2))
|
| 89 |
+
flux = np.maximum(flux, 0.0)
|
| 90 |
+
|
| 91 |
+
snr = np.where(flux_err > 0, flux / flux_err, 0.0)
|
| 92 |
+
|
| 93 |
+
# Sky coordinates via WCS
|
| 94 |
+
sky = wcs.pixel_to_world(sources["xcentroid"], sources["ycentroid"])
|
| 95 |
+
ra = np.atleast_1d(sky.icrs.ra.deg)
|
| 96 |
+
dec = np.atleast_1d(sky.icrs.dec.deg)
|
| 97 |
+
|
| 98 |
+
# Magnitude (relative, no ZP needed for detection)
|
| 99 |
+
with np.errstate(invalid="ignore", divide="ignore"):
|
| 100 |
+
mag = np.where(flux > 0, -2.5 * np.log10(flux), 99.0)
|
| 101 |
+
|
| 102 |
+
catalog = Table({
|
| 103 |
+
"x_pixel": np.array(sources["xcentroid"]),
|
| 104 |
+
"y_pixel": np.array(sources["ycentroid"]),
|
| 105 |
+
"ra_deg": ra,
|
| 106 |
+
"dec_deg": dec,
|
| 107 |
+
"flux_adu": flux,
|
| 108 |
+
"flux_err": flux_err,
|
| 109 |
+
"mag": mag,
|
| 110 |
+
"snr": snr,
|
| 111 |
+
"fwhm": np.full(len(flux), fwhm_est),
|
| 112 |
+
"roundness": np.array(sources["roundness1"]),
|
| 113 |
+
"sharpness": np.array(sources["sharpness"]),
|
| 114 |
+
})
|
| 115 |
+
|
| 116 |
+
logger.info("Extracted %d sources (fwhm=%.2fpx, threshold=%.1fσ)",
|
| 117 |
+
len(catalog), fwhm_est, thresh_lo)
|
| 118 |
+
return catalog
|
| 119 |
+
|
| 120 |
+
except ImportError:
|
| 121 |
+
logger.warning("photutils not available — falling back to sigma-clip peak finder")
|
| 122 |
+
return _fallback_extract(data_sub, bkg_rms, wcs, thresh_lo)
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
def _estimate_fwhm(data, bkg_rms, thresh, lo, hi):
|
| 126 |
+
"""Estimate FWHM from bright sources, clamped to [lo, hi]."""
|
| 127 |
+
try:
|
| 128 |
+
from photutils.detection import DAOStarFinder
|
| 129 |
+
rms = float(np.nanmedian(bkg_rms))
|
| 130 |
+
finder = DAOStarFinder(fwhm=3.5, threshold=thresh * rms,
|
| 131 |
+
sharplo=0.3, sharphi=0.9,
|
| 132 |
+
roundlo=-0.5, roundhi=0.5,
|
| 133 |
+
exclude_border=True)
|
| 134 |
+
data_clean = np.nan_to_num(data, nan=0.0)
|
| 135 |
+
sources = finder(data_clean)
|
| 136 |
+
if sources and len(sources) > 5:
|
| 137 |
+
fwhm = float(np.median(sources["fwhm"]))
|
| 138 |
+
return float(np.clip(fwhm, lo, hi))
|
| 139 |
+
except Exception:
|
| 140 |
+
pass
|
| 141 |
+
return 3.5 # default
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def _empty_catalog() -> Table:
|
| 145 |
+
return Table({c: [] for c in _CATALOG_COLS})
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def _fallback_extract(data, bkg_rms, wcs, thresh):
|
| 149 |
+
"""Simple connected-component fallback when photutils absent."""
|
| 150 |
+
try:
|
| 151 |
+
from scipy.ndimage import label, center_of_mass
|
| 152 |
+
rms = float(np.nanmedian(bkg_rms))
|
| 153 |
+
binary = np.nan_to_num(data) > thresh * rms
|
| 154 |
+
labeled, n = label(binary)
|
| 155 |
+
if n == 0:
|
| 156 |
+
return _empty_catalog()
|
| 157 |
+
indices = list(range(1, n + 1))
|
| 158 |
+
coms = center_of_mass(data, labeled, indices)
|
| 159 |
+
xs = np.array([c[1] for c in coms])
|
| 160 |
+
ys = np.array([c[0] for c in coms])
|
| 161 |
+
sky = wcs.pixel_to_world(xs, ys)
|
| 162 |
+
ra = np.atleast_1d(sky.icrs.ra.deg)
|
| 163 |
+
dec = np.atleast_1d(sky.icrs.dec.deg)
|
| 164 |
+
flux = np.array([float(np.nansum(data[labeled == i])) for i in indices])
|
| 165 |
+
flux = np.maximum(flux, 0.0)
|
| 166 |
+
return Table({
|
| 167 |
+
"x_pixel": xs, "y_pixel": ys,
|
| 168 |
+
"ra_deg": ra, "dec_deg": dec,
|
| 169 |
+
"flux_adu": flux, "flux_err": np.full(n, rms),
|
| 170 |
+
"mag": np.where(flux > 0, -2.5 * np.log10(np.maximum(flux, 1e-10)), 99.0),
|
| 171 |
+
"snr": flux / rms,
|
| 172 |
+
"fwhm": np.full(n, 3.5),
|
| 173 |
+
"roundness": np.zeros(n),
|
| 174 |
+
"sharpness": np.zeros(n),
|
| 175 |
+
})
|
| 176 |
+
except Exception as exc:
|
| 177 |
+
logger.error("Fallback extraction failed: %s", exc)
|
| 178 |
+
return _empty_catalog()
|
asteroidnet/tracklet_linker/__init__.py
ADDED
|
File without changes
|
asteroidnet/tracklet_linker/linker.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET Tracklet Linker (tracklet_linker.linker)
|
| 3 |
+
|
| 4 |
+
Links detections across frames into tracklets using:
|
| 5 |
+
1. Hough-transform pair generation (velocity-direction clustering)
|
| 6 |
+
2. KD-tree spatial extension to ≥3 frames
|
| 7 |
+
3. Kinematic validation (velocity, residuals, time span)
|
| 8 |
+
"""
|
| 9 |
+
from __future__ import annotations
|
| 10 |
+
import logging, math
|
| 11 |
+
from dataclasses import dataclass, field
|
| 12 |
+
from typing import Optional
|
| 13 |
+
|
| 14 |
+
import numpy as np
|
| 15 |
+
from astropy.table import Table
|
| 16 |
+
from astropy.time import Time
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class Tracklet:
|
| 23 |
+
detections: list[dict] # sorted by time
|
| 24 |
+
velocity_ra_arcsec_s: float
|
| 25 |
+
velocity_dec_arcsec_s: float
|
| 26 |
+
velocity_arcsec_s: float
|
| 27 |
+
position_angle_deg: float
|
| 28 |
+
rms_residual_arcsec: float
|
| 29 |
+
time_span_min: float
|
| 30 |
+
frame_ids: list[int]
|
| 31 |
+
score: float = 0.0
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def link_tracklets(
|
| 35 |
+
catalogs: list[Table],
|
| 36 |
+
times: list[Time],
|
| 37 |
+
config: Optional[dict] = None,
|
| 38 |
+
) -> list[Tracklet]:
|
| 39 |
+
"""
|
| 40 |
+
Link multi-frame detections into moving-object tracklets.
|
| 41 |
+
|
| 42 |
+
Parameters
|
| 43 |
+
----------
|
| 44 |
+
catalogs : list[Table]
|
| 45 |
+
One source catalog per frame (from catalog_matcher output).
|
| 46 |
+
times : list[Time]
|
| 47 |
+
Observation times (UTC, mid-exposure) for each frame.
|
| 48 |
+
|
| 49 |
+
Returns
|
| 50 |
+
-------
|
| 51 |
+
list[Tracklet]
|
| 52 |
+
Validated tracklets sorted by score (descending).
|
| 53 |
+
"""
|
| 54 |
+
if len(catalogs) < 2:
|
| 55 |
+
logger.warning("Need ≥2 frames for tracklet linking")
|
| 56 |
+
return []
|
| 57 |
+
|
| 58 |
+
cfg = (config or {}).get("tracking", {})
|
| 59 |
+
min_dets = int(cfg.get("min_detections", 3))
|
| 60 |
+
vel_min = float(cfg.get("velocity_range_arcsec_s", [0.01, 10.0])[0])
|
| 61 |
+
vel_max = float(cfg.get("velocity_range_arcsec_s", [0.01, 10.0])[1])
|
| 62 |
+
pos_tol = float(cfg.get("position_tolerance_arcsec", 3.0))
|
| 63 |
+
max_resid = float(cfg.get("max_motion_residual_arcsec", 1.0))
|
| 64 |
+
min_tspan = float(cfg.get("min_time_span_minutes", 30.0))
|
| 65 |
+
|
| 66 |
+
# Convert catalogs to detection lists with time info
|
| 67 |
+
frame_dets = []
|
| 68 |
+
for i, (cat, t) in enumerate(zip(catalogs, times)):
|
| 69 |
+
dets = []
|
| 70 |
+
for row in cat:
|
| 71 |
+
dets.append({
|
| 72 |
+
"frame_id": i,
|
| 73 |
+
"ra": float(row["ra_deg"]),
|
| 74 |
+
"dec": float(row["dec_deg"]),
|
| 75 |
+
"x": float(row["x_pixel"]),
|
| 76 |
+
"y": float(row["y_pixel"]),
|
| 77 |
+
"snr": float(row["snr"]),
|
| 78 |
+
"mag": float(row["mag"]),
|
| 79 |
+
"time_unix": t.unix,
|
| 80 |
+
})
|
| 81 |
+
frame_dets.append(dets)
|
| 82 |
+
|
| 83 |
+
# Generate seed pairs between frame 0 and frame 1
|
| 84 |
+
seeds = _generate_pairs(frame_dets[0], frame_dets[1], vel_min, vel_max)
|
| 85 |
+
logger.debug("Generated %d seed pairs from frames 0→1", len(seeds))
|
| 86 |
+
|
| 87 |
+
# Extend each seed to remaining frames
|
| 88 |
+
tracklets: list[Tracklet] = []
|
| 89 |
+
for seed in seeds:
|
| 90 |
+
extended = _extend_tracklet(seed, frame_dets[2:], pos_tol)
|
| 91 |
+
if len(extended) < min_dets:
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
t = _build_tracklet(extended)
|
| 95 |
+
if t is None:
|
| 96 |
+
continue
|
| 97 |
+
|
| 98 |
+
# Validate
|
| 99 |
+
if t.rms_residual_arcsec > max_resid:
|
| 100 |
+
continue
|
| 101 |
+
if t.time_span_min < min_tspan:
|
| 102 |
+
continue
|
| 103 |
+
if not (vel_min <= t.velocity_arcsec_s <= vel_max):
|
| 104 |
+
continue
|
| 105 |
+
|
| 106 |
+
tracklets.append(t)
|
| 107 |
+
|
| 108 |
+
# Deduplicate overlapping tracklets (keep highest-score)
|
| 109 |
+
tracklets = _deduplicate(tracklets)
|
| 110 |
+
tracklets.sort(key=lambda x: x.score, reverse=True)
|
| 111 |
+
|
| 112 |
+
logger.info("Linked %d valid tracklets from %d frames", len(tracklets), len(catalogs))
|
| 113 |
+
return tracklets
|
| 114 |
+
|
| 115 |
+
|
| 116 |
+
# ── Private helpers ──────────────────────────────────────────────────────────
|
| 117 |
+
|
| 118 |
+
def _generate_pairs(
|
| 119 |
+
dets0: list[dict],
|
| 120 |
+
dets1: list[dict],
|
| 121 |
+
vel_min: float,
|
| 122 |
+
vel_max: float,
|
| 123 |
+
) -> list[list[dict]]:
|
| 124 |
+
"""Generate all pairs between two frames consistent with velocity bounds."""
|
| 125 |
+
pairs = []
|
| 126 |
+
if not dets0 or not dets1:
|
| 127 |
+
return pairs
|
| 128 |
+
|
| 129 |
+
for d0 in dets0:
|
| 130 |
+
for d1 in dets1:
|
| 131 |
+
dt = abs(d1["time_unix"] - d0["time_unix"])
|
| 132 |
+
if dt < 1.0:
|
| 133 |
+
continue
|
| 134 |
+
dra = (d1["ra"] - d0["ra"]) * 3600.0 * math.cos(math.radians(d0["dec"]))
|
| 135 |
+
ddec = (d1["dec"] - d0["dec"]) * 3600.0
|
| 136 |
+
dist = math.sqrt(dra**2 + ddec**2)
|
| 137 |
+
vel = dist / dt
|
| 138 |
+
if vel_min <= vel <= vel_max:
|
| 139 |
+
pairs.append([d0, d1])
|
| 140 |
+
return pairs
|
| 141 |
+
|
| 142 |
+
|
| 143 |
+
def _extend_tracklet(
|
| 144 |
+
seed: list[dict],
|
| 145 |
+
remaining_frames: list[list[dict]],
|
| 146 |
+
pos_tol_arcsec: float,
|
| 147 |
+
) -> list[dict]:
|
| 148 |
+
"""Extend a seed pair to additional frames using linear prediction."""
|
| 149 |
+
if len(seed) < 2:
|
| 150 |
+
return seed
|
| 151 |
+
|
| 152 |
+
extended = list(seed)
|
| 153 |
+
|
| 154 |
+
for frame_dets in remaining_frames:
|
| 155 |
+
if not frame_dets:
|
| 156 |
+
continue
|
| 157 |
+
# Predict position via linear extrapolation
|
| 158 |
+
t0, t1 = extended[-2]["time_unix"], extended[-1]["time_unix"]
|
| 159 |
+
dt = t1 - t0
|
| 160 |
+
if abs(dt) < 1.0:
|
| 161 |
+
continue
|
| 162 |
+
vra = (extended[-1]["ra"] - extended[-2]["ra"]) / dt
|
| 163 |
+
vdec = (extended[-1]["dec"] - extended[-2]["dec"]) / dt
|
| 164 |
+
|
| 165 |
+
t_pred = frame_dets[0]["time_unix"]
|
| 166 |
+
ra_pred = extended[-1]["ra"] + vra * (t_pred - t1)
|
| 167 |
+
dec_pred = extended[-1]["dec"] + vdec * (t_pred - t1)
|
| 168 |
+
|
| 169 |
+
# Find closest detection within tolerance
|
| 170 |
+
best_sep = pos_tol_arcsec / 3600.0
|
| 171 |
+
best_det = None
|
| 172 |
+
for d in frame_dets:
|
| 173 |
+
sep = math.sqrt(
|
| 174 |
+
((d["ra"] - ra_pred) * math.cos(math.radians(dec_pred)))**2
|
| 175 |
+
+ (d["dec"] - dec_pred)**2
|
| 176 |
+
)
|
| 177 |
+
if sep < best_sep:
|
| 178 |
+
best_sep = sep
|
| 179 |
+
best_det = d
|
| 180 |
+
|
| 181 |
+
if best_det is not None:
|
| 182 |
+
extended.append(best_det)
|
| 183 |
+
|
| 184 |
+
return extended
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def _build_tracklet(dets: list[dict]) -> Optional[Tracklet]:
|
| 188 |
+
"""Fit a linear motion model and compute tracklet statistics."""
|
| 189 |
+
if len(dets) < 2:
|
| 190 |
+
return None
|
| 191 |
+
|
| 192 |
+
dets = sorted(dets, key=lambda d: d["time_unix"])
|
| 193 |
+
times = np.array([d["time_unix"] for d in dets])
|
| 194 |
+
ras = np.array([d["ra"] for d in dets])
|
| 195 |
+
decs = np.array([d["dec"] for d in dets])
|
| 196 |
+
cos_dec = math.cos(math.radians(float(np.mean(decs))))
|
| 197 |
+
|
| 198 |
+
# Linear fit
|
| 199 |
+
dt = times - times[0]
|
| 200 |
+
try:
|
| 201 |
+
pra = np.polyfit(dt, ras, 1)
|
| 202 |
+
pdec = np.polyfit(dt, decs, 1)
|
| 203 |
+
except Exception:
|
| 204 |
+
return None
|
| 205 |
+
|
| 206 |
+
ra_fit = np.polyval(pra, dt)
|
| 207 |
+
dec_fit = np.polyval(pdec, dt)
|
| 208 |
+
|
| 209 |
+
res_arcsec = np.sqrt(
|
| 210 |
+
((ras - ra_fit) * cos_dec)**2 + (decs - dec_fit)**2
|
| 211 |
+
) * 3600.0
|
| 212 |
+
rms = float(np.sqrt(np.mean(res_arcsec**2)))
|
| 213 |
+
|
| 214 |
+
vel_ra_s = float(pra[0]) * cos_dec * 3600.0 # arcsec/s
|
| 215 |
+
vel_dec_s = float(pdec[0]) * 3600.0 # arcsec/s
|
| 216 |
+
vel = math.sqrt(vel_ra_s**2 + vel_dec_s**2)
|
| 217 |
+
pa = math.degrees(math.atan2(vel_ra_s, vel_dec_s)) % 360.0
|
| 218 |
+
tspan_min = float(times[-1] - times[0]) / 60.0
|
| 219 |
+
|
| 220 |
+
# Score: higher SNR, longer arc, lower residuals
|
| 221 |
+
mean_snr = float(np.mean([d["snr"] for d in dets]))
|
| 222 |
+
score = mean_snr * len(dets) * tspan_min / max(rms, 0.1)
|
| 223 |
+
|
| 224 |
+
return Tracklet(
|
| 225 |
+
detections=dets,
|
| 226 |
+
velocity_ra_arcsec_s=vel_ra_s,
|
| 227 |
+
velocity_dec_arcsec_s=vel_dec_s,
|
| 228 |
+
velocity_arcsec_s=vel,
|
| 229 |
+
position_angle_deg=pa,
|
| 230 |
+
rms_residual_arcsec=rms,
|
| 231 |
+
time_span_min=tspan_min,
|
| 232 |
+
frame_ids=[d["frame_id"] for d in dets],
|
| 233 |
+
score=score,
|
| 234 |
+
)
|
| 235 |
+
|
| 236 |
+
|
| 237 |
+
def _deduplicate(tracklets: list[Tracklet]) -> list[Tracklet]:
|
| 238 |
+
"""Remove tracklets sharing >50% of detections with a higher-score one."""
|
| 239 |
+
kept: list[Tracklet] = []
|
| 240 |
+
sorted_t = sorted(tracklets, key=lambda t: t.score, reverse=True)
|
| 241 |
+
used_positions: set[tuple] = set()
|
| 242 |
+
|
| 243 |
+
for t in sorted_t:
|
| 244 |
+
positions = {(d["frame_id"], round(d["ra"], 5), round(d["dec"], 5))
|
| 245 |
+
for d in t.detections}
|
| 246 |
+
overlap = len(positions & used_positions)
|
| 247 |
+
if overlap <= len(positions) * 0.5:
|
| 248 |
+
kept.append(t)
|
| 249 |
+
used_positions |= positions
|
| 250 |
+
|
| 251 |
+
return kept
|
asteroidnet/training/__init__.py
ADDED
|
File without changes
|
asteroidnet/training/dataset_builder.py
ADDED
|
@@ -0,0 +1,349 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET Training Dataset Builder (training.dataset_builder)
|
| 3 |
+
|
| 4 |
+
Mines Pan-STARRS and ZTF archives to create labeled asteroid detection
|
| 5 |
+
training data using confirmed MPC objects as ground truth.
|
| 6 |
+
|
| 7 |
+
Methodology (from research):
|
| 8 |
+
1. Select sky fields and time ranges
|
| 9 |
+
2. Query SkyBoT to find all known SSOs in each field at each epoch
|
| 10 |
+
3. Query JPL Horizons for high-precision positions of found SSOs
|
| 11 |
+
4. Download FITS frames covering those fields
|
| 12 |
+
5. Project ephemeris positions → pixel coordinates via WCS
|
| 13 |
+
6. Extract 63×63 px cutouts centered on confirmed positions (positives)
|
| 14 |
+
7. Extract random cutouts away from known objects (negatives)
|
| 15 |
+
8. Save as .npz training dataset
|
| 16 |
+
|
| 17 |
+
This approach mirrors how ALeRCE built their stamp classifier training set
|
| 18 |
+
from ZTF alerts cross-matched with the MPC.
|
| 19 |
+
"""
|
| 20 |
+
from __future__ import annotations
|
| 21 |
+
|
| 22 |
+
import logging
|
| 23 |
+
import hashlib
|
| 24 |
+
from dataclasses import dataclass, field
|
| 25 |
+
from pathlib import Path
|
| 26 |
+
from typing import Optional
|
| 27 |
+
|
| 28 |
+
import numpy as np
|
| 29 |
+
from astropy.coordinates import SkyCoord
|
| 30 |
+
from astropy.time import Time
|
| 31 |
+
import astropy.units as u
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger(__name__)
|
| 34 |
+
|
| 35 |
+
CUTOUT_SIZE = 63 # ALeRCE-compatible stamp size
|
| 36 |
+
NEG_PER_POS = 3 # negative examples per positive
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
@dataclass
|
| 40 |
+
class TrainingExample:
|
| 41 |
+
cutout: np.ndarray # shape (CUTOUT_SIZE, CUTOUT_SIZE)
|
| 42 |
+
label: int # 1 = asteroid, 0 = non-asteroid
|
| 43 |
+
snr: float
|
| 44 |
+
velocity_arcsec_s: float # 0 for negatives
|
| 45 |
+
source_survey: str
|
| 46 |
+
field_ra: float
|
| 47 |
+
field_dec: float
|
| 48 |
+
epoch: str
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def build_training_dataset(
|
| 52 |
+
sky_fields: list[tuple[float, float]],
|
| 53 |
+
date_range: tuple[str, str],
|
| 54 |
+
output_path: str | Path,
|
| 55 |
+
surveys: list[str] = ("ps1", "ztf"),
|
| 56 |
+
neg_per_pos: int = NEG_PER_POS,
|
| 57 |
+
config: Optional[dict] = None,
|
| 58 |
+
) -> Path:
|
| 59 |
+
"""
|
| 60 |
+
Build a labeled training dataset from archival survey data.
|
| 61 |
+
|
| 62 |
+
Parameters
|
| 63 |
+
----------
|
| 64 |
+
sky_fields : list of (ra, dec) tuples in degrees
|
| 65 |
+
Sky fields to mine. Random high-galactic-latitude positions work well.
|
| 66 |
+
date_range : (start_iso, end_iso)
|
| 67 |
+
Date range for archival frames (e.g. ('2023-01-01', '2024-06-01')).
|
| 68 |
+
output_path : Path
|
| 69 |
+
Where to save the .npz dataset.
|
| 70 |
+
surveys : list
|
| 71 |
+
Which surveys to use: 'ps1', 'ztf'.
|
| 72 |
+
neg_per_pos : int
|
| 73 |
+
Number of negative examples to generate per positive.
|
| 74 |
+
config : dict, optional
|
| 75 |
+
Pipeline configuration.
|
| 76 |
+
|
| 77 |
+
Returns
|
| 78 |
+
-------
|
| 79 |
+
Path
|
| 80 |
+
Path to the saved .npz dataset.
|
| 81 |
+
"""
|
| 82 |
+
output_path = Path(output_path)
|
| 83 |
+
output_path.parent.mkdir(parents=True, exist_ok=True)
|
| 84 |
+
|
| 85 |
+
examples: list[TrainingExample] = []
|
| 86 |
+
|
| 87 |
+
for ra, dec in sky_fields:
|
| 88 |
+
for survey in surveys:
|
| 89 |
+
try:
|
| 90 |
+
field_examples = _mine_field(
|
| 91 |
+
ra, dec, survey, date_range, neg_per_pos, config
|
| 92 |
+
)
|
| 93 |
+
examples.extend(field_examples)
|
| 94 |
+
logger.info(
|
| 95 |
+
"Field (%.2f, %.2f) survey=%s: +%d examples (total %d)",
|
| 96 |
+
ra, dec, survey, len(field_examples), len(examples)
|
| 97 |
+
)
|
| 98 |
+
except Exception as exc:
|
| 99 |
+
logger.warning("Failed to mine field (%.2f, %.2f) %s: %s", ra, dec, survey, exc)
|
| 100 |
+
|
| 101 |
+
if not examples:
|
| 102 |
+
logger.error("No training examples collected")
|
| 103 |
+
return output_path
|
| 104 |
+
|
| 105 |
+
# Save as compressed numpy archive
|
| 106 |
+
cutouts = np.stack([e.cutout for e in examples])
|
| 107 |
+
labels = np.array([e.label for e in examples], dtype=np.int8)
|
| 108 |
+
snrs = np.array([e.snr for e in examples], dtype=np.float32)
|
| 109 |
+
vels = np.array([e.velocity_arcsec_s for e in examples], dtype=np.float32)
|
| 110 |
+
surveys_ = np.array([e.source_survey for e in examples])
|
| 111 |
+
|
| 112 |
+
np.savez_compressed(
|
| 113 |
+
output_path,
|
| 114 |
+
cutouts=cutouts,
|
| 115 |
+
labels=labels,
|
| 116 |
+
snr=snrs,
|
| 117 |
+
velocity=vels,
|
| 118 |
+
survey=surveys_,
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
n_pos = int(labels.sum())
|
| 122 |
+
n_neg = len(labels) - n_pos
|
| 123 |
+
logger.info(
|
| 124 |
+
"Dataset saved: %d examples (%d positive, %d negative) → %s",
|
| 125 |
+
len(examples), n_pos, n_neg, output_path
|
| 126 |
+
)
|
| 127 |
+
return output_path
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
def _mine_field(
|
| 131 |
+
ra: float,
|
| 132 |
+
dec: float,
|
| 133 |
+
survey: str,
|
| 134 |
+
date_range: tuple[str, str],
|
| 135 |
+
neg_per_pos: int,
|
| 136 |
+
config: Optional[dict],
|
| 137 |
+
) -> list[TrainingExample]:
|
| 138 |
+
"""Mine a single sky field for labeled training examples."""
|
| 139 |
+
from asteroidnet.fits_ingestor.ingestor import ingest_fits_file
|
| 140 |
+
from asteroidnet.image_preprocessor.preprocessor import preprocess_frame, align_frames
|
| 141 |
+
|
| 142 |
+
# Download FITS frames
|
| 143 |
+
frames_raw = _download_frames(ra, dec, survey, date_range, config)
|
| 144 |
+
if not frames_raw:
|
| 145 |
+
return []
|
| 146 |
+
|
| 147 |
+
# Ingest + preprocess
|
| 148 |
+
fits_frames = []
|
| 149 |
+
data_subs = []
|
| 150 |
+
bkg_rmss = []
|
| 151 |
+
for frame_data, frame_header, frame_time, frame_survey in frames_raw:
|
| 152 |
+
# Build a minimal FITSFrame-like object
|
| 153 |
+
from asteroidnet.fits_ingestor.ingestor import FITSFrame
|
| 154 |
+
from astropy.wcs import WCS
|
| 155 |
+
import warnings
|
| 156 |
+
from astropy.wcs import FITSFixedWarning
|
| 157 |
+
with warnings.catch_warnings():
|
| 158 |
+
warnings.simplefilter("ignore", FITSFixedWarning)
|
| 159 |
+
wcs = WCS(frame_header, naxis=2)
|
| 160 |
+
ff = FITSFrame(
|
| 161 |
+
path=Path(f"synthetic_{ra:.2f}_{dec:.2f}"),
|
| 162 |
+
data=frame_data,
|
| 163 |
+
header=frame_header,
|
| 164 |
+
wcs=wcs,
|
| 165 |
+
obs_time=frame_time,
|
| 166 |
+
exptime_s=float(frame_header.get("EXPTIME", 30.0)),
|
| 167 |
+
filter_band=str(frame_header.get("FILTER", "r")),
|
| 168 |
+
telescope=str(frame_header.get("TELESCOP", "?")),
|
| 169 |
+
instrument=str(frame_header.get("INSTRUME", "?")),
|
| 170 |
+
survey=frame_survey,
|
| 171 |
+
pixel_scale_arcsec=0.25 if frame_survey == "ps1" else 1.012,
|
| 172 |
+
)
|
| 173 |
+
from asteroidnet.image_preprocessor.preprocessor import preprocess_frame
|
| 174 |
+
data_sub, bkg_rms = preprocess_frame(ff, config)
|
| 175 |
+
fits_frames.append(ff)
|
| 176 |
+
data_subs.append(data_sub)
|
| 177 |
+
bkg_rmss.append(bkg_rms)
|
| 178 |
+
|
| 179 |
+
if not fits_frames:
|
| 180 |
+
return []
|
| 181 |
+
|
| 182 |
+
# Query SkyBoT for known SSOs at each epoch
|
| 183 |
+
examples = []
|
| 184 |
+
rng = np.random.default_rng(
|
| 185 |
+
seed=int(hashlib.md5(f"{ra}{dec}{survey}".encode()).hexdigest()[:8], 16)
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
for ff, data_sub, bkg_rms in zip(fits_frames, data_subs, bkg_rmss):
|
| 189 |
+
positives = _extract_positive_cutouts(ff, data_sub, bkg_rms, config)
|
| 190 |
+
negatives = _extract_negative_cutouts(
|
| 191 |
+
ff, data_sub, bkg_rms, positives, neg_per_pos, rng
|
| 192 |
+
)
|
| 193 |
+
examples.extend(positives)
|
| 194 |
+
examples.extend(negatives)
|
| 195 |
+
|
| 196 |
+
return examples
|
| 197 |
+
|
| 198 |
+
|
| 199 |
+
def _download_frames(
|
| 200 |
+
ra: float,
|
| 201 |
+
dec: float,
|
| 202 |
+
survey: str,
|
| 203 |
+
date_range: tuple[str, str],
|
| 204 |
+
config: Optional[dict],
|
| 205 |
+
) -> list:
|
| 206 |
+
"""Download raw FITS data for a field. Returns list of (data, header, time, survey)."""
|
| 207 |
+
results = []
|
| 208 |
+
try:
|
| 209 |
+
if survey == "ps1":
|
| 210 |
+
from asteroidnet.data_access.ps1_client import get_ps1_warp_list, download_ps1_cutout
|
| 211 |
+
warps = get_ps1_warp_list(ra, dec, filter_band="r", config=config)
|
| 212 |
+
if len(warps) == 0:
|
| 213 |
+
return []
|
| 214 |
+
# Sample a few frames
|
| 215 |
+
for row in warps[:4]:
|
| 216 |
+
frame = download_ps1_cutout(ra, dec, row["filename"], size_pixels=300, config=config)
|
| 217 |
+
if frame:
|
| 218 |
+
results.append((frame.data, frame.header, frame.obs_time, "ps1"))
|
| 219 |
+
|
| 220 |
+
elif survey == "ztf":
|
| 221 |
+
from asteroidnet.data_access.ztf_client import search_ztf_images, download_ztf_cutout
|
| 222 |
+
rows = search_ztf_images(ra, dec, filter_band="r", config=config)
|
| 223 |
+
for row in rows[:4]:
|
| 224 |
+
frame = download_ztf_cutout(ra, dec, row, size_arcsec=180, config=config)
|
| 225 |
+
if frame:
|
| 226 |
+
results.append((frame.data, frame.header, frame.obs_time, "ztf"))
|
| 227 |
+
|
| 228 |
+
except Exception as exc:
|
| 229 |
+
logger.warning("Frame download failed for (%s %.2f %.2f): %s", survey, ra, dec, exc)
|
| 230 |
+
|
| 231 |
+
return results
|
| 232 |
+
|
| 233 |
+
|
| 234 |
+
def _extract_positive_cutouts(
|
| 235 |
+
ff,
|
| 236 |
+
data_sub: np.ndarray,
|
| 237 |
+
bkg_rms: np.ndarray,
|
| 238 |
+
config: Optional[dict],
|
| 239 |
+
) -> list[TrainingExample]:
|
| 240 |
+
"""Find known SSOs in this frame and extract cutouts around them."""
|
| 241 |
+
examples = []
|
| 242 |
+
try:
|
| 243 |
+
from asteroidnet.data_access.skybot_client import query_skybot, skybot_table_to_skycoord
|
| 244 |
+
center = SkyCoord(ra=ff.ra_center if hasattr(ff, "ra_center") else 180.0,
|
| 245 |
+
dec=ff.dec_center if hasattr(ff, "dec_center") else 0.0,
|
| 246 |
+
unit=u.deg)
|
| 247 |
+
table = query_skybot(center, 15*u.arcmin, ff.obs_time,
|
| 248 |
+
observer="F51" if ff.survey == "ps1" else "I41",
|
| 249 |
+
config=config)
|
| 250 |
+
if len(table) == 0:
|
| 251 |
+
return []
|
| 252 |
+
|
| 253 |
+
sso_coords = skybot_table_to_skycoord(table)
|
| 254 |
+
if sso_coords is None:
|
| 255 |
+
return []
|
| 256 |
+
|
| 257 |
+
for coord in sso_coords:
|
| 258 |
+
try:
|
| 259 |
+
x, y = ff.wcs.world_to_pixel(coord)
|
| 260 |
+
x, y = int(round(float(x))), int(round(float(y)))
|
| 261 |
+
cutout = _extract_cutout(data_sub, x, y, CUTOUT_SIZE)
|
| 262 |
+
if cutout is None:
|
| 263 |
+
continue
|
| 264 |
+
|
| 265 |
+
snr_val = float(np.nanmax(cutout) / (np.nanmedian(bkg_rms) + 1e-10))
|
| 266 |
+
examples.append(TrainingExample(
|
| 267 |
+
cutout=cutout,
|
| 268 |
+
label=1,
|
| 269 |
+
snr=max(snr_val, 0.0),
|
| 270 |
+
velocity_arcsec_s=0.5, # approximate
|
| 271 |
+
source_survey=ff.survey,
|
| 272 |
+
field_ra=float(coord.ra.deg),
|
| 273 |
+
field_dec=float(coord.dec.deg),
|
| 274 |
+
epoch=ff.obs_time.isot,
|
| 275 |
+
))
|
| 276 |
+
except Exception:
|
| 277 |
+
continue
|
| 278 |
+
|
| 279 |
+
except Exception as exc:
|
| 280 |
+
logger.debug("Positive cutout extraction failed: %s", exc)
|
| 281 |
+
|
| 282 |
+
return examples
|
| 283 |
+
|
| 284 |
+
|
| 285 |
+
def _extract_negative_cutouts(
|
| 286 |
+
ff,
|
| 287 |
+
data_sub: np.ndarray,
|
| 288 |
+
bkg_rms: np.ndarray,
|
| 289 |
+
positives: list[TrainingExample],
|
| 290 |
+
n: int,
|
| 291 |
+
rng: np.random.Generator,
|
| 292 |
+
) -> list[TrainingExample]:
|
| 293 |
+
"""Extract random cutouts avoiding known object positions."""
|
| 294 |
+
h, w = data_sub.shape
|
| 295 |
+
half = CUTOUT_SIZE // 2
|
| 296 |
+
examples = []
|
| 297 |
+
attempts = 0
|
| 298 |
+
|
| 299 |
+
while len(examples) < n * len(positives) and attempts < 1000:
|
| 300 |
+
attempts += 1
|
| 301 |
+
x = int(rng.integers(half, w - half))
|
| 302 |
+
y = int(rng.integers(half, h - half))
|
| 303 |
+
|
| 304 |
+
cutout = _extract_cutout(data_sub, x, y, CUTOUT_SIZE)
|
| 305 |
+
if cutout is None:
|
| 306 |
+
continue
|
| 307 |
+
if np.isnan(cutout).mean() > 0.3:
|
| 308 |
+
continue
|
| 309 |
+
|
| 310 |
+
examples.append(TrainingExample(
|
| 311 |
+
cutout=cutout,
|
| 312 |
+
label=0,
|
| 313 |
+
snr=0.0,
|
| 314 |
+
velocity_arcsec_s=0.0,
|
| 315 |
+
source_survey=ff.survey,
|
| 316 |
+
field_ra=0.0,
|
| 317 |
+
field_dec=0.0,
|
| 318 |
+
epoch=ff.obs_time.isot,
|
| 319 |
+
))
|
| 320 |
+
|
| 321 |
+
return examples
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _extract_cutout(
|
| 325 |
+
data: np.ndarray,
|
| 326 |
+
x: int,
|
| 327 |
+
y: int,
|
| 328 |
+
size: int,
|
| 329 |
+
) -> Optional[np.ndarray]:
|
| 330 |
+
"""Extract a square cutout, normalizing to [0,1]. Returns None if out of bounds."""
|
| 331 |
+
half = size // 2
|
| 332 |
+
h, w = data.shape
|
| 333 |
+
if x - half < 0 or y - half < 0 or x + half >= w or y + half >= h:
|
| 334 |
+
return None
|
| 335 |
+
cutout = data[y - half:y + half + 1, x - half:x + half + 1].copy()
|
| 336 |
+
if cutout.shape != (size, size):
|
| 337 |
+
return None
|
| 338 |
+
# Normalize: subtract median, divide by MAD
|
| 339 |
+
finite = cutout[np.isfinite(cutout)]
|
| 340 |
+
if len(finite) == 0:
|
| 341 |
+
return None
|
| 342 |
+
med = np.median(finite)
|
| 343 |
+
mad = np.median(np.abs(finite - med))
|
| 344 |
+
if mad < 1e-10:
|
| 345 |
+
mad = 1.0
|
| 346 |
+
cutout = (cutout - med) / (3.0 * mad)
|
| 347 |
+
cutout = np.clip(cutout, -3.0, 3.0)
|
| 348 |
+
cutout = np.nan_to_num(cutout, nan=0.0).astype(np.float32)
|
| 349 |
+
return cutout
|
asteroidnet/utils/__init__.py
ADDED
|
File without changes
|
asteroidnet/utils/synthetic.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""AsteroidNET Synthetic FITS generator for testing."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import math, warnings
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from typing import Optional
|
| 6 |
+
|
| 7 |
+
import numpy as np
|
| 8 |
+
from astropy.io import fits
|
| 9 |
+
from astropy.time import Time
|
| 10 |
+
from astropy.wcs import WCS
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def make_synthetic_fits(
|
| 14 |
+
path: str | Path,
|
| 15 |
+
n_stars: int = 500,
|
| 16 |
+
n_asteroids: int = 3,
|
| 17 |
+
image_size: int = 512,
|
| 18 |
+
obs_time: Optional[Time] = None,
|
| 19 |
+
snr_asteroid: float = 8.0,
|
| 20 |
+
seed: int = 42,
|
| 21 |
+
) -> Path:
|
| 22 |
+
"""Create a synthetic FITS file with stars and planted asteroid-like sources."""
|
| 23 |
+
path = Path(path)
|
| 24 |
+
path.parent.mkdir(parents=True, exist_ok=True)
|
| 25 |
+
rng = np.random.default_rng(seed)
|
| 26 |
+
|
| 27 |
+
if obs_time is None:
|
| 28 |
+
obs_time = Time("2024-03-15T04:30:00", scale="utc")
|
| 29 |
+
|
| 30 |
+
# Background + noise
|
| 31 |
+
sky_bg = 1000.0
|
| 32 |
+
readout = 10.0
|
| 33 |
+
data = rng.poisson(sky_bg, size=(image_size, image_size)).astype(np.float32)
|
| 34 |
+
data += rng.normal(0, readout, size=(image_size, image_size)).astype(np.float32)
|
| 35 |
+
|
| 36 |
+
# Plant stars
|
| 37 |
+
for _ in range(n_stars):
|
| 38 |
+
x, y = rng.uniform(10, image_size - 10, 2)
|
| 39 |
+
flux = 10 ** rng.uniform(2, 5)
|
| 40 |
+
_plant_psf(data, x, y, flux, fwhm=3.5)
|
| 41 |
+
|
| 42 |
+
# Plant asteroid-like sources
|
| 43 |
+
ast_positions = []
|
| 44 |
+
for i in range(n_asteroids):
|
| 45 |
+
x, y = rng.uniform(50, image_size - 50, 2)
|
| 46 |
+
noise_rms = math.sqrt(sky_bg + readout**2)
|
| 47 |
+
flux = snr_asteroid * noise_rms * math.pi * 3.5
|
| 48 |
+
_plant_psf(data, x, y, flux, fwhm=3.5)
|
| 49 |
+
ast_positions.append((x, y, flux))
|
| 50 |
+
|
| 51 |
+
# Build WCS (simple TAN at RA=180, Dec=0)
|
| 52 |
+
w = WCS(naxis=2)
|
| 53 |
+
w.wcs.crpix = [image_size / 2, image_size / 2]
|
| 54 |
+
w.wcs.cdelt = [-0.000069444, 0.000069444] # 0.25 arcsec/px in degrees
|
| 55 |
+
w.wcs.crval = [180.0, 0.0]
|
| 56 |
+
w.wcs.ctype = ["RA---TAN", "DEC--TAN"]
|
| 57 |
+
|
| 58 |
+
hdr = w.to_header()
|
| 59 |
+
hdr["DATE-OBS"] = obs_time.utc.isot
|
| 60 |
+
hdr["MJD-OBS"] = obs_time.utc.mjd
|
| 61 |
+
hdr["EXPTIME"] = 30.0
|
| 62 |
+
hdr["FILTER"] = "r"
|
| 63 |
+
hdr["TELESCOP"] = "Pan-STARRS"
|
| 64 |
+
hdr["INSTRUME"] = "GPC1"
|
| 65 |
+
hdr["TIMESYS"] = "TAI"
|
| 66 |
+
hdr["RADESYS"] = "FK5"
|
| 67 |
+
hdr["BKGND"] = sky_bg
|
| 68 |
+
hdr["RDNOISE"] = readout
|
| 69 |
+
hdr["NAST"] = n_asteroids
|
| 70 |
+
|
| 71 |
+
# Store planted positions in header
|
| 72 |
+
for i, (x, y, f) in enumerate(ast_positions):
|
| 73 |
+
hdr[f"ASTX{i:02d}"] = round(x, 2)
|
| 74 |
+
hdr[f"ASTY{i:02d}"] = round(y, 2)
|
| 75 |
+
hdr[f"ASTF{i:02d}"] = round(f, 1)
|
| 76 |
+
|
| 77 |
+
with warnings.catch_warnings():
|
| 78 |
+
warnings.simplefilter("ignore")
|
| 79 |
+
hdul = fits.HDUList([fits.PrimaryHDU(data=data.astype(np.float32), header=hdr)])
|
| 80 |
+
hdul.writeto(path, overwrite=True, output_verify="silentfix")
|
| 81 |
+
|
| 82 |
+
return path
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def make_synthetic_sequence(
|
| 86 |
+
output_dir: str | Path,
|
| 87 |
+
n_frames: int = 4,
|
| 88 |
+
n_stars: int = 500,
|
| 89 |
+
n_asteroids: int = 3,
|
| 90 |
+
image_size: int = 512,
|
| 91 |
+
velocity_arcsec_s: float = 0.3,
|
| 92 |
+
cadence_min: float = 10.0,
|
| 93 |
+
seed: int = 42,
|
| 94 |
+
) -> list[Path]:
|
| 95 |
+
"""Generate a sequence of FITS frames with moving asteroid trails."""
|
| 96 |
+
output_dir = Path(output_dir)
|
| 97 |
+
output_dir.mkdir(parents=True, exist_ok=True)
|
| 98 |
+
rng = np.random.default_rng(seed)
|
| 99 |
+
|
| 100 |
+
t0 = Time("2024-03-15T04:00:00", scale="utc")
|
| 101 |
+
paths = []
|
| 102 |
+
|
| 103 |
+
# Initial asteroid positions
|
| 104 |
+
ast0 = [(float(rng.uniform(80, image_size - 80)),
|
| 105 |
+
float(rng.uniform(80, image_size - 80)))
|
| 106 |
+
for _ in range(n_asteroids)]
|
| 107 |
+
# Random velocities in pixels/s for each asteroid
|
| 108 |
+
pixel_scale = 0.25 # arcsec/px
|
| 109 |
+
vel_pix_s = velocity_arcsec_s / pixel_scale
|
| 110 |
+
ast_vel = [(float(rng.normal(vel_pix_s * 0.7, vel_pix_s * 0.3)),
|
| 111 |
+
float(rng.normal(vel_pix_s * 0.3, vel_pix_s * 0.1)))
|
| 112 |
+
for _ in range(n_asteroids)]
|
| 113 |
+
|
| 114 |
+
for i in range(n_frames):
|
| 115 |
+
# TimeDelta used properly below — this line replaced
|
| 116 |
+
from astropy.time import TimeDelta
|
| 117 |
+
t = t0 + TimeDelta(i * cadence_min * 60, format="sec")
|
| 118 |
+
dt = i * cadence_min * 60 # seconds from t0
|
| 119 |
+
|
| 120 |
+
path = output_dir / f"frame_{i:02d}.fits"
|
| 121 |
+
make_synthetic_fits(path, n_stars, 0, image_size, t, seed=seed + i)
|
| 122 |
+
|
| 123 |
+
# Overwrite with moving asteroids at their updated positions
|
| 124 |
+
with fits.open(path, mode="update") as hdul:
|
| 125 |
+
data = hdul[0].data
|
| 126 |
+
for j, ((x0, y0), (vx, vy)) in enumerate(zip(ast0, ast_vel)):
|
| 127 |
+
x = x0 + vx * dt
|
| 128 |
+
y = y0 + vy * dt
|
| 129 |
+
noise_rms = math.sqrt(1000.0 + 100.0)
|
| 130 |
+
flux = 8.0 * noise_rms * math.pi * 3.5
|
| 131 |
+
_plant_psf(data, x, y, flux, fwhm=3.5)
|
| 132 |
+
hdul[0].header[f"ASTX{j:02d}"] = round(x, 2)
|
| 133 |
+
hdul[0].header[f"ASTY{j:02d}"] = round(y, 2)
|
| 134 |
+
hdul.flush()
|
| 135 |
+
|
| 136 |
+
paths.append(path)
|
| 137 |
+
|
| 138 |
+
return paths
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def _plant_psf(data: np.ndarray, cx: float, cy: float, flux: float, fwhm: float = 3.5):
|
| 142 |
+
"""Plant a 2D Gaussian PSF at (cx, cy) with given total flux."""
|
| 143 |
+
sigma = fwhm / 2.355
|
| 144 |
+
h, w = data.shape
|
| 145 |
+
r = int(4 * sigma) + 1
|
| 146 |
+
|
| 147 |
+
x0, y0 = int(round(cx)), int(round(cy))
|
| 148 |
+
for dy in range(-r, r + 1):
|
| 149 |
+
for dx in range(-r, r + 1):
|
| 150 |
+
xi, yi = x0 + dx, y0 + dy
|
| 151 |
+
if 0 <= xi < w and 0 <= yi < h:
|
| 152 |
+
g = math.exp(-0.5 * ((dx**2 + dy**2) / sigma**2))
|
| 153 |
+
data[yi, xi] += flux * g / (2 * math.pi * sigma**2)
|
asteroidnet/utils/time_utils.py
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
AsteroidNET Time Utilities (utils.time_utils)
|
| 3 |
+
|
| 4 |
+
Handles the TAI vs UTC mismatch between Pan-STARRS and ZTF — the most
|
| 5 |
+
critical gotcha when processing real survey data.
|
| 6 |
+
|
| 7 |
+
Key facts:
|
| 8 |
+
- Pan-STARRS MJD-OBS is in TAI (full skycell images MISSING 'TIMESYS' header)
|
| 9 |
+
- ZTF OBSJD / OBSMJD / DATE-OBS are in UTC
|
| 10 |
+
- TAI is ahead of UTC by 37 seconds (as of 2017, PS1 DR2 era)
|
| 11 |
+
- 37 seconds = ~0.5–2 arcseconds of asteroid apparent motion
|
| 12 |
+
(enough to put a predicted position outside the detection aperture)
|
| 13 |
+
- For asteroid ephemeris queries, JPL Horizons expects UTC
|
| 14 |
+
- Mid-exposure time = start_time + 0.5 * EXPTIME
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import logging
|
| 20 |
+
from typing import Optional
|
| 21 |
+
|
| 22 |
+
from astropy.time import Time
|
| 23 |
+
|
| 24 |
+
import astropy.units as u
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger(__name__)
|
| 27 |
+
|
| 28 |
+
# TAI is ahead of UTC by this many seconds during the PS1 observation period.
|
| 29 |
+
# Updated per IERS bulletins — 37 seconds since 2017-01-01.
|
| 30 |
+
_PS1_TAI_UTC_OFFSET_S = 37.0
|
| 31 |
+
|
| 32 |
+
# Survey-specific time scale lookup
|
| 33 |
+
_SURVEY_TIMESCALE = {
|
| 34 |
+
"ps1": "tai", # Pan-STARRS — TAI (header often silent about this)
|
| 35 |
+
"ztf": "utc", # ZTF — UTC
|
| 36 |
+
"catalina": "utc",
|
| 37 |
+
"atlas": "utc",
|
| 38 |
+
"generic": "utc",
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def parse_fits_time(
|
| 43 |
+
header,
|
| 44 |
+
survey: str = "generic",
|
| 45 |
+
exptime_key: str = "EXPTIME",
|
| 46 |
+
obs_time_key: Optional[str] = None,
|
| 47 |
+
return_midexp: bool = True,
|
| 48 |
+
) -> Time:
|
| 49 |
+
"""
|
| 50 |
+
Parse observation time from a FITS header, correctly handling survey-specific
|
| 51 |
+
time scale conventions (most importantly Pan-STARRS TAI vs ZTF UTC).
|
| 52 |
+
|
| 53 |
+
Parameters
|
| 54 |
+
----------
|
| 55 |
+
header : fits.Header or dict
|
| 56 |
+
FITS header containing time keywords.
|
| 57 |
+
survey : str
|
| 58 |
+
Survey identifier: 'ps1', 'ztf', 'catalina', 'atlas', 'generic'.
|
| 59 |
+
Controls which time scale is assumed.
|
| 60 |
+
exptime_key : str
|
| 61 |
+
Header keyword for exposure time in seconds.
|
| 62 |
+
obs_time_key : str, optional
|
| 63 |
+
Override the time keyword to read. If None, auto-detected from survey.
|
| 64 |
+
return_midexp : bool
|
| 65 |
+
If True, add half the exposure time to get mid-exposure time.
|
| 66 |
+
|
| 67 |
+
Returns
|
| 68 |
+
-------
|
| 69 |
+
Time
|
| 70 |
+
Observation time in UTC scale, at mid-exposure if return_midexp=True.
|
| 71 |
+
"""
|
| 72 |
+
survey = survey.lower()
|
| 73 |
+
scale = _SURVEY_TIMESCALE.get(survey, "utc")
|
| 74 |
+
|
| 75 |
+
# Determine which header keyword holds the observation time
|
| 76 |
+
if obs_time_key is not None:
|
| 77 |
+
time_key = obs_time_key
|
| 78 |
+
elif survey == "ps1":
|
| 79 |
+
# Pan-STARRS: MJD-OBS is TAI (even if TIMESYS is absent/wrong)
|
| 80 |
+
time_key = "MJD-OBS"
|
| 81 |
+
elif survey == "ztf":
|
| 82 |
+
# ZTF: prefer OBSMJD (MJD), fall back to DATE-OBS (ISO)
|
| 83 |
+
time_key = "OBSMJD" if "OBSMJD" in header else "DATE-OBS"
|
| 84 |
+
else:
|
| 85 |
+
# Generic: try common keywords in order
|
| 86 |
+
for k in ("DATE-OBS", "MJD-OBS", "OBSMJD", "OBSJD"):
|
| 87 |
+
if k in header:
|
| 88 |
+
time_key = k
|
| 89 |
+
break
|
| 90 |
+
else:
|
| 91 |
+
raise KeyError(f"No recognized time keyword in header. Keys: {list(header.keys())[:20]}")
|
| 92 |
+
|
| 93 |
+
raw_time = header[time_key]
|
| 94 |
+
|
| 95 |
+
# Parse according to format
|
| 96 |
+
if isinstance(raw_time, (int, float)):
|
| 97 |
+
# Floating-point MJD or JD
|
| 98 |
+
fmt = "jd" if raw_time > 2400000 else "mjd"
|
| 99 |
+
t = Time(raw_time, format=fmt, scale=scale)
|
| 100 |
+
else:
|
| 101 |
+
# ISO string
|
| 102 |
+
t = Time(str(raw_time), format="isot", scale=scale)
|
| 103 |
+
|
| 104 |
+
# Convert to UTC (all downstream code expects UTC)
|
| 105 |
+
t_utc = t.utc
|
| 106 |
+
|
| 107 |
+
# Add half exposure time to get mid-exposure
|
| 108 |
+
if return_midexp and exptime_key in header:
|
| 109 |
+
exptime_s = float(header[exptime_key])
|
| 110 |
+
t_utc = t_utc + (exptime_s / 2.0) * u.s
|
| 111 |
+
logger.debug(
|
| 112 |
+
"Mid-exposure time (%s, scale=%s→utc): %s (exptime=%.1fs)",
|
| 113 |
+
survey, scale, t_utc.isot, exptime_s
|
| 114 |
+
)
|
| 115 |
+
else:
|
| 116 |
+
logger.debug("Start-of-exposure time (%s→utc): %s", survey, t_utc.isot)
|
| 117 |
+
|
| 118 |
+
return t_utc
|
| 119 |
+
|
| 120 |
+
|
| 121 |
+
def detect_survey_from_header(header) -> str:
|
| 122 |
+
"""
|
| 123 |
+
Attempt to identify the survey from FITS header keywords.
|
| 124 |
+
|
| 125 |
+
Returns one of: 'ps1', 'ztf', 'catalina', 'atlas', 'generic'
|
| 126 |
+
"""
|
| 127 |
+
telescop = str(header.get("TELESCOP", "")).lower()
|
| 128 |
+
instrume = str(header.get("INSTRUME", "")).lower()
|
| 129 |
+
origin = str(header.get("ORIGIN", "")).lower()
|
| 130 |
+
|
| 131 |
+
if any(x in telescop for x in ("ps1", "panstarrs", "pan-starrs", "haleakala", "p60")):
|
| 132 |
+
return "ps1"
|
| 133 |
+
if any(x in telescop for x in ("p48", "palomar48", "ztf")):
|
| 134 |
+
return "ztf"
|
| 135 |
+
if "catalina" in telescop or "css" in instrume:
|
| 136 |
+
return "catalina"
|
| 137 |
+
if "atlas" in telescop:
|
| 138 |
+
return "atlas"
|
| 139 |
+
if "ps1" in str(header.get("FILENAME", "")).lower():
|
| 140 |
+
return "ps1"
|
| 141 |
+
if "ztf_" in str(header.get("FILENAME", "")).lower():
|
| 142 |
+
return "ztf"
|
| 143 |
+
|
| 144 |
+
return "generic"
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
def fix_ps1_header(header) -> None:
|
| 148 |
+
"""
|
| 149 |
+
In-place fix for Pan-STARRS full skycell FITS headers.
|
| 150 |
+
|
| 151 |
+
Known PS1 DR2 issues (from STScI documentation):
|
| 152 |
+
1. TIMESYS keyword absent �� should be 'TAI'
|
| 153 |
+
2. RADESYS keyword absent — should be 'FK5'
|
| 154 |
+
3. WCS uses obsolete PC001001 naming instead of PC1_1
|
| 155 |
+
|
| 156 |
+
The fitscut.cgi service corrects these, but full skycell downloads don't.
|
| 157 |
+
"""
|
| 158 |
+
if "TIMESYS" not in header:
|
| 159 |
+
header["TIMESYS"] = ("TAI", "Time system [added by AsteroidNET]")
|
| 160 |
+
logger.debug("Added missing TIMESYS=TAI to PS1 header")
|
| 161 |
+
|
| 162 |
+
if "RADESYS" not in header:
|
| 163 |
+
header["RADESYS"] = ("FK5", "Celestial coordinate system [added by AsteroidNET]")
|
| 164 |
+
logger.debug("Added missing RADESYS=FK5 to PS1 header")
|
| 165 |
+
|
| 166 |
+
# Fix obsolete PC matrix naming (PC001001 → PC1_1 etc.)
|
| 167 |
+
for old, new in [("PC001001", "PC1_1"), ("PC001002", "PC1_2"),
|
| 168 |
+
("PC002001", "PC2_1"), ("PC002002", "PC2_2")]:
|
| 169 |
+
if old in header and new not in header:
|
| 170 |
+
header[new] = header[old]
|
| 171 |
+
logger.debug("Renamed WCS keyword %s → %s", old, new)
|