Coverage for tests / test_gbdesAstrometricFit.py: 9%

604 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-04-18 09:15 +0000

1# This file is part of drp_tasks 

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 

22import os.path 

23import unittest 

24from copy import copy 

25 

26import astropy.table 

27import astropy.time 

28import astropy.units as u 

29import numpy as np 

30import pandas as pd 

31import wcsfit 

32import yaml 

33from astropy.coordinates import Distance, SkyCoord 

34from smatch.matcher import Matcher 

35 

36import lsst.afw.geom as afwgeom 

37import lsst.afw.table as afwTable 

38import lsst.geom 

39import lsst.utils 

40from lsst import sphgeom 

41from lsst.daf.base import PropertyList 

42from lsst.drp.tasks.gbdesAstrometricFit import ( 

43 GbdesAstrometricFitConfig, 

44 GbdesAstrometricFitTask, 

45 GbdesGlobalAstrometricFitConfig, 

46 GbdesGlobalAstrometricFitTask, 

47 calculate_apparent_motion, 

48) 

49from lsst.meas.algorithms import ReferenceObjectLoader 

50from lsst.meas.algorithms.testUtils import MockRefcatDataId 

51from lsst.pipe.base import InMemoryDatasetHandle 

52 

53TESTDIR = os.path.abspath(os.path.dirname(__file__)) 

54 

55 

56class TestGbdesAstrometricFit(lsst.utils.tests.TestCase): 

57 """This class tests `GbdesAstrometricFit` using real `visitSummaryTable`s 

58 from HSC RC2 processing, with simulated sources for those visits. 

59 """ 

60 

61 @classmethod 

62 def setUpClass(cls): 

63 # Set random seed 

64 np.random.seed(1234) 

65 

66 # Make fake data 

67 cls.datadir = os.path.join(TESTDIR, "data") 

68 

69 cls.fieldNumber = 0 

70 cls.nFields = 1 

71 cls.instrumentName = "HSC" 

72 cls.refEpoch = 57205.5 

73 

74 # Make test inputVisitSummary. VisitSummaryTables are taken from 

75 # collection HSC/runs/RC2/w_2022_20/DM-34794 

76 cls.testVisits = [1176, 17900, 17930, 17934] 

77 cls.inputVisitSummary = [] 

78 for testVisit in cls.testVisits: 

79 visSum = afwTable.ExposureCatalog.readFits( 

80 os.path.join(cls.datadir, f"visitSummary_{testVisit}.fits") 

81 ) 

82 cls.inputVisitSummary.append(visSum) 

83 

84 cls.config = GbdesAstrometricFitConfig() 

85 cls.config.systematicError = 0 

86 cls.config.devicePolyOrder = 4 

87 cls.config.exposurePolyOrder = 6 

88 cls.config.fitReserveFraction = 0 

89 cls.config.fitReserveRandomSeed = 1234 

90 cls.config.saveModelParams = True 

91 cls.config.allowSelfMatches = True 

92 cls.config.saveCameraModel = True 

93 cls.config.applyRefCatProperMotion = False 

94 cls.task = GbdesAstrometricFitTask(config=cls.config) 

95 

96 ( 

97 cls.exposureInfo, 

98 cls.exposuresHelper, 

99 cls.extensionInfo, 

100 cls.instruments, 

101 ) = cls.task._get_exposure_info(cls.inputVisitSummary, refEpoch=cls.refEpoch) 

102 

103 cls.fields, cls.fieldCenter, cls.fieldRadius = cls.task._prep_sky( 

104 cls.inputVisitSummary, cls.exposureInfo.medianEpoch 

105 ) 

106 

107 # Bounding box of observations: 

108 raMins, raMaxs = [], [] 

109 decMins, decMaxs = [], [] 

110 for visSum in cls.inputVisitSummary: 

111 raMins.append(visSum["raCorners"].min()) 

112 raMaxs.append(visSum["raCorners"].max()) 

113 decMins.append(visSum["decCorners"].min()) 

114 decMaxs.append(visSum["decCorners"].max()) 

115 raMin = min(raMins) 

116 raMax = max(raMaxs) 

117 decMin = min(decMins) 

118 decMax = max(decMaxs) 

119 

120 corners = [ 

121 lsst.geom.SpherePoint(raMin, decMin, lsst.geom.degrees).getVector(), 

122 lsst.geom.SpherePoint(raMax, decMin, lsst.geom.degrees).getVector(), 

123 lsst.geom.SpherePoint(raMax, decMax, lsst.geom.degrees).getVector(), 

124 lsst.geom.SpherePoint(raMin, decMax, lsst.geom.degrees).getVector(), 

125 ] 

126 cls.boundingPolygon = sphgeom.ConvexPolygon(corners) 

127 

128 # Make random set of data in a bounding box determined by input visits 

129 # Make wcs objects for the "true" model 

130 cls.nStars, starIds, starRAs, starDecs = cls._make_simulated_stars( 

131 raMin, raMax, decMin, decMax, cls.config.matchRadius 

132 ) 

133 

134 # Fraction of simulated stars in the reference catalog and science 

135 # exposures 

136 inReferenceFraction = 1 

137 inScienceFraction = 1 

138 

139 # Make a reference catalog and load it into ReferenceObjectLoader 

140 refDataId, deferredRefCat = cls._make_refCat( 

141 starIds, starRAs, starDecs, inReferenceFraction, cls.boundingPolygon 

142 ) 

143 cls.refObjectLoader = ReferenceObjectLoader([refDataId], [deferredRefCat]) 

144 cls.refObjectLoader.config.requireProperMotion = False 

145 cls.refObjectLoader.config.anyFilterMapsToThis = "test_filter" 

146 

147 cls.task.refObjectLoader = cls.refObjectLoader 

148 

149 # Get True WCS for stars: 

150 with open(os.path.join(cls.datadir, "sample_wcs.yaml"), "r") as f: 

151 cls.trueModel = yaml.load(f, Loader=yaml.Loader) 

152 

153 trueWCSs = cls._make_wcs(cls.trueModel, cls.inputVisitSummary) 

154 

155 # Make source catalogs: 

156 cls.inputCatalogRefs = cls._make_sourceCat(starIds, starRAs, starDecs, trueWCSs, inScienceFraction) 

157 

158 cls.colorCatalog = cls._make_colors(starRAs, starDecs) 

159 

160 cls.outputs = cls.task.run( 

161 cls.inputCatalogRefs, 

162 cls.inputVisitSummary, 

163 instrumentName=cls.instrumentName, 

164 refEpoch=cls.refEpoch, 

165 refObjectLoader=cls.refObjectLoader, 

166 ) 

167 

168 @staticmethod 

169 def _make_simulated_stars(raMin, raMax, decMin, decMax, matchRadius, nStars=10000): 

170 """Generate random positions for "stars" in an RA/Dec box. 

171 

172 Parameters 

173 ---------- 

174 raMin : `float` 

175 Minimum RA for simulated stars. 

176 raMax : `float` 

177 Maximum RA for simulated stars. 

178 decMin : `float` 

179 Minimum Dec for simulated stars. 

180 decMax : `float` 

181 Maximum Dec for simulated stars. 

182 matchRadius : `float` 

183 Minimum allowed distance in arcsec between stars. 

184 nStars : `int`, optional 

185 Number of stars to simulate. Final number will be lower if any 

186 too-close stars are dropped. 

187 

188 Returns 

189 ------- 

190 nStars : `int` 

191 Number of stars simulated. 

192 starIds: `np.ndarray` 

193 Unique identification number for stars. 

194 starRAs: `np.ndarray` 

195 Simulated Right Ascensions. 

196 starDecs: `np.ndarray` 

197 Simulated Declination. 

198 """ 

