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

1027 statements  

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

1# This file is part of drp_tasks. 

2# 

3# LSST Data Management System 

4# This product includes software developed by the 

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

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

7# 

8# This program is free software: you can redistribute it and/or modify 

9# it under the terms of the GNU General Public License as published by 

10# the Free Software Foundation, either version 3 of the License, or 

11# (at your option) any later version. 

12# 

13# This program is distributed in the hope that it will be useful, 

14# but WITHOUT ANY WARRANTY; without even the implied warranty of 

15# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 

16# GNU General Public License for more details. 

17# 

18# You should have received a copy of the LSST License Statement and 

19# the GNU General Public License along with this program. If not, 

20# see <https://www.lsstcorp.org/LegalNotices/>. 

21# 

22import 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 astropy.table import vstack 

33from sklearn.cluster import AgglomerativeClustering 

34from smatch.matcher import Matcher 

35 

36import lsst.afw.geom as afwgeom 

37import lsst.afw.table 

38import lsst.geom 

39import lsst.pex.config as pexConfig 

40import lsst.pipe.base as pipeBase 

41import lsst.sphgeom 

42from lsst.meas.algorithms import ( 

43 LoadReferenceObjectsConfig, 

44 ReferenceObjectLoader, 

45 ReferenceSourceSelectorTask, 

46) 

47from lsst.meas.algorithms.sourceSelector import sourceSelectorRegistry 

48 

49from .build_camera import BuildCameraFromAstrometryTask 

50 

51__all__ = [ 

52 "calculate_apparent_motion", 

53 "GbdesAstrometricFitConnections", 

54 "GbdesAstrometricFitConfig", 

55 "GbdesAstrometricFitTask", 

56 "GbdesAstrometricMultibandFitConnections", 

57 "GbdesAstrometricMultibandFitTask", 

58 "GbdesGlobalAstrometricFitConnections", 

59 "GbdesGlobalAstrometricFitConfig", 

60 "GbdesGlobalAstrometricFitTask", 

61 "GbdesGlobalAstrometricMultibandFitConnections", 

62 "GbdesGlobalAstrometricMultibandFitTask", 

63] 

64 

65 

66def calculate_apparent_motion(catalog, refEpoch): 

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

68 at another date. 

69 

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

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

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

73 assessing results. 

74 

75 Parameters 

76 ---------- 

77 catalog : `astropy.table.Table` 

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

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

80 and 'MJD'. 

81 refEpoch : `astropy.time.Time` 

82 Epoch of the reference position. 

83 

84 Returns 

85 ------- 

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

87 RA shift in degrees. 

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

89 Dec shift in degrees. 

90 """ 

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

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

93 

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

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

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

97 

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

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

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

101 

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

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

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

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

106 ra_rad 

107 ) * np.cos(sunLongitudes) 

108 parallaxFactorDec = ( 

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

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

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

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

113 parallaxCorrectionRA = parallaxDegrees * parallaxFactorRA 

114 parallaxCorrectionDec = parallaxDegrees * parallaxFactorDec 

115 

116 apparentMotionRA = properMotionRA + parallaxCorrectionRA 

117 apparentMotionDec = properMotionDec + parallaxCorrectionDec 

118 

119 return apparentMotionRA, apparentMotionDec 

120 

121 

122def _make_ref_covariance_matrix( 

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

124): 

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

126 motion and parallax. 

127 

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

129 `gbdes`. 

130 

131 Parameters 

132 ---------- 

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

134 Catalog including proper motion and parallax measurements. 

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

136 Units of the input catalog 

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

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

139 expects milliarcseconds. 

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

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

142 `gbdes` expects milliarcseconds. 

143 version : `int` 

144 Version of the reference catalog. Version 2 includes covariance 

145 measurements. 

146 Returns 

147 ------- 

148 cov : `list` [`float`] 

149 Flattened output covariance matrix. 

150 """ 

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

152 if version == 1: 

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

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

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

156 # the string in Gaia column names for this 

157 # the ordering in the Gaia catalog 

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

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

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

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

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

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

164 stdOrder = ( 

165 (raErr, "ra", 0), 

166 (decErr, "dec", 1), 

167 (raPMErr, "pmra", 3), 

168 (decPMErr, "pmdec", 4), 

169 (parallaxErr, "parallax", 2), 

170 ) 

171 

172 k = 0 

173 for i, pr1 in enumerate(stdOrder): 

174 for j, pr2 in enumerate(stdOrder): 

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

176 cov[:, k] = 0 

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

178 cov[:, k] = 0 

179 else: 

180 # diagnonal element 

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

182 k = k + 1 

183 

184 elif version == 2: 

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

186 units = [outputCoordUnit, outputCoordUnit, outputPMUnit, outputPMUnit, outputPMUnit] 

187 k = 0 

188 for i, pi in enumerate(positionParameters): 

189 for j, pj in enumerate(positionParameters): 

190 if i == j: 

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

192 elif i > j: 

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

194 else: 

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

196 k += 1 

197 return cov 

198 

199 

200def _nCoeffsFromDegree(degree): 

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

202 two variables. 

203 

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

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

206 case n is fixed to 2. 

207 

208 Parameters 

209 ---------- 

210 degree : `int` 

211 Degree of the polynomial in question. 

212 

213 Returns 

214 ------- 

215 nCoeffs : `int` 

216 Number of coefficients for the polynomial in question. 

217 """ 

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

219 return nCoeffs 

220 

221 

222def _degreeFromNCoeffs(nCoeffs): 

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

224 of coefficients. 

225 

226 This is done by applying the quadratic formula to the 

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

228 

229 Parameters 

230 ---------- 

231 nCoeffs : `int` 

232 Number of coefficients for the polynomial in question. 

233 

234 Returns 

235 ------- 

236 degree : `int` 

237 Degree of the polynomial in question. 

238 """ 

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

240 return degree 

241 

242 

243def _convert_to_ast_polymap_coefficients(coefficients): 

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

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

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

247 coordinates. 

248 

249 Parameters 

250 ---------- 

251 coefficients : `list` 

252 Coefficients of the polynomials. 

253 degree : `int` 

254 Degree of the polynomial. 

255 

256 Returns 

257 ------- 

258 astPoly : `astshim.PolyMap` 

259 Coefficients in AST polynomial format. 

260 """ 

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

262 N = len(coefficients) / 2 

263 degree = _degreeFromNCoeffs(N) 

264 

265 for outVar in [1, 2]: 

266 for i in range(degree + 1): 

267 for j in range(degree + 1): 

268 if (i + j) > degree: 

269 continue 

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

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

272 polyArray[vectorIndex, 1] = outVar 

273 polyArray[vectorIndex, 2] = i 

274 polyArray[vectorIndex, 3] = j 

275 

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

277 return astPoly 

278 

279 

280def _get_instruments(inputVisitSummaries): 

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

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

283 visits to the instrument/filter used. 

284 

285 Parameters 

286 ---------- 

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

288 List of catalogs with per-detector summary information. 

289 

290 Returns 

291 ------- 

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

293 List of instrument objects. 

294 instrumentIndices : `list` [`int`] 

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

296 """ 

297 instruments = [] 

298 filters = [] 

299 instrumentIndices = [] 

300 for visitSummary in inputVisitSummaries: 

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

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

303 if filter not in filters: 

304 filters.append(filter) 

305 filter_instrument = wcsfit.Instrument(instrumentName) 

306 filter_instrument.band = filter 

307 instruments.append(filter_instrument) 

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

309 return instruments, instrumentIndices 

310 

311 

312class CholeskyError(pipeBase.AlgorithmError): 

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

314 

315 def __init__(self) -> None: 

316 super().__init__( 

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

318 ) 

319 

320 @property 

321 def metadata(self) -> dict: 

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

323 return {} 

324 

325 

326class GbdesAstrometricFitConnections( 

327 pipeBase.PipelineTaskConnections, 

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

329 defaultTemplates={ 

330 "outputName": "gbdesAstrometricFit", 

331 }, 

332): 

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

334 

335 inputCatalogRefs = pipeBase.connectionTypes.Input( 

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

337 name="preSourceTable_visit", 

338 storageClass="DataFrame", 

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

340 deferLoad=True, 

341 multiple=True, 

342 ) 

343 inputVisitSummaries = pipeBase.connectionTypes.Input( 

344 doc=( 

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

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

347 "fast lookups of a detector." 

348 ), 

349 name="visitSummary", 

350 storageClass="ExposureCatalog", 

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

352 multiple=True, 

353 ) 

354 referenceCatalog = pipeBase.connectionTypes.PrerequisiteInput( 

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

356 name="gaia_dr3_20230707", 

357 storageClass="SimpleCatalog", 

358 dimensions=("skypix",), 

359 deferLoad=True, 

360 multiple=True, 

361 ) 

362 colorCatalog = pipeBase.connectionTypes.Input( 

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

364 name="fgcm_Cycle4_StandardStars", 

365 storageClass="SimpleCatalog", 

366 dimensions=("instrument",), 

367 ) 

