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

1006 statements  

« prev     ^ index     » next       coverage.py v7.11.0, created at 2025-11-20 02:33 -0800

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 dataclasses 

23import re 

24 

25import astropy.coordinates 

26import astropy.time 

27import astropy.units as u 

28import astshim 

29import numpy as np 

30import wcsfit 

31import yaml 

32from sklearn.cluster import AgglomerativeClustering 

33from smatch.matcher import Matcher 

34 

35import lsst.afw.geom as afwgeom 

36import lsst.afw.table 

37import lsst.geom 

38import lsst.pex.config as pexConfig 

39import lsst.pipe.base as pipeBase 

40import lsst.sphgeom 

41from lsst.meas.algorithms import ( 

42 LoadReferenceObjectsConfig, 

43 ReferenceObjectLoader, 

44 ReferenceSourceSelectorTask, 

45) 

46from lsst.meas.algorithms.sourceSelector import sourceSelectorRegistry 

47 

48from .build_camera import BuildCameraFromAstrometryTask 

49 

50__all__ = [ 

51 "calculate_apparent_motion", 

52 "GbdesAstrometricFitConnections", 

53 "GbdesAstrometricFitConfig", 

54 "GbdesAstrometricFitTask", 

55 "GbdesAstrometricMultibandFitConnections", 

56 "GbdesAstrometricMultibandFitTask", 

57 "GbdesGlobalAstrometricFitConnections", 

58 "GbdesGlobalAstrometricFitConfig", 

59 "GbdesGlobalAstrometricFitTask", 

60 "GbdesGlobalAstrometricMultibandFitConnections", 

61 "GbdesGlobalAstrometricMultibandFitTask", 

62] 

63 

64 

65def calculate_apparent_motion(catalog, refEpoch): 

66 """Calculate shift from reference epoch to the apparent observed position 

67 at another date. 

68 

69 This function calculates the shift due to proper motion combined with the 

70 apparent motion due to parallax. This is not used in the 

71 `GbdesAstrometricFitTask` or related child tasks, but is useful for 

72 assessing results. 

73 

74 Parameters 

75 ---------- 

76 catalog : `astropy.table.Table` 

77 Table containing position, proper motion, parallax, and epoch for each 

78 source, labeled by columns 'ra', 'dec', 'pmRA', 'pmDec', 'parallax', 

79 and 'MJD'. 

80 refEpoch : `astropy.time.Time` 

81 Epoch of the reference position. 

82 

83 Returns 

84 ------- 

85 apparentMotionRA : `np.ndarray` [`astropy.units.Quantity`] 

86 RA shift in degrees. 

87 apparentMotionDec : `np.ndarray` [`astropy.units.Quantity`] 

88 Dec shift in degrees. 

89 """ 

90 ra_rad = catalog["ra"].to(u.rad) 

91 dec_rad = catalog["dec"].to(u.rad) 

92 

93 dt = (catalog["MJD"] - refEpoch).to(u.yr) 

94 properMotionRA = catalog["pmRA"].to(u.deg / u.yr) * dt 

95 properMotionDec = catalog["pmDec"].to(u.deg / u.yr) * dt 

96 

97 sun = astropy.coordinates.get_body("sun", time=catalog["MJD"]) 

98 frame = astropy.coordinates.GeocentricTrueEcliptic(equinox=catalog["MJD"]) 

99 sunLongitudes = sun.transform_to(frame).lon.radian 

100 

101 # These equations for parallax come from Equations 5.2 in Van de Kamp's 

102 # book Stellar Paths. They differ from the parallax calculated in gbdes by 

103 # ~0.01 mas, which is acceptable for QA and plotting purposes. 

104 parallaxFactorRA = np.cos(wcsfit.EclipticInclination) * np.cos(ra_rad) * np.sin(sunLongitudes) - np.sin( 

105 ra_rad 

106 ) * np.cos(sunLongitudes) 

107 parallaxFactorDec = ( 

108 np.sin(wcsfit.EclipticInclination) * np.cos(dec_rad) 

109 - np.cos(wcsfit.EclipticInclination) * np.sin(ra_rad) * np.sin(dec_rad) 

110 ) * np.sin(sunLongitudes) - np.cos(ra_rad) * np.sin(dec_rad) * np.cos(sunLongitudes) 

111 parallaxDegrees = catalog["parallax"].to(u.degree) 

112 parallaxCorrectionRA = parallaxDegrees * parallaxFactorRA 

113 parallaxCorrectionDec = parallaxDegrees * parallaxFactorDec 

114 

115 apparentMotionRA = properMotionRA + parallaxCorrectionRA 

116 apparentMotionDec = properMotionDec + parallaxCorrectionDec 

117 

118 return apparentMotionRA, apparentMotionDec 

119 

120 

121def _make_ref_covariance_matrix( 

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

123): 

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

125 motion and parallax. 

126 

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

128 `gbdes`. 

129 

130 Parameters 

131 ---------- 

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

133 Catalog including proper motion and parallax measurements. 

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

135 Units of the input catalog 

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

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

138 expects milliarcseconds. 

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

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

141 `gbdes` expects milliarcseconds. 

142 version : `int` 

143 Version of the reference catalog. Version 2 includes covariance 

144 measurements. 

145 Returns 

146 ------- 

147 cov : `list` [`float`] 

148 Flattened output covariance matrix. 

149 """ 

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

151 if version == 1: 

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

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

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

155 # the string in Gaia column names for this 

156 # the ordering in the Gaia catalog 

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

158 raErr = (refCat["coord_raErr"]).to(outputCoordUnit).to_value() 

159 decErr = (refCat["coord_decErr"]).to(outputCoordUnit).to_value() 

160 raPMErr = (refCat["pm_raErr"]).to(outputPMUnit).to_value() 

161 decPMErr = (refCat["pm_decErr"]).to(outputPMUnit).to_value() 

162 parallaxErr = (refCat["parallaxErr"]).to(outputPMUnit).to_value() 

163 stdOrder = ( 

164 (raErr, "ra", 0), 

165 (decErr, "dec", 1), 

166 (raPMErr, "pmra", 3), 

167 (decPMErr, "pmdec", 4), 

168 (parallaxErr, "parallax", 2), 

169 ) 

170 

171 k = 0 

172 for i, pr1 in enumerate(stdOrder): 

173 for j, pr2 in enumerate(stdOrder): 

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

175 cov[:, k] = 0 

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

177 cov[:, k] = 0 

178 else: 

179 # diagnonal element 

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

181 k = k + 1 

182 

183 elif version == 2: 

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

185 units = [outputCoordUnit, outputCoordUnit, outputPMUnit, outputPMUnit, outputPMUnit] 

186 k = 0 

187 for i, pi in enumerate(positionParameters): 

188 for j, pj in enumerate(positionParameters): 

189 if i == j: 

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

191 elif i > j: 

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

193 else: 

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

195 k += 1 

196 return cov 

197 

198 

199def _nCoeffsFromDegree(degree): 

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

201 two variables. 

202 

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

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

205 case n is fixed to 2. 

206 

207 Parameters 

208 ---------- 

209 degree : `int` 

210 Degree of the polynomial in question. 

211 

212 Returns 

213 ------- 

214 nCoeffs : `int` 

215 Number of coefficients for the polynomial in question. 

216 """ 

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

218 return nCoeffs 

219 

220 

221def _degreeFromNCoeffs(nCoeffs): 

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

223 of coefficients. 

224 

225 This is done by applying the quadratic formula to the 

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

227 

228 Parameters 

229 ---------- 

230 nCoeffs : `int` 

231 Number of coefficients for the polynomial in question. 

232 

233 Returns 

234 ------- 

235 degree : `int` 

236 Degree of the polynomial in question. 

237 """ 

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

239 return degree 

240 

241 

242def _convert_to_ast_polymap_coefficients(coefficients): 

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

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

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

246 coordinates. 

247 

248 Parameters 

249 ---------- 

250 coefficients : `list` 

251 Coefficients of the polynomials. 

252 degree : `int` 

253 Degree of the polynomial. 

254 

255 Returns 

256 ------- 

257 astPoly : `astshim.PolyMap` 

258 Coefficients in AST polynomial format. 

259 """ 

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

261 N = len(coefficients) / 2 

262 degree = _degreeFromNCoeffs(N) 

263 

264 for outVar in [1, 2]: 

265 for i in range(degree + 1): 

266 for j in range(degree + 1): 

267 if (i + j) > degree: 

268 continue 

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

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

271 polyArray[vectorIndex, 1] = outVar 

272 polyArray[vectorIndex, 2] = i 

273 polyArray[vectorIndex, 3] = j 

274 

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

276 return astPoly 

277 

278 

279def _get_instruments(inputVisitSummaries): 

280 """Make `wcsfit.Instrument` objects for all of the instruments and filters 

281 used for the input visits. This also returns the indices to match the 

282 visits to the instrument/filter used. 

283 

284 Parameters 

285 ---------- 

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

287 List of catalogs with per-detector summary information. 

288 

289 Returns 

290 ------- 

291 instruments : `list` [`wcsfit.Instrument`] 

292 List of instrument objects. 

293 instrumentIndices : `list` [`int`] 

294 Indices matching each visit to the instrument/filter used. 

295 """ 

296 instruments = [] 

297 filters = [] 

298 instrumentIndices = [] 

299 for visitSummary in inputVisitSummaries: 

300 filter = visitSummary[0]["physical_filter"] 

301 instrumentName = visitSummary[0].getVisitInfo().instrumentLabel 

302 if filter not in filters: 

303 filters.append(filter) 

304 filter_instrument = wcsfit.Instrument(instrumentName) 

305 filter_instrument.band = filter 

306 instruments.append(filter_instrument) 

307 instrumentIndices.append(filters.index(filter)) 

308 return instruments, instrumentIndices 

309 

310 

311class CholeskyError(pipeBase.AlgorithmError): 

312 """Raised if the Cholesky decomposition in the model fit fails.""" 

313 

314 def __init__(self) -> None: 

315 super().__init__( 

316 "Cholesky decomposition failed, likely because data is not sufficient to constrain the model." 

317 ) 

318 

319 @property 

320 def metadata(self) -> dict: 

321 """There is no metadata associated with this error.""" 

322 return {} 

323 

324 

325class GbdesAstrometricFitConnections( 

326 pipeBase.PipelineTaskConnections, 

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

328 defaultTemplates={ 

329 "outputName": "gbdesAstrometricFit", 

330 }, 

331): 

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

333 

334 inputCatalogRefs = pipeBase.connectionTypes.Input( 

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

336 name="preSourceTable_visit", 

337 storageClass="DataFrame", 

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

339 deferLoad=True, 

340 multiple=True, 

341 ) 

342 inputVisitSummaries = pipeBase.connectionTypes.Input( 

343 doc=( 

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

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

346 "fast lookups of a detector." 

347 ), 

348 name="visitSummary", 

349 storageClass="ExposureCatalog", 

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

351 multiple=True, 

352 ) 

353 referenceCatalog = pipeBase.connectionTypes.PrerequisiteInput( 

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

355 name="gaia_dr3_20230707", 

356 storageClass="SimpleCatalog", 

357 dimensions=("skypix",), 

358 deferLoad=True, 

359 multiple=True, 

360 ) 

361 colorCatalog = pipeBase.connectionTypes.Input( 

362 doc="The catalog of magnitudes to match to input sources.", 

363 name="fgcm_Cycle4_StandardStars", 

364 storageClass="SimpleCatalog", 

365 dimensions=("instrument",), 

366 ) 

367 inputCameraModel = pipeBase.connectionTypes.PrerequisiteInput( 

368 doc="Camera parameters to use for 'device' part of model", 

369 name="gbdesAstrometricFit_cameraModel", 

370 storageClass="ArrowNumpyDict", 

371 dimensions=("instrument", "physical_filter"), 

372 ) 

373 inputCamera = pipeBase.connectionTypes.PrerequisiteInput( 

374 doc="Input camera to use when constructing camera from astrometric model.", 

375 name="camera", 

376 storageClass="Camera", 

377 dimensions=("instrument",), 

378 isCalibration=True, 

379 ) 

380 outputWcs = pipeBase.connectionTypes.Output( 

381 doc=( 

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

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

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

385 ), 

386 name="{outputName}SkyWcsCatalog", 

387 storageClass="ExposureCatalog", 

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

389 multiple=True, 

390 ) 

391 outputCatalog = pipeBase.connectionTypes.Output( 

392 doc=( 

393 "Catalog of sources used in fit, along with residuals in pixel coordinates and tangent " 

394 "plane coordinates and chisq values." 

395 ), 

396 name="{outputName}_fitStars", 

397 storageClass="ArrowNumpyDict", 

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

399 ) 

400 starCatalog = pipeBase.connectionTypes.Output( 

401 doc=( 

402 "Catalog of best-fit object positions. Also includes the fit proper motion and parallax if " 

403 "fitProperMotion is True." 

404 ), 

405 name="{outputName}_starCatalog", 

406 storageClass="ArrowNumpyDict", 

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

408 ) 

409 modelParams = pipeBase.connectionTypes.Output( 

410 doc="WCS parameters and covariance.", 

411 name="{outputName}_modelParams", 

412 storageClass="ArrowNumpyDict", 

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

414 ) 