199 starIds = np.arange(nStars) 

200 starRAs = np.random.random(nStars) * (raMax - raMin) + raMin 

201 starDecs = np.random.random(nStars) * (decMax - decMin) + decMin 

202 # Remove neighbors: 

203 with Matcher(starRAs, starDecs) as matcher: 

204 idx = matcher.query_groups(matchRadius / 3600.0, min_match=2) 

205 if len(idx) > 0: 

206 neighbors = np.unique(np.concatenate(idx)) 

207 starRAs = np.delete(starRAs, neighbors) 

208 starDecs = np.delete(starDecs, neighbors) 

209 nStars = len(starRAs) 

210 starIds = np.arange(nStars) 

211 

212 return nStars, starIds, starRAs, starDecs 

213 

214 @classmethod 

215 def _make_refCat(cls, starIds, starRas, starDecs, inReferenceFraction, bounds): 

216 """Make reference catalog from a subset of the simulated data 

217 

218 Parameters 

219 ---------- 

220 starIds : `np.ndarray` [`int`] 

221 Source ids for the simulated stars 

222 starRas : `np.ndarray` [`float`] 

223 RAs of the simulated stars 

224 starDecs : `np.ndarray` [`float`] 

225 Decs of the simulated stars 

226 inReferenceFraction : float 

227 Percentage of simulated stars to include in reference catalog 

228 bounds : `lsst.sphgeom.ConvexPolygon` 

229 Boundary of the reference catalog region 

230 

231 Returns 

232 ------- 

233 refDataId : `lsst.meas.algorithms.testUtils.MockRefcatDataId` 

234 Object that replicates the functionality of a dataId. 

235 deferredRefCat : `lsst.pipe.base.InMemoryDatasetHandle` 

236 Dataset handle for reference catalog. 

237 """ 

238 nRefs = int(cls.nStars * inReferenceFraction) 

239 refStarIndices = np.random.choice(cls.nStars, nRefs, replace=False) 

240 # Make simpleCatalog to hold data, create datasetRef with `region` 

241 # determined by bounding box used in above simulate. 

242 refSchema = afwTable.SimpleTable.makeMinimalSchema() 

243 idKey = refSchema.addField("sourceId", type="I") 

244 fluxKey = refSchema.addField("test_filter_flux", units="nJy", type=np.float64) 

245 raErrKey = refSchema.addField("coord_raErr", units="rad", type=np.float64) 

246 decErrKey = refSchema.addField("coord_decErr", units="rad", type=np.float64) 

247 pmraErrKey = refSchema.addField("pm_raErr", units="rad2 / yr", type=np.float64) 

248 pmdecErrKey = refSchema.addField("pm_decErr", units="rad2 / yr", type=np.float64) 

249 refCat = afwTable.SimpleCatalog(refSchema) 

250 ref_md = PropertyList() 

251 ref_md.set("REFCAT_FORMAT_VERSION", 1) 

252 refCat.table.setMetadata(ref_md) 

253 for i in refStarIndices: 

254 record = refCat.addNew() 

255 record.set(idKey, starIds[i]) 

256 record.setRa(lsst.geom.Angle(starRas[i], lsst.geom.degrees)) 

257 record.setDec(lsst.geom.Angle(starDecs[i], lsst.geom.degrees)) 

258 record.set(fluxKey, 1) 

259 record.set(raErrKey, 0.00001) 

260 record.set(decErrKey, 0.00001) 

261 record.set(pmraErrKey, 1e-9) 

262 record.set(pmdecErrKey, 1e-9) 

263 refDataId = MockRefcatDataId(bounds) 

264 deferredRefCat = InMemoryDatasetHandle(refCat, storageClass="SourceCatalog", htm7="mockRefCat") 

265 

266 return refDataId, deferredRefCat 

267 

268 @classmethod 

269 def _make_sourceCat(cls, starIds, starRas, starDecs, trueWCSs, inScienceFraction): 

270 """Make a `pd.DataFrame` catalog with the columns needed for the 

271 object selector. 

272 

273 Parameters 

274 ---------- 

275 starIds : `np.ndarray` [`int`] 

276 Source ids for the simulated stars 

277 starRas : `np.ndarray` [`float`] 

278 RAs of the simulated stars 

279 starDecs : `np.ndarray` [`float`] 

280 Decs of the simulated stars 

281 trueWCSs : `list` [`lsst.afw.geom.SkyWcs`] 

282 WCS with which to simulate the source pixel coordinates 

283 inReferenceFraction : float 

284 Percentage of simulated stars to include in reference catalog 

285 

286 Returns 

287 ------- 

288 sourceCat : `list` [`lsst.pipe.base.InMemoryDatasetHandle`] 

289 List of reference to source catalogs. 

290 """ 

291 inputCatalogRefs = [] 

292 # Take a subset of the simulated data 

293 # Use true wcs objects to put simulated data into ccds 

294 bbox = lsst.geom.BoxD( 

295 lsst.geom.Point2D( 

296 cls.inputVisitSummary[0][0]["bbox_min_x"], cls.inputVisitSummary[0][0]["bbox_min_y"] 

297 ), 

298 lsst.geom.Point2D( 

299 cls.inputVisitSummary[0][0]["bbox_max_x"], cls.inputVisitSummary[0][0]["bbox_max_y"] 

300 ), 

301 ) 

302 bboxCorners = bbox.getCorners() 

303 cls.inputCatalogRefs = [] 

304 for v, visit in enumerate(cls.testVisits): 

305 nVisStars = int(cls.nStars * inScienceFraction) 

306 visitStarIndices = np.random.choice(cls.nStars, nVisStars, replace=False) 

307 visitStarIds = starIds[visitStarIndices] 

308 visitStarRas = starRas[visitStarIndices] 

309 visitStarDecs = starDecs[visitStarIndices] 

310 sourceCats = [] 

311 for detector in trueWCSs[visit]: 

312 detWcs = detector.getWcs() 

313 detectorId = detector["id"] 

314 radecCorners = detWcs.pixelToSky(bboxCorners) 

315 detectorFootprint = sphgeom.ConvexPolygon([rd.getVector() for rd in radecCorners]) 

316 detectorIndices = detectorFootprint.contains( 

317 (visitStarRas * u.degree).to(u.radian), (visitStarDecs * u.degree).to(u.radian) 

318 ) 

319 nDetectorStars = detectorIndices.sum() 

320 detectorArray = np.ones(nDetectorStars, dtype=bool) * detector["id"] 

321 

322 ones_like = np.ones(nDetectorStars) 

323 zeros_like = np.zeros(nDetectorStars, dtype=bool) 

324 

325 x, y = detWcs.skyToPixelArray( 

326 visitStarRas[detectorIndices], visitStarDecs[detectorIndices], degrees=True 

327 ) 

328 

329 origWcs = (cls.inputVisitSummary[v][cls.inputVisitSummary[v]["id"] == detectorId])[0].getWcs() 

330 inputRa, inputDec = origWcs.pixelToSkyArray(x, y, degrees=True) 

331 

332 sourceDict = {} 

333 sourceDict["detector"] = detectorArray 

334 sourceDict["sourceId"] = visitStarIds[detectorIndices] 

335 sourceDict["x"] = x 

336 sourceDict["y"] = y 

337 sourceDict["xErr"] = 1e-3 * ones_like 

