Coverage for python/lsst/drp/tasks/fit_turbulence.py: 56%

257 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-09 11:09 +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 

23 

24import astropy.units as u 

25import astshim as ast 

26import matplotlib.pyplot as plt 

27import numpy as np 

28import treecorr 

29import treegp 

30from astropy.table import Table 

31from scipy.interpolate import RectBivariateSpline 

32 

33import lsst.afw.geom as afwgeom 

34import lsst.afw.table 

35import lsst.pex.config as pexConfig 

36import lsst.pipe.base as pipeBase 

37 

38# We need to explicitly turn off multiprocessing in treecorr which is used 

39# by treegp. 

40treecorr.set_max_omp_threads(1) 

41 

42__all__ = [ 

43 "GaussianProcessesTurbulenceFitConnections", 

44 "GaussianProcessesTurbulenceFitConfig", 

45 "GaussianProcessesTurbulenceFitTask", 

46] 

47 

48 

49def plot_visit(x, y, dx, dy, predx, predy): 

50 """Utility function for plotting Gaussian Processes results. 

51 

52 Parameters 

53 ---------- 

54 x : `np.ndarray` 

55 x-direction coordinates. 

56 y : `np.ndarray` 

57 y-direction coordinates. 

58 dx : `np.ndarray` 

59 x-direction residuals to be fit. 

60 dy : `np.ndarray` 

61 x-direction residuals to be fit. 

62 predx : `np.ndarray` 

63 x-direction prediction. 

64 predy : `np.ndarray` 

65 y-direction prediction. 

66 

67 Returns 

68 ------- 

69 fig : `matplotlib.pyplot.Figure` 

70 Figure showing input data, Gaussian Processes prediction, and E and 

71 B-modes. 

72 """ 

73 

74 xie, xib, logr = treegp.comp_eb_treecorr(x, y, dx, dy, rmin=20 / 3600, rmax=0.6, dlogr=0.3) 

75 xie_resid, xib_resid, logr_resid = treegp.comp_eb_treecorr( 

76 x, y, dx - predx, dy - predy, rmin=20 / 3600, rmax=0.6, dlogr=0.3 

77 ) 

78 

79 residualLimit = np.nanstd(dx) 

80 

81 fig, subs = plt.subplot_mosaic( 

82 [["dx", "predx", "residx", "eb"], ["dy", "predy", "residy", "eb"]], 

83 figsize=(15, 8), 

84 layout="constrained", 

85 ) 

86 plt.subplots_adjust(wspace=0.3, right=0.99, left=0.05) 

87 im = subs["dx"].scatter(x, y, c=dx, vmin=-residualLimit, vmax=residualLimit, cmap=plt.cm.seismic, s=1) 

88 subs["dy"].scatter(x, y, c=dy, vmin=-residualLimit, vmax=residualLimit, cmap=plt.cm.seismic, s=1) 

89 

90 subs["predx"].scatter(x, y, c=predx, vmin=-residualLimit, vmax=residualLimit, cmap=plt.cm.seismic, s=1) 

91 subs["predy"].scatter(x, y, c=predy, vmin=-residualLimit, vmax=residualLimit, cmap=plt.cm.seismic, s=1) 

92 

93 subs["residx"].scatter( 

94 x, y, c=dx - predx, vmin=-residualLimit, vmax=residualLimit, cmap=plt.cm.seismic, s=1 

95 ) 

96 subs["residy"].scatter( 

97 x, y, c=dy - predy, vmin=-residualLimit, vmax=residualLimit, cmap=plt.cm.seismic, s=1 

98 ) 

99 

100 cb = fig.colorbar( 

101 im, ax=[subs["dx"], subs["dy"], subs["predx"], subs["predy"], subs["residx"], subs["residy"]] 

102 ) 

103 

104 subs["eb"].scatter(np.exp(logr) * 60, xie, c="b", label="E-mode") 

105 subs["eb"].scatter(np.exp(logr) * 60, xib, c="r", label="B-mode") 

106 

