Coverage for python/lsst/meas/astrom/matchPessimisticB.py: 18%

177 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-02 04:07 -0700

1# This file is part of meas_astrom. 

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/>. 

21 

22__all__ = ["MatchPessimisticBTask", "MatchPessimisticBConfig", 

23 "MatchTolerancePessimistic"] 

24 

25import numpy as np 

26from scipy.spatial import cKDTree 

27 

28import lsst.pex.config as pexConfig 

29import lsst.pipe.base as pipeBase 

30import lsst.geom as geom 

31import lsst.afw.table as afwTable 

32from lsst.utils.timer import timeMethod 

33 

34from . import exceptions 

35from .matchOptimisticBTask import MatchTolerance 

36from .pessimistic_pattern_matcher_b_3D import PessimisticPatternMatcherB 

37 

38 

39class MatchTolerancePessimistic(MatchTolerance): 

40 """Stores match tolerances for use in AstrometryTask and later 

41 iterations of the matcher. 

42 

43 MatchPessimisticBTask relies on several state variables to be 

44 preserved over different iterations in the 

45 AstrometryTask.matchAndFitWcs loop of AstrometryTask. 

46 

47 Parameters 

48 ---------- 

49 maxMatchDist : `lsst.geom.Angle` 

50 Maximum distance to consider a match from the previous match/fit 

51 iteration. 

52 autoMaxMatchDist : `lsst.geom.Angle` 

53 Automated estimation of the maxMatchDist from the sky statistics of the 

54 source and reference catalogs. 

55 maxShift : `lsst.geom.Angle` 

56 Maximum shift found in the previous match/fit cycle. 

57 lastMatchedPattern : `int` 

58 Index of the last source pattern that was matched into the reference 

59 data. 

60 failedPatternList : `list` of `int` 

61 Previous matches were found to be false positives. 

62 PPMbObj : `lsst.meas.astrom.PessimisticPatternMatcherB` 

63 Initialized Pessimistic pattern matcher object. Storing this prevents 

64 the need for recalculation of the searchable distances in the PPMB. 

65 """ 

66 

67 def __init__(self, maxMatchDist=None, autoMaxMatchDist=None, 

68 maxShift=None, lastMatchedPattern=None, 

69 failedPatternList=None, PPMbObj=None): 

70 self.maxMatchDist = maxMatchDist 

71 self.autoMaxMatchDist = autoMaxMatchDist 

72 self.maxShift = maxShift 

73 self.lastMatchedPattern = lastMatchedPattern 

74 self.PPMbObj = PPMbObj 

75 if failedPatternList is None: 

76 self.failedPatternList = [] 

77 else: 

78 self.failedPatternList = failedPatternList 

79 

80 

81class MatchPessimisticBConfig(pexConfig.Config): 

82 """Configuration for MatchPessimisticBTask 

83 """ 

84 numBrightStars = pexConfig.RangeField( 

85 doc="Maximum number of bright stars to use. Sets the max number of patterns " 

86 "that can be tested.", 

87 dtype=int, 

88 default=150, 

89 min=2, 

90 ) 

91 minMatchedPairs = pexConfig.RangeField( 

92 doc="Minimum number of matched pairs; see also minFracMatchedPairs.", 

93 dtype=int, 

94 default=30, 

95 min=2, 

96 ) 

97 minFracMatchedPairs = pexConfig.RangeField( 

98 doc="Minimum number of matched pairs as a fraction of the smaller of " 

99 "the number of reference stars or the number of good sources; " 

100 "the actual minimum is the smaller of this value or " 

101 "minMatchedPairs.", 

102 dtype=float, 

103 default=0.3, 

104 min=0, 

105 max=1, 

106 ) 

107 matcherIterations = pexConfig.RangeField( 

108 doc="Number of softening iterations in matcher.", 

109 dtype=int, 

110 default=5, 

111 min=1, 

112 ) 

113 maxOffsetPix = pexConfig.RangeField( 

114 doc="Maximum allowed shift of WCS, due to matching (pixel). " 

115 "When changing this value, the " 

116 "LoadReferenceObjectsConfig.pixelMargin should also be updated.", 

117 dtype=int, 

118 default=250, 

119 max=4000, 

120 ) 

