Coverage for python/lsst/summit/utils/astrometry/utils.py: 15%
144 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-15 03:27 -0700
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-15 03:27 -0700
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/>.
23from typing import Any
25import astropy
26import astropy.units as u
27import numpy as np
28from astropy.coordinates import AltAz, EarthLocation, SkyCoord
29from astropy.time import Time
31import lsst.afw.geom as afwGeom
32import lsst.afw.image as afwImage
33import lsst.afw.table as afwTable
34import lsst.pipe.base as pipeBase
35from lsst.afw.geom import SkyWcs
36from lsst.daf.base import PropertySet
37from lsst.pipe.tasks.characterizeImage import CharacterizeImageConfig, CharacterizeImageTask
39__all__ = [
40 "claverHeaderToWcs",
41 "getAverageRaFromHeader",
42 "getAverageDecFromHeader",
43 "getAverageAzFromHeader",
44 "getAverageElFromHeader",
45 "genericCameraHeaderToWcs",
46 "getIcrsAtZenith",
47 "headerToWcs",
48 "runCharactierizeImage",
49 "filterSourceCatOnBrightest",
50]
53def claverHeaderToWcs(
54 exp: afwImage.Exposure, nominalRa: float | None = None, nominalDec: float | None = None
55) -> afwGeom.SkyWcs:
56 """Given an exposure taken by Chuck Claver at his house, construct a wcs
57 with the ra/dec set to zenith unless a better guess is supplied.
59 Automatically sets the platescale depending on the lens.
61 Parameters
62 ----------
63 exp : `lsst.afw.image.Exposure`
64 The exposure to construct the wcs for.
65 nominalRa : `float`, optional
66 The guess for the ra.
67 nominalDec : `float`, optional
68 The guess for the Dec.
70 Returns
71 -------
72 wcs : `lsst.afw.geom.SkyWcs`
73 The constructed wcs.
74 """
75 header = exp.getMetadata().toDict()
77 # set the plate scale depending on the lens and put into CD matrix
78 # plate scale info from:
79 # https://confluence.lsstcorp.org/pages/viewpage.action?pageId=191987725
80 lens = header["INSTLEN"]
81 if "135mm" in lens:
82 arcSecPerPix = 8.64
83 elif "375mm" in lens:
84 arcSecPerPix = 3.11
85 elif "750mm" in lens:
86 arcSecPerPix = 1.56
87 else:
88 raise ValueError(f"Unrecognised lens: {lens}")
90 header["CD1_1"] = arcSecPerPix / 3600
91 header["CD1_2"] = 0
92 header["CD2_1"] = 0
93 header["CD2_2"] = arcSecPerPix / 3600
95 # calculate the ra/dec at zenith and assume Chuck pointed it vertically
96 icrs = getIcrsAtZenith(
97 float(header["OBSLON"]), float(header["OBSLAT"]), float(header["OBSHGT"]), header["UTC"]
98 )
99 header["CRVAL1"] = nominalRa if nominalRa else icrs.ra.degree
100 header["CRVAL2"] = nominalDec if nominalDec else icrs.dec.degree
102 # just use the nomimal chip centre, not that it matters
103 # given radec = zenith
104 width, height = exp.image.array.shape
105 header["CRPIX1"] = width / 2
106 header["CRPIX2"] = height / 2
108 header["CTYPE1"] = "RA---TAN-SIP"
109 header["CTYPE2"] = "DEC--TAN-SIP"
111 wcsPropSet = PropertySet.from_mapping(header)
112 wcs = SkyWcs(wcsPropSet)
113 return wcs
116# don't be tempted to get cute and try to combine these 4 functions. It would
117# be easy to do but it's not unlikley they will diverge in the future.
118def getAverageRaFromHeader(header: dict) -> float:
119 raStart = header.get("RASTART")
120 raEnd = header.get("RAEND")
121 if not raStart or not raEnd:
122 raise RuntimeError(f"Failed to get RA from header due to missing RASTART/END {raStart} {raEnd}")
123 raStart = float(raStart)
124 raEnd = float(raEnd)
125 return (raStart + raEnd) / 2
128def getAverageDecFromHeader(header: dict) -> float:
129 decStart = header.get("DECSTART")
130 decEnd = header.get("DECEND")
131 if not decStart or not decEnd:
132 raise RuntimeError(f"Failed to get DEC from header due to missing DECSTART/END {decStart} {decEnd}")
133 decStart = float(decStart)
134 decEnd = float(decEnd)
135 return (decStart + decEnd) / 2
138def getAverageAzFromHeader(header: dict) -> float:
139 azStart = header.get("AZSTART")
140 azEnd = header.get("AZEND")
141 if not azStart or not azEnd:
142 raise RuntimeError(f"Failed to get az from header due to missing AZSTART/END {azStart} {azEnd}")
143 azStart = float(azStart)
144 azEnd = float(azEnd)
145 return (azStart + azEnd) / 2
148def getAverageElFromHeader(header: dict) -> float:
149 elStart = header.get("ELSTART")
150 elEnd = header.get("ELEND")
151 if not elStart or not elEnd:
152 raise RuntimeError(f"Failed to get el from header due to missing ELSTART/END {elStart} {elEnd}")
153 elStart = float(elStart)
154 elEnd = float(elEnd)
155 return (elStart + elEnd) / 2
158def patchHeader(header: dict[str, float | int | str]) -> dict[str, float | int | str]:
159 """This is a TEMPORARY function to patch some info into the headers."""
160 if header.get("CAMCODE") == "GC102": # regular aka narrow camera
161 # the narrow camera currently is wrong about its place scale by of ~2.2
162 header["SECPIX"] = "1.44"
163 # update the boresight locations until this goes into the header
164 # service
165 header["CRPIX1"] = 1898.10
166 header["CRPIX2"] = 998.47
167 if header.get("CAMCODE") == "GC101": # wide camera
168 # update the boresight locations until this goes into the header
169 # service
170 header["CRPIX1"] = 1560.85
171 header["CRPIX2"] = 1257.15
172 if header.get("CAMCODE") == "GC103": # fast camera
173 # use the fast camera chip centre until we know better
174 header["SECPIX"] = "0.6213" # measured from a fit
175 header["CRPIX1"] = 329.5
176 header["CRPIX2"] = 246.5
177 return header
180def genericCameraHeaderToWcs(exp: Any) -> afwGeom.SkyWcs:
181 header = exp.getMetadata().toDict()
182 header = patchHeader(header)
184 header["CTYPE1"] = "RA---TAN-SIP"
185 header["CTYPE2"] = "DEC--TAN-SIP"
187 header["CRVAL1"] = getAverageRaFromHeader(header)
188 header["CRVAL2"] = getAverageDecFromHeader(header)
190 plateScale = header.get("SECPIX")
191 if not plateScale:
192 raise RuntimeError("Failed to find platescale in header")
193 plateScale = float(plateScale)
195 header["CD1_1"] = plateScale / 3600
196 header["CD1_2"] = 0
197 header["CD2_1"] = 0
198 header["CD2_2"] = plateScale / 3600
200 wcsPropSet = PropertySet.from_mapping(header)
201 wcs = SkyWcs(wcsPropSet)
202 return wcs
205def getIcrsAtZenith(lon: float, lat: float, height: float, utc: str) -> astropy.coordinates.SkyCoord:
206 """Get the icrs at zenith given a lat/long/height/time in UTC.
208 Parameters
209 ----------
210 lon : `float`
211 The longitude, in degrees.
212 lat : `float`
213 The latitude, in degrees.
214 height : `float`
215 The height above sea level in meters.
216 utc : `str`
217 The time in UTC as an ISO string, e.g. '2022-05-27 20:41:02'
219 Returns
220 -------
221 skyCoordAtZenith : `astropy.coordinates.SkyCoord`
222 The skyCoord at zenith.
223 """
224 location = EarthLocation.from_geodetic(lon=lon * u.deg, lat=lat * u.deg, height=height)
225 obsTime = Time(utc, format="iso", scale="utc")
226 skyCoord = SkyCoord(AltAz(obstime=obsTime, alt=90.0 * u.deg, az=180.0 * u.deg, location=location))
227 return skyCoord.transform_to("icrs")
230def headerToWcs(header: dict) -> afwGeom.SkyWcs:
231 """Convert an astrometry.net wcs header dict to a DM wcs object.
233 Parameters
234 ----------
235 header : `dict`
236 The wcs header, as returned from from the astrometry_net fit.
238 Returns
239 -------
240 wcs : `lsst.afw.geom.SkyWcs`
241 The wcs.
242 """
243 wcsPropSet = PropertySet.from_mapping(header)
244 return SkyWcs(wcsPropSet)
247def runCharactierizeImage(exp: afwImage.Exposure, snr: float, minPix: int) -> pipeBase.Struct:
248 """Run the image characterization task, finding only bright sources.
250 Parameters
251 ----------
252 exp : `lsst.afw.image.Exposure`
253 The exposure to characterize.
254 snr : `float`
255 The SNR threshold for detection.
256 minPix : `int`
257 The minimum number of pixels to count as a source.
259 Returns
260 -------
261 result : `lsst.pipe.base.Struct`
262 The result from the image characterization task.
263 """
264 charConfig = CharacterizeImageConfig()
265 charConfig.doMeasurePsf = False
266 charConfig.doApCorr = False
267 charConfig.doDeblend = False
268 charConfig.doMaskStreaks = False
269 charConfig.repair.doCosmicRay = False
271 charConfig.detection.minPixels = minPix
272 charConfig.detection.thresholdValue = snr
273 charConfig.detection.includeThresholdMultiplier = 1
274 charConfig.detection.thresholdType = "stdev"
276 # fit background with the most simple thing possible as we don't need
277 # much sophistication here. weighting=False is *required* for very
278 # large binSizes.
279 charConfig.background.algorithm = "CONSTANT"
280 charConfig.background.approxOrderX = 0
281 charConfig.background.approxOrderY = -1
282 charConfig.background.binSize = max(exp.getWidth(), exp.getHeight())
283 charConfig.background.weighting = False
285 # set this to use all the same minimal settings as those above
286 charConfig.detection.background = charConfig.background
288 charTask = CharacterizeImageTask(config=charConfig)
290 charResult = charTask.run(exp)
291 return charResult
294def filterSourceCatOnBrightest(
295 catalog: afwTable.SourceCatalog,
296 brightFraction: float,
297 minSources: int = 15,
298 maxSources: int = 200,
299 flux_field: str = "base_CircularApertureFlux_3_0_instFlux",
300) -> afwTable.SourceCatalog:
301 """Filter a sourceCat on the brightness, leaving only the top fraction.
303 Return a catalog containing the brightest sources in the input. Makes an
304 initial coarse cut, keeping those above 0.1% of the maximum finite flux,
305 and then returning the specified fraction of the remaining sources,
306 or minSources, whichever is greater.
308 Parameters
309 ----------
310 catalog : `lsst.afw.table.SourceCatalog`
311 Catalog to be filtered.
312 brightFraction : `float`
313 Return this fraction of the brightest sources.
314 minSources : `int`, optional
315 Always return at least this many sources.
316 maxSources : `int`, optional
317 Never return more than this many sources.
318 flux_field : `str`, optional
319 Name of flux field to filter on.
321 Returns
322 -------
323 result : `lsst.afw.table.SourceCatalog`
324 Brightest sources in the input image, in ascending order of brightness.
325 """
326 assert minSources > 0
327 assert brightFraction > 0 and brightFraction <= 1
328 if not maxSources >= minSources:
329 raise ValueError(
330 "maxSources must be greater than or equal to minSources, got " f"{maxSources=}, {minSources=}"
331 )
333 maxFlux = np.nanmax(catalog[flux_field])
334 result = catalog.subset(catalog[flux_field] > maxFlux * 0.001)
336 print(f"Catalog had {len(catalog)} sources, of which {len(result)} were above 0.1% of max")
338 item = catalog.schema.find(flux_field)
339 result = catalog.copy(deep=True) # sort() is in place; copy so we don't modify the original
340 result.sort(item.key)
341 result = result.copy(deep=True) # make it memory contiguous
342 end = int(np.ceil(len(result) * brightFraction))
343 return result[-min(maxSources, max(end, minSources)) :]