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

280 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-07-05 11:25 +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 

22import lsst.geom as geom 

23 

24import matplotlib.pyplot as plt 

25from matplotlib.colors import LogNorm 

26from matplotlib.patches import Arrow, Rectangle, Circle 

27import matplotlib.cm as cm 

28from matplotlib import gridspec 

29 

30from dataclasses import dataclass 

31 

32import numpy as np 

33from scipy.optimize import curve_fit 

34from scipy.linalg import norm 

35 

36# TODO: change these back to local .imports 

37from lsst.summit.utils.bestEffort import BestEffortIsr 

38from lsst.summit.utils import ImageExaminer 

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

40from lsst.atmospec.utils import isDispersedExp 

41 

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

43from lsst.summit.utils.butlerUtils import (makeDefaultLatissButler, getExpRecordFromDataId) 

44 

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

46 

47 

48@dataclass 

49class FitResult: 

50 amp: float 

51 mean: float 

52 sigma: float 

53 

54 

55def getFocusFromExposure(exp): 

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

57 

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

59 visitInfo. 

60 

61 Parameters 

62 ---------- 

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

64 The exposure. 

65 

66 Returns 

67 ------- 

68 focus : `float` 

69 The focus value. 

70 

71 """ 

72 return float(exp.visitInfo.focusZ) 

73 

74 

75class SpectralFocusAnalyzer(): 

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

77 

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

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

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

81 

82 Nominal usage is something like: 

83 

84 %matplotlib inline 

85 dayObs = 20210101 

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

87 focusAnalyzer = SpectralFocusAnalyzer() 

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

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

90 focusAnalyzer.fitDataAndPlot() 

91 

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

93 """ 

94 

95 def __init__(self, embargo=False): 

96 self.butler = makeDefaultLatissButler(embargo=embargo) 

97 self._bestEffort = BestEffortIsr(embargo=embargo) 

98 qfmTaskConfig = QuickFrameMeasurementTaskConfig() 

99 self._quickMeasure = QuickFrameMeasurementTask(config=qfmTaskConfig) 

100 

101 self.spectrumHalfWidth = 100 

102 self.spectrumBoxLength = 20 

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

104 self._setColors(len(self._spectrumBoxOffsets)) 

105 

106 def setSpectrumBoxOffsets(self, offsets): 

107 """Set the current spectrum slice offsets. 

108 

109 Parameters 

110 ---------- 

111 offsets : `list` of `float` 

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

113 from the main star's location. 

114 """ 

115 self._spectrumBoxOffsets = offsets 

116 self._setColors(len(offsets)) 

117 

118 def getSpectrumBoxOffsets(self): 

119 """Get the current spectrum slice offsets. 

120 

121 Returns 

122 ------- 

123 offsets : `list` of `float` 

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

125 from the main star's location. 

126 """ 

127 return self._spectrumBoxOffsets 

128 

129 def _setColors(self, nPoints): 

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

131 

132 def _getBboxes(self, centroid): 

133 x, y = centroid 

134 bboxes = [] 

135 

136 for offset in self._spectrumBoxOffsets: 