368 inputCameraModel = pipeBase.connectionTypes.PrerequisiteInput( 

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

370 name="gbdesAstrometricFit_cameraModel", 

371 storageClass="ArrowNumpyDict", 

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

373 ) 

374 inputCamera = pipeBase.connectionTypes.PrerequisiteInput( 

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

376 name="camera", 

377 storageClass="Camera", 

378 dimensions=("instrument",), 

379 isCalibration=True, 

380 ) 

381 outputWcs = pipeBase.connectionTypes.Output( 

382 doc=( 

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

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

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

386 ), 

387 name="{outputName}SkyWcsCatalog", 

388 storageClass="ExposureCatalog", 

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

390 multiple=True, 

391 ) 

392 outputCatalog = pipeBase.connectionTypes.Output( 

393 doc=( 

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

395 "plane coordinates and chisq values." 

396 ), 

397 name="{outputName}_fitStars", 

398 storageClass="ArrowNumpyDict", 

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

400 ) 

401 starCatalog = pipeBase.connectionTypes.Output( 

402 doc=( 

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

404 "fitProperMotion is True." 

405 ), 

406 name="{outputName}_starCatalog", 

407 storageClass="ArrowNumpyDict", 

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

409 ) 

410 modelParams = pipeBase.connectionTypes.Output( 

411 doc="WCS parameters and covariance.", 

412 name="{outputName}_modelParams", 

413 storageClass="ArrowNumpyDict", 

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

415 ) 

416 outputCameraModel = pipeBase.connectionTypes.Output( 

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

418 name="{outputName}_cameraModel", 

419 storageClass="ArrowNumpyDict", 

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

421 ) 

422 camera = pipeBase.connectionTypes.Output( 

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

424 name="{outputName}Camera", 

425 storageClass="Camera", 

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

427 ) 

428 dcrCoefficients = pipeBase.connectionTypes.Output( 

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

430 name="{outputName}_dcrCoefficients", 

431 storageClass="ArrowNumpyDict", 

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

433 ) 

434 

435 def getSpatialBoundsConnections(self): 

436 return ("inputVisitSummaries",) 

437 

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

439 super().__init__(config=config) 

440 

441 if self.config.healpix is not None: 

442 self.dimensions.remove("tract") 

443 self.dimensions.remove("skymap") 

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

445 self.dimensions.add(healpixName) 

446 self.outputWcs = dataclasses.replace( 

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

448 ) 

449 self.outputCatalog = dataclasses.replace( 

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

451 ) 

452 self.starCatalog = dataclasses.replace( 

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

454 ) 

455 self.modelParams = dataclasses.replace( 

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

457 ) 

458 self.outputCameraModel = dataclasses.replace( 

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

460 ) 

461 self.camera = dataclasses.replace( 

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

463 ) 

464 self.dcrCoefficients = dataclasses.replace( 

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

466 ) 

467 

468 if not self.config.useColor: 

469 self.inputs.remove("colorCatalog") 

470 self.outputs.remove("dcrCoefficients") 

471 if not self.config.saveModelParams: 

472 self.outputs.remove("modelParams") 

473 if not self.config.useInputCameraModel: 

474 self.prerequisiteInputs.remove("inputCameraModel") 

475 if not self.config.saveCameraModel: 

476 self.outputs.remove("outputCameraModel") 

477 if not self.config.saveCameraObject: 

478 self.prerequisiteInputs.remove("inputCamera") 

479 self.outputs.remove("camera") 

480 

481 

482class GbdesAstrometricFitConfig( 

483 pipeBase.PipelineTaskConfig, pipelineConnections=GbdesAstrometricFitConnections 

484): 

485 """Configuration for GbdesAstrometricFitTask""" 

486 

487 sourceSelector = sourceSelectorRegistry.makeField( 

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

489 ) 

490 referenceSelector = pexConfig.ConfigurableField( 

491 target=ReferenceSourceSelectorTask, 

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

493 ) 

494 referenceFilter = pexConfig.Field( 

495 dtype=str, 

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

497 "returned are not used.", 

498 default="phot_g_mean", 

499 ) 

500 setRefEpoch = pexConfig.Field( 

501 dtype=float, 

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

503 default=None, 

504 optional=True, 

505 ) 

506 applyRefCatProperMotion = pexConfig.Field( 

507 dtype=bool, 

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

509 default=True, 

510 ) 

511 matchRadius = pexConfig.Field( 

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

513 ) 

514 minMatches = pexConfig.Field( 

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

516 ) 

517 allowSelfMatches = pexConfig.Field( 

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

519 dtype=bool, 

520 default=False, 

521 ) 

522 sourceFluxType = pexConfig.Field( 

523 dtype=str, 

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

525 default="apFlux_12_0", 

526 ) 

527 systematicError = pexConfig.Field( 

528 dtype=float, 

529 doc=( 

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

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

532 ), 

533 default=0.0034, 

534 ) 

535 referenceSystematicError = pexConfig.Field( 

536 dtype=float, 

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

538 default=0.0, 

539 ) 

540 useColor = pexConfig.Field( 

541 dtype=bool, 

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

543 default=False, 

544 ) 

545 color = pexConfig.ListField( 

546 dtype=str, 

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

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

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

550 ) 

551 referenceColor = pexConfig.Field( 

552 dtype=float, 

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

554 default=0.61, 

555 ) 

556 modelComponents = pexConfig.ListField( 

557 dtype=str, 

558 doc=( 

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

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

561 ), 

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

563 ) 

564 deviceModel = pexConfig.ListField( 

565 dtype=str, 

566 doc=( 

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

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

569 ), 

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

571 ) 

572 exposureModel = pexConfig.ListField( 

573 dtype=str, 

574 doc=( 

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

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

577 ), 

578 default=["EXPOSURE/poly"], 

579 ) 

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

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

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

583 excludeNonPMObjects = pexConfig.Field( 

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

585 ) 

586 fitReserveFraction = pexConfig.Field( 

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

588 ) 

589 fitReserveRandomSeed = pexConfig.Field( 

590 dtype=int, 

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

592 default=1234, 

593 ) 

594 saveModelParams = pexConfig.Field( 

595 dtype=bool, 

596 doc=( 

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

598 "false because this can be very large." 

599 ), 

600 default=False, 

601 ) 

602 useInputCameraModel = pexConfig.Field( 

603 dtype=bool, 

604 doc=( 

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

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

607 ), 

608 default=False, 

609 ) 

610 saveCameraModel = pexConfig.Field( 

611 dtype=bool, 

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

613 default=False, 

614 ) 

615 buildCamera = pexConfig.ConfigurableField( 

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

617 ) 

618 saveCameraObject = pexConfig.Field( 

619 dtype=bool, 

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

621 default=False, 

622 ) 

623 clipThresh = pexConfig.Field( 

624 dtype=float, 

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

626 default=5.0, 

627 ) 

628 clipFraction = pexConfig.Field( 

629 dtype=float, 

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

631 default=0.0, 

632 ) 

633 healpix = pexConfig.Field( 

634 dtype=int, 

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

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

637 optional=True, 

638 default=None, 

639 ) 

640 minDetectorFraction = pexConfig.Field( 

641 dtype=float, 

642 doc=( 

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

644 "fit." 

645 ), 

646 default=0.25, 

647 ) 

648 

649 def setDefaults(self): 

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

651 # depend on seeing. 

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

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

654 

655 # Use only isolated sources. 

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

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

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

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

660 # chosen from the usual QA flags for stars. 

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

662 badFlags = [ 

663 "pixelFlags_edge", 

664 "pixelFlags_saturated", 

665 "pixelFlags_interpolatedCenter", 

666 "pixelFlags_interpolated", 

667 "pixelFlags_crCenter", 

668 "pixelFlags_bad", 

669 "hsmPsfMoments_flag", 

670 f"{self.sourceFluxType}_flag", 

671 ] 

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

673 

674 # Use only primary sources. 

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

676 

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

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

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

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

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

682 

683 def validate(self): 

684 super().validate() 

685 

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

687 # supported. 

688 for component in self.deviceModel: 

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

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

691 raise pexConfig.FieldValidationError( 

692 GbdesAstrometricFitConfig.deviceModel, 

693 self, 

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

695 ) 

696 

697 for component in self.exposureModel: 

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

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

700 raise pexConfig.FieldValidationError( 

701 GbdesAstrometricFitConfig.exposureModel, 

702 self, 

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

704 ) 

705 

706 if self.saveCameraModel and self.useInputCameraModel: 

707 raise pexConfig.FieldValidationError( 

708 GbdesAstrometricFitConfig.saveCameraModel, 

709 self, 

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

711 ) 

712 

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

714 raise pexConfig.FieldValidationError( 

715 GbdesAstrometricFitConfig.saveCameraObject, 

716 self, 

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

718 ) 

719 

720 

721class GbdesAstrometricFitTask(pipeBase.PipelineTask): 

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

723 GBDES package. 

