Coverage for python/lsst/drp/tasks/gbdesAstrometricFit.py: 10%

509 statements  

« prev     ^ index     » next       coverage.py v7.3.3, created at 2023-12-16 15:07 +0000

1# This file is part of drp_tasks. 

2# 

3# LSST Data Management System 

4# This product includes software developed by the 

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

6# See COPYRIGHT file at the top of the source tree. 

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 <https://www.lsstcorp.org/LegalNotices/>. 

21# 

22import astropy.coordinates 

23import astropy.time 

24import astropy.units as u 

25import astshim 

26import lsst.afw.geom as afwgeom 

27import lsst.afw.table 

28import lsst.geom 

29import lsst.pex.config as pexConfig 

30import lsst.pipe.base as pipeBase 

31import lsst.sphgeom 

32import numpy as np 

33import wcsfit 

34import yaml 

35from lsst.meas.algorithms import ( 

36 LoadReferenceObjectsConfig, 

37 ReferenceObjectLoader, 

38 ReferenceSourceSelectorTask, 

39) 

40from lsst.meas.algorithms.sourceSelector import sourceSelectorRegistry 

41 

42__all__ = ["GbdesAstrometricFitConnections", "GbdesAstrometricFitConfig", "GbdesAstrometricFitTask"] 

43 

44 

45def _make_ref_covariance_matrix( 

46 refCat, inputUnit=u.radian, outputCoordUnit=u.marcsec, outputPMUnit=u.marcsec, version=1 

47): 

48 """Make a covariance matrix for the reference catalog including proper 

49 motion and parallax. 

50 

51 The output is flattened to one dimension to match the format expected by 

52 `gbdes`. 

53 

54 Parameters 

55 ---------- 

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

57 Catalog including proper motion and parallax measurements. 

58 inputUnit : `astropy.unit.core.Unit` 

59 Units of the input catalog 

60 outputCoordUnit : `astropy.unit.core.Unit` 

61 Units required for the coordinates in the covariance matrix. `gbdes` 

62 expects milliarcseconds. 

63 outputPMUnit : `astropy.unit.core.Unit` 

64 Units required for the proper motion/parallax in the covariance matrix. 

65 `gbdes` expects milliarcseconds. 

66 version : `int` 

67 Version of the reference catalog. Version 2 includes covariance 

68 measurements. 

69 Returns 

70 ------- 

71 cov : `list` of `float` 

72 Flattened output covariance matrix. 

73 """ 

74 cov = np.zeros((len(refCat), 25)) 

75 if version == 1: 

76 # Here is the standard ordering of components in the cov matrix, 

77 # to match the PM enumeration in C++ code of gbdes package's Match. 

78 # Each tuple gives: the array holding the 1d error, 

79 # the string in Gaia column names for this 

80 # the ordering in the Gaia catalog 

81 # and the ordering of the tuples is the order we want in our cov matrix 

82 raErr = (refCat["coord_raErr"] * inputUnit).to(outputCoordUnit).to_value() 

83 decErr = (refCat["coord_decErr"] * inputUnit).to(outputCoordUnit).to_value() 

84 raPMErr = (refCat["pm_raErr"] * inputUnit).to(outputPMUnit).to_value() 

85 decPMErr = (refCat["pm_decErr"] * inputUnit).to(outputPMUnit).to_value() 

86 parallaxErr = (refCat["parallaxErr"] * inputUnit).to(outputPMUnit).to_value() 

87 stdOrder = ( 

88 (raErr, "ra", 0), 

89 (decErr, "dec", 1), 

90 (raPMErr, "pmra", 3), 

91 (decPMErr, "pmdec", 4), 

92 (parallaxErr, "parallax", 2), 

93 ) 

94 

95 k = 0 

96 for i, pr1 in enumerate(stdOrder): 

97 for j, pr2 in enumerate(stdOrder): 

98 if pr1[2] < pr2[2]: 

99 cov[:, k] = 0 

100 elif pr1[2] > pr2[2]: 

101 cov[:, k] = 0 

102 else: 

103 # diagnonal element 

104 cov[:, k] = pr1[0] * pr2[0] 

105 k = k + 1 

106 

107 elif version == 2: 

108 positionParameters = ["coord_ra", "coord_dec", "pm_ra", "pm_dec", "parallax"] 

109 units = [outputCoordUnit, outputCoordUnit, outputPMUnit, outputPMUnit, outputPMUnit] 

110 k = 0 

111 for i, pi in enumerate(positionParameters): 

112 for j, pj in enumerate(positionParameters): 

113 if i == j: 

114 cov[:, k] = (refCat[f"{pi}Err"] ** 2 * inputUnit**2).to_value(units[j] * units[j]) 

115 elif i > j: 

116 cov[:, k] = (refCat[f"{pj}_{pi}_Cov"] * inputUnit**2).to_value(units[i] * units[j]) 

117 else: 

118 cov[:, k] = (refCat[f"{pi}_{pj}_Cov"] * inputUnit**2).to_value(units[i] * units[j]) 

119 

120 k += 1 

121 return cov 

122 

123 

124def _nCoeffsFromDegree(degree): 

125 """Get the number of coefficients for a polynomial of a certain degree with 

126 two variables. 

127 

128 This uses the general formula that the number of coefficients for a 

129 polynomial of degree d with n variables is (n + d) choose d, where in this 

130 case n is fixed to 2. 

131 

132 Parameters 

133 ---------- 

134 degree : `int` 

135 Degree of the polynomial in question. 

136 

137 Returns 

138 ------- 

139 nCoeffs : `int` 

140 Number of coefficients for the polynomial in question. 

141 """ 

142 nCoeffs = int((degree + 2) * (degree + 1) / 2) 

143 return nCoeffs 

144 

145 

146def _degreeFromNCoeffs(nCoeffs): 

147 """Get the degree for a polynomial with two variables and a certain number 

148 of coefficients. 

149 

150 This is done by applying the quadratic formula to the 

151 formula for calculating the number of coefficients of the polynomial. 

152 

153 Parameters 

154 ---------- 

155 nCoeffs : `int` 

156 Number of coefficients for the polynomial in question. 

157 

158 Returns 

159 ------- 

160 degree : `int` 

161 Degree of the polynomial in question. 

162 """ 

163 degree = int(-1.5 + 0.5 * (1 + 8 * nCoeffs) ** 0.5) 

164 return degree 

165 

166 

167def _convert_to_ast_polymap_coefficients(coefficients): 

168 """Convert vector of polynomial coefficients from the format used in 

169 `gbdes` into AST format (see Poly2d::vectorIndex(i, j) in 

170 gbdes/gbutil/src/Poly2d.cpp). This assumes two input and two output 

171 coordinates. 

172 

173 Parameters 

174 ---------- 

175 coefficients : `list` 

176 Coefficients of the polynomials. 

177 degree : `int` 

178 Degree of the polynomial. 

179 

180 Returns 

181 ------- 

182 astPoly : `astshim.PolyMap` 

183 Coefficients in AST polynomial format. 

184 """ 

185 polyArray = np.zeros((len(coefficients), 4)) 

186 N = len(coefficients) / 2 

187 degree = _degreeFromNCoeffs(N) 

188 

189 for outVar in [1, 2]: 

190 for i in range(degree + 1): 

191 for j in range(degree + 1): 

192 if (i + j) > degree: 

193 continue 

194 vectorIndex = int(((i + j) * (i + j + 1)) / 2 + j + N * (outVar - 1)) 

195 polyArray[vectorIndex, 0] = coefficients[vectorIndex] 

196 polyArray[vectorIndex, 1] = outVar 

197 polyArray[vectorIndex, 2] = i 

198 polyArray[vectorIndex, 3] = j 

199 

200 astPoly = astshim.PolyMap(polyArray, 2, options="IterInverse=1,NIterInverse=10,TolInverse=1e-7") 

201 return astPoly 

202 

203 

