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

280 statements  

« prev     ^ index     » next       coverage.py v7.4.4, created at 2024-03-21 04:25 -0700

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.cm as cm 

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.geom as geom 

34from lsst.atmospec.utils import isDispersedExp 

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

36from lsst.summit.utils import ImageExaminer 

37 

38# TODO: change these back to local .imports 

39from lsst.summit.utils.bestEffort import BestEffortIsr 

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

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

42 

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

44 

45 

46@dataclass 

47class FitResult: 

48 amp: float 

49 mean: float 

50 sigma: float 

51 

52 

53def getFocusFromExposure(exp): 

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

55 

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

57 visitInfo. 

58 

59 Parameters 

60 ---------- 

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

62 The exposure. 

63 

64 Returns 

65 ------- 

66 focus : `float` 

67 The focus value. 

68 

69 """ 

70 return float(exp.visitInfo.focusZ) 

71 

72 

73class SpectralFocusAnalyzer: 

74 """Analyze a focus sweep taken for spectral data. 

75 

76 Take slices across the spectrum for each image, fitting a Gaussian to each 

77 slice, and perform a parabolic fit to these widths. The number of slices 

78 and their distances can be customized by calling setSpectrumBoxOffsets(). 

79 

80 Nominal usage is something like: 

81 

82 %matplotlib inline 

83 dayObs = 20210101 

84 seqNums = [100, 101, 102, 103, 104] 

85 focusAnalyzer = SpectralFocusAnalyzer() 

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

87 focusAnalyzer.getFocusData(dayObs, seqNums, doDisplay=True) 

88 focusAnalyzer.fitDataAndPlot() 

89 

90 focusAnalyzer.run() can be used instead of the last two lines separately. 

91 """ 

92 

93 def __init__(self, embargo=False): 

94 self.butler = makeDefaultLatissButler(embargo=embargo) 

95 self._bestEffort = BestEffortIsr(embargo=embargo) 

96 qfmTaskConfig = QuickFrameMeasurementTaskConfig() 

97 self._quickMeasure = QuickFrameMeasurementTask(config=qfmTaskConfig) 

98 

99 self.spectrumHalfWidth = 100 

100 self.spectrumBoxLength = 20 

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

102 self._setColors(len(self._spectrumBoxOffsets)) 

103 

104 def setSpectrumBoxOffsets(self, offsets): 

105 """Set the current spectrum slice offsets. 

106 

107 Parameters 

108 ---------- 

109 offsets : `list` of `float` 

110 The distance at which to slice the spectrum, measured in pixels 

111 from the main star's location. 

112 """ 

113 self._spectrumBoxOffsets = offsets 

114 self._setColors(len(offsets)) 

115 

116 def getSpectrumBoxOffsets(self): 

117 """Get the current spectrum slice offsets. 

118 

119 Returns 

120 ------- 

121 offsets : `list` of `float` 

122 The distance at which to slice the spectrum, measured in pixels 

123 from the main star's location. 

124 """ 

125 return self._spectrumBoxOffsets 

126 

127 def _setColors(self, nPoints): 

128 self.COLORS = cm.rainbow(np.linspace(0, 1, nPoints)) 

129 

130 def _getBboxes(self, centroid): 

131 x, y = centroid 

132 bboxes = [] 

133 

134 for offset in self._spectrumBoxOffsets: 

135 bbox = geom.Box2I( 

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

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

138 ) 

139 bboxes.append(bbox) 

140 return bboxes 

141 

142 def _bboxToMplRectangle(self, bbox, colorNum): 

143 xmin = bbox.getBeginX() 

144 ymin = bbox.getBeginY() 

145 xsize = bbox.getWidth() 

146 ysize = bbox.getHeight() 

147 rectangle = Rectangle( 

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

149 ) 

150 return rectangle 

151 

152 @staticmethod 

153 def gauss(x, *pars): 

154 amp, mean, sigma = pars 

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

156 

157 def run(self, dayObs, seqNums, doDisplay=False, hideFit=False, hexapodZeroPoint=0): 

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

159 

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

161 spectrum at y-offsets as specified by the offsets 

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

163 to the spectrum slice to measure its width. 

164 

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

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

167 for each. 

168 

169 Parameters 

170 ---------- 

171 dayObs : `int` 

172 The dayObs to use. 

173 seqNums : `list` of `int` 

174 The seqNums for the focus sweep to analyze. 

175 doDisplay : `bool` 

176 Show the plots? Designed to be used in a notebook with 

177 %matplotlib inline. 

178 hideFit : `bool`, optional 

179 Hide the fit and just return the result? 

180 hexapodZeroPoint : `float`, optional 

181 Add a zeropoint offset to the hexapod axis? 

182 

183 Returns 

184 ------- 

185 bestFits : `list` of `float` 

186 A list of the best fit focuses, one for each spectral slice. 

187 """ 

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

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

