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

534 statements  

« prev     ^ index     » next       coverage.py v7.4.0, created at 2024-01-25 13:37 +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, mapTemplate = 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, mapTemplate=mapTemplate) 

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 if (np.isfinite(ra) and (np.isfinite(dec))) 

634 ] 

635 allDetectorCorners.extend(detectorCorners) 

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

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

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

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

640 radius = boundingCircle.getOpeningAngle() 

641 

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

643 # observations will be fit together in one field. 

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

645 

646 return fields, center, radius 

647 

648 def _get_exposure_info( 

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

650 ): 

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

652 fitting routines. 

653 

654 Parameters 

655 ---------- 

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

657 Tables for each visit with information for detectors. 

658 instrument : `wcsfit.Instrument` 

659 Instrument object to which detector information is added. 

660 fieldNumber : `int` 

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

662 being fit together. 

663 instrumentNumber : `int` 

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

665 data comes from the same instrument. 

666 refEpoch : `float` 

667 Epoch of the reference objects in MJD. 

668 

669 Returns 

670 ------- 

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

672 Struct containing general properties for the visits: 

673 ``visits`` : `list` 

674 List of visit names. 

675 ``detectors`` : `list` 

676 List of all detectors in any visit. 

677 ``ras`` : `list` of float 

678 List of boresight RAs for each visit. 

679 ``decs`` : `list` of float 

680 List of borseight Decs for each visit. 

681 ``medianEpoch`` : float 

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

683 exposuresHelper : `wcsfit.ExposuresHelper` 

684 Object containing information about the input visits. 

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

686 Struct containing properties for each extension: 

687 ``visit`` : `np.ndarray` 

688 Name of the visit for this extension. 

689 ``detector`` : `np.ndarray` 

690 Name of the detector for this extension. 

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

692 Index of visit for this extension. 

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

694 Index of the detector for this extension. 

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

696 Initial WCS for this extension. 

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

698 "SCIENCE" or "REFERENCE". 

699 """ 

700 exposureNames = [] 

701 ras = [] 

702 decs = [] 

703 visits = [] 

704 detectors = [] 

705 airmasses = [] 

706 exposureTimes = [] 

707 mjds = [] 

708 observatories = [] 

709 wcss = [] 

710 

711 extensionType = [] 

712 extensionVisitIndices = [] 

713 extensionDetectorIndices = [] 

714 extensionVisits = [] 

715 extensionDetectors = [] 

716 # Get information for all the science visits 

717 for v, visitSummary in enumerate(inputVisitSummaries): 

718 visitInfo = visitSummary[0].getVisitInfo() 

719 visit = visitSummary[0]["visit"] 

720 visits.append(visit) 

721 exposureNames.append(str(visit)) 

722 raDec = visitInfo.getBoresightRaDec() 

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

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

725 airmasses.append(visitInfo.getBoresightAirmass()) 

726 exposureTimes.append(visitInfo.getExposureTime()) 

727 obsDate = visitInfo.getDate() 

728 obsMJD = obsDate.get(obsDate.MJD) 

729 mjds.append(obsMJD) 

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

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

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

733 obsElev = visitInfo.observatory.getElevation() 

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

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

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

737 # We want the position in AU in Cartesian coordinates 

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

739 

740 for row in visitSummary: 

741 detector = row["id"] 

742 

743 wcs = row.getWcs() 

744 if wcs is None: 

745 self.log.warning( 

746 "WCS is None for visit %d, detector %d: this extension (visit/detector) will be " 

747 "dropped.", 

748 visit, 

749 detector, 

750 ) 

751 continue 

752 else: 

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

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

755 tangentPoint = wcsfit.Gnomonic(wcsRA, wcsDec) 

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

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

758 wcss.append(gbdes_wcs) 

759 

760 if detector not in detectors: 

761 detectors.append(detector) 

762 detectorBounds = wcsfit.Bounds( 

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

764 ) 

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

766 

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

768 extensionVisitIndices.append(v) 

769 extensionDetectorIndices.append(detectorIndex) 

770 extensionVisits.append(visit) 

771 extensionDetectors.append(detector) 

772 extensionType.append("SCIENCE") 

773 

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

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

776 

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

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

779 medianMJD = np.median(mjds) 

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

781 

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

783 # not used. 

784 exposureNames.append("REFERENCE") 

785 visits.append(-1) 

786 fieldNumbers.append(0) 

787 if self.config.fitProperMotion: 

788 instrumentNumbers.append(-2) 

789 else: 

790 instrumentNumbers.append(-1) 

791 ras.append(0.0) 

792 decs.append(0.0) 

793 airmasses.append(0.0) 

794 exposureTimes.append(0) 

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

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

797 identity = wcsfit.IdentityMap() 

798 icrs = wcsfit.SphericalICRS() 

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

800 wcss.append(refWcs) 

801 

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

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

804 extensionVisits.append(-1) 

805 extensionDetectors.append(-1) 

806 extensionType.append("REFERENCE") 

807 

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

809 extensionInfo = pipeBase.Struct( 

810 visit=np.array(extensionVisits), 

811 detector=np.array(extensionDetectors), 

812 visitIndex=np.array(extensionVisitIndices), 

813 detectorIndex=np.array(extensionDetectorIndices), 

814 wcs=np.array(wcss), 

815 extensionType=np.array(extensionType), 

816 ) 

817 

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

819 exposuresHelper = wcsfit.ExposuresHelper( 

820 exposureNames, 

821 fieldNumbers, 

822 instrumentNumbers, 

823 ras, 

824 decs, 

825 airmasses, 

826 exposureTimes, 

827 mjds, 

828 observatories, 

829 ) 

830 

831 exposureInfo = pipeBase.Struct( 

832 visits=visits, detectors=detectors, ras=ras, decs=decs, medianEpoch=medianEpoch 

833 ) 

834 

835 return exposureInfo, exposuresHelper, extensionInfo 

836 

837 def _load_refcat( 

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

839 ): 

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

841 `wcsfit.FoFClass` object. 

842 

843 Parameters 

844 ---------- 

845 associations : `wcsfit.FoFClass` 

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

847 refObjectLoader : 

848 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader` 

849 Object set up to load reference catalog objects. 

850 center : `lsst.geom.SpherePoint` 

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

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

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

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

855 Struct containing properties for each extension. 

856 epoch : `float` 

857 MJD to which to correct the object positions. 

858 fieldIndex : `int` 

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

860 

861 Returns 

862 ------- 

863 refObjects : `dict` 

864 Position and error information of reference objects. 

865 refCovariance : `list` of `float` 

866 Flattened output covariance matrix. 

867 """ 

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

