Coverage for python/lsst/summit/extras/focusAnalysis.py: 14%

284 statements  

« prev     ^ index     » next       coverage.py v7.17.2, created at 2026-11-10 10:35 +0000

1# This file is part of summit_extras. 

2# 

3# Developed for the LSST Data Management System. 

4# This product includes software developed by the LSST Project 

5# (https://www.lsst.org). 

6# See the COPYRIGHT file at the top-level directory of this distribution 

7# for details of code ownership. 

8# 

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

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

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

12# (at your option) any later version. 

13# 

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

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

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

17# GNU General Public License for more details. 

18# 

19# You should have received a copy of the GNU General Public License 

20# along with this program. If not, see <https://www.gnu.org/licenses/>. 

21 

22from dataclasses import dataclass 

23 

24import matplotlib 

25import matplotlib.pyplot as plt 

26import numpy as np 

27from matplotlib import gridspec 

28from matplotlib.colors import LogNorm 

29from matplotlib.patches import Arrow, Circle, Rectangle 

30from scipy.linalg import norm 

31from scipy.optimize import curve_fit 

32 

33import lsst.afw.image as afwImage 

34import lsst.geom as geom 

35from lsst.atmospec.utils import isDispersedExp 

36from lsst.pipe.tasks.quickFrameMeasurement import QuickFrameMeasurementTask, QuickFrameMeasurementTaskConfig 

37from lsst.summit.utils import ImageExaminer 

38 

39# TODO: change these back to local .imports 

40from lsst.summit.utils.bestEffort import BestEffortIsr 

41from lsst.summit.utils.butlerUtils import getExpRecordFromDataId, makeDefaultLatissButler 

42from lsst.summit.utils.utils import FWHMTOSIGMA, SIGMATOFWHM 

43 

44__all__ = ["SpectralFocusAnalyzer", "NonSpectralFocusAnalyzer"] 

45 

46 

47@dataclass 

48class FitResult: 

49 """A single 1D Gaussian fit result. 

50 

51 Attributes 

52 ---------- 

53 amp : `float` 

54 The fitted amplitude. 

55 mean : `float` 

56 The fitted mean (peak position). 

57 sigma : `float` 

58 The fitted standard deviation. 

59 """ 

60 

61 amp: float 

62 mean: float 

63 sigma: float 

64 

65 

66def getFocusFromExposure(exp: afwImage.Exposure) -> float: 

67 """Get the focus value from an exposure. 

68 

69 This was previously accessed via raw metadata but now lives inside the 

70 visitInfo. 

71 

72 Parameters 

73 ---------- 

74 exp : `lsst.afw.image.Exposure` 

75 The exposure. 

76 

77 Returns 

78 ------- 

79 focus : `float` 

80 The focus value. 

81 

82 """ 

83 return float(exp.visitInfo.focusZ) 

84 

85 

86class SpectralFocusAnalyzer: 

87 """Analyze a focus sweep taken on dispersed (spectral) LATISS data. 

88 

89 Takes slices across the spectrum for each image, fits a Gaussian to 

90 each slice to measure its width, and then fits a parabola to the 

91 widths as a function of hexapod focus position to determine the 

92 best-focus hexapod position. The number of slices and their 

93 distances from the main star can be customized via 

94 `setSpectrumBoxOffsets`. 

95 

96 Parameters 

97 ---------- 

98 embargo : `bool`, optional 

99 If `True`, use the embargo butler repo rather than the default 

100 LATISS repo. 

101 

102 Examples 

103 -------- 

104 >>> %matplotlib inline 

105 >>> dayObs = 20210101 

106 >>> seqNums = [100, 101, 102, 103, 104] 

107 >>> focusAnalyzer = SpectralFocusAnalyzer() 

108 >>> focusAnalyzer.setSpectrumBoxOffsets([500, 750, 1000, 1250]) 

109 >>> focusAnalyzer.getFocusData(dayObs, seqNums, doDisplay=True) 

110 >>> focusAnalyzer.fitDataAndPlot() 

111 

112 `run` performs the two steps above in one call. 

113 """ 

114 

115 def __init__(self, embargo: bool = False): 

116 self.butler = makeDefaultLatissButler(embargo=embargo) 