724 """ 

725 

726 ConfigClass = GbdesAstrometricFitConfig 

727 _DefaultName = "gbdesAstrometricFit" 

728 

729 def __init__(self, **kwargs): 

730 super().__init__(**kwargs) 

731 self.makeSubtask("sourceSelector") 

732 self.makeSubtask("referenceSelector") 

733 if self.config.saveCameraObject: 

734 self.makeSubtask("buildCamera") 

735 

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

737 # We override runQuantum to set up the refObjLoaders 

738 inputs = butlerQC.get(inputRefs) 

739 

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

741 

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

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

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

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

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

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

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

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

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

751 

752 refConfig = LoadReferenceObjectsConfig() 

753 if self.config.applyRefCatProperMotion: 

754 refConfig.requireProperMotion = True 

755 refObjectLoader = ReferenceObjectLoader( 

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

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

758 config=refConfig, 

759 log=self.log, 

760 ) 

761 

762 nCores = butlerQC.resources.num_cores 

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

764 

765 if self.config.useColor: 

766 colorCatalog = inputs.pop("colorCatalog") 

767 else: 

768 colorCatalog = None 

769 try: 

770 output = self.run( 

771 **inputs, 

772 instrumentName=instrumentName, 

773 refObjectLoader=refObjectLoader, 

774 colorCatalog=colorCatalog, 

775 nCores=nCores, 

776 ) 

777 except pipeBase.AlgorithmError as e: 

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

779 # No partial outputs for butler to put 

780 raise error from e 

781 

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

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

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

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

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

787 if self.config.saveModelParams: 

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

789 if self.config.saveCameraModel: 

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

791 if self.config.saveCameraObject: 

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

793 if self.config.useColor: 

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

795 if output.partialOutputs: 

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

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

798 raise error from e 

799 

800 def run( 

801 self, 

802 inputCatalogRefs, 

803 inputVisitSummaries, 

804 instrumentName="", 

805 refEpoch=None, 

806 refObjectLoader=None, 

807 inputCameraModel=None, 

808 colorCatalog=None, 

809 inputCamera=None, 

810 nCores=1, 

811 ): 

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

813 

814 Parameters 

815 ---------- 

816 inputCatalogRefs : `list` [`DeferredDatasetHandle`] 

817 List of handles pointing to visit-level source 

818 tables. 

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

820 List of catalogs with per-detector summary information. 

821 instrumentName : `str`, optional 

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

823 refEpoch : `float` 

824 Epoch of the reference objects in MJD. 

825 refObjectLoader : instance of 

826 `lsst.meas.algorithms.loadReferenceObjects.ReferenceObjectLoader` 

827 Reference object loader instance. 

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

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

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

831 Catalog containing object coordinates and magnitudes. 

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

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

834 nCores : `int`, optional 

835 Number of cores to use during WCS fit. 

836 

837 Returns 

838 ------- 

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

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

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

842 detector set by the new fitted WCS. 

843 ``fitModel`` : `wcsfit.WCSFit` 

844 Model-fitting object with final model parameters. 

845 ``outputCatalog`` : `pyarrow.Table` 

846 Catalog with fit residuals of all sources used. 

847 ``starCatalog`` : `pyarrow.Table` 

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

849 ``modelParams`` : `dict` 

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

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

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

853 needed as input for future runs. 

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

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

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

857 Camera object constructed from the per-detector model. 

858 """ 

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

860 

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

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

863 inputVisitSummaries 

864 ) 

865 

866 # Get information about the extent of the input visits 

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

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

869 

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

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

872 # friends-of-friends algorithm 

873 associations = wcsfit.FoFClass( 

874 fields, 

875 instruments, 

876 exposuresHelper, 

877 [fieldRadius.asDegrees()], 

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

879 ) 

880 

881 # Add the reference catalog to the associator 

882 medianEpoch = astropy.time.Time(exposureInfo.medianEpoch, format="jyear").mjd 

883 refObjects, refCovariance = self._load_refcat( 

884 refObjectLoader, 

885 extensionInfo, 

886 epoch=medianEpoch, 

887 center=fieldCenter, 

888 radius=fieldRadius, 

889 associations=associations, 

890 ) 

891 

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

893 sourceIndices, usedColumns = self._load_catalogs_and_associate( 

894 associations, inputCatalogRefs, extensionInfo 

895 ) 

896 self._check_degeneracies(associations, extensionInfo) 

897 

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

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

900 # visit 

901 inputYaml, mapTemplate = self.make_yaml( 

902 inputVisitSummaries[0], 

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

904 ) 

905 

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

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

908 # properly propagated. 

909 loglevel = self.log.getEffectiveLevel() 

910 if loglevel >= self.log.WARNING: 

911 verbose = 0 

912 elif loglevel == self.log.INFO: 

913 verbose = 1 

914 else: 

915 verbose = 2 

916 

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

918 if self.config.useInputCameraModel: 

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

920 else: 

921 fixMaps = "" 