869 

870 refFilter = refObjectLoader.config.anyFilterMapsToThis 

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

872 

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

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

875 if not selected.sourceCat.isContiguous(): 

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

877 else: 

878 refCat = selected.sourceCat 

879 

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

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

882 refCat = refCat[finiteInd] 

883 

884 if self.config.excludeNonPMObjects: 

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

886 hasPM = ( 

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

888 ) 

889 refCat = refCat[hasPM] 

890 

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

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

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

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

895 

896 # Get refcat version from refcat metadata 

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

898 refCatVersion = refCatMetadata["REFCAT_FORMAT_VERSION"] 

899 if refCatVersion == 2: 

900 raDecCov = ( 

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

902 ) 

903 else: 

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

905 

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

907 refCovariance = [] 

908 

909 if self.config.fitProperMotion: 

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

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

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

913 cov = _make_ref_covariance_matrix(refCat, version=refCatVersion) 

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

915 refObjects.update(pmDict) 

916 refCovariance = cov 

917 

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

919 visitIndex = extensionInfo.visitIndex[extensionIndex] 

920 detectorIndex = extensionInfo.detectorIndex[extensionIndex] 

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

922 refWcs = extensionInfo.wcs[extensionIndex] 

923 

924 associations.addCatalog( 

925 refWcs, 

926 "STELLAR", 

927 visitIndex, 

928 fieldIndex, 

929 instrumentIndex, 

930 detectorIndex, 

931 extensionIndex, 

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

933 ra, 

934 dec, 

935 np.arange(len(ra)), 

936 ) 

937 

938 return refObjects, refCovariance 

939 

940 @staticmethod 

941 def _find_extension_index(extensionInfo, visit, detector): 

942 """Find the index for a given extension from its visit and detector 

943 number. 

944 

945 If no match is found, None is returned. 

946 

947 Parameters 

948 ---------- 

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

950 Struct containing properties for each extension. 

951 visit : `int` 

952 Visit number 

953 detector : `int` 

954 Detector number 

955 

956 Returns 

957 ------- 

958 extensionIndex : `int` or None 

959 Index of this extension 

960 """ 

961 findExtension = np.flatnonzero((extensionInfo.visit == visit) & (extensionInfo.detector == detector)) 

962 if len(findExtension) == 0: 

963 extensionIndex = None 