190 return bestFits 

191 

192 def getFocusData(self, dayObs, seqNums, doDisplay=False): 

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

194 

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

196 spectrum at y-offsets as specified by the offsets 

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

198 to the spectrum slice to measure its width. 

199 

200 Parameters 

201 ---------- 

202 dayObs : `int` 

203 The dayObs to use. 

204 seqNums : `list` of `int` 

205 The seqNums for the focus sweep to analyze. 

206 doDisplay : `bool` 

207 Show the plots? Designed to be used in a notebook with 

208 %matplotlib inline. 

209 

210 Notes 

211 ----- 

212 Performs the focus analysis per-image, holding the data in the class. 

213 Call fitDataAndPlot() after running this to perform the parabolic fit 

214 to the focus data itself. 

215 """ 

216 fitData = {} 

217 filters = set() 

218 objects = set() 

219 

220 for seqNum in seqNums: 

221 fitData[seqNum] = {} 

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

223 exp = self._bestEffort.getExposure(dataId) 

224 

225 # sanity checking 

226 filt = exp.filter.physicalLabel 

227 expRecord = getExpRecordFromDataId(self.butler, dataId) 

228 obj = expRecord.target_name 

229 objects.add(obj) 

230 filters.add(filt) 

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

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

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

234 

235 quickMeasResult = self._quickMeasure.run(exp) 

236 centroid = quickMeasResult.brightestObjCentroid 

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

238 

239 if doDisplay: 

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

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

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

243 plt.tight_layout() 

244 arrowy, arrowx = centroid[0] - 400, centroid[1] # numpy is backwards 

245 dx, dy = 0, 300 

246 arrow = Arrow(arrowy, arrowx, dy, dx, width=200.0, color="red") 

247 circle = Circle(centroid, radius=25, facecolor="none", color="red") 

248 axes[0].add_patch(arrow) 

249 axes[0].add_patch(circle) 

250 for i, bbox in enumerate(spectrumSliceBboxes): 

251 rect = self._bboxToMplRectangle(bbox, i) 

252 axes[0].add_patch(rect) 

253 

254 for i, bbox in enumerate(spectrumSliceBboxes): 

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

256 data1d -= np.median(data1d) 

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

258 

259 # get rough estimates for fit 

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

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

262 amp = np.max(data1d) 

263 mean = np.argmax(data1d) 

264 sigma = 20 

265 p0 = amp, mean, sigma 

266 

267 try: 

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

269 except RuntimeError: 

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

271 

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

273 if doDisplay: 

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

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

276 if coeffs[0] is not np.nan: 

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

278 

279 if doDisplay: # show all color boxes together 

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

281 plt.show() 

282 

283 focuserPosition = getFocusFromExposure(exp) 

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

285 

286 self.fitData = fitData 

287 self.filter = filters.pop() 

288 self.object = objects.pop() 

289 

290 return 

291 

292 def fitDataAndPlot(self, hideFit=False, hexapodZeroPoint=0): 

293 """Fit a parabola to each series of slices and return the best focus. 

294 

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

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

297 for each. 

298 

299 Parameters 

300 ---------- 

301 hideFit : `bool`, optional 

302 Hide the fit and just return the result? 

303 hexapodZeroPoint : `float`, optional 

304 Add a zeropoint offset to the hexapod axis? 