922 wcsf = wcsfit.WCSFit( 

923 fields, 

924 instruments, 

925 exposuresHelper, 

926 extensionInfo.visitIndex, 

927 extensionInfo.detectorIndex, 

928 inputYaml, 

929 extensionInfo.wcs, 

930 associations.sequence, 

931 associations.extn, 

932 associations.obj, 

933 sysErr=self.config.systematicError, 

934 refSysErr=self.config.referenceSystematicError, 

935 usePM=self.config.fitProperMotion, 

936 verbose=verbose, 

937 fixMaps=fixMaps, 

938 num_threads=nCores, 

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").jyear 

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 neededColumns = ["coord_ra", "coord_dec", "coord_raErr", "coord_decErr"] 

1329 if self.config.applyRefCatProperMotion: 

1330 neededColumns += [ 

1331 "pm_ra", 

1332 "pm_dec", 

1333 "parallax", 

1334 "pm_raErr", 

1335 "pm_decErr", 

1336 "parallaxErr", 

1337 ] 

1338 # Get refcat version from refcat metadata 

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

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

1341 # the_monster_20240904 catalog that does not have this key. 

1342 if "REFCAT_FORMAT_VERSION" not in refCatMetadata: 

1343 refCatVersion = 2 

1344 else: 

1345 refCatVersion = refCatMetadata["REFCAT_FORMAT_VERSION"] 

1346 if refCatVersion == 2: 

1347 neededColumns += [ 

1348 "coord_ra_coord_dec_Cov", 

1349 "coord_ra_pm_ra_Cov", 

1350 "coord_ra_pm_dec_Cov", 

1351 "coord_ra_parallax_Cov", 

1352 "coord_dec_pm_ra_Cov", 

1353 "coord_dec_pm_dec_Cov", 

1354 "coord_dec_parallax_Cov", 

1355 "pm_ra_pm_dec_Cov", 

1356 "pm_ra_parallax_Cov", 

1357 "pm_dec_parallax_Cov", 

1358 ] 

1359 

1360 # Load each shard of the reference catalog separately to avoid a spike 

1361 # in the memory load. 

1362 refCatShards = [] 

1363 for dataId, cat in zip(refObjectLoader.dataIds, refObjectLoader.refCats): 

1364 miniRefObjectLoader = ReferenceObjectLoader( 

1365 dataIds=[dataId], 

1366 refCats=[cat], 

1367 config=refObjectLoader.config, 

1368 log=self.log, 

1369 ) 

1370 try: 

1371 if region is not None: 

1372 skyRegion = miniRefObjectLoader.loadRegion( 

1373 region, self.config.referenceFilter, epoch=formattedEpoch 

1374 ) 

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

1376 skyRegion = miniRefObjectLoader.loadSkyCircle( 

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

1378 ) 

1379 else: 

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

1381 except RuntimeError: 

1382 self.log.debug("Reference catalog shard has no objects inside the region.") 

1383 continue 

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

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

1386 if not selected.sourceCat.isContiguous(): 

1387 refCatShard = selected.sourceCat.copy(deep=True) 

1388 else: 

1389 refCatShard = selected.sourceCat 

1390 refCatShard = refCatShard.asAstropy()[neededColumns] 

1391 

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

1393 finiteInd = np.isfinite(refCatShard["coord_ra"]) & np.isfinite(refCatShard["coord_dec"]) 

1394 refCatShard = refCatShard[finiteInd] 

1395 refCatShards.append(refCatShard) 

1396 refCat = vstack(refCatShards) 

1397 

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

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

1400 hasPM = ( 

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

1402 ) 

1403 refCat = refCat[hasPM] 

1404 

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

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

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

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

1409 

1410 if refCatVersion == 2: 

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

1412 else: 

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

1414 

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

1416 refCovariance = [] 

1417 

1418 if self.config.fitProperMotion: 

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

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

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

1422 cov = _make_ref_covariance_matrix(refCat, version=refCatVersion) 

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

1424 refObjects.update(pmDict) 

1425 refCovariance = cov 

1426 

1427 if associations is not None: 

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

1429 visitIndex = extensionInfo.visitIndex[extensionIndex] 

1430 detectorIndex = extensionInfo.detectorIndex[extensionIndex] 

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

1432 refWcs = extensionInfo.wcs[extensionIndex] 

1433 

1434 associations.addCatalog( 

1435 refWcs, 

1436 "STELLAR", 

1437 visitIndex, 

1438 fieldIndex, 

1439 instrumentIndex, 

1440 detectorIndex, 

1441 extensionIndex, 

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

1443 ra, 

1444 dec, 

1445 np.arange(len(ra)), 

1446 ) 

1447 

1448 return refObjects, refCovariance 

1449 

1450 @staticmethod 

1451 def _find_extension_index(extensionInfo, visit, detector): 

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

1453 number. 

1454 

1455 If no match is found, None is returned. 

1456 

1457 Parameters 

1458 ---------- 

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

1460 Struct containing properties for each extension. 

1461 visit : `int` 

1462 Visit number 

1463 detector : `int` 

1464 Detector number 

1465 

1466 Returns 

1467 ------- 

1468 extensionIndex : `int` or None 

1469 Index of this extension 

1470 """ 

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

1472 if len(findExtension) == 0: 

1473 extensionIndex = None 

1474 else: 

1475 extensionIndex = findExtension[0] 

1476 return extensionIndex 

1477 

1478 def _load_catalogs_and_associate( 

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

1480 ): 

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

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

1483 

1484 Parameters 

1485 ---------- 

1486 associations : `wcsfit.FoFClass` 

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

1488 the source association. 

1489 inputCatalogRefs : `list` 

1490 List of DeferredDatasetHandles pointing to visit-level source 

1491 tables. 

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

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

1494 ``visit`` : `np.ndarray` 

1495 Name of the visit for this extension. 

1496 ``detector`` : `np.ndarray` 

1497 Name of the detector for this extension. 

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

1499 Index of visit for this extension. 

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

1501 Index of the detector for this extension. 

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

1503 Initial WCS for this extension. 

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

1505 "SCIENCE" or "REFERENCE". 

1506 fieldIndex : `int` 

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

1508 data is being fit together. 

1509 instrumentIndex : `int` 

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

1511 assuming all data comes from the same instrument. 

1512 

1513 Returns 

1514 ------- 

1515 sourceIndices : `list` 

1516 List of boolean arrays used to select sources. 

1517 columns : `list` [`str`] 

1518 List of columns needed from source tables. 

1519 """ 

1520 columns = [ 

1521 "detector", 

1522 "sourceId", 

1523 "x", 

1524 "xErr", 

1525 "y", 

1526 "yErr", 

1527 "ixx", 

1528 "iyy", 

1529 "ixy", 

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

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

1532 ] 

1533 if self.sourceSelector.config.doFlags: 

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

1535 if self.sourceSelector.config.doUnresolved: 

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

1537 if self.sourceSelector.config.doIsolated: 

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

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

1540 if self.sourceSelector.config.doRequirePrimary: 

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

1542 

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

1544 for inputCatalogRef in inputCatalogRefs: 

1545 visit = inputCatalogRef.dataId["visit"] 

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

1547 # Get a sorted array of detector names 

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

1549 

1550 for detector in detectors: 

1551 detectorSources = inputCatalog[inputCatalog["detector"] == detector] 

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

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

1554 xyCov = ( 

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

1556 ) 

1557 # Remove sources with bad shape measurements 

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

1559 selected = self.sourceSelector.run(detectorSources) 

1560 goodInds = selected.selected & goodShapes 

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

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

1563 if extensionIndex is None: 

1564 # This extension does not have information necessary for 

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

1566 # visit. 

1567 continue 

1568 detectorIndex = extensionInfo.detectorIndex[extensionIndex] 

1569 visitIndex = extensionInfo.visitIndex[extensionIndex] 

1570 

1571 sourceIndices[extensionIndex] = goodInds 

1572 

1573 wcs = extensionInfo.wcs[extensionIndex] 

1574 associations.reprojectWCS(wcs, fieldIndex) 

1575 

1576 associations.addCatalog( 

1577 wcs, 

1578 "STELLAR", 

1579 visitIndex, 

1580 fieldIndex, 

1581 instrumentIndex, 

1582 detectorIndex, 

1583 extensionIndex, 

1584 isStar, 

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

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

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

1588 ) 

1589 

1590 associations.sortMatches( 

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

1592 ) 

1593 

1594 return sourceIndices, columns 

1595 

1596 def _check_degeneracies(self, associations, extensionInfo): 

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

1598 constrain the model are present. 

1599 

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

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

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

1603 positive-definite and the model cannot be fit. 

1604 

1605 Parameters 

1606 ---------- 

1607 associations : `wcsfit.FoFClass` 

1608 Object holding the source association information. 

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

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

1611 ``visit`` : `np.ndarray` 

1612 Name of the visit for this extension. 

1613 ``detector`` : `np.ndarray` 

1614 Name of the detector for this extension. 

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

1616 Index of visit for this extension. 

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

1618 Index of the detector for this extension. 

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

1620 Initial WCS for this extension. 

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

1622 "SCIENCE" or "REFERENCE". 

1623 """ 

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

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

1626 whichExtension = np.array(associations.extn) 

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

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

1629 

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

1631 ex_ind = whichExtension == extension 

1632 whichDetector[ex_ind] = detector 

1633 whichVisit[ex_ind] = visit 

1634 

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

1636 nCoeffDetectorModel = _nCoeffsFromDegree(self.config.devicePolyOrder) 

1637 unconstrainedDetectors = [] 

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

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

1640 if numSources < nCoeffDetectorModel: 

1641 unconstrainedDetectors.append(str(detector)) 

1642 

1643 if unconstrainedDetectors: 

1644 raise pipeBase.NoWorkFound( 

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

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

1647 ", ".join(unconstrainedDetectors), 

1648 ) 

1649 

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

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

1652 model. 

1653 

1654 Parameters 

1655 ---------- 

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

1657 Catalog with per-detector summary information. 

1658 inputFile : `str` 

1659 Path to a file that contains a basic model. 

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

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

1662 

1663 Returns 

1664 ------- 

1665 inputYaml : `wcsfit.YAMLCollector` 

1666 YAML object containing the model description. 

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

1668 Dictionary containing the model description. 

1669 """ 

1670 if inputFile is not None: 

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

1672 else: 

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

1674 inputDict = {} 

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

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

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

1678 

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

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

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

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

1683 

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

1685 inputDict["BAND/DEVICE"] = deviceModel 

1686 for component in self.config.deviceModel: 

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

1688 if mapping == "poly": 

1689 componentDict = { 

1690 "Type": "Poly", 

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

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

1693 "XMin": xMin, 

1694 "XMax": xMax, 

1695 "YMin": yMin, 

1696 "YMax": yMax, 

1697 } 

1698 elif mapping == "identity": 

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

1700 

1701 inputDict[component] = componentDict 

1702 

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

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

1705 nCoeffs = _nCoeffsFromDegree(self.config.devicePolyOrder) 

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

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

1708 raise RuntimeError( 

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

1710 ) 

1711 mapDict = { 

1712 "Type": "Poly", 

1713 "XPoly": { 

1714 "OrderX": self.config.devicePolyOrder, 

1715 "SumOrder": True, 

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

1717 }, 

1718 "YPoly": { 

1719 "OrderX": self.config.devicePolyOrder, 

1720 "SumOrder": True, 

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

1722 }, 

1723 "XMin": xMin, 

1724 "XMax": xMax, 

1725 "YMin": yMin, 

1726 "YMax": yMax, 

1727 } 

1728 inputDict[key] = mapDict 

1729 

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

1731 if self.config.useColor: 

1732 exposureModelComponents.append("EXPOSURE/dcr") 

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

1734 inputDict["EXPOSURE"] = exposureModel 

1735 for component in exposureModelComponents: 

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

1737 if mapping == "poly": 

1738 componentDict = { 

1739 "Type": "Poly", 

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

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

1742 } 

1743 elif mapping == "identity": 

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

1745 elif mapping == "dcr": 

1746 componentDict = { 

1747 "Type": "Color", 

1748 "Reference": self.config.referenceColor, 

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

1750 } 

1751 

1752 inputDict[component] = componentDict 

1753 

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

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

1756 

1757 return inputYaml, inputDict 

1758 

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

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

1761 

1762 Parameters 

1763 ---------- 

1764 wcsf : `wcsfit.WCSFit` 

1765 WCS-fitting object. 

1766 inputCatalogRefs : `list` 

1767 List of DeferredDatasetHandles pointing to visit-level source 

1768 tables. 

1769 sourceIndices : `list` 

1770 List of boolean arrays used to select sources. 

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

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

1773 ``visit`` : `np.ndarray` 

1774 Name of the visit for this extension. 

1775 ``detector`` : `np.ndarray` 

1776 Name of the detector for this extension. 

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

1778 Index of visit for this extension. 

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

1780 Index of the detector for this extension. 

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

1782 Initial WCS for this extension. 

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

1784 "SCIENCE" or "REFERENCE". 

1785 columns : `list` [`str`] 

1786 List of columns needed from source tables. 

1787 """ 

1788 for inputCatalogRef in inputCatalogRefs: 

1789 visit = inputCatalogRef.dataId["visit"] 

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

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

1792 

1793 for detector in detectors: 

1794 detectorSources = inputCatalog[inputCatalog["detector"] == detector] 

1795 

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

1797 if extensionIndex is None: 

1798 # This extension does not have information necessary for 

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

1800 # visit. 

1801 continue 

1802 

1803 sourceCat = detectorSources[sourceIndices[extensionIndex]] 

1804 

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

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

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

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

1809 

1810 d = { 

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

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

1813 "xCov": xCov.to_numpy(), 

1814 "yCov": yCov.to_numpy(), 

1815 "xyCov": xyCov.to_numpy(), 

1816 } 

1817 

1818 wcsf.setObjects( 

1819 extensionIndex, 

1820 d, 

1821 "x", 

1822 "y", 

1823 ["xCov", "yCov", "xyCov"], 

1824 defaultColor=self.config.referenceColor, 

1825 ) 

1826 

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

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

1829 

1830 Parameters 

1831 ---------- 

1832 wcsf : `wcsfit.WCSFit` 

1833 WCS-fitting object. 

1834 refObjects : `dict` 

1835 Position and error information of reference objects. 

1836 refCovariance : `list` [`float`] 

1837 Flattened output covariance matrix. 

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

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

1840 ``visit`` : `np.ndarray` 

1841 Name of the visit for this extension. 

1842 ``detector`` : `np.ndarray` 

1843 Name of the detector for this extension. 

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

1845 Index of visit for this extension. 

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

1847 Index of the detector for this extension. 

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

1849 Initial WCS for this extension. 

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

1851 "SCIENCE" or "REFERENCE". 

1852 fieldIndex : `int`, optional 

1853 Index of the field to which these sources correspond. 

1854 """ 

1855 extensionIndex = np.flatnonzero( 

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

1857 )[0] 

1858 if self.config.fitProperMotion: 

1859 wcsf.setObjects( 

1860 extensionIndex, 

1861 refObjects, 

1862 "ra", 

1863 "dec", 

1864 ["raCov", "decCov", "raDecCov"], 

1865 pmDecKey="decPM", 

1866 pmRaKey="raPM", 

1867 parallaxKey="parallax", 

1868 pmCovKey="fullCov", 

1869 pmCov=refCovariance, 

1870 ) 

1871 else: 

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

1873 

1874 def _add_color_objects(self, wcsf, colorCatalog): 

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

1876 color value. 

1877 

1878 Parameters 

1879 ---------- 

1880 wcsf : `wcsfit.WCSFit` 

1881 WCSFit object, assumed to have fit model. 

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

1883 Catalog containing object coordinates and magnitudes. 

1884 """ 

1885 

1886 # Get current best position for matches 

1887 starCat = wcsf.getStarCatalog() 

1888 

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

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

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

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

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

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

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

1896 ) 

1897 

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

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

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

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

1902 self.config.matchRadius / 3600.0, 

1903 return_indices=True, 

1904 ) 

1905 

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

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

1908 matchColors = colors[goodInd][idx_colorCat] 

1909 wcsf.setColors(matchesWithColor, matchColors) 

1910 

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

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

1913 

1914 Parameters 

1915 ---------- 

1916 mapDict : `dict` 

1917 Dictionary of mapping parameters. 

1918 centerRA : `lsst.geom.Angle` 

1919 RA of the tangent point. 

1920 centerDec : `lsst.geom.Angle` 

1921 Declination of the tangent point. 

1922 doNormalizePixels : `bool` 

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

1924 xScale : `float` 

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

1926 detector. 

1927 yScale : `float` 

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

1929 detector. 

1930 

1931 Returns 

1932 ------- 

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

1934 WCS constructed from the input mappings 

1935 """ 

1936 # Set up pixel frames 

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

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

1939 

1940 if doNormalizePixels: 

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

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

1943 normMap = _convert_to_ast_polymap_coefficients(normCoefficients) 

1944 else: 

1945 normMap = astshim.UnitMap(2) 

1946 

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

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

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

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

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

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

1953 

1954 frameDict = astshim.FrameDict(pixelFrame) 

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

1956 

1957 currentFrameName = "NORMEDPIXELS" 

1958 

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

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

1961 mapType = mapElement["Type"] 

1962 

1963 if mapType == "Poly": 

1964 mapCoefficients = mapElement["Coefficients"] 

1965 astMap = _convert_to_ast_polymap_coefficients(mapCoefficients) 

1966 elif mapType == "Identity": 

1967 astMap = astshim.UnitMap(2) 

1968 else: 

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

1970 

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

1972 newFrameName = "IWC" 

1973 else: 

1974 newFrameName = "INTERMEDIATE" + str(m) 

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

1976 frameDict.addFrame(currentFrameName, astMap, newFrame) 

1977 currentFrameName = newFrameName 

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

1979 

1980 outWCS = afwgeom.SkyWcs(frameDict) 

1981 return outWCS 

1982 

1983 def _make_outputs( 

1984 self, 

1985 wcsf, 

1986 visitSummaryTables, 

1987 exposureInfo, 

1988 mapTemplate, 

1989 extensionInfo, 

1990 inputCameraModel=None, 

1991 inputCamera=None, 

1992 ): 

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

1994 

1995 Parameters 

1996 ---------- 

1997 wcsf : `wcsfit.WCSFit` 

1998 WCSFit object, assumed to have fit model. 

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

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

2001 detector information. 

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

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

2004 ``visit`` : `np.ndarray` 

2005 Name of the visit for this extension. 

2006 ``detector`` : `np.ndarray` 

2007 Name of the detector for this extension. 

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

2009 Index of visit for this extension. 

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

2011 Index of the detector for this extension. 

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

2013 Initial WCS for this extension. 

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

2015 "SCIENCE" or "REFERENCE". 

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

2017 Dictionary containing the model description. 

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

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

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

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

2022 provided if an input camera model was used. 

2023 

2024 Returns 

2025 ------- 

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

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

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

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

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

2031 when used as input for future runs. 

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

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

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

2035 Camera object constructed from the per-detector model. 

2036 """ 

2037 # Get the parameters of the fit models 

2038 mapParams = wcsf.mapCollection.getParamDict() 

2039 cameraParams = {} 

2040 if self.config.saveCameraModel: 

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

2042 for detector in exposureInfo.detectors: 

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

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

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

2046 if re.fullmatch(detectorTemplate, k): 

2047 cameraParams[k] = params 

2048 if self.config.saveCameraObject: 

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

2050 rotations = [ 

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

2052 ] 

2053 rotationAngle = np.mean(rotations) 

2054 if inputCamera is None: 

2055 raise RuntimeError( 

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

2057 ) 

2058 camera = self.buildCamera.run( 

2059 mapParams, 

2060 mapTemplate, 

2061 exposureInfo.detectors, 

2062 exposureInfo.visits, 

2063 inputCamera, 

2064 rotationAngle, 

2065 ) 

2066 else: 

2067 camera = None 

2068 if self.config.useInputCameraModel: 

2069 if inputCameraModel is None: 

2070 raise RuntimeError( 

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

2072 ) 

2073 mapParams.update(inputCameraModel) 

2074 

2075 # Set up the schema for the output catalogs 

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

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

2078 schema.addField( 

2079 "recoveredWcs", 

2080 type="Flag", 

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

2082 ) 

2083 

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

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

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

2087 

2088 # Make dictionary of bboxes for each detector. 

2089 detectorBBoxes = {} 

2090 for detector in exposureInfo.detectors: 

2091 for visitSummary in visitSummaryTables: 

2092 if detInfo := visitSummary.find(detector): 

2093 detectorBBoxes[detector] = detInfo.getBBox() 

2094 break 

2095 

2096 catalogs = {} 

2097 colorFits = {} 

2098 partialOutputs = False 

2099 for v, visitSummary in enumerate(visitSummaryTables): 

2100 visit = visitSummary[0]["visit"] 

2101 if visit not in extensionInfo.visit: 

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

2103 partialOutputs = True 

2104 continue 

2105 

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

2107 if self.config.useColor: 

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

2109 colorFits[visit] = mapParams[colorMap] 

2110 visitMap = visitMaps[0] 

2111 visitMapType = wcsf.mapCollection.getMapType(visitMap) 

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

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

2114 partialOutputs = True 

2115 continue 

2116 

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

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

2119 catalog["visit"] = visit 

2120 

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

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

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

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

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

2126 else: 

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

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

2129 # same detector and from sources on other detectors from 

2130 # this visit. 

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

2132 mapElements = [] 

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

2134 

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

2136 # of the specific maps for this extension. 

2137 for component in genericElements: 

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

2139 for element in elements: 

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

2141 # instrument name currently. This will need to be 

2142 # disambiguated if we run on multiple bands at 

2143 # once. 

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

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

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

2147 mapElements.append(element) 

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

2149 mapDict = {} 

2150 for m, mapElement in enumerate(mapElements): 

2151 mapType = wcsf.mapCollection.getMapType(mapElement) 

2152 if mapType == "Color": 

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

2154 continue 

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

2156 

2157 if mapType == "Poly": 

2158 mapCoefficients = mapParams[mapElement] 

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

2160 

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

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

2163 outWCS = self._make_afw_wcs( 

2164 mapDict, 

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

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

2167 doNormalizePixels=True, 

2168 xScale=xscale, 

2169 yScale=yscale, 

2170 ) 

2171 

2172 catalog[d].setId(detector) 

2173 catalog[d].setBBox(detectorBBoxes[detector]) 

2174 catalog[d].setWcs(outWCS) 

2175 catalog.sort() 

2176 catalogs[visit] = catalog 

2177 if self.config.useColor: 

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

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

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

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

2182 

2183 return catalogs, cameraParams, colorFits, camera, partialOutputs 

2184 

2185 def _compute_model_params(self, wcsf): 

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

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

2188 

2189 Parameters 

2190 ---------- 

2191 wcsf : `wcsfit.WCSFit` 

2192 WCSFit object, assumed to have fit model. 

2193 

2194 Returns 

2195 ------- 

2196 modelParams : `dict` 

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

2198 """ 

2199 modelParamDict = wcsf.mapCollection.getParamDict() 

2200 modelCovariance = wcsf.getModelCovariance() 

2201 

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

2203 i = 0 

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

2205 nCoeffs = len(params) 

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

2207 nCoordCoeffs = nCoeffs // 2 

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

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

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

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

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

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

2214 

2215 for p in range(nCoeffs): 

2216 if p < nCoordCoeffs: 

2217 coord = "x" 

2218 else: 

2219 coord = "y" 

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

2221 i += 1 

2222 

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

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

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

2226 

2227 return modelParams 

2228 

2229 

2230class GbdesAstrometricMultibandFitConnections( 

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

2232): 

2233 outputCatalog = pipeBase.connectionTypes.Output( 

2234 doc=( 

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

2236 "plane coordinates and chisq values." 

2237 ), 

2238 name="gbdesAstrometricMultibandFit_fitStars", 

2239 storageClass="ArrowNumpyDict", 

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

2241 ) 

2242 starCatalog = pipeBase.connectionTypes.Output( 

2243 doc=( 

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

2245 "fitProperMotion is True." 

2246 ), 

2247 name="gbdesAstrometricMultibandFit_starCatalog", 

2248 storageClass="ArrowNumpyDict", 

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

2250 ) 

2251 modelParams = pipeBase.connectionTypes.Output( 

2252 doc="WCS parameters and covariance.", 

2253 name="gbdesAstrometricMultibandFit_modelParams", 

2254 storageClass="ArrowNumpyDict", 

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

2256 ) 

2257 

2258 

2259class GbdesAstrometricMultibandFitConfig( 

2260 GbdesAstrometricFitConfig, pipelineConnections=GbdesAstrometricMultibandFitConnections 

2261): 

2262 pass 

2263 

2264 

2265class GbdesAstrometricMultibandFitTask(GbdesAstrometricFitTask): 

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

2267 field using the GBDES package. 

2268 """ 

2269 

2270 ConfigClass = GbdesAstrometricMultibandFitConfig 

2271 _DefaultName = "gbdesAstrometricMultibandFit" 

2272 

2273 

2274class GbdesGlobalAstrometricFitConnections( 

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

2276): 

2277 inputVisitSummaries = pipeBase.connectionTypes.Input( 

2278 doc=( 

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

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

2281 "fast lookups of a detector." 

2282 ), 

2283 name="visitSummary", 

2284 storageClass="ExposureCatalog", 

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

2286 multiple=True, 

2287 ) 

2288 referenceCatalog = pipeBase.connectionTypes.PrerequisiteInput( 

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

2290 name="gaia_dr3_20230707", 

2291 storageClass="SimpleCatalog", 

2292 dimensions=("skypix",), 

2293 deferLoad=True, 

2294 multiple=True, 

2295 ) 

2296 colorCatalog = pipeBase.connectionTypes.Input( 

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

2298 name="fgcm_Cycle4_StandardStars", 

2299 storageClass="SimpleCatalog", 

2300 dimensions=("instrument",), 

2301 ) 

2302 isolatedStarSources = pipeBase.connectionTypes.Input( 

2303 doc="Catalog of matched sources.", 

2304 name="isolated_star_presources", 

2305 storageClass="DataFrame", 

2306 dimensions=( 

2307 "instrument", 

2308 "skymap", 

2309 "tract", 

2310 ), 

2311 multiple=True, 

2312 deferLoad=True, 

2313 ) 

2314 isolatedStarCatalogs = pipeBase.connectionTypes.Input( 

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

2316 name="isolated_star_presource_associations", 

2317 storageClass="DataFrame", 

2318 dimensions=( 

2319 "instrument", 

2320 "skymap", 

2321 "tract", 

2322 ), 

2323 multiple=True, 

2324 deferLoad=True, 

2325 ) 

2326 inputCameraModel = pipeBase.connectionTypes.PrerequisiteInput( 

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 inputCamera = pipeBase.connectionTypes.PrerequisiteInput( 

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

2334 name="camera", 

2335 storageClass="Camera", 

2336 dimensions=("instrument",), 

2337 isCalibration=True, 

2338 ) 

2339 outputWcs = pipeBase.connectionTypes.Output( 

2340 doc=( 

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

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

2343 "for fast lookups of a detector." 

2344 ), 

2345 name="gbdesGlobalAstrometricFitSkyWcsCatalog", 

2346 storageClass="ExposureCatalog", 

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

2348 multiple=True, 

2349 ) 

2350 outputCatalog = pipeBase.connectionTypes.Output( 

2351 doc=( 

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

2353 "plane coordinates and chisq values." 

2354 ), 

2355 name="gbdesGlobalAstrometricFit_fitStars", 

2356 storageClass="ArrowNumpyDict", 

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

2358 ) 

2359 starCatalog = pipeBase.connectionTypes.Output( 

2360 doc=( 

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

2362 "fitProperMotion is True." 

2363 ), 

2364 name="gbdesGlobalAstrometricFit_starCatalog", 

2365 storageClass="ArrowNumpyDict", 

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

2367 ) 

2368 modelParams = pipeBase.connectionTypes.Output( 

2369 doc="WCS parameters and covariance.", 

2370 name="gbdesGlobalAstrometricFit_modelParams", 

2371 storageClass="ArrowNumpyDict", 

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

2373 ) 

2374 outputCameraModel = pipeBase.connectionTypes.Output( 

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

2376 name="gbdesAstrometricFit_cameraModel", 

2377 storageClass="ArrowNumpyDict", 

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

2379 ) 

2380 dcrCoefficients = pipeBase.connectionTypes.Output( 

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

2382 name="gbdesGlobalAstrometricFit_dcrCoefficients", 

2383 storageClass="ArrowNumpyDict", 

2384 ) 

2385 camera = pipeBase.connectionTypes.Output( 

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

2387 name="gbdesGlobalAstrometricFitCamera", 

2388 storageClass="Camera", 

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

2390 ) 

2391 

2392 def getSpatialBoundsConnections(self): 

2393 return ("inputVisitSummaries",) 

2394 

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

2396 super().__init__(config=config) 

2397 

2398 if not self.config.useColor: 

2399 self.inputs.remove("colorCatalog") 

2400 self.outputs.remove("dcrCoefficients") 

2401 if not self.config.saveModelParams: 

2402 self.outputs.remove("modelParams") 

2403 if not self.config.useInputCameraModel: 

2404 self.prerequisiteInputs.remove("inputCameraModel") 

2405 if not self.config.saveCameraModel: 

2406 self.outputs.remove("outputCameraModel") 

2407 if not self.config.saveCameraObject: 

2408 self.prerequisiteInputs.remove("inputCamera") 

2409 self.outputs.remove("camera") 

2410 

2411 

2412class GbdesGlobalAstrometricFitConfig( 

2413 GbdesAstrometricFitConfig, pipelineConnections=GbdesGlobalAstrometricFitConnections 

2414): 

2415 visitOverlap = pexConfig.Field( 

2416 dtype=float, 

2417 default=1.0, 

2418 doc=( 

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

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

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

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

2423 "fields for the astrometric fit." 

2424 ), 

2425 ) 

2426 

2427 

2428class GbdesGlobalAstrometricFitTask(GbdesAstrometricFitTask): 

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

2430 GBDES package. 

2431 

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

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

2434 hemisphere. 

2435 """ 

2436 

2437 ConfigClass = GbdesGlobalAstrometricFitConfig 

2438 _DefaultName = "gbdesAstrometricFit" 

2439 

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

2441 # We override runQuantum to set up the refObjLoaders 

2442 inputs = butlerQC.get(inputRefs) 

2443 

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

2445 

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

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

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

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

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

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

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

2453 inputIsolatedStarSourceTracts = np.array( 

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

2455 ) 

2456 inputIsolatedStarCatalogTracts = np.array( 

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

2458 ) 

2459 for tract in inputIsolatedStarCatalogTracts: 

2460 if tract not in inputIsolatedStarSourceTracts: 

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

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

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

2464 ) 

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

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