415 outputCameraModel = pipeBase.connectionTypes.Output( 

416 doc="Camera parameters to use for 'device' part of model", 

417 name="{outputName}_cameraModel", 

418 storageClass="ArrowNumpyDict", 

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

420 ) 

421 camera = pipeBase.connectionTypes.Output( 

422 doc="Camera object constructed using the per-detector part of the astrometric model", 

423 name="{outputName}Camera", 

424 storageClass="Camera", 

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

426 ) 

427 dcrCoefficients = pipeBase.connectionTypes.Output( 

428 doc="Per-visit coefficients for DCR correction.", 

429 name="{outputName}_dcrCoefficients", 

430 storageClass="ArrowNumpyDict", 

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

432 ) 

433 

434 def getSpatialBoundsConnections(self): 

435 return ("inputVisitSummaries",) 

436 

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

438 super().__init__(config=config) 

439 

440 if self.config.healpix is not None: 

441 self.dimensions.remove("tract") 

442 self.dimensions.remove("skymap") 

443 healpixName = f"healpix{self.config.healpix}" 

444 self.dimensions.add(healpixName) 

445 self.outputWcs = dataclasses.replace( 

446 self.outputWcs, dimensions=("instrument", "visit", healpixName) 

447 ) 

448 self.outputCatalog = dataclasses.replace( 

449 self.outputCatalog, dimensions=("instrument", "physical_filter", healpixName) 

450 ) 

451 self.starCatalog = dataclasses.replace( 

452 self.starCatalog, dimensions=("instrument", "physical_filter", healpixName) 

453 ) 

454 self.modelParams = dataclasses.replace( 

455 self.modelParams, dimensions=("instrument", "physical_filter", healpixName) 

456 ) 

457 self.outputCameraModel = dataclasses.replace( 

458 self.outputCameraModel, dimensions=("instrument", "physical_filter", healpixName) 

459 ) 

460 self.camera = dataclasses.replace( 

461 self.camera, dimensions=("instrument", "physical_filter", healpixName) 

462 ) 

463 self.dcrCoefficients = dataclasses.replace( 

464 self.dcrCoefficients, dimensions=("instrument", "physical_filter", healpixName) 

465 ) 

466 

467 if not self.config.useColor: 

468 self.inputs.remove("colorCatalog") 

469 self.outputs.remove("dcrCoefficients") 

470 if not self.config.saveModelParams: 

471 self.outputs.remove("modelParams") 

472 if not self.config.useInputCameraModel: 

473 self.prerequisiteInputs.remove("inputCameraModel") 

474 if not self.config.saveCameraModel: 

475 self.outputs.remove("outputCameraModel") 

476 if not self.config.saveCameraObject: 

477 self.prerequisiteInputs.remove("inputCamera") 

478 self.outputs.remove("camera") 

479 

480 

481class GbdesAstrometricFitConfig( 

482 pipeBase.PipelineTaskConfig, pipelineConnections=GbdesAstrometricFitConnections 

483): 

484 """Configuration for GbdesAstrometricFitTask""" 

485 

486 sourceSelector = sourceSelectorRegistry.makeField( 

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

488 ) 

489 referenceSelector = pexConfig.ConfigurableField( 

490 target=ReferenceSourceSelectorTask, 

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

492 ) 

493 referenceFilter = pexConfig.Field( 

494 dtype=str, 

495 doc="Name of filter to load from reference catalog. This is a required argument, although the values" 

496 "returned are not used.", 

497 default="phot_g_mean", 

498 ) 

499 setRefEpoch = pexConfig.Field( 

500 dtype=float, 

501 doc="Set the reference epoch to a fixed value in MJD (if None, median observation date is used)", 

502 default=None, 

503 optional=True, 

504 ) 

505 applyRefCatProperMotion = pexConfig.Field( 

506 dtype=bool, 

507 doc="Apply proper motion to shift reference catalog to epoch of observations.", 

508 default=True, 

509 ) 

510 matchRadius = pexConfig.Field( 

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

512 ) 

513 minMatches = pexConfig.Field( 

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

515 ) 

516 allowSelfMatches = pexConfig.Field( 

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

518 dtype=bool, 

519 default=False, 

520 ) 

521 sourceFluxType = pexConfig.Field( 

522 dtype=str, 

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

524 default="apFlux_12_0", 

525 ) 

526 systematicError = pexConfig.Field( 

527 dtype=float, 

528 doc=( 

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

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

531 ), 

532 default=0.0034, 

533 ) 

534 referenceSystematicError = pexConfig.Field( 

535 dtype=float, 

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

537 default=0.0, 

538 ) 

539 useColor = pexConfig.Field( 

540 dtype=bool, 

541 doc="Use color information to correct for differential chromatic refraction.", 

542 default=False, 

543 ) 

544 color = pexConfig.ListField( 

545 dtype=str, 

546 doc="The bands to use for calculating color.", 

547 default=["g", "i"], 

548 listCheck=(lambda x: (len(x) == 2) and (len(set(x)) == len(x))), 

549 ) 

550 referenceColor = pexConfig.Field( 

551 dtype=float, 

552 doc="The color for which DCR is defined as zero.", 

553 default=0.61, 

554 ) 

555 modelComponents = pexConfig.ListField( 

556 dtype=str, 

557 doc=( 

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

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

560 ), 

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

562 ) 

563 deviceModel = pexConfig.ListField( 

564 dtype=str, 

565 doc=( 

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

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

568 ), 

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

570 ) 

571 exposureModel = pexConfig.ListField( 

572 dtype=str, 

573 doc=( 

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

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

576 ), 

577 default=["EXPOSURE/poly"], 

578 ) 

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

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

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

582 excludeNonPMObjects = pexConfig.Field( 

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

584 ) 

585 fitReserveFraction = pexConfig.Field( 

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

587 ) 

588 fitReserveRandomSeed = pexConfig.Field( 

589 dtype=int, 

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

591 default=1234, 

592 ) 

593 saveModelParams = pexConfig.Field( 

594 dtype=bool, 

595 doc=( 

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

597 "false because this can be very large." 

598 ), 

599 default=False, 

600 ) 

601 useInputCameraModel = pexConfig.Field( 

602 dtype=bool, 

603 doc=( 

604 "Use a preexisting model for the 'device' part of the model. When true, the device part of the" 

605 " model will be held fixed in the fitting process." 

606 ), 

607 default=False, 

608 ) 

609 saveCameraModel = pexConfig.Field( 

610 dtype=bool, 

611 doc="Save the 'device' part of the model to be used as input in future runs.", 

612 default=False, 

613 ) 

614 buildCamera = pexConfig.ConfigurableField( 

615 target=BuildCameraFromAstrometryTask, doc="Subtask to build camera from astrometric model." 

616 ) 

617 saveCameraObject = pexConfig.Field( 

618 dtype=bool, 

619 doc="Build and output an lsst.afw.cameraGeom.Camera object using the fit per-detector model.", 

620 default=False, 

621 ) 

622 clipThresh = pexConfig.Field( 

623 dtype=float, 

624 doc="Threshold for clipping outliers in the fit (in standard deviations)", 

625 default=5.0, 

626 ) 

627 clipFraction = pexConfig.Field( 

628 dtype=float, 

629 doc="Minimum fraction of clipped sources that triggers a new fit iteration.", 

630 default=0.0, 

631 ) 

632 healpix = pexConfig.Field( 

633 dtype=int, 

634 doc="Run using all visits overlapping a healpix pixel with this order instead of a tract. Order 3 " 

635 "corresponds to pixels with angular size of 7.329 degrees.", 

636 optional=True, 

637 default=None, 

638 ) 

639 minDetectorFraction = pexConfig.Field( 

640 dtype=float, 

641 doc=( 

642 "Minimum fraction of detectors with valid WCSs per visit required to include the visit in the " 

643 "fit." 

644 ), 

645 default=0.25, 

646 ) 

647 

648 def setDefaults(self): 

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

650 # depend on seeing. 

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

652 self.sourceSelector["science"].unresolved.name = "sizeExtendedness" 

653 

654 # Use only isolated sources. 

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

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

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

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

659 # chosen from the usual QA flags for stars. 

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

661 badFlags = [ 

662 "pixelFlags_edge", 

663 "pixelFlags_saturated", 

664 "pixelFlags_interpolatedCenter", 

665 "pixelFlags_interpolated", 

666 "pixelFlags_crCenter", 

667 "pixelFlags_bad", 

668 "hsmPsfMoments_flag", 

669 f"{self.sourceFluxType}_flag", 

670 ] 

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

672 

673 # Use only primary sources. 

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

675 

676 self.sourceSelector["science"].doSignalToNoise = True 

677 self.sourceSelector["science"].signalToNoise.minimum = 8.0 

678 self.sourceSelector["science"].signalToNoise.maximum = 1000.0 

679 self.sourceSelector["science"].signalToNoise.fluxField = self.sourceFluxType + "_instFlux" 

680 self.sourceSelector["science"].signalToNoise.errField = self.sourceFluxType + "_instFluxErr" 

681 

682 def validate(self): 

683 super().validate() 

684 

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

686 # supported. 

687 for component in self.deviceModel: 

688 mapping = component.split("/")[-1] 

689 if mapping not in ["poly", "identity"]: 

690 raise pexConfig.FieldValidationError( 

691 GbdesAstrometricFitConfig.deviceModel, 

692 self, 

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

694 ) 

695 

696 for component in self.exposureModel: 

697 mapping = component.split("/")[-1] 

698 if mapping not in ["poly", "identity", "dcr"]: 

699 raise pexConfig.FieldValidationError( 

700 GbdesAstrometricFitConfig.exposureModel, 

701 self, 

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

703 ) 

704 

705 if self.saveCameraModel and self.useInputCameraModel: 

706 raise pexConfig.FieldValidationError( 

707 GbdesAstrometricFitConfig.saveCameraModel, 

708 self, 

709 "saveCameraModel and useInputCameraModel cannot both be true.", 

710 ) 

711 

712 if self.saveCameraObject and (self.exposurePolyOrder != 1): 

713 raise pexConfig.FieldValidationError( 

714 GbdesAstrometricFitConfig.saveCameraObject, 

715 self, 

716 "If saveCameraObject is True, exposurePolyOrder must be set to 1.", 

717 ) 

718 

719 

720class GbdesAstrometricFitTask(pipeBase.PipelineTask): 

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

722 GBDES package. 

723 """ 

724 

725 ConfigClass = GbdesAstrometricFitConfig 

726 _DefaultName = "gbdesAstrometricFit" 

727 

728 def __init__(self, **kwargs): 

729 super().__init__(**kwargs) 

730 self.makeSubtask("sourceSelector") 

731 self.makeSubtask("referenceSelector") 

732 if self.config.saveCameraObject: 

733 self.makeSubtask("buildCamera") 

734 

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

736 # We override runQuantum to set up the refObjLoaders 

737 inputs = butlerQC.get(inputRefs) 

738 

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

740 

741 # Ensure the inputs are in a consistent and deterministic order 

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

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

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

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

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

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

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

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

750 

751 refConfig = LoadReferenceObjectsConfig() 

752 if self.config.applyRefCatProperMotion: 

753 refConfig.requireProperMotion = True 

754 refObjectLoader = ReferenceObjectLoader( 

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

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

757 config=refConfig, 

758 log=self.log, 

759 ) 

760 

761 nCores = butlerQC.resources.num_cores 

762 self.log.info("Running with nCores = %d", nCores) 

763 

764 if self.config.useColor: 

765 colorCatalog = inputs.pop("colorCatalog") 

766 else: 

767 colorCatalog = None 

768 try: 

769 output = self.run( 

770 **inputs, 

771 instrumentName=instrumentName, 

772 refObjectLoader=refObjectLoader, 

773 colorCatalog=colorCatalog, 

774 nCores=nCores, 

775 ) 

776 except pipeBase.AlgorithmError as e: 

777 error = pipeBase.AnnotatedPartialOutputsError.annotate(e, self, log=self.log) 

778 # No partial outputs for butler to put 

779 raise error from e 

780 

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

782 for visit, outputWcs in output.outputWcss.items(): 

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

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

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

786 if self.config.saveModelParams: 

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

788 if self.config.saveCameraModel: 

789 butlerQC.put(output.cameraModelParams, outputRefs.outputCameraModel) 

790 if self.config.saveCameraObject: 

791 butlerQC.put(output.camera, outputRefs.camera) 

792 if self.config.useColor: 

793 butlerQC.put(output.colorParams, outputRefs.dcrCoefficients) 

794 if output.partialOutputs: 

795 e = RuntimeError("Some visits were dropped because data was insufficient to fit model.") 

796 error = pipeBase.AnnotatedPartialOutputsError.annotate(e, self, log=self.log) 

797 raise error from e 

798 

799 def run( 

800 self, 

801 inputCatalogRefs, 

802 inputVisitSummaries, 

803 instrumentName="", 

804 refEpoch=None, 

805 refObjectLoader=None, 

806 inputCameraModel=None, 

807 colorCatalog=None, 

808 inputCamera=None, 

809 nCores=1, 

810 ): 

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

812 

813 Parameters 

814 ---------- 

815 inputCatalogRefs : `list` [`DeferredDatasetHandle`] 

816 List of handles pointing to visit-level source 

817 tables. 

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

819 List of catalogs with per-detector summary information. 

820 instrumentName : `str`, optional 

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

822 refEpoch : `float` 

823 Epoch of the reference objects in MJD. 

824 refObjectLoader : instance of 

825 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader` 

826 Reference object loader instance. 

827 inputCameraModel : `dict` [`str`, `np.ndarray`], optional 

828 Parameters to use for the device part of the model. 

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

830 Catalog containing object coordinates and magnitudes. 

831 inputCamera : `lsst.afw.cameraGeom.Camera`, optional 

832 Camera to be used as template when constructing new camera. 

833 nCores : `int`, optional 

834 Number of cores to use during WCS fit. 

835 

836 Returns 

837 ------- 

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

839 ``outputWcss`` : `list` [`lsst.afw.table.ExposureCatalog`] 

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

841 detector set by the new fitted WCS. 

842 ``fitModel`` : `wcsfit.WCSFit` 

843 Model-fitting object with final model parameters. 

844 ``outputCatalog`` : `pyarrow.Table` 

845 Catalog with fit residuals of all sources used. 

846 ``starCatalog`` : `pyarrow.Table` 

847 Catalog with best-fit positions of the objects fit. 

848 ``modelParams`` : `dict` 

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

850 ``cameraModelParams`` : `dict` [`str`, `np.ndarray`] 

851 Parameters of the device part of the model, in the format 

852 needed as input for future runs. 

853 ``colorParams`` : `dict` [`int`, `np.ndarray`] 

854 DCR parameters fit in RA and Dec directions for each visit. 

855 ``camera`` : `lsst.afw.cameraGeom.Camera` 

856 Camera object constructed from the per-detector model. 

857 """ 