107 subs["eb"].scatter( 

108 np.exp(logr_resid) * 60, xie_resid, c="b", marker="+", label="E-mode after GP correction" 

109 ) 

110 subs["eb"].scatter( 

111 np.exp(logr_resid) * 60, xib_resid, c="r", marker="+", label="B-mode after GP correction" 

112 ) 

113 subs["eb"].legend() 

114 subs["eb"].grid(True) 

115 

116 subs["dx"].set_aspect("equal") 

117 subs["dy"].set_aspect("equal") 

118 subs["predx"].set_aspect("equal") 

119 subs["predy"].set_aspect("equal") 

120 subs["residx"].set_aspect("equal") 

121 subs["residy"].set_aspect("equal") 

122 subs["dy"].set_xlabel("x (degree)") 

123 subs["predy"].set_xlabel("x (degree)") 

124 subs["residy"].set_xlabel("x (degree)") 

125 subs["dy"].set_ylabel("y (degree)") 

126 subs["dx"].set_ylabel("y (degree)") 

127 

128 subs["dx"].set_title(r"$\delta$x") 

129 subs["predx"].set_title("GP prediction") 

130 subs["residx"].set_title("Residual") 

131 

132 subs["dy"].set_title(r"$\delta$y") 

133 subs["predy"].set_title("GP prediction") 

134 subs["residy"].set_title("Residual") 

135 

136 cb.set_label("mas") 

137 

138 subs["eb"].set_title("E and B modes") 

139 subs["eb"].set_ylabel(r"$\xi_{E/B}$ (mas$^2$)") 

140 subs["eb"].set_xlabel(r"$\Delta \theta$ (arcmin)") 

141 

142 return fig 

143 

144 

145class SingularMatrixError(pipeBase.AlgorithmError): 

146 """Raised if the Gaussian Processes fit raises a Singular Matrix linear 

147 algebra error.""" 

148 

149 def __init__(self, nSources) -> None: 

150 super().__init__("The Gaussian Processes fit failed with a singular matrix linear algebra error.") 

151 self._nSources = nSources 

152 

153 @property 

154 def metadata(self): 

155 return { 

156 "nSources": self._nSources, 

157 } 

158 

159 

160class NotPositiveDefiniteMatrixError(pipeBase.AlgorithmError): 

161 """Raised if the Gaussian Processes fit raises a not positive definite 

162 linear algebra error.""" 

163 

164 def __init__(self, nSources) -> None: 

165 super().__init__( 

166 "The Gaussian Processes fit failed with a not-positive-definite linear algebra error." 

167 ) 

168 self._nSources = nSources 

169 

170 @property 

171 def metadata(self): 

172 return { 

173 "nSources": self._nSources, 

174 } 

175 

176 

177class GaussianProcessesTurbulenceFitConnections( 

178 pipeBase.PipelineTaskConnections, 

179 dimensions=("instrument", "visit", "healpix3"), 

180 defaultTemplates={ 

181 "inputName": "gbdesHealpix3AstrometricFit", 

182 }, 

183): 

184 inputWcs = pipeBase.connectionTypes.Input( 

185 doc=( 

186 "Per-healpix, per-visit world coordinate systems derived from the fitted model." 

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

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

189 ), 

190 name="{inputName}SkyWcsCatalog", 

191 storageClass="ExposureCatalog", 

192 dimensions=("instrument", "visit", "healpix3"), 

193 ) 

194 inputPositions = pipeBase.connectionTypes.Input( 

195 doc=( 

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

197 "plane coordinates and chisq values." 

198 ), 

199 name="{inputName}_fitStars", 

200 storageClass="ArrowAstropy", 

201 dimensions=("instrument", "healpix3", "physical_filter"), 

202 deferLoad=True, 

203 ) 

204 outputWcs = pipeBase.connectionTypes.Output( 

205 doc=( 

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

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

208 "for fast lookups of a detector." 

209 ), 

210 name="turbulenceCorrectedSkyWcsCatalog", 

211 storageClass="ExposureCatalog", 

212 dimensions=("instrument", "visit", "healpix3"), 

213 ) 