204class GbdesAstrometricFitConnections( 

205 pipeBase.PipelineTaskConnections, dimensions=("skymap", "tract", "instrument", "physical_filter") 

206): 

207 """Middleware input/output connections for task data.""" 

208 

209 inputCatalogRefs = pipeBase.connectionTypes.Input( 

210 doc="Source table in parquet format, per visit.", 

211 name="preSourceTable_visit", 

212 storageClass="DataFrame", 

213 dimensions=("instrument", "visit"), 

214 deferLoad=True, 

215 multiple=True, 

216 ) 

217 inputVisitSummaries = pipeBase.connectionTypes.Input( 

218 doc=( 

219 "Per-visit consolidated exposure metadata built from calexps. " 

220 "These catalogs use detector id for the id and must be sorted for " 

221 "fast lookups of a detector." 

222 ), 

223 name="visitSummary", 

224 storageClass="ExposureCatalog", 

225 dimensions=("instrument", "visit"), 

226 multiple=True, 

227 ) 

228 referenceCatalog = pipeBase.connectionTypes.PrerequisiteInput( 

229 doc="The astrometry reference catalog to match to loaded input catalog sources.", 

230 name="gaia_dr3_20230707", 

231 storageClass="SimpleCatalog", 

232 dimensions=("skypix",), 

233 deferLoad=True, 

234 multiple=True, 

235 ) 

236 outputWcs = pipeBase.connectionTypes.Output( 

237 doc=( 

238 "Per-tract, per-visit world coordinate systems derived from the fitted model." 

239 " These catalogs only contain entries for detectors with an output, and use" 

240 " the detector id for the catalog id, sorted on id for fast lookups of a detector." 

241 ), 

242 name="gbdesAstrometricFitSkyWcsCatalog", 

243 storageClass="ExposureCatalog", 

244 dimensions=("instrument", "visit", "skymap", "tract"), 

245 multiple=True, 

246 ) 

247 outputCatalog = pipeBase.connectionTypes.Output( 

248 doc=( 

249 "Source table with stars used in fit, along with residuals in pixel coordinates and tangent " 

250 "plane coordinates and chisq values." 

251 ), 

252 name="gbdesAstrometricFit_fitStars", 

253 storageClass="ArrowNumpyDict", 

254 dimensions=("instrument", "skymap", "tract", "physical_filter"), 

255 ) 

256 starCatalog = pipeBase.connectionTypes.Output( 

257 doc="Star catalog.", 

258 name="gbdesAstrometricFit_starCatalog", 

259 storageClass="ArrowNumpyDict", 

260 dimensions=("instrument", "skymap", "tract", "physical_filter"), 

261 ) 

262 modelParams = pipeBase.connectionTypes.Output( 

263 doc="WCS parameter covariance.", 

264 name="gbdesAstrometricFit_modelParams", 

265 storageClass="ArrowNumpyDict", 

266 dimensions=("instrument", "skymap", "tract", "physical_filter"), 

267 ) 

268 

269 def getSpatialBoundsConnections(self): 

270 return ("inputVisitSummaries",) 

271 

272 def __init__(self, *, config=None): 

273 super().__init__(config=config) 

274 

275 if not self.config.saveModelParams: 

276 self.outputs.remove("modelParams") 

277 

278 

279class GbdesAstrometricFitConfig( 

280 pipeBase.PipelineTaskConfig, pipelineConnections=GbdesAstrometricFitConnections 

281): 

282 """Configuration for GbdesAstrometricFitTask""" 

283 

284 sourceSelector = sourceSelectorRegistry.makeField( 

285 doc="How to select sources for cross-matching.", default="science" 

286 ) 

287 referenceSelector = pexConfig.ConfigurableField( 

288 target=ReferenceSourceSelectorTask, 

289 doc="How to down-select the loaded astrometry reference catalog.", 

290 ) 

291 matchRadius = pexConfig.Field( 

292 doc="Matching tolerance between associated objects (arcseconds).", dtype=float, default=1.0 

293 ) 

294 minMatches = pexConfig.Field( 

295 doc="Number of matches required to keep a source object.", dtype=int, default=2 

296 ) 

297 allowSelfMatches = pexConfig.Field( 

298 doc="Allow multiple sources from the same visit to be associated with the same object.", 

299 dtype=bool, 

300 default=False, 

301 ) 

302 sourceFluxType = pexConfig.Field( 

303 dtype=str, 

304 doc="Source flux field to use in source selection and to get fluxes from the catalog.", 

305 default="apFlux_12_0", 

306 ) 

307 systematicError = pexConfig.Field( 

308 dtype=float, 

309 doc=( 

310 "Systematic error padding added in quadrature for the science catalogs (marcsec). The default" 

311 "value is equivalent to 0.02 pixels for HSC." 

312 ), 

313 default=0.0034, 

314 ) 

315 referenceSystematicError = pexConfig.Field( 

316 dtype=float, 

317 doc="Systematic error padding added in quadrature for the reference catalog (marcsec).", 

318 default=0.0, 

319 ) 

320 modelComponents = pexConfig.ListField( 

321 dtype=str, 

322 doc=( 

323 "List of mappings to apply to transform from pixels to sky, in order of their application." 

324 "Supported options are 'INSTRUMENT/DEVICE' and 'EXPOSURE'." 

325 ), 

326 default=["INSTRUMENT/DEVICE", "EXPOSURE"], 

327 ) 

328 deviceModel = pexConfig.ListField( 

329 dtype=str, 

330 doc=( 

331 "List of mappings to apply to transform from detector pixels to intermediate frame. Map names" 

332 "should match the format 'BAND/DEVICE/<map name>'." 

333 ), 

334 default=["BAND/DEVICE/poly"], 

335 ) 

336 exposureModel = pexConfig.ListField( 

337 dtype=str, 

338 doc=( 

339 "List of mappings to apply to transform from intermediate frame to sky coordinates. Map names" 

340 "should match the format 'EXPOSURE/<map name>'." 

341 ), 

342 default=["EXPOSURE/poly"], 

343 ) 

344 devicePolyOrder = pexConfig.Field(dtype=int, doc="Order of device polynomial model.", default=4) 

345 exposurePolyOrder = pexConfig.Field(dtype=int, doc="Order of exposure polynomial model.", default=6) 

346 fitProperMotion = pexConfig.Field(dtype=bool, doc="Fit the proper motions of the objects.", default=False) 

347 excludeNonPMObjects = pexConfig.Field( 

348 dtype=bool, doc="Exclude reference objects without proper motion/parallax information.", default=True 

349 ) 

350 fitReserveFraction = pexConfig.Field( 

351 dtype=float, default=0.2, doc="Fraction of objects to reserve from fit for validation." 

352 ) 

353 fitReserveRandomSeed = pexConfig.Field( 

354 dtype=int, 

355 doc="Set the random seed for selecting data points to reserve from the fit for validation.", 

356 default=1234, 

357 ) 

358 saveModelParams = pexConfig.Field( 

359 dtype=bool, 

360 doc=( 

361 "Save the parameters and covariance of the WCS model. Default to " 

362 "false because this can be very large." 

363 ), 

364 default=False, 

365 ) 

366 

367 def setDefaults(self): 

368 # Use only stars because aperture fluxes of galaxies are biased and 

369 # depend on seeing. 

370 self.sourceSelector["science"].doUnresolved = True 

371 self.sourceSelector["science"].unresolved.name = "extendedness" 

372 

373 # Use only isolated sources. 

374 self.sourceSelector["science"].doIsolated = True 

375 self.sourceSelector["science"].isolated.parentName = "parentSourceId" 

376 self.sourceSelector["science"].isolated.nChildName = "deblend_nChild" 

377 # Do not use either flux or centroid measurements with flags, 

378 # chosen from the usual QA flags for stars. 

379 self.sourceSelector["science"].doFlags = True 

