Coverage for python/lsst/skymap/baseSkyMap.py: 23%

120 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2022-12-01 20:23 +0000

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 

76class BaseSkyMap: 

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.ConfigClass() 

107 config.freeze() # just to be sure, e.g. for pickling 

108 self.config = config 

109 self._tractInfoList = [] 

110 self._wcsFactory = detail.WcsFactory( 

111 pixelScale=Angle(self.config.pixelScale, arcseconds), 

112 projection=self.config.projection, 

113 rotation=Angle(self.config.rotation, degrees), 

114 ) 

115 self._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.findTract(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.findTract(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[ind] 

242 

243 def __iter__(self): 

244 return iter(self._tractInfoList) 

245 

246 def __len__(self): 

247 return len(self._tractInfoList) 

248 

249 def __hash__(self): 

250 return hash(self.getSha1()) 

251 

252 def __eq__(self, other): 

253 try: 

254 return self.getSha1() == 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 is None: 

299 sha1 = hashlib.sha1() 

300 sha1.update(type(self).__name__.encode('utf-8')) 

301 configPacked = struct.pack( 

302 "<iiidd3sd", 

303 self.config.patchInnerDimensions[0], 

304 self.config.patchInnerDimensions[1], 

305 self.config.patchBorder, 

306 self.config.tractOverlap, 

307 self.config.pixelScale, 

308 self.config.projection.encode('ascii'), 

309 self.config.rotation 

310 ) 

311 sha1.update(configPacked) 

312 self.updateSha1(sha1) 

313 self._sha1 = sha1.digest() 

314 return self._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.getSha1(), 

397 "tract_max": len(self), 

398 "patch_nx_max": nxMax, 

399 "patch_ny_max": nyMax, 

400 } 

401 butler.registry.registerRun(self.SKYMAP_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_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_NAME)