214 hyperparameters = pipeBase.connectionTypes.Output( 

215 doc="Best fit hyperparameters for the Gaussian Processes fit.", 

216 name="turbulence_fit_hyperparameters", 

217 storageClass="ArrowAstropy", 

218 dimensions=("instrument", "visit", "healpix3"), 

219 ) 

220 

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

222 super().__init__(config=config) 

223 

224 if not self.config.healpix: 

225 self.dimensions.remove("healpix3") 

226 if self.config.healpix is None: 

227 extra_dimensions = [] 

228 else: 

229 extra_dimensions = ["tract", "skymap"] 

230 self.dimensions.update(extra_dimensions) 

231 self.inputWcs = dataclasses.replace( 

232 self.inputWcs, dimensions=["instrument", "visit"] + extra_dimensions 

233 ) 

234 self.inputPositions = dataclasses.replace( 

235 self.inputPositions, dimensions=["instrument", "band", "physical_filter"] + extra_dimensions 

236 ) 

237 self.outputWcs = dataclasses.replace( 

238 self.outputWcs, dimensions=["instrument", "visit"] + extra_dimensions 

239 ) 

240 self.hyperparameters = dataclasses.replace( 

241 self.hyperparameters, dimensions=["instrument", "visit"] + extra_dimensions 

242 ) 

243 

244 

245class GaussianProcessesTurbulenceFitConfig( 

246 pipeBase.PipelineTaskConfig, pipelineConnections=GaussianProcessesTurbulenceFitConnections 

247): 

248 initKernel = pexConfig.Field( 

249 dtype=str, 

250 doc="The type of function that will be used to modeled spatial correlation.", 

251 default="15**2 * AnisotropicVonKarman(invLam=array([[1./0.8**2,0],[0,1./0.8**2]]))", 

252 ) 

253 initAnisotropicCorrelationLength = pexConfig.ListField( 

254 dtype=float, 

255 doc=( 

256 "The initial parameters for fiting the anisotropic correlation length. p0[0] is equivalent of " 

257 "the isotropic correlation length in degrees, and p0[1]/p0[2] are ellipticity parameters and are " 

258 "mathematically equivalent to e1/e2 in weak-lensing. p0[1]/p0[2] must be in the range [-1,1], " 

259 "where 0 means the correlation is isotropic." 

260 ), 

261 default=[1, -0.2, -0.2], 

262 ) 

263 correlationSeparationMin = pexConfig.Field( 

264 dtype=float, 

265 doc="Minimum distance separation in degrees in the computation of the 2-point correlation function.", 

266 default=0.0, 

267 optional=True, 

268 ) 

269 correlationSeparationMax = pexConfig.Field( 

270 dtype=float, 

271 doc="Maximum distance separation in degrees in the computation of the 2-point correlation function.", 

272 default=0.3, 

273 optional=True, 

274 ) 

275 maxTrainingPoints = pexConfig.Field( 

276 dtype=int, 

277 doc="Maximum number of points to use in the Gaussian Processes training.", 

278 default=10000, 

279 ) 

280 pixelSize = pexConfig.Field( 

281 dtype=float, 

282 doc="Pixel size in arcseconds.", 

283 default=0.2, 

284 ) 

285 healpix = pexConfig.Field( 

286 dtype=bool, 

287 doc="Use input WCS calculated over healpix-based region. If false, use tract-based WCS.", 

288 default=True, 

289 optional=True, 

290 ) 

291 splineDegree = pexConfig.Field( 

292 dtype=int, 

293 doc="Degree of the spline expressing Gaussian Processes prediction.", 

294 default=4, 

295 ) 

296 splineNNodes = pexConfig.Field( 

297 dtype=int, 

298 doc="Number of nodes to use for the spline expressing Gaussian Processes prediction.", 

299 default=30, 

300 ) 

