mmrech commited on
Commit
41d98e2
·
verified ·
1 Parent(s): 09ab3e3

feat: v0.2 — real FITS support, TAI/UTC fix, SkyBoT, two-pass bg

Browse files
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)