380 badFlags = [ 

381 "pixelFlags_edge", 

382 "pixelFlags_saturated", 

383 "pixelFlags_interpolatedCenter", 

384 "pixelFlags_interpolated", 

385 "pixelFlags_crCenter", 

386 "pixelFlags_bad", 

387 "hsmPsfMoments_flag", 

388 f"{self.sourceFluxType}_flag", 

389 ] 

390 self.sourceSelector["science"].flags.bad = badFlags 

391 

392 # Use only primary sources. 

393 self.sourceSelector["science"].doRequirePrimary = True 

394 

395 def validate(self): 

396 super().validate() 

397 

398 # Check if all components of the device and exposure models are 

399 # supported. 

400 for component in self.deviceModel: 

401 if not (("poly" in component.lower()) or ("identity" in component.lower())): 

402 raise pexConfig.FieldValidationError( 

403 GbdesAstrometricFitConfig.deviceModel, 

404 self, 

405 f"deviceModel component {component} is not supported.", 

406 ) 

407 

408 for component in self.exposureModel: 

409 if not (("poly" in component.lower()) or ("identity" in component.lower())): 

410 raise pexConfig.FieldValidationError( 

411 GbdesAstrometricFitConfig.exposureModel, 

412 self, 

413 f"exposureModel component {component} is not supported.", 

414 ) 

415 

416 

417class GbdesAstrometricFitTask(pipeBase.PipelineTask): 

418 """Calibrate the WCS across multiple visits of the same field using the 

419 GBDES package. 

420 """ 

421 

422 ConfigClass = GbdesAstrometricFitConfig 

423 _DefaultName = "gbdesAstrometricFit" 

424 

425 def __init__(self, **kwargs): 

426 super().__init__(**kwargs) 

427 self.makeSubtask("sourceSelector") 

428 self.makeSubtask("referenceSelector") 

429 

430 def runQuantum(self, butlerQC, inputRefs, outputRefs): 

431 # We override runQuantum to set up the refObjLoaders 

432 inputs = butlerQC.get(inputRefs) 

433 

434 instrumentName = butlerQC.quantum.dataId["instrument"] 

435 

436 # Ensure the inputs are in a consistent order 

437 inputCatVisits = np.array([inputCat.dataId["visit"] for inputCat in inputs["inputCatalogRefs"]]) 

438 inputs["inputCatalogRefs"] = [inputs["inputCatalogRefs"][v] for v in inputCatVisits.argsort()] 

439 inputSumVisits = np.array([inputSum[0]["visit"] for inputSum in inputs["inputVisitSummaries"]]) 

440 inputs["inputVisitSummaries"] = [inputs["inputVisitSummaries"][v] for v in inputSumVisits.argsort()] 

441 inputRefHtm7s = np.array([inputRefCat.dataId["htm7"] for inputRefCat in inputRefs.referenceCatalog]) 

442 inputRefCatRefs = [inputRefs.referenceCatalog[htm7] for htm7 in inputRefHtm7s.argsort()] 

443 inputRefCats = np.array([inputRefCat.dataId["htm7"] for inputRefCat in inputs["referenceCatalog"]]) 

444 inputs["referenceCatalog"] = [inputs["referenceCatalog"][v] for v in inputRefCats.argsort()] 

445 

446 sampleRefCat = inputs["referenceCatalog"][0].get() 

447 refEpoch = sampleRefCat[0]["epoch"] 

448 

449 refConfig = LoadReferenceObjectsConfig() 

450 refConfig.anyFilterMapsToThis = "phot_g_mean" 

451 refConfig.requireProperMotion = True 

452 refObjectLoader = ReferenceObjectLoader( 

453 dataIds=[ref.datasetRef.dataId for ref in inputRefCatRefs], 

454 refCats=inputs.pop("referenceCatalog"), 

455 config=refConfig, 

456 log=self.log, 

457 ) 

458 

459 output = self.run( 

460 **inputs, instrumentName=instrumentName, refEpoch=refEpoch, refObjectLoader=refObjectLoader 

461 ) 

462 

463 wcsOutputRefDict = {outWcsRef.dataId["visit"]: outWcsRef for outWcsRef in outputRefs.outputWcs} 

464 for visit, outputWcs in output.outputWCSs.items(): 

465 butlerQC.put(outputWcs, wcsOutputRefDict[visit]) 

466 butlerQC.put(output.outputCatalog, outputRefs.outputCatalog) 

467 butlerQC.put(output.starCatalog, outputRefs.starCatalog) 

468 if self.config.saveModelParams: 

469 butlerQC.put(output.modelParams, outputRefs.modelParams) 

470 

471 def run( 

472 self, inputCatalogRefs, inputVisitSummaries, instrumentName="", refEpoch=None, refObjectLoader=None 

473 ): 

474 """Run the WCS fit for a given set of visits 

475 

476 Parameters 

477 ---------- 

478 inputCatalogRefs : `list` 

479 List of `DeferredDatasetHandle`s pointing to visit-level source 

480 tables. 

481 inputVisitSummaries : `list` of `lsst.afw.table.ExposureCatalog` 

482 List of catalogs with per-detector summary information. 

483 instrumentName : `str`, optional 

484 Name of the instrument used. This is only used for labelling. 

485 refEpoch : `float` 

486 Epoch of the reference objects in MJD. 

487 refObjectLoader : instance of 

488 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader` 

489 Referencef object loader instance. 

490 

491 Returns 

492 ------- 

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

494 ``outputWCSs`` : `list` of `lsst.afw.table.ExposureCatalog` 

495 List of exposure catalogs (one per visit) with the WCS for each 

496 detector set by the new fitted WCS. 

497 ``fitModel`` : `wcsfit.WCSFit` 

498 Model-fitting object with final model parameters. 

499 ``outputCatalog`` : `pyarrow.Table` 

500 Catalog with fit residuals of all sources used. 

501 """ 

502 if (len(inputVisitSummaries) == 1) and self.config.deviceModel and self.config.exposureModel: 

503 raise RuntimeError( 

504 "More than one exposure is necessary to break the degeneracy between the " 

505 "device model and the exposure model." 

506 ) 

507 self.log.info("Gathering instrument, exposure, and field info") 

508 # Set up an instrument object 

509 instrument = wcsfit.Instrument(instrumentName) 

510 

511 # Get RA, Dec, MJD, etc., for the input visits 

512 exposureInfo, exposuresHelper, extensionInfo = self._get_exposure_info( 

513 inputVisitSummaries, instrument 

514 ) 

515 

516 # Get information about the extent of the input visits 

517 fields, fieldCenter, fieldRadius = self._prep_sky(inputVisitSummaries, exposureInfo.medianEpoch) 

518 

519 self.log.info("Load catalogs and associate sources") 

520 # Set up class to associate sources into matches using a 

521 # friends-of-friends algorithm 

522 associations = wcsfit.FoFClass( 

523 fields, 

524 [instrument], 

525 exposuresHelper, 

526 [fieldRadius.asDegrees()], 

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

528 ) 

529 

530 # Add the reference catalog to the associator 

531 medianEpoch = astropy.time.Time(exposureInfo.medianEpoch, format="decimalyear").mjd 

532 refObjects, refCovariance = self._load_refcat( 

533 associations, refObjectLoader, fieldCenter, fieldRadius, extensionInfo, epoch=medianEpoch 

534 ) 

535 

536 # Add the science catalogs and associate new sources as they are added 

537 sourceIndices, usedColumns = self._load_catalogs_and_associate( 

538 associations, inputCatalogRefs, extensionInfo 

539 ) 

540 self._check_degeneracies(associations, extensionInfo) 

541 

542 self.log.info("Fit the WCSs") 

543 # Set up a YAML-type string using the config variables and a sample 

544 # visit 

545 inputYAML = self.make_yaml(inputVisitSummaries[0]) 

546 

547 # Set the verbosity level for WCSFit from the task log level. 