858 self.log.info("Gather instrument, exposure, and field info") 

859 

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

861 exposureInfo, exposuresHelper, extensionInfo, instruments = self._get_exposure_info( 

862 inputVisitSummaries 

863 ) 

864 

865 # Get information about the extent of the input visits 

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

867 self.log.info("Field center set at %s with radius %s degrees", fieldCenter, fieldRadius.asDegrees()) 

868 

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

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

871 # friends-of-friends algorithm 

872 associations = wcsfit.FoFClass( 

873 fields, 

874 instruments, 

875 exposuresHelper, 

876 [fieldRadius.asDegrees()], 

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

878 ) 

879 

880 # Add the reference catalog to the associator 

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

882 refObjects, refCovariance = self._load_refcat( 

883 refObjectLoader, 

884 extensionInfo, 

885 epoch=medianEpoch, 

886 center=fieldCenter, 

887 radius=fieldRadius, 

888 associations=associations, 

889 ) 

890 

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

892 sourceIndices, usedColumns = self._load_catalogs_and_associate( 

893 associations, inputCatalogRefs, extensionInfo 

894 ) 

895 self._check_degeneracies(associations, extensionInfo) 

896 

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

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

899 # visit 

900 inputYaml, mapTemplate = self.make_yaml( 

901 inputVisitSummaries[0], 

902 inputCameraModel=(inputCameraModel if self.config.useInputCameraModel else None), 

903 ) 

904 

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

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

907 # properly propagated. 

908 loglevel = self.log.getEffectiveLevel() 

909 if loglevel >= self.log.WARNING: 

910 verbose = 0 

911 elif loglevel == self.log.INFO: 

912 verbose = 1 

913 else: 

914 verbose = 2 

915 

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

917 if self.config.useInputCameraModel: 

918 fixMaps = ",".join(inputCameraModel.keys()) 

919 else: 

920 fixMaps = "" 

921 wcsf = wcsfit.WCSFit( 

922 fields, 

923 instruments, 

924 exposuresHelper, 

925 extensionInfo.visitIndex, 

926 extensionInfo.detectorIndex, 

927 inputYaml, 

928 extensionInfo.wcs, 

929 associations.sequence, 

930 associations.extn, 

931 associations.obj, 

932 sysErr=self.config.systematicError, 

933 refSysErr=self.config.referenceSystematicError, 

934 usePM=self.config.fitProperMotion, 

935 verbose=verbose, 

936 fixMaps=fixMaps, 

937 num_threads=nCores, 

938 ) 

939 

940 # Add the science and reference sources 

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

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

943 if self.config.useColor: 

944 self._add_color_objects(wcsf, colorCatalog) 

945 

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

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

948 # the number of free parameters divided by the fraction of non-reserved 

949 # sources, so that visits with fewer sources are dropped. 

950 nCoeffVisitModel = _nCoeffsFromDegree(self.config.exposurePolyOrder) 

951 minFitExposures = int(np.ceil(nCoeffVisitModel / (1 - self.config.fitReserveFraction))) 

952 # Do the WCS fit 

953 try: 

954 wcsf.fit( 

955 reserveFraction=self.config.fitReserveFraction, 

956 randomNumberSeed=self.config.fitReserveRandomSeed, 

957 minFitExposures=minFitExposures, 

958 clipThresh=self.config.clipThresh, 

959 clipFraction=self.config.clipFraction, 

960 ) 

961 except RuntimeError as e: 

962 if "Cholesky decomposition failed" in str(e): 

963 raise CholeskyError() from e 

964 else: 

965 raise 

966 

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

968 

969 outputWcss, cameraParams, colorParams, camera, partialOutputs = self._make_outputs( 

970 wcsf, 

971 inputVisitSummaries, 

972 exposureInfo, 

973 mapTemplate, 

974 extensionInfo, 

975 inputCameraModel=(inputCameraModel if self.config.useInputCameraModel else None), 

976 inputCamera=(inputCamera if self.config.buildCamera else None), 

977 ) 

978 outputCatalog = wcsf.getOutputCatalog() 

979 outputCatalog["exposureName"] = np.array(outputCatalog["exposureName"]) 

980 outputCatalog["deviceName"] = np.array(outputCatalog["deviceName"]) 

981 

982 starCatalog = wcsf.getStarCatalog() 

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

984 

985 return pipeBase.Struct( 

986 outputWcss=outputWcss, 

987 fitModel=wcsf, 

988 outputCatalog=outputCatalog, 

989 starCatalog=starCatalog, 

990 modelParams=modelParams, 

991 cameraModelParams=cameraParams, 

992 colorParams=colorParams, 

993 camera=camera, 

994 partialOutputs=partialOutputs, 

995 ) 

996 

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

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

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

1000 

1001 Paramaters 

1002 ---------- 

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

1004 List of catalogs with per-detector summary information. 

1005 epoch : float 

1006 Reference epoch. 

1007 fieldName : str 

1008 Name of the field, used internally. 

1009 

1010 Returns 

1011 ------- 

1012 fields : `wcsfit.Fields` 

1013 Object with field information. 

1014 center : `lsst.geom.SpherePoint` 

1015 Center of the field. 

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

1017 Radius of the bounding circle of the tract. 

1018 """ 

1019 allDetectorCorners = [] 

1020 for visSum in inputVisitSummaries: 

1021 detectorCorners = [ 

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

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

1024 if (np.isfinite(ra) and (np.isfinite(dec))) 

1025 ] 

1026 allDetectorCorners.extend(detectorCorners) 

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

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

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

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

1031 radius = boundingCircle.getOpeningAngle() 

1032 

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

1034 # observations will be fit together in one field. 

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

1036 

1037 return fields, center, radius 

1038 

1039 def _get_exposure_info( 

1040 self, 

1041 inputVisitSummaries, 

1042 fieldNumber=0, 

1043 refEpoch=None, 

1044 fieldRegions=None, 

1045 ): 

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

1047 fitting routines. 

1048 

1049 Parameters 

1050 ---------- 

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

1052 Tables for each visit with information for detectors. 

1053 fieldNumber : `int`, optional 

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

1055 being fit together. This is ignored if `fieldRegions` is not None. 

1056 refEpoch : `float`, optional 

1057 Epoch of the reference objects in MJD. 

1058 fieldRegions : `dict` [`int`, `lsst.sphgeom.ConvexPolygon`], optional 

1059 Dictionary of regions encompassing each group of input visits 

1060 keyed by an arbitrary index. 

1061 

1062 Returns 

1063 ------- 

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

1065 Struct containing general properties for the visits: 

1066 ``visits`` : `list` 

1067 List of visit names. 

1068 ``detectors`` : `list` 

1069 List of all detectors in any visit. 

1070 ``ras`` : `list` [`float`] 

1071 List of boresight RAs for each visit. 

1072 ``decs`` : `list` [`float`] 

1073 List of borseight Decs for each visit. 

1074 ``medianEpoch`` : float 

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

1076 exposuresHelper : `wcsfit.ExposuresHelper` 

1077 Object containing information about the input visits. 

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

1079 Struct containing properties for each extension (visit/detector): 

1080 ``visit`` : `np.ndarray` 

1081 Name of the visit for this extension. 

1082 ``detector`` : `np.ndarray` 

1083 Name of the detector for this extension. 

1084 ``visitIndex` : `np.ndarray` [`int`] 

1085 Index of visit for this extension. 

1086 ``detectorIndex`` : `np.ndarray` [`int`] 

1087 Index of the detector for this extension. 

1088 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

1089 Initial WCS for this extension. 

1090 ``extensionType`` : `np.ndarray` [`str`] 

1091 "SCIENCE" or "REFERENCE". 

1092 instruments : `list` [`wcsfit.Instrument`] 

1093 List of instrument objects used for the input visits. 

1094 """ 

1095 exposureNames = [] 

1096 ras = [] 

1097 decs = [] 

1098 visits = [] 

1099 detectors = [] 

1100 airmasses = [] 

1101 exposureTimes = [] 

1102 mjds = [] 

1103 observatories = [] 

1104 wcss = [] 

1105 fieldNumbers = [] 

1106 

1107 extensionType = [] 

1108 extensionVisitIndices = [] 

1109 extensionDetectorIndices = [] 

1110 extensionVisits = [] 

1111 extensionDetectors = [] 

1112 

1113 instruments, instrumentNumbers = _get_instruments(inputVisitSummaries) 

1114 

1115 # Get information for all the science visits 

1116 for v, visitSummary in enumerate(inputVisitSummaries): 

1117 visitInfo = visitSummary[0].getVisitInfo() 

1118 visit = visitSummary[0]["visit"] 

1119 visits.append(visit) 

1120 exposureNames.append(str(visit)) 

1121 raDec = visitInfo.getBoresightRaDec() 

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

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

1124 if fieldRegions is not None: 

1125 inField = [r for r, region in fieldRegions.items() if region.contains(raDec.getVector())] 

1126 if len(inField) != 1: 

1127 raise RuntimeError( 

1128 f"Visit should be in one and only one field, but {visit} is contained " 

1129 f"in {len(inField)} fields." 

1130 ) 

1131 fieldNumbers.append(inField[0]) 

1132 else: 

1133 fieldNumbers.append(fieldNumber) 

1134 airmasses.append(visitInfo.getBoresightAirmass()) 

1135 exposureTimes.append(visitInfo.getExposureTime()) 

1136 obsDate = visitInfo.getDate() 

1137 obsMJD = obsDate.get(obsDate.MJD) 

1138 mjds.append(obsMJD) 

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

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

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

1142 obsElev = visitInfo.observatory.getElevation() 

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

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

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

1146 # We want the position in AU in Cartesian coordinates 

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

1148 

1149 validDetectors = [row for row in visitSummary if row.wcs is not None] 

1150 nDetectorFraction = len(validDetectors) / len(visitSummary) 

1151 if nDetectorFraction < self.config.minDetectorFraction: 

1152 self.log.warning( 

1153 "Visit %d has only %d detectors with valid WCSs (%s of total) and will be dropped.", 

1154 visit, 

1155 len(validDetectors), 

1156 nDetectorFraction, 

1157 ) 

1158 continue 

1159 

1160 for row in visitSummary: 

1161 detector = row["id"] 

1162 

1163 wcs = row.getWcs() 

1164 if wcs is None: 

1165 self.log.warning( 

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

1167 "dropped.", 

1168 visit, 

1169 detector, 

1170 ) 

1171 continue 

1172 else: 

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

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

1175 tangentPoint = wcsfit.Gnomonic(wcsRA, wcsDec) 

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

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

1178 wcss.append(gbdes_wcs) 

1179 

1180 if detector not in detectors: 

1181 detectors.append(detector) 

1182 if not instruments[instrumentNumbers[v]].hasDevice(str(detector)): 

1183 detectorBounds = wcsfit.Bounds( 

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

1185 ) 

1186 instruments[instrumentNumbers[v]].addDevice(str(detector), detectorBounds) 

1187 

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

1189 extensionVisitIndices.append(v) 

1190 extensionDetectorIndices.append(detectorIndex) 

1191 extensionVisits.append(visit) 

1192 extensionDetectors.append(detector) 

1193 extensionType.append("SCIENCE") 

1194 

1195 if len(wcss) == 0: 

1196 raise pipeBase.NoWorkFound("No input extensions have valid WCSs.") 

1197 

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

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

1200 if self.config.setRefEpoch is None: 

1201 medianMJD = np.median(mjds) 

1202 self.log.info(f"Ref epoch set to median: {medianMJD}") 

1203 else: 

1204 medianMJD = self.config.setRefEpoch 

1205 self.log.info(f"Ref epoch set by user: {medianMJD}") 

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

1207 

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

1209 # not used. There needs to be a separate catalog for each field. 