137 bbox = geom.Box2I(geom.Point2I(x-self.spectrumHalfWidth, y+offset), 

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

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((xmin, ymin), xsize, ysize, alpha=1, facecolor='none', lw=2, 

148 edgecolor=self.COLORS[colorNum]) 

149 return rectangle 

150 

151 @staticmethod 

152 def gauss(x, *pars): 

153 amp, mean, sigma = pars 

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

155 

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

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

158 

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

160 spectrum at y-offsets as specified by the offsets 

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

162 to the spectrum slice to measure its width. 

163 

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

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

166 for each. 

167 

168 Parameters 

169 ---------- 

170 dayObs : `int` 

171 The dayObs to use. 

172 seqNums : `list` of `int` 

173 The seqNums for the focus sweep to analyze. 

174 doDisplay : `bool` 

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

176 %matplotlib inline. 

177 hideFit : `bool`, optional 

178 Hide the fit and just return the result? 

179 hexapodZeroPoint : `float`, optional 

180 Add a zeropoint offset to the hexapod axis? 

181 

182 Returns 

183 ------- 

184 bestFits : `list` of `float` 

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

186 """ 

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

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

189 return bestFits 

190 

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

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

193 

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

195 spectrum at y-offsets as specified by the offsets 

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

197 to the spectrum slice to measure its width. 

198 

199 Parameters 

200 ---------- 

201 dayObs : `int` 

202 The dayObs to use. 

203 seqNums : `list` of `int` 

204 The seqNums for the focus sweep to analyze. 

205 doDisplay : `bool` 

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

207 %matplotlib inline. 

208 

209 Notes 

210 ----- 

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

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

213 to the focus data itself. 

214 """ 

215 fitData = {} 

216 filters = set() 

217 objects = set() 

218 

219 for seqNum in seqNums: 

220 fitData[seqNum] = {} 

221 dataId = {'day_obs': dayObs, 'seq_num': seqNum, 'detector': 0} 

222 exp = self._bestEffort.getExposure(dataId) 

223 

224 # sanity checking 

225 filt = exp.filter.physicalLabel 

226 expRecord = getExpRecordFromDataId(self.butler, dataId) 

227 obj = expRecord.target_name 

228 objects.add(obj) 

229 filters.add(filt) 

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

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

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

233 

234 quickMeasResult = self._quickMeasure.run(exp) 

235 centroid = quickMeasResult.brightestObjCentroid 

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

237 

238 if doDisplay: 

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

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

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

242 plt.tight_layout() 

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

244 dx, dy = 0, 300 

245 arrow = Arrow(arrowy, arrowx, dy, dx, width=200., color='red') 

246 circle = Circle(centroid, radius=25, facecolor='none', color='red') 

247 axes[0].add_patch(arrow) 

248 axes[0].add_patch(circle) 

249 for i, bbox in enumerate(spectrumSliceBboxes): 

250 rect = self._bboxToMplRectangle(bbox, i) 

251 axes[0].add_patch(rect) 

252 

253 for i, bbox in enumerate(spectrumSliceBboxes): 

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

255 data1d -= np.median(data1d) 

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

257 

258 # get rough estimates for fit 

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

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

261 amp = np.max(data1d) 

262 mean = np.argmax(data1d) 

263 sigma = 20 

264 p0 = amp, mean, sigma 

265 

266 try: 

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

268 except RuntimeError: 

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

270 

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

272 if doDisplay: 

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

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

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

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

277 

278 if doDisplay: # show all color boxes together 

279 plt.title(f'Fits to seqNum {seqNum}') 

280 plt.show() 

281 

282 focuserPosition = getFocusFromExposure(exp) 

283 fitData[seqNum]['focus'] = focuserPosition 

284 

285 self.fitData = fitData 

286 self.filter = filters.pop() 

287 self.object = objects.pop() 

288 

289 return 

290 

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

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

293 

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

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

296 for each. 

297 

298 Parameters 

299 ---------- 

300 hideFit : `bool`, optional 

301 Hide the fit and just return the result? 

302 hexapodZeroPoint : `float`, optional 

303 Add a zeropoint offset to the hexapod axis? 

304 

305 Returns 

306 ------- 

307 bestFits : `list` of `float` 

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

309 """ 

310 data = self.fitData 

311 filt = self.filter 

312 obj = self.object 

313 

314 bestFits = [] 

315 

316 titleFontSize = 18 

317 legendFontSize = 12 

318 labelFontSize = 14 

319 

320 arcminToPixel = 10 

321 sigmaToFwhm = 2.355 

322 

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

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

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

326 seqNums = sorted(data.keys()) 

327 

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

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

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

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

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

333 

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

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

336 

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

338 axes[0].set_xlabel('Focus position (mm)', fontsize=labelFontSize) 

339 axes[0].set_ylabel('Height (ADU)', fontsize=labelFontSize) 

340 

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

342 axes[1].set_xlabel('Focus position (mm)', fontsize=labelFontSize) 

343 axes[1].set_ylabel('FWHM (arcsec)', fontsize=labelFontSize) 

344 

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

346 if not hideFit: 

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

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

349 bestFits.append(fitMin) 

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

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

352 axes[1].text(fitMin, np.mean(widths), msg, horizontalalignment='right', 

353 verticalalignment='center', rotation=90, color=self.COLORS[spectrumSlice], 

354 fontsize=legendFontSize) 

355 

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

357 plt.suptitle(titleText, fontsize=titleFontSize) 

358 legendText = self._generateLegendText(nSpectrumSlices) 

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

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

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

362 plt.show() 

363 

364 for i, bestFit in enumerate(bestFits): 

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

366 return bestFits 

367 

368 def _generateLegendText(self, nSpectrumSlices): 

369 if nSpectrumSlices == 1: 

370 return ['m=+1 spectrum slice'] 

371 if nSpectrumSlices == 2: 

372 return ['m=+1 blue end', 'm=+1 red end'] 

373 

374 legendText = [] 

375 legendText.append('m=+1 blue end') 

376 for i in range(nSpectrumSlices-2): 

377 legendText.append('m=+1 redder...') 

378 legendText.append('m=+1 red end') 

379 return legendText 

380 

381 

382class NonSpectralFocusAnalyzer(): 

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

384 

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

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

387 focus. 

388 

389 Nominal usage is something like: 

390 

391 %matplotlib inline 

392 dayObs = 20210101 

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

394 focusAnalyzer = NonSpectralFocusAnalyzer() 

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

396 focusAnalyzer.fitDataAndPlot() 

397 

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

399 """ 

400 

401 def __init__(self, embargo=False): 

402 self.butler = makeDefaultLatissButler(embargo=embargo) 

403 self._bestEffort = BestEffortIsr(embargo=embargo) 

404 

405 @staticmethod 

406 def gauss(x, *pars): 

407 amp, mean, sigma = pars 

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

409 

410 def run(self, dayObs, seqNums, *, manualCentroid=None, doCheckDispersed=True, doDisplay=False, 

411 doForceCoM=False): 

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

413 

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

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

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

417 

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

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

420 found. 

421 

422 Parameters 

423 ---------- 

424 dayObs : `int` 

425 The dayObs to use. 

426 seqNums : `list` of `int` 

427 The seqNums for the focus sweep to analyze. 

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

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

430 doCheckDispersed : `bool`, optional 

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

432 doDisplay : `bool`, optional 

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

434 %matplotlib inline. 

435 doForceCoM : `bool`, optional 

436 Force using centre-of-mass for centroiding? 

437 

438 Returns 

439 ------- 

440 result : `dict` of `float` 

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

442 """ 

443 self.getFocusData(dayObs, seqNums, manualCentroid=manualCentroid, doCheckDispersed=doCheckDispersed, 

444 doDisplay=doDisplay, doForceCoM=doForceCoM) 

445 bestFit = self.fitDataAndPlot() 

446 return bestFit 

447 

448 def getFocusData(self, dayObs, seqNums, *, manualCentroid=None, doCheckDispersed=True, 

449 doDisplay=False, doForceCoM=False): 

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

451 

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

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

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

455 

456 Parameters 

457 ---------- 

458 dayObs : `int` 

459 The dayObs to use. 

460 seqNums : `list` of `int` 

461 The seqNums for the focus sweep to analyze. 

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

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

464 doCheckDispersed : `bool`, optional 

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

466 doDisplay : `bool`, optional 

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

468 %matplotlib inline. 

469 doForceCoM : `bool`, optional 

470 Force using centre-of-mass for centroiding? 

471 

472 Notes 

473 ----- 

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

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

476 to the focus data itself. 

477 """ 

478 fitData = {} 

479 filters = set() 

480 objects = set() 

481 

482 maxDistance = 200 

483 firstCentroid = None 

484 

485 for seqNum in seqNums: 

486 fitData[seqNum] = {} 

487 dataId = {'day_obs': dayObs, 'seq_num': seqNum, 'detector': 0} 

488 exp = self._bestEffort.getExposure(dataId) 

489 

490 # sanity/consistency checking 

491 filt = exp.filter.physicalLabel 

492 expRecord = getExpRecordFromDataId(self.butler, dataId) 

493 obj = expRecord.target_name 

494 objects.add(obj) 

495 filters.add(filt) 

496 if doCheckDispersed: 

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

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

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

500 

501 imExam = ImageExaminer(exp, centroid=manualCentroid, doTweakCentroid=True, boxHalfSize=105, 

502 doForceCoM=doForceCoM) 

503 if doDisplay: 

504 imExam.plot() 

505 

506 fwhm = imExam.imStats.fitFwhm 

507 amp = imExam.imStats.fitAmp 

508 gausMean = imExam.imStats.fitGausMean 

509 centroid = imExam.centroid 

510 

511 if seqNum == seqNums[0]: 

512 firstCentroid = centroid 

513 

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

515 if dist > maxDistance: 

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

517 

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

519 fitData[seqNum]['eeRadius50'] = imExam.imStats.eeRadius50 

520 fitData[seqNum]['eeRadius80'] = imExam.imStats.eeRadius80 

521 fitData[seqNum]['eeRadius90'] = imExam.imStats.eeRadius90 

522 

523 focuserPosition = getFocusFromExposure(exp) 

524 fitData[seqNum]['focus'] = focuserPosition 

525 

526 self.fitData = fitData 

527 self.filter = filters.pop() 

528 self.object = objects.pop() 

529 

530 return 

531 

532 def fitDataAndPlot(self): 

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

534 

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

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

537 found. 

538 

539 Returns 

540 ------- 

541 result : `dict` of `float` 

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

543 """ 

544 fitData = self.fitData 

545 

546 labelFontSize = 14 

547 

548 arcminToPixel = 10 

549 

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

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

552 

553 seqNums = sorted(fitData.keys()) 

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

555 focusPositions = [fitData[seqNum]['focus'] for seqNum in seqNums] 

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

557 

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

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

560 

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

562 ax0.scatter(focusPositions, widths, c='k') 

563 ax0.set_ylabel('FWHM (arcsec)', fontsize=labelFontSize) 

564 ax0.plot(fineXs, np.poly1d(fwhmFitPars)(fineXs), 'b-') 

565 ax0.axvline(fwhmFitMin, c='r', ls='--') 

566 

567 ee90s = [fitData[seqNum]['eeRadius90'] for seqNum in seqNums] 

568 ee80s = [fitData[seqNum]['eeRadius80'] for seqNum in seqNums] 

569 ee50s = [fitData[seqNum]['eeRadius50'] for seqNum in seqNums] 

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

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

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

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

574 

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

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

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

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

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

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

581 

582 ax1.plot(fineXs, np.poly1d(ee90FitPars)(fineXs), 'r-') 

583 ax1.plot(fineXs, np.poly1d(ee80FitPars)(fineXs), 'g-') 

584 ax1.plot(fineXs, np.poly1d(ee50FitPars)(fineXs), 'b-') 

585 

586 ax1.axvline(ee90FitMin, c='r', ls='--') 

587 ax1.axvline(ee80FitMin, c='g', ls='--') 

588 ax1.axvline(ee50FitMin, c='b', ls='--') 

589 

590 ax1.set_xlabel('User-applied focus offset (mm)', fontsize=labelFontSize) 

591 ax1.set_ylabel('Radius (pixels)', fontsize=labelFontSize) 

592 

593 ax1.legend() 

594 

595 plt.subplots_adjust(hspace=.0) 

596 plt.show() 

597 

598 results = {"fwhmFitMin": fwhmFitMin, 

599 "ee90FitMin": ee90FitMin, 

600 "ee80FitMin": ee80FitMin, 

601 "ee50FitMin": ee50FitMin} 

602 

603 return results 

604 

605 

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

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

608 analyzer = SpectralFocusAnalyzer() 

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

610 dataId = {'day_obs': 20200312} 

611 seqNums = [121, 122] 

612 analyzer.getFocusData(dataId['day_obs'], seqNums, doDisplay=True) 

613 analyzer.fitDataAndPlot()