Coverage for python/lsst/skymap/tractInfo.py: 18%

161 statements  

« prev     ^ index     » next       coverage.py v7.1.0, created at 2023-02-05 17:57 -0800

1# 

2# LSST Data Management System 

3# Copyright 2008, 2009, 2010 LSST Corporation. 

4# 

5# This product includes software developed by the 

6# LSST Project (http://www.lsst.org/). 

7# 

8# This program is free software: you can redistribute it and/or modify 

9# it under the terms of the GNU General Public License as published by 

10# the Free Software Foundation, either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# This program is distributed in the hope that it will be useful, 

14# but WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <http://www.lsstcorp.org/LegalNotices/>. 

21# 

22 

23__all__ = ["TractInfo"] 

24 

25import numbers 

26 

27import lsst.pex.exceptions 

28import lsst.geom as geom 

29from lsst.sphgeom import ConvexPolygon 

30 

31from .patchInfo import PatchInfo, makeSkyPolygonFromBBox 

32 

33 

34class TractInfo: 

35 """Information about a tract in a SkyMap sky pixelization 

36 

37 Parameters 

38 ---------- 

39 id : `int` 

40 tract ID 

41 patchInnerDimensions : `tuple` of `int` 

42 Dimensions of inner region of patches (x,y pixels). 

43 patchBorder : `int` 

44 Overlap between adjacent patches (in pixels) 

45 ctrCoord : `lsst.geom.SpherePoint` 

46 ICRS sky coordinate of center of inner region of tract; also used as 

47 the CRVAL for the WCS. 

48 vertexCoordList : `list` of `lsst.geom.SpherePoint` 

49 Vertices that define the boundaries of the inner region. 

50 tractOverlap : `lsst.geom.Angle` 

51 Minimum overlap between adjacent sky tracts; this defines the minimum 

52 distance the tract extends beyond the inner region in all directions. 

53 wcs : `lsst.afw.image.SkyWcs` 

54 WCS for tract. The reference pixel will be shifted as required so that 

55 the lower left-hand pixel (index 0,0) has pixel position 0.0, 0.0. 

56 

57 Notes 

58 ----- 

59 The tract is subdivided into rectangular patches. Each patch has the 

60 following properties: 

61 

62 - An inner region defined by an inner bounding box. The inner regions of 

63 the patches exactly tile the tract, and all inner regions have the same 

64 dimensions. The tract is made larger as required to make this work. 

65 

66 - An outer region defined by an outer bounding box. The outer region 

67 extends beyond the inner region by patchBorder pixels in all directions, 

68 except there is no border at the edges of the tract. 

69 Thus patches overlap each other but never extend off the tract. 

70 If you do not want any overlap between adjacent patches then set 

71 patchBorder to 0. 

72 

73 - An index that consists of a pair of integers: 

74 

75 * 0 <= x index < numPatches[0] 

76 

77 * 0 <= y index < numPatches[1] 

78 

79 Patch 0,0 is at the minimum corner of the tract bounding box. 

80 

81 - It is not enforced that ctrCoord is the center of vertexCoordList, but 

82 SkyMap relies on it. 

83 """ 

84 

85 def __init__(self, id, patchInnerDimensions, patchBorder, ctrCoord, vertexCoordList, tractOverlap, wcs): 

86 self._id = id 

87 try: 

88 assert len(patchInnerDimensions) == 2 

89 self._patchInnerDimensions = geom.Extent2I(*(int(val) for val in patchInnerDimensions)) 

90 except Exception: 

91 raise TypeError("patchInnerDimensions=%s; must be two ints" % (patchInnerDimensions,)) 

92 self._patchBorder = int(patchBorder) 

93 self._ctrCoord = ctrCoord 

94 self._vertexCoordList = tuple(vertexCoordList) 

95 self._tractOverlap = tractOverlap 

96 

97 minBBox = self._minimumBoundingBox(wcs) 

98 initialBBox, self._numPatches = self._setupPatches(minBBox, wcs) 

99 self._bbox, self._wcs = self._finalOrientation(initialBBox, wcs) 

100 

101 def _minimumBoundingBox(self, wcs): 

102 """Calculate the minimum bounding box for the tract, given the WCS. 

103 

104 The bounding box is created in the frame of the supplied WCS, 

105 so that it's OK if the coordinates are negative. 

106 

107 We compute the bounding box that holds all the vertices and the 

108 desired overlap. 

109 """ 

110 minBBoxD = geom.Box2D() 