1210 if fieldRegions is None: 

1211 fieldRegions = {0: None} 

1212 for f in fieldRegions: 

1213 exposureNames.append("REFERENCE") 

1214 # Make the "visit" number the field * -1 to disambiguate it from 

1215 # any potential visit number: 

1216 visits.append(-1 * f) 

1217 fieldNumbers.append(f) 

1218 if self.config.fitProperMotion: 

1219 instrumentNumbers.append(-2) 

1220 else: 

1221 instrumentNumbers.append(-1) 

1222 ras.append(0.0) 

1223 decs.append(0.0) 

1224 airmasses.append(0.0) 

1225 exposureTimes.append(0) 

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

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

1228 identity = wcsfit.IdentityMap() 

1229 icrs = wcsfit.SphericalICRS() 

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

1231 wcss.append(refWcs) 

1232 

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

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

1235 extensionVisits.append(-1 * f) 

1236 extensionDetectors.append(-1) 

1237 extensionType.append("REFERENCE") 

1238 

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

1240 extensionInfo = pipeBase.Struct( 

1241 visit=np.array(extensionVisits), 

1242 detector=np.array(extensionDetectors), 

1243 visitIndex=np.array(extensionVisitIndices), 

1244 detectorIndex=np.array(extensionDetectorIndices), 

1245 wcs=np.array(wcss), 

1246 extensionType=np.array(extensionType), 

1247 ) 

1248 

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

1250 exposuresHelper = wcsfit.ExposuresHelper( 

1251 exposureNames, 

1252 fieldNumbers, 

1253 instrumentNumbers, 

1254 ras, 

1255 decs, 

1256 airmasses, 

1257 exposureTimes, 

1258 mjds, 

1259 observatories, 

1260 ) 

1261 

1262 exposureInfo = pipeBase.Struct( 

1263 visits=visits, detectors=detectors, ras=ras, decs=decs, medianEpoch=medianEpoch 

1264 ) 

1265 

1266 return exposureInfo, exposuresHelper, extensionInfo, instruments 

1267 

1268 def _load_refcat( 

1269 self, 

1270 refObjectLoader, 

1271 extensionInfo, 

1272 epoch=None, 

1273 fieldIndex=0, 

1274 associations=None, 

1275 center=None, 

1276 radius=None, 

1277 region=None, 

1278 ): 

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

1280 `wcsfit.FoFClass` object. 

1281 

1282 Parameters 

1283 ---------- 

1284 refObjectLoader : 

1285 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader` 

1286 Object set up to load reference catalog objects. 

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

1288 Struct containing properties for each extension (visit/detector). 

1289 ``visit`` : `np.ndarray` 

1290 Name of the visit for this extension. 

1291 ``detector`` : `np.ndarray` 

1292 Name of the detector for this extension. 

1293 ``visitIndex` : `np.ndarray` [`int`] 

1294 Index of visit for this extension. 

1295 ``detectorIndex`` : `np.ndarray` [`int`] 

1296 Index of the detector for this extension. 

1297 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

1298 Initial WCS for this extension. 

1299 ``extensionType`` : `np.ndarray` [`str`] 

1300 "SCIENCE" or "REFERENCE". 

1301 epoch : `float`, optional 

1302 MJD to which to correct the object positions. 

1303 fieldIndex : `int`, optional 

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

1305 associations : `wcsfit.FoFClass`, optional 

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

1307 center : `lsst.geom.SpherePoint`, optional 

1308 Center of the circle in which to load reference objects. Ignored if 

1309 `region` is set. If used, `radius` must also be set. 

1310 radius : `lsst.sphgeom._sphgeom.Angle`, optional 

1311 Radius of the circle in which to load reference objects. Ignored if 

1312 `region` is set. If used, `center` must also be set. 

1313 region : `lsst.sphgeom.ConvexPolygon`, optional 

1314 Region in which to load reference objects. 

1315 

1316 Returns 

1317 ------- 

1318 refObjects : `dict` 

1319 Position and error information of reference objects. 

1320 refCovariance : `list` [`float`] 

1321 Flattened output covariance matrix. 

1322 """ 

1323 if self.config.applyRefCatProperMotion: 

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

1325 else: 

1326 formattedEpoch = None 

1327 

1328 if region is not None: 

1329 skyRegion = refObjectLoader.loadRegion(region, self.config.referenceFilter, epoch=formattedEpoch) 

1330 elif (center is not None) and (radius is not None): 

1331 skyRegion = refObjectLoader.loadSkyCircle( 

1332 center, radius, self.config.referenceFilter, epoch=formattedEpoch 

1333 ) 

1334 else: 

1335 raise RuntimeError("Either `region` or `center` and `radius` must be set.") 

1336 

1337 selected = self.referenceSelector.run(skyRegion.refCat) 

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

1339 if not selected.sourceCat.isContiguous(): 

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

1341 else: 

1342 refCat = selected.sourceCat 

1343 refCat = refCat.asAstropy() 

1344 

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

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

1347 refCat = refCat[finiteInd] 

1348 

1349 if self.config.excludeNonPMObjects and self.config.applyRefCatProperMotion: 

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

1351 hasPM = ( 

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

1353 ) 

1354 refCat = refCat[hasPM] 

1355 

1356 ra = (refCat["coord_ra"]).to(u.degree).to_value().tolist() 

1357 dec = (refCat["coord_dec"]).to(u.degree).to_value().tolist() 

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

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

1360 

1361 # Get refcat version from refcat metadata 

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

1363 

1364 # DM-47181: Added this to work for LSSTComCam with 

1365 # the_monster_20240904 catalog that has not this key. 

1366 if "REFCAT_FORMAT_VERSION" not in refCatMetadata: 

1367 refCatVersion = 2 

1368 raDecCov = (refCat["coord_ra_coord_dec_Cov"]).to(u.degree**2).to_value().tolist() 

1369 else: 

1370 refCatVersion = refCatMetadata["REFCAT_FORMAT_VERSION"] 

1371 if refCatVersion == 2: 

1372 raDecCov = (refCat["coord_ra_coord_dec_Cov"]).to(u.degree**2).to_value().tolist() 

1373 else: 

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

1375 

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

1377 refCovariance = [] 

1378 

1379 if self.config.fitProperMotion: 

1380 raPM = (refCat["pm_ra"]).to(u.marcsec).to_value().tolist() 

1381 decPM = (refCat["pm_dec"]).to(u.marcsec).to_value().tolist() 

1382 parallax = (refCat["parallax"]).to(u.marcsec).to_value().tolist() 

1383 cov = _make_ref_covariance_matrix(refCat, version=refCatVersion) 

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

1385 refObjects.update(pmDict) 

1386 refCovariance = cov 

1387 

1388 if associations is not None: 

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

1390 visitIndex = extensionInfo.visitIndex[extensionIndex] 

1391 detectorIndex = extensionInfo.detectorIndex[extensionIndex] 

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

1393 refWcs = extensionInfo.wcs[extensionIndex] 

1394 

1395 associations.addCatalog( 

1396 refWcs, 

1397 "STELLAR", 

1398 visitIndex, 

1399 fieldIndex, 

1400 instrumentIndex, 

1401 detectorIndex, 

1402 extensionIndex, 

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

1404 ra, 

1405 dec, 

1406 np.arange(len(ra)), 

1407 ) 

1408 

1409 return refObjects, refCovariance 

1410 

1411 @staticmethod 

1412 def _find_extension_index(extensionInfo, visit, detector): 

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

1414 number. 

1415 

1416 If no match is found, None is returned. 

1417 

1418 Parameters 

1419 ---------- 

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

1421 Struct containing properties for each extension. 

1422 visit : `int` 

1423 Visit number 

1424 detector : `int` 

1425 Detector number 

1426 

1427 Returns 

1428 ------- 

1429 extensionIndex : `int` or None 

1430 Index of this extension 

1431 """ 

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

1433 if len(findExtension) == 0: 

1434 extensionIndex = None 

1435 else: 

1436 extensionIndex = findExtension[0] 

1437 return extensionIndex 

1438 

1439 def _load_catalogs_and_associate( 

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

1441 ): 

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

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

1444 

1445 Parameters 

1446 ---------- 

1447 associations : `wcsfit.FoFClass` 

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

1449 the source association. 

1450 inputCatalogRefs : `list` 

1451 List of DeferredDatasetHandles pointing to visit-level source 

1452 tables. 

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

1454 Struct containing properties for each extension (visit/detector). 

1455 ``visit`` : `np.ndarray` 

1456 Name of the visit for this extension. 

1457 ``detector`` : `np.ndarray` 

1458 Name of the detector for this extension. 

1459 ``visitIndex` : `np.ndarray` [`int`] 

1460 Index of visit for this extension. 

1461 ``detectorIndex`` : `np.ndarray` [`int`] 

1462 Index of the detector for this extension. 

1463 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

1464 Initial WCS for this extension. 

1465 ``extensionType`` : `np.ndarray` [`str`] 

1466 "SCIENCE" or "REFERENCE". 

1467 fieldIndex : `int` 

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

1469 data is being fit together. 

1470 instrumentIndex : `int` 

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

1472 assuming all data comes from the same instrument. 

1473 

1474 Returns 

1475 ------- 

1476 sourceIndices : `list` 

1477 List of boolean arrays used to select sources. 

1478 columns : `list` [`str`] 

1479 List of columns needed from source tables. 

1480 """ 

1481 columns = [ 

1482 "detector", 

1483 "sourceId", 

1484 "x", 

1485 "xErr", 

1486 "y", 

1487 "yErr", 

1488 "ixx", 

1489 "iyy", 

1490 "ixy", 

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

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

1493 ] 

1494 if self.sourceSelector.config.doFlags: 

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

1496 if self.sourceSelector.config.doUnresolved: 

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

1498 if self.sourceSelector.config.doIsolated: 

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

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

1501 if self.sourceSelector.config.doRequirePrimary: 

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

1503 

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

1505 for inputCatalogRef in inputCatalogRefs: 

1506 visit = inputCatalogRef.dataId["visit"] 

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

1508 # Get a sorted array of detector names 

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

1510 

1511 for detector in detectors: 

1512 detectorSources = inputCatalog[inputCatalog["detector"] == detector] 

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

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

1515 xyCov = ( 

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

1517 ) 

1518 # Remove sources with bad shape measurements 

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

1520 selected = self.sourceSelector.run(detectorSources) 

1521 goodInds = selected.selected & goodShapes 

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

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

1524 if extensionIndex is None: 

1525 # This extension does not have information necessary for 

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

1527 # visit. 

1528 continue 

1529 detectorIndex = extensionInfo.detectorIndex[extensionIndex] 

1530 visitIndex = extensionInfo.visitIndex[extensionIndex] 

1531 

1532 sourceIndices[extensionIndex] = goodInds 

1533 

1534 wcs = extensionInfo.wcs[extensionIndex] 

1535 associations.reprojectWCS(wcs, fieldIndex) 

1536 

1537 associations.addCatalog( 

1538 wcs, 

1539 "STELLAR", 

1540 visitIndex, 

1541 fieldIndex, 

1542 instrumentIndex, 

1543 detectorIndex, 

1544 extensionIndex, 

1545 isStar, 

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

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

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

1549 ) 

1550 

1551 associations.sortMatches( 

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

1553 ) 

1554 

1555 return sourceIndices, columns 

1556 

1557 def _check_degeneracies(self, associations, extensionInfo): 

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

1559 constrain the model are present. 

1560 

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

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

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

1564 positive-definite and the model cannot be fit. 

1565 

1566 Parameters 

1567 ---------- 

1568 associations : `wcsfit.FoFClass` 

1569 Object holding the source association information. 

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

1571 Struct containing properties for each extension (visit/detector): 

1572 ``visit`` : `np.ndarray` 

1573 Name of the visit for this extension. 

1574 ``detector`` : `np.ndarray` 

1575 Name of the detector for this extension. 

1576 ``visitIndex` : `np.ndarray` [`int`] 

1577 Index of visit for this extension. 

1578 ``detectorIndex`` : `np.ndarray` [`int`] 

1579 Index of the detector for this extension. 

1580 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

1581 Initial WCS for this extension. 

1582 ``extensionType`` : `np.ndarray` [`str`] 

1583 "SCIENCE" or "REFERENCE". 

1584 """ 

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

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

1587 whichExtension = np.array(associations.extn) 

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

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

1590 

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

1592 ex_ind = whichExtension == extension 

1593 whichDetector[ex_ind] = detector 

1594 whichVisit[ex_ind] = visit 

1595 

1596 if (not self.config.useInputCameraModel) and ("BAND/DEVICE/poly" in self.config.deviceModel): 

1597 nCoeffDetectorModel = _nCoeffsFromDegree(self.config.devicePolyOrder) 

1598 unconstrainedDetectors = [] 

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

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

1601 if numSources < nCoeffDetectorModel: 

1602 unconstrainedDetectors.append(str(detector)) 

1603 

1604 if unconstrainedDetectors: 

1605 raise pipeBase.NoWorkFound( 

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

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

1608 ", ".join(unconstrainedDetectors), 

1609 ) 

1610 

1611 def make_yaml(self, inputVisitSummary, inputFile=None, inputCameraModel=None): 

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

1613 model. 

1614 

1615 Parameters 

1616 ---------- 

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

1618 Catalog with per-detector summary information. 

1619 inputFile : `str` 

1620 Path to a file that contains a basic model. 

1621 inputCameraModel : `dict` [`str`, `np.ndarray`], optional 

1622 Parameters to use for the device part of the model. 

1623 

1624 Returns 

1625 ------- 

1626 inputYaml : `wcsfit.YAMLCollector` 

1627 YAML object containing the model description. 

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

1629 Dictionary containing the model description. 

1630 """ 