121 maxRotationDeg = pexConfig.RangeField( 

122 doc="Rotation angle allowed between sources and position reference " 

123 "objects (degrees).", 

124 dtype=float, 

125 default=1.0, 

126 max=6.0, 

127 ) 

128 numPointsForShape = pexConfig.Field( 

129 doc="Number of points to define a shape for matching.", 

130 dtype=int, 

131 default=6, 

132 ) 

133 numPointsForShapeAttempt = pexConfig.Field( 

134 doc="Number of points to try for creating a shape. This value should " 

135 "be greater than or equal to numPointsForShape. Besides " 

136 "loosening the signal to noise cut in the 'matcher' SourceSelector, " 

137 "increasing this number will solve CCDs where no match was found.", 

138 dtype=int, 

139 default=6, 

140 ) 

141 minMatchDistPixels = pexConfig.RangeField( 

142 doc="Distance in units of pixels to always consider a source-" 

143 "reference pair a match. This prevents the astrometric fitter " 

144 "from over-fitting and removing stars that should be matched and " 

145 "allows for inclusion of new matches as the wcs improves.", 

146 dtype=float, 

147 default=1.0, 

148 min=0.0, 

149 max=6.0, 

150 ) 

151 numPatternConsensus = pexConfig.Field( 

152 doc="Number of implied shift/rotations from patterns that must agree " 

153 "before it a given shift/rotation is accepted. This is only used " 

154 "after the first softening iteration fails and if both the " 

155 "number of reference and source objects is greater than " 

156 "numBrightStars.", 

157 dtype=int, 

158 default=3, 

159 ) 

160 numRefRequireConsensus = pexConfig.Field( 

161 doc="If the available reference objects exceeds this number, " 

162 "consensus/pessimistic mode will enforced regardless of the " 

163 "number of available sources. Below this optimistic mode (" 

164 "exit at first match rather than requiring numPatternConsensus to " 

165 "be matched) can be used. If more sources are required to match, " 

166 "decrease the signal to noise cut in the sourceSelector.", 

167 dtype=int, 

168 default=1000, 

169 ) 

170 maxRefObjects = pexConfig.RangeField( 

171 doc="Maximum number of reference objects to use for the matcher. The " 

172 "absolute maximum allowed for is 2 ** 16 for memory reasons.", 

173 dtype=int, 

174 default=2**16, 

175 min=0, 

176 max=2**16 + 1, 

177 ) 

178 

179 def validate(self): 

180 pexConfig.Config.validate(self) 

181 if self.numPointsForShapeAttempt < self.numPointsForShape: 

182 raise ValueError("numPointsForShapeAttempt must be greater than " 

183 "or equal to numPointsForShape.") 

184 if self.numPointsForShape > self.numBrightStars: 

185 raise ValueError("numBrightStars must be greater than " 

186 "numPointsForShape.") 

187 

188 

189# The following block adds links to this task from the Task Documentation page. 

190# \addtogroup LSST_task_documentation 

191# \{ 

192# \page measAstrom_MatchPessimisticBTask 

193# \ref MatchPessimisticBTask "MatchPessimisticBTask" 

194# Match sources to reference objects 

195# \} 

196 

197 

198class MatchPessimisticBTask(pipeBase.Task): 

199 """Match sources to reference objects. 

200 """ 

201 

202 ConfigClass = MatchPessimisticBConfig 

203 _DefaultName = "matchPessimisticB" 

204 

205 def __init__(self, **kwargs): 

206 pipeBase.Task.__init__(self, **kwargs) 

207 

208 @timeMethod 

209 def matchObjectsToSources(self, refCat, sourceCat, wcs, sourceFluxField, refFluxField, 

210 matchTolerance=None): 