338 sourceDict["yErr"] = 1e-3 * ones_like 

339 sourceDict["inputRA"] = inputRa 

340 sourceDict["inputDec"] = inputDec 

341 sourceDict["trueRA"] = visitStarRas[detectorIndices] 

342 sourceDict["trueDec"] = visitStarDecs[detectorIndices] 

343 for key in ["apFlux_12_0_flux", "apFlux_12_0_instFlux", "ixx", "iyy"]: 

344 sourceDict[key] = ones_like 

345 for key in [ 

346 "pixelFlags_edge", 

347 "pixelFlags_saturated", 

348 "pixelFlags_interpolatedCenter", 

349 "pixelFlags_interpolated", 

350 "pixelFlags_crCenter", 

351 "pixelFlags_bad", 

352 "hsmPsfMoments_flag", 

353 "apFlux_12_0_flag", 

354 "extendedness", 

355 "sizeExtendedness", 

356 "parentSourceId", 

357 "deblend_nChild", 

358 "ixy", 

359 ]: 

360 sourceDict[key] = zeros_like 

361 sourceDict["apFlux_12_0_instFluxErr"] = 1e-2 * ones_like 

362 sourceDict["detect_isPrimary"] = ones_like.astype(bool) 

363 

364 sourceCat = pd.DataFrame(sourceDict) 

365 sourceCats.append(sourceCat) 

366 

367 visitSourceTable = pd.concat(sourceCats) 

368 

369 inputCatalogRef = InMemoryDatasetHandle( 

370 visitSourceTable, storageClass="DataFrame", dataId={"visit": visit} 

371 ) 

372 

373 inputCatalogRefs.append(inputCatalogRef) 

374 

375 return inputCatalogRefs 

376 

377 @classmethod 

378 def _make_wcs(cls, model, inputVisitSummaries): 

379 """Make a `lsst.afw.geom.SkyWcs` from given model parameters 

380 

381 Parameters 

382 ---------- 

383 model : `dict` 

384 Dictionary with WCS model parameters 

385 inputVisitSummaries : `list` [`lsst.afw.table.ExposureCatalog`] 

386 Visit summary catalogs 

387 Returns 

388 ------- 

389 catalogs : `dict` [`int`, `lsst.afw.table.ExposureCatalog`] 

390 Visit summary catalogs with WCS set to input model 

391 """ 

392 

393 # Pixels will need to be rescaled before going into the mappings 

394 xscale = inputVisitSummaries[0][0]["bbox_max_x"] - inputVisitSummaries[0][0]["bbox_min_x"] 

395 yscale = inputVisitSummaries[0][0]["bbox_max_y"] - inputVisitSummaries[0][0]["bbox_min_y"] 

396 

397 catalogs = {} 

398 schema = lsst.afw.table.ExposureTable.makeMinimalSchema() 

399 schema.addField("visit", type="L", doc="Visit number") 

400 for visitSum in inputVisitSummaries: 

401 visit = visitSum[0]["visit"] 

402 visitMapName = f"{visit}/poly" 

403 visitModel = model[visitMapName] 

404 

405 catalog = lsst.afw.table.ExposureCatalog(schema) 

406 catalog.resize(len(visitSum)) 

407 catalog["visit"] = visit 

408 

409 raDec = visitSum[0].getVisitInfo().getBoresightRaDec() 

410 

411 visitMapType = visitModel["Type"] 

412 visitDict = {"Type": visitMapType} 

413 if visitMapType == "Poly": 

414 mapCoefficients = visitModel["XPoly"]["Coefficients"] + visitModel["YPoly"]["Coefficients"] 

415 visitDict["Coefficients"] = mapCoefficients 

416 

417 for d, detector in enumerate(visitSum): 

418 detectorId = detector["id"] 

419 filterName = detector["physical_filter"] 

420 detectorMapName = f"{filterName}/{detectorId}/poly" 

421 detectorModel = model[detectorMapName] 

422 

423 detectorMapType = detectorModel["Type"] 

424 mapDict = {detectorMapName: {"Type": detectorMapType}, visitMapName: visitDict} 

425 if detectorMapType == "Poly": 

426 mapCoefficients = ( 

427 detectorModel["XPoly"]["Coefficients"] + detectorModel["YPoly"]["Coefficients"] 

428 ) 

429 mapDict[detectorMapName]["Coefficients"] = mapCoefficients 

430 

431 outWCS = cls.task._make_afw_wcs( 

432 mapDict, 

433 raDec.getRa(), 

434 raDec.getDec(), 

435 doNormalizePixels=True, 

436 xScale=xscale, 

437 yScale=yscale, 

438 ) 

439 catalog[d].setId(detectorId) 

440 catalog[d].setWcs(outWCS) 

441 

442 catalog.sort() 

443 catalogs[visit] = catalog 

444 

445 return catalogs 

446 

447 @classmethod 

448 def _make_colors(cls, starRas, starDecs): 

449 """Make a catalog with the star magnitudes. 

450 

451 Parameters 

452 ---------- 

453 starRas : `np.ndarray` [`float`] 

454 RAs of the simulated stars 

455 starDecs : `np.ndarray` [`float`] 

456 Decs of the simulated stars 

457 

458 Returns 

459 ------- 

460 colorCatalog : `lsst.afw.table.SimpleCatalog` 

461 Catalog with star magnitudes. 

462 """ 

463 bands = ["g", "r", "i", "z", "y"] 

464 nStars = len(starRas) 

465 

466 # Make a catalog following what is done in `fgcmCal`. 

467 schema = afwTable.SimpleTable.makeMinimalSchema() 

468 schema.addField( 

469 "mag_std_noabs", 

470 type="ArrayF", 

471 doc="Standard magnitude (no absolute calibration)", 

472 size=len(bands), 

473 ) 

474 colorCatalog = afwTable.SimpleCatalog(schema) 

475 colorCatalog.resize(len(starRas)) 

476 colorCatalog["coord_ra"] = (starRas * u.degree + 10 * np.random.randn(nStars) * u.mas).to(u.radian) 

477 colorCatalog["coord_dec"] = (starDecs * u.degree + 10 * np.random.randn(nStars) * u.mas).to(u.radian) 

478 

479 magMin = 19 

480 magMax = 23 

481 for i in range(len(bands)): 

482 colorCatalog["mag_std_noabs"][:, i] = np.random.random(nStars) * (magMax - magMin) + magMin 

483 

484 md = PropertyList() 

485 md.set("BANDS", bands) 

486 colorCatalog.setMetadata(md) 

487 return colorCatalog 

488 

489 def test_get_exposure_info(self): 

490 """Test that information for input exposures is as expected and that 

491 the WCS in the class object gives approximately the same results as the 

492 input `lsst.afw.geom.SkyWcs`. 

493 """ 

494 

495 # The total number of extensions is the number of detectors for each 

496 # visit plus one for the reference catalog for each field. 

497 totalExtensions = sum([len(visSum) for visSum in self.inputVisitSummary]) + self.nFields 

498 

499 self.assertEqual(totalExtensions, len(self.extensionInfo.visit)) 

500 

501 taskVisits = set(self.extensionInfo.visit) 

502 refVisits = np.arange(0, -1 * self.nFields, -1).tolist() 

503 self.assertEqual(taskVisits, set(self.testVisits + refVisits)) 

504 

505 xx = np.linspace(0, 2000, 3) 

506 yy = np.linspace(0, 4000, 6) 

507 xgrid, ygrid = np.meshgrid(xx, yy) 

508 for visSum in self.inputVisitSummary: 