2467 ) 

2468 

2469 refConfig = LoadReferenceObjectsConfig() 

2470 if self.config.applyRefCatProperMotion: 

2471 refConfig.requireProperMotion = True 

2472 refObjectLoader = ReferenceObjectLoader( 

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

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

2475 config=refConfig, 

2476 log=self.log, 

2477 ) 

2478 if self.config.useColor: 

2479 colorCatalog = inputs.pop("colorCatalog") 

2480 else: 

2481 colorCatalog = None 

2482 

2483 try: 

2484 output = self.run( 

2485 **inputs, 

2486 instrumentName=instrumentName, 

2487 refObjectLoader=refObjectLoader, 

2488 colorCatalog=colorCatalog, 

2489 ) 

2490 except pipeBase.AlgorithmError as e: 

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

2492 # No partial outputs for butler to put 

2493 raise error from e 

2494 

2495 for outputRef in outputRefs.outputWcs: 

2496 visit = outputRef.dataId["visit"] 

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

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

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

2500 if self.config.saveModelParams: 

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

2502 if self.config.saveCameraModel: 

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

2504 if self.config.saveCameraObject: 

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

2506 if self.config.useColor: 

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

2508 if output.partialOutputs: 

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

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

2511 raise error from e 

2512 

2513 def run( 

2514 self, 

2515 inputVisitSummaries, 

2516 isolatedStarSources, 

2517 isolatedStarCatalogs, 

2518 instrumentName="", 

2519 refEpoch=None, 

2520 refObjectLoader=None, 

2521 inputCameraModel=None, 

2522 colorCatalog=None, 

2523 inputCamera=None, 

2524 ): 

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