111 halfOverlap = self._tractOverlap / 2.0 

112 for vertexCoord in self._vertexCoordList: 

113 if self._tractOverlap == 0: 

114 minBBoxD.include(wcs.skyToPixel(vertexCoord)) 

115 else: 

116 numAngles = 24 

117 angleIncr = geom.Angle(360.0, geom.degrees) / float(numAngles) 

118 for i in range(numAngles): 

119 offAngle = angleIncr * i 

120 offCoord = vertexCoord.offset(offAngle, halfOverlap) 

121 pixPos = wcs.skyToPixel(offCoord) 

122 minBBoxD.include(pixPos) 

123 return minBBoxD 

124 

125 def _setupPatches(self, minBBox, wcs): 

126 """Setup for patches of a particular size. 

127 

128 We grow the bounding box to hold an exact multiple of 

129 the desired size (patchInnerDimensions), while keeping 

130 the center roughly the same. We return the final 

131 bounding box, and the number of patches in each dimension 

132 (as an Extent2I). 

133 

134 Parameters 

135 ---------- 

136 minBBox : `lsst.geom.Box2I` 

137 Minimum bounding box for tract 

138 wcs : `lsst.afw.geom.SkyWcs` 

139 Wcs object 

140 

141 Returns 

142 ------- 

143 bbox : `lsst.geom.Box2I 

144 final bounding box, number of patches 

145 numPatches : `int` 

146 """ 

147 bbox = geom.Box2I(minBBox) 

148 bboxMin = bbox.getMin() 

149 bboxDim = bbox.getDimensions() 

150 numPatches = geom.Extent2I(0, 0) 

151 for i, innerDim in enumerate(self._patchInnerDimensions): 

152 num = (bboxDim[i] + innerDim - 1) // innerDim # round up 

153 deltaDim = (innerDim * num) - bboxDim[i] 

154 if deltaDim > 0: 

155 bboxDim[i] = innerDim * num 

156 bboxMin[i] -= deltaDim // 2 

157 numPatches[i] = num 

158 bbox = geom.Box2I(bboxMin, bboxDim) 

159 return bbox, numPatches 

160 

161 def _finalOrientation(self, bbox, wcs): 

162 """Determine the final orientation 

163 

164 We offset everything so the lower-left corner is at 0,0 

165 and compute the final Wcs. 

166 

167 Parameters 

168 ---------- 

169 bbox : `lsst.geom.Box2I` 

170 Current bounding box. 

171 wcs : `lsst.afw.geom.SkyWcs 

172 Current Wcs. 

173 

174 Returns 

175 ------- 

176 finalBBox : `lsst.geom.Box2I` 

177 Revised bounding box. 

178 wcs : `lsst.afw.geom.SkyWcs` 

179 Revised Wcs. 

180 """ 

181 finalBBox = geom.Box2I(geom.Point2I(0, 0), bbox.getDimensions()) 

182 # shift the WCS by the same amount as the bbox; extra code is required 

183 # because simply subtracting makes an Extent2I 

184 pixPosOffset = geom.Extent2D(finalBBox.getMinX() - bbox.getMinX(), 

185 finalBBox.getMinY() - bbox.getMinY()) 

186 wcs = wcs.copyAtShiftedPixelOrigin(pixPosOffset) 

187 return finalBBox, wcs 

188 

189 def getSequentialPatchIndex(self, patchInfo): 

190 """Return a single integer that uniquely identifies the given patch 

191 within this tract. 

192 """ 

193 x, y = patchInfo.getIndex() 

194 nx, ny = self.getNumPatches() 

195 return nx*y + x 

196 

197 def getPatchIndexPair(self, sequentialIndex): 

198 nx, ny = self.getNumPatches() 

199 x = sequentialIndex % nx 

200 y = (sequentialIndex - x) // nx 

201 return (x, y) 

202 

203 def findPatch(self, coord): 

204 """Find the patch containing the specified coord. 

205 

206 Parameters 

207 ---------- 

208 coord : `lsst.geom.SpherePoint` 

209 ICRS sky coordinate to search for. 

210 

211 Returns 

212 ------- 

213 result : `lsst.skymap.PatchInfo` 

214 PatchInfo of patch whose inner bbox contains the specified coord 

215 

216 Raises 

217 ------ 

218 LookupError 

219 If coord is not in tract or we cannot determine the 

220 pixel coordinate (which likely means the coord is off the tract). 

221 """ 

222 try: 

223 pixel = self.getWcs().skyToPixel(coord) 