301 splineBuffer = pexConfig.Field( 

302 dtype=float, 

303 doc="Minimum distance in degrees to extend spline map outside the detector boundary.", 

304 default=0.1, 

305 ) 

306 

307 

308class GaussianProcessesTurbulenceFitTask(pipeBase.PipelineTask): 

309 """Run Gaussian Processes on astrometric residuals with the assumption that 

310 they are due to atmospheric turbulence.""" 

311 

312 ConfigClass = GaussianProcessesTurbulenceFitConfig 

313 _DefaultName = "gaussianProcessesTurbulenceFit" 

314 

315 def run(self, inputWcs, inputPositions): 

316 """Run Gaussian Processes on position residuals and subtract the fitted 

317 Gaussian Processes prediction from the WCS to account for atmospheric 

318 turbulence. 

319 

320 Parameters 

321 ---------- 

322 inputWcs : `lsst.afw.table.ExposureCatalog` 

323 Catalog with WCSs for each detector of the input exposure. 

324 inputPositions : `astropy.table.Table` 

325 Catalog of input positions with residuals to the current best fit. 

326 

327 Returns 

328 ------- 

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

330 ``outputWcs`` : `lsst.afw.table.ExposureCatalog` 

331 Catalog with WCS after inserting the correction for atmospheric 

332 turbulence. 

333 ``hyperparameters`` : `astropy.table.Table` 

334 Table of best-fit hyperparameters in x and y-directions. 

335 """ 

336 

337 visit = inputWcs[0]["visit"] 

338 

339 inputPositions = inputPositions.get( 

340 parameters={ 

341 "columns": [ 

342 "xworld", 

343 "yworld", 

344 "xresw", 

345 "yresw", 

346 "exposureName", 

347 "xpix", 

348 "ypix", 

349 "deviceName", 

350 "clip", 

351 "covTotalW_00", 

352 "covTotalW_11", 

353 ] 

354 } 

355 ) 

356 

357 visitPositions = inputPositions[ 

358 (inputPositions["exposureName"] == str(visit)) & ~inputPositions["clip"] 

359 ] 

360 

361 gpx, gpy, trainInd, testInd, hyperparameters = self.runGP(inputWcs, visitPositions) 

362 

363 try: 

364 self.evaluate(gpx, gpy, visitPositions, trainInd, testInd, inputWcs) 

365 wcsWithSpline = self.addGPToWcs(gpx, gpy, inputWcs) 

366 except np.linalg.LinAlgError as e: 

367 if "not positive definite" in str(e): 

368 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

369 NotPositiveDefiniteMatrixError(trainInd.sum()), 

370 self, 

371 log=self.log, 

372 ) 

373 raise error from e 

374 else: 

375 raise 

376 

377 return pipeBase.Struct(outputWcs=wcsWithSpline, hyperparameters=hyperparameters) 

378 

379 def runGP(self, inputWcs, positions): 

380 """Run Gaussian Processes in tangent plane coordinates. 

381 

382 Parameters 

383 ---------- 

384 inputWcs : `lsst.afw.table.ExposureCatalog` 

385 Catalog with WCSs for each detector of the input exposure. 

386 inputPositions : `astropy.table.Table` 

387 Catalog of input positions with residuals to the current best fit. 

388 

389 Returns 

390 ------- 

391 gpx : `treegp.gp_interp.GPInterpolation` 

392 Gaussian Processes interpolator for x-direction residuals. 

393 gpy : `treegp.gp_interp.GPInterpolation` 

394 Gaussian Processes interpolator for y-direction residuals. 

395 trainInds : `numpy.ndarray` 

396 Array of indices for points used in training. 

397 testInds : `numpy.ndarray` 

398 Array of indices for points not used in training. 

399 """ 

400 dx = positions["xresw"] 

401 dy = positions["yresw"] 

402 dxErr = positions["covTotalW_00"] ** 0.5 

403 dyErr = positions["covTotalW_11"] ** 0.5 

404 

405 # Get tangent plane coordinates for input points 