2526 

2527 Parameters 

2528 ---------- 

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

2530 List of catalogs with per-detector summary information. 

2531 isolatedStarSources : `list` [`DeferredDatasetHandle`] 

2532 List of handles pointing to isolated star sources. 

2533 isolatedStarCatalog: `list` [`DeferredDatasetHandle`] 

2534 List of handles pointing to isolated star catalogs. 

2535 instrumentName : `str`, optional 

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

2537 refEpoch : `float`, optional 

2538 Epoch of the reference objects in MJD. 

2539 refObjectLoader : instance of 

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

2541 optional 

2542 Reference object loader instance. 

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

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

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

2546 Catalog containing object coordinates and magnitudes. 

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

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

2549 

2550 Returns 

2551 ------- 

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

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

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

2555 detector set by the new fitted WCS. 

2556 ``fitModel`` : `wcsfit.WCSFit` 

2557 Model-fitting object with final model parameters. 

2558 ``outputCatalog`` : `pyarrow.Table` 

2559 Catalog with fit residuals of all sources used. 

2560 ``starCatalog`` : `pyarrow.Table` 

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

2562 ``modelParams`` : `dict` 

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

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

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

2566 needed as input for future runs. 

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

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

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

2570 Camera object constructed from the per-detector model. 

2571 """ 

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

2573 

2574 # Get information about the extent of the input visits 

2575 fields, fieldRegions = self._prep_sky(inputVisitSummaries) 

2576 

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

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

2579 inputVisitSummaries, fieldRegions=fieldRegions 

2580 ) 

2581 

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

2583 medianEpoch = astropy.time.Time(exposureInfo.medianEpoch, format="jyear").mjd 

2584 allRefObjects, allRefCovariances = {}, {} 

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

2586 refObjects, refCovariance = self._load_refcat( 

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

2588 ) 

2589 allRefObjects[f] = refObjects 

2590 allRefCovariances[f] = refCovariance 

2591 

2592 associations, sourceDict = self._associate_from_isolated_sources( 

2593 isolatedStarSources, isolatedStarCatalogs, extensionInfo, allRefObjects 

2594 ) 

2595 

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

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

2598 # visit 

2599 inputYaml, mapTemplate = self.make_yaml( 

2600 inputVisitSummaries[0], 

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

2602 ) 

2603 

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

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

2606 # properly propagated. 

2607 loglevel = self.log.getEffectiveLevel() 

2608 if loglevel >= self.log.WARNING: 

2609 verbose = 0 

2610 elif loglevel == self.log.INFO: 

2611 verbose = 1 

2612 else: 

2613 verbose = 2 

2614 

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

2616 # isolated star sources plus the reference catalog. 

2617 wcsf = wcsfit.WCSFit( 

2618 fields, 

2619 instruments, 

2620 exposuresHelper, 

2621 extensionInfo.visitIndex, 

2622 extensionInfo.detectorIndex, 

2623 inputYaml, 

2624 extensionInfo.wcs, 

2625 associations.sequence, 

2626 associations.extn, 

2627 associations.obj, 

2628 sysErr=self.config.systematicError, 

2629 refSysErr=self.config.referenceSystematicError, 

2630 usePM=self.config.fitProperMotion, 

2631 verbose=verbose, 

2632 ) 

2633 

2634 # Add the science and reference sources 

2635 self._add_objects(wcsf, sourceDict, extensionInfo) 

2636 for f in fieldRegions.keys(): 

2637 self._add_ref_objects( 

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

2639 ) 

2640 if self.config.useColor: 

2641 self._add_color_objects(wcsf, colorCatalog) 

2642 

2643 # Do the WCS fit 

2644 try: 

2645 wcsf.fit( 

2646 reserveFraction=self.config.fitReserveFraction, 

2647 randomNumberSeed=self.config.fitReserveRandomSeed, 

2648 clipThresh=self.config.clipThresh, 

2649 clipFraction=self.config.clipFraction, 

2650 ) 

2651 except RuntimeError as e: 

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

2653 raise CholeskyError() from e 

2654 else: 

2655 raise 

2656 

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

2658 

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

2660 wcsf, 

2661 inputVisitSummaries, 

2662 exposureInfo, 

2663 mapTemplate, 

2664 extensionInfo, 

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

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

2667 ) 

2668 outputCatalog = wcsf.getOutputCatalog() 

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

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

2671 

2672 starCatalog = wcsf.getStarCatalog() 

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

2674 

2675 return pipeBase.Struct( 

2676 outputWcss=outputWcss, 

2677 fitModel=wcsf, 

2678 outputCatalog=outputCatalog, 

2679 starCatalog=starCatalog, 

2680 modelParams=modelParams, 

2681 cameraModelParams=cameraParams, 

2682 colorParams=colorParams, 

2683 camera=camera, 

2684 partialOutputs=partialOutputs, 

2685 ) 

2686 

2687 def _prep_sky(self, inputVisitSummaries): 

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

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

2690 convex hull around all of its component visits. 

2691 

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

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

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

2695 from separate groups overlap by more than this amount. 

2696 

2697 Paramaters 

2698 ---------- 

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

2700 List of catalogs with per-detector summary information. 

2701 

2702 Returns 

2703 ------- 

2704 fields : `wcsfit.Fields` 

2705 Object with field information. 

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

2707 Dictionary of regions encompassing each group of input visits, 

2708 keyed by an arbitrary index. 

2709 """ 