211 """Match sources to position reference stars 

212 

213 refCat : `lsst.afw.table.SimpleCatalog` 

214 catalog of reference objects that overlap the exposure; reads 

215 fields for: 

216 

217 - coord 

218 - the specified flux field 

219 

220 sourceCat : `lsst.afw.table.SourceCatalog` 

221 Catalog of sources found on an exposure. This should already be 

222 down-selected to "good"/"usable" sources in the calling Task. 

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

224 estimated WCS 

225 sourceFluxField: `str` 

226 field of sourceCat to use for flux 

227 refFluxField : `str` 

228 field of refCat to use for flux 

229 matchTolerance : `lsst.meas.astrom.MatchTolerancePessimistic` 

230 is a MatchTolerance class object or `None`. This this class is used 

231 to communicate state between AstrometryTask and MatcherTask. 

232 AstrometryTask will also set the MatchTolerance class variable 

233 maxMatchDist based on the scatter AstrometryTask has found after 

234 fitting for the wcs. 

235 

236 Returns 

237 ------- 

238 result : `lsst.pipe.base.Struct` 

239 Result struct with components: 

240 

241 - ``matches`` : source to reference matches found (`list` of 

242 `lsst.afw.table.ReferenceMatch`) 

243 - ``usableSourceCat`` : a catalog of sources potentially usable for 

244 matching and WCS fitting (`lsst.afw.table.SourceCatalog`). 

245 - ``matchTolerance`` : a MatchTolerance object containing the 

246 resulting state variables from the match 

247 (`lsst.meas.astrom.MatchTolerancePessimistic`). 

248 """ 

249 import lsstDebug 

250 debug = lsstDebug.Info(__name__) 

251 

252 # If we get an empty tolerance struct create the variables we need for 

253 # this matcher. 

254 if matchTolerance is None: 

255 matchTolerance = MatchTolerancePessimistic() 

256 

257 # Make a name alias here for consistency with older code, and to make 

258 # it clear that this is a good/usable (cleaned) source catalog. 

259 goodSourceCat = sourceCat 

260 

261 if (numUsableSources := len(goodSourceCat)) == 0: 

262 raise exceptions.MatcherFailure("No sources are good") 

263 

264 minMatchedPairs = min(self.config.minMatchedPairs, 

265 int(self.config.minFracMatchedPairs 

266 * min([len(refCat), len(goodSourceCat)]))) 

267 

268 if len(goodSourceCat) <= self.config.numPointsForShape: 

269 msg = (f"Not enough catalog objects ({len(goodSourceCat)}) to make a " 

270 f"shape for the matcher (need {self.config.numPointsForShape}).") 

271 raise exceptions.MatcherFailure(msg) 

272 if len(refCat) <= self.config.numPointsForShape: 

273 msg = (f"Not enough refcat objects ({len(refCat)}) to make a " 

274 f"shape for the matcher (need {self.config.numPointsForShape}).") 

275 raise exceptions.MatcherFailure(msg) 

276 

277 if len(refCat) > self.config.maxRefObjects: 

278 self.log.warning( 

279 "WARNING: Reference catalog larger than maximum allowed. " 

280 "Trimming to %i", self.config.maxRefObjects) 

281 trimmedRefCat = self._filterRefCat(refCat, refFluxField) 

282 else: 

283 trimmedRefCat = refCat 

284 

285 doMatchReturn = self._doMatch( 

286 refCat=trimmedRefCat, 

287 sourceCat=goodSourceCat, 

288 wcs=wcs, 

289 refFluxField=refFluxField, 

290 numUsableSources=numUsableSources, 

291 minMatchedPairs=minMatchedPairs, 

292 matchTolerance=matchTolerance, 

293 sourceFluxField=sourceFluxField, 

294 verbose=debug.verbose, 

295 ) 

296 matches = doMatchReturn.matches 

297 matchTolerance = doMatchReturn.matchTolerance 

298 

299 if (nMatches := len(matches)) == 0: 

300 raise exceptions.MatcherFailure("No matches found") 

301 

302 self.log.info("Matched %d sources", nMatches) 

303 if nMatches < minMatchedPairs: 

304 self.log.warning("Number of matches (%s) is smaller than minimum requested (%s)", 

305 nMatches, minMatchedPairs) 

306 

307 return pipeBase.Struct( 

308 matches=matches, 

309 usableSourceCat=goodSourceCat, 

310 matchTolerance=matchTolerance, 

311 ) 

312 

313 def _filterRefCat(self, refCat, refFluxField): 