224 except (lsst.pex.exceptions.DomainError, lsst.pex.exceptions.RuntimeError): 

225 # Point must be way off the tract 

226 raise LookupError("Unable to determine pixel position for coordinate %s" % (coord,)) 

227 pixelInd = geom.Point2I(pixel) 

228 if not self.getBBox().contains(pixelInd): 

229 raise LookupError("coord %s is not in tract %s" % (coord, self.getId())) 

230 patchInd = tuple(int(pixelInd[i]/self._patchInnerDimensions[i]) for i in range(2)) 

231 return self.getPatchInfo(patchInd) 

232 

233 def findPatchList(self, coordList): 

234 """Find patches containing the specified list of coords. 

235 

236 Parameters 

237 ---------- 

238 coordList : `list` of `lsst.geom.SpherePoint` 

239 ICRS sky coordinates to search for. 

240 

241 Returns 

242 ------- 

243 result : `list` of `lsst.skymap.PatchInfo` 

244 List of PatchInfo for patches that contain, or may contain, the 

245 specified region. The list will be empty if there is no overlap. 

246 

247 Notes 

248 ----- 

249 **Warning:** 

250 

251 - This may give incorrect answers on regions that are larger than a 

252 tract. 

253 

254 - This uses a naive algorithm that may find some patches that do not 

255 overlap the region (especially if the region is not a rectangle 

256 aligned along patch x,y). 

257 """ 

258 box2D = geom.Box2D() 

259 for coord in coordList: 

260 try: 

261 pixelPos = self.getWcs().skyToPixel(coord) 

262 except (lsst.pex.exceptions.DomainError, lsst.pex.exceptions.RuntimeError): 

263 # the point is so far off the tract that its pixel position cannot be computed 

264 continue 

265 box2D.include(pixelPos) 

266 bbox = geom.Box2I(box2D) 

267 bbox.grow(self.getPatchBorder()) 

268 bbox.clip(self.getBBox()) 

269 if bbox.isEmpty(): 

270 return () 

271 

272 llPatchInd = tuple(int(bbox.getMin()[i]/self._patchInnerDimensions[i]) for i in range(2)) 

273 urPatchInd = tuple(int(bbox.getMax()[i]/self._patchInnerDimensions[i]) for i in range(2)) 

274 return tuple(self.getPatchInfo((xInd, yInd)) 

275 for xInd in range(llPatchInd[0], urPatchInd[0]+1) 

276 for yInd in range(llPatchInd[1], urPatchInd[1]+1)) 

277 

278 def getBBox(self): 

279 """Get bounding box of tract (as an geom.Box2I) 

280 """ 

281 return geom.Box2I(self._bbox) 

282 

283 def getCtrCoord(self): 

284 """Get ICRS sky coordinate of center of tract 

285 (as an lsst.geom.SpherePoint) 

286 """ 

287 return self._ctrCoord 

288 

289 def getId(self): 

290 """Get ID of tract 

291 """ 

292 return self._id 

293 

294 def getNumPatches(self): 

295 """Get the number of patches in x, y. 

296 

297 Returns 

298 ------- 

299 result : `tuple` of `int` 

300 The number of patches in x, y 

301 """ 

302 return self._numPatches 

303 

304 def getPatchBorder(self): 

305 return self._patchBorder 

306 

307 def getPatchInfo(self, index): 

308 """Return information for the specified patch. 

309 

310 Parameters 

311 ---------- 

312 index : `tuple` of `int` 

313 Index of patch, as a pair of ints; 

314 or a sequential index as returned by getSequentialPatchIndex; 

315 negative values are not supported. 

316 

317 Returns 

318 ------- 

319 result : `lsst.skymap.PatchInfo` 

320 The patch info for that index. 

321 

322 Raises 

323 ------ 

324 IndexError 

325 If index is out of range. 

326 """ 

327 if isinstance(index, numbers.Number): 

328 index = self.getPatchIndexPair(index) 

329 if (not 0 <= index[0] < self._numPatches[0]) \ 

330 or (not 0 <= index[1] < self._numPatches[1]): 

331 raise IndexError("Patch index %s is not in range [0-%d, 0-%d]" % 

332 (index, self._numPatches[0]-1, self._numPatches[1]-1)) 

333 innerMin = geom.Point2I(*[index[i] * self._patchInnerDimensions[i] for i in range(2)]) 

334 innerBBox = geom.Box2I(innerMin, self._patchInnerDimensions) 

335 if not self._bbox.contains(innerBBox): 