964 else: 

965 extensionIndex = findExtension[0] 

966 return extensionIndex 

967 

968 def _load_catalogs_and_associate( 

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

970 ): 

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

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

973 

974 Parameters 

975 ---------- 

976 associations : `wcsfit.FoFClass` 

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

978 the source association. 

979 inputCatalogRefs : `list` 

980 List of DeferredDatasetHandles pointing to visit-level source 

981 tables. 

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

983 Struct containing properties for each extension. 

984 fieldIndex : `int` 

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

986 data is being fit together. 

987 instrumentIndex : `int` 

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

989 assuming all data comes from the same instrument. 

990 

991 Returns 

992 ------- 

993 sourceIndices : `list` 

994 List of boolean arrays used to select sources. 

995 columns : `list` of `str` 

996 List of columns needed from source tables. 

997 """ 

998 columns = [ 

999 "detector", 

1000 "sourceId", 

1001 "x", 

1002 "xErr", 

1003 "y", 

1004 "yErr", 

1005 "ixx", 

1006 "iyy", 

1007 "ixy", 

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

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

1010 ] 

1011 if self.sourceSelector.config.doFlags: 

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

1013 if self.sourceSelector.config.doUnresolved: 

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

1015 if self.sourceSelector.config.doIsolated: 

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

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

1018 if self.sourceSelector.config.doRequirePrimary: 

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

1020 

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

1022 for inputCatalogRef in inputCatalogRefs: 

1023 visit = inputCatalogRef.dataId["visit"] 

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

1025 # Get a sorted array of detector names 

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

1027 

1028 for detector in detectors: 

1029 detectorSources = inputCatalog[inputCatalog["detector"] == detector] 

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

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

1032 xyCov = ( 

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

1034 ) 

1035 # Remove sources with bad shape measurements 

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

1037 selected = self.sourceSelector.run(detectorSources) 

1038 goodInds = selected.selected & goodShapes 

1039 

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

1041 extensionIndex = self._find_extension_index(extensionInfo, visit, detector) 

1042 if extensionIndex is None: 

1043 # This extension does not have information necessary for 

1044 # fit. Skip the detections from this detector for this 

1045 # visit. 

1046 continue 

1047 detectorIndex = extensionInfo.detectorIndex[extensionIndex] 

1048 visitIndex = extensionInfo.visitIndex[extensionIndex] 

1049 

1050 sourceIndices[extensionIndex] = goodInds 

1051 

1052 wcs = extensionInfo.wcs[extensionIndex] 

1053 associations.reprojectWCS(wcs, fieldIndex) 

1054 

1055 associations.addCatalog( 

1056 wcs, 

1057 "STELLAR", 

1058 visitIndex, 

1059 fieldIndex, 

1060 instrumentIndex, 

1061 detectorIndex, 

1062 extensionIndex, 

1063 isStar, 

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

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

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

1067 ) 

1068 

1069 associations.sortMatches( 

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

1071 ) 

1072 

1073 return sourceIndices, columns 

1074 

1075 def _check_degeneracies(self, associations, extensionInfo): 

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

1077 constrain the model are present. 

1078 

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

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

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

1082 positive-definite and the model cannot be fit. 

1083 

1084 Parameters 

1085 ---------- 

1086 associations : `wcsfit.FoFClass` 

1087 Object holding the source association information. 

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

1089 Struct containing properties for each extension. 

1090 """ 

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

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

1093 whichExtension = np.array(associations.extn) 

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

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

1096 

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

1098 ex_ind = whichExtension == extension 

1099 whichDetector[ex_ind] = detector 

1100 whichVisit[ex_ind] = visit 

1101 

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

1103 nCoeffDetectorModel = _nCoeffsFromDegree(self.config.devicePolyOrder) 

1104 unconstrainedDetectors = [] 

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

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

1107 if numSources < nCoeffDetectorModel: 

1108 unconstrainedDetectors.append(str(detector)) 

1109 

1110 if unconstrainedDetectors: 

1111 raise RuntimeError( 

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

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

1114 ", ".join(unconstrainedDetectors), 

1115 ) 

1116 

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

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

1119 model. 

1120 

1121 Parameters 

1122 ---------- 

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

1124 Catalog with per-detector summary information. 

1125 inputFile : `str` 

1126 Path to a file that contains a basic model. 

1127 

1128 Returns 

1129 ------- 

1130 inputYAML : `wcsfit.YAMLCollector` 

1131 YAML object containing the model description. 

1132 inputDict : `dict` [`str`, `str`] 