548 # TODO: DM-36850, Add lsst.log to gbdes so that log messages are 

549 # properly propagated. 

550 loglevel = self.log.getEffectiveLevel() 

551 if loglevel >= self.log.WARNING: 

552 verbose = 0 

553 elif loglevel == self.log.INFO: 

554 verbose = 1 

555 else: 

556 verbose = 2 

557 

558 # Set up the WCS-fitting class using the results of the FOF associator 

559 wcsf = wcsfit.WCSFit( 

560 fields, 

561 [instrument], 

562 exposuresHelper, 

563 extensionInfo.visitIndex, 

564 extensionInfo.detectorIndex, 

565 inputYAML, 

566 extensionInfo.wcs, 

567 associations.sequence, 

568 associations.extn, 

569 associations.obj, 

570 sysErr=self.config.systematicError, 

571 refSysErr=self.config.referenceSystematicError, 

572 usePM=self.config.fitProperMotion, 

573 verbose=verbose, 

574 ) 

575 

576 # Add the science and reference sources 

577 self._add_objects(wcsf, inputCatalogRefs, sourceIndices, extensionInfo, usedColumns) 

578 self._add_ref_objects(wcsf, refObjects, refCovariance, extensionInfo) 

579 

580 # There must be at least as many sources per visit as the number of 

581 # free parameters in the per-visit mapping. Set minFitExposures to be 

582 # the number of free parameters, so that visits with fewer visits are 

583 # dropped. 

584 nCoeffVisitModel = _nCoeffsFromDegree(self.config.exposurePolyOrder) 

585 # Do the WCS fit 

586 wcsf.fit( 

587 reserveFraction=self.config.fitReserveFraction, 

588 randomNumberSeed=self.config.fitReserveRandomSeed, 

589 minFitExposures=nCoeffVisitModel, 

590 ) 

591 self.log.info("WCS fitting done") 

592 

593 outputWCSs = self._make_outputs(wcsf, inputVisitSummaries, exposureInfo) 

594 outputCatalog = wcsf.getOutputCatalog() 

595 starCatalog = wcsf.getStarCatalog() 

596 modelParams = self._compute_model_params(wcsf) if self.config.saveModelParams else None 

597 

598 return pipeBase.Struct( 

599 outputWCSs=outputWCSs, 

600 fitModel=wcsf, 

601 outputCatalog=outputCatalog, 

602 starCatalog=starCatalog, 

603 modelParams=modelParams, 

604 ) 

605 

606 def _prep_sky(self, inputVisitSummaries, epoch, fieldName="Field"): 

607 """Get center and radius of the input tract. This assumes that all 

608 visits will be put into the same `wcsfit.Field` and fit together. 

609 

610 Paramaters 

611 ---------- 

612 inputVisitSummaries : `list` of `lsst.afw.table.ExposureCatalog` 

613 List of catalogs with per-detector summary information. 

614 epoch : float 

615 Reference epoch. 

616 fieldName : str 

617 Name of the field, used internally. 

618 

619 Returns 

620 ------- 

621 fields : `wcsfit.Fields` 

622 Object with field information. 

623 center : `lsst.geom.SpherePoint` 

624 Center of the field. 

625 radius : `lsst.sphgeom._sphgeom.Angle` 

626 Radius of the bounding circle of the tract. 

627 """ 

628 allDetectorCorners = [] 

629 for visSum in inputVisitSummaries: 

630 detectorCorners = [ 

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

632 for (ra, dec) in zip(visSum["raCorners"].ravel(), visSum["decCorners"].ravel()) 

633 ] 

634 allDetectorCorners.extend(detectorCorners) 

635 boundingCircle = lsst.sphgeom.ConvexPolygon.convexHull(allDetectorCorners).getBoundingCircle() 

636 center = lsst.geom.SpherePoint(boundingCircle.getCenter()) 

637 ra = center.getRa().asDegrees() 

638 dec = center.getDec().asDegrees() 

639 radius = boundingCircle.getOpeningAngle() 

640 

641 # wcsfit.Fields describes a list of fields, but we assume all 

642 # observations will be fit together in one field. 

643 fields = wcsfit.Fields([fieldName], [ra], [dec], [epoch]) 

644 

645 return fields, center, radius 

646 

647 def _get_exposure_info( 

648 self, inputVisitSummaries, instrument, fieldNumber=0, instrumentNumber=0, refEpoch=None 

649 ): 

650 """Get various information about the input visits to feed to the 

651 fitting routines. 

652 

653 Parameters 

654 ---------- 

655 inputVisitSummaries : `list` of `lsst.afw.table.ExposureCatalog` 

656 Tables for each visit with information for detectors. 

657 instrument : `wcsfit.Instrument` 

658 Instrument object to which detector information is added. 

659 fieldNumber : `int` 

660 Index of the field for these visits. Should be zero if all data is 

661 being fit together. 

662 instrumentNumber : `int` 

663 Index of the instrument for these visits. Should be zero if all 

664 data comes from the same instrument. 

665 refEpoch : `float` 

666 Epoch of the reference objects in MJD. 

667 

668 Returns 

669 ------- 

670 exposureInfo : `lsst.pipe.base.Struct` 

671 Struct containing general properties for the visits: 

672 ``visits`` : `list` 

673 List of visit names. 

674 ``detectors`` : `list` 

675 List of all detectors in any visit. 

676 ``ras`` : `list` of float 

677 List of boresight RAs for each visit. 

678 ``decs`` : `list` of float 

679 List of borseight Decs for each visit. 

680 ``medianEpoch`` : float 

681 Median epoch of all visits in decimal-year format. 

682 exposuresHelper : `wcsfit.ExposuresHelper` 

683 Object containing information about the input visits. 

684 extensionInfo : `lsst.pipe.base.Struct` 

685 Struct containing properties for each extension: 

686 ``visit`` : `np.ndarray` 

687 Name of the visit for this extension. 

688 ``detector`` : `np.ndarray` 

689 Name of the detector for this extension. 

690 ``visitIndex` : `np.ndarray` of `int` 

691 Index of visit for this extension. 

692 ``detectorIndex`` : `np.ndarray` of `int` 

693 Index of the detector for this extension. 

694 ``wcss`` : `np.ndarray` of `lsst.afw.geom.SkyWcs` 

695 Initial WCS for this extension. 

696 ``extensionType`` : `np.ndarray` of `str` 

697 "SCIENCE" or "REFERENCE". 

698 """ 

699 exposureNames = [] 

700 ras = [] 

701 decs = [] 

702 visits = [] 

703 detectors = [] 

704 airmasses = [] 

705 exposureTimes = [] 

706 mjds = [] 

707 observatories = [] 

708 wcss = [] 

709 

710 extensionType = [] 

711 extensionVisitIndices = [] 

712 extensionDetectorIndices = [] 

713 extensionVisits = [] 

714 extensionDetectors = [] 

715 # Get information for all the science visits 

716 for v, visitSummary in enumerate(inputVisitSummaries): 

717 visitInfo = visitSummary[0].getVisitInfo() 

718 visit = visitSummary[0]["visit"] 

719 visits.append(visit) 

720 exposureNames.append(str(visit)) 

721 raDec = visitInfo.getBoresightRaDec() 

722 ras.append(raDec.getRa().asRadians()) 

723 decs.append(raDec.getDec().asRadians()) 

724 airmasses.append(visitInfo.getBoresightAirmass()) 

725 exposureTimes.append(visitInfo.getExposureTime()) 

726 obsDate = visitInfo.getDate() 

727 obsMJD = obsDate.get(obsDate.MJD) 

728 mjds.append(obsMJD) 

729 # Get the observatory ICRS position for use in fitting parallax 

730 obsLon = visitInfo.observatory.getLongitude().asDegrees() 

731 obsLat = visitInfo.observatory.getLatitude().asDegrees() 

732 obsElev = visitInfo.observatory.getElevation() 