1631 if inputFile is not None: 

1632 inputYaml = wcsfit.YAMLCollector(inputFile, "PixelMapCollection") 

1633 else: 

1634 inputYaml = wcsfit.YAMLCollector("", "PixelMapCollection") 

1635 inputDict = {} 

1636 modelComponents = ["BAND/DEVICE", "EXPOSURE"] 

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

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

1639 

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

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

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

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

1644 

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

1646 inputDict["BAND/DEVICE"] = deviceModel 

1647 for component in self.config.deviceModel: 

1648 mapping = component.split("/")[-1] 

1649 if mapping == "poly": 

1650 componentDict = { 

1651 "Type": "Poly", 

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

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

1654 "XMin": xMin, 

1655 "XMax": xMax, 

1656 "YMin": yMin, 

1657 "YMax": yMax, 

1658 } 

1659 elif mapping == "identity": 

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

1661 

1662 inputDict[component] = componentDict 

1663 

1664 if (inputCameraModel is not None) and self.config.useInputCameraModel: 

1665 # This assumes that the input camera model is a 'poly' model 

1666 nCoeffs = _nCoeffsFromDegree(self.config.devicePolyOrder) 

1667 for key, coeffs in inputCameraModel.items(): 

1668 if len(coeffs) != nCoeffs * 2: 

1669 raise RuntimeError( 

1670 "Input camera model polynomial order does not match the devicePolyOrder" 

1671 ) 

1672 mapDict = { 

1673 "Type": "Poly", 

1674 "XPoly": { 

1675 "OrderX": self.config.devicePolyOrder, 

1676 "SumOrder": True, 

1677 "Coefficients": coeffs[:nCoeffs].tolist(), 

1678 }, 

1679 "YPoly": { 

1680 "OrderX": self.config.devicePolyOrder, 

1681 "SumOrder": True, 

1682 "Coefficients": coeffs[nCoeffs:].tolist(), 

1683 }, 

1684 "XMin": xMin, 

1685 "XMax": xMax, 

1686 "YMin": yMin, 

1687 "YMax": yMax, 

1688 } 

1689 inputDict[key] = mapDict 

1690 

1691 exposureModelComponents = self.config.exposureModel.list() 

1692 if self.config.useColor: 

1693 exposureModelComponents.append("EXPOSURE/dcr") 

1694 exposureModel = {"Type": "Composite", "Elements": exposureModelComponents} 

1695 inputDict["EXPOSURE"] = exposureModel 

1696 for component in exposureModelComponents: 

1697 mapping = component.split("/")[-1] 

1698 if mapping == "poly": 

1699 componentDict = { 

1700 "Type": "Poly", 

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

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

1703 } 

1704 elif mapping == "identity": 

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

1706 elif mapping == "dcr": 

1707 componentDict = { 

1708 "Type": "Color", 

1709 "Reference": self.config.referenceColor, 

1710 "Function": {"Type": "Constant"}, 

1711 } 

1712 

1713 inputDict[component] = componentDict 

1714 

1715 inputYaml.addInput(yaml.dump(inputDict)) 

1716 inputYaml.addInput("Identity:\n Type: Identity\n") 

1717 

1718 return inputYaml, inputDict 

1719 

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

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

1722 

1723 Parameters 

1724 ---------- 

1725 wcsf : `wcsfit.WCSFit` 

1726 WCS-fitting object. 

1727 inputCatalogRefs : `list` 

1728 List of DeferredDatasetHandles pointing to visit-level source 

1729 tables. 

1730 sourceIndices : `list` 

1731 List of boolean arrays used to select sources. 

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

1733 Struct containing properties for each extension (visit/detector): 

1734 ``visit`` : `np.ndarray` 

1735 Name of the visit for this extension. 

1736 ``detector`` : `np.ndarray` 

1737 Name of the detector for this extension. 

1738 ``visitIndex` : `np.ndarray` [`int`] 

1739 Index of visit for this extension. 

1740 ``detectorIndex`` : `np.ndarray` [`int`] 

1741 Index of the detector for this extension. 

1742 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

1743 Initial WCS for this extension. 

1744 ``extensionType`` : `np.ndarray` [`str`] 

1745 "SCIENCE" or "REFERENCE". 

1746 columns : `list` [`str`] 

1747 List of columns needed from source tables. 

1748 """ 

1749 for inputCatalogRef in inputCatalogRefs: 

1750 visit = inputCatalogRef.dataId["visit"] 

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

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

1753 

1754 for detector in detectors: 

1755 detectorSources = inputCatalog[inputCatalog["detector"] == detector] 

1756 

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

1758 if extensionIndex is None: 

1759 # This extension does not have information necessary for 

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

1761 # visit. 

1762 continue 

1763 

1764 sourceCat = detectorSources[sourceIndices[extensionIndex]] 

1765 

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

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

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

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

1770 

1771 d = { 

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

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

1774 "xCov": xCov.to_numpy(), 

1775 "yCov": yCov.to_numpy(), 

1776 "xyCov": xyCov.to_numpy(), 

1777 } 

1778 

1779 wcsf.setObjects( 

1780 extensionIndex, 

1781 d, 

1782 "x", 

1783 "y", 

1784 ["xCov", "yCov", "xyCov"], 

1785 defaultColor=self.config.referenceColor, 

1786 ) 

1787 

1788 def _add_ref_objects(self, wcsf, refObjects, refCovariance, extensionInfo, fieldIndex=0): 

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

1790 

1791 Parameters 

1792 ---------- 

1793 wcsf : `wcsfit.WCSFit` 

1794 WCS-fitting object. 

1795 refObjects : `dict` 

1796 Position and error information of reference objects. 

1797 refCovariance : `list` [`float`] 

1798 Flattened output covariance matrix. 

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

1800 Struct containing properties for each extension (visit/detector): 

1801 ``visit`` : `np.ndarray` 

1802 Name of the visit for this extension. 

1803 ``detector`` : `np.ndarray` 

1804 Name of the detector for this extension. 

1805 ``visitIndex` : `np.ndarray` [`int`] 

1806 Index of visit for this extension. 

1807 ``detectorIndex`` : `np.ndarray` [`int`] 

1808 Index of the detector for this extension. 

1809 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

1810 Initial WCS for this extension. 

1811 ``extensionType`` : `np.ndarray` [`str`] 

1812 "SCIENCE" or "REFERENCE". 

1813 fieldIndex : `int`, optional 

1814 Index of the field to which these sources correspond. 

1815 """ 

1816 extensionIndex = np.flatnonzero( 

1817 (extensionInfo.extensionType == "REFERENCE") & (extensionInfo.visit == fieldIndex) 

1818 )[0] 

1819 if self.config.fitProperMotion: 

1820 wcsf.setObjects( 

1821 extensionIndex, 

1822 refObjects, 

1823 "ra", 

1824 "dec", 

1825 ["raCov", "decCov", "raDecCov"], 

1826 pmDecKey="decPM", 

1827 pmRaKey="raPM", 

1828 parallaxKey="parallax", 

1829 pmCovKey="fullCov", 

1830 pmCov=refCovariance, 

1831 ) 

1832 else: 

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

1834 

1835 def _add_color_objects(self, wcsf, colorCatalog): 

1836 """Associate input matches with objects in color catalog and set their 

1837 color value. 

1838 

1839 Parameters 

1840 ---------- 

1841 wcsf : `wcsfit.WCSFit` 

1842 WCSFit object, assumed to have fit model. 

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

1844 Catalog containing object coordinates and magnitudes. 

1845 """ 

1846 

1847 # Get current best position for matches 

1848 starCat = wcsf.getStarCatalog() 

1849 

1850 # TODO: DM-45650, update how the colors are read in here. 

1851 catalogBands = colorCatalog.metadata.getArray("BANDS") 

1852 colorInd1 = catalogBands.index(self.config.color[0]) 

1853 colorInd2 = catalogBands.index(self.config.color[1]) 

1854 colors = colorCatalog["mag_std_noabs"][:, colorInd1] - colorCatalog["mag_std_noabs"][:, colorInd2] 

1855 goodInd = (colorCatalog["mag_std_noabs"][:, colorInd1] != 99.0) & ( 

1856 colorCatalog["mag_std_noabs"][:, colorInd2] != 99.0 

1857 ) 

1858 

1859 with Matcher(np.array(starCat["starX"]), np.array(starCat["starY"])) as matcher: 

1860 idx, idx_starCat, idx_colorCat, d = matcher.query_radius( 

1861 (colorCatalog[goodInd]["coord_ra"] * u.radian).to(u.degree).value, 

1862 (colorCatalog[goodInd]["coord_dec"] * u.radian).to(u.degree).value, 

1863 self.config.matchRadius / 3600.0, 

1864 return_indices=True, 

1865 ) 

1866 

1867 matchesWithColor = starCat["starMatchID"][idx_starCat] 

1868 matchColors = np.ones(len(matchesWithColor)) * self.config.referenceColor 

1869 matchColors = colors[goodInd][idx_colorCat] 

1870 wcsf.setColors(matchesWithColor, matchColors) 

1871 

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

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

1874 

1875 Parameters 

1876 ---------- 

1877 mapDict : `dict` 

1878 Dictionary of mapping parameters. 

1879 centerRA : `lsst.geom.Angle` 

1880 RA of the tangent point. 

1881 centerDec : `lsst.geom.Angle` 

1882 Declination of the tangent point. 

1883 doNormalizePixels : `bool` 

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

1885 xScale : `float` 

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

1887 detector. 

1888 yScale : `float` 

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

1890 detector. 

1891 

1892 Returns 

1893 ------- 

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

1895 WCS constructed from the input mappings 

1896 """ 

1897 # Set up pixel frames 

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

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

1900 

1901 if doNormalizePixels: 

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

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

1904 normMap = _convert_to_ast_polymap_coefficients(normCoefficients) 

1905 else: 

1906 normMap = astshim.UnitMap(2) 

1907 

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

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

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

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

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

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

1914 

1915 frameDict = astshim.FrameDict(pixelFrame) 

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

1917 

1918 currentFrameName = "NORMEDPIXELS" 

1919 

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

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

1922 mapType = mapElement["Type"] 

1923 

1924 if mapType == "Poly": 

1925 mapCoefficients = mapElement["Coefficients"] 

1926 astMap = _convert_to_ast_polymap_coefficients(mapCoefficients) 

1927 elif mapType == "Identity": 

1928 astMap = astshim.UnitMap(2) 

1929 else: 

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

1931 

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

1933 newFrameName = "IWC" 

1934 else: 

1935 newFrameName = "INTERMEDIATE" + str(m) 

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

1937 frameDict.addFrame(currentFrameName, astMap, newFrame) 

1938 currentFrameName = newFrameName 

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

1940 

1941 outWCS = afwgeom.SkyWcs(frameDict) 

1942 return outWCS 

1943 

1944 def _make_outputs( 

1945 self, 

1946 wcsf, 

1947 visitSummaryTables, 

1948 exposureInfo, 

1949 mapTemplate, 

1950 extensionInfo, 

1951 inputCameraModel=None, 

1952 inputCamera=None, 

1953 ): 

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

1955 

1956 Parameters 

1957 ---------- 

1958 wcsf : `wcsfit.WCSFit` 

1959 WCSFit object, assumed to have fit model. 

1960 visitSummaryTables : `list` [`lsst.afw.table.ExposureCatalog`] 

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

1962 detector information. 

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

1964 Struct containing properties for each extension (visit/detector): 

1965 ``visit`` : `np.ndarray` 

1966 Name of the visit for this extension. 

1967 ``detector`` : `np.ndarray` 

1968 Name of the detector for this extension. 