314 """Sub-select a number of reference objects starting from the brightest 

315 and maxing out at the number specified by maxRefObjects in the config. 

316 

317 No trimming is done if len(refCat) > config.maxRefObjects. 

318 

319 Parameters 

320 ---------- 

321 refCat : `lsst.afw.table.SimpleCatalog` 

322 Catalog of reference objects to trim. 

323 refFluxField : `str` 

324 field of refCat to use for flux 

325 Returns 

326 ------- 

327 outCat : `lsst.afw.table.SimpleCatalog` 

328 Catalog trimmed to the number set in the task config from the 

329 brightest flux down. 

330 """ 

331 # Find the flux cut that gives us the desired number of objects. 

332 if len(refCat) <= self.config.maxRefObjects: 

333 return refCat 

334 fluxArray = refCat.get(refFluxField) 

335 sortedFluxArray = fluxArray[fluxArray.argsort()] 

336 minFlux = sortedFluxArray[-(self.config.maxRefObjects + 1)] 

337 

338 selected = (refCat.get(refFluxField) > minFlux) 

339 

340 outCat = afwTable.SimpleCatalog(refCat.schema) 

341 outCat.reserve(self.config.maxRefObjects) 

342 outCat.extend(refCat[selected]) 

343 

344 return outCat 

345 

346 @timeMethod 

347 def _doMatch(self, refCat, sourceCat, wcs, refFluxField, numUsableSources, 

348 minMatchedPairs, matchTolerance, sourceFluxField, verbose): 

349 """Implementation of matching sources to position reference objects 

350 

351 Unlike matchObjectsToSources, this method does not check if the sources 

352 are suitable. 

353 

354 Parameters 

355 ---------- 

356 refCat : `lsst.afw.table.SimpleCatalog` 

357 catalog of position reference objects that overlap an exposure 

358 sourceCat : `lsst.afw.table.SourceCatalog` 

359 catalog of sources found on the exposure 

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

361 estimated WCS of exposure 

362 refFluxField : `str` 

363 field of refCat to use for flux 

364 numUsableSources : `int` 

365 number of usable sources (sources with known centroid that are not 

366 near the edge, but may be saturated) 

367 minMatchedPairs : `int` 

368 minimum number of matches 

369 matchTolerance : `lsst.meas.astrom.MatchTolerancePessimistic` 

370 a MatchTolerance object containing variables specifying matcher 

371 tolerances and state from possible previous runs. 

372 sourceFluxField : `str` 

373 Name of the flux field in the source catalog. 

374 verbose : `bool` 

375 Set true to print diagnostic information to std::cout 

376 

377 Returns 

378 ------- 

379 result : 

380 Results struct with components: 

381 

382 - ``matches`` : a list the matches found 

383 (`list` of `lsst.afw.table.ReferenceMatch`). 

384 - ``matchTolerance`` : MatchTolerance containing updated values from 

385 this fit iteration (`lsst.meas.astrom.MatchTolerancePessimistic`) 

386 """ 

387 

388 # Load the source and reference catalog as spherical points 

389 # in numpy array. We do this rather than relying on internal 

390 # lsst C objects for simplicity and because we require 

391 # objects contiguous in memory. We need to do these slightly 

392 # differently for the reference and source cats as they are 

393 # different catalog objects with different fields. 

394 src_array = np.empty((len(sourceCat), 4), dtype=np.float64) 

395 for src_idx, srcObj in enumerate(sourceCat): 

396 coord = wcs.pixelToSky(srcObj.getCentroid()) 

397 theta = np.pi / 2 - coord.getLatitude().asRadians() 

398 phi = coord.getLongitude().asRadians() 

399 flux = srcObj[sourceFluxField] 

400 src_array[src_idx, :] = \ 

401 self._latlong_flux_to_xyz_mag(theta, phi, flux) 

402 

403 if matchTolerance.PPMbObj is None or \ 

404 matchTolerance.autoMaxMatchDist is None: 

405 # The reference catalog is fixed per AstrometryTask so we only 

406 # create the data needed if this is the first step in the match 

407 # fit cycle. 