509 visit = visSum[0]["visit"] 

510 for detectorInfo in visSum: 

511 detector = detectorInfo["id"] 

512 extensionIndex = np.flatnonzero( 

513 (self.extensionInfo.visit == visit) & (self.extensionInfo.detector == detector) 

514 )[0] 

515 fitWcs = self.extensionInfo.wcs[extensionIndex] 

516 calexpWcs = detectorInfo.getWcs() 

517 

518 tanPlaneXY = np.array([fitWcs.toWorld(x, y) for (x, y) in zip(xgrid.ravel(), ygrid.ravel())]) 

519 

520 calexpra, calexpdec = calexpWcs.pixelToSkyArray(xgrid.ravel(), ygrid.ravel(), degrees=True) 

521 

522 # The pixel origin maps to a position slightly off from the 

523 # tangent plane origin, so we want to use this value for the 

524 # tangent-plane-to-sky part of the mapping. 

525 tangentPlaneCenter = fitWcs.toWorld( 

526 calexpWcs.getPixelOrigin().getX(), calexpWcs.getPixelOrigin().getY() 

527 ) 

528 tangentPlaneOrigin = lsst.geom.Point2D(tangentPlaneCenter) 

529 skyOrigin = calexpWcs.pixelToSky( 

530 calexpWcs.getPixelOrigin().getX(), calexpWcs.getPixelOrigin().getY() 

531 ) 

532 cdMatrix = afwgeom.makeCdMatrix(1.0 * lsst.geom.degrees, 0.0 * lsst.geom.degrees, True) 

533 iwcToSkyWcs = afwgeom.makeSkyWcs(tangentPlaneOrigin, skyOrigin, cdMatrix) 

534 newRAdeg, newDecdeg = iwcToSkyWcs.pixelToSkyArray( 

535 tanPlaneXY[:, 0], tanPlaneXY[:, 1], degrees=True 

536 ) 

537 

538 np.testing.assert_allclose(calexpra, newRAdeg) 

539 np.testing.assert_allclose(calexpdec, newDecdeg) 

540 

541 def test_refCatLoader(self): 

542 """Test that we can load objects from refCat""" 

543 

544 tmpAssociations = wcsfit.FoFClass( 

545 self.fields, 

546 self.instruments, 

547 self.exposuresHelper, 

548 [self.fieldRadius.asDegrees()], 

549 (self.task.config.matchRadius * u.arcsec).to(u.degree).value, 

550 ) 

551 

552 self.task._load_refcat( 

553 self.refObjectLoader, 

554 self.extensionInfo, 

555 associations=tmpAssociations, 

556 center=self.fieldCenter, 

557 radius=self.fieldRadius, 

558 epoch=self.exposureInfo.medianEpoch, 

559 ) 

560 

561 # We have only loaded one catalog, so getting the 'matches' should just 

562 # return the same objects we put in, except some random objects that 

563 # are too close together. 

564 tmpAssociations.sortMatches(self.fieldNumber, minMatches=1) 

565 

566 nMatches = (np.array(tmpAssociations.sequence) == 0).sum() 

567 

568 self.assertLessEqual(nMatches, self.nStars) 

569 self.assertGreater(nMatches, self.nStars * 0.9) 

570 

571 def test_loading_and_association(self): 

572 """Test that objects can be loaded and correctly associated.""" 

573 # Running `_load_catalogs_and_associate` changes the input WCSs, so 

574 # recalculate them here so that the variables shared among tests are 

575 # not affected. 

576 _, exposuresHelper, extensionInfo, instruments = self.task._get_exposure_info( 

577 self.inputVisitSummary, refEpoch=self.refEpoch 

578 ) 

579 

580 tmpAssociations = wcsfit.FoFClass( 

581 self.fields, 

582 self.instruments, 

583 exposuresHelper, 

584 [self.fieldRadius.asDegrees()], 

585 (self.task.config.matchRadius * u.arcsec).to(u.degree).value, 

586 ) 

587 self.task._load_catalogs_and_associate(tmpAssociations, self.inputCatalogRefs, extensionInfo) 

588 

589 tmpAssociations.sortMatches(self.fieldNumber, minMatches=2) 

590 

591 matchIds = [] 

592 correctMatches = [] 

593 for s, e, o in zip(tmpAssociations.sequence, tmpAssociations.extn, tmpAssociations.obj): 

594 objVisitInd = extensionInfo.visitIndex[e] 

595 objDet = extensionInfo.detector[e] 

596 extnInds = self.inputCatalogRefs[objVisitInd].get()["detector"] == objDet 

597 objInfo = self.inputCatalogRefs[objVisitInd].get()[extnInds].iloc[o] 

598 if s == 0: 

599 if len(matchIds) > 0: 

600 correctMatches.append(len(set(matchIds)) == 1) 

601 matchIds = [] 

602 

603 matchIds.append(objInfo["sourceId"]) 

604 

605 # A few matches may incorrectly associate sources because of the random 

606 # positions 

607 self.assertGreater(sum(correctMatches), len(correctMatches) * 0.95) 

608 

609 def test_make_outputs(self): 

610 """Test that the run method recovers the input model parameters.""" 

611 for v, visit in enumerate(self.testVisits): 

612 visitSummary = self.inputVisitSummary[v] 

613 outputWcsCatalog = self.outputs.outputWcss[visit] 

614 visitSources = self.inputCatalogRefs[v].get() 

615 for d, detectorRow in enumerate(visitSummary): 

616 detectorId = detectorRow["id"] 

617 fitwcs = outputWcsCatalog[d].getWcs() 

618 detSources = visitSources[visitSources["detector"] == detectorId] 

619 fitRA, fitDec = fitwcs.pixelToSkyArray(detSources["x"], detSources["y"], degrees=True) 

620 dRA = fitRA - detSources["trueRA"] 

621 dDec = fitDec - detSources["trueDec"] 

622 # Check that input coordinates match the output coordinates 

623 self.assertAlmostEqual(np.mean(dRA), 0) 

624 self.assertAlmostEqual(np.std(dRA), 0) 

625 self.assertAlmostEqual(np.mean(dDec), 0) 

626 self.assertAlmostEqual(np.std(dDec), 0) 

627 

628 def test_compute_model_params(self): 

629 """Test the optional model parameters and covariance output.""" 

630 modelParams = pd.DataFrame(self.outputs.modelParams) 

631 # Check that DataFrame is the expected size. 

632 shape = modelParams.shape 

633 self.assertEqual(shape[0] + 4, shape[1]) 

634 # Check that covariance matrix is symmetric. 

635 covariance = (modelParams.iloc[:, 4:]).to_numpy() 

636 np.testing.assert_allclose(covariance, covariance.T, atol=1e-18) 

637 

638 def test_run(self): 

639 """Test that run method recovers the input model parameters""" 

640 outputMaps = self.outputs.fitModel.mapCollection.getParamDict() 

641 

642 for v, visit in enumerate(self.testVisits): 

643 visitSummary = self.inputVisitSummary[v] 

644 visitMapName = f"{visit}/poly" 

645 

646 origModel = self.trueModel[visitMapName] 

647 if origModel["Type"] != "Identity": 

648 fitModel = outputMaps[visitMapName] 

649 origXPoly = origModel["XPoly"]["Coefficients"] 

650 origYPoly = origModel["YPoly"]["Coefficients"] 

651 fitXPoly = fitModel[: len(origXPoly)] 

652 fitYPoly = fitModel[len(origXPoly) :] 

653 

654 absDiffX = abs(fitXPoly - origXPoly) 