1969 ``visitIndex` : `np.ndarray` [`int`] 

1970 Index of visit for this extension. 

1971 ``detectorIndex`` : `np.ndarray` [`int`] 

1972 Index of the detector for this extension. 

1973 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

1974 Initial WCS for this extension. 

1975 ``extensionType`` : `np.ndarray` [`str`] 

1976 "SCIENCE" or "REFERENCE". 

1977 mapTemplate : `dict` [`str`, `str`] 

1978 Dictionary containing the model description. 

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

1980 Struct containing properties for each extension (visit/detector). 

1981 inputCameraModel : `dict` [`str`, `np.ndarray`], optional 

1982 Parameters to use for the device part of the model. This must be 

1983 provided if an input camera model was used. 

1984 

1985 Returns 

1986 ------- 

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

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

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

1990 cameraParams : `dict` [`str`, `np.ndarray`], optional 

1991 Parameters for the device part of the model in the format needed 

1992 when used as input for future runs. 

1993 colorFits : `dict` [`int`, `np.ndarray`], optional 

1994 DCR parameters fit in RA and Dec directions for each visit. 

1995 camera : `lsst.afw.cameraGeom.Camera`, optional 

1996 Camera object constructed from the per-detector model. 

1997 """ 

1998 # Get the parameters of the fit models 

1999 mapParams = wcsf.mapCollection.getParamDict() 

2000 cameraParams = {} 

2001 if self.config.saveCameraModel: 

2002 for element in mapTemplate["BAND/DEVICE"]["Elements"]: 

2003 for detector in exposureInfo.detectors: 

2004 detectorTemplate = element.replace("DEVICE", str(detector)) 

2005 detectorTemplate = detectorTemplate.replace("BAND", ".+") 

2006 for k, params in mapParams.items(): 

2007 if re.fullmatch(detectorTemplate, k): 

2008 cameraParams[k] = params 

2009 if self.config.saveCameraObject: 

2010 # Get the average rotation angle of the input visits. 

2011 rotations = [ 

2012 visTable[0].visitInfo.boresightRotAngle.asRadians() for visTable in visitSummaryTables 

2013 ] 

2014 rotationAngle = np.mean(rotations) 

2015 if inputCamera is None: 

2016 raise RuntimeError( 

2017 "inputCamera must be provided to _make_outputs in order to build output camera." 

2018 ) 

2019 camera = self.buildCamera.run( 

2020 mapParams, 

2021 mapTemplate, 

2022 exposureInfo.detectors, 

2023 exposureInfo.visits, 

2024 inputCamera, 

2025 rotationAngle, 

2026 ) 

2027 else: 

2028 camera = None 

2029 if self.config.useInputCameraModel: 

2030 if inputCameraModel is None: 

2031 raise RuntimeError( 

2032 "inputCameraModel must be provided to _make_outputs in order to build output WCS." 

2033 ) 

2034 mapParams.update(inputCameraModel) 

2035 

2036 # Set up the schema for the output catalogs 

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

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

2039 schema.addField( 

2040 "recoveredWcs", 

2041 type="Flag", 

2042 doc="Input WCS missing, output recovered from other input visit/detectors.", 

2043 ) 

2044 

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

2046 xscale = int(mapTemplate["BAND/DEVICE/poly"]["XMax"]) - int(mapTemplate["BAND/DEVICE/poly"]["XMin"]) 

2047 yscale = int(mapTemplate["BAND/DEVICE/poly"]["YMax"]) - int(mapTemplate["BAND/DEVICE/poly"]["YMin"]) 

2048 

2049 catalogs = {} 

2050 colorFits = {} 

2051 partialOutputs = False 

2052 for v, visitSummary in enumerate(visitSummaryTables): 

2053 visit = visitSummary[0]["visit"] 

2054 if visit not in extensionInfo.visit: 

2055 self.log.warning("Visit %d was dropped because no detectors had valid WCSs.", visit) 

2056 partialOutputs = True 

2057 continue 

2058 

2059 visitMaps = wcsf.mapCollection.orderAtoms(f"{visit}") 

2060 if self.config.useColor: 

2061 colorMap = visitMaps.pop(visitMaps.index(f"{visit}/dcr")) 

2062 colorFits[visit] = mapParams[colorMap] 

2063 visitMap = visitMaps[0] 

2064 visitMapType = wcsf.mapCollection.getMapType(visitMap) 

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

2066 self.log.warning("Visit %d was dropped because of an insufficient amount of data.", visit) 

2067 partialOutputs = True 

2068 continue 

2069 

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

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

2072 catalog["visit"] = visit 

2073 

2074 for d, detector in enumerate(exposureInfo.detectors): 

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

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

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

2078 catalog[d]["recoveredWcs"] = False 

2079 else: 

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

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

2082 # same detector and from sources on other detectors from 

2083 # this visit. 

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

2085 mapElements = [] 

2086 band = visitSummary[0]["physical_filter"] 

2087 

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

2089 # of the specific maps for this extension. 

2090 for component in genericElements: 

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

2092 for element in elements: 

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

2094 # instrument name currently. This will need to be 

2095 # disambiguated if we run on multiple bands at 

2096 # once. 

2097 element = element.replace("BAND", str(band)) 

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

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

2100 mapElements.append(element) 

2101 catalog[d]["recoveredWcs"] = True 

2102 mapDict = {} 

2103 for m, mapElement in enumerate(mapElements): 

2104 mapType = wcsf.mapCollection.getMapType(mapElement) 

2105 if mapType == "Color": 

2106 # DCR fit should not go into the generic WCS. 

2107 continue 

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

2109 

2110 if mapType == "Poly": 

2111 mapCoefficients = mapParams[mapElement] 

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

2113 

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

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

2116 outWCS = self._make_afw_wcs( 

2117 mapDict, 

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

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

2120 doNormalizePixels=True, 

2121 xScale=xscale, 

2122 yScale=yscale, 

2123 ) 

2124 

2125 catalog[d].setId(detector) 

2126 catalog[d].setWcs(outWCS) 

2127 catalog.sort() 

2128 catalogs[visit] = catalog 

2129 if self.config.useColor: 

2130 colorVisits = np.array(list(colorFits.keys())) 

2131 colorRA = np.array([colorFits[vis][0] for vis in colorVisits]) 

2132 colorDec = np.array([colorFits[vis][1] for vis in colorVisits]) 

2133 colorFits = {"visit": colorVisits, "raCoefficient": colorRA, "decCoefficient": colorDec} 

2134 

2135 return catalogs, cameraParams, colorFits, camera, partialOutputs 

2136 

2137 def _compute_model_params(self, wcsf): 

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

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

2140 

2141 Parameters 

2142 ---------- 

2143 wcsf : `wcsfit.WCSFit` 

2144 WCSFit object, assumed to have fit model. 

2145 

2146 Returns 

2147 ------- 

2148 modelParams : `dict` 

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

2150 """ 

2151 modelParamDict = wcsf.mapCollection.getParamDict() 

2152 modelCovariance = wcsf.getModelCovariance() 

2153 

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

2155 i = 0 

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

2157 nCoeffs = len(params) 

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

2159 nCoordCoeffs = nCoeffs // 2 

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

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

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

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

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

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

2166 

2167 for p in range(nCoeffs): 

2168 if p < nCoordCoeffs: 

2169 coord = "x" 

2170 else: 

2171 coord = "y" 

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

2173 i += 1 

2174 

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

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

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

2178 

2179 return modelParams 

2180 

2181 

2182class GbdesAstrometricMultibandFitConnections( 

2183 GbdesAstrometricFitConnections, dimensions=("skymap", "tract", "instrument") 

2184): 

2185 outputCatalog = pipeBase.connectionTypes.Output( 

2186 doc=( 

2187 "Catalog of sources used in fit, along with residuals in pixel coordinates and tangent " 

2188 "plane coordinates and chisq values." 

2189 ), 

2190 name="gbdesAstrometricMultibandFit_fitStars", 

2191 storageClass="ArrowNumpyDict", 

2192 dimensions=("instrument", "skymap", "tract"), 

2193 ) 

2194 starCatalog = pipeBase.connectionTypes.Output( 

2195 doc=( 

2196 "Catalog of best-fit object positions. Also includes the fit proper motion and parallax if " 

2197 "fitProperMotion is True." 

2198 ), 

2199 name="gbdesAstrometricMultibandFit_starCatalog", 

2200 storageClass="ArrowNumpyDict", 

2201 dimensions=("instrument", "skymap", "tract"), 

2202 ) 

2203 modelParams = pipeBase.connectionTypes.Output( 

2204 doc="WCS parameters and covariance.", 

2205 name="gbdesAstrometricMultibandFit_modelParams", 

2206 storageClass="ArrowNumpyDict", 

2207 dimensions=("instrument", "skymap", "tract"), 

2208 ) 

2209 

2210 

2211class GbdesAstrometricMultibandFitConfig( 

2212 GbdesAstrometricFitConfig, pipelineConnections=GbdesAstrometricMultibandFitConnections 

2213): 

2214 pass 

2215 

2216 

2217class GbdesAstrometricMultibandFitTask(GbdesAstrometricFitTask): 

2218 """Calibrate the WCS across multiple visits in multiple filters of the same 

2219 field using the GBDES package. 

2220 """ 

2221 

2222 ConfigClass = GbdesAstrometricMultibandFitConfig 

2223 _DefaultName = "gbdesAstrometricMultibandFit" 

2224 

2225 

2226class GbdesGlobalAstrometricFitConnections( 

2227 pipeBase.PipelineTaskConnections, dimensions=("instrument", "physical_filter") 

2228): 

2229 inputVisitSummaries = pipeBase.connectionTypes.Input( 

2230 doc=( 

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

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

2233 "fast lookups of a detector." 

2234 ), 

2235 name="visitSummary", 

2236 storageClass="ExposureCatalog", 

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

2238 multiple=True, 

2239 ) 

2240 referenceCatalog = pipeBase.connectionTypes.PrerequisiteInput( 

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

2242 name="gaia_dr3_20230707", 

2243 storageClass="SimpleCatalog", 

2244 dimensions=("skypix",), 

2245 deferLoad=True, 

2246 multiple=True, 

2247 ) 

2248 colorCatalog = pipeBase.connectionTypes.Input( 

2249 doc="The catalog of magnitudes to match to input sources.", 

2250 name="fgcm_Cycle4_StandardStars", 

2251 storageClass="SimpleCatalog", 

2252 dimensions=("instrument",), 

2253 ) 

2254 isolatedStarSources = pipeBase.connectionTypes.Input( 

2255 doc="Catalog of matched sources.", 

2256 name="isolated_star_presources", 

2257 storageClass="DataFrame", 

2258 dimensions=( 

2259 "instrument", 

2260 "skymap", 

2261 "tract", 

2262 ), 

2263 multiple=True, 

2264 deferLoad=True, 

2265 ) 

2266 isolatedStarCatalogs = pipeBase.connectionTypes.Input( 

2267 doc="Catalog of objects corresponding to the isolatedStarSources.", 

2268 name="isolated_star_presource_associations", 

2269 storageClass="DataFrame", 

2270 dimensions=( 

2271 "instrument", 

2272 "skymap", 

2273 "tract", 

2274 ), 

2275 multiple=True, 

2276 deferLoad=True, 

2277 ) 

2278 inputCameraModel = pipeBase.connectionTypes.PrerequisiteInput( 

2279 doc="Camera parameters to use for 'device' part of model", 

2280 name="gbdesAstrometricFit_cameraModel", 

2281 storageClass="ArrowNumpyDict", 

2282 dimensions=("instrument", "physical_filter"), 

2283 ) 

2284 inputCamera = pipeBase.connectionTypes.PrerequisiteInput( 

2285 doc="Input camera to use when constructing camera from astrometric model.", 

2286 name="camera", 

2287 storageClass="Camera", 

2288 dimensions=("instrument",), 

2289 isCalibration=True, 

2290 ) 

2291 outputWcs = pipeBase.connectionTypes.Output( 

2292 doc=( 

2293 "Per-visit world coordinate systems derived from the fitted model. These catalogs only contain " 

2294 "entries for detectors with an output, and use the detector id for the catalog id, sorted on id " 

2295 "for fast lookups of a detector." 

2296 ), 

2297 name="gbdesGlobalAstrometricFitSkyWcsCatalog", 

2298 storageClass="ExposureCatalog", 

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

2300 multiple=True, 

2301 ) 

2302 outputCatalog = pipeBase.connectionTypes.Output( 

2303 doc=( 

2304 "Catalog of sources used in fit, along with residuals in pixel coordinates and tangent " 

2305 "plane coordinates and chisq values." 

2306 ), 

2307 name="gbdesGlobalAstrometricFit_fitStars", 

2308 storageClass="ArrowNumpyDict", 

2309 dimensions=("instrument", "physical_filter"), 

2310 ) 

2311 starCatalog = pipeBase.connectionTypes.Output( 

2312 doc=( 

2313 "Catalog of best-fit object positions. Also includes the fit proper motion and parallax if " 

2314 "fitProperMotion is True." 

2315 ), 

2316 name="gbdesGlobalAstrometricFit_starCatalog", 

2317 storageClass="ArrowNumpyDict", 

2318 dimensions=("instrument", "physical_filter"), 

2319 ) 

2320 modelParams = pipeBase.connectionTypes.Output( 

2321 doc="WCS parameters and covariance.", 

2322 name="gbdesGlobalAstrometricFit_modelParams", 

2323 storageClass="ArrowNumpyDict", 

2324 dimensions=("instrument", "physical_filter"), 

2325 ) 

2326 outputCameraModel = pipeBase.connectionTypes.Output( 

2327 doc="Camera parameters to use for 'device' part of model", 

2328 name="gbdesAstrometricFit_cameraModel", 

2329 storageClass="ArrowNumpyDict", 

2330 dimensions=("instrument", "physical_filter"), 

2331 ) 

2332 dcrCoefficients = pipeBase.connectionTypes.Output( 

2333 doc="Per-visit coefficients for DCR correction.", 

2334 name="gbdesGlobalAstrometricFit_dcrCoefficients", 

2335 storageClass="ArrowNumpyDict", 

2336 ) 

2337 camera = pipeBase.connectionTypes.Output( 

2338 doc="Camera object constructed using the per-detector part of the astrometric model", 

2339 name="gbdesGlobalAstrometricFitCamera", 

2340 storageClass="Camera", 

2341 dimensions=("instrument", "physical_filter"), 

2342 ) 

2343 

2344 def getSpatialBoundsConnections(self): 

2345 return ("inputVisitSummaries",) 

2346 

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

2348 super().__init__(config=config) 

2349 

2350 if not self.config.useColor: 

2351 self.inputs.remove("colorCatalog") 

2352 self.outputs.remove("dcrCoefficients") 

2353 if not self.config.saveModelParams: 

2354 self.outputs.remove("modelParams") 

2355 if not self.config.useInputCameraModel: 

2356 self.prerequisiteInputs.remove("inputCameraModel") 

2357 if not self.config.saveCameraModel: 

2358 self.outputs.remove("outputCameraModel") 

2359 if not self.config.saveCameraObject: 

2360 self.prerequisiteInputs.remove("inputCamera") 

2361 self.outputs.remove("camera") 

2362 

2363 

2364class GbdesGlobalAstrometricFitConfig( 

2365 GbdesAstrometricFitConfig, pipelineConnections=GbdesGlobalAstrometricFitConnections 

2366): 

2367 visitOverlap = pexConfig.Field( 

2368 dtype=float, 

2369 default=1.0, 

2370 doc=( 

2371 "The linkage distance threshold above which clustered groups of visits will not be merged " 

2372 "together in an agglomerative clustering algorithm. The linkage distance is calculated using the " 

2373 "minimum distance between the field-of-view centers of a given visit and all other visits in a " 

2374 "group, and is in units of the field-of-view radius. The resulting groups of visits define the " 

2375 "fields for the astrometric fit." 

2376 ), 

2377 ) 

2378 

2379 

2380class GbdesGlobalAstrometricFitTask(GbdesAstrometricFitTask): 

2381 """Calibrate the WCS across multiple visits and multiple fields using the 

2382 GBDES package. 

2383 

2384 This class assumes that the input visits can be separated into contiguous 

2385 groups, for which an individual group covers an area of less than a 

2386 hemisphere. 

2387 """ 

2388 

2389 ConfigClass = GbdesGlobalAstrometricFitConfig 

2390 _DefaultName = "gbdesAstrometricFit" 

2391 

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

2393 # We override runQuantum to set up the refObjLoaders 

2394 inputs = butlerQC.get(inputRefs) 

2395 

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

2397 

2398 # Ensure the inputs are in a consistent and deterministic order 

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

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

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

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

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

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

2405 inputIsolatedStarSourceTracts = np.array( 

2406 [isolatedStarSource.dataId["tract"] for isolatedStarSource in inputs["isolatedStarSources"]] 

2407 ) 

2408 inputIsolatedStarCatalogTracts = np.array( 

2409 [isolatedStarCatalog.dataId["tract"] for isolatedStarCatalog in inputs["isolatedStarCatalogs"]] 

2410 ) 

2411 for tract in inputIsolatedStarCatalogTracts: 

2412 if tract not in inputIsolatedStarSourceTracts: 

2413 raise RuntimeError(f"tract {tract} in isolated_star_cats but not isolated_star_sources") 

2414 inputs["isolatedStarSources"] = np.array( 

2415 [inputs["isolatedStarSources"][t] for t in inputIsolatedStarSourceTracts.argsort()] 

2416 ) 

2417 inputs["isolatedStarCatalogs"] = np.array( 

2418 [inputs["isolatedStarCatalogs"][t] for t in inputIsolatedStarSourceTracts.argsort()] 

2419 ) 

2420 

2421 refConfig = LoadReferenceObjectsConfig() 

2422 if self.config.applyRefCatProperMotion: 

2423 refConfig.requireProperMotion = True 

2424 refObjectLoader = ReferenceObjectLoader( 

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

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

2427 config=refConfig, 

2428 log=self.log, 

2429 ) 

2430 if self.config.useColor: 

2431 colorCatalog = inputs.pop("colorCatalog") 

2432 else: 

2433 colorCatalog = None 

2434 

2435 try: 

2436 output = self.run( 

2437 **inputs, 

2438 instrumentName=instrumentName, 

2439 refObjectLoader=refObjectLoader, 

2440 colorCatalog=colorCatalog, 

2441 ) 

2442 except pipeBase.AlgorithmError as e: 

2443 error = pipeBase.AnnotatedPartialOutputsError.annotate(e, self, log=self.log) 

2444 # No partial outputs for butler to put 

2445 raise error from e 

2446 

2447 for outputRef in outputRefs.outputWcs: 

2448 visit = outputRef.dataId["visit"] 

2449 butlerQC.put(output.outputWcss[visit], outputRef) 

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

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

2452 if self.config.saveModelParams: 

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

2454 if self.config.saveCameraModel: 

2455 butlerQC.put(output.cameraModelParams, outputRefs.outputCameraModel) 

2456 if self.config.saveCameraObject: 

2457 butlerQC.put(output.camera, outputRefs.camera) 

2458 if self.config.useColor: 

2459 butlerQC.put(output.colorParams, outputRefs.dcrCoefficients) 

2460 if output.partialOutputs: 

2461 e = RuntimeError("Some visits were dropped because data was insufficient to fit model.") 

2462 error = pipeBase.AnnotatedPartialOutputsError.annotate(e, self, log=self.log) 

2463 raise error from e 

2464 

2465 def run( 

2466 self, 

2467 inputVisitSummaries, 

2468 isolatedStarSources, 

2469 isolatedStarCatalogs, 

2470 instrumentName="", 

2471 refEpoch=None, 

2472 refObjectLoader=None, 

2473 inputCameraModel=None, 

2474 colorCatalog=None, 

2475 inputCamera=None, 

2476 ): 

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

2478 

2479 Parameters 

2480 ---------- 

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

2482 List of catalogs with per-detector summary information. 

2483 isolatedStarSources : `list` [`DeferredDatasetHandle`] 

2484 List of handles pointing to isolated star sources. 

2485 isolatedStarCatalog: `list` [`DeferredDatasetHandle`] 

2486 List of handles pointing to isolated star catalogs. 

2487 instrumentName : `str`, optional 

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

2489 refEpoch : `float`, optional 

2490 Epoch of the reference objects in MJD. 

2491 refObjectLoader : instance of 

2492 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader`, 