336 raise RuntimeError( 

337 "Bug: patch index %s valid but inner bbox=%s not contained in tract bbox=%s" % 

338 (index, innerBBox, self._bbox)) 

339 outerBBox = geom.Box2I(innerBBox) 

340 outerBBox.grow(self.getPatchBorder()) 

341 outerBBox.clip(self._bbox) 

342 return PatchInfo( 

343 index=index, 

344 innerBBox=innerBBox, 

345 outerBBox=outerBBox, 

346 ) 

347 

348 def getPatchInnerDimensions(self): 

349 """Get dimensions of inner region of the patches (all are the same) 

350 """ 

351 return self._patchInnerDimensions 

352 

353 def getTractOverlap(self): 

354 """Get minimum overlap of adjacent sky tracts. 

355 """ 

356 return self._tractOverlap 

357 

358 def getVertexList(self): 

359 """Get list of ICRS sky coordinates of vertices that define the 

360 boundary of the inner region. 

361 

362 Notes 

363 ----- 

364 **warning:** this is not a deep copy. 

365 """ 

366 return self._vertexCoordList 

367 

368 def getInnerSkyPolygon(self): 

369 """Get inner on-sky region as a sphgeom.ConvexPolygon. 

370 """ 

371 skyUnitVectors = [sp.getVector() for sp in self.getVertexList()] 

372 return ConvexPolygon.convexHull(skyUnitVectors) 

373 

374 def getOuterSkyPolygon(self): 

375 """Get outer on-sky region as a sphgeom.ConvexPolygon 

376 """ 

377 return makeSkyPolygonFromBBox(bbox=self.getBBox(), wcs=self.getWcs()) 

378 

379 def getWcs(self): 

380 """Get WCS of tract. 

381 

382 Returns 

383 ------- 

384 wcs : `lsst.afw.geom.SkyWcs` 

385 The WCS of this tract 

386 """ 

387 return self._wcs 

388 

389 def __str__(self): 

390 return "TractInfo(id=%s)" % (self._id,) 

391 

392 def __repr__(self): 

393 return "TractInfo(id=%s, ctrCoord=%s)" % (self._id, self._ctrCoord.getVector()) 

394 

395 def __iter__(self): 

396 xNum, yNum = self.getNumPatches() 

397 for y in range(yNum): 

398 for x in range(xNum): 

399 yield self.getPatchInfo((x, y)) 

400 

401 def __len__(self): 

402 xNum, yNum = self.getNumPatches() 

403 return xNum*yNum 

404 

405 def __getitem__(self, index): 

406 return self.getPatchInfo(index) 

407 

408 def contains(self, coord): 

409 """Does this tract contain the coordinate?""" 

410 try: 

411 pixels = self.getWcs().skyToPixel(coord) 

412 except (lsst.pex.exceptions.DomainError, lsst.pex.exceptions.RuntimeError): 

413 # Point must be way off the tract 

414 return False 

415 return self.getBBox().contains(geom.Point2I(pixels)) 

416 

417 

418class ExplicitTractInfo(TractInfo): 

419 """Information for a tract specified explicitly. 

420 

421 A tract is placed at the explicitly defined coordinates, with the nominated 

422 radius. The tracts are square (i.e., the radius is really a half-size). 

423 """ 

424 

425 def __init__(self, ident, patchInnerDimensions, patchBorder, ctrCoord, radius, tractOverlap, wcs): 

426 # We don't want TractInfo setting the bbox on the basis of vertices, but on the radius. 

427 vertexList = [] 

428 self._radius = radius 

429 super(ExplicitTractInfo, self).__init__(ident, patchInnerDimensions, patchBorder, ctrCoord, 

430 vertexList, tractOverlap, wcs) 

431 # Shrink the box slightly to make sure the vertices are in the tract 

432 bboxD = geom.BoxD(self.getBBox()) 

433 bboxD.grow(-0.001) 

434 finalWcs = self.getWcs() 

435 self._vertexCoordList = finalWcs.pixelToSky(bboxD.getCorners()) 

436 

437 def _minimumBoundingBox(self, wcs): 

438 """Calculate the minimum bounding box for the tract, given the WCS, and 

439 the nominated radius. 

440 """ 

441 bbox = geom.Box2D() 

442 for i in range(4): 

443 cornerCoord = self._ctrCoord.offset(i*90*geom.degrees, self._radius + self._tractOverlap) 

444 pixPos = wcs.skyToPixel(cornerCoord) 

445 bbox.include(pixPos) 

446 return bbox