2710 allDetectorCorners = [] 

2711 mjds = [] 

2712 radecs = [] 

2713 radii = [] 

2714 for visSum in inputVisitSummaries: 

2715 detectorCorners = [ 

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

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

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

2719 ] 

2720 if len(detectorCorners) == 0: 

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

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

2723 # will get formally dropped elsewhere. 

2724 continue 

2725 allDetectorCorners.append(detectorCorners) 

2726 

2727 # Get center and approximate radius of field of view 

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

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

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

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

2732 radecs.append([ra, dec]) 

2733 radius = boundingCircle.getOpeningAngle() 

2734 radii.append(radius) 

2735 

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

2737 obsMJD = obsDate.get(obsDate.MJD) 

2738 mjds.append(obsMJD) 

2739 

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

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

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

2743 clustering = AgglomerativeClustering( 

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

2745 ) 

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

2747 

2748 medianMJD = np.median(mjds) 

2749 medianEpoch = astropy.time.Time(medianMJD, format="mjd").jyear 

2750 

2751 fieldNames = [] 

2752 fieldRAs = [] 

2753 fieldDecs = [] 

2754 epochs = [] 

2755 fieldRegions = {} 

2756 

2757 for i in range(clusters.n_clusters_): 

2758 fieldInd = clusters.labels_ == i 

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

2760 # field 

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

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

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

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

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

2766 

2767 fieldRegions[i] = hull 

2768 fieldNames.append(str(i)) 

2769 fieldRAs.append(ra) 

2770 fieldDecs.append(dec) 

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

2772 # positions are calculated for the same epoch. 

2773 epochs.append(medianEpoch) 

2774 

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

2776 

2777 return fields, fieldRegions 

2778 

2779 def _associate_from_isolated_sources( 

2780 self, isolatedStarSourceRefs, isolatedStarCatalogRefs, extensionInfo, refObjects 

2781 ): 

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

2783 and transform the combined isolated star sources and reference source 

2784 into the format needed for gbdes. 

2785 

2786 Parameters 

2787 ---------- 

2788 isolatedStarSourceRefs : `list` [`DeferredDatasetHandle`] 

2789 List of handles pointing to isolated star sources. 

2790 isolatedStarCatalogRefs: `list` [`DeferredDatasetHandle`] 

2791 List of handles pointing to isolated star catalogs. 

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

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

2794 ``visit`` : `np.ndarray` 