408 ref_array = np.empty((len(refCat), 4), dtype=np.float64) 

409 for ref_idx, refObj in enumerate(refCat): 

410 theta = np.pi / 2 - refObj.getDec().asRadians() 

411 phi = refObj.getRa().asRadians() 

412 flux = refObj[refFluxField] 

413 ref_array[ref_idx, :] = \ 

414 self._latlong_flux_to_xyz_mag(theta, phi, flux) 

415 # Create our matcher object. 

416 matchTolerance.PPMbObj = PessimisticPatternMatcherB( 

417 ref_array[:, :3], self.log) 

418 self.log.debug("Computing source statistics...") 

419 maxMatchDistArcSecSrc = self._get_pair_pattern_statistics( 

420 src_array) 

421 self.log.debug("Computing reference statistics...") 

422 maxMatchDistArcSecRef = self._get_pair_pattern_statistics( 

423 ref_array) 

424 maxMatchDistArcSec = np.max(( 

425 self.config.minMatchDistPixels 

426 * wcs.getPixelScale().asArcseconds(), 

427 np.min((maxMatchDistArcSecSrc, 

428 maxMatchDistArcSecRef)))) 

429 matchTolerance.autoMaxMatchDist = geom.Angle( 

430 maxMatchDistArcSec, geom.arcseconds) 

431 

432 # Set configurable defaults when we encounter None type or set 

433 # state based on previous run of AstrometryTask._matchAndFitWcs. 

434 if matchTolerance.maxShift is None: 

435 maxShiftArcseconds = (self.config.maxOffsetPix 

436 * wcs.getPixelScale().asArcseconds()) 

437 else: 

438 # We don't want to clamp down too hard on the allowed shift so 

439 # we test that the smallest we ever allow is the pixel scale. 

440 maxShiftArcseconds = np.max( 

441 (matchTolerance.maxShift.asArcseconds(), 

442 self.config.minMatchDistPixels 

443 * wcs.getPixelScale().asArcseconds())) 

444 

445 # If our tolerances are not set from a previous run, estimate a 

446 # starting tolerance guess from the statistics of patterns we can 

447 # create on both the source and reference catalog. We use the smaller 

448 # of the two. 

449 if matchTolerance.maxMatchDist is None: 

450 matchTolerance.maxMatchDist = matchTolerance.autoMaxMatchDist 

451 else: 

452 maxMatchDistArcSec = np.max( 

453 (self.config.minMatchDistPixels 

454 * wcs.getPixelScale().asArcseconds(), 

455 np.min((matchTolerance.maxMatchDist.asArcseconds(), 

456 matchTolerance.autoMaxMatchDist.asArcseconds())))) 

457 

458 # Make sure the data we are considering is dense enough to require 

459 # the consensus mode of the matcher. If not default to Optimistic 

460 # pattern matcher behavior. We enforce pessimistic mode if the 

461 # reference cat is sufficiently large, avoiding false positives. 

462 numConsensus = self.config.numPatternConsensus 

463 if len(refCat) < self.config.numRefRequireConsensus: 

464 minObjectsForConsensus = \ 

465 self.config.numBrightStars + \ 

466 self.config.numPointsForShapeAttempt 

467 if len(refCat) < minObjectsForConsensus or \ 

468 len(sourceCat) < minObjectsForConsensus: 

469 numConsensus = 1 

470 

471 self.log.debug("Current tol maxDist: %.4f arcsec", 

472 maxMatchDistArcSec) 

473 self.log.debug("Current shift: %.4f arcsec", 

474 maxShiftArcseconds) 

475 

476 match_found = False 

477 # Start the iteration over our tolerances. 

478 for soften_dist in range(self.config.matcherIterations): 

479 if soften_dist == 0 and \ 

480 matchTolerance.lastMatchedPattern is not None: 

481 # If we are on the first, most stringent tolerance, 

482 # and have already found a match, the matcher should behave 

483 # like an optimistic pattern matcher. Exiting at the first 

484 # match. 

485 run_n_consent = 1 

486 else: 

487 # If we fail or this is the first match attempt, set the 

488 # pattern consensus to the specified config value. 

489 run_n_consent = numConsensus 