655 absDiffY = abs(fitYPoly - origYPoly) 

656 # Check that input visit model matches fit 

657 np.testing.assert_array_less(absDiffX, 1e-6) 

658 np.testing.assert_array_less(absDiffY, 1e-6) 

659 for d, detectorRow in enumerate(visitSummary): 

660 detectorId = detectorRow["id"] 

661 filterName = detectorRow["physical_filter"] 

662 detectorMapName = f"{filterName}/{detectorId}/poly" 

663 origModel = self.trueModel[detectorMapName] 

664 if (origModel["Type"] != "Identity") and (v == 0): 

665 fitModel = outputMaps[detectorMapName] 

666 origXPoly = origModel["XPoly"]["Coefficients"] 

667 origYPoly = origModel["YPoly"]["Coefficients"] 

668 fitXPoly = fitModel[: len(origXPoly)] 

669 fitYPoly = fitModel[len(origXPoly) :] 

670 absDiffX = abs(fitXPoly - origXPoly) 

671 absDiffY = abs(fitYPoly - origYPoly) 

672 # Check that input detector model matches fit 

673 np.testing.assert_array_less(absDiffX, 1e-7) 

674 np.testing.assert_array_less(absDiffY, 1e-7) 

675 

676 def test_missingWcs(self): 

677 """Test that task does not fail when the input WCS is None for one 

678 extension and that the fit WCS for that extension returns a finite 

679 result. 

680 """ 

681 # Need to copy the catalogs since they are modified later. 

682 inputVisitSummary = [cat.copy(deep=True) for cat in self.inputVisitSummary] 

683 # Set one WCS to be None 

684 testVisit = 0 

685 testDetector = 20 

686 inputVisitSummary[testVisit][testDetector].setWcs(None) 

687 

688 outputs = self.task.run( 

689 self.inputCatalogRefs, 

690 inputVisitSummary, 

691 instrumentName=self.instrumentName, 

692 refEpoch=self.refEpoch, 

693 refObjectLoader=self.refObjectLoader, 

694 ) 

695 

696 # Check that the fit WCS for the extension with input WCS=None returns 

697 # finite sky values. 

698 testWcs = outputs.outputWcss[self.testVisits[testVisit]][testDetector].getWcs() 

699 testSky = testWcs.pixelToSky(0, 0) 

700 self.assertTrue(testSky.isFinite()) 

701 

702 def test_inputCameraModel(self): 

703 """Test running task with an input camera model, and check that true 

704 object coordinates are recovered. 

705 """ 

706 config = copy(self.config) 

707 config.saveCameraModel = False 

708 config.useInputCameraModel = True 

709 task = GbdesAstrometricFitTask(config=config) 

710 with open(os.path.join(self.datadir, "sample_camera_model.yaml"), "r") as f: 

711 cameraModel = yaml.load(f, Loader=yaml.Loader) 

712 

713 outputs = task.run( 

714 self.inputCatalogRefs, 

715 self.inputVisitSummary, 

716 instrumentName=self.instrumentName, 

717 refObjectLoader=self.refObjectLoader, 

718 inputCameraModel=cameraModel, 

719 ) 

720 

721 # Check that the output WCS is close (not necessarily exactly equal) to 

722 # the result when fitting the full model. 

723 for v, visit in enumerate(self.testVisits): 

724 visitSummary = self.inputVisitSummary[v] 

725 outputWcsCatalog = outputs.outputWcss[visit] 

726 visitSources = self.inputCatalogRefs[v].get() 

727 for d, detectorRow in enumerate(visitSummary): 

728 detectorId = detectorRow["id"] 

729 fitwcs = outputWcsCatalog[d].getWcs() 

730 detSources = visitSources[visitSources["detector"] == detectorId] 

731 fitRA, fitDec = fitwcs.pixelToSkyArray(detSources["x"], detSources["y"], degrees=True) 

732 dRA = fitRA - detSources["trueRA"] 

733 dDec = fitDec - detSources["trueDec"] 

734 # Check that input coordinates match the output coordinates 

735 self.assertAlmostEqual(np.mean(dRA), 0) 

736 self.assertAlmostEqual(np.std(dRA), 0) 

737 self.assertAlmostEqual(np.mean(dDec), 0) 

738 self.assertAlmostEqual(np.std(dDec), 0) 

739 

740 def test_useColor(self): 

741 """Test running task with color catalog and DCR fitting.""" 

742 config = copy(self.config) 

743 config.useColor = True 

744 

745 task = GbdesAstrometricFitTask(config=config) 

746 outputs = task.run( 

747 self.inputCatalogRefs, 

748 self.inputVisitSummary, 

749 instrumentName=self.instrumentName, 

750 refObjectLoader=self.refObjectLoader, 

751 colorCatalog=self.colorCatalog, 

752 ) 

753 self.assertEqual(len(outputs.colorParams["visit"]), len(self.testVisits)) 

754 

755 

756class TestGbdesGlobalAstrometricFit(TestGbdesAstrometricFit): 

757 @classmethod 

758 def setUpClass(cls): 

759 # Set random seed 

760 np.random.seed(1234) 

761 

762 # Fraction of simulated stars in the reference catalog and science 

763 # exposures 

764 inReferenceFraction = 1 

765 inScienceFraction = 1 

766 

767 # Make fake data 

768 cls.datadir = os.path.join(TESTDIR, "data") 

769 

770 cls.nFields = 2 

771 cls.instrumentName = "HSC" 

772 cls.refEpoch = 57205.5 

773 

774 # Make test inputVisitSummary. VisitSummaryTables are taken from 

775 # collection HSC/runs/RC2/w_2022_20/DM-34794 

776 cls.testVisits = [1176, 17900, 17930, 17934, 36434, 36460, 36494, 36446] 

777 cls.inputVisitSummary = [] 

778 for testVisit in cls.testVisits: 

779 visSum = afwTable.ExposureCatalog.readFits( 

780 os.path.join(cls.datadir, f"visitSummary_{testVisit}.fits") 

781 ) 

782 cls.inputVisitSummary.append(visSum) 

783 

784 cls.config = GbdesGlobalAstrometricFitConfig() 

785 cls.config.systematicError = 0 

786 cls.config.devicePolyOrder = 4 

787 cls.config.exposurePolyOrder = 6 

788 cls.config.fitReserveFraction = 0 

789 cls.config.fitReserveRandomSeed = 1234 

790 cls.config.saveModelParams = True 

791 cls.config.saveCameraModel = True 

792 cls.config.applyRefCatProperMotion = False 

793 cls.task = GbdesGlobalAstrometricFitTask(config=cls.config) 

794 

795 cls.fields, cls.fieldRegions = cls.task._prep_sky(cls.inputVisitSummary) 

796 

797 ( 

798 cls.exposureInfo, 

799 cls.exposuresHelper, 

800 cls.extensionInfo, 

801 cls.instruments, 

802 ) = cls.task._get_exposure_info(cls.inputVisitSummary, fieldRegions=cls.fieldRegions) 

803 

804 refDataIds, deferredRefCats = [], [] 

805 allStarIds = [] 

806 allStarRAs = [] 

807 allStarDecs = [] 

808 for region in cls.fieldRegions.values(): 

809 # Bounding box of observations: 

810 bbox = region.getBoundingBox() 

811 raMin = bbox.getLon().getA().asDegrees() 

812 raMax = bbox.getLon().getB().asDegrees() 

813 decMin = bbox.getLat().getA().asDegrees() 

814 decMax = bbox.getLat().getB().asDegrees() 