117 self._bestEffort = BestEffortIsr(embargo=embargo) 

118 qfmTaskConfig = QuickFrameMeasurementTaskConfig() 

119 self._quickMeasure = QuickFrameMeasurementTask(config=qfmTaskConfig) 

120 

121 self.spectrumHalfWidth = 100 

122 self.spectrumBoxLength = 20 

123 self._spectrumBoxOffsets = [882, 1170, 1467] 

124 self._setColors(len(self._spectrumBoxOffsets)) 

125 

126 def setSpectrumBoxOffsets(self, offsets: list[int]) -> None: 

127 """Set the spectrum slice offsets used by the analyzer. 

128 

129 Parameters 

130 ---------- 

131 offsets : `list` [`int`] 

132 The y-distances from the main star's centroid at which to 

133 slice the spectrum, in pixels. 

134 """ 

135 self._spectrumBoxOffsets = offsets 

136 self._setColors(len(offsets)) 

137 

138 def getSpectrumBoxOffsets(self) -> list[int]: 

139 """Return the current spectrum slice offsets. 

140 

141 Returns 

142 ------- 

143 offsets : `list` [`int`] 

144 The y-distances from the main star's centroid at which the 

145 spectrum is sliced, in pixels. 

146 """ 

147 return self._spectrumBoxOffsets 

148 

149 def _setColors(self, nPoints: int) -> None: 

150 """Populate ``self.COLORS`` with ``nPoints`` rainbow colors. 

151 

152 Parameters 

153 ---------- 

154 nPoints : `int` 

155 Number of colors to sample from the ``rainbow`` colormap. 

156 """ 

157 self.COLORS = matplotlib.colormaps["rainbow"](np.linspace(0, 1, nPoints)) 

158 

159 def _getBboxes(self, centroid: list[float]) -> list[geom.Box2I]: 

160 """Build the spectrum-slice bounding boxes for this centroid. 

161 

162 Parameters 

163 ---------- 

164 centroid : `list` [`float`] or `tuple` [`float`, `float`] 

165 The ``(x, y)`` centroid of the main star. 

166 

167 Returns 

168 ------- 

169 bboxes : `list` [`lsst.geom.Box2I`] 

170 One bounding box per configured spectrum offset. 

171 """ 

172 x, y = centroid 

173 bboxes = [] 

174 

175 for offset in self._spectrumBoxOffsets: 

176 bbox = geom.Box2I( 

177 geom.Point2I(x - self.spectrumHalfWidth, y + offset), 

178 geom.Point2I(x + self.spectrumHalfWidth, y + offset + self.spectrumBoxLength), 

179 ) 

180 bboxes.append(bbox) 

181 return bboxes 

182 

183 def _bboxToMplRectangle(self, bbox: geom.Box2I, colorNum: int) -> matplotlib.patches.Rectangle: 

184 """Convert a Box2I to a matplotlib `Rectangle` patch. 

185 

186 Parameters 

187 ---------- 

188 bbox : `lsst.geom.Box2I` 

189 The bounding box to draw. 

190 colorNum : `int` 

191 Index into ``self.COLORS`` to pick the edge color. 

192 

193 Returns 

194 ------- 

195 rectangle : `matplotlib.patches.Rectangle` 

196 A non-filled rectangle patch matching ``bbox``. 

197 """ 

198 xmin = bbox.getBeginX() 

199 ymin = bbox.getBeginY() 

200 xsize = bbox.getWidth() 

201 ysize = bbox.getHeight() 

202 rectangle = Rectangle( 

203 (xmin, ymin), xsize, ysize, alpha=1, facecolor="none", lw=2, edgecolor=self.COLORS[colorNum] 

204 ) 

205 return rectangle 

206 

207 @staticmethod 

208 def gauss(x: float | np.ndarray, *pars: float) -> float | np.ndarray: 

209 """Evaluate a 1D Gaussian. 

210 

211 Parameters 

212 ---------- 

213 x : `float` or `numpy.ndarray` 

214 The abscissa at which to evaluate the Gaussian. 

215 *pars : `float` 

216 The Gaussian parameters in the order 

217 ``(amplitude, mean, sigma)``, as required by 

218 `scipy.optimize.curve_fit`. 

219 

220 Returns 

221 ------- 

222 y : `float` or `numpy.ndarray` 

223 The Gaussian evaluated at ``x``. 

224 """ 