406 allTPCoords = np.zeros((len(positions), 2)) 

407 for detector in inputWcs: 

408 detId = detector["id"] 

409 detWCS = detector.wcs 

410 detInd = positions["deviceName"].astype(int) == detId 

411 detectorSources = positions[detInd] 

412 

413 tangentPlaneToSky = detWCS.getFrameDict().getMapping("PIXELS", "IWC") 

414 tangentPlaneCoords = tangentPlaneToSky.applyForward( 

415 np.array([detectorSources["xpix"], detectorSources["ypix"]]) 

416 ) 

417 allTPCoords[detInd] = tangentPlaneCoords.T 

418 

419 # Choose a random subset for training. 

420 rng = np.random.default_rng(1234) 

421 nPoints = len(allTPCoords) 

422 nTrain = min([nPoints, self.config.maxTrainingPoints]) 

423 perm = rng.permutation(np.arange(nPoints)) 

424 trainInds = perm[:nTrain] 

425 testInds = perm[nTrain:] 

426 

427 # Solve Gaussian Processes in dx direction. 

428 gpx = treegp.GPInterpolation( 

429 kernel=self.config.initKernel, 

430 optimizer="anisotropic", 

431 normalize=True, 

432 nbins=21, 

433 min_sep=self.config.correlationSeparationMin, 

434 max_sep=self.config.correlationSeparationMax, 

435 p0=self.config.initAnisotropicCorrelationLength, 

436 ) 

437 

438 gpx.initialize(allTPCoords[trainInds], dx[trainInds], y_err=dxErr[trainInds]) 

439 

440 # Solve Gaussian Processes in dy direction. 

441 gpy = treegp.GPInterpolation( 

442 kernel=self.config.initKernel, 

443 optimizer="anisotropic", 

444 normalize=True, 

445 nbins=21, 

446 min_sep=self.config.correlationSeparationMin, 

447 max_sep=self.config.correlationSeparationMax, 

448 p0=self.config.initAnisotropicCorrelationLength, 

449 ) 

450 

451 gpy.initialize(allTPCoords[trainInds], dy[trainInds], y_err=dyErr[trainInds]) 

452 

453 try: 

454 gpx.solve() 

455 gpy.solve() 

456 except np.linalg.LinAlgError as e: 

457 if "Singular matrix" in str(e): 457 ↛ 464line 457 didn't jump to line 464 because the condition on line 457 was always true

458 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

459 SingularMatrixError(len(allTPCoords[trainInds])), 

460 self, 

461 log=self.log, 

462 ) 

463 raise error from e 

464 elif "not positive definite" in str(e): 

465 error = pipeBase.AnnotatedPartialOutputsError.annotate( 

466 NotPositiveDefiniteMatrixError(len(allTPCoords[trainInds])), 

467 self, 

468 log=self.log, 

469 ) 

470 raise error from e 

471 else: 

472 raise 

473 

474 hyperparameters = Table( 

475 {"x": np.array(gpx._optimizer._results_robust), "y": np.array(gpy._optimizer._results_robust)} 

476 ) 

477 

478 return gpx, gpy, trainInds, testInds, hyperparameters 

479 

480 def predict(self, gpx, gpy, inputWcs, sourceCatalog): 

481 """Get the positions for sources after correction for atmospheric 

482 turbulence. 

483 

484 Parameters 

485 ---------- 

486 gpx : `treegp.gp_interp.GPInterpolation` 

487 Gaussian Processes interpolator for x-direction residuals. 

488 gpy : `treegp.gp_interp.GPInterpolation` 

489 Gaussian Processes interpolator for y-direction residuals. 

490 inputWcs : `lsst.afw.table.ExposureCatalog` 

491 Catalog with WCSs for each detector of the input exposure. 

492 inputPositions : `astropy.table.Table` 

493 Catalog of input positions with residuals to the current best fit. 

494 

495 Returns 

496 ------- 

497 outCat : `astropy.table.Table` 

498 Catalog matching `inputPositions`, with `coord_ra` and `coord_dec` 

499 columns corrected for atmospheric turbulence. 

500 """ 

