Coverage for python/lsst/summit/extras/focusAnalysis.py: 15%
282 statements
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-02 05:32 -0700
« prev ^ index » next coverage.py v7.5.0, created at 2024-05-02 05:32 -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/>.
22from dataclasses import dataclass
24import matplotlib
25import matplotlib.cm as cm
26import matplotlib.pyplot as plt
27import numpy as np
28from matplotlib import gridspec
29from matplotlib.colors import LogNorm
30from matplotlib.patches import Arrow, Circle, Rectangle
31from scipy.linalg import norm
32from scipy.optimize import curve_fit
34import lsst.afw.image as afwImage
35import lsst.geom as geom
36from lsst.atmospec.utils import isDispersedExp
37from lsst.pipe.tasks.quickFrameMeasurement import QuickFrameMeasurementTask, QuickFrameMeasurementTaskConfig
38from lsst.summit.utils import ImageExaminer
40# TODO: change these back to local .imports
41from lsst.summit.utils.bestEffort import BestEffortIsr
42from lsst.summit.utils.butlerUtils import getExpRecordFromDataId, makeDefaultLatissButler
43from lsst.summit.utils.utils import FWHMTOSIGMA, SIGMATOFWHM
45__all__ = ["SpectralFocusAnalyzer", "NonSpectralFocusAnalyzer"]
48@dataclass
49class FitResult:
50 amp: float
51 mean: float
52 sigma: float
55def getFocusFromExposure(exp: afwImage.Exposure) -> float:
56 """Get the focus value from an exposure.
58 This was previously accessed via raw metadata but now lives inside the
59 visitInfo.
61 Parameters
62 ----------
63 exp : `lsst.afw.image.Exposure`
64 The exposure.
66 Returns
67 -------
68 focus : `float`
69 The focus value.
71 """
72 return float(exp.visitInfo.focusZ)
75class SpectralFocusAnalyzer:
76 """Analyze a focus sweep taken for spectral data.
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().
82 Nominal usage is something like:
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()
92 focusAnalyzer.run() can be used instead of the last two lines separately.
93 """
95 def __init__(self, embargo: bool = False):
96 self.butler = makeDefaultLatissButler(embargo=embargo)
97 self._bestEffort = BestEffortIsr(embargo=embargo)
98 qfmTaskConfig = QuickFrameMeasurementTaskConfig()
99 self._quickMeasure = QuickFrameMeasurementTask(config=qfmTaskConfig)
101 self.spectrumHalfWidth = 100
102 self.spectrumBoxLength = 20
103 self._spectrumBoxOffsets = [882, 1170, 1467]
104 self._setColors(len(self._spectrumBoxOffsets))
106 def setSpectrumBoxOffsets(self, offsets: list[int]) -> None:
107 """Set the current spectrum slice offsets.
109 Parameters
110 ----------
111 offsets : `list` of `int`
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))
118 def getSpectrumBoxOffsets(self) -> list[int]:
119 """Get the current spectrum slice offsets.
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
129 def _setColors(self, nPoints: int) -> None:
130 self.COLORS = cm.rainbow(np.linspace(0, 1, nPoints))
132 def _getBboxes(self, centroid: list[float]) -> geom.Box2I:
133 x, y = centroid
134 bboxes = []
136 for offset in self._spectrumBoxOffsets:
137 bbox = geom.Box2I(
138 geom.Point2I(x - self.spectrumHalfWidth, y + offset),
139 geom.Point2I(x + self.spectrumHalfWidth, y + offset + self.spectrumBoxLength),
140 )
141 bboxes.append(bbox)
142 return bboxes
144 def _bboxToMplRectangle(self, bbox: geom.Box2I, colorNum: int) -> matplotlib.patches.Rectangle:
145 xmin = bbox.getBeginX()
146 ymin = bbox.getBeginY()
147 xsize = bbox.getWidth()
148 ysize = bbox.getHeight()
149 rectangle = Rectangle(
150 (xmin, ymin), xsize, ysize, alpha=1, facecolor="none", lw=2, edgecolor=self.COLORS[colorNum]
151 )
152 return rectangle
154 @staticmethod
155 def gauss(x: float, *pars: float) -> float:
156 amp, mean, sigma = pars
157 return amp * np.exp(-((x - mean) ** 2) / (2.0 * sigma**2))
159 def run(
160 self,
161 dayObs: int,
162 seqNums: list[int],
163 doDisplay: bool = False,
164 hideFit: bool = False,
165 hexapodZeroPoint: float = 0,
166 ) -> list[float]:
167 """Perform a focus sweep analysis for spectral data.
169 For each seqNum for the specified dayObs, take a slice through the
170 spectrum at y-offsets as specified by the offsets
171 (see get/setSpectrumBoxOffsets() for setting these) and fit a Gaussian
172 to the spectrum slice to measure its width.
174 For each offset distance, fit a parabola to the fitted spectral widths
175 and return the hexapod position at which the best focus was achieved
176 for each.
178 Parameters
179 ----------
180 dayObs : `int`
181 The dayObs to use.
182 seqNums : `list` of `int`
183 The seqNums for the focus sweep to analyze.
184 doDisplay : `bool`
185 Show the plots? Designed to be used in a notebook with
186 %matplotlib inline.
187 hideFit : `bool`, optional
188 Hide the fit and just return the result?
189 hexapodZeroPoint : `float`, optional
190 Add a zeropoint offset to the hexapod axis?
192 Returns
193 -------
194 bestFits : `list` of `float`
195 A list of the best fit focuses, one for each spectral slice.
196 """
197 self.getFocusData(dayObs, seqNums, doDisplay=doDisplay)
198 bestFits = self.fitDataAndPlot(hideFit=hideFit, hexapodZeroPoint=hexapodZeroPoint)
199 return bestFits
201 def getFocusData(self, dayObs: int, seqNums: list[int], doDisplay: bool = False) -> None:
202 """Perform a focus sweep analysis for spectral data.
204 For each seqNum for the specified dayObs, take a slice through the
205 spectrum at y-offsets as specified by the offsets
206 (see get/setSpectrumBoxOffsets() for setting these) and fit a Gaussian
207 to the spectrum slice to measure its width.
209 Parameters
210 ----------
211 dayObs : `int`
212 The dayObs to use.
213 seqNums : `list` of `int`
214 The seqNums for the focus sweep to analyze.
215 doDisplay : `bool`
216 Show the plots? Designed to be used in a notebook with
217 %matplotlib inline.
219 Notes
220 -----
221 Performs the focus analysis per-image, holding the data in the class.
222 Call fitDataAndPlot() after running this to perform the parabolic fit
223 to the focus data itself.
224 """
225 fitData = {}
226 filters = set()
227 objects = set()
229 for seqNum in seqNums:
230 fitData[seqNum] = {}
231 dataId = {"day_obs": dayObs, "seq_num": seqNum, "detector": 0}
232 exp = self._bestEffort.getExposure(dataId)
234 # sanity checking
235 filt = exp.filter.physicalLabel
236 expRecord = getExpRecordFromDataId(self.butler, dataId)
237 obj = expRecord.target_name
238 objects.add(obj)
239 filters.add(filt)
240 assert isDispersedExp(exp), f"Image is not dispersed! (filter = {filt})"
241 assert len(filters) == 1, "You accidentally mixed filters!"
242 assert len(objects) == 1, "You accidentally mixed objects!"
244 quickMeasResult = self._quickMeasure.run(exp)
245 centroid = quickMeasResult.brightestObjCentroid
246 spectrumSliceBboxes = self._getBboxes(centroid) # inside the loop due to centroid shifts
248 if doDisplay:
249 fig, axes = plt.subplots(1, 2, figsize=(18, 9))
250 exp.image.array[exp.image.array <= 0] = 0.001
251 axes[0].imshow(exp.image.array, norm=LogNorm(), origin="lower", cmap="gray_r")
252 plt.tight_layout()
253 arrowy, arrowx = centroid[0] - 400, centroid[1] # numpy is backwards
254 dx, dy = 0, 300
255 arrow = Arrow(arrowy, arrowx, dy, dx, width=200.0, color="red")
256 circle = Circle(centroid, radius=25, facecolor="none", color="red")
257 axes[0].add_patch(arrow)
258 axes[0].add_patch(circle)
259 for i, bbox in enumerate(spectrumSliceBboxes):
260 rect = self._bboxToMplRectangle(bbox, i)
261 axes[0].add_patch(rect)
263 for i, bbox in enumerate(spectrumSliceBboxes):
264 data1d = np.mean(exp[bbox].image.array, axis=0) # flatten
265 data1d -= np.median(data1d)
266 xs = np.arange(len(data1d))
268 # get rough estimates for fit
269 # can't use sigma from quickMeasResult due to SDSS shape
270 # failing on saturated starts, and fp.getShape() is weird
271 amp = np.max(data1d)
272 mean = np.argmax(data1d)
273 sigma = 20
274 p0 = amp, mean, sigma
276 try:
277 coeffs, var_matrix = curve_fit(self.gauss, xs, data1d, p0=p0)
278 except RuntimeError:
279 coeffs = (np.nan, np.nan, np.nan)
281 fitData[seqNum][i] = FitResult(amp=abs(coeffs[0]), mean=coeffs[1], sigma=abs(coeffs[2]))
282 if doDisplay:
283 axes[1].plot(xs, data1d, "x", c=self.COLORS[i])
284 highResX = np.linspace(0, len(data1d), 1000)
285 if coeffs[0] is not np.nan:
286 axes[1].plot(highResX, self.gauss(highResX, *coeffs), "k-")
288 if doDisplay: # show all color boxes together
289 plt.title(f"Fits to seqNum {seqNum}")
290 plt.show()
292 focuserPosition = getFocusFromExposure(exp)
293 fitData[seqNum]["focus"] = focuserPosition
295 self.fitData = fitData
296 self.filter = filters.pop()
297 self.object = objects.pop()
299 return
301 def fitDataAndPlot(self, hideFit: bool = False, hexapodZeroPoint: float = 0) -> list[float]:
302 """Fit a parabola to each series of slices and return the best focus.
304 For each offset distance, fit a parabola to the fitted spectral widths
305 and return the hexapod position at which the best focus was achieved
306 for each.
308 Parameters
309 ----------
310 hideFit : `bool`, optional
311 Hide the fit and just return the result?
312 hexapodZeroPoint : `float`, optional
313 Add a zeropoint offset to the hexapod axis?
315 Returns
316 -------
317 bestFits : `list` of `float`
318 A list of the best fit focuses, one for each spectral slice.
319 """
320 data = self.fitData
321 filt = self.filter
322 obj = self.object
324 bestFits = []
326 titleFontSize = 18
327 legendFontSize = 12
328 labelFontSize = 14
330 arcminToPixel = 10
331 sigmaToFwhm = 2.355
333 f, axes = plt.subplots(2, 1, figsize=[10, 12])
334 focusPositions = [data[k]["focus"] - hexapodZeroPoint for k in sorted(data.keys())]
335 fineXs = np.linspace(np.min(focusPositions), np.max(focusPositions), 101)
336 seqNums = sorted(data.keys())
338 nSpectrumSlices = len(data[list(data.keys())[0]]) - 1
339 pointsForLegend = [0.0 for offset in range(nSpectrumSlices)]
340 for spectrumSlice in range(nSpectrumSlices): # the blue/green/red slices through the spectrum
341 # for scatter plots, the color needs to be a single-row 2d array
342 thisColor = np.array([self.COLORS[spectrumSlice]])
344 amps = [data[seqNum][spectrumSlice].amp for seqNum in seqNums]
345 widths = [data[seqNum][spectrumSlice].sigma / arcminToPixel * sigmaToFwhm for seqNum in seqNums]
347 pointsForLegend[spectrumSlice] = axes[0].scatter(focusPositions, amps, c=thisColor)
348 axes[0].set_xlabel("Focus position (mm)", fontsize=labelFontSize)
349 axes[0].set_ylabel("Height (ADU)", fontsize=labelFontSize)
351 axes[1].scatter(focusPositions, widths, c=thisColor)
352 axes[1].set_xlabel("Focus position (mm)", fontsize=labelFontSize)
353 axes[1].set_ylabel("FWHM (arcsec)", fontsize=labelFontSize)
355 quadFitPars = np.polyfit(focusPositions, widths, 2)
356 if not hideFit:
357 axes[1].plot(fineXs, np.poly1d(quadFitPars)(fineXs), c=self.COLORS[spectrumSlice])
358 fitMin = -quadFitPars[1] / (2.0 * quadFitPars[0])
359 bestFits.append(fitMin)
360 axes[1].axvline(fitMin, color=self.COLORS[spectrumSlice])
361 msg = f"Best focus offset = {np.round(fitMin, 2)}"
362 axes[1].text(
363 fitMin,
364 np.mean(widths),
365 msg,
366 horizontalalignment="right",
367 verticalalignment="center",
368 rotation=90,
369 color=self.COLORS[spectrumSlice],
370 fontsize=legendFontSize,
371 )
373 titleText = f"Focus curve for {obj} w/ {filt}"
374 plt.suptitle(titleText, fontsize=titleFontSize)
375 legendText = self._generateLegendText(nSpectrumSlices)
376 axes[0].legend(pointsForLegend, legendText, fontsize=legendFontSize)
377 axes[1].legend(pointsForLegend, legendText, fontsize=legendFontSize)
378 f.tight_layout(rect=(0, 0.03, 1, 0.95))
379 plt.show()
381 for i, bestFit in enumerate(bestFits):
382 print(f"Best fit for spectrum slice {i} = {bestFit:.4f}mm")
383 return bestFits
385 def _generateLegendText(self, nSpectrumSlices: int) -> str:
386 if nSpectrumSlices == 1:
387 return ["m=+1 spectrum slice"]
388 if nSpectrumSlices == 2:
389 return ["m=+1 blue end", "m=+1 red end"]
391 legendText = []
392 legendText.append("m=+1 blue end")
393 for i in range(nSpectrumSlices - 2):
394 legendText.append("m=+1 redder...")
395 legendText.append("m=+1 red end")
396 return legendText
399class NonSpectralFocusAnalyzer:
400 """Analyze a focus sweep taken for direct imaging data.
402 For each image, measure the FWHM of the main star and the 50/80/90%
403 encircled energy radii, and fit a parabola to get the position of best
404 focus.
406 Nominal usage is something like:
408 %matplotlib inline
409 dayObs = 20210101
410 seqNums = [100, 101, 102, 103, 104]
411 focusAnalyzer = NonSpectralFocusAnalyzer()
412 focusAnalyzer.getFocusData(dayObs, seqNums, doDisplay=True)
413 focusAnalyzer.fitDataAndPlot()
415 focusAnalyzer.run() can be used instead of the last two lines separately.
416 """
418 def __init__(self, embargo: bool = False):
419 self.butler = makeDefaultLatissButler(embargo=embargo)
420 self._bestEffort = BestEffortIsr(embargo=embargo)
422 @staticmethod
423 def gauss(x: float, *pars: float) -> float:
424 amp, mean, sigma = pars
425 return amp * np.exp(-((x - mean) ** 2) / (2.0 * sigma**2))
427 def run(
428 self,
429 dayObs: int,
430 seqNums: list[int],
431 *,
432 manualCentroid: tuple[float, float] | None = None,
433 doCheckDispersed: bool = True,
434 doDisplay: bool = False,
435 doForceCoM: bool = False,
436 ) -> dict:
437 """Perform a focus sweep analysis for direct imaging data.
439 For each seqNum for the specified dayObs, run the image through imExam
440 and collect the widths from the Gaussian fit and the 50/80/90%
441 encircled energy metrics, saving the data in the class for fitting.
443 For each of the [Gaussian fit, 50%, 80%, 90% encircled energy] metrics,
444 fit a parabola and return the focus value at which the minimum is
445 found.
447 Parameters
448 ----------
449 dayObs : `int`
450 The dayObs to use.
451 seqNums : `list` of `int`
452 The seqNums for the focus sweep to analyze.
453 manualCentroid : `tuple` of `float`, optional
454 Use this as the centroid position instead of fitting each image.
455 doCheckDispersed : `bool`, optional
456 Check if any of the seqNums actually refer to dispersed images?
457 doDisplay : `bool`, optional
458 Show the plots? Designed to be used in a notebook with
459 %matplotlib inline.
460 doForceCoM : `bool`, optional
461 Force using centre-of-mass for centroiding?
463 Returns
464 -------
465 result : `dict` of `float`
466 A dict of the fit minima keyed by the metric it is the minimum for.
467 """
468 self.getFocusData(
469 dayObs,
470 seqNums,
471 manualCentroid=manualCentroid,
472 doCheckDispersed=doCheckDispersed,
473 doDisplay=doDisplay,
474 doForceCoM=doForceCoM,
475 )
476 bestFit = self.fitDataAndPlot()
477 return bestFit
479 def getFocusData(
480 self,
481 dayObs: int,
482 seqNums: list[int],
483 *,
484 manualCentroid: tuple[float, float] | None = None,
485 doCheckDispersed: bool = True,
486 doDisplay: bool = False,
487 doForceCoM: bool = False,
488 ) -> None:
489 """Perform a focus sweep analysis for direct imaging data.
491 For each seqNum for the specified dayObs, run the image through imExam
492 and collect the widths from the Gaussian fit and the 50/80/90%
493 encircled energy metrics, saving the data in the class for fitting.
495 Parameters
496 ----------
497 dayObs : `int`
498 The dayObs to use.
499 seqNums : `list` of `int`
500 The seqNums for the focus sweep to analyze.
501 manualCentroid : `tuple` of `float`, optional
502 Use this as the centroid position instead of fitting each image.
503 doCheckDispersed : `bool`, optional
504 Check if any of the seqNums actually refer to dispersed images?
505 doDisplay : `bool`, optional
506 Show the plots? Designed to be used in a notebook with
507 %matplotlib inline.
508 doForceCoM : `bool`, optional
509 Force using centre-of-mass for centroiding?
511 Notes
512 -----
513 Performs the focus analysis per-image, holding the data in the class.
514 Call fitDataAndPlot() after running this to perform the parabolic fit
515 to the focus data itself.
516 """
517 fitData = {}
518 filters = set()
519 objects = set()
521 maxDistance = 200
522 firstCentroid = None
524 for seqNum in seqNums:
525 fitData[seqNum] = {}
526 dataId = {"day_obs": dayObs, "seq_num": seqNum, "detector": 0}
527 exp = self._bestEffort.getExposure(dataId)
529 # sanity/consistency checking
530 filt = exp.filter.physicalLabel
531 expRecord = getExpRecordFromDataId(self.butler, dataId)
532 obj = expRecord.target_name
533 objects.add(obj)
534 filters.add(filt)
535 if doCheckDispersed:
536 assert not isDispersedExp(exp), f"Image is dispersed! (filter = {filt})"
537 assert len(filters) == 1, "You accidentally mixed filters!"
538 assert len(objects) == 1, "You accidentally mixed objects!"
540 imExam = ImageExaminer(
541 exp, centroid=manualCentroid, doTweakCentroid=True, boxHalfSize=105, doForceCoM=doForceCoM
542 )
543 if doDisplay:
544 imExam.plot()
546 fwhm = imExam.imStats.fitFwhm
547 amp = imExam.imStats.fitAmp
548 gausMean = imExam.imStats.fitGausMean
549 centroid = imExam.centroid
551 if seqNum == seqNums[0]:
552 firstCentroid = centroid
554 dist = norm(np.array(centroid) - np.array(firstCentroid))
555 if dist > maxDistance:
556 print(f"Skipping {seqNum} because distance {dist}> maxDistance {maxDistance}")
558 fitData[seqNum]["fitResult"] = FitResult(amp=amp, mean=gausMean, sigma=fwhm * FWHMTOSIGMA)
559 fitData[seqNum]["eeRadius50"] = imExam.imStats.eeRadius50
560 fitData[seqNum]["eeRadius80"] = imExam.imStats.eeRadius80
561 fitData[seqNum]["eeRadius90"] = imExam.imStats.eeRadius90
563 focuserPosition = getFocusFromExposure(exp)
564 fitData[seqNum]["focus"] = focuserPosition
566 self.fitData = fitData
567 self.filter = filters.pop()
568 self.object = objects.pop()
570 return
572 def fitDataAndPlot(self) -> dict:
573 """Fit a parabola to each width metric, returning their best focuses.
575 For each of the [Gaussian fit, 50%, 80%, 90% encircled energy] metrics,
576 fit a parabola and return the focus value at which the minimum is
577 found.
579 Returns
580 -------
581 result : `dict` of `float`
582 A dict of the fit minima keyed by the metric it is the minimum for.
583 """
584 fitData = self.fitData
586 labelFontSize = 14
588 arcminToPixel = 10
590 fig = plt.figure(figsize=(10, 10)) # noqa
591 gs = gridspec.GridSpec(2, 1, height_ratios=[1, 1])
593 seqNums = sorted(fitData.keys())
594 widths = [fitData[seqNum]["fitResult"].sigma * SIGMATOFWHM / arcminToPixel for seqNum in seqNums]
595 focusPositions = [fitData[seqNum]["focus"] for seqNum in seqNums]
596 fineXs = np.linspace(np.min(focusPositions), np.max(focusPositions), 101)
598 fwhmFitPars = np.polyfit(focusPositions, widths, 2)
599 fwhmFitMin = -fwhmFitPars[1] / (2.0 * fwhmFitPars[0])
601 ax0 = plt.subplot(gs[0])
602 ax0.scatter(focusPositions, widths, c="k")
603 ax0.set_ylabel("FWHM (arcsec)", fontsize=labelFontSize)
604 ax0.plot(fineXs, np.poly1d(fwhmFitPars)(fineXs), "b-")
605 ax0.axvline(fwhmFitMin, c="r", ls="--")
607 ee90s = [fitData[seqNum]["eeRadius90"] for seqNum in seqNums]
608 ee80s = [fitData[seqNum]["eeRadius80"] for seqNum in seqNums]
609 ee50s = [fitData[seqNum]["eeRadius50"] for seqNum in seqNums]
610 ax1 = plt.subplot(gs[1], sharex=ax0)
611 ax1.scatter(focusPositions, ee90s, c="r", label="Encircled energy 90%")
612 ax1.scatter(focusPositions, ee80s, c="g", label="Encircled energy 80%")
613 ax1.scatter(focusPositions, ee50s, c="b", label="Encircled energy 50%")
615 ee90FitPars = np.polyfit(focusPositions, ee90s, 2)
616 ee90FitMin = -ee90FitPars[1] / (2.0 * ee90FitPars[0])
617 ee80FitPars = np.polyfit(focusPositions, ee80s, 2)
618 ee80FitMin = -ee80FitPars[1] / (2.0 * ee80FitPars[0])
619 ee50FitPars = np.polyfit(focusPositions, ee50s, 2)
620 ee50FitMin = -ee50FitPars[1] / (2.0 * ee50FitPars[0])
622 ax1.plot(fineXs, np.poly1d(ee90FitPars)(fineXs), "r-")
623 ax1.plot(fineXs, np.poly1d(ee80FitPars)(fineXs), "g-")
624 ax1.plot(fineXs, np.poly1d(ee50FitPars)(fineXs), "b-")
626 ax1.axvline(ee90FitMin, c="r", ls="--")
627 ax1.axvline(ee80FitMin, c="g", ls="--")
628 ax1.axvline(ee50FitMin, c="b", ls="--")
630 ax1.set_xlabel("User-applied focus offset (mm)", fontsize=labelFontSize)
631 ax1.set_ylabel("Radius (pixels)", fontsize=labelFontSize)
633 ax1.legend()
635 plt.subplots_adjust(hspace=0.0)
636 plt.show()
638 results = {
639 "fwhmFitMin": fwhmFitMin,
640 "ee90FitMin": ee90FitMin,
641 "ee80FitMin": ee80FitMin,
642 "ee50FitMin": ee50FitMin,
643 }
645 return results
648if __name__ == "__main__": 648 ↛ 650line 648 didn't jump to line 650, because the condition on line 648 was never true
649 # TODO: DM-34239 Move this to be a butler-driven test
650 analyzer = SpectralFocusAnalyzer()
651 # dataId = {'dayObs': '2020-02-20', 'seqNum': 485} # direct image
652 dataId = {"day_obs": 20200312}
653 seqNums = [121, 122]
654 analyzer.getFocusData(dataId["day_obs"], seqNums, doDisplay=True)
655 analyzer.fitDataAndPlot()