225 amp, mean, sigma = pars 

226 return amp * np.exp(-((x - mean) ** 2) / (2.0 * sigma**2)) 

227 

228 def run( 

229 self, 

230 dayObs: int, 

231 seqNums: list[int], 

232 doDisplay: bool = False, 

233 hideFit: bool = False, 

234 hexapodZeroPoint: float = 0, 

235 ) -> list[float]: 

236 """Perform a focus sweep analysis for spectral data. 

237 

238 For each seqNum for the specified dayObs, take a slice through the 

239 spectrum at y-offsets as specified by the offsets 

240 (see get/setSpectrumBoxOffsets() for setting these) and fit a Gaussian 

241 to the spectrum slice to measure its width. 

242 

243 For each offset distance, fit a parabola to the fitted spectral widths 

244 and return the hexapod position at which the best focus was achieved 

245 for each. 

246 

247 Parameters 

248 ---------- 

249 dayObs : `int` 

250 The dayObs to use. 

251 seqNums : `list` [`int`] 

252 The seqNums for the focus sweep to analyze. 

253 doDisplay : `bool`, optional 

254 If `True`, render the per-image diagnostic plots. Designed 

255 to be used in a notebook with ``%matplotlib inline``. 

256 hideFit : `bool`, optional 

257 If `True`, do not overlay the parabolic fit on the width 

258 plot (data points are still shown). 

259 hexapodZeroPoint : `float`, optional 

260 Zero point subtracted from the hexapod focus axis before 

261 plotting and fitting. 

262 

263 Returns 

264 ------- 

265 bestFits : `list` [`float`] 

266 Best-focus hexapod position for each spectral slice. 

267 """ 

268 self.getFocusData(dayObs, seqNums, doDisplay=doDisplay) 

269 bestFits = self.fitDataAndPlot(hideFit=hideFit, hexapodZeroPoint=hexapodZeroPoint) 

270 return bestFits 

271 

272 def getFocusData(self, dayObs: int, seqNums: list[int], doDisplay: bool = False) -> None: 

273 """Collect the per-image fit data for a spectral focus sweep. 

274 

275 For each seqNum of the specified dayObs, take a slice through 

276 the spectrum at the y-offsets configured via 

277 `setSpectrumBoxOffsets` and fit a Gaussian to each slice to 

278 measure its width. Results are stored in ``self.fitData`` for 

279 later use by `fitDataAndPlot`. 

280 

281 Parameters 

282 ---------- 

283 dayObs : `int` 

284 The dayObs to use. 

285 seqNums : `list` [`int`] 

286 The seqNums for the focus sweep to analyze. 

287 doDisplay : `bool`, optional 

288 If `True`, render per-image diagnostic plots showing the 

289 exposure with the slice boxes overlaid and the Gaussian 

290 fits to each slice. Designed to be used in a notebook with 

291 ``%matplotlib inline``. 

292 

293 Notes 

294 ----- 

295 Performs the focus analysis per-image, holding the data in the 

296 class. Call `fitDataAndPlot` after this to fit a parabola to 

297 the focus data itself. 

298 """ 

299 fitData: dict[int, dict] = {} 

300 filters: set[str] = set() 

301 objects: set[str] = set() 

302 

303 axes = None 

304 for seqNum in seqNums: 

305 fitData[seqNum] = {} 

306 dataId = {"day_obs": dayObs, "seq_num": seqNum, "detector": 0} 

307 exp = self._bestEffort.getExposure(dataId) 

308 

309 # sanity checking 

310 filt = exp.filter.physicalLabel 

311 expRecord = getExpRecordFromDataId(self.butler, dataId) 

312 obj = expRecord.target_name 

313 objects.add(obj) 

314 filters.add(filt) 

315 assert isDispersedExp(exp), f"Image is not dispersed! (filter = {filt})" 

316 assert len(filters) == 1, "You accidentally mixed filters!" 

317 assert len(objects) == 1, "You accidentally mixed objects!" 

318 

319 quickMeasResult = self._quickMeasure.run(exp) 

320 centroid = quickMeasResult.brightestObjCentroid 