2493 optional 

2494 Reference object loader instance. 

2495 inputCameraModel : `dict` [`str`, `np.ndarray`], optional 

2496 Parameters to use for the device part of the model. 

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

2498 Catalog containing object coordinates and magnitudes. 

2499 inputCamera : `lsst.afw.cameraGeom.Camera`, optional 

2500 Camera to be used as template when constructing new camera. 

2501 

2502 Returns 

2503 ------- 

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

2505 ``outputWcss`` : `list` [`lsst.afw.table.ExposureCatalog`] 

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

2507 detector set by the new fitted WCS. 

2508 ``fitModel`` : `wcsfit.WCSFit` 

2509 Model-fitting object with final model parameters. 

2510 ``outputCatalog`` : `pyarrow.Table` 

2511 Catalog with fit residuals of all sources used. 

2512 ``starCatalog`` : `pyarrow.Table` 

2513 Catalog with best-fit positions of the objects fit. 

2514 ``modelParams`` : `dict` 

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

2516 ``cameraModelParams`` : `dict` [`str`, `np.ndarray`] 

2517 Parameters of the device part of the model, in the format 

2518 needed as input for future runs. 

2519 ``colorParams`` : `dict` [`int`, `np.ndarray`] 

2520 DCR parameters fit in RA and Dec directions for each visit. 

2521 ``camera`` : `lsst.afw.cameraGeom.Camera` 

2522 Camera object constructed from the per-detector model. 

2523 """ 

2524 self.log.info("Gather instrument, exposure, and field info") 

2525 

2526 # Get information about the extent of the input visits 

2527 fields, fieldRegions = self._prep_sky(inputVisitSummaries) 

2528 

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

2530 exposureInfo, exposuresHelper, extensionInfo, instruments = self._get_exposure_info( 

2531 inputVisitSummaries, fieldRegions=fieldRegions 

2532 ) 

2533 

2534 self.log.info("Load associated sources") 

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

2536 allRefObjects, allRefCovariances = {}, {} 

2537 for f, fieldRegion in fieldRegions.items(): 

2538 refObjects, refCovariance = self._load_refcat( 

2539 refObjectLoader, extensionInfo, epoch=medianEpoch, region=fieldRegion 

2540 ) 

2541 allRefObjects[f] = refObjects 

2542 allRefCovariances[f] = refCovariance 

2543 

2544 associations, sourceDict = self._associate_from_isolated_sources( 

2545 isolatedStarSources, isolatedStarCatalogs, extensionInfo, allRefObjects 

2546 ) 

2547 

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

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

2550 # visit 

2551 inputYaml, mapTemplate = self.make_yaml( 

2552 inputVisitSummaries[0], 

2553 inputCameraModel=(inputCameraModel if self.config.useInputCameraModel else None), 

2554 ) 

2555 

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

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

2558 # properly propagated. 

2559 loglevel = self.log.getEffectiveLevel() 

2560 if loglevel >= self.log.WARNING: 

2561 verbose = 0 

2562 elif loglevel == self.log.INFO: 

2563 verbose = 1 

2564 else: 

2565 verbose = 2 

2566 

2567 # Set up the WCS-fitting class using the source matches from the 

2568 # isolated star sources plus the reference catalog. 

2569 wcsf = wcsfit.WCSFit( 

2570 fields, 

2571 instruments, 

2572 exposuresHelper, 

2573 extensionInfo.visitIndex, 

2574 extensionInfo.detectorIndex, 

2575 inputYaml, 

2576 extensionInfo.wcs, 

2577 associations.sequence, 

2578 associations.extn, 

2579 associations.obj, 

2580 sysErr=self.config.systematicError, 

2581 refSysErr=self.config.referenceSystematicError, 

2582 usePM=self.config.fitProperMotion, 

2583 verbose=verbose, 

2584 ) 

2585 

2586 # Add the science and reference sources 

2587 self._add_objects(wcsf, sourceDict, extensionInfo) 

2588 for f in fieldRegions.keys(): 

2589 self._add_ref_objects( 

2590 wcsf, allRefObjects[f], allRefCovariances[f], extensionInfo, fieldIndex=-1 * f 

2591 ) 

2592 if self.config.useColor: 

2593 self._add_color_objects(wcsf, colorCatalog) 

2594 

2595 # Do the WCS fit 

2596 try: 

2597 wcsf.fit( 

2598 reserveFraction=self.config.fitReserveFraction, 

2599 randomNumberSeed=self.config.fitReserveRandomSeed, 

2600 clipThresh=self.config.clipThresh, 

2601 clipFraction=self.config.clipFraction, 

2602 ) 

2603 except RuntimeError as e: 

2604 if "Cholesky decomposition failed" in str(e): 

2605 raise CholeskyError() from e 

2606 else: 

2607 raise 

2608 

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

2610 

2611 outputWcss, cameraParams, colorParams, camera, partialOutputs = self._make_outputs( 

2612 wcsf, 

2613 inputVisitSummaries, 

2614 exposureInfo, 

2615 mapTemplate, 

2616 extensionInfo, 

2617 inputCameraModel=(inputCameraModel if self.config.useInputCameraModel else None), 

2618 inputCamera=(inputCamera if self.config.buildCamera else None), 

2619 ) 

2620 outputCatalog = wcsf.getOutputCatalog() 

2621 outputCatalog["exposureName"] = np.array(outputCatalog["exposureName"]) 

2622 outputCatalog["deviceName"] = np.array(outputCatalog["deviceName"]) 

2623 

2624 starCatalog = wcsf.getStarCatalog() 

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

2626 

2627 return pipeBase.Struct( 

2628 outputWcss=outputWcss, 

2629 fitModel=wcsf, 

2630 outputCatalog=outputCatalog, 

2631 starCatalog=starCatalog, 

2632 modelParams=modelParams, 

2633 cameraModelParams=cameraParams, 

2634 colorParams=colorParams, 

2635 camera=camera, 

2636 partialOutputs=partialOutputs, 

2637 ) 

2638 

2639 def _prep_sky(self, inputVisitSummaries): 

2640 """Cluster the input visits into disjoint groups that will define 

2641 separate fields in the astrometric fit, and, for each group, get the 

2642 convex hull around all of its component visits. 

2643 

2644 The groups are created such that each visit overlaps with at least one 

2645 other visit in the same group by the `visitOverlap` amount, which is 

2646 calculated as a fraction of the field-of-view radius, and no visits 

2647 from separate groups overlap by more than this amount. 

2648 

2649 Paramaters 

2650 ---------- 

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

2652 List of catalogs with per-detector summary information. 

2653 

2654 Returns 

2655 ------- 

2656 fields : `wcsfit.Fields` 

2657 Object with field information. 

2658 fieldRegions : `dict` [`int`, `lsst.sphgeom.ConvexPolygon`] 

2659 Dictionary of regions encompassing each group of input visits, 

2660 keyed by an arbitrary index. 