815 

816 # Make random set of data in a bounding box determined by input 

817 # visits 

818 cls.nStars, starIds, starRAs, starDecs = cls._make_simulated_stars( 

819 raMin, raMax, decMin, decMax, cls.config.matchRadius 

820 ) 

821 

822 allStarIds.append(starIds) 

823 allStarRAs.append(starRAs) 

824 allStarDecs.append(starDecs) 

825 

826 corners = [ 

827 lsst.geom.SpherePoint(ra, dec, lsst.geom.degrees).getVector() 

828 for (ra, dec) in zip([raMin, raMax, raMax, raMin], [decMin, decMin, decMax, decMax]) 

829 ] 

830 conv_box = lsst.sphgeom.ConvexPolygon.convexHull(corners) 

831 # Make a reference catalog that will be loaded into 

832 # ReferenceObjectLoader 

833 refDataId, deferredRefCat = cls._make_refCat( 

834 starIds, starRAs, starDecs, inReferenceFraction, conv_box 

835 ) 

836 refDataIds.append(refDataId) 

837 deferredRefCats.append(deferredRefCat) 

838 

839 cls.refObjectLoader = ReferenceObjectLoader(refDataIds, deferredRefCats) 

840 cls.refObjectLoader.config.requireProperMotion = False 

841 cls.refObjectLoader.config.anyFilterMapsToThis = "test_filter" 

842 

843 cls.task.refObjectLoader = cls.refObjectLoader 

844 

845 allRefObjects, allRefCovariances = {}, {} 

846 for f, fieldRegion in cls.fieldRegions.items(): 

847 refObjects, refCovariance = cls.task._load_refcat( 

848 cls.refObjectLoader, cls.extensionInfo, epoch=cls.exposureInfo.medianEpoch, region=fieldRegion 

849 ) 

850 allRefObjects[f] = refObjects 

851 allRefCovariances[f] = refCovariance 

852 cls.refObjects = allRefObjects 

853 

854 # Get True WCS for stars: 

855 with open(os.path.join(cls.datadir, "sample_global_wcs.yaml"), "r") as f: 

856 cls.trueModel = yaml.load(f, Loader=yaml.Loader) 

857 

858 cls.trueWCSs = cls._make_wcs(cls.trueModel, cls.inputVisitSummary) 

859 

860 cls.isolatedStarCatalogs, cls.isolatedStarSources = cls._make_isolatedStars( 

861 allStarIds, allStarRAs, allStarDecs, cls.trueWCSs, inScienceFraction 

862 ) 

863 

864 cls.colorCatalog = cls._make_colors(np.concatenate(allStarRAs), np.concatenate(allStarDecs)) 

865 

866 cls.outputs = cls.task.run( 

867 cls.inputVisitSummary, 

868 cls.isolatedStarSources, 

869 cls.isolatedStarCatalogs, 

870 instrumentName=cls.instrumentName, 

871 refEpoch=cls.refEpoch, 

872 refObjectLoader=cls.refObjectLoader, 

873 ) 

874 

875 @classmethod 

876 def _make_isolatedStars(cls, allStarIds, allStarRAs, allStarDecs, trueWCSs, inScienceFraction): 

877 """Given a subset of the simulated data, make source catalogs and star 

878 catalogs. 

879 

880 This takes the true WCSs to go from the RA and Decs of the simulated 

881 stars to pixel coordinates for a given visit and detector. If those 

882 pixel coordinates are within the bounding box of the detector, the 

883 source and visit information is put in the corresponding catalog of 

884 isolated star sources. 

885 

886 Parameters 

887 ---------- 

888 allStarIds : `np.ndarray` [`int`] 

889 Source ids for the simulated stars 

890 allStarRas : `np.ndarray` [`float`] 

891 RAs of the simulated stars 

892 allStarDecs : `np.ndarray` [`float`] 

893 Decs of the simulated stars 

894 trueWCSs : `list` [`lsst.afw.geom.SkyWcs`] 

895 WCS with which to simulate the source pixel coordinates 

896 inReferenceFraction : float 

897 Percentage of simulated stars to include in reference catalog 

898 

899 Returns 

900 ------- 

901 isolatedStarCatalogRefs : `list` 

902 [`lsst.pipe.base.InMemoryDatasetHandle`] 

903 List of references to isolated star catalogs. 

904 isolatedStarSourceRefs : `list` 

905 [`lsst.pipe.base.InMemoryDatasetHandle`] 

906 List of references to isolated star sources. 

907 """ 

908 bbox = lsst.geom.BoxD( 

909 lsst.geom.Point2D( 

910 cls.inputVisitSummary[0][0]["bbox_min_x"], cls.inputVisitSummary[0][0]["bbox_min_y"] 

911 ), 

912 lsst.geom.Point2D( 

913 cls.inputVisitSummary[0][0]["bbox_max_x"], cls.inputVisitSummary[0][0]["bbox_max_y"] 

914 ), 

915 ) 

916 bboxCorners = bbox.getCorners() 

917 

918 isolatedStarCatalogRefs = [] 

919 isolatedStarSourceRefs = [] 

920 for i in range(len(allStarIds)): 

921 starIds = allStarIds[i] 

922 starRAs = allStarRAs[i] 

923 starDecs = allStarDecs[i] 

924 isolatedStarCatalog = pd.DataFrame({"ra": starRAs, "dec": starDecs}, index=starIds) 

925 isolatedStarCatalogRefs.append( 

926 InMemoryDatasetHandle(isolatedStarCatalog, storageClass="DataFrame", dataId={"tract": 0}) 

927 ) 

928 sourceCats = [] 

929 for v, visit in enumerate(cls.testVisits): 

930 nVisStars = int(cls.nStars * inScienceFraction) 

931 visitStarIndices = np.random.choice(cls.nStars, nVisStars, replace=False) 

932 visitStarIds = starIds[visitStarIndices] 

933 visitStarRas = starRAs[visitStarIndices] 

934 visitStarDecs = starDecs[visitStarIndices] 

935 for detector in trueWCSs[visit]: 

936 detWcs = detector.getWcs() 

937 detectorId = detector["id"] 

938 radecCorners = detWcs.pixelToSky(bboxCorners) 

939 detectorFootprint = sphgeom.ConvexPolygon([rd.getVector() for rd in radecCorners]) 

940 detectorIndices = detectorFootprint.contains( 

941 (visitStarRas * u.degree).to(u.radian), (visitStarDecs * u.degree).to(u.radian) 

942 ) 

943 nDetectorStars = detectorIndices.sum() 

944 detectorArray = np.ones(nDetectorStars, dtype=int) * detector["id"] 

945 visitArray = np.ones(nDetectorStars, dtype=int) * visit 

946 

947 ones_like = np.ones(nDetectorStars) 

948 zeros_like = np.zeros(nDetectorStars, dtype=bool) 

949 

950 x, y = detWcs.skyToPixelArray( 

951 visitStarRas[detectorIndices], visitStarDecs[detectorIndices], degrees=True 

952 ) 

953 

954 origWcs = (cls.inputVisitSummary[v][cls.inputVisitSummary[v]["id"] == detectorId])[ 

955 0 

956 ].getWcs() 

957 inputRa, inputDec = origWcs.pixelToSkyArray(x, y, degrees=True) 

958 

959 sourceDict = {} 

960 sourceDict["detector"] = detectorArray 

961 sourceDict["visit"] = visitArray 

962 sourceDict["obj_index"] = visitStarIds[detectorIndices] 

963 sourceDict["x"] = x 