1133 Dictionary containing the model description. 

1134 """ 

1135 if inputFile is not None: 

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

1137 else: 

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

1139 inputDict = {} 

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

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

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

1143 

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

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

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

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

1148 

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

1150 inputDict["INSTRUMENT/DEVICE"] = deviceModel 

1151 for component in self.config.deviceModel: 

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

1153 componentDict = { 

1154 "Type": "Poly", 

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

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

1157 "XMin": xMin, 

1158 "XMax": xMax, 

1159 "YMin": yMin, 

1160 "YMax": yMax, 

1161 } 

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

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

1164 

1165 inputDict[component] = componentDict 

1166 

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

1168 inputDict["EXPOSURE"] = exposureModel 

1169 for component in self.config.exposureModel: 

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

1171 componentDict = { 

1172 "Type": "Poly", 

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

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

1175 } 

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

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

1178 

1179 inputDict[component] = componentDict 

1180 

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

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

1183 

1184 return inputYAML, inputDict 

1185 

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

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

1188 

1189 Parameters 

1190 ---------- 

1191 wcsf : `wcsfit.WCSFit` 

1192 WCS-fitting object. 

1193 inputCatalogRefs : `list` 

1194 List of DeferredDatasetHandles pointing to visit-level source 

1195 tables. 

1196 sourceIndices : `list` 

1197 List of boolean arrays used to select sources. 

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

1199 Struct containing properties for each extension. 

1200 columns : `list` of `str` 

1201 List of columns needed from source tables. 

1202 """ 

1203 for inputCatalogRef in inputCatalogRefs: 

1204 visit = inputCatalogRef.dataId["visit"] 

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

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

1207 

1208 for detector in detectors: 

1209 detectorSources = inputCatalog[inputCatalog["detector"] == detector] 

1210 

1211 extensionIndex = self._find_extension_index(extensionInfo, visit, detector) 

1212 if extensionIndex is None: 

1213 # This extension does not have information necessary for 

1214 # fit. Skip the detections from this detector for this 

1215 # visit. 

1216 continue 

1217 

1218 sourceCat = detectorSources[sourceIndices[extensionIndex]] 

1219 

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

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

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

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

1224 

1225 d = { 

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

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

1228 "xCov": xCov.to_numpy(), 

1229 "yCov": yCov.to_numpy(), 

1230 "xyCov": xyCov.to_numpy(), 

1231 } 

1232 

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

1234 

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

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

1237 

1238 Parameters 

1239 ---------- 

1240 wcsf : `wcsfit.WCSFit` 

1241 WCS-fitting object. 

1242 refObjects : `dict` 

1243 Position and error information of reference objects. 

1244 refCovariance : `list` of `float` 

1245 Flattened output covariance matrix. 

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

1247 Struct containing properties for each extension. 

1248 """ 

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

1250 

1251 if self.config.fitProperMotion: 

1252 wcsf.setObjects( 

1253 extensionIndex, 

1254 refObjects, 

1255 "ra", 

1256 "dec", 

1257 ["raCov", "decCov", "raDecCov"], 

1258 pmDecKey="decPM", 

1259 pmRaKey="raPM", 

1260 parallaxKey="parallax", 

1261 pmCovKey="fullCov", 

1262 pmCov=refCovariance, 

1263 ) 

1264 else: 

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

1266 

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

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

1269 

1270 Parameters 

1271 ---------- 

1272 mapDict : `dict` 

1273 Dictionary of mapping parameters. 

1274 centerRA : `lsst.geom.Angle` 

1275 RA of the tangent point. 

1276 centerDec : `lsst.geom.Angle` 

1277 Declination of the tangent point. 

1278 doNormalizePixels : `bool` 

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

1280 xScale : `float` 

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

1282 detector. 

1283 yScale : `float` 

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

1285 detector. 

1286 

1287 Returns 

1288 ------- 

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

1290 WCS constructed from the input mappings 

1291 """ 

1292 # Set up pixel frames 

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

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

1295 

1296 if doNormalizePixels: 

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

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

1299 normMap = _convert_to_ast_polymap_coefficients(normCoefficients) 

1300 else: 

1301 normMap = astshim.UnitMap(2) 

1302 

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

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

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

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

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

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

1309 

1310 frameDict = astshim.FrameDict(pixelFrame) 

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

1312 

1313 currentFrameName = "NORMEDPIXELS" 

1314 

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

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

1317 mapType = mapElement["Type"] 

1318 

1319 if mapType == "Poly": 