321 spectrumSliceBboxes = self._getBboxes(centroid) # inside the loop due to centroid shifts 

322 

323 if doDisplay: 

324 fig, axes = plt.subplots(1, 2, figsize=(18, 9)) 

325 exp.image.array[exp.image.array <= 0] = 0.001 

326 axes[0].imshow(exp.image.array, norm=LogNorm(), origin="lower", cmap="gray_r") 

327 plt.tight_layout() 

328 arrowX = centroid[0] - 400 

329 arrowY = centroid[1] 

330 arrowDx, arrowDy = 300, 0 

331 arrow = Arrow(arrowX, arrowY, arrowDx, arrowDy, width=200.0, color="red") 

332 circle = Circle(centroid, radius=25, facecolor="none", edgecolor="red") 

333 axes[0].add_patch(arrow) 

334 axes[0].add_patch(circle) 

335 for i, bbox in enumerate(spectrumSliceBboxes): 

336 rect = self._bboxToMplRectangle(bbox, i) 

337 axes[0].add_patch(rect) 

338 

339 for i, bbox in enumerate(spectrumSliceBboxes): 

340 data1d = np.mean(exp[bbox].image.array, axis=0) # flatten 

341 data1d -= np.median(data1d) 

342 xs = np.arange(len(data1d)) 

343 

344 # get rough estimates for fit 

345 # can't use sigma from quickMeasResult due to SDSS shape 

346 # failing on saturated starts, and fp.getShape() is weird 

347 amp = np.max(data1d) 

348 mean = np.argmax(data1d) 

349 sigma = 20 

350 p0 = amp, mean, sigma 

351 

352 try: 

353 coeffs, var_matrix = curve_fit(self.gauss, xs, data1d, p0=p0) 

354 except RuntimeError: 

355 coeffs = (np.nan, np.nan, np.nan) 

356 

357 fitData[seqNum][i] = FitResult(amp=abs(coeffs[0]), mean=coeffs[1], sigma=abs(coeffs[2])) 

358 if doDisplay: 

359 assert axes is not None 

360 axes[1].plot(xs, data1d, "x", c=self.COLORS[i]) 

361 highResX = np.linspace(0, len(data1d), 1000) 

362 if not np.isnan(coeffs[0]): 

363 axes[1].plot(highResX, self.gauss(highResX, *coeffs), "k-") 

364 

365 if doDisplay: # show all color boxes together 

366 plt.title(f"Fits to seqNum {seqNum}") 

367 plt.show() 

368 

369 focuserPosition = getFocusFromExposure(exp) 

370 fitData[seqNum]["focus"] = focuserPosition 

371 

372 self.fitData = fitData 

373 self.filter = filters.pop() 

374 self.object = objects.pop() 

375 

376 return 

377 

378 def fitDataAndPlot(self, hideFit: bool = False, hexapodZeroPoint: float = 0) -> list[float]: 

379 """Fit a parabola to each spectral slice and plot the result. 

380 

381 For each configured slice offset, fit a parabola to the fitted 

382 Gaussian widths vs. hexapod focus and return the hexapod 

383 position at which each parabola is minimized. 

384 

385 Parameters 

386 ---------- 

387 hideFit : `bool`, optional 

388 If `True`, do not overlay the parabolic fit on the width 

389 plot (data points are still shown). 

390 hexapodZeroPoint : `float`, optional 

391 Zero point subtracted from the hexapod focus axis before 

392 plotting and fitting. 

393 

394 Returns 

395 ------- 

396 bestFits : `list` [`float`] 

397 Best-focus hexapod position for each spectral slice. 

398 """ 

399 data = self.fitData 

400 filt = self.filter 

401 obj = self.object 

402 

403 bestFits = [] 

404 

405 titleFontSize = 18 

406 legendFontSize = 12 

407 labelFontSize = 14 

408 

409 pixelsPerArcsec = 10 

410 sigmaToFwhm = 2.355 

411 

412 f, axes = plt.subplots(2, 1, figsize=[10, 12]) 

413 focusPositions = [data[k]["focus"] - hexapodZeroPoint for k in sorted(data.keys())] 

414 fineXs = np.linspace(np.min(focusPositions), np.max(focusPositions), 101) 

415 seqNums = sorted(data.keys()) 