964 sourceDict["y"] = y 

965 sourceDict["xErr"] = 1e-3 * ones_like 

966 sourceDict["yErr"] = 1e-3 * ones_like 

967 sourceDict["inputRA"] = inputRa 

968 sourceDict["inputDec"] = inputDec 

969 sourceDict["trueRA"] = visitStarRas[detectorIndices] 

970 sourceDict["trueDec"] = visitStarDecs[detectorIndices] 

971 for key in ["apFlux_12_0_flux", "apFlux_12_0_instFlux", "ixx", "iyy"]: 

972 sourceDict[key] = ones_like 

973 for key in [ 

974 "pixelFlags_edge", 

975 "pixelFlags_saturated", 

976 "pixelFlags_interpolatedCenter", 

977 "pixelFlags_interpolated", 

978 "pixelFlags_crCenter", 

979 "pixelFlags_bad", 

980 "hsmPsfMoments_flag", 

981 "apFlux_12_0_flag", 

982 "extendedness", 

983 "parentSourceId", 

984 "deblend_nChild", 

985 "ixy", 

986 ]: 

987 sourceDict[key] = zeros_like 

988 sourceDict["apFlux_12_0_instFluxErr"] = 1e-2 * ones_like 

989 sourceDict["detect_isPrimary"] = ones_like.astype(bool) 

990 

991 sourceCat = pd.DataFrame(sourceDict) 

992 sourceCats.append(sourceCat) 

993 

994 isolatedStarSourceTable = pd.concat(sourceCats, ignore_index=True) 

995 isolatedStarSourceTable = isolatedStarSourceTable.sort_values(by=["obj_index"]) 

996 isolatedStarSourceRefs.append( 

997 InMemoryDatasetHandle(isolatedStarSourceTable, storageClass="DataFrame", dataId={"tract": 0}) 

998 ) 

999 

1000 return isolatedStarCatalogRefs, isolatedStarSourceRefs 

1001 

1002 def test_loading_and_association(self): 

1003 """Test that associated objects actually correspond to the same 

1004 simulated object.""" 

1005 associations, sourceDict = self.task._associate_from_isolated_sources( 

1006 self.isolatedStarSources, self.isolatedStarCatalogs, self.extensionInfo, self.refObjects 

1007 ) 

1008 

1009 object_groups = np.flatnonzero(np.array(associations.sequence) == 0) 

1010 for i in range(len(object_groups) - 1)[:10]: 

1011 ras, decs = [], [] 

1012 for ind in np.arange(object_groups[i], object_groups[i + 1]): 

1013 visit = self.extensionInfo.visit[associations.extn[ind]] 

1014 detectorInd = self.extensionInfo.detectorIndex[associations.extn[ind]] 

1015 detector = self.extensionInfo.detector[associations.extn[ind]] 

1016 if detectorInd == -1: 

1017 ra = self.refObjects[visit * -1]["ra"][associations.obj[ind]] 

1018 dec = self.refObjects[visit * -1]["dec"][associations.obj[ind]] 

1019 ras.append(ra) 

1020 decs.append(dec) 

1021 else: 

1022 x = sourceDict[visit][detector]["x"][associations.obj[ind]] 

1023 y = sourceDict[visit][detector]["y"][associations.obj[ind]] 

1024 ra, dec = self.trueWCSs[visit][detectorInd].getWcs().pixelToSky(x, y) 

1025 ras.append(ra.asDegrees()) 

1026 decs.append(dec.asDegrees()) 

1027 np.testing.assert_allclose(ras, ras[0]) 

1028 np.testing.assert_allclose(decs, decs[0]) 

1029 

1030 def test_refCatLoader(self): 

1031 """Test loading objects from the refCat in each of the fields.""" 

1032 

1033 for region in self.fieldRegions.values(): 

1034 refCat, refCov = self.task._load_refcat( 

1035 self.refObjectLoader, self.extensionInfo, region=region, epoch=self.exposureInfo.medianEpoch 

1036 ) 

1037 assert len(refCat) > 0 

1038 

1039 def test_make_outputs(self): 

1040 """Test that the run method recovers the input model parameters.""" 

1041 for isolatedStarSourceRef in self.isolatedStarSources: 

1042 iss = isolatedStarSourceRef.get() 

1043 visits = np.unique(iss["visit"]) 

1044 for v, visit in enumerate(visits): 

1045 outputWcsCatalog = self.outputs.outputWcss[visit] 

1046 visitSources = iss[iss["visit"] == visit] 

1047 detectors = outputWcsCatalog["id"] 

1048 for d, detectorId in enumerate(detectors): 

1049 fitwcs = outputWcsCatalog[d].getWcs() 

1050 detSources = visitSources[visitSources["detector"] == detectorId] 

1051 fitRA, fitDec = fitwcs.pixelToSkyArray(detSources["x"], detSources["y"], degrees=True) 

1052 dRA = fitRA - detSources["trueRA"] 

1053 dDec = fitDec - detSources["trueDec"] 

1054 # Check that input coordinates match the output coordinates 

1055 self.assertAlmostEqual(np.mean(dRA), 0) 

1056 self.assertAlmostEqual(np.std(dRA), 0) 

1057 self.assertAlmostEqual(np.mean(dDec), 0) 

1058 self.assertAlmostEqual(np.std(dDec), 0) 

1059 

1060 def test_missingWcs(self): 

1061 """Test that task does not fail when the input WCS is None for one 

1062 extension and that the fit WCS for that extension returns a finite 

1063 result. 

1064 """ 

1065 # Need to copy the catalogs since they are modified later. 

1066 inputVisitSummary = [cat.copy(deep=True) for cat in self.inputVisitSummary] 

1067 # Set one WCS to be None 

1068 testVisit = 0 

1069 testDetector = 20 

1070 inputVisitSummary[testVisit][testDetector].setWcs(None) 

1071 

1072 outputs = self.task.run( 

1073 inputVisitSummary, 

1074 self.isolatedStarSources, 

1075 self.isolatedStarCatalogs, 

1076 instrumentName=self.instrumentName, 

1077 refEpoch=self.refEpoch, 

1078 refObjectLoader=self.refObjectLoader, 

1079 ) 

1080 

1081 # Check that the fit WCS for the extension with input WCS=None returns 

1082 # finite sky values. 

1083 testWcs = outputs.outputWcss[self.testVisits[testVisit]][testDetector].getWcs() 

1084 testSky = testWcs.pixelToSky(0, 0) 

1085 self.assertTrue(testSky.isFinite()) 

1086 

1087 def test_inputCameraModel(self): 

1088 """Test running task with an input camera model, and check that true 

1089 object coordinates are recovered. 

1090 """ 

1091 config = copy(self.config) 

1092 config.saveCameraModel = False 

1093 config.useInputCameraModel = True 

1094 task = GbdesGlobalAstrometricFitTask(config=config) 

1095 with open(os.path.join(self.datadir, "sample_global_camera_model.yaml"), "r") as f: 

1096 cameraModel = yaml.load(f, Loader=yaml.Loader) 

1097 

1098 outputs = task.run( 

1099 self.inputVisitSummary, 

1100 self.isolatedStarSources, 

1101 self.isolatedStarCatalogs, 

1102 instrumentName=self.instrumentName, 

1103 refObjectLoader=self.refObjectLoader, 

1104 inputCameraModel=cameraModel, 

1105 ) 

1106 

1107 # Check that the output WCS is close (not necessarily exactly equal) to 

1108 # the result when fitting the full model. 

1109 for isolatedStarSourceRef in self.isolatedStarSources: 