733 earthLocation = astropy.coordinates.EarthLocation.from_geodetic(obsLon, obsLat, obsElev) 

734 observatory_gcrs = earthLocation.get_gcrs(astropy.time.Time(obsMJD, format="mjd")) 

735 observatory_icrs = observatory_gcrs.transform_to(astropy.coordinates.ICRS()) 

736 # We want the position in AU in Cartesian coordinates 

737 observatories.append(observatory_icrs.cartesian.xyz.to(u.AU).value) 

738 

739 for row in visitSummary: 

740 detector = row["id"] 

741 if detector not in detectors: 

742 detectors.append(detector) 

743 detectorBounds = wcsfit.Bounds( 

744 row["bbox_min_x"], row["bbox_max_x"], row["bbox_min_y"], row["bbox_max_y"] 

745 ) 

746 instrument.addDevice(str(detector), detectorBounds) 

747 

748 detectorIndex = np.flatnonzero(detector == np.array(detectors))[0] 

749 extensionVisitIndices.append(v) 

750 extensionDetectorIndices.append(detectorIndex) 

751 extensionVisits.append(visit) 

752 extensionDetectors.append(detector) 

753 extensionType.append("SCIENCE") 

754 

755 wcs = row.getWcs() 

756 wcsRA = wcs.getSkyOrigin().getRa().asRadians() 

757 wcsDec = wcs.getSkyOrigin().getDec().asRadians() 

758 tangentPoint = wcsfit.Gnomonic(wcsRA, wcsDec) 

759 mapping = wcs.getFrameDict().getMapping("PIXELS", "IWC") 

760 gbdes_wcs = wcsfit.Wcs(wcsfit.ASTMap(mapping), tangentPoint) 

761 wcss.append(gbdes_wcs) 

762 

763 fieldNumbers = list(np.ones(len(exposureNames), dtype=int) * fieldNumber) 

764 instrumentNumbers = list(np.ones(len(exposureNames), dtype=int) * instrumentNumber) 

765 

766 # Set the reference epoch to be the median of the science visits. 

767 # The reference catalog will be shifted to this date. 

768 medianMJD = np.median(mjds) 

769 medianEpoch = astropy.time.Time(medianMJD, format="mjd").decimalyear 

770 

771 # Add information for the reference catalog. Most of the values are 

772 # not used. 

773 exposureNames.append("REFERENCE") 

774 visits.append(-1) 

775 fieldNumbers.append(0) 

776 if self.config.fitProperMotion: 

777 instrumentNumbers.append(-2) 

778 else: 

779 instrumentNumbers.append(-1) 

780 ras.append(0.0) 

781 decs.append(0.0) 

782 airmasses.append(0.0) 

783 exposureTimes.append(0) 

784 mjds.append((refEpoch if (refEpoch is not None) else medianMJD)) 

785 observatories.append(np.array([0, 0, 0])) 

786 identity = wcsfit.IdentityMap() 

787 icrs = wcsfit.SphericalICRS() 

788 refWcs = wcsfit.Wcs(identity, icrs, "Identity", np.pi / 180.0) 

789 wcss.append(refWcs) 

790 

791 extensionVisitIndices.append(len(exposureNames) - 1) 

792 extensionDetectorIndices.append(-1) # REFERENCE device must be -1 

793 extensionVisits.append(-1) 

794 extensionDetectors.append(-1) 

795 extensionType.append("REFERENCE") 

796 

797 # Make a table of information to use elsewhere in the class 

798 extensionInfo = pipeBase.Struct( 

799 visit=np.array(extensionVisits), 

800 detector=np.array(extensionDetectors), 

801 visitIndex=np.array(extensionVisitIndices), 

802 detectorIndex=np.array(extensionDetectorIndices), 

803 wcs=np.array(wcss), 

804 extensionType=np.array(extensionType), 

805 ) 

806 

807 # Make the exposureHelper object to use in the fitting routines 

808 exposuresHelper = wcsfit.ExposuresHelper( 

809 exposureNames, 

810 fieldNumbers, 

811 instrumentNumbers, 

812 ras, 

813 decs, 

814 airmasses, 

815 exposureTimes, 

816 mjds, 

817 observatories, 

818 ) 

819 

820 exposureInfo = pipeBase.Struct( 

821 visits=visits, detectors=detectors, ras=ras, decs=decs, medianEpoch=medianEpoch 

822 ) 

823 

824 return exposureInfo, exposuresHelper, extensionInfo 

825 

826 def _load_refcat( 

827 self, associations, refObjectLoader, center, radius, extensionInfo, epoch=None, fieldIndex=0 

828 ): 

829 """Load the reference catalog and add reference objects to the 

830 `wcsfit.FoFClass` object. 

831 

832 Parameters 

833 ---------- 

834 associations : `wcsfit.FoFClass` 

835 Object to which to add the catalog of reference objects. 

836 refObjectLoader : 

837 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader` 

838 Object set up to load reference catalog objects. 

839 center : `lsst.geom.SpherePoint` 

840 Center of the circle in which to load reference objects. 

841 radius : `lsst.sphgeom._sphgeom.Angle` 

842 Radius of the circle in which to load reference objects. 

843 extensionInfo : `lsst.pipe.base.Struct` 

844 Struct containing properties for each extension. 

845 epoch : `float` 

846 MJD to which to correct the object positions. 

847 fieldIndex : `int` 

848 Index of the field. Should be zero if all the data is fit together. 

849 

850 Returns 

851 ------- 

852 refObjects : `dict` 

853 Position and error information of reference objects. 

854 refCovariance : `list` of `float` 

855 Flattened output covariance matrix. 

856 """ 

857 formattedEpoch = astropy.time.Time(epoch, format="mjd") 

858 

859 refFilter = refObjectLoader.config.anyFilterMapsToThis 

860 skyCircle = refObjectLoader.loadSkyCircle(center, radius, refFilter, epoch=formattedEpoch) 

861 

862 selected = self.referenceSelector.run(skyCircle.refCat) 

863 # Need memory contiguity to get reference filters as a vector. 

864 if not selected.sourceCat.isContiguous(): 

865 refCat = selected.sourceCat.copy(deep=True) 

866 else: 

867 refCat = selected.sourceCat 

868 

869 # In Gaia DR3, missing values are denoted by NaNs. 

870 finiteInd = np.isfinite(refCat["coord_ra"]) & np.isfinite(refCat["coord_dec"]) 

871 refCat = refCat[finiteInd] 

872 

873 if self.config.excludeNonPMObjects: 

874 # Gaia DR2 has zeros for missing data, while Gaia DR3 has NaNs: 

875 hasPM = ( 

876 (refCat["pm_raErr"] != 0) & np.isfinite(refCat["pm_raErr"]) & np.isfinite(refCat["pm_decErr"]) 

877 ) 

878 refCat = refCat[hasPM] 

879 

880 ra = (refCat["coord_ra"] * u.radian).to(u.degree).to_value().tolist() 

881 dec = (refCat["coord_dec"] * u.radian).to(u.degree).to_value().tolist() 

882 raCov = ((refCat["coord_raErr"] * u.radian).to(u.degree).to_value() ** 2).tolist() 

883 decCov = ((refCat["coord_decErr"] * u.radian).to(u.degree).to_value() ** 2).tolist() 

884 

885 # Get refcat version from refcat metadata 

886 refCatMetadata = refObjectLoader.refCats[0].get().getMetadata() 

887 refCatVersion = refCatMetadata["REFCAT_FORMAT_VERSION"] 

888 if refCatVersion == 2: 

889 raDecCov = ( 

890 (refCat["coord_ra_coord_dec_Cov"] * u.radian**2).to(u.degree**2).to_value().tolist() 

891 ) 

892 else: 

893 raDecCov = np.zeros(len(ra)) 

894 

895 refObjects = {"ra": ra, "dec": dec, "raCov": raCov, "decCov": decCov, "raDecCov": raDecCov} 