305 

306 Returns 

307 ------- 

308 bestFits : `list` of `float` 

309 A list of the best fit focuses, one for each spectral slice. 

310 """ 

311 data = self.fitData 

312 filt = self.filter 

313 obj = self.object 

314 

315 bestFits = [] 

316 

317 titleFontSize = 18 

318 legendFontSize = 12 

319 labelFontSize = 14 

320 

321 arcminToPixel = 10 

322 sigmaToFwhm = 2.355 

323 

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

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

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

327 seqNums = sorted(data.keys()) 

328 

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

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

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

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

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

334 

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

336 widths = [data[seqNum][spectrumSlice].sigma / arcminToPixel * sigmaToFwhm for seqNum in seqNums] 

337 

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

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

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

341 

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

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

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

345 

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

347 if not hideFit: 

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

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

350 bestFits.append(fitMin) 

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

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

353 axes[1].text( 

354 fitMin, 

355 np.mean(widths), 

356 msg, 

357 horizontalalignment="right", 

358 verticalalignment="center", 

359 rotation=90, 

360 color=self.COLORS[spectrumSlice], 

361 fontsize=legendFontSize, 

362 ) 

363 

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

365 plt.suptitle(titleText, fontsize=titleFontSize) 

366 legendText = self._generateLegendText(nSpectrumSlices) 

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

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

369 f.tight_layout(rect=[0, 0.03, 1, 0.95]) 

370 plt.show() 

371 

372 for i, bestFit in enumerate(bestFits): 

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

374 return bestFits 

375 

376 def _generateLegendText(self, nSpectrumSlices): 

377 if nSpectrumSlices == 1: 

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

379 if nSpectrumSlices == 2: 

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

381 

382 legendText = [] 

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

384 for i in range(nSpectrumSlices - 2): 

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

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

387 return legendText 

388 

389 

390class NonSpectralFocusAnalyzer: 

391 """Analyze a focus sweep taken for direct imaging data. 

392 

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

394 encircled energy radii, and fit a parabola to get the position of best 

395 focus. 

396 

397 Nominal usage is something like: 

398 

399 %matplotlib inline 

400 dayObs = 20210101 

401 seqNums = [100, 101, 102, 103, 104] 

402 focusAnalyzer = NonSpectralFocusAnalyzer() 

403 focusAnalyzer.getFocusData(dayObs, seqNums, doDisplay=True) 

404 focusAnalyzer.fitDataAndPlot() 

405 

406 focusAnalyzer.run() can be used instead of the last two lines separately. 

407 """ 

408 

409 def __init__(self, embargo=False): 

410 self.butler = makeDefaultLatissButler(embargo=embargo) 

411 self._bestEffort = BestEffortIsr(embargo=embargo) 

412 

413 @staticmethod 

414 def gauss(x, *pars): 

415 amp, mean, sigma = pars 

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

417 

418 def run( 

419 self, 

420 dayObs, 

421 seqNums, 

422 *, 

423 manualCentroid=None, 

424 doCheckDispersed=True, 

425 doDisplay=False, 

426 doForceCoM=False, 

427 ): 

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

429 

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

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

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

433 

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

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

436 found. 

437 

438 Parameters 

439 ---------- 

440 dayObs : `int` 

441 The dayObs to use. 

442 seqNums : `list` of `int` 

443 The seqNums for the focus sweep to analyze. 

444 manualCentroid : `tuple` of `float`, optional 

445 Use this as the centroid position instead of fitting each image. 

446 doCheckDispersed : `bool`, optional 

447 Check if any of the seqNums actually refer to dispersed images? 

448 doDisplay : `bool`, optional 

449 Show the plots? Designed to be used in a notebook with 

450 %matplotlib inline. 

451 doForceCoM : `bool`, optional 

452 Force using centre-of-mass for centroiding? 

453 

454 Returns 

455 ------- 

456 result : `dict` of `float` 

457 A dict of the fit minima keyed by the metric it is the minimum for. 

458 """ 

