lsst.skymap g037fbe16b5+6aff9d8993
baseSkyMap.py
Go to the documentation of this file.
1# LSST Data Management System
2# Copyright 2008, 2009, 2010 LSST Corporation.
3#
4# This product includes software developed by the
5# LSST Project (http://www.lsst.org/).
6#
7# This program is free software: you can redistribute it and/or modify
8# it under the terms of the GNU General Public License as published by
9# the Free Software Foundation, either version 3 of the License, or
10# (at your option) any later version.
11#
12# This program is distributed in the hope that it will be useful,
13# but WITHOUT ANY WARRANTY; without even the implied warranty of
14# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15# GNU General Public License for more details.
16#
17# You should have received a copy of the LSST License Statement and
18# the GNU General Public License along with this program. If not,
19# see <http://www.lsstcorp.org/LegalNotices/>.
20#
21
22"""
23todo: Consider tweaking pixel scale so the average scale is as specified,
24rather than the scale at the center.
25"""
26
27__all__ = ["BaseSkyMapConfig", "BaseSkyMap"]
28
29import hashlib
30import struct
31import numpy as np
32
33import lsst.geom as geom
34import lsst.pex.config as pexConfig
35from lsst.geom import SpherePoint, Angle, arcseconds, degrees
36from . import detail
37
38
39class BaseSkyMapConfig(pexConfig.Config):
40 patchInnerDimensions = pexConfig.ListField(
41 doc="dimensions of inner region of patches (x,y pixels)",
42 dtype=int,
43 length=2,
44 default=(4000, 4000),
45 )
46 patchBorder = pexConfig.Field(
47 doc="border between patch inner and outer bbox (pixels)",
48 dtype=int,
49 default=100,
50 )
51 tractOverlap = pexConfig.Field(
52 doc="minimum overlap between adjacent sky tracts, on the sky (deg)",
53 dtype=float,
54 default=1.0,
55 )
56 pixelScale = pexConfig.Field(
57 doc="nominal pixel scale (arcsec/pixel)",
58 dtype=float,
59 default=0.333
60 )
61 projection = pexConfig.Field(
62 doc="one of the FITS WCS projection codes, such as:"
63 "- STG: stereographic projection"
64 "- MOL: Molleweide's projection"
65 "- TAN: tangent-plane projection",
66 dtype=str,
67 default="STG",
68 )
69 rotation = pexConfig.Field(
70 doc="Rotation for WCS (deg)",
71 dtype=float,
72 default=0,
73 )
74
75
77 """A collection of overlapping Tracts that map part or all of the sky.
78
79 See TractInfo for more information.
80
81 Parameters
82 ----------
83 config : `BaseSkyMapConfig` or None (optional)
84 The configuration for this SkyMap; if None use the default config.
85
86 Notes
87 -----
88 BaseSkyMap is an abstract base class. Subclasses must do the following:
89 define ``__init__`` and have it construct the TractInfo objects and put
90 them in ``__tractInfoList__`` define ``__getstate__`` and ``__setstate__``
91 to allow pickling (the butler saves sky maps using pickle);
92 see DodecaSkyMap for an example of how to do this. (Most of that code could
93 be moved into this base class, but that would make it harder to handle
94 older versions of pickle data.) define updateSha1 to add any
95 subclass-specific state to the hash.
96
97 All SkyMap subclasses must be conceptually immutable; they must always
98 refer to the same set of mathematical tracts and patches even if the in-
99 memory representation of those objects changes.
100 """
101
102 ConfigClass = BaseSkyMapConfig
103
104 def __init__(self, config=None):
105 if config is None:
106 config = self.ConfigClassConfigClass()
107 config.freeze() # just to be sure, e.g. for pickling
108 self.configconfig = config
109 self._tractInfoList_tractInfoList = []
110 self._wcsFactory_wcsFactory = detail.WcsFactory(
111 pixelScale=Angle(self.configconfig.pixelScale, arcseconds),
112 projection=self.configconfig.projection,
113 rotation=Angle(self.configconfig.rotation, degrees),
114 )
115 self._sha1_sha1 = None
116
117 def findTract(self, coord):
118 """Find the tract whose center is nearest the specified coord.
119
120 Parameters
121 ----------
122 coord : `lsst.geom.SpherePoint`
123 ICRS sky coordinate to search for.
124
125 Returns
126 -------
127 result : `TractInfo`
128 TractInfo of tract whose center is nearest the specified coord.
129
130 Notes
131 -----
132 - If coord is equidistant between multiple sky tract centers then one
133 is arbitrarily chosen.
134
135 - The default implementation is not very efficient; subclasses may wish
136 to override.
137
138 **Warning:**
139 If tracts do not cover the whole sky then the returned tract may not
140 include the coord.
141 """
142 distTractInfoList = []
143 for i, tractInfo in enumerate(self):
144 angSep = coord.separation(tractInfo.getCtrCoord()).asDegrees()
145 # include index in order to disambiguate identical angSep values
146 distTractInfoList.append((angSep, i, tractInfo))
147 distTractInfoList.sort()
148 return distTractInfoList[0][2]
149
150 def findTractIdArray(self, ra, dec, degrees=False):
151 """Find array of tract IDs with vectorized operations (where supported).
152
153 If a given sky map does not support vectorized operations, then a loop
154 over findTract will be called.
155
156 Parameters
157 ----------
158 ra : `np.ndarray`
159 Array of Right Ascension. Units are radians unless
160 degrees=True.
161 dec : `np.ndarray`
162 Array of Declination. Units are radians unless
163 degrees=True.
164 degrees : `bool`, optional
165 Input ra, dec arrays are degrees if True.
166
167 Returns
168 -------
169 tractId : `np.ndarray`
170 Array of tract IDs
171
172 Notes
173 -----
174 - If coord is equidistant between multiple sky tract centers then one
175 is arbitrarily chosen.
176
177 **Warning:**
178 If tracts do not cover the whole sky then the returned tract may not
179 include the given ra/dec.
180 """
181 units = geom.degrees if degrees else geom.radians
182 coords = [geom.SpherePoint(r*units, d*units) for r, d in zip(np.atleast_1d(ra),
183 np.atleast_1d(dec))]
184
185 tractId = np.array([self.findTractfindTract(coord).getId() for coord in coords])
186
187 return tractId
188
189 def findTractPatchList(self, coordList):
190 """Find tracts and patches that overlap a region.
191
192 Parameters
193 ----------
194 coordList : `list` of `lsst.geom.SpherePoint`
195 List of ICRS sky coordinates to search for.
196
197 Returns
198 -------
199 reList : `list` of (`TractInfo`, `list` of `PatchInfo`)
200 For tracts and patches that contain, or may contain, the specified
201 region. The list will be empty if there is no overlap.
202
203 Notes
204 -----
205 **warning:**
206 This uses a naive algorithm that may find some tracts and patches
207 that do not overlap the region (especially if the region is not a
208 rectangle aligned along patch x, y).
209 """
210 retList = []
211 for tractInfo in self:
212 patchList = tractInfo.findPatchList(coordList)
213 if patchList:
214 retList.append((tractInfo, patchList))
215 return retList
216
217 def findClosestTractPatchList(self, coordList):
218 """Find closest tract and patches that overlap coordinates.
219
220 Parameters
221 ----------
222 coordList : `lsst.geom.SpherePoint`
223 List of ICRS sky coordinates to search for.
224
225 Returns
226 -------
227 retList : `list`
228 list of (TractInfo, list of PatchInfo) for tracts and patches
229 that contain, or may contain, the specified region.
230 The list will be empty if there is no overlap.
231 """
232 retList = []
233 for coord in coordList:
234 tractInfo = self.findTractfindTract(coord)
235 patchList = tractInfo.findPatchList(coordList)
236 if patchList and not (tractInfo, patchList) in retList:
237 retList.append((tractInfo, patchList))
238 return retList
239
240 def __getitem__(self, ind):
241 return self._tractInfoList_tractInfoList[ind]
242
243 def __iter__(self):
244 return iter(self._tractInfoList_tractInfoList)
245
246 def __len__(self):
247 return len(self._tractInfoList_tractInfoList)
248
249 def __hash__(self):
250 return hash(self.getSha1getSha1())
251
252 def __eq__(self, other):
253 try:
254 return self.getSha1getSha1() == other.getSha1()
255 except AttributeError:
256 return NotImplemented
257
258 def __ne__(self, other):
259 return not (self == other)
260
261 def logSkyMapInfo(self, log):
262 """Write information about a sky map to supplied log
263
264 Parameters
265 ----------
266 log : `lsst.log.Log`
267 Log object that information about skymap will be written
268 """
269 log.info("sky map has %s tracts" % (len(self),))
270 for tractInfo in self:
271 wcs = tractInfo.getWcs()
272 posBox = geom.Box2D(tractInfo.getBBox())
273 pixelPosList = (
274 posBox.getMin(),
275 geom.Point2D(posBox.getMaxX(), posBox.getMinY()),
276 posBox.getMax(),
277 geom.Point2D(posBox.getMinX(), posBox.getMaxY()),
278 )
279 skyPosList = [wcs.pixelToSky(pos).getPosition(geom.degrees) for pos in pixelPosList]
280 posStrList = ["(%0.3f, %0.3f)" % tuple(skyPos) for skyPos in skyPosList]
281 log.info("tract %s has corners %s (RA, Dec deg) and %s x %s patches" %
282 (tractInfo.getId(), ", ".join(posStrList),
283 tractInfo.getNumPatches()[0], tractInfo.getNumPatches()[1]))
284
285 def getSha1(self):
286 """Return a SHA1 hash that uniquely identifies this SkyMap instance.
287
288 Returns
289 -------
290 sha1 : `bytes`
291 A 20-byte hash that uniquely identifies this SkyMap instance.
292
293 Notes
294 -----
295 Subclasses should almost always override ``updateSha1`` instead of
296 this function to add subclass-specific state to the hash.
297 """
298 if self._sha1_sha1 is None:
299 sha1 = hashlib.sha1()
300 sha1.update(type(self).__name__.encode('utf-8'))
301 configPacked = struct.pack(
302 "<iiidd3sd",
303 self.configconfig.patchInnerDimensions[0],
304 self.configconfig.patchInnerDimensions[1],
305 self.configconfig.patchBorder,
306 self.configconfig.tractOverlap,
307 self.configconfig.pixelScale,
308 self.configconfig.projection.encode('ascii'),
309 self.configconfig.rotation
310 )
311 sha1.update(configPacked)
312 self.updateSha1updateSha1(sha1)
313 self._sha1_sha1 = sha1.digest()
314 return self._sha1_sha1
315
316 def updateSha1(self, sha1):
317 """Add subclass-specific state or configuration options to the SHA1.
318
319 Parameters
320 ----------
321 sha1 : `hashlib.sha1`
322 A hashlib object on which `update` can be called to add
323 additional state to the hash.
324
325 Notes
326 -----
327 This method is conceptually "protected" : it should be reimplemented by
328 all subclasses, but called only by the base class implementation of
329 `getSha1` .
330 """
331 raise NotImplementedError()
332
333 SKYMAP_RUN_COLLECTION_NAME = "skymaps"
334
335 SKYMAP_DATASET_TYPE_NAME = "skyMap"
336
337 def register(self, name, butler):
338 """Add skymap, tract, and patch Dimension entries to the given Gen3
339 Butler.
340
341 Parameters
342 ----------
343 name : `str`
344 The name of the skymap.
345 butler : `lsst.daf.butler.Butler`
346 The butler to add to.
347
348 Raises
349 ------
350 lsst.daf.butler.registry.ConflictingDefinitionError
351 Raised if a different skymap exists with the same name.
352
353 Notes
354 -----
355 Registering the same skymap multiple times (with the exact same
356 definition) is safe, but inefficient; most of the work of computing
357 the rows to be inserted must be done first in order to check for
358 consistency between the new skymap and any existing one.
359
360 Re-registering a skymap with different tract and/or patch definitions
361 but the same summary information may not be detected as a conflict but
362 will never result in updating the skymap; there is intentionally no
363 way to modify a registered skymap (aside from manual administrative
364 operations on the database), as it is hard to guarantee that this can
365 be done without affecting reproducibility.
366 """
367 nxMax = 0
368 nyMax = 0
369 tractRecords = []
370 patchRecords = []
371 for tractInfo in self:
372 nx, ny = tractInfo.getNumPatches()
373 nxMax = max(nxMax, nx)
374 nyMax = max(nyMax, ny)
375 region = tractInfo.getOuterSkyPolygon()
376 centroid = SpherePoint(region.getCentroid())
377 tractRecords.append({
378 "skymap": name,
379 "tract": tractInfo.getId(),
380 "region": region,
381 "ra": centroid.getRa().asDegrees(),
382 "dec": centroid.getDec().asDegrees(),
383 })
384 for patchInfo in tractInfo:
385 cellX, cellY = patchInfo.getIndex()
386 patchRecords.append({
387 "skymap": name,
388 "tract": tractInfo.getId(),
389 "patch": tractInfo.getSequentialPatchIndex(patchInfo),
390 "cell_x": cellX,
391 "cell_y": cellY,
392 "region": patchInfo.getOuterSkyPolygon(tractInfo.getWcs()),
393 })
394 skyMapRecord = {
395 "skymap": name,
396 "hash": self.getSha1getSha1(),
397 "tract_max": len(self),
398 "patch_nx_max": nxMax,
399 "patch_ny_max": nyMax,
400 }
401 butler.registry.registerRun(self.SKYMAP_RUN_COLLECTION_NAMESKYMAP_RUN_COLLECTION_NAME)
402 # Kind of crazy that we've got three different capitalizations of
403 # "skymap" here, but that's what the various conventions (or at least
404 # precedents) dictate.
405 from lsst.daf.butler import DatasetType
406 from lsst.daf.butler.registry import ConflictingDefinitionError
407 datasetType = DatasetType(
408 name=self.SKYMAP_DATASET_TYPE_NAMESKYMAP_DATASET_TYPE_NAME,
409 dimensions=["skymap"],
410 storageClass="SkyMap",
411 universe=butler.registry.dimensions
412 )
413 butler.registry.registerDatasetType(datasetType)
414 with butler.transaction():
415 try:
416 inserted = butler.registry.syncDimensionData("skymap", skyMapRecord)
417 except ConflictingDefinitionError as err:
418 raise ConflictingDefinitionError(
419 f"SkyMap with hash {self.getSha1().hex()} is already registered with a different name."
420 ) from err
421 if inserted:
422 butler.registry.insertDimensionData("tract", *tractRecords)
423 butler.registry.insertDimensionData("patch", *patchRecords)
424 butler.put(self, datasetType, {"skymap": name}, run=self.SKYMAP_RUN_COLLECTION_NAMESKYMAP_RUN_COLLECTION_NAME)
def register(self, name, butler)
Definition: baseSkyMap.py:337
def findClosestTractPatchList(self, coordList)
Definition: baseSkyMap.py:217
def findTractPatchList(self, coordList)
Definition: baseSkyMap.py:189
def findTractIdArray(self, ra, dec, degrees=False)
Definition: baseSkyMap.py:150
def __init__(self, config=None)
Definition: baseSkyMap.py:104