896 refCovariance = [] 

897 

898 if self.config.fitProperMotion: 

899 raPM = (refCat["pm_ra"] * u.radian).to(u.marcsec).to_value().tolist() 

900 decPM = (refCat["pm_dec"] * u.radian).to(u.marcsec).to_value().tolist() 

901 parallax = (refCat["parallax"] * u.radian).to(u.marcsec).to_value().tolist() 

902 cov = _make_ref_covariance_matrix(refCat, version=refCatVersion) 

903 pmDict = {"raPM": raPM, "decPM": decPM, "parallax": parallax} 

904 refObjects.update(pmDict) 

905 refCovariance = cov 

906 

907 extensionIndex = np.flatnonzero(extensionInfo.extensionType == "REFERENCE")[0] 

908 visitIndex = extensionInfo.visitIndex[extensionIndex] 

909 detectorIndex = extensionInfo.detectorIndex[extensionIndex] 

910 instrumentIndex = -1 # -1 indicates the reference catalog 

911 refWcs = extensionInfo.wcs[extensionIndex] 

912 

913 associations.addCatalog( 

914 refWcs, 

915 "STELLAR", 

916 visitIndex, 

917 fieldIndex, 

918 instrumentIndex, 

919 detectorIndex, 

920 extensionIndex, 

921 np.ones(len(refCat), dtype=bool), 

922 ra, 

923 dec, 

924 np.arange(len(ra)), 

925 ) 

926 

927 return refObjects, refCovariance 

928 

929 def _load_catalogs_and_associate( 

930 self, associations, inputCatalogRefs, extensionInfo, fieldIndex=0, instrumentIndex=0 

931 ): 

932 """Load the science catalogs and add the sources to the associator 

933 class `wcsfit.FoFClass`, associating them into matches as you go. 

934 

935 Parameters 

936 ---------- 

937 associations : `wcsfit.FoFClass` 

938 Object to which to add the catalog of source and which performs 

939 the source association. 

940 inputCatalogRefs : `list` 

941 List of DeferredDatasetHandles pointing to visit-level source 

942 tables. 

943 extensionInfo : `lsst.pipe.base.Struct` 

944 Struct containing properties for each extension. 

945 fieldIndex : `int` 

946 Index of the field for these catalogs. Should be zero assuming all 

947 data is being fit together. 

948 instrumentIndex : `int` 

949 Index of the instrument for these catalogs. Should be zero 

950 assuming all data comes from the same instrument. 

951 

952 Returns 

953 ------- 

954 sourceIndices : `list` 

955 List of boolean arrays used to select sources. 

956 columns : `list` of `str` 

957 List of columns needed from source tables. 

958 """ 

959 columns = [ 

960 "detector", 

961 "sourceId", 

962 "x", 

963 "xErr", 

964 "y", 

965 "yErr", 

966 "ixx", 

967 "iyy", 

968 "ixy", 

969 f"{self.config.sourceFluxType}_instFlux", 

970 f"{self.config.sourceFluxType}_instFluxErr", 

971 ] 

972 if self.sourceSelector.config.doFlags: 

973 columns.extend(self.sourceSelector.config.flags.bad) 

974 if self.sourceSelector.config.doUnresolved: 

975 columns.append(self.sourceSelector.config.unresolved.name) 

976 if self.sourceSelector.config.doIsolated: 

977 columns.append(self.sourceSelector.config.isolated.parentName) 

978 columns.append(self.sourceSelector.config.isolated.nChildName) 

979 if self.sourceSelector.config.doRequirePrimary: 

980 columns.append(self.sourceSelector.config.requirePrimary.primaryColName) 

981 

982 sourceIndices = [None] * len(extensionInfo.visit) 

983 for inputCatalogRef in inputCatalogRefs: 

984 visit = inputCatalogRef.dataId["visit"] 

985 inputCatalog = inputCatalogRef.get(parameters={"columns": columns}) 

986 # Get a sorted array of detector names 

987 detectors = np.unique(inputCatalog["detector"]) 

988 

989 for detector in detectors: 

990 detectorSources = inputCatalog[inputCatalog["detector"] == detector] 

991 xCov = detectorSources["xErr"] ** 2 

992 yCov = detectorSources["yErr"] ** 2 

993 xyCov = ( 

994 detectorSources["ixy"] * (xCov + yCov) / (detectorSources["ixx"] + detectorSources["iyy"]) 

995 ) 

996 # Remove sources with bad shape measurements 

997 goodShapes = xyCov**2 <= (xCov * yCov) 

998 selected = self.sourceSelector.run(detectorSources) 

999 goodInds = selected.selected & goodShapes 

1000 

1001 isStar = np.ones(goodInds.sum()) 

1002 extensionIndex = np.flatnonzero( 

1003 (extensionInfo.visit == visit) & (extensionInfo.detector == detector) 

1004 )[0] 

1005 detectorIndex = extensionInfo.detectorIndex[extensionIndex] 

1006 visitIndex = extensionInfo.visitIndex[extensionIndex] 

1007 

1008 sourceIndices[extensionIndex] = goodInds 

1009 

1010 wcs = extensionInfo.wcs[extensionIndex] 

1011 associations.reprojectWCS(wcs, fieldIndex) 

1012 

1013 associations.addCatalog( 

1014 wcs, 

1015 "STELLAR", 

1016 visitIndex, 

1017 fieldIndex, 

1018 instrumentIndex, 

1019 detectorIndex, 

1020 extensionIndex, 

1021 isStar, 

1022 detectorSources[goodInds]["x"].to_list(), 

1023 detectorSources[goodInds]["y"].to_list(), 

1024 np.arange(goodInds.sum()), 

1025 ) 

1026 

1027 associations.sortMatches( 

1028 fieldIndex, minMatches=self.config.minMatches, allowSelfMatches=self.config.allowSelfMatches 

1029 ) 

1030 

1031 return sourceIndices, columns 

1032 

1033 def _check_degeneracies(self, associations, extensionInfo): 

1034 """Check that the minimum number of visits and sources needed to 

1035 constrain the model are present. 

1036 

1037 This does not guarantee that the Hessian matrix of the chi-square, 

1038 which is used to fit the model, will be positive-definite, but if the 

1039 checks here do not pass, the matrix is certain to not be 

1040 positive-definite and the model cannot be fit. 

1041 

1042 Parameters 

1043 ---------- 

1044 associations : `wcsfit.FoFClass` 

1045 Object holding the source association information. 

1046 extensionInfo : `lsst.pipe.base.Struct` 

1047 Struct containing properties for each extension. 

1048 """ 

1049 # As a baseline, need to have more stars per detector than per-detector 

1050 # parameters, and more stars per visit than per-visit parameters. 

1051 whichExtension = np.array(associations.extn) 

1052 whichDetector = np.zeros(len(whichExtension)) 

1053 whichVisit = np.zeros(len(whichExtension)) 

1054 

1055 for extension, (detector, visit) in enumerate(zip(extensionInfo.detector, extensionInfo.visit)): 

1056 ex_ind = whichExtension == extension 

1057 whichDetector[ex_ind] = detector 

1058 whichVisit[ex_ind] = visit 

1059 

1060 if "BAND/DEVICE/poly" in self.config.deviceModel: 

1061 nCoeffDetectorModel = _nCoeffsFromDegree(self.config.devicePolyOrder) 

1062 unconstrainedDetectors = [] 

1063 for detector in np.unique(extensionInfo.detector): 

1064 numSources = (whichDetector == detector).sum() 

1065 if numSources < nCoeffDetectorModel: 

1066 unconstrainedDetectors.append(str(detector)) 

1067 

1068 if unconstrainedDetectors: 

1069 raise RuntimeError( 

1070 "The model is not constrained. The following detectors do not have enough " 

1071 f"sources ({nCoeffDetectorModel} required): ", 

1072 ", ".join(unconstrainedDetectors), 

1073 ) 