490 # We double the match dist tolerance each round and add 1 to the 

491 # to the number of candidate spokes to check. 

492 matcher_struct = matchTolerance.PPMbObj.match( 

493 source_array=src_array, 

494 n_check=self.config.numPointsForShapeAttempt, 

495 n_match=self.config.numPointsForShape, 

496 n_agree=run_n_consent, 

497 max_n_patterns=self.config.numBrightStars, 

498 max_shift=maxShiftArcseconds, 

499 max_rotation=self.config.maxRotationDeg, 

500 max_dist=maxMatchDistArcSec * 2. ** soften_dist, 

501 min_matches=minMatchedPairs, 

502 pattern_skip_array=np.array( 

503 matchTolerance.failedPatternList) 

504 ) 

505 

506 if soften_dist == 0 and \ 

507 len(matcher_struct.match_ids) == 0 and \ 

508 matchTolerance.lastMatchedPattern is not None: 

509 # If we found a pattern on a previous match-fit iteration and 

510 # can't find an optimistic match on our first try with the 

511 # tolerances as found in the previous match-fit, 

512 # the match we found in the last iteration was likely bad. We 

513 # append the bad match's index to the a list of 

514 # patterns/matches to skip on subsequent iterations. 

515 matchTolerance.failedPatternList.append( 

516 matchTolerance.lastMatchedPattern) 

517 matchTolerance.lastMatchedPattern = None 

518 maxShiftArcseconds = \ 

519 self.config.maxOffsetPix * wcs.getPixelScale().asArcseconds() 

520 elif len(matcher_struct.match_ids) > 0: 

521 # Match found, save a bit a state regarding this pattern 

522 # in the match tolerance class object and exit. 

523 matchTolerance.maxShift = \ 

524 matcher_struct.shift * geom.arcseconds 

525 matchTolerance.lastMatchedPattern = \ 

526 matcher_struct.pattern_idx 

527 match_found = True 

528 break 

529 

530 # If we didn't find a match, exit early. 

531 if not match_found: 

532 return pipeBase.Struct( 

533 matches=[], 

534 matchTolerance=matchTolerance, 

535 ) 

536 

537 # The matcher returns all the nearest neighbors that agree between 

538 # the reference and source catalog. For the current astrometric solver 

539 # we need to remove as many false positives as possible before sending 

540 # the matches off to the solver. The low value of 100 and high value of 

541 # 2 are the low number of sigma and high respectively. The exact values 

542 # were found after testing on data of various reference/source 

543 # densities and astrometric distortion quality, specifically the 

544 # visits: HSC (3358), DECam (406285, 410827), 

545 # CFHT (793169, 896070, 980526). 

546 distances_arcsec = np.degrees(matcher_struct.distances_rad) * 3600 

547 dist_cut_arcsec = np.max( 

548 (np.degrees(matcher_struct.max_dist_rad) * 3600, 

549 self.config.minMatchDistPixels * wcs.getPixelScale().asArcseconds())) 

550 

551 # A match has been found, return our list of matches and 

552 # return. 

553 matches = [] 

554 for match_id_pair, dist_arcsec in zip(matcher_struct.match_ids, 

555 distances_arcsec): 

556 if dist_arcsec < dist_cut_arcsec: 

557 match = afwTable.ReferenceMatch() 

558 match.first = refCat[int(match_id_pair[1])] 

559 match.second = sourceCat[int(match_id_pair[0])] 

560 # We compute the true distance along and sphere. This isn't 

561 # used in the WCS fitter however it is used in the unittest 

562 # to confirm the matches computed. 

563 match.distance = match.first.getCoord().separation( 

564 match.second.getCoord()).asArcseconds() 

565 matches.append(match) 

566 

567 return pipeBase.Struct( 

568 matches=matches, 

569 matchTolerance=matchTolerance, 

570 ) 

571 

572 def _latlong_flux_to_xyz_mag(self, theta, phi, flux): 

