Coverage for python/lsst/summit/extras/focusAnalysis.py: 14%
280 statements
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-10 03:54 -0800
« prev ^ index » next coverage.py v6.5.0, created at 2023-03-10 03:54 -0800
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/>.
22import lsst.geom as geom
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
30from dataclasses import dataclass
32import numpy as np
33from scipy.optimize import curve_fit
34from scipy.linalg import norm
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
42from lsst.pipe.tasks.quickFrameMeasurement import QuickFrameMeasurementTask, QuickFrameMeasurementTaskConfig
43from lsst.summit.utils.butlerUtils import (makeDefaultLatissButler, getExpRecordFromDataId)
45__all__ = ["SpectralFocusAnalyzer", "NonSpectralFocusAnalyzer"]
48@dataclass
49class FitResult:
50 amp: float
51 mean: float
52 sigma: float
55def getFocusFromExposure(exp):
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=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):
107 """Set the current spectrum slice offsets.
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))
118 def getSpectrumBoxOffsets(self):
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):
130 self.COLORS = cm.rainbow(np.linspace(0, 1, nPoints))
132 def _getBboxes(self, centroid):
133 x, y = centroid
134 bboxes = []
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
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
151 @staticmethod
152 def gauss(x, *pars):
153 amp, mean, sigma = pars
154 return amp*np.exp(-(x-mean)**2/(2.*sigma**2))
156 def run(self, dayObs, seqNums, doDisplay=False, hideFit=False, hexapodZeroPoint=0):
157 """Perform a focus sweep analysis for spectral data.
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.
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.
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?
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
191 def getFocusData(self, dayObs, seqNums, doDisplay=False):
192 """Perform a focus sweep analysis for spectral data.
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.
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.
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()
219 for seqNum in seqNums:
220 fitData[seqNum] = {}
221 dataId = {'day_obs': dayObs, 'seq_num': seqNum, 'detector': 0}
222 exp = self._bestEffort.getExposure(dataId)
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!"
234 quickMeasResult = self._quickMeasure.run(exp)
235 centroid = quickMeasResult.brightestObjCentroid
236 spectrumSliceBboxes = self._getBboxes(centroid) # inside the loop due to centroid shifts
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)
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))
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
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)
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-')
278 if doDisplay: # show all color boxes together
279 plt.title(f'Fits to seqNum {seqNum}')
280 plt.show()
282 focuserPosition = getFocusFromExposure(exp)
283 fitData[seqNum]['focus'] = focuserPosition
285 self.fitData = fitData
286 self.filter = filters.pop()
287 self.object = objects.pop()
289 return
291 def fitDataAndPlot(self, hideFit=False, hexapodZeroPoint=0):
292 """Fit a parabola to each series of slices and return the best focus.
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.
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?
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
314 bestFits = []
316 titleFontSize = 18
317 legendFontSize = 12
318 labelFontSize = 14
320 arcminToPixel = 10
321 sigmaToFwhm = 2.355
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())
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]])
334 amps = [data[seqNum][spectrumSlice].amp for seqNum in seqNums]
335 widths = [data[seqNum][spectrumSlice].sigma / arcminToPixel * sigmaToFwhm for seqNum in seqNums]
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)
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)
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)
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()
364 for i, bestFit in enumerate(bestFits):
365 print(f"Best fit for spectrum slice {i} = {bestFit:.4f}mm")
366 return bestFits
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']
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
382class NonSpectralFocusAnalyzer():
383 """Analyze a focus sweep taken for direct imaging data.
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.
389 Nominal usage is something like:
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()
398 focusAnalyzer.run() can be used instead of the last two lines separately.
399 """
401 def __init__(self, embargo=False):
402 self.butler = makeDefaultLatissButler(embargo=embargo)
403 self._bestEffort = BestEffortIsr(embargo=embargo)
405 @staticmethod
406 def gauss(x, *pars):
407 amp, mean, sigma = pars
408 return amp*np.exp(-(x-mean)**2/(2.*sigma**2))
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.
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.
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.
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?
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
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.
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.
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?
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()
482 maxDistance = 200
483 firstCentroid = None
485 for seqNum in seqNums:
486 fitData[seqNum] = {}
487 dataId = {'day_obs': dayObs, 'seq_num': seqNum, 'detector': 0}
488 exp = self._bestEffort.getExposure(dataId)
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!"
501 imExam = ImageExaminer(exp, centroid=manualCentroid, doTweakCentroid=True, boxHalfSize=105,
502 doForceCoM=doForceCoM)
503 if doDisplay:
504 imExam.plot()
506 fwhm = imExam.imStats.fitFwhm
507 amp = imExam.imStats.fitAmp
508 gausMean = imExam.imStats.fitGausMean
509 centroid = imExam.centroid
511 if seqNum == seqNums[0]:
512 firstCentroid = centroid
514 dist = norm(np.array(centroid) - np.array(firstCentroid))
515 if dist > maxDistance:
516 print(f"Skipping {seqNum} because distance {dist}> maxDistance {maxDistance}")
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
523 focuserPosition = getFocusFromExposure(exp)
524 fitData[seqNum]['focus'] = focuserPosition
526 self.fitData = fitData
527 self.filter = filters.pop()
528 self.object = objects.pop()
530 return
532 def fitDataAndPlot(self):
533 """Fit a parabola to each width metric, returning their best focuses.
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.
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
546 labelFontSize = 14
548 arcminToPixel = 10
550 fig = plt.figure(figsize=(10, 10)) # noqa
551 gs = gridspec.GridSpec(2, 1, height_ratios=[1, 1])
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)
558 fwhmFitPars = np.polyfit(focusPositions, widths, 2)
559 fwhmFitMin = -fwhmFitPars[1] / (2.0*fwhmFitPars[0])
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='--')
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%')
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])
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-')
586 ax1.axvline(ee90FitMin, c='r', ls='--')
587 ax1.axvline(ee80FitMin, c='g', ls='--')
588 ax1.axvline(ee50FitMin, c='b', ls='--')
590 ax1.set_xlabel('User-applied focus offset (mm)', fontsize=labelFontSize)
591 ax1.set_ylabel('Radius (pixels)', fontsize=labelFontSize)
593 ax1.legend()
595 plt.subplots_adjust(hspace=.0)
596 plt.show()
598 results = {"fwhmFitMin": fwhmFitMin,
599 "ee90FitMin": ee90FitMin,
600 "ee80FitMin": ee80FitMin,
601 "ee50FitMin": ee50FitMin}
603 return results
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()