1320 mapCoefficients = mapElement["Coefficients"] 

1321 astMap = _convert_to_ast_polymap_coefficients(mapCoefficients) 

1322 elif mapType == "Identity": 

1323 astMap = astshim.UnitMap(2) 

1324 else: 

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

1326 

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

1328 newFrameName = "IWC" 

1329 else: 

1330 newFrameName = "INTERMEDIATE" + str(m) 

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

1332 frameDict.addFrame(currentFrameName, astMap, newFrame) 

1333 currentFrameName = newFrameName 

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

1335 

1336 outWCS = afwgeom.SkyWcs(frameDict) 

1337 return outWCS 

1338 

1339 def _make_outputs(self, wcsf, visitSummaryTables, exposureInfo, mapTemplate=None): 

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

1341 

1342 Parameters 

1343 ---------- 

1344 wcsf : `wcsfit.WCSFit` 

1345 WCSFit object, assumed to have fit model. 

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

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

1348 detector information. 

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

1350 Struct containing properties for each extension. 

1351 

1352 Returns 

1353 ------- 

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

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

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

1357 """ 

1358 # Get the parameters of the fit models 

1359 mapParams = wcsf.mapCollection.getParamDict() 

1360 

1361 # Set up the schema for the output catalogs 

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

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

1364 

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

1366 sampleDetector = visitSummaryTables[0][0] 

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

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

1369 

1370 catalogs = {} 

1371 for v, visitSummary in enumerate(visitSummaryTables): 

1372 visit = visitSummary[0]["visit"] 

1373 

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

1375 visitMapType = wcsf.mapCollection.getMapType(visitMap) 

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

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

1378 continue 

1379 

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

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

1382 catalog["visit"] = visit 

1383 

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

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

1386 if mapName in wcsf.mapCollection.allMapNames(): 

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

1388 else: 

1389 # This extension was not fit, but the WCS can be recovered 

1390 # using the maps fit from sources on other visits but the 

1391 # same detector and from sources on other detectors from 

1392 # this visit. 

1393 genericElements = mapTemplate["EXPOSURE/DEVICE/base"]["Elements"] 

1394 mapElements = [] 

1395 instrument = visitSummary[0].getVisitInfo().instrumentLabel 

1396 # Go through the generic map components to build the names 

1397 # of the specific maps for this extension. 

1398 for component in genericElements: 

1399 elements = mapTemplate[component]["Elements"] 

1400 for element in elements: 

1401 # TODO: DM-42519, gbdes sets the "BAND" to the 

1402 # instrument name currently. This will need to be 

1403 # disambiguated if we run on multiple bands at 

1404 # once. 

1405 element = element.replace("BAND", str(instrument)) 

1406 element = element.replace("EXPOSURE", str(visit)) 

1407 element = element.replace("DEVICE", str(detector)) 

1408 mapElements.append(element) 

1409 mapDict = {} 

1410 for m, mapElement in enumerate(mapElements): 

1411 mapType = wcsf.mapCollection.getMapType(mapElement) 

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

1413 

1414 if mapType == "Poly": 

1415 mapCoefficients = mapParams[mapElement] 

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

1417 

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

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

1420 outWCS = self._make_afw_wcs( 

1421 mapDict, 

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

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

1424 doNormalizePixels=True, 

1425 xScale=xscale, 

1426 yScale=yscale, 

1427 ) 

1428 

1429 catalog[d].setId(detector) 

1430 catalog[d].setWcs(outWCS) 

1431 catalog.sort() 

1432 catalogs[visit] = catalog 

1433 

1434 return catalogs 

1435 

1436 def _compute_model_params(self, wcsf): 

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

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

1439 

1440 Parameters 

1441 ---------- 

1442 wcsf : `wcsfit.WCSFit` 

1443 WCSFit object, assumed to have fit model. 

1444 

1445 Returns 

1446 ------- 

1447 modelParams : `dict` 

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

1449 """ 

1450 modelParamDict = wcsf.mapCollection.getParamDict() 

1451 modelCovariance = wcsf.getModelCovariance() 

1452 

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

1454 i = 0 

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

1456 nCoeffs = len(params) 

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

1458 nCoordCoeffs = nCoeffs // 2 

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

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

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

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

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

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

1465 

1466 for p in range(nCoeffs): 

1467 if p < nCoordCoeffs: 

1468 coord = "x" 

1469 else: 

1470 coord = "y" 

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

1472 i += 1 

1473 

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

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

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

1477 

1478 return modelParams