501 correctedCoordinates = np.zeros((len(sourceCatalog), 2)) 

502 prediction = np.zeros((len(sourceCatalog), 2)) 

503 allTPCoords = np.zeros((len(sourceCatalog), 2)) 

504 allCoords = np.zeros((len(sourceCatalog), 2)) 

505 

506 for detector in inputWcs: 

507 detId = detector["id"] 

508 detWCS = detector.wcs 

509 detInd = sourceCatalog["detector"] == detId 

510 detectorSources = sourceCatalog[detInd] 

511 

512 # The Gaussian Processes is fit on the tangent plane coordinates, 

513 # so we must transform points to the tangent plane, then subtract 

514 # the effect of atmospheric turbulence, then transform the tangent 

515 # plane coordinates to sky coordinates. 

516 initialSky = detWCS.pixelToSkyArray(detectorSources["x"], detectorSources["y"]) 

517 allCoords[detInd] = np.array(initialSky).T 

518 tangentPlaneToSky = detWCS.getFrameDict().getMapping("IWC", "SKY") 

519 tangentPlaneCoords = tangentPlaneToSky.applyInverse(np.array(initialSky)).T 

520 allTPCoords[detInd] = tangentPlaneCoords 

521 

522 xPred = gpx.predict(tangentPlaneCoords) 

523 xPrediction = (xPred * u.mas).to(u.degree) 

524 yPred = gpy.predict(tangentPlaneCoords) 

525 yPrediction = (yPred * u.mas).to(u.degree) 

526 prediction[detInd, 0] = xPred 

527 prediction[detInd, 1] = yPred 

528 

529 correctedTangentPlaneX = tangentPlaneCoords[:, 0] * u.degree - xPrediction 

530 correctedTangentPlaneY = tangentPlaneCoords[:, 1] * u.degree - yPrediction 

531 correctedSkyCoords = tangentPlaneToSky.applyForward( 

532 np.array([correctedTangentPlaneX, correctedTangentPlaneY]) 

533 ) 

534 correctedCoordinates[detInd] = ((correctedSkyCoords.T) * u.radian).to(u.degree).value 

535 

536 outCat = sourceCatalog.copy() 

537 outCat["coord_ra"] = correctedCoordinates[:, 0] 

538 outCat["coord_dec"] = correctedCoordinates[:, 1] 

539 

540 return outCat 

541 

542 def addGPToWcs(self, gpx, gpy, inputWcs): 

543 """Convert Gaussian Processes prediction to a spline, and insert it in 

544 the WCS for each detector. 

545 

546 Parameters 

547 ---------- 

548 gpx : `treegp.gp_interp.GPInterpolation` 

549 Gaussian Processes interpolator for x-direction residuals. 

550 gpy : `treegp.gp_interp.GPInterpolation` 

551 Gaussian Processes interpolator for y-direction residuals. 

552 inputWcs : `lsst.afw.table.ExposureCatalog` 

553 Catalog with WCSs for each detector of the input exposure. 

554 

555 Returns 

556 ------- 

557 catalog : `lsst.afw.table.ExposureCatalog` 

558 Exposure catalog with the WCS set to the existing WCS plus the 

559 gaussian processes fit. 

560 """ 

561 pixelFrame = ast.Frame(2, "Domain=PIXELS") 

562 tpFrame = ast.Frame(2, "Domain=TP") 

563 iwcFrame = ast.Frame(2, "Domain=IWC") 

564 

565 # Set up the schema for the output catalogs 

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

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

568 

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

570 catalog.resize(len(inputWcs)) 

571 catalog["visit"] = inputWcs["visit"] 

572 

573 for d, detectorRow in enumerate(inputWcs): 

574 detId = detectorRow.getId() 

575 catalog[d].setId(detId) 

576 

577 # Make a grid of points in tangent plane coordinates. 

578 bbox = detectorRow.getBBox() 

579 catalog[d].setBBox(bbox) 