2795 Name of the visit for this extension. 

2796 ``detector`` : `np.ndarray` 

2797 Name of the detector for this extension. 

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

2799 Index of visit for this extension. 

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

2801 Index of the detector for this extension. 

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

2803 Initial WCS for this extension. 

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

2805 "SCIENCE" or "REFERENCE". 

2806 refObjects : `dict` 

2807 Dictionary of dictionaries containing the position and error 

2808 information of reference objects. 

2809 

2810 Returns 

2811 ------- 

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

2813 Struct containing the associations of sources with objects. 

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

2815 Dictionary containing the source centroids for each visit. 

2816 """ 

2817 sequences = [] 

2818 extensions = [] 

2819 object_indices = [] 

2820 

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

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

2823 

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

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

2826 sourceDict[visit][detector] = {"x": [], "y": [], "xCov": [], "yCov": [], "xyCov": []} 

2827 

2828 for isolatedStarCatalogRef, isolatedStarSourceRef in zip( 

2829 isolatedStarCatalogRefs, isolatedStarSourceRefs 

2830 ): 

2831 isolatedStarCatalog = isolatedStarCatalogRef.get(parameters={"columns": catalogColumns}) 

2832 isolatedStarSources = isolatedStarSourceRef.get(parameters={"columns": sourceColumns}) 

2833 if len(isolatedStarCatalog) == 0: 

2834 # This is expected when only one visit overlaps with a given 

2835 # tract, meaning that no sources can be associated. 

2836 self.log.debug( 

2837 "Skipping tract %d, which has no associated isolated stars", 

2838 isolatedStarCatalogRef.dataId["tract"], 

2839 ) 

2840 continue 

2841 

2842 # Match the reference stars to the existing isolated stars, then 

2843 # insert the reference stars into the isolated star sources. 

2844 allVisits = np.copy(isolatedStarSources["visit"]) 

2845 allDetectors = np.copy(isolatedStarSources["detector"]) 

2846 allObjectIndices = np.copy(isolatedStarSources["obj_index"]) 

2847 issIndices = np.copy(isolatedStarSources.index) 

2848 for f, regionRefObjects in refObjects.items(): 

2849 # Use the same matching technique that is done in 

2850 # isolatedStarAssociation and fgcmBuildFromIsolatedStars. 

2851 with Matcher( 

2852 isolatedStarCatalog["ra"].to_numpy(), isolatedStarCatalog["dec"].to_numpy() 

2853 ) as matcher: 

2854 idx, idx_isoStarCat, idx_refObjects, d = matcher.query_radius( 

2855 np.array(regionRefObjects["ra"]), 

2856 np.array(regionRefObjects["dec"]), 

2857 self.config.matchRadius / 3600.0, 

2858 return_indices=True, 

2859 ) 

2860 

2861 refSort = np.searchsorted(isolatedStarSources["obj_index"], idx_isoStarCat) 

2862 refDetector = np.ones(len(idx_isoStarCat)) * -1 

2863 # The "visit" for the reference catalogs is the field times -1. 

2864 refVisit = np.ones(len(idx_isoStarCat)) * f * -1 

2865 

2866 allVisits = np.insert(allVisits, refSort, refVisit) 

2867 allDetectors = np.insert(allDetectors, refSort, refDetector) 

2868 allObjectIndices = np.insert(allObjectIndices, refSort, idx_isoStarCat) 

2869 issIndices = np.insert(issIndices, refSort, idx_refObjects) 

2870 

2871 # Loop through the associated sources to convert them to the gbdes 

2872 # format, which requires the extension index, the source's index in 

2873 # the input table, and a sequence number corresponding to the 

2874 # object with which it is associated. 

2875 sequence = 0 

2876 obj_index = allObjectIndices[0] 

2877 for visit, detector, row, obj_ind in zip(allVisits, allDetectors, issIndices, allObjectIndices): 

2878 extensionIndex = np.flatnonzero( 

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

2880 ) 

2881 if len(extensionIndex) == 0: 

2882 # This happens for runs where you are not using all the 

2883 # visits overlapping a given tract that were included in 

2884 # the isolated star association task." 

2885 continue 

2886 else: 

2887 extensionIndex = extensionIndex[0] 

2888 

2889 extensions.append(extensionIndex) 

2890 if visit <= 0: 

2891 object_indices.append(row) 

2892 else: 

2893 object_indices.append(len(sourceDict[visit][detector]["x"])) 

2894 source = isolatedStarSources.loc[row] 

2895 sourceDict[visit][detector]["x"].append(source["x"]) 

2896 sourceDict[visit][detector]["y"].append(source["y"]) 

2897 xCov = source["xErr"] ** 2 

2898 yCov = source["yErr"] ** 2 

2899 xyCov = source["ixy"] * (xCov + yCov) / (source["ixx"] + source["iyy"]) 

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

2901 sourceDict[visit][detector]["xCov"].append(xCov) 

2902 sourceDict[visit][detector]["yCov"].append(yCov) 

2903 sourceDict[visit][detector]["xyCov"].append(xyCov) 

2904 if obj_ind != obj_index: 

2905 sequence = 0 

2906 sequences.append(sequence) 

2907 obj_index = obj_ind 

2908 sequence += 1 

2909 else: 

2910 sequences.append(sequence) 

2911 sequence += 1 

2912 

2913 associations = pipeBase.Struct(extn=extensions, obj=object_indices, sequence=sequences) 

2914 return associations, sourceDict 

2915 

2916 def _add_objects(self, wcsf, sourceDict, extensionInfo): 

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

2918 

2919 Parameters 

2920 ---------- 

2921 wcsf : `wcsfit.WCSFit` 

2922 WCS-fitting object. 

2923 sourceDict : `dict` 

2924 Dictionary containing the source centroids for each visit. 

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

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

2927 ``visit`` : `np.ndarray` 

2928 Name of the visit for this extension. 

2929 ``detector`` : `np.ndarray` 

2930 Name of the detector for this extension. 

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

2932 Index of visit for this extension. 

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

2934 Index of the detector for this extension. 

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

2936 Initial WCS for this extension. 

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

2938 "SCIENCE" or "REFERENCE". 

2939 """ 

2940 for visit, visitSources in sourceDict.items(): 

2941 # Visit numbers equal or below zero connote the reference catalog. 

2942 if visit <= 0: 

2943 # This "visit" number corresponds to a reference catalog. 

2944 continue 

2945 

2946 for detector, sourceCat in visitSources.items(): 

2947 extensionIndex = np.flatnonzero( 

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

2949 )[0] 

2950 

2951 d = { 

2952 "x": np.array(sourceCat["x"]), 

2953 "y": np.array(sourceCat["y"]), 

2954 "xCov": np.array(sourceCat["xCov"]), 

2955 "yCov": np.array(sourceCat["yCov"]), 

2956 "xyCov": np.array(sourceCat["xyCov"]), 

2957 } 

2958 wcsf.setObjects( 

2959 extensionIndex, 

2960 d, 

2961 "x", 

2962 "y", 

2963 ["xCov", "yCov", "xyCov"], 

2964 defaultColor=self.config.referenceColor, 

2965 ) 

2966 

2967 

2968class GbdesGlobalAstrometricMultibandFitConnections( 

2969 GbdesGlobalAstrometricFitConnections, 

2970 dimensions=("instrument",), 

2971): 

2972 outputCatalog = pipeBase.connectionTypes.Output( 

2973 doc=( 

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

2975 "plane coordinates and chisq values." 

2976 ), 

2977 name="gbdesGlobalAstrometricMultibandFit_fitStars", 

2978 storageClass="ArrowNumpyDict", 

2979 dimensions=("instrument",), 

2980 ) 

2981 starCatalog = pipeBase.connectionTypes.Output( 

2982 doc=( 

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

2984 "fitProperMotion is True." 

2985 ), 

2986 name="gbdesGlobalAstrometricMultibandFit_starCatalog", 

2987 storageClass="ArrowNumpyDict", 

2988 dimensions=("instrument",), 

2989 ) 

2990 modelParams = pipeBase.connectionTypes.Output( 

2991 doc="WCS parameters and covariance.", 

2992 name="gbdesGlobalAstrometricMultibandFit_modelParams", 

2993 storageClass="ArrowNumpyDict", 

2994 dimensions=("instrument",), 

2995 ) 

2996 

2997 

2998class GbdesGlobalAstrometricMultibandFitConfig( 

2999 GbdesAstrometricFitConfig, 

3000 pipelineConnections=GbdesGlobalAstrometricMultibandFitConnections, 

3001): 

3002 """Configuration for the GbdesGlobalAstrometricMultibandFitTask""" 

3003 

3004 pass 

3005 

3006 

3007class GbdesGlobalAstrometricMultibandFitTask(GbdesGlobalAstrometricFitTask): 

3008 """Calibrate the WCS across multiple visits in multiple filters and 

3009 multiple fields using the GBDES package. 

3010 """ 

3011 

3012 ConfigClass = GbdesGlobalAstrometricMultibandFitConfig 

3013 _DefaultName = "gbdesAstrometricMultibandFit"