Coverage for python/lsst/summit/extras/focusAnalysis.py: 15%
284 statements
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-01 02:09 -0700
« prev ^ index » next coverage.py v6.5.0, created at 2022-11-01 02:09 -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/>.
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
55class SpectralFocusAnalyzer():
56 """Analyze a focus sweep taken for spectral data.
58 Take slices across the spectrum for each image, fitting a Gaussian to each
59 slice, and perform a parabolic fit to these widths. The number of slices
60 and their distances can be customized by calling setSpectrumBoxOffsets().
62 Nominal usage is something like:
64 %matplotlib inline
65 dayObs = 20210101
66 seqNums = [100, 101, 102, 103, 104]
67 focusAnalyzer = SpectralFocusAnalyzer()
68 focusAnalyzer.setSpectrumBoxOffsets([500, 750, 1000, 1250])
69 focusAnalyzer.getFocusData(dayObs, seqNums, doDisplay=True)
70 focusAnalyzer.fitDataAndPlot()
72 focusAnalyzer.run() can be used instead of the last two lines separately.
73 """
75 def __init__(self, **kwargs):
76 self.butler = makeDefaultLatissButler()
77 self._bestEffort = BestEffortIsr(**kwargs)
78 qfmTaskConfig = QuickFrameMeasurementTaskConfig()
79 self._quickMeasure = QuickFrameMeasurementTask(config=qfmTaskConfig)
81 self.spectrumHalfWidth = 100
82 self.spectrumBoxLength = 20
83 self._spectrumBoxOffsets = [882, 1170, 1467]
84 self._setColors(len(self._spectrumBoxOffsets))
86 def setSpectrumBoxOffsets(self, offsets):
87 """Set the current spectrum slice offsets.
89 Parameters
90 ----------
91 offsets : `list` of `float`
92 The distance at which to slice the spectrum, measured in pixels
93 from the main star's location.
94 """
95 self._spectrumBoxOffsets = offsets
96 self._setColors(len(offsets))
98 def getSpectrumBoxOffsets(self):
99 """Get the current spectrum slice offsets.
101 Returns
102 -------
103 offsets : `list` of `float`
104 The distance at which to slice the spectrum, measured in pixels
105 from the main star's location.
106 """
107 return self._spectrumBoxOffsets
109 def _setColors(self, nPoints):
110 self.COLORS = cm.rainbow(np.linspace(0, 1, nPoints))
112 @staticmethod
113 def _getFocusFromHeader(exp):
114 return float(exp.getMetadata()["FOCUSZ"])
116 def _getBboxes(self, centroid):
117 x, y = centroid
118 bboxes = []
120 for offset in self._spectrumBoxOffsets:
121 bbox = geom.Box2I(geom.Point2I(x-self.spectrumHalfWidth, y+offset),
122 geom.Point2I(x+self.spectrumHalfWidth, y+offset+self.spectrumBoxLength))
123 bboxes.append(bbox)
124 return bboxes
126 def _bboxToMplRectangle(self, bbox, colorNum):
127 xmin = bbox.getBeginX()
128 ymin = bbox.getBeginY()
129 xsize = bbox.getWidth()
130 ysize = bbox.getHeight()
131 rectangle = Rectangle((xmin, ymin), xsize, ysize, alpha=1, facecolor='none', lw=2,
132 edgecolor=self.COLORS[colorNum])
133 return rectangle
135 @staticmethod
136 def gauss(x, *pars):
137 amp, mean, sigma = pars
138 return amp*np.exp(-(x-mean)**2/(2.*sigma**2))
140 def run(self, dayObs, seqNums, doDisplay=False, hideFit=False, hexapodZeroPoint=0):
141 """Perform a focus sweep analysis for spectral data.
143 For each seqNum for the specified dayObs, take a slice through the
144 spectrum at y-offsets as specified by the offsets
145 (see get/setSpectrumBoxOffsets() for setting these) and fit a Gaussian
146 to the spectrum slice to measure its width.
148 For each offset distance, fit a parabola to the fitted spectral widths
149 and return the hexapod position at which the best focus was achieved
150 for each.
152 Parameters
153 ----------
154 dayObs : `int`
155 The dayObs to use.
156 seqNums : `list` of `int`
157 The seqNums for the focus sweep to analyze.
158 doDisplay : `bool`
159 Show the plots? Designed to be used in a notebook with
160 %matplotlib inline.
161 hideFit : `bool`, optional
162 Hide the fit and just return the result?
163 hexapodZeroPoint : `float`, optional
164 Add a zeropoint offset to the hexapod axis?
166 Returns
167 -------
168 bestFits : `list` of `float`
169 A list of the best fit focuses, one for each spectral slice.
170 """
171 self.getFocusData(dayObs, seqNums, doDisplay=doDisplay)
172 bestFits = self.fitDataAndPlot(hideFit=hideFit, hexapodZeroPoint=hexapodZeroPoint)
173 return bestFits
175 def getFocusData(self, dayObs, seqNums, doDisplay=False):
176 """Perform a focus sweep analysis for spectral data.
178 For each seqNum for the specified dayObs, take a slice through the
179 spectrum at y-offsets as specified by the offsets
180 (see get/setSpectrumBoxOffsets() for setting these) and fit a Gaussian
181 to the spectrum slice to measure its width.
183 Parameters
184 ----------
185 dayObs : `int`
186 The dayObs to use.
187 seqNums : `list` of `int`
188 The seqNums for the focus sweep to analyze.
189 doDisplay : `bool`
190 Show the plots? Designed to be used in a notebook with
191 %matplotlib inline.
193 Notes
194 -----
195 Performs the focus analysis per-image, holding the data in the class.
196 Call fitDataAndPlot() after running this to perform the parabolic fit
197 to the focus data itself.
198 """
199 fitData = {}
200 filters = set()
201 objects = set()
203 for seqNum in seqNums:
204 fitData[seqNum] = {}
205 dataId = {'day_obs': dayObs, 'seq_num': seqNum, 'detector': 0}
206 exp = self._bestEffort.getExposure(dataId)
208 # sanity checking
209 filt = exp.filter.physicalLabel
210 expRecord = getExpRecordFromDataId(self.butler, dataId)
211 obj = expRecord.target_name
212 objects.add(obj)
213 filters.add(filt)
214 assert isDispersedExp(exp), f"Image is not dispersed! (filter = {filt})"
215 assert len(filters) == 1, "You accidentally mixed filters!"
216 assert len(objects) == 1, "You accidentally mixed objects!"
218 quickMeasResult = self._quickMeasure.run(exp)
219 centroid = quickMeasResult.brightestObjCentroid
220 spectrumSliceBboxes = self._getBboxes(centroid) # inside the loop due to centroid shifts
222 if doDisplay:
223 fig, axes = plt.subplots(1, 2, figsize=(18, 9))
224 exp.image.array[exp.image.array <= 0] = 0.001
225 axes[0].imshow(exp.image.array, norm=LogNorm(), origin='lower', cmap='gray_r')
226 plt.tight_layout()
227 arrowy, arrowx = centroid[0] - 400, centroid[1] # numpy is backwards
228 dx, dy = 0, 300
229 arrow = Arrow(arrowy, arrowx, dy, dx, width=200., color='red')
230 circle = Circle(centroid, radius=25, facecolor='none', color='red')
231 axes[0].add_patch(arrow)
232 axes[0].add_patch(circle)
233 for i, bbox in enumerate(spectrumSliceBboxes):
234 rect = self._bboxToMplRectangle(bbox, i)
235 axes[0].add_patch(rect)
237 for i, bbox in enumerate(spectrumSliceBboxes):
238 data1d = np.mean(exp[bbox].image.array, axis=0) # flatten
239 data1d -= np.median(data1d)
240 xs = np.arange(len(data1d))
242 # get rough estimates for fit
243 # can't use sigma from quickMeasResult due to SDSS shape
244 # failing on saturated starts, and fp.getShape() is weird
245 amp = np.max(data1d)
246 mean = np.argmax(data1d)
247 sigma = 20
248 p0 = amp, mean, sigma
250 try:
251 coeffs, var_matrix = curve_fit(self.gauss, xs, data1d, p0=p0)
252 except RuntimeError:
253 coeffs = (np.nan, np.nan, np.nan)
255 fitData[seqNum][i] = FitResult(amp=abs(coeffs[0]), mean=coeffs[1], sigma=abs(coeffs[2]))
256 if doDisplay:
257 axes[1].plot(xs, data1d, 'x', c=self.COLORS[i])
258 highResX = np.linspace(0, len(data1d), 1000)
259 if coeffs[0] is not np.nan:
260 axes[1].plot(highResX, self.gauss(highResX, *coeffs), 'k-')
262 if doDisplay: # show all color boxes together
263 plt.title(f'Fits to seqNum {seqNum}')
264 plt.show()
266 focuserPosition = self._getFocusFromHeader(exp)
267 fitData[seqNum]['focus'] = focuserPosition
269 self.fitData = fitData
270 self.filter = filters.pop()
271 self.object = objects.pop()
273 return
275 def fitDataAndPlot(self, hideFit=False, hexapodZeroPoint=0):
276 """Fit a parabola to each series of slices and return the best focus.
278 For each offset distance, fit a parabola to the fitted spectral widths
279 and return the hexapod position at which the best focus was achieved
280 for each.
282 Parameters
283 ----------
284 hideFit : `bool`, optional
285 Hide the fit and just return the result?
286 hexapodZeroPoint : `float`, optional
287 Add a zeropoint offset to the hexapod axis?
289 Returns
290 -------
291 bestFits : `list` of `float`
292 A list of the best fit focuses, one for each spectral slice.
293 """
294 data = self.fitData
295 filt = self.filter
296 obj = self.object
298 bestFits = []
300 titleFontSize = 18
301 legendFontSize = 12
302 labelFontSize = 14
304 arcminToPixel = 10
305 sigmaToFwhm = 2.355
307 f, axes = plt.subplots(2, 1, figsize=[10, 12])
308 focusPositions = [data[k]['focus']-hexapodZeroPoint for k in sorted(data.keys())]
309 fineXs = np.linspace(np.min(focusPositions), np.max(focusPositions), 101)
310 seqNums = sorted(data.keys())
312 nSpectrumSlices = len(data[list(data.keys())[0]])-1
313 pointsForLegend = [0.0 for offset in range(nSpectrumSlices)]
314 for spectrumSlice in range(nSpectrumSlices): # the blue/green/red slices through the spectrum
315 # for scatter plots, the color needs to be a single-row 2d array
316 thisColor = np.array([self.COLORS[spectrumSlice]])
318 amps = [data[seqNum][spectrumSlice].amp for seqNum in seqNums]
319 widths = [data[seqNum][spectrumSlice].sigma / arcminToPixel * sigmaToFwhm for seqNum in seqNums]
321 pointsForLegend[spectrumSlice] = axes[0].scatter(focusPositions, amps, c=thisColor)
322 axes[0].set_xlabel('Focus position (mm)', fontsize=labelFontSize)
323 axes[0].set_ylabel('Height (ADU)', fontsize=labelFontSize)
325 axes[1].scatter(focusPositions, widths, c=thisColor)
326 axes[1].set_xlabel('Focus position (mm)', fontsize=labelFontSize)
327 axes[1].set_ylabel('FWHM (arcsec)', fontsize=labelFontSize)
329 quadFitPars = np.polyfit(focusPositions, widths, 2)
330 if not hideFit:
331 axes[1].plot(fineXs, np.poly1d(quadFitPars)(fineXs), c=self.COLORS[spectrumSlice])
332 fitMin = -quadFitPars[1] / (2.0*quadFitPars[0])
333 bestFits.append(fitMin)
334 axes[1].axvline(fitMin, color=self.COLORS[spectrumSlice])
335 msg = f"Best focus offset = {np.round(fitMin, 2)}"
336 axes[1].text(fitMin, np.mean(widths), msg, horizontalalignment='right',
337 verticalalignment='center', rotation=90, color=self.COLORS[spectrumSlice],
338 fontsize=legendFontSize)
340 titleText = f"Focus curve for {obj} w/ {filt}"
341 plt.suptitle(titleText, fontsize=titleFontSize)
342 legendText = self._generateLegendText(nSpectrumSlices)
343 axes[0].legend(pointsForLegend, legendText, fontsize=legendFontSize)
344 axes[1].legend(pointsForLegend, legendText, fontsize=legendFontSize)
345 f.tight_layout(rect=[0, 0.03, 1, 0.95])
346 plt.show()
348 for i, bestFit in enumerate(bestFits):
349 print(f"Best fit for spectrum slice {i} = {bestFit:.4f}mm")
350 return bestFits
352 def _generateLegendText(self, nSpectrumSlices):
353 if nSpectrumSlices == 1:
354 return ['m=+1 spectrum slice']
355 if nSpectrumSlices == 2:
356 return ['m=+1 blue end', 'm=+1 red end']
358 legendText = []
359 legendText.append('m=+1 blue end')
360 for i in range(nSpectrumSlices-2):
361 legendText.append('m=+1 redder...')
362 legendText.append('m=+1 red end')
363 return legendText
366class NonSpectralFocusAnalyzer():
367 """Analyze a focus sweep taken for direct imaging data.
369 For each image, measure the FWHM of the main star and the 50/80/90%
370 encircled energy radii, and fit a parabola to get the position of best
371 focus.
373 Nominal usage is something like:
375 %matplotlib inline
376 dayObs = 20210101
377 seqNums = [100, 101, 102, 103, 104]
378 focusAnalyzer = NonSpectralFocusAnalyzer()
379 focusAnalyzer.getFocusData(dayObs, seqNums, doDisplay=True)
380 focusAnalyzer.fitDataAndPlot()
382 focusAnalyzer.run() can be used instead of the last two lines separately.
383 """
385 def __init__(self, **kwargs):
386 self.butler = makeDefaultLatissButler()
387 self._bestEffort = BestEffortIsr(**kwargs)
389 @staticmethod
390 def _getFocusFromHeader(exp):
391 return float(exp.getMetadata()["FOCUSZ"])
393 @staticmethod
394 def gauss(x, *pars):
395 amp, mean, sigma = pars
396 return amp*np.exp(-(x-mean)**2/(2.*sigma**2))
398 def run(self, dayObs, seqNums, *, manualCentroid=None, doCheckDispersed=True, doDisplay=False,
399 doForceCoM=False):
400 """Perform a focus sweep analysis for direct imaging data.
402 For each seqNum for the specified dayObs, run the image through imExam
403 and collect the widths from the Gaussian fit and the 50/80/90%
404 encircled energy metrics, saving the data in the class for fitting.
406 For each of the [Gaussian fit, 50%, 80%, 90% encircled energy] metrics,
407 fit a parabola and return the focus value at which the minimum is
408 found.
410 Parameters
411 ----------
412 dayObs : `int`
413 The dayObs to use.
414 seqNums : `list` of `int`
415 The seqNums for the focus sweep to analyze.
416 manualCentroid : `tuple` of `float`, optional
417 Use this as the centroid position instead of fitting each image.
418 doCheckDispersed : `bool`, optional
419 Check if any of the seqNums actually refer to dispersed images?
420 doDisplay : `bool`, optional
421 Show the plots? Designed to be used in a notebook with
422 %matplotlib inline.
423 doForceCoM : `bool`, optional
424 Force using centre-of-mass for centroiding?
426 Returns
427 -------
428 result : `dict` of `float`
429 A dict of the fit minima keyed by the metric it is the minimum for.
430 """
431 self.getFocusData(dayObs, seqNums, manualCentroid=manualCentroid, doCheckDispersed=doCheckDispersed,
432 doDisplay=doDisplay, doForceCoM=doForceCoM)
433 bestFit = self.fitDataAndPlot()
434 return bestFit
436 def getFocusData(self, dayObs, seqNums, *, manualCentroid=None, doCheckDispersed=True,
437 doDisplay=False, doForceCoM=False):
438 """Perform a focus sweep analysis for direct imaging data.
440 For each seqNum for the specified dayObs, run the image through imExam
441 and collect the widths from the Gaussian fit and the 50/80/90%
442 encircled energy metrics, saving the data in the class for fitting.
444 Parameters
445 ----------
446 dayObs : `int`
447 The dayObs to use.
448 seqNums : `list` of `int`
449 The seqNums for the focus sweep to analyze.
450 manualCentroid : `tuple` of `float`, optional
451 Use this as the centroid position instead of fitting each image.
452 doCheckDispersed : `bool`, optional
453 Check if any of the seqNums actually refer to dispersed images?
454 doDisplay : `bool`, optional
455 Show the plots? Designed to be used in a notebook with
456 %matplotlib inline.
457 doForceCoM : `bool`, optional
458 Force using centre-of-mass for centroiding?
460 Notes
461 -----
462 Performs the focus analysis per-image, holding the data in the class.
463 Call fitDataAndPlot() after running this to perform the parabolic fit
464 to the focus data itself.
465 """
466 fitData = {}
467 filters = set()
468 objects = set()
470 maxDistance = 200
471 firstCentroid = None
473 for seqNum in seqNums:
474 fitData[seqNum] = {}
475 dataId = {'day_obs': dayObs, 'seq_num': seqNum, 'detector': 0}
476 exp = self._bestEffort.getExposure(dataId)
478 # sanity/consistency checking
479 filt = exp.filter.physicalLabel
480 expRecord = getExpRecordFromDataId(self.butler, dataId)
481 obj = expRecord.target_name
482 objects.add(obj)
483 filters.add(filt)
484 if doCheckDispersed:
485 assert not isDispersedExp(exp), f"Image is dispersed! (filter = {filt})"
486 assert len(filters) == 1, "You accidentally mixed filters!"
487 assert len(objects) == 1, "You accidentally mixed objects!"
489 imExam = ImageExaminer(exp, centroid=manualCentroid, doTweakCentroid=True, boxHalfSize=105,
490 doForceCoM=doForceCoM)
491 if doDisplay:
492 imExam.plot()
494 fwhm = imExam.imStats.fitFwhm
495 amp = imExam.imStats.fitAmp
496 gausMean = imExam.imStats.fitGausMean
497 centroid = imExam.centroid
499 if seqNum == seqNums[0]:
500 firstCentroid = centroid
502 dist = norm(np.array(centroid) - np.array(firstCentroid))
503 if dist > maxDistance:
504 print(f"Skipping {seqNum} because distance {dist}> maxDistance {maxDistance}")
506 fitData[seqNum]['fitResult'] = FitResult(amp=amp, mean=gausMean, sigma=fwhm*FWHMTOSIGMA)
507 fitData[seqNum]['eeRadius50'] = imExam.imStats.eeRadius50
508 fitData[seqNum]['eeRadius80'] = imExam.imStats.eeRadius80
509 fitData[seqNum]['eeRadius90'] = imExam.imStats.eeRadius90
511 focuserPosition = self._getFocusFromHeader(exp)
512 fitData[seqNum]['focus'] = focuserPosition
514 self.fitData = fitData
515 self.filter = filters.pop()
516 self.object = objects.pop()
518 return
520 def fitDataAndPlot(self):
521 """Fit a parabola to each width metric, returning their best focuses.
523 For each of the [Gaussian fit, 50%, 80%, 90% encircled energy] metrics,
524 fit a parabola and return the focus value at which the minimum is
525 found.
527 Returns
528 -------
529 result : `dict` of `float`
530 A dict of the fit minima keyed by the metric it is the minimum for.
531 """
532 fitData = self.fitData
534 labelFontSize = 14
536 arcminToPixel = 10
538 fig = plt.figure(figsize=(10, 10)) # noqa
539 gs = gridspec.GridSpec(2, 1, height_ratios=[1, 1])
541 seqNums = sorted(fitData.keys())
542 widths = [fitData[seqNum]['fitResult'].sigma * SIGMATOFWHM / arcminToPixel for seqNum in seqNums]
543 focusPositions = [fitData[seqNum]['focus'] for seqNum in seqNums]
544 fineXs = np.linspace(np.min(focusPositions), np.max(focusPositions), 101)
546 fwhmFitPars = np.polyfit(focusPositions, widths, 2)
547 fwhmFitMin = -fwhmFitPars[1] / (2.0*fwhmFitPars[0])
549 ax0 = plt.subplot(gs[0])
550 ax0.scatter(focusPositions, widths, c='k')
551 ax0.set_ylabel('FWHM (arcsec)', fontsize=labelFontSize)
552 ax0.plot(fineXs, np.poly1d(fwhmFitPars)(fineXs), 'b-')
553 ax0.axvline(fwhmFitMin, c='r', ls='--')
555 ee90s = [fitData[seqNum]['eeRadius90'] for seqNum in seqNums]
556 ee80s = [fitData[seqNum]['eeRadius80'] for seqNum in seqNums]
557 ee50s = [fitData[seqNum]['eeRadius50'] for seqNum in seqNums]
558 ax1 = plt.subplot(gs[1], sharex=ax0)
559 ax1.scatter(focusPositions, ee90s, c='r', label='Encircled energy 90%')
560 ax1.scatter(focusPositions, ee80s, c='g', label='Encircled energy 80%')
561 ax1.scatter(focusPositions, ee50s, c='b', label='Encircled energy 50%')
563 ee90FitPars = np.polyfit(focusPositions, ee90s, 2)
564 ee90FitMin = -ee90FitPars[1] / (2.0*ee90FitPars[0])
565 ee80FitPars = np.polyfit(focusPositions, ee80s, 2)
566 ee80FitMin = -ee80FitPars[1] / (2.0*ee80FitPars[0])
567 ee50FitPars = np.polyfit(focusPositions, ee50s, 2)
568 ee50FitMin = -ee50FitPars[1] / (2.0*ee50FitPars[0])
570 ax1.plot(fineXs, np.poly1d(ee90FitPars)(fineXs), 'r-')
571 ax1.plot(fineXs, np.poly1d(ee80FitPars)(fineXs), 'g-')
572 ax1.plot(fineXs, np.poly1d(ee50FitPars)(fineXs), 'b-')
574 ax1.axvline(ee90FitMin, c='r', ls='--')
575 ax1.axvline(ee80FitMin, c='g', ls='--')
576 ax1.axvline(ee50FitMin, c='b', ls='--')
578 ax1.set_xlabel('User-applied focus offset (mm)', fontsize=labelFontSize)
579 ax1.set_ylabel('Radius (pixels)', fontsize=labelFontSize)
581 ax1.legend()
583 plt.subplots_adjust(hspace=.0)
584 plt.show()
586 results = {"fwhmFitMin": fwhmFitMin,
587 "ee90FitMin": ee90FitMin,
588 "ee80FitMin": ee80FitMin,
589 "ee50FitMin": ee50FitMin}
591 return results
594if __name__ == '__main__': 594 ↛ 596line 594 didn't jump to line 596, because the condition on line 594 was never true
595 # TODO: DM-34239 Move this to be a butler-driven test
596 analyzer = SpectralFocusAnalyzer()
597 # dataId = {'dayObs': '2020-02-20', 'seqNum': 485} # direct image
598 dataId = {'day_obs': 20200312}
599 seqNums = [121, 122]
600 analyzer.getFocusData(dataId['day_obs'], seqNums, doDisplay=True)
601 analyzer.fitDataAndPlot()