580 corners = np.array( 

581 [ 

582 [bbox.getBeginX(), bbox.getEndX(), bbox.getEndX(), bbox.getBeginX()], 

583 [bbox.getBeginY(), bbox.getBeginY(), bbox.getEndY(), bbox.getEndY()], 

584 ] 

585 ).astype(float) 

586 

587 initWcsRow = inputWcs.find(detId) 

588 pixToTPMap = initWcsRow.wcs.getFrameDict().getMapping("PIXELS", "IWC") 

589 tpToSky = initWcsRow.wcs.getFrameDict().getMapping("IWC", "SKY") 

590 skyFrame = initWcsRow.wcs.getFrameDict().getFrame("SKY") 

591 tangentPlaneX, tangentPlaneY = pixToTPMap.applyForward(corners) 

592 

593 xs = np.linspace( 

594 tangentPlaneX.min() - self.config.splineBuffer, 

595 tangentPlaneX.max() + self.config.splineBuffer, 

596 self.config.splineNNodes, 

597 ) 

598 ys = np.linspace( 

599 tangentPlaneY.min() - self.config.splineBuffer, 

600 tangentPlaneY.max() + self.config.splineBuffer, 

601 self.config.splineNNodes, 

602 ) 

603 

604 xx, yy = np.meshgrid(xs, ys) 

605 inArray = np.array([xx.ravel(), yy.ravel()]).T 

606 

607 # Get Gaussian Processes prediction on grid and fit spline to it. 

608 xPred = (gpx.predict(inArray) * u.mas).to(u.degree).value 

609 

610 splineX = RectBivariateSpline( 

611 xs, 

612 ys, 

613 (xx - xPred.reshape(self.config.splineNNodes, self.config.splineNNodes)).T, 

614 s=0, 

615 kx=self.config.splineDegree - 1, 

616 ky=self.config.splineDegree - 1, 

617 ) 

618 (tx, ty) = splineX.get_knots() 

619 coeffsX = splineX.get_coeffs() 

620 

621 yPred = (gpy.predict(inArray) * u.mas).to(u.degree).value 

622 splineY = RectBivariateSpline( 

623 xs, 

624 ys, 

625 (yy - yPred.reshape(self.config.splineNNodes, self.config.splineNNodes)).T, 

626 s=0, 

627 kx=self.config.splineDegree - 1, 

628 ky=self.config.splineDegree - 1, 

629 ) 

630 coeffsY = splineY.get_coeffs() 

631 

632 # Turn spline into AST object and insert in new WCS. 

633 splineMap = ast.SplineMap( 

634 self.config.splineDegree, 

635 self.config.splineDegree, 

636 self.config.splineNNodes, 

637 self.config.splineNNodes, 

638 tx, 

639 ty, 

640 coeffsX, 

641 coeffsY, 

642 options="OutUnit=1", 

643 ) 

644 

645 newFrameDict = ast.FrameDict(pixelFrame) 

646 newFrameDict.addFrame("PIXELS", pixToTPMap, tpFrame) 

647 newFrameDict.addFrame("TP", splineMap, iwcFrame) 

648 newFrameDict.addFrame("IWC", tpToSky, skyFrame) 

649 outWcs = afwgeom.SkyWcs(newFrameDict) 

650 catalog[d].setWcs(outWcs) 

651 

652 return catalog 

653 

654 def evaluate(self, gpx, gpy, positions, trainInd, testInd, inputWcs, makeValidationPlot=False): 

655 """Calculate E and B-modes in the 2-point correlation function before 

656 and after correcting for atmospheric turbulence, and validate 

657 prediction on some of the test data. 

658 

659 Parameters 

660 ---------- 

661 gpx : `treegp.gp_interp.GPInterpolation` 

662 Gaussian Processes interpolator for x-direction residuals. 

663 gpy : `treegp.gp_interp.GPInterpolation` 

664 Gaussian Processes interpolator for y-direction residuals. 

665 positions : `astropy.table.Table` 

666 Catalog of input positions with residuals to the best fit. 

667 trainInds : `numpy.ndarray` 

668 Array of indices for points used in training. 

669 testInds : `numpy.ndarray` 

670 Array of indices for points not used in training. 

671 inputWcs : `lsst.afw.table.ExposureCatalog` 

672 Catalog with WCSs for each detector of the input exposure. 

673 makeValidationPlot : `bool`, optional 

674 Whether to make a plot showing the prediction on the validation 

675 data. 

676 """ 