573 """Convert angles theta and phi and a flux into unit sphere 

574 x, y, z, and a relative magnitude. 

575 

576 Takes in a afw catalog object and converts the catalog object RA, DECs 

577 to points on the unit sphere. Also converts the flux into a simple, 

578 non-zero-pointed magnitude for relative sorting. 

579 

580 Parameters 

581 ---------- 

582 theta : `float` 

583 Angle from the north pole (z axis) of the sphere 

584 phi : `float` 

585 Rotation around the sphere 

586 

587 Return 

588 ------ 

589 output_array : `numpy.ndarray`, (N, 4) 

590 Spherical unit vector x, y, z with flux. 

591 """ 

592 output_array = np.empty(4, dtype=np.float64) 

593 output_array[0] = np.sin(theta)*np.cos(phi) 

594 output_array[1] = np.sin(theta)*np.sin(phi) 

595 output_array[2] = np.cos(theta) 

596 if flux > 0: 

597 output_array[3] = -2.5 * np.log10(flux) 

598 else: 

599 # Set flux to a very faint mag if its for some reason it 

600 # does not exist 

601 output_array[3] = 99. 

602 

603 return output_array 

604 

605 def _get_pair_pattern_statistics(self, cat_array): 

606 """ Compute the tolerances for the matcher automatically by comparing 

607 pinwheel patterns as we would in the matcher. 

608 

609 We test how similar the patterns we can create from a given set of 

610 objects by computing the spoke lengths for each pattern and sorting 

611 them from smallest to largest. The match tolerance is the average 

612 distance per spoke between the closest two patterns in the sorted 

613 spoke length space. 

614 

615 Parameters 

616 ---------- 

617 cat_array : `numpy.ndarray`, (N, 3) 

618 array of 3 vectors representing the x, y, z position of catalog 

619 objects on the unit sphere. 

620 

621 Returns 

622 ------- 

623 dist_tol : `float` 

624 Suggested max match tolerance distance calculated from comparisons 

625 between pinwheel patterns used in optimistic/pessimistic pattern 

626 matcher. 

627 """ 

628 

629 self.log.debug("Starting automated tolerance calculation...") 

630 

631 # Create an empty array of all the patterns we possibly make 

632 # sorting from brightest to faintest. 

633 pattern_array = np.empty( 

634 (cat_array.shape[0] - self.config.numPointsForShape, 

635 self.config.numPointsForShape - 1)) 

636 flux_args_array = np.argsort(cat_array[:, -1]) 

637 

638 # Sort our input array. 

639 tmp_sort_array = cat_array[flux_args_array] 

640 

641 # Start making patterns. 

642 for start_idx in range(cat_array.shape[0] 

643 - self.config.numPointsForShape): 

644 pattern_points = tmp_sort_array[start_idx:start_idx 

645 + self.config.numPointsForShape, :-1] 

646 pattern_delta = pattern_points[1:, :] - pattern_points[0, :] 

647 pattern_array[start_idx, :] = np.sqrt( 

648 pattern_delta[:, 0] ** 2 

649 + pattern_delta[:, 1] ** 2 

650 + pattern_delta[:, 2] ** 2) 

651 

652 # When we store the length of each spoke in our pattern we 

653 # sort from shortest to longest so we have a defined space 

654 # to compare them in. 

655 pattern_array[start_idx, :] = pattern_array[ 

656 start_idx, np.argsort(pattern_array[start_idx, :])] 

657 

658 # Create a searchable tree object of the patterns and find 

659 # for any given pattern the closest pattern in the sorted 

660 # spoke length space. 

661 dist_tree = cKDTree( 

662 pattern_array[:, :(self.config.numPointsForShape - 1)]) 

663 dist_nearest_array, ids = dist_tree.query( 

664 pattern_array[:, :(self.config.numPointsForShape - 1)], k=2) 

665 dist_nearest_array = dist_nearest_array[:, 1] 

666 dist_nearest_array.sort() 

667 

668 # We use the two closest patterns to set our tolerance. 

669 dist_idx = 0 

670 dist_tol = (np.degrees(dist_nearest_array[dist_idx]) * 3600. 

671 / (self.config.numPointsForShape - 1.)) 

672 

673 self.log.debug("Automated tolerance") 

674 self.log.debug("\tdistance/match tol: %.4f [arcsec]", dist_tol) 

675 

676 return dist_tol