1074 

1075 def make_yaml(self, inputVisitSummary, inputFile=None): 

1076 """Make a YAML-type object that describes the parameters of the fit 

1077 model. 

1078 

1079 Parameters 

1080 ---------- 

1081 inputVisitSummary : `lsst.afw.table.ExposureCatalog` 

1082 Catalog with per-detector summary information. 

1083 inputFile : `str` 

1084 Path to a file that contains a basic model. 

1085 

1086 Returns 

1087 ------- 

1088 inputYAML : `wcsfit.YAMLCollector` 

1089 YAML object containing the model description. 

1090 """ 

1091 if inputFile is not None: 

1092 inputYAML = wcsfit.YAMLCollector(inputFile, "PixelMapCollection") 

1093 else: 

1094 inputYAML = wcsfit.YAMLCollector("", "PixelMapCollection") 

1095 inputDict = {} 

1096 modelComponents = ["INSTRUMENT/DEVICE", "EXPOSURE"] 

1097 baseMap = {"Type": "Composite", "Elements": modelComponents} 

1098 inputDict["EXPOSURE/DEVICE/base"] = baseMap 

1099 

1100 xMin = str(inputVisitSummary["bbox_min_x"].min()) 

1101 xMax = str(inputVisitSummary["bbox_max_x"].max()) 

1102 yMin = str(inputVisitSummary["bbox_min_y"].min()) 

1103 yMax = str(inputVisitSummary["bbox_max_y"].max()) 

1104 

1105 deviceModel = {"Type": "Composite", "Elements": self.config.deviceModel.list()} 

1106 inputDict["INSTRUMENT/DEVICE"] = deviceModel 

1107 for component in self.config.deviceModel: 

1108 if "poly" in component.lower(): 

1109 componentDict = { 

1110 "Type": "Poly", 

1111 "XPoly": {"OrderX": self.config.devicePolyOrder, "SumOrder": True}, 

1112 "YPoly": {"OrderX": self.config.devicePolyOrder, "SumOrder": True}, 

1113 "XMin": xMin, 

1114 "XMax": xMax, 

1115 "YMin": yMin, 

1116 "YMax": yMax, 

1117 } 

1118 elif "identity" in component.lower(): 

1119 componentDict = {"Type": "Identity"} 

1120 

1121 inputDict[component] = componentDict 

1122 

1123 exposureModel = {"Type": "Composite", "Elements": self.config.exposureModel.list()} 

1124 inputDict["EXPOSURE"] = exposureModel 

1125 for component in self.config.exposureModel: 

1126 if "poly" in component.lower(): 

1127 componentDict = { 

1128 "Type": "Poly", 

1129 "XPoly": {"OrderX": self.config.exposurePolyOrder, "SumOrder": "true"}, 

1130 "YPoly": {"OrderX": self.config.exposurePolyOrder, "SumOrder": "true"}, 

1131 } 

1132 elif "identity" in component.lower(): 

1133 componentDict = {"Type": "Identity"} 

1134 

1135 inputDict[component] = componentDict 

1136 

1137 inputYAML.addInput(yaml.dump(inputDict)) 

1138 inputYAML.addInput("Identity:\n Type: Identity\n") 

1139 

1140 return inputYAML 

1141 

1142 def _add_objects(self, wcsf, inputCatalogRefs, sourceIndices, extensionInfo, columns): 

1143 """Add science sources to the wcsfit.WCSFit object. 

1144 

1145 Parameters 

1146 ---------- 

1147 wcsf : `wcsfit.WCSFit` 

1148 WCS-fitting object. 

1149 inputCatalogRefs : `list` 

1150 List of DeferredDatasetHandles pointing to visit-level source 

1151 tables. 

1152 sourceIndices : `list` 

1153 List of boolean arrays used to select sources. 

1154 extensionInfo : `lsst.pipe.base.Struct` 

1155 Struct containing properties for each extension. 

1156 columns : `list` of `str` 

1157 List of columns needed from source tables. 

1158 """ 

1159 for inputCatalogRef in inputCatalogRefs: 

1160 visit = inputCatalogRef.dataId["visit"] 

1161 inputCatalog = inputCatalogRef.get(parameters={"columns": columns}) 

1162 detectors = np.unique(inputCatalog["detector"]) 

1163 

1164 for detector in detectors: 

1165 detectorSources = inputCatalog[inputCatalog["detector"] == detector] 

1166 

1167 extensionIndex = np.flatnonzero( 

1168 (extensionInfo.visit == visit) & (extensionInfo.detector == detector) 

1169 )[0] 

1170 sourceCat = detectorSources[sourceIndices[extensionIndex]] 

1171 

1172 xCov = sourceCat["xErr"] ** 2 

1173 yCov = sourceCat["yErr"] ** 2 

1174 xyCov = sourceCat["ixy"] * (xCov + yCov) / (sourceCat["ixx"] + sourceCat["iyy"]) 

1175 # TODO: add correct xyErr if DM-7101 is ever done. 

1176 

1177 d = { 

1178 "x": sourceCat["x"].to_numpy(), 

1179 "y": sourceCat["y"].to_numpy(), 

1180 "xCov": xCov.to_numpy(), 

1181 "yCov": yCov.to_numpy(), 

1182 "xyCov": xyCov.to_numpy(), 

1183 } 

1184 

1185 wcsf.setObjects(extensionIndex, d, "x", "y", ["xCov", "yCov", "xyCov"]) 

1186 

1187 def _add_ref_objects(self, wcsf, refObjects, refCovariance, extensionInfo): 

1188 """Add reference sources to the wcsfit.WCSFit object. 

1189 

1190 Parameters 

1191 ---------- 

1192 wcsf : `wcsfit.WCSFit` 

1193 WCS-fitting object. 

1194 refObjects : `dict` 

1195 Position and error information of reference objects. 

1196 refCovariance : `list` of `float` 

1197 Flattened output covariance matrix. 

1198 extensionInfo : `lsst.pipe.base.Struct` 

1199 Struct containing properties for each extension. 

1200 """ 

1201 extensionIndex = np.flatnonzero(extensionInfo.extensionType == "REFERENCE")[0] 

1202 

1203 if self.config.fitProperMotion: 

1204 wcsf.setObjects( 

1205 extensionIndex, 

1206 refObjects, 

1207 "ra", 

1208 "dec", 

1209 ["raCov", "decCov", "raDecCov"], 

1210 pmDecKey="decPM", 

1211 pmRaKey="raPM", 

1212 parallaxKey="parallax", 

1213 pmCovKey="fullCov", 

1214 pmCov=refCovariance, 

1215 ) 

1216 else: 

1217 wcsf.setObjects(extensionIndex, refObjects, "ra", "dec", ["raCov", "decCov", "raDecCov"]) 

1218 

1219 def _make_afw_wcs(self, mapDict, centerRA, centerDec, doNormalizePixels=False, xScale=1, yScale=1): 

1220 """Make an `lsst.afw.geom.SkyWcs` from a dictionary of mappings. 

1221 

1222 Parameters 

1223 ---------- 

1224 mapDict : `dict` 

1225 Dictionary of mapping parameters. 

1226 centerRA : `lsst.geom.Angle` 

1227 RA of the tangent point. 

1228 centerDec : `lsst.geom.Angle` 

1229 Declination of the tangent point. 

1230 doNormalizePixels : `bool` 

1231 Whether to normalize pixels so that range is [-1,1]. 

1232 xScale : `float` 

1233 Factor by which to normalize x-dimension. Corresponds to width of 

1234 detector. 

1235 yScale : `float` 

1236 Factor by which to normalize y-dimension. Corresponds to height of 

1237 detector. 

1238 

1239 Returns 

1240 ------- 

1241 outWCS : `lsst.afw.geom.SkyWcs` 

1242 WCS constructed from the input mappings 

1243 """ 

1244 # Set up pixel frames 