2661 """ 

2662 allDetectorCorners = [] 

2663 mjds = [] 

2664 radecs = [] 

2665 radii = [] 

2666 for visSum in inputVisitSummaries: 

2667 detectorCorners = [ 

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

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

2670 if (np.isfinite(ra) and (np.isfinite(dec))) 

2671 ] 

2672 if len(detectorCorners) == 0: 

2673 # Skip this visit if none of the detectors have finite ra/dec 

2674 # corners, which happens when the WCSs are missing. The visit 

2675 # will get formally dropped elsewhere. 

2676 continue 

2677 allDetectorCorners.append(detectorCorners) 

2678 

2679 # Get center and approximate radius of field of view 

2680 boundingCircle = lsst.sphgeom.ConvexPolygon.convexHull(detectorCorners).getBoundingCircle() 

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

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

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

2684 radecs.append([ra, dec]) 

2685 radius = boundingCircle.getOpeningAngle() 

2686 radii.append(radius) 

2687 

2688 obsDate = visSum[0].getVisitInfo().getDate() 

2689 obsMJD = obsDate.get(obsDate.MJD) 

2690 mjds.append(obsMJD) 

2691 

2692 # Find groups of visits where any one of the visits overlaps another by 

2693 # a given fraction of the field-of-view radius. 

2694 distance = self.config.visitOverlap * np.median(radii) 

2695 clustering = AgglomerativeClustering( 

2696 distance_threshold=distance.asDegrees(), n_clusters=None, linkage="single" 

2697 ) 

2698 clusters = clustering.fit(np.array(radecs)) 

2699 

2700 medianMJD = np.median(mjds) 

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

2702 

2703 fieldNames = [] 

2704 fieldRAs = [] 

2705 fieldDecs = [] 

2706 epochs = [] 

2707 fieldRegions = {} 

2708 

2709 for i in range(clusters.n_clusters_): 

2710 fieldInd = clusters.labels_ == i 

2711 # Concatenate the lists of all detector corners that are in this 

2712 # field 

2713 fieldDetectors = sum([allDetectorCorners[f] for f, fInd in enumerate(fieldInd) if fInd], []) 

2714 hull = lsst.sphgeom.ConvexPolygon.convexHull(fieldDetectors) 

2715 center = lsst.geom.SpherePoint(hull.getCentroid()) 

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

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

2718 

2719 fieldRegions[i] = hull 

2720 fieldNames.append(str(i)) 

2721 fieldRAs.append(ra) 

2722 fieldDecs.append(dec) 

2723 # Use the same median epoch for all fields so that the final object 

2724 # positions are calculated for the same epoch. 

2725 epochs.append(medianEpoch) 

2726 

2727 fields = wcsfit.Fields(fieldNames, fieldRAs, fieldDecs, epochs) 

2728 

2729 return fields, fieldRegions 

2730 

2731 def _associate_from_isolated_sources( 

2732 self, isolatedStarSourceRefs, isolatedStarCatalogRefs, extensionInfo, refObjects 

2733 ): 

2734 """Match the input catalog of isolated stars with the reference catalog 

2735 and transform the combined isolated star sources and reference source 

2736 into the format needed for gbdes. 

2737 

2738 Parameters 

2739 ---------- 

2740 isolatedStarSourceRefs : `list` [`DeferredDatasetHandle`] 

2741 List of handles pointing to isolated star sources. 

2742 isolatedStarCatalogRefs: `list` [`DeferredDatasetHandle`] 

2743 List of handles pointing to isolated star catalogs. 

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

2745 Struct containing properties for each extension (visit/detector). 

2746 ``visit`` : `np.ndarray` 

2747 Name of the visit for this extension. 

2748 ``detector`` : `np.ndarray` 

2749 Name of the detector for this extension. 

2750 ``visitIndex` : `np.ndarray` [`int`] 

2751 Index of visit for this extension. 

2752 ``detectorIndex`` : `np.ndarray` [`int`] 

2753 Index of the detector for this extension. 

2754 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

2755 Initial WCS for this extension. 

2756 ``extensionType`` : `np.ndarray` [`str`] 

2757 "SCIENCE" or "REFERENCE". 

2758 refObjects : `dict` 

2759 Dictionary of dictionaries containing the position and error 

2760 information of reference objects. 

2761 

2762 Returns 

2763 ------- 

2764 associations : `lsst.pipe.base.Struct` 

2765 Struct containing the associations of sources with objects. 

2766 sourceDict : `dict` [`int`, [`int`, [`str`, `list` [`float`]]]] 

2767 Dictionary containing the source centroids for each visit. 

2768 """ 

2769 sequences = [] 

2770 extensions = [] 

2771 object_indices = [] 

2772 

2773 sourceColumns = ["x", "y", "xErr", "yErr", "ixx", "ixy", "iyy", "obj_index", "visit", "detector"] 

2774 catalogColumns = ["ra", "dec"] 

2775 

2776 sourceDict = dict([(visit, {}) for visit in np.unique(extensionInfo.visit)]) 

2777 for visit, detector in zip(extensionInfo.visit, extensionInfo.detector): 

2778 sourceDict[visit][detector] = {"x": [], "y": [], "xCov": [], "yCov": [], "xyCov": []} 

2779 

2780 for isolatedStarCatalogRef, isolatedStarSourceRef in zip( 

2781 isolatedStarCatalogRefs, isolatedStarSourceRefs 

2782 ): 

2783 isolatedStarCatalog = isolatedStarCatalogRef.get(parameters={"columns": catalogColumns}) 

2784 isolatedStarSources = isolatedStarSourceRef.get(parameters={"columns": sourceColumns}) 

2785 if len(isolatedStarCatalog) == 0: 

2786 # This is expected when only one visit overlaps with a given 

2787 # tract, meaning that no sources can be associated. 

2788 self.log.debug( 

2789 "Skipping tract %d, which has no associated isolated stars", 

2790 isolatedStarCatalogRef.dataId["tract"], 

2791 ) 

2792 continue 

2793 

2794 # Match the reference stars to the existing isolated stars, then 

2795 # insert the reference stars into the isolated star sources. 

2796 allVisits = np.copy(isolatedStarSources["visit"]) 

2797 allDetectors = np.copy(isolatedStarSources["detector"]) 

2798 allObjectIndices = np.copy(isolatedStarSources["obj_index"]) 

2799 issIndices = np.copy(isolatedStarSources.index) 

2800 for f, regionRefObjects in refObjects.items(): 

2801 # Use the same matching technique that is done in 

2802 # isolatedStarAssociation and fgcmBuildFromIsolatedStars. 

2803 with Matcher( 

2804 isolatedStarCatalog["ra"].to_numpy(), isolatedStarCatalog["dec"].to_numpy() 

2805 ) as matcher: 

2806 idx, idx_isoStarCat, idx_refObjects, d = matcher.query_radius( 

2807 np.array(regionRefObjects["ra"]), 

2808 np.array(regionRefObjects["dec"]), 

2809 self.config.matchRadius / 3600.0, 

2810 return_indices=True, 

2811 ) 

2812 

2813 refSort = np.searchsorted(isolatedStarSources["obj_index"], idx_isoStarCat) 

2814 refDetector = np.ones(len(idx_isoStarCat)) * -1 

2815 # The "visit" for the reference catalogs is the field times -1. 

2816 refVisit = np.ones(len(idx_isoStarCat)) * f * -1 

2817 

2818 allVisits = np.insert(allVisits, refSort, refVisit) 

2819 allDetectors = np.insert(allDetectors, refSort, refDetector) 

2820 allObjectIndices = np.insert(allObjectIndices, refSort, idx_isoStarCat) 

2821 issIndices = np.insert(issIndices, refSort, idx_refObjects) 

2822 

2823 # Loop through the associated sources to convert them to the gbdes 

2824 # format, which requires the extension index, the source's index in 

2825 # the input table, and a sequence number corresponding to the 

2826 # object with which it is associated. 

2827 sequence = 0 

2828 obj_index = allObjectIndices[0] 

2829 for visit, detector, row, obj_ind in zip(allVisits, allDetectors, issIndices, allObjectIndices): 

2830 extensionIndex = np.flatnonzero( 

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

2832 ) 

2833 if len(extensionIndex) == 0: 

2834 # This happens for runs where you are not using all the 

2835 # visits overlapping a given tract that were included in 

2836 # the isolated star association task." 

2837 continue 

2838 else: 

2839 extensionIndex = extensionIndex[0] 

2840 

2841 extensions.append(extensionIndex) 

2842 if visit <= 0: 

2843 object_indices.append(row) 

2844 else: 

2845 object_indices.append(len(sourceDict[visit][detector]["x"])) 

2846 source = isolatedStarSources.loc[row] 

2847 sourceDict[visit][detector]["x"].append(source["x"]) 

2848 sourceDict[visit][detector]["y"].append(source["y"]) 

2849 xCov = source["xErr"] ** 2 

2850 yCov = source["yErr"] ** 2 

2851 xyCov = source["ixy"] * (xCov + yCov) / (source["ixx"] + source["iyy"]) 

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

2853 sourceDict[visit][detector]["xCov"].append(xCov) 

2854 sourceDict[visit][detector]["yCov"].append(yCov) 

2855 sourceDict[visit][detector]["xyCov"].append(xyCov) 

2856 if obj_ind != obj_index: 

2857 sequence = 0 

2858 sequences.append(sequence) 

2859 obj_index = obj_ind 

2860 sequence += 1 

2861 else: 

2862 sequences.append(sequence) 

2863 sequence += 1 

2864 

2865 associations = pipeBase.Struct(extn=extensions, obj=object_indices, sequence=sequences) 

2866 return associations, sourceDict 

2867 

2868 def _add_objects(self, wcsf, sourceDict, extensionInfo): 

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

2870 

2871 Parameters 

2872 ---------- 

2873 wcsf : `wcsfit.WCSFit` 

2874 WCS-fitting object. 

2875 sourceDict : `dict` 

2876 Dictionary containing the source centroids for each visit. 

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

2878 Struct containing properties for each extension (visit/detector). 

2879 ``visit`` : `np.ndarray` 

2880 Name of the visit for this extension. 

2881 ``detector`` : `np.ndarray` 

2882 Name of the detector for this extension. 

2883 ``visitIndex` : `np.ndarray` [`int`] 

2884 Index of visit for this extension. 

2885 ``detectorIndex`` : `np.ndarray` [`int`] 

2886 Index of the detector for this extension. 

2887 ``wcss`` : `np.ndarray` [`lsst.afw.geom.SkyWcs`] 

2888 Initial WCS for this extension. 

2889 ``extensionType`` : `np.ndarray` [`str`] 

2890 "SCIENCE" or "REFERENCE". 

2891 """ 

2892 for visit, visitSources in sourceDict.items(): 

2893 # Visit numbers equal or below zero connote the reference catalog. 

2894 if visit <= 0: 

2895 # This "visit" number corresponds to a reference catalog. 

2896 continue 

2897 

2898 for detector, sourceCat in visitSources.items(): 

2899 extensionIndex = np.flatnonzero( 

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

2901 )[0] 

2902 

2903 d = { 

2904 "x": np.array(sourceCat["x"]), 

2905 "y": np.array(sourceCat["y"]), 

2906 "xCov": np.array(sourceCat["xCov"]), 

2907 "yCov": np.array(sourceCat["yCov"]), 

2908 "xyCov": np.array(sourceCat["xyCov"]), 

2909 } 

2910 wcsf.setObjects( 

2911 extensionIndex, 

2912 d, 

2913 "x", 

2914 "y", 

2915 ["xCov", "yCov", "xyCov"], 

2916 defaultColor=self.config.referenceColor, 

2917 ) 

2918 

2919 

2920class GbdesGlobalAstrometricMultibandFitConnections( 

2921 GbdesGlobalAstrometricFitConnections, 

2922 dimensions=("instrument",), 

2923): 

2924 outputCatalog = pipeBase.connectionTypes.Output( 

2925 doc=( 

2926 "Catalog of sources used in fit, along with residuals in pixel coordinates and tangent " 

2927 "plane coordinates and chisq values." 

2928 ), 

2929 name="gbdesGlobalAstrometricMultibandFit_fitStars", 

2930 storageClass="ArrowNumpyDict", 

2931 dimensions=("instrument",), 

2932 ) 

2933 starCatalog = pipeBase.connectionTypes.Output( 

2934 doc=( 

2935 "Catalog of best-fit object positions. Also includes the fit proper motion and parallax if " 

2936 "fitProperMotion is True." 

2937 ), 

2938 name="gbdesGlobalAstrometricMultibandFit_starCatalog", 

2939 storageClass="ArrowNumpyDict", 

2940 dimensions=("instrument",), 

2941 ) 

2942 modelParams = pipeBase.connectionTypes.Output( 

2943 doc="WCS parameters and covariance.", 

2944 name="gbdesGlobalAstrometricMultibandFit_modelParams", 

2945 storageClass="ArrowNumpyDict", 

2946 dimensions=("instrument",), 

2947 ) 

2948 

2949 

2950class GbdesGlobalAstrometricMultibandFitConfig( 

2951 GbdesAstrometricFitConfig, 

2952 pipelineConnections=GbdesGlobalAstrometricMultibandFitConnections, 

2953): 

2954 """Configuration for the GbdesGlobalAstrometricMultibandFitTask""" 

2955 

2956 pass 

2957 

2958 

2959class GbdesGlobalAstrometricMultibandFitTask(GbdesGlobalAstrometricFitTask): 

2960 """Calibrate the WCS across multiple visits in multiple filters and 

2961 multiple fields using the GBDES package. 

2962 """ 

2963 

2964 ConfigClass = GbdesGlobalAstrometricMultibandFitConfig 

2965 _DefaultName = "gbdesAstrometricMultibandFit"