677 dx = positions["xresw"] 

678 dy = positions["yresw"] 

679 

680 # Get tangent plane coordinates for input points 

681 tpCoords = np.zeros((len(positions), 2)) 

682 for detector in inputWcs: 

683 detId = detector["id"] 

684 detWCS = detector.wcs 

685 detInd = positions["deviceName"].astype(int) == detId 

686 detectorSources = positions[detInd] 

687 

688 tangentPlaneToSky = detWCS.getFrameDict().getMapping("PIXELS", "IWC") 

689 tangentPlaneCoords = tangentPlaneToSky.applyForward( 

690 np.array([detectorSources["xpix"], detectorSources["ypix"]]) 

691 ) 

692 tpCoords[detInd] = tangentPlaneCoords.T 

693 

694 # Calculate E/B modes before and after Gaussian Processes correction. 

695 xPredict = gpx.predict(tpCoords[trainInd]) 

696 yPredict = gpy.predict(tpCoords[trainInd]) 

697 xie, xib, logr = treegp.comp_eb_treecorr( 

698 tpCoords[trainInd, 0], 

699 tpCoords[trainInd, 1], 

700 dx[trainInd], 

701 dy[trainInd], 

702 rmin=20 / 3600, 

703 rmax=0.6, 

704 dlogr=0.3, 

705 ) 

706 start, stop = np.searchsorted(np.exp(logr), [0, 15]) 

707 meanE = np.mean(xie[start:stop]) 

708 meanB = np.mean(xib[start:stop]) 

709 self.log.info( 

710 "Original average correlation level over 0-15 arcminutes: E-mode=%0.2f, B-mode=%0.2f", 

711 meanE, 

712 meanB, 

713 ) 

714 

715 xie_resid, xib_resid, logr = treegp.comp_eb_treecorr( 

716 tpCoords[trainInd, 0], 

717 tpCoords[trainInd, 1], 

718 dx[trainInd] - xPredict, 

719 dy[trainInd] - yPredict, 

720 rmin=20 / 3600, 

721 rmax=0.6, 

722 dlogr=0.3, 

723 ) 

724 start, stop = np.searchsorted(np.exp(logr), [0, 15]) 

725 meanE_resid = np.mean(xie_resid[start:stop]) 

726 meanB_resid = np.mean(xib_resid[start:stop]) 

727 self.log.info( 

728 "Correlation level after GP correction over 0-15 arcminutes: E-mode=%0.2f, B-mode=%0.2f", 

729 meanE_resid, 

730 meanB_resid, 

731 ) 

732 

733 # Predict on all test data and make a plot. 

734 if makeValidationPlot: 

735 print(len(testInd)) 

736 testInd = testInd[:50000] 

737 chunkSize = 5000 

738 nChunks = np.ceil(len(testInd) / chunkSize).astype(int) 

739 xPredict = np.zeros(len(testInd)) 

740 yPredict = np.zeros(len(testInd)) 

741 for i in range(nChunks): 

742 ind = testInd[chunkSize * i : chunkSize * (i + 1)] 

743 xPredict[chunkSize * i : chunkSize * (i + 1)] = gpx.predict(tpCoords[ind]) 

744 yPredict[chunkSize * i : chunkSize * (i + 1)] = gpy.predict(tpCoords[ind]) 

745 fig = plot_visit( 

746 tpCoords[testInd, 0], tpCoords[testInd, 1], dx[testInd], dy[testInd], xPredict, yPredict 

747 ) 

748 return fig