1245 pixelFrame = astshim.Frame(2, "Domain=PIXELS") 

1246 normedPixelFrame = astshim.Frame(2, "Domain=NORMEDPIXELS") 

1247 

1248 if doNormalizePixels: 

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

1250 normCoefficients = [-1.0, 2.0 / xScale, 0, -1.0, 0, 2.0 / yScale] 

1251 normMap = _convert_to_ast_polymap_coefficients(normCoefficients) 

1252 else: 

1253 normMap = astshim.UnitMap(2) 

1254 

1255 # All of the detectors for one visit map to the same tangent plane 

1256 tangentPoint = lsst.geom.SpherePoint(centerRA, centerDec) 

1257 cdMatrix = afwgeom.makeCdMatrix(1.0 * lsst.geom.degrees, 0 * lsst.geom.degrees, True) 

1258 iwcToSkyWcs = afwgeom.makeSkyWcs(lsst.geom.Point2D(0, 0), tangentPoint, cdMatrix) 

1259 iwcToSkyMap = iwcToSkyWcs.getFrameDict().getMapping("PIXELS", "SKY") 

1260 skyFrame = iwcToSkyWcs.getFrameDict().getFrame("SKY") 

1261 

1262 frameDict = astshim.FrameDict(pixelFrame) 

1263 frameDict.addFrame("PIXELS", normMap, normedPixelFrame) 

1264 

1265 currentFrameName = "NORMEDPIXELS" 

1266 

1267 # Dictionary values are ordered according to the maps' application. 

1268 for m, mapElement in enumerate(mapDict.values()): 

1269 mapType = mapElement["Type"] 

1270 

1271 if mapType == "Poly": 

1272 mapCoefficients = mapElement["Coefficients"] 

1273 astMap = _convert_to_ast_polymap_coefficients(mapCoefficients) 

1274 elif mapType == "Identity": 

1275 astMap = astshim.UnitMap(2) 

1276 else: 

1277 raise ValueError(f"Converting map type {mapType} to WCS is not supported") 

1278 

1279 if m == len(mapDict) - 1: 

1280 newFrameName = "IWC" 

1281 else: 

1282 newFrameName = "INTERMEDIATE" + str(m) 

1283 newFrame = astshim.Frame(2, f"Domain={newFrameName}") 

1284 frameDict.addFrame(currentFrameName, astMap, newFrame) 

1285 currentFrameName = newFrameName 

1286 frameDict.addFrame("IWC", iwcToSkyMap, skyFrame) 

1287 

1288 outWCS = afwgeom.SkyWcs(frameDict) 

1289 return outWCS 

1290 

1291 def _make_outputs(self, wcsf, visitSummaryTables, exposureInfo): 

1292 """Make a WCS object out of the WCS models. 

1293 

1294 Parameters 

1295 ---------- 

1296 wcsf : `wcsfit.WCSFit` 

1297 WCSFit object, assumed to have fit model. 

1298 visitSummaryTables : `list` of `lsst.afw.table.ExposureCatalog` 

1299 Catalogs with per-detector summary information from which to grab 

1300 detector information. 

1301 extensionInfo : `lsst.pipe.base.Struct` 

1302 Struct containing properties for each extension. 

1303 

1304 Returns 

1305 ------- 

1306 catalogs : `dict` of [`str`, `lsst.afw.table.ExposureCatalog`] 

1307 Dictionary of `lsst.afw.table.ExposureCatalog` objects with the WCS 

1308 set to the WCS fit in wcsf, keyed by the visit name. 

1309 """ 

1310 # Get the parameters of the fit models 

1311 mapParams = wcsf.mapCollection.getParamDict() 

1312 

1313 # Set up the schema for the output catalogs 

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

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

1316 

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

1318 sampleDetector = visitSummaryTables[0][0] 

1319 xscale = sampleDetector["bbox_max_x"] - sampleDetector["bbox_min_x"] 

1320 yscale = sampleDetector["bbox_max_y"] - sampleDetector["bbox_min_y"] 

1321 

1322 catalogs = {} 

1323 for v, visitSummary in enumerate(visitSummaryTables): 

1324 visit = visitSummary[0]["visit"] 

1325 

1326 visitMap = wcsf.mapCollection.orderAtoms(f"{visit}")[0] 

1327 visitMapType = wcsf.mapCollection.getMapType(visitMap) 

1328 if (visitMap not in mapParams) and (visitMapType != "Identity"): 

1329 self.log.warning("Visit %d was dropped because of an insufficient number of sources.", visit) 

1330 continue 

1331 

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

1333 catalog.resize(len(exposureInfo.detectors)) 

1334 catalog["visit"] = visit 

1335 

1336 for d, detector in enumerate(visitSummary["id"]): 

1337 mapName = f"{visit}/{detector}" 

1338 

1339 mapElements = wcsf.mapCollection.orderAtoms(f"{mapName}/base") 

1340 mapDict = {} 

1341 for m, mapElement in enumerate(mapElements): 

1342 mapType = wcsf.mapCollection.getMapType(mapElement) 

1343 mapDict[mapElement] = {"Type": mapType} 

1344 

1345 if mapType == "Poly": 

1346 mapCoefficients = mapParams[mapElement] 

1347 mapDict[mapElement]["Coefficients"] = mapCoefficients 

1348 

1349 # The RA and Dec of the visit are needed for the last step of 

1350 # the mapping from the visit tangent plane to RA and Dec 

1351 outWCS = self._make_afw_wcs( 

1352 mapDict, 

1353 exposureInfo.ras[v] * lsst.geom.radians, 

1354 exposureInfo.decs[v] * lsst.geom.radians, 

1355 doNormalizePixels=True, 

1356 xScale=xscale, 

1357 yScale=yscale, 

1358 ) 

1359 

1360 catalog[d].setId(detector) 

1361 catalog[d].setWcs(outWCS) 

1362 catalog.sort() 

1363 catalogs[visit] = catalog 

1364 

1365 return catalogs 

1366 

1367 def _compute_model_params(self, wcsf): 

1368 """Get the WCS model parameters and covariance and convert to a 

1369 dictionary that will be readable as a pandas dataframe or other table. 

1370 

1371 Parameters 

1372 ---------- 

1373 wcsf : `wcsfit.WCSFit` 

1374 WCSFit object, assumed to have fit model. 

1375 

1376 Returns 

1377 ------- 

1378 modelParams : `dict` 

1379 Parameters and covariance of the best-fit WCS model. 

1380 """ 

1381 modelParamDict = wcsf.mapCollection.getParamDict() 

1382 modelCovariance = wcsf.getModelCovariance() 

1383 

1384 modelParams = {k: [] for k in ["mapName", "coordinate", "parameter", "coefficientNumber"]} 

1385 i = 0 

1386 for mapName, params in modelParamDict.items(): 

1387 nCoeffs = len(params) 

1388 # There are an equal number of x and y coordinate parameters 

1389 nCoordCoeffs = nCoeffs // 2 

1390 modelParams["mapName"].extend([mapName] * nCoeffs) 

1391 modelParams["coordinate"].extend(["x"] * nCoordCoeffs) 

1392 modelParams["coordinate"].extend(["y"] * nCoordCoeffs) 

1393 modelParams["parameter"].extend(params) 

1394 modelParams["coefficientNumber"].extend(np.arange(nCoordCoeffs)) 

1395 modelParams["coefficientNumber"].extend(np.arange(nCoordCoeffs)) 

1396 

1397 for p in range(nCoeffs): 

1398 if p < nCoordCoeffs: 

1399 coord = "x" 

1400 else: 

1401 coord = "y" 

1402 modelParams[f"{mapName}_{coord}_{p}_cov"] = modelCovariance[i] 

1403 i += 1 

1404 

1405 # Convert the dictionary values from lists to numpy arrays. 

1406 for key, value in modelParams.items(): 

1407 modelParams[key] = np.array(value) 

1408 

1409 return modelParams