Coverage for python / lsst / summit / utils / guiders / tracking.py: 12%
156 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:17 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-22 09:17 +0000
1# This file is part of summit_utils.
2#
3# Developed for the LSST Data Management System.
4# This product includes software developed by the LSST Project
5# (https://www.lsst.org).
6# See the COPYRIGHT file at the top-level directory of this distribution
7# for details of code ownership.
8#
9# This program is free software: you can redistribute it and/or modify
10# it under the terms of the GNU General Public License as published by
11# the Free Software Foundation, either version 3 of the License, or
12# (at your option) any later version.
13#
14# This program is distributed in the hope that it will be useful,
15# but WITHOUT ANY WARRANTY; without even the implied warranty of
16# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
17# GNU General Public License for more details.
18#
19# You should have received a copy of the GNU General Public License
20# along with this program. If not, see <https://www.gnu.org/licenses/>.
21from __future__ import annotations
23__all__ = ["GuiderStarTracker"]
25import logging
26from typing import TYPE_CHECKING
28import numpy as np
29import pandas as pd
31from .transformation import convertRoiToCcd, convertToAltaz, convertToFocalPlane
33if TYPE_CHECKING:
34 from .reading import GuiderData
36from .detection import GuiderStarTrackerConfig, buildReferenceCatalog, makeBlankCatalog, trackStarAcrossStamp
39def _selectBrighestStar(refCatalog: pd.DataFrame, guiderName: str) -> tuple[float, float]:
40 """
41 Select the brightest star from the reference catalog for a given guider.
43 Parameters
44 ----------
45 refCatalog : `pd.DataFrame`
46 Reference catalog with star positions and SNR.
47 guiderName : `str`
48 Name of the guider.
50 Returns
51 -------
52 (xroi, yroi) : `tuple[float, float]`
53 Coordinates of the brightest star in the ROI.
54 """
55 ref = refCatalog[refCatalog["detector"] == guiderName].copy()
56 ref.sort_values(by=["snr"], ascending=False, inplace=True)
57 return (ref["xroi"].values[0], ref["yroi"].values[0])
60class GuiderStarTracker:
61 """
62 Class to track stars in the Guider data.
64 Parameters
65 ----------
66 guiderData : `GuiderData`
67 GuiderData instance containing guider data and metadata.
68 config : `GuiderStarTrackerConfig`, optional
69 Config object with setup and quality control parameters. If None, a new
70 default GuiderStarTrackerConfig is created per GuiderStarTracker
71 instance.
72 """
74 def __init__(
75 self,
76 guiderData: GuiderData,
77 config: GuiderStarTrackerConfig | None = None,
78 ) -> None:
79 """
80 Initialize the GuiderStarTracker with guider data and configuration.
82 Parameters
83 ----------
84 guiderData : `GuiderData`
85 GuiderData instance containing guider data and metadata.
86 config : `GuiderStarTrackerConfig`, optional
87 Config object with setup and quality control parameters.
88 """
89 self.log = logging.getLogger(__name__)
90 self.guiderData = guiderData
91 self.nStamps = len(guiderData)
92 self.expid = guiderData.expid
93 self.shape = guiderData[guiderData.detNameMax, 0].shape
95 # detection and QC parameters from config
96 if config is None:
97 config = GuiderStarTrackerConfig()
98 self.config = config
100 # initialize outputs
101 self.blankStars = makeBlankCatalog()
102 self.columns = list(self.blankStars.columns)
104 def trackGuiderStars(self, refCatalog: None | pd.DataFrame = None) -> pd.DataFrame:
105 """
106 Track stars across guider exposures using a reference catalog.
108 Parameters
109 ----------
110 refCatalog : `pd.DataFrame`, optional
111 Reference catalog with known star positions per detector.
113 Returns
114 -------
115 stars : `pd.DataFrame`
116 DataFrame with tracked stars and their properties,
117 including positions, fluxes, and residual offsets.
118 """
119 if refCatalog is None:
120 self.log.info("Using self-generated refcat")
121 _refCatalog = buildReferenceCatalog(self.guiderData, self.log, self.config)
122 refCatalog = applyQualityCuts(_refCatalog, self.shape, self.config)
124 if refCatalog.empty:
125 self.log.warning(f"Reference catalog is empty for {self.expid}. No stars to track.")
126 return self.blankStars
128 trackedStarTables = []
129 for guiderName in self.guiderData.guiderNames:
130 refDet = refCatalog.query(f"detector == '{guiderName}'")
131 if refDet.empty:
132 self.log.warning(f"No stars found in reference catalog for {guiderName} in {self.expid}.")
133 continue
135 table = self._trackStarForOneGuider(refDet, guiderName)
136 if not table.empty:
137 trackedStarTables.append(table)
139 if not trackedStarTables:
140 self.log.warning(f"No stars tracked for {self.expid} for any guider.")
141 return self.blankStars
143 # build the final catalog
144 trackedStarCatalog = pd.concat(trackedStarTables, ignore_index=True)
146 # Set unique IDs for all tracked stars
147 trackedStarCatalog = setUniqueId(self.guiderData, trackedStarCatalog)
149 # Make the final DataFrame with selected columns
150 return trackedStarCatalog[self.columns]
152 def _trackStarForOneGuider(self, refCatalog: pd.DataFrame, guiderName: str) -> pd.DataFrame:
153 """
154 Track stars for a single guider using the reference catalog.
156 Parameters
157 ----------
158 refCatalog : `pd.DataFrame`
159 Reference catalog with known star positions per detector.
160 guiderName : `str`
161 Name of the guider to process.
163 Returns
164 -------
165 stars : `pd.DataFrame`
166 DataFrame with tracked stars for the specified guider.
167 """
168 gd = self.guiderData
169 shape = self.shape
170 cfg = self.config
172 # Select the brightest star from the reference catalog
173 refCenter = _selectBrighestStar(refCatalog, guiderName)
175 # Measure the stars across all stamps for this guider
176 starStamps = trackStarAcrossStamp(refCenter, gd, guiderName, cfg)
178 # Apply quality cuts to the tracked stars
179 stars = applyQualityCuts(starStamps, shape, cfg)
181 # Filter by minimum number of detections
182 minStampDetections = int(len(gd) * cfg.minValidStampFraction)
183 mask = stars["stamp"].groupby(stars["detector"]).transform("count") >= minStampDetections
184 stars = stars[mask].copy()
186 if stars.empty:
187 self.log.warning(
188 f"No tracked stars passed the quality cuts" f" for {guiderName} in {self.expid}."
189 )
190 return self.blankStars
192 # convert to CCD, focal plane and Alt/Az coordinates
193 stars = convertToCcdFocalPlaneAltAz(stars, gd, guiderName)
195 # Compute the rotator angle: theta = np.arctan2(yfp, xfp)
196 stars = computeRotatorAngle(stars)
198 # Convert e1, e2 to Alt/Az coordinates
199 stars = convertEllipticity(stars, gd.camRotAngle)
201 # Add timestamp and elapsed time
202 stars = addTimeStamp(stars, gd, guiderName)
204 # Compute Offsets
205 stars = computeOffsets(stars)
207 return stars
210def addTimeStamp(stars: pd.DataFrame, guiderData: GuiderData, guiderName: str) -> pd.DataFrame:
211 """
212 Add timestamp and elapsed time to the star DataFrame.
214 Parameters
215 ----------
216 stars : `pd.DataFrame`
217 DataFrame with star measurements.
218 guiderData : `GuiderData`
219 GuiderData instance containing guider data and metadata.
220 guiderName : `str`
221 Name of the guider.
223 Returns
224 -------
225 stars : `pd.DataFrame`
226 DataFrame with added 'timestamp' and 'elapsed_time' columns.
227 """
228 gd = guiderData
229 # the stamp are aligned with the index of the timestamps
230 stampIndex = stars["stamp"].to_numpy()
231 indices = np.array([ix for ix in stampIndex], dtype=int)
233 # get the timestamp for each stamp
234 timeStamp = gd.timestampMap[guiderName][indices]
236 # inital time is the time of the first stamp of the guider with most stamps
237 t0 = gd.timestampMap[gd.detNameMax][0]
239 stars["timestamp"] = timeStamp
240 stars["elapsed_time"] = np.where(timeStamp.mask, np.nan, (timeStamp - t0).sec)
241 return stars
244def convertToCcdFocalPlaneAltAz(stars: pd.DataFrame, guiderData: GuiderData, guiderName: str) -> pd.DataFrame:
245 """
246 Convert star positions to CCD, focal plane, and Alt/Az coordinates.
248 Parameters
249 ----------
250 stars : `pd.DataFrame`
251 DataFrame with star measurements in ROI coordinates.
252 guiderData : `GuiderData`
253 GuiderData instance containing guider data and metadata.
254 guiderName : `str`
255 Name of the guider.
257 Returns
258 -------
259 stars : `pd.DataFrame`
260 DataFrame with added columns for CCD, focal plane,
261 and Alt/Az coordinates.
262 """
263 gd = guiderData
264 obsTime = gd.obsTime
265 detNum = gd.getGuiderDetNum(guiderName)
266 wcs = gd.getWcs(guiderName)
267 pixelScale = wcs.getPixelScale().asArcseconds()
269 # Convert to CCD coordinates
270 stars["xccd"], stars["yccd"] = convertRoiToCcd(
271 stars["xroi"].to_numpy(), stars["yroi"].to_numpy(), gd, guiderName
272 )
274 # Convert to focal plane coordinates
275 stars["xfp"], stars["yfp"] = convertToFocalPlane(
276 stars["xccd"].to_numpy(), stars["yccd"].to_numpy(), detNum
277 )
279 # Convert to Alt/Az coordinates
280 stars["alt"], stars["az"] = convertToAltaz(
281 stars["xccd"].to_numpy(), stars["yccd"].to_numpy(), wcs, obsTime
282 )
284 # Convert fwhm to arcsec
285 stars["fwhm"] = stars["fwhm"] * pixelScale
287 return stars
290def applyQualityCuts(
291 stars: pd.DataFrame, shape: tuple[float, float], config: GuiderStarTrackerConfig
292) -> pd.DataFrame:
293 """
294 Apply cuts according to min SNR, maximum ellipticity and edge margin.
296 Parameters
297 ----------
298 stars : `pd.DataFrame`
299 DataFrame with star measurements.
300 shape : `tuple[float, float]`
301 Shape of the ROI (cols, rows).
302 config : `GuiderStarTrackerConfig`
303 Configuration object with quality cut parameters.
305 Returns
306 -------
307 stars : `pd.DataFrame`
308 DataFrame with stars that passed the quality cuts.
309 """
310 minSnr = config.minSnr
311 maxEllipticity = config.maxEllipticity
312 edgeMargin = config.edgeMargin
313 roiCols, roiRows = shape
315 # Filter by minimum SNR
316 mask1 = (stars["snr"] >= minSnr) & (stars["flux"] > 0) & (stars["flux_err"] > 0)
318 # Filter by minimum number of detections and maximum ellipticity
319 eabs = np.hypot(stars["e1"], stars["e2"])
320 mask3 = (stars["e1"].abs() <= maxEllipticity) & (stars["e1"].abs() <= maxEllipticity)
321 mask3 &= eabs <= maxEllipticity
323 # Add edge margin mask
324 mask4 = (
325 (stars["xroi"] >= edgeMargin)
326 & (stars["xroi"] <= roiRows - edgeMargin)
327 & (stars["yroi"] >= edgeMargin)
328 & (stars["yroi"] <= roiCols - edgeMargin)
329 )
330 # Combine all masks
331 mask = mask1 & mask3 & mask4
332 return stars[mask].copy()
335def setUniqueId(guiderData: GuiderData, stars: pd.DataFrame) -> pd.DataFrame:
336 """
337 Assign unique IDs to tracked stars.
339 Parameters
340 ----------
341 guiderData : `GuiderData`
342 GuiderData instance containing guider data and metadata.
343 stars : `pd.DataFrame`
344 DataFrame with star measurements.
346 Returns
347 -------
348 stars : `pd.DataFrame`
349 DataFrame with added 'detid' and 'trackid' columns.
351 """
352 # Create a numeric “global” starid:
353 detMap = guiderData.guiderNameMap
354 stars["detid"] = stars["detector"].map(detMap)
355 stars["trackid"] = stars["detid"] * 1000 + stars["stamp"]
356 return stars
359def computeOffsets(stars: pd.DataFrame) -> pd.DataFrame:
360 """
361 Compute the offsets for each star in the catalog.
363 Parameters
364 ----------
365 stars : `pd.DataFrame`
366 DataFrame with star measurements.
368 Returns
369 -------
370 stars : `pd.DataFrame`
371 DataFrame with added offset columns:
372 - dx, dy : offsets in CCD coordinates (pixels)
373 - dxfp, dyfp : offsets in focal plane coordinates (mm)
374 - dalt, daz : offsets in Alt/Az coordinates (arcsec)
375 - dtheta : offset in rotator angle (arcsec)
376 - magoffset : magnitude offset (mmag)
378 """
379 # make reference positions
380 stars["xroi_ref"] = stars.groupby("detector")["xroi"].transform("median")
381 stars["yroi_ref"] = stars.groupby("detector")["yroi"].transform("median")
382 stars["xccd_ref"] = stars.groupby("detector")["xccd"].transform("median")
383 stars["yccd_ref"] = stars.groupby("detector")["yccd"].transform("median")
384 stars["xfp_ref"] = stars.groupby("detector")["xfp"].transform("median")
385 stars["yfp_ref"] = stars.groupby("detector")["yfp"].transform("median")
386 stars["alt_ref"] = stars.groupby("detector")["alt"].transform("median")
387 stars["az_ref"] = stars.groupby("detector")["az"].transform("median")
388 stars["theta_ref"] = stars.groupby("detector")["theta"].transform("median")
390 # Compute all your offsets
391 stars["dx"] = stars["xccd"] - stars["xccd_ref"]
392 stars["dy"] = stars["yccd"] - stars["yccd_ref"]
393 stars["dxfp"] = stars["xfp"] - stars["xfp_ref"]
394 stars["dyfp"] = stars["yfp"] - stars["yfp_ref"]
395 stars["dalt"] = (stars["alt"] - stars["alt_ref"]) * 3600
396 stars["daz"] = (stars["az"] - stars["az_ref"]) * 3600
397 stars["dtheta"] = (stars["theta"] - stars["theta_ref"]) * 3600
399 # Correct for cos(alt) in daz
400 stars["daz"] = np.cos(stars["alt_ref"] * np.pi / 180) * stars["daz"]
402 # compute mag offset
403 stars["flux_ref"] = stars.groupby("detector")["flux"].transform("median")
404 stars["flux_ref"] = pd.to_numeric(stars["flux_ref"], errors="coerce")
405 stars["flux"] = pd.to_numeric(stars["flux"], errors="coerce")
406 stars["magoffset"] = -2.5 * np.log10((stars["flux"] + 1e-12) / (stars["flux_ref"] + 1e-12))
407 # convert to mmag and replace inf with nan
408 stars["magoffset"] = 1000 * stars["magoffset"].replace([np.inf, -np.inf], np.nan)
410 # not detections have nan offsets
411 offsetCols = ["dx", "dy", "dxfp", "dyfp", "dalt", "daz", "dtheta", "magoffset"]
412 stars.loc[stars["flux"].isna(), offsetCols] = np.nan
414 return stars
417def computeRotatorAngle(stars: pd.DataFrame) -> pd.DataFrame:
418 """
419 Compute the rotator angle (theta) and its propagated 1-sigma uncertainty
420 for each row in `stars`.
422 Parameters
423 ----------
424 stars : `pd.DataFrame`
425 DataFrame with star measurements. Must contain 'xfp', 'yfp', 'xerr',
426 and 'yerr' columns.
428 Returns
429 -------
430 stars : `pd.DataFrame`
431 DataFrame with added 'theta' and 'theta_err' columns.
432 """
433 mm = 0.001 # convert microns to mm
434 xfp = stars["xfp"].to_numpy()
435 yfp = stars["yfp"].to_numpy()
436 sigmaX = stars["xerr"].to_numpy() * 10 * mm
437 sigmaY = stars["yerr"].to_numpy() * 10 * mm
439 # Angle in degrees
440 theta = np.degrees(np.arctan2(yfp, xfp))
442 # Denominator r^2 = x^2 + y^2
443 denom = xfp**2 + yfp**2
445 # Suppress warnings for rows where denom == 0; set sigmaTheta to NaN there.
446 with np.errstate(divide="ignore", invalid="ignore"):
447 sigmaThetaRad = np.sqrt((yfp**2 * sigmaX**2 + xfp**2 * sigmaY**2) / denom**2)
448 sigmaTheta = np.degrees(sigmaThetaRad)
449 sigmaTheta = np.where(denom == 0, np.nan, sigmaTheta)
451 stars["theta"] = theta
452 stars["theta_err"] = sigmaTheta * 3600 # convert to arcsec
453 return stars
456def convertEllipticity(stars: pd.DataFrame, camRotAngleDeg: float) -> pd.DataFrame:
457 """
458 Rotate ellipticity components (e1, e2) to Alt/Az.
460 Parameters
461 ----------
462 stars : `pd.DataFrame`
463 DataFrame with star measurements.
464 camRotAngleDeg : `float`
465 Camera rotator angle in degrees.
467 Returns
468 -------
469 stars : `pd.DataFrame`
470 DataFrame with added columns 'e1_altaz' and 'e2_altaz'.
471 """
472 e1_rot, e2_rot = rotateEllipticity(stars["e1"], stars["e2"], camRotAngleDeg)
473 stars["e1_altaz"] = e1_rot
474 stars["e2_altaz"] = e2_rot
475 return stars
478def rotateEllipticity(e1: float, e2: float, theta_deg: float) -> tuple[float, float]:
479 """
480 Rotate ellipticity components (e1, e2) by theta_deg degrees.
482 Parameters
483 ----------
484 e1 : `float`
485 First ellipticity component.
486 e2 : `float`
487 Second ellipticity component.
488 theta_deg : `float`
489 Rotation angle in degrees.
491 Returns
492 -------
493 e1_rot, e2_rot : `tuple[float, float]`
494 Rotated ellipticity components.
495 """
496 theta = np.deg2rad(theta_deg)
497 cos2t = np.cos(2 * theta)
498 sin2t = np.sin(2 * theta)
499 e1_rot = e1 * cos2t + e2 * sin2t
500 e2_rot = -e1 * sin2t + e2 * cos2t
501 return e1_rot, e2_rot