1110 iss = isolatedStarSourceRef.get() 

1111 visits = np.unique(iss["visit"]) 

1112 for v, visit in enumerate(visits): 

1113 outputWcsCatalog = outputs.outputWcss[visit] 

1114 visitSources = iss[iss["visit"] == visit] 

1115 detectors = outputWcsCatalog["id"] 

1116 for d, detectorId in enumerate(detectors): 

1117 fitwcs = outputWcsCatalog[d].getWcs() 

1118 detSources = visitSources[visitSources["detector"] == detectorId] 

1119 fitRA, fitDec = fitwcs.pixelToSkyArray(detSources["x"], detSources["y"], degrees=True) 

1120 dRA = fitRA - detSources["trueRA"] 

1121 dDec = fitDec - detSources["trueDec"] 

1122 # Check that input coordinates match the output coordinates 

1123 self.assertAlmostEqual(np.mean(dRA), 0, places=6) 

1124 self.assertAlmostEqual(np.std(dRA), 0) 

1125 self.assertAlmostEqual(np.mean(dDec), 0, places=6) 

1126 self.assertAlmostEqual(np.std(dDec), 0) 

1127 

1128 def test_useColor(self): 

1129 """Test running task with color catalog and DCR fitting.""" 

1130 config = copy(self.config) 

1131 config.useColor = True 

1132 

1133 task = GbdesGlobalAstrometricFitTask(config=config) 

1134 

1135 outputs = task.run( 

1136 self.inputVisitSummary, 

1137 self.isolatedStarSources, 

1138 self.isolatedStarCatalogs, 

1139 instrumentName=self.instrumentName, 

1140 refObjectLoader=self.refObjectLoader, 

1141 colorCatalog=self.colorCatalog, 

1142 ) 

1143 self.assertEqual(len(outputs.colorParams["visit"]), len(self.testVisits)) 

1144 

1145 

1146class TestApparentMotion(lsst.utils.tests.TestCase): 

1147 """This class simulates an array of objects with random proper motions and 

1148 parallaxes and compares the results of 

1149 `lsst.drp.tasks.gbdesAstrometricFit.calculate_apparent_motion` against the 

1150 results calculated by astropy.""" 

1151 

1152 def setUp(self): 

1153 np.random.seed(12345) 

1154 

1155 self.ras = (np.random.rand(10) + 150.0) * u.degree 

1156 self.decs = (np.random.rand(10) + 2.5) * u.degree 

1157 

1158 self.pmRAs = (np.random.random(10) * 10) * u.mas / u.yr 

1159 self.pmDecs = (np.random.random(10) * 10) * u.mas / u.yr 

1160 

1161 self.parallaxes = (abs(np.random.random(10) * 5)) * u.mas 

1162 

1163 self.mjds = astropy.time.Time(np.random.rand(10) * 20 + 57000, format="mjd", scale="tai") 

1164 

1165 self.refEpoch = astropy.time.Time(57100, format="mjd", scale="tai") 

1166 

1167 def test_proper_motion(self): 

1168 """Calculate the change in position due to proper motion only for a 

1169 given time change, and compare the results against astropy. 

1170 """ 

1171 # Set parallaxes to zero to test proper motion part by itself. 

1172 parallaxes = np.zeros(len(self.ras)) * u.mas 

1173 

1174 data = astropy.table.Table( 

1175 { 

1176 "ra": self.ras, 

1177 "dec": self.decs, 

1178 "pmRA": self.pmRAs, 

1179 "pmDec": self.pmDecs, 

1180 "parallax": parallaxes, 

1181 "MJD": self.mjds, 

1182 } 

1183 ) 

1184 raCorrection, decCorrection = calculate_apparent_motion(data, self.refEpoch) 

1185 

1186 pmRACosDec = self.pmRAs * np.cos(self.decs) 

1187 coords = SkyCoord( 

1188 ra=self.ras, 

1189 dec=self.decs, 

1190 pm_ra_cosdec=pmRACosDec, 

1191 pm_dec=self.pmDecs, 

1192 obstime=self.refEpoch, 

1193 ) 

1194 

1195 newCoords = coords.apply_space_motion(new_obstime=self.mjds) 

1196 astropyDRA = newCoords.ra.degree - coords.ra.degree 

1197 astropyDDec = newCoords.dec.degree - coords.dec.degree 

1198 

1199 # The proper motion values are on the order of 0-3 mas/yr and the 

1200 # differences are on the scale of 1e-8 mas. 

1201 self.assertFloatsAlmostEqual(astropyDRA, raCorrection.value, rtol=1e-6) 

1202 self.assertFloatsAlmostEqual(astropyDDec, decCorrection.value, rtol=1e-6) 

1203 

1204 def test_parallax(self): 

1205 """Calculate the change in position due to parallax for a given time 

1206 change and compare the results against astropy. Astropy will not give 

1207 the parallax part separate from other sources of space motion, such as 

1208 annual aberration, but it can be obtained by comparing the calculated 

1209 positions of an object with a given parallax and one with a much 

1210 further distance. The result is still only approximately the same, but 

1211 is close enough for accuracy needed here. 

1212 """ 

1213 # Set proper motions to zero to test parallax correction by itself. 

1214 pms = np.zeros(len(self.ras)) * u.mas / u.yr 

1215 

1216 data = astropy.table.Table( 

1217 { 

1218 "ra": self.ras, 

1219 "dec": self.decs, 

1220 "pmRA": pms, 

1221 "pmDec": pms, 

1222 "parallax": self.parallaxes, 

1223 "MJD": self.mjds, 

1224 } 

1225 ) 

1226 raCorrection, decCorrection = calculate_apparent_motion(data, self.refEpoch) 

1227 

1228 coords = SkyCoord( 

1229 ra=self.ras, 

1230 dec=self.decs, 

1231 pm_ra_cosdec=pms, 

1232 pm_dec=pms, 

1233 distance=Distance(parallax=self.parallaxes), 

1234 obstime=self.refEpoch, 

1235 ) 

1236 # Make another set of objects at the same coordinates but a much 

1237 # greater distant in order to isolate parallax when applying space 

1238 # motion. 

1239 refCoords = SkyCoord( 

1240 ra=self.ras, 

1241 dec=self.decs, 

1242 pm_ra_cosdec=pms, 

1243 pm_dec=pms, 

1244 distance=1e10 * Distance(parallax=self.parallaxes), 

1245 obstime=self.refEpoch, 

1246 ) 

1247 

1248 newCoords = coords.apply_space_motion(new_obstime=self.mjds).transform_to("gcrs") 

1249 refNewCoords = refCoords.apply_space_motion(new_obstime=self.mjds).transform_to("gcrs") 

1250 astropyDRA = newCoords.ra.degree - refNewCoords.ra.degree 

1251 astropyDDec = newCoords.dec.degree - refNewCoords.dec.degree 

1252 

1253 # The parallax values are on the scale of 1-3 mas, and the difference 

1254 # between the two calculation methods is ~0.05 mas. 

1255 self.assertFloatsAlmostEqual(astropyDRA, raCorrection.value, rtol=2e-2) 

1256 self.assertFloatsAlmostEqual(astropyDDec, decCorrection.value, rtol=2e-2) 

1257 

1258 

1259def setup_module(module): 

1260 lsst.utils.tests.init() 

1261 

1262 

1263if __name__ == "__main__": 1263 ↛ 1264line 1263 didn't jump to line 1264 because the condition on line 1263 was never true

1264 lsst.utils.tests.init() 

1265 unittest.main()