459 self.getFocusData( 

460 dayObs, 

461 seqNums, 

462 manualCentroid=manualCentroid, 

463 doCheckDispersed=doCheckDispersed, 

464 doDisplay=doDisplay, 

465 doForceCoM=doForceCoM, 

466 ) 

467 bestFit = self.fitDataAndPlot() 

468 return bestFit 

469 

470 def getFocusData( 

471 self, 

472 dayObs, 

473 seqNums, 

474 *, 

475 manualCentroid=None, 

476 doCheckDispersed=True, 

477 doDisplay=False, 

478 doForceCoM=False, 

479 ): 

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

481 

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

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

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

485 

486 Parameters 

487 ---------- 

488 dayObs : `int` 

489 The dayObs to use. 

490 seqNums : `list` of `int` 

491 The seqNums for the focus sweep to analyze. 

492 manualCentroid : `tuple` of `float`, optional 

493 Use this as the centroid position instead of fitting each image. 

494 doCheckDispersed : `bool`, optional 

495 Check if any of the seqNums actually refer to dispersed images? 

496 doDisplay : `bool`, optional 

497 Show the plots? Designed to be used in a notebook with 

498 %matplotlib inline. 

499 doForceCoM : `bool`, optional 

500 Force using centre-of-mass for centroiding? 

501 

502 Notes 

503 ----- 

504 Performs the focus analysis per-image, holding the data in the class. 

505 Call fitDataAndPlot() after running this to perform the parabolic fit 

506 to the focus data itself. 

507 """ 

508 fitData = {} 

509 filters = set() 

510 objects = set() 

511 

512 maxDistance = 200 

513 firstCentroid = None 

514 

515 for seqNum in seqNums: 

516 fitData[seqNum] = {} 

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

518 exp = self._bestEffort.getExposure(dataId) 

519 

520 # sanity/consistency checking 

521 filt = exp.filter.physicalLabel 

522 expRecord = getExpRecordFromDataId(self.butler, dataId) 

523 obj = expRecord.target_name 

524 objects.add(obj) 

525 filters.add(filt) 

526 if doCheckDispersed: 

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

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

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

530 

531 imExam = ImageExaminer( 

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

533 ) 

534 if doDisplay: 

535 imExam.plot() 

536 

537 fwhm = imExam.imStats.fitFwhm 

538 amp = imExam.imStats.fitAmp 

539 gausMean = imExam.imStats.fitGausMean 

540 centroid = imExam.centroid 

541 

542 if seqNum == seqNums[0]: 

543 firstCentroid = centroid 

544 

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

546 if dist > maxDistance: 

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

548 

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

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

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

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

553 

554 focuserPosition = getFocusFromExposure(exp) 

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

556 

557 self.fitData = fitData 

558 self.filter = filters.pop() 

559 self.object = objects.pop() 

560 

561 return 

562 

563 def fitDataAndPlot(self): 

564 """Fit a parabola to each width metric, returning their best focuses. 

565 

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

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

568 found. 

569 

570 Returns 

571 ------- 

572 result : `dict` of `float` 

573 A dict of the fit minima keyed by the metric it is the minimum for. 

574 """ 

575 fitData = self.fitData 

576 

577 labelFontSize = 14 

578 

579 arcminToPixel = 10 

580 

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

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

583 

584 seqNums = sorted(fitData.keys()) 

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

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

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

588 

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

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

591 

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

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

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

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

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

597 

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

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

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

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

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

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

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

605 

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

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

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

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

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

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

612 

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

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

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

616 

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

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

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

620 

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

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

623 

624 ax1.legend() 

625 

626 plt.subplots_adjust(hspace=0.0) 

627 plt.show() 

628 

629 results = { 

630 "fwhmFitMin": fwhmFitMin, 

631 "ee90FitMin": ee90FitMin, 

632 "ee80FitMin": ee80FitMin, 

633 "ee50FitMin": ee50FitMin, 

634 } 

635 

636 return results 

637 

638 

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

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

641 analyzer = SpectralFocusAnalyzer() 

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

643 dataId = {"day_obs": 20200312} 

644 seqNums = [121, 122] 

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

646 analyzer.fitDataAndPlot()