416 

417 nSpectrumSlices = len(data[list(data.keys())[0]]) - 1 

418 pointsForLegend = [0.0 for offset in range(nSpectrumSlices)] 

419 for spectrumSlice in range(nSpectrumSlices): # the blue/green/red slices through the spectrum 

420 # for scatter plots, the color needs to be a single-row 2d array 

421 thisColor = np.array([self.COLORS[spectrumSlice]]) 

422 

423 amps = [data[seqNum][spectrumSlice].amp for seqNum in seqNums] 

424 widths = [data[seqNum][spectrumSlice].sigma / pixelsPerArcsec * sigmaToFwhm for seqNum in seqNums] 

425 

426 pointsForLegend[spectrumSlice] = axes[0].scatter(focusPositions, amps, c=thisColor) 

427 axes[0].set_xlabel("Focus position (mm)", fontsize=labelFontSize) 

428 axes[0].set_ylabel("Height (ADU)", fontsize=labelFontSize) 

429 

430 axes[1].scatter(focusPositions, widths, c=thisColor) 

431 axes[1].set_xlabel("Focus position (mm)", fontsize=labelFontSize) 

432 axes[1].set_ylabel("FWHM (arcsec)", fontsize=labelFontSize) 

433 

434 quadFitPars = np.polyfit(focusPositions, widths, 2) 

435 fitMin = -quadFitPars[1] / (2.0 * quadFitPars[0]) 

436 bestFits.append(fitMin) 

437 if not hideFit: 

438 axes[1].plot(fineXs, np.poly1d(quadFitPars)(fineXs), c=self.COLORS[spectrumSlice]) 

439 axes[1].axvline(fitMin, color=self.COLORS[spectrumSlice]) 

440 msg = f"Best focus offset = {np.round(fitMin, 2)}" 

441 axes[1].text( 

442 fitMin, 

443 np.mean(widths), 

444 msg, 

445 horizontalalignment="right", 

446 verticalalignment="center", 

447 rotation=90, 

448 color=self.COLORS[spectrumSlice], 

449 fontsize=legendFontSize, 

450 ) 

451 

452 titleText = f"Focus curve for {obj} w/ {filt}" 

453 plt.suptitle(titleText, fontsize=titleFontSize) 

454 legendText = self._generateLegendText(nSpectrumSlices) 

455 axes[0].legend(pointsForLegend, legendText, fontsize=legendFontSize) 

456 axes[1].legend(pointsForLegend, legendText, fontsize=legendFontSize) 

457 f.tight_layout(rect=(0, 0.03, 1, 0.95)) 

458 

459 for i, bestFit in enumerate(bestFits): 

460 print(f"Best fit for spectrum slice {i} = {bestFit:.4f}mm") 

461 return bestFits 

462 

463 def _generateLegendText(self, nSpectrumSlices: int) -> list[str]: 

464 """Build legend entries labelling each spectrum slice. 

465 

466 Parameters 

467 ---------- 

468 nSpectrumSlices : `int` 

469 The number of slices to label. 

470 

471 Returns 

472 ------- 

473 legendText : `list` [`str`] 

474 One legend label per slice, running from the blue end to 

475 the red end of the ``m=+1`` order. 

476 """ 

477 if nSpectrumSlices == 1: 

478 return ["m=+1 spectrum slice"] 

479 if nSpectrumSlices == 2: 

480 return ["m=+1 blue end", "m=+1 red end"] 

481 

482 legendText = [] 

483 legendText.append("m=+1 blue end") 

484 for i in range(nSpectrumSlices - 2): 

485 legendText.append("m=+1 redder...") 

486 legendText.append("m=+1 red end") 

487 return legendText 

488 

489 

490class NonSpectralFocusAnalyzer: 

491 """Analyze a focus sweep taken on direct (non-dispersed) imaging data. 

492 

493 For each image, measure the FWHM of the main star and the 50/80/90% 

494 encircled-energy radii using 

495 `lsst.summit.utils.ImageExaminer`, and fit a parabola to each 

496 metric as a function of hexapod focus to determine the best-focus 

497 position. 

498 

499 Parameters 

500 ---------- 

501 embargo : `bool`, optional 

502 If `True`, use the embargo butler repo rather than the default 

503 LATISS repo. 

504 

505 Examples 

506 -------- 

507 >>> %matplotlib inline 

508 >>> dayObs = 20210101 

509 >>> seqNums = [100, 101, 102, 103, 104] 

510 >>> focusAnalyzer = NonSpectralFocusAnalyzer() 

511 >>> focusAnalyzer.getFocusData(dayObs, seqNums, doDisplay=True) 

512 >>> focusAnalyzer.fitDataAndPlot() 

513 

514 `run` performs the two steps above in one call. 

515 """ 

516 

517 def __init__(self, embargo: bool = False): 

518 self.butler = makeDefaultLatissButler(embargo=embargo) 

519 self._bestEffort = BestEffortIsr(embargo=embargo) 

520 

521 @staticmethod 

522 def gauss(x: float | np.ndarray, *pars: float) -> float | np.ndarray: 

523 """Evaluate a 1D Gaussian. 

524 

525 Parameters 

526 ---------- 

527 x : `float` or `numpy.ndarray` 

528 The abscissa at which to evaluate the Gaussian. 

529 *pars : `float` 

530 The Gaussian parameters in the order 

531 ``(amplitude, mean, sigma)``. 

532 

533 Returns 

534 ------- 

535 y : `float` or `numpy.ndarray` 

536 The Gaussian evaluated at ``x``. 

537 """ 

538 amp, mean, sigma = pars 

539 return amp * np.exp(-((x - mean) ** 2) / (2.0 * sigma**2)) 

540 

541 def run( 

542 self, 

543 dayObs: int, 

544 seqNums: list[int], 

545 *, 

546 manualCentroid: tuple[float, float] | None = None, 

547 doCheckDispersed: bool = True, 

548 doDisplay: bool = False, 

549 doForceCoM: bool = False, 

550 ) -> dict: 

551 """Perform a focus sweep analysis for direct imaging data. 

552 

553 For each seqNum for the specified dayObs, run the image through imExam 

554 and collect the widths from the Gaussian fit and the 50/80/90% 

555 encircled energy metrics, saving the data in the class for fitting. 

556 

557 For each of the [Gaussian fit, 50%, 80%, 90% encircled energy] metrics, 

558 fit a parabola and return the focus value at which the minimum is 

559 found. 

560 

561 Parameters 

562 ---------- 

563 dayObs : `int` 

564 The dayObs to use. 

565 seqNums : `list` [`int`] 

566 The seqNums for the focus sweep to analyze. 

567 manualCentroid : `tuple` [`float`, `float`], optional 

568 If provided, use this ``(x, y)`` centroid for every image 

569 instead of fitting one per image. 

570 doCheckDispersed : `bool`, optional 

571 If `True`, assert that none of the exposures are dispersed. 

572 doDisplay : `bool`, optional 

573 If `True`, render per-image diagnostic plots. Designed to 

574 be used in a notebook with ``%matplotlib inline``. 

575 doForceCoM : `bool`, optional 

576 If `True`, force the image examiner to use a centre-of-mass 

577 centroid rather than its default. 

578 

579 Returns 

580 ------- 

581 result : `dict` [`str`, `float`] 

582 The parabolic-fit minima keyed by the metric name: 

583 ``fwhmFitMin``, ``ee50FitMin``, ``ee80FitMin``, 

584 ``ee90FitMin``. 

585 """ 

586 self.getFocusData( 

587 dayObs, 

588 seqNums, 

589 manualCentroid=manualCentroid, 

590 doCheckDispersed=doCheckDispersed, 

591 doDisplay=doDisplay, 

592 doForceCoM=doForceCoM, 

593 ) 

594 bestFit = self.fitDataAndPlot() 

595 return bestFit 

596 

597 def getFocusData( 

598 self, 

599 dayObs: int, 

600 seqNums: list[int], 

601 *, 

602 manualCentroid: tuple[float, float] | None = None, 

603 doCheckDispersed: bool = True, 

604 doDisplay: bool = False, 

605 doForceCoM: bool = False, 

606 ) -> None: 

607 """Perform a focus sweep analysis for direct imaging data. 

608 

609 For each seqNum for the specified dayObs, run the image through imExam 

610 and collect the widths from the Gaussian fit and the 50/80/90% 

611 encircled energy metrics, saving the data in the class for fitting. 

612 

613 Parameters 

614 ---------- 

615 dayObs : `int` 

616 The dayObs to use. 

617 seqNums : `list` [`int`] 

618 The seqNums for the focus sweep to analyze. 

619 manualCentroid : `tuple` [`float`, `float`], optional 

620 If provided, use this ``(x, y)`` centroid for every image 

621 instead of fitting one per image. 

622 doCheckDispersed : `bool`, optional 

623 If `True`, assert that none of the exposures are dispersed. 

624 doDisplay : `bool`, optional 

625 If `True`, render the per-image ImageExaminer diagnostic 

626 plot. Designed to be used in a notebook with 

627 ``%matplotlib inline``. 

628 doForceCoM : `bool`, optional 

629 If `True`, force the image examiner to use a centre-of-mass 

630 centroid rather than its default. 

631 

632 Notes 

633 ----- 

634 Performs the focus analysis per-image, holding the data in the 

635 class. Call `fitDataAndPlot` after this to fit a parabola to 

636 the focus data itself. 

637 """ 

638 fitData: dict[int, dict] = {} 

639 filters = set() 

640 objects = set() 

641 

642 maxDistance = 200 

643 firstCentroid = None 

644 

645 for seqNum in seqNums: 

646 fitData[seqNum] = {} 

647 dataId = {"day_obs": dayObs, "seq_num": seqNum, "detector": 0} 

648 exp = self._bestEffort.getExposure(dataId) 

649 

650 # sanity/consistency checking 

651 filt = exp.filter.physicalLabel 

652 expRecord = getExpRecordFromDataId(self.butler, dataId) 

653 obj = expRecord.target_name 

654 objects.add(obj) 

655 filters.add(filt) 

656 if doCheckDispersed: 

657 assert not isDispersedExp(exp), f"Image is dispersed! (filter = {filt})" 

658 assert len(filters) == 1, "You accidentally mixed filters!" 

659 assert len(objects) == 1, "You accidentally mixed objects!" 

660 

661 imExam = ImageExaminer( 

662 exp, centroid=manualCentroid, doTweakCentroid=True, boxHalfSize=105, doForceCoM=doForceCoM 

663 ) 

664 if doDisplay: 

665 imExam.plot() 

666 

667 fwhm = imExam.imStats.fitFwhm 

668 amp = imExam.imStats.fitAmp 

669 gausMean = imExam.imStats.fitGausMean 

670 centroid = imExam.centroid 

671 

672 if seqNum == seqNums[0]: 

673 firstCentroid = centroid 

674 

675 dist = norm(np.array(centroid) - np.array(firstCentroid)) 

676 if dist > maxDistance: 

677 print(f"Skipping {seqNum} because distance {dist}> maxDistance {maxDistance}") 

678 del fitData[seqNum] 

679 continue 

680 

681 fitData[seqNum]["fitResult"] = FitResult(amp=amp, mean=gausMean, sigma=fwhm * FWHMTOSIGMA) 

682 fitData[seqNum]["eeRadius50"] = imExam.imStats.eeRadius50 

683 fitData[seqNum]["eeRadius80"] = imExam.imStats.eeRadius80 

684 fitData[seqNum]["eeRadius90"] = imExam.imStats.eeRadius90 

685 

686 focuserPosition = getFocusFromExposure(exp) 

687 fitData[seqNum]["focus"] = focuserPosition 

688 

689 self.fitData = fitData 

690 self.filter = filters.pop() 

691 self.object = objects.pop() 

692 

693 return 

694 

695 def fitDataAndPlot(self) -> dict: 

696 """Fit a parabola to each width metric and plot the result. 

697 

698 Fits a parabola to the Gaussian FWHM and the 50%, 80% and 90% 

699 encircled-energy radii as functions of the hexapod focus 

700 position. Returns the focus position at which each parabola is 

701 minimized. 

702 

703 Returns 

704 ------- 

705 result : `dict` [`str`, `float`] 

706 The parabolic-fit minima keyed by the metric name: 

707 ``fwhmFitMin``, ``ee50FitMin``, ``ee80FitMin``, 

708 ``ee90FitMin``. 

709 """ 

710 fitData = self.fitData 

711 

712 labelFontSize = 14 

713 

714 pixelsPerArcsec = 10 

715 

716 fig = plt.figure(figsize=(10, 10)) # noqa 

717 gs = gridspec.GridSpec(2, 1, height_ratios=[1, 1]) 

718 

719 seqNums = sorted(fitData.keys()) 

720 widths = [fitData[seqNum]["fitResult"].sigma * SIGMATOFWHM / pixelsPerArcsec for seqNum in seqNums] 

721 focusPositions = [fitData[seqNum]["focus"] for seqNum in seqNums] 

722 fineXs = np.linspace(np.min(focusPositions), np.max(focusPositions), 101) 

723 

724 fwhmFitPars = np.polyfit(focusPositions, widths, 2) 

725 fwhmFitMin = -fwhmFitPars[1] / (2.0 * fwhmFitPars[0]) 

726 

727 ax0 = plt.subplot(gs[0]) 

728 ax0.scatter(focusPositions, widths, c="k") 

729 ax0.set_ylabel("FWHM (arcsec)", fontsize=labelFontSize) 

730 ax0.plot(fineXs, np.poly1d(fwhmFitPars)(fineXs), "b-") 

731 ax0.axvline(fwhmFitMin, c="r", ls="--") 

732 

733 ee90s = [fitData[seqNum]["eeRadius90"] for seqNum in seqNums] 

734 ee80s = [fitData[seqNum]["eeRadius80"] for seqNum in seqNums] 

735 ee50s = [fitData[seqNum]["eeRadius50"] for seqNum in seqNums] 

736 ax1 = plt.subplot(gs[1], sharex=ax0) 

737 ax1.scatter(focusPositions, ee90s, c="r", label="Encircled energy 90%") 

738 ax1.scatter(focusPositions, ee80s, c="g", label="Encircled energy 80%") 

739 ax1.scatter(focusPositions, ee50s, c="b", label="Encircled energy 50%") 

740 

741 ee90FitPars = np.polyfit(focusPositions, ee90s, 2) 

742 ee90FitMin = -ee90FitPars[1] / (2.0 * ee90FitPars[0]) 

743 ee80FitPars = np.polyfit(focusPositions, ee80s, 2) 

744 ee80FitMin = -ee80FitPars[1] / (2.0 * ee80FitPars[0]) 

745 ee50FitPars = np.polyfit(focusPositions, ee50s, 2) 

746 ee50FitMin = -ee50FitPars[1] / (2.0 * ee50FitPars[0]) 

747 

748 ax1.plot(fineXs, np.poly1d(ee90FitPars)(fineXs), "r-") 

749 ax1.plot(fineXs, np.poly1d(ee80FitPars)(fineXs), "g-") 

750 ax1.plot(fineXs, np.poly1d(ee50FitPars)(fineXs), "b-") 

751 

752 ax1.axvline(ee90FitMin, c="r", ls="--") 

753 ax1.axvline(ee80FitMin, c="g", ls="--") 

754 ax1.axvline(ee50FitMin, c="b", ls="--") 

755 

756 ax1.set_xlabel("User-applied focus offset (mm)", fontsize=labelFontSize) 

757 ax1.set_ylabel("Radius (pixels)", fontsize=labelFontSize) 

758 

759 ax1.legend() 

760 

761 plt.subplots_adjust(hspace=0.0) 

762 

763 results = { 

764 "fwhmFitMin": fwhmFitMin, 

765 "ee90FitMin": ee90FitMin, 

766 "ee80FitMin": ee80FitMin, 

767 "ee50FitMin": ee50FitMin, 

768 } 

769 

770 return results 

771 

772 

773if __name__ == "__main__": 773 ↛ 775line 773 didn't jump to line 775 because the condition on line 773 was never true

774 # TODO: DM-34239 Move this to be a butler-driven test 

775 analyzer = SpectralFocusAnalyzer() 

776 # dataId = {'dayObs': '2020-02-20', 'seqNum': 485} # direct image 

777 dataId = {"day_obs": 20200312} 

778 seqNums = [121, 122] 

779 analyzer.getFocusData(dataId["day_obs"], seqNums, doDisplay=True) 

780 analyzer.fitDataAndPlot()