Coverage for python/lsst/summit/utils/nightReport.py: 11%
337 statements
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-24 12:14 +0000
« prev ^ index » next coverage.py v7.3.2, created at 2023-11-24 12:14 +0000
1# This file is part of summit_utils.
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 pickle
23import logging
25from dataclasses import dataclass
26import numpy as np
27import matplotlib
28import matplotlib.pyplot as plt
29from matplotlib.pyplot import cm
31from lsst.utils.iteration import ensure_iterable
32from astro_metadata_translator import ObservationInfo
33from .utils import obsInfoToDict, getFieldNameAndTileNumber
35try: # TODO: Remove post RFC-896: add humanize to rubin-env
36 from humanize.time import precisedelta
37 HAVE_HUMANIZE = True
38except ImportError:
39 # log a python warning about the lack of humanize
40 logging.warning("humanize not available, install it to get better time printing")
41 HAVE_HUMANIZE = False
42 precisedelta = repr
45__all__ = ['NightReport']
47CALIB_VALUES = ['FlatField position', 'Park position', 'azel_target', 'slew_icrs',
48 'DaytimeCheckout001', 'DaytimeCheckout002']
49N_STARS_PER_SYMBOL = 6
50MARKER_SEQUENCE = ['*', 'o', "D", 'P', 'v', "^", 's', 'o', 'v', '^', '<', '>',
51 '1', '2', '3', '4', '8', 's', 'p', 'P', '*', 'h', 'H', '+',
52 'x', 'X', 'D', 'd', '|', '_']
53SOUTHPOLESTAR = 'HD 185975'
56@dataclass
57class ColorAndMarker:
58 '''Class for holding colors and marker symbols'''
59 color: list
60 marker: str = '*'
63class NightReport():
64 _version = 1
66 def __init__(self, butler, dayObs, loadFromFile=None):
67 self._supressAstroMetadataTranslatorWarnings() # call early
68 self.log = logging.getLogger('lsst.summit.utils.NightReport')
69 self.butler = butler
70 self.dayObs = dayObs
71 self.data = dict()
72 self._expRecordsLoaded = set() # set of the expRecords loaded
73 self._obsInfosLoaded = set() # set of the seqNums loaded
74 self.stars = None
75 self.cMap = None
76 if loadFromFile is not None:
77 self._load(loadFromFile)
78 self.rebuild() # sets stars and cMap
80 def _supressAstroMetadataTranslatorWarnings(self):
81 """NB: must be called early"""
82 logging.basicConfig()
83 logger = logging.getLogger("lsst.obs.lsst.translators.latiss")
84 logger.setLevel(logging.ERROR)
85 logger = logging.getLogger("astro_metadata_translator.observationInfo")
86 logger.setLevel(logging.ERROR)
88 def save(self, filename):
89 """Save the internal data to a file.
91 Parameters
92 ----------
93 filename : `str`
94 The full name and path of the file to save to.
95 """
96 toSave = dict(data=self.data,
97 _expRecordsLoaded=self._expRecordsLoaded,
98 _obsInfosLoaded=self._obsInfosLoaded,
99 dayObs=self.dayObs,
100 version=self._version)
101 with open(filename, "wb") as f:
102 pickle.dump(toSave, f, pickle.HIGHEST_PROTOCOL)
104 def _load(self, filename):
105 """Load the report data from a file.
107 Called on init if loadFromFile is not None. Should not be used directly
108 as other things are populated on load in the __init__.
110 Parameters
111 ----------
112 filename : `str`
113 The full name and path of the file to load from.
114 """
115 with open(filename, "rb") as f:
116 loaded = pickle.load(f)
117 self.data = loaded['data']
118 self._expRecordsLoaded = loaded['_expRecordsLoaded']
119 self._obsInfosLoaded = loaded['_obsInfosLoaded']
120 dayObs = loaded['dayObs']
121 loadedVersion = loaded.get('version', 0)
123 if dayObs != self.dayObs:
124 raise RuntimeError(f"Loaded data is for {dayObs} but current dayObs is {self.dayObs}")
125 if loadedVersion < self._version:
126 self.log.critical(f"Loaded version is {loadedVersion} but current version is {self._version}."
127 " Check carefully for compatibility issues/regenerate your saved report!")
128 # update to the version on the instance in case the report is
129 # re-saved.
130 self._version = loadedVersion
131 assert len(self.data) == len(self._expRecordsLoaded)
132 assert len(self.data) == len(self._obsInfosLoaded)
133 self.log.info(f"Loaded {len(self.data)} records from {filename}")
135 @staticmethod
136 def _getSortedData(data):
137 """Get a sorted copy of the internal data.
138 """
139 if list(data.keys()) == sorted(data.keys()):
140 return data
141 else:
142 return {k: data[k] for k in sorted(data.keys())}
144 def getExpRecordDictForDayObs(self, dayObs):
145 """Get all the exposureRecords as dicts for the current dayObs.
147 Notes
148 -----
149 Runs in ~0.05s for 1000 records.
150 """
151 expRecords = self.butler.registry.queryDimensionRecords("exposure",
152 where="exposure.day_obs=day_obs",
153 bind={'day_obs': dayObs},
154 datasets='raw')
155 expRecords = list(expRecords)
156 records = {e.seq_num: e.toDict() for e in expRecords} # not guaranteed to be in order
157 for record in records.values():
158 target = record['target_name'] if record['target_name'] is not None else ''
159 if target:
160 shortTarget, _ = getFieldNameAndTileNumber(target, warn=False)
161 else:
162 shortTarget = ''
163 record['target_name_short'] = shortTarget
164 return self._getSortedData(records)
166 def getObsInfoAndMetadataForSeqNum(self, seqNum):
167 """Get the obsInfo and metadata for a given seqNum.
169 TODO: Once we have a summit repo containing all this info, remove this
170 method and all scraping of headers! Probably also remove the save/load
171 functionalty there too, as the whole init will go from many minutes to
172 under a second.
174 Parameters
175 ----------
176 seqNum : `int`
177 The seqNum.
179 Returns
180 -------
181 obsInfo : `astro_metadata_translator.ObservationInfo`
182 The obsInfo.
183 md : `dict`
184 The raw metadata.
186 Notes
187 -----
188 Very slow, as it has to load the whole file on object store repos
189 and access the file on regular filesystem repos.
190 """
191 dataId = {'day_obs': self.dayObs, 'seq_num': seqNum, 'detector': 0}
192 md = self.butler.get('raw.metadata', dataId)
193 return ObservationInfo(md), md.toDict()
195 def rebuild(self, full=False):
196 """Scrape new data if there is any, otherwise is a no-op.
198 If full is True, then all data is reloaded.
200 Parameters
201 ----------
202 full : `bool`, optional
203 Do a full reload of all the data, removing any which is pre-loaded?
204 """
205 if full:
206 self.data = dict()
207 self._expRecordsLoaded = set()
208 self._obsInfosLoaded = set()
210 records = self.getExpRecordDictForDayObs(self.dayObs)
211 if len(records) == len(self.data): # nothing to do
212 self.log.info('No new records found')
213 # NB don't return here, because we need to rebuild the
214 # star maps etc if we came from a file.
215 else:
216 # still need to merge the new expRecordDicts into self.data
217 # but only these, as the other items have obsInfos merged into them
218 for seqNum in list(records.keys() - self._expRecordsLoaded):
219 self.data[seqNum] = records[seqNum]
220 self._expRecordsLoaded.add(seqNum)
222 # now load all the obsInfos
223 seqNums = list(records.keys())
224 obsInfosToLoad = set(seqNums) - self._obsInfosLoaded
225 if obsInfosToLoad:
226 self.log.info(f"Loading {len(obsInfosToLoad)} obsInfo(s)")
227 for i, seqNum in enumerate(obsInfosToLoad):
228 if (i + 1) % 200 == 0:
229 self.log.info(f"Loaded {i+1} obsInfos")
230 obsInfo, metadata = self.getObsInfoAndMetadataForSeqNum(seqNum)
231 obsInfoDict = obsInfoToDict(obsInfo)
232 records[seqNum].update(obsInfoDict)
233 # _raw_metadata item will hopefully not be needed in the future
234 # but add it while we have it for free, as it has DIMM seeing
235 records[seqNum]['_raw_metadata'] = metadata
236 self._obsInfosLoaded.add(seqNum)
238 self.data = self._getSortedData(self.data) # make sure we stay sorted
239 self.stars = self.getObservedObjects()
240 self.cMap = self.makeStarColorAndMarkerMap(self.stars)
242 def getDatesForSeqNums(self):
243 """Get a dict of {seqNum: date} for the report.
245 Returns
246 -------
247 dates : `dict`
248 Dict of {seqNum: date} for the current report.
249 """
250 return {seqNum: self.data[seqNum]['timespan'].begin.to_datetime()
251 for seqNum in sorted(self.data.keys())}
253 def getObservedObjects(self, ignoreTileNum=True):
254 """Get a list of the observed objects for the night.
256 Repeated observations of individual imaging fields have _NNN appended
257 to the field name. Use ``ignoreTileNum`` to remove these, collapsing
258 the observations of the field to a single target name.
260 Parameters
261 ----------
262 ignoreTileNum : `bool`, optional
263 Remove the trailing _NNN tile number for imaging fields?
264 """
265 key = 'target_name_short' if ignoreTileNum else 'target_name'
266 allTargets = sorted({record[key] if record[key] is not None else ''
267 for record in self.data.values()})
268 return allTargets
270 def getSeqNumsMatching(self, invert=False, subset=None, **kwargs):
271 """Get seqNums which match/don't match all kwargs provided, e.g.
273 report.getSeqNumsMatching(exposure_time=30,
274 target_name='ETA1 DOR')
276 Set invert=True to get all seqNums which don't match the provided
277 args, e.g. to find all seqNums which are not calibs
279 Subset allows for repeated filtering by passing in a set of seqNums
280 """
281 # copy data so we can pop, and restrict to subset if provided
282 local = {seqNum: rec for seqNum, rec in self.data.items() if (subset is None or seqNum in subset)}
284 # for each kwarg, filter out items which match/don't
285 for filtAttr, filtVal in kwargs.items():
286 toPop = [] # can't pop inside inner loop so collect
287 for seqNum, record in local.items():
288 v = record.get(filtAttr)
289 if invert:
290 if v == filtVal:
291 toPop.append(seqNum)
292 else:
293 if v != filtVal:
294 toPop.append(seqNum)
295 [local.pop(seqNum) for seqNum in toPop]
297 return sorted(local.keys())
299 def printAvailableKeys(self, sample=False, includeRaw=False):
300 """Print all the keys available to query on, optionally including the
301 full set of header keys.
303 Note that there is a big mix of quantities, some are int/float/string
304 but some are astropy quantities.
306 If sample is True, then a sample value for each key is printed too,
307 which is useful for dealing with types and seeing what each item
308 actually means.
309 """
310 for seqNum, recordDict in self.data.items(): # loop + break because we don't know the first seqNum
311 for k, v in recordDict.items():
312 if sample:
313 print(f"{k}: {v}")
314 else:
315 print(k)
316 if includeRaw:
317 print("\nRaw header keys in _raw_metadata:")
318 for k in recordDict['_raw_metadata']:
319 print(k)
320 break
322 @staticmethod
323 def makeStarColorAndMarkerMap(stars):
324 """Create a color/marker map for a list of observed objects.
325 """
326 markerMap = {}
327 colors = cm.rainbow(np.linspace(0, 1, N_STARS_PER_SYMBOL))
328 for i, star in enumerate(stars):
329 markerIndex = i//(N_STARS_PER_SYMBOL)
330 colorIndex = i%(N_STARS_PER_SYMBOL)
331 markerMap[star] = ColorAndMarker(colors[colorIndex], MARKER_SEQUENCE[markerIndex])
332 return markerMap
334 def calcShutterTimes(self):
335 """Calculate the total time spent on science, engineering and readout.
337 Science and engineering time both include the time spent on readout,
338 such that if images were taken all night with no downtime and no slews
339 the efficiency would be 100%.
341 Returns
342 -------
343 timings : `dict`
344 Dictionary of the various calculated times, in seconds, and the
345 seqNums of the first and last observations used in the calculation.
346 """
347 firstObs = self.getObservingStartSeqNum(method='heuristic')
348 if not firstObs:
349 self.log.warning("No on-sky observations found.")
350 return None
351 lastObs = max(self.data.keys())
353 begin = self.data[firstObs]['datetime_begin']
354 end = self.data[lastObs]['datetime_end']
356 READOUT_TIME = 2.0
357 shutterOpenTime = sum([self.data[s]['exposure_time'] for s in range(firstObs, lastObs + 1)])
358 readoutTime = sum([READOUT_TIME for _ in range(firstObs, lastObs + 1)])
360 sciSeqNums = self.getSeqNumsMatching(observation_type='science')
361 scienceIntegration = sum([self.data[s]['exposure_time'] for s in sciSeqNums])
362 scienceTimeTotal = scienceIntegration.value + (len(sciSeqNums)*READOUT_TIME)
364 result = {}
365 result['firstObs'] = firstObs
366 result['lastObs'] = lastObs
367 result['startTime'] = begin
368 result['endTime'] = end
369 result['nightLength'] = (end - begin).sec # was a datetime.timedelta
370 result['shutterOpenTime'] = shutterOpenTime.value # was an Quantity
371 result['readoutTime'] = readoutTime
372 result['scienceIntegration'] = scienceIntegration.value # was an Quantity
373 result['scienceTimeTotal'] = scienceTimeTotal
375 return result
377 def printShutterTimes(self):
378 """Print out the shutter efficiency stats in a human-readable format.
379 """
380 if not HAVE_HUMANIZE:
381 self.log.warning('Please install humanize to make this print as intended.')
382 timings = self.calcShutterTimes()
383 if not timings:
384 print('No on-sky observations found, so no shutter efficiency stats are available yet.')
385 return
387 print(f"Observations started at: seqNum {timings['firstObs']:>3} at"
388 f" {timings['startTime'].to_datetime().strftime('%H:%M:%S')} TAI")
389 print(f"Observations ended at: seqNum {timings['lastObs']:>3} at"
390 f" {timings['endTime'].to_datetime().strftime('%H:%M:%S')} TAI")
391 print(f"Total time on sky: {precisedelta(timings['nightLength'])}")
392 print()
393 print(f"Shutter open time: {precisedelta(timings['shutterOpenTime'])}")
394 print(f"Readout time: {precisedelta(timings['readoutTime'])}")
395 engEff = 100 * (timings['shutterOpenTime'] + timings['readoutTime']) / timings['nightLength']
396 print(f"Engineering shutter efficiency = {engEff:.1f}%")
397 print()
398 print(f"Science integration: {precisedelta(timings['scienceIntegration'])}")
399 sciEff = 100*(timings['scienceTimeTotal'] / timings['nightLength'])
400 print(f"Science shutter efficiency = {sciEff:.1f}%")
402 def getTimeDeltas(self):
403 """Returns a dict, keyed by seqNum, of the time since the end of the
404 last integration. The time since does include the readout, so is always
405 greater than or equal to the readout time.
407 Returns
408 -------
409 timeGaps : `dict`
410 Dictionary of the time gaps, in seconds, keyed by seqNum.
411 """
412 seqNums = list(self.data.keys()) # need a list not a generator, and NB it might not be contiguous!
413 dts = [0] # first item is zero by definition
414 for i, seqNum in enumerate(seqNums[1:]):
415 dt = self.data[seqNum]['datetime_begin'] - self.data[(seqNums[i])]['datetime_end']
416 dts.append(dt.sec)
418 return {s: dt for s, dt in zip(seqNums, dts)}
420 def printObsGaps(self, threshold=100, includeCalibs=False):
421 """Print out the gaps between observations in a human-readable format.
423 Prints the most recent gaps first.
425 Parameters
426 ----------
427 threshold : `float`, optional
428 The minimum time gap to print out, in seconds.
429 includeCalibs : `bool`, optional
430 If True, start at the lowest seqNum, otherwise start when the
431 night's observing started.
432 """
433 if not HAVE_HUMANIZE:
434 self.log.warning('Please install humanize to make this print as intended.')
435 dts = self.getTimeDeltas()
437 allSeqNums = list(self.data.keys())
438 if includeCalibs:
439 seqNums = allSeqNums
440 else:
441 firstObs = self.getObservingStartSeqNum(method='heuristic')
442 if not firstObs:
443 print("No on-sky observations found, so there can be no gaps in observing yet.")
444 return
445 # there is always a big gap before firstObs by definition so add 1
446 startPoint = allSeqNums.index(firstObs) + 1
447 seqNums = allSeqNums[startPoint:]
449 messages = []
450 for seqNum in reversed(seqNums):
451 dt = dts[seqNum]
452 if dt > threshold:
453 messages.append(f"seqNum {seqNum:3}: {precisedelta(dt)} gap")
455 if messages:
456 print(f"Gaps between observations greater than {threshold}s:")
457 for line in messages:
458 print(line)
460 def getObservingStartSeqNum(self, method='safe'):
461 """Get the seqNum at which on-sky observations started.
463 If no on-sky observations were taken ``None`` is returned.
465 Parameters
466 ----------
467 method : `str`
468 The calculation method to use. Options are:
469 - 'safe': Use the first seqNum with an observation_type that is
470 explicitly not a calibration or test. This is a safe way of
471 excluding the calibs, but will include observations where we
472 take some closed dome test images, or start observing too early,
473 and go back to taking calibs for a while before the night starts.
474 - 'heuristic': Use a heuristic to find the first seqNum. The
475 current heuristic is to find the first seqNum with an observation
476 type of CWFS, as we always do a CWFS focus before going on sky.
477 This does not work well for old days, because this wasn't always
478 the way data was taken. Note: may be updated in the future, at
479 which point this will be renamed ``cwfs``.
481 Returns
482 -------
483 startSeqNum : `int`
484 The seqNum of the start of the night's observing.
485 """
486 allowedMethods = ['heuristic', 'safe']
487 if method not in allowedMethods:
488 raise ValueError(f"Method must be one of {allowedMethods}")
490 if method == 'safe':
491 # as of 20221211, the full set of observation_types ever seen is:
492 # acq, bias, cwfs, dark, engtest, flat, focus, science, stuttered,
493 # test, unknown
494 offSkyObsTypes = ['bias', 'dark', 'flat', 'test', 'unknown']
495 for seqNum in sorted(self.data.keys()):
496 if self.data[seqNum]['observation_type'] not in offSkyObsTypes:
497 return seqNum
498 return None
500 if method == 'heuristic':
501 # take the first cwfs image and return that
502 seqNums = self.getSeqNumsMatching(observation_type='cwfs')
503 if not seqNums:
504 self.log.warning('No cwfs images found, observing is assumed not to have started.')
505 return None
506 return min(seqNums)
508 def printObsTable(self, **kwargs):
509 """Print a table of the days observations.
511 Parameters
512 ----------
513 **kwargs : `dict`
514 Filter the observation table according to seqNums which match these
515 {k: v} pairs. For example, to only print out science observations
516 pass ``observation_type='science'``.
517 """
518 seqNums = self.data.keys() if not kwargs else self.getSeqNumsMatching(**kwargs)
519 seqNums = sorted(seqNums) # should always be sorted, but is a total disaster here if not
521 dts = self.getTimeDeltas()
522 lines = []
523 for seqNum in seqNums:
524 try:
525 expTime = self.data[seqNum]['exposure_time'].value
526 imageType = self.data[seqNum]['observation_type']
527 target = self.data[seqNum]['target_name']
528 deadtime = dts[seqNum]
529 filt = self.data[seqNum]['physical_filter']
531 msg = f'{seqNum} {target} {expTime:.1f} {deadtime:.02f} {imageType} {filt}'
532 except Exception:
533 msg = f"Error parsing {seqNum}!"
534 lines.append(msg)
536 print(r"seqNum target expTime deadtime imageType filt")
537 print(r"------ ------ ------- -------- --------- ----")
538 for line in lines:
539 print(line)
541 def getExposureMidpoint(self, seqNum):
542 """Return the midpoint of the exposure as a float in MJD.
544 Parameters
545 ----------
546 seqNum : `int`
547 The seqNum to get the midpoint for.
549 Returns
550 -------
551 midpoint : `datetime.datetime`
552 The midpoint, as a python datetime object.
553 """
554 timespan = self.data[seqNum]['timespan']
555 expTime = self.data[seqNum]['exposure_time']
556 return ((timespan.begin) + expTime / 2).to_datetime()
558 def plotPerObjectAirMass(self, objects=None, airmassOneAtTop=True, saveFig=''):
559 """Plot the airmass for objects observed over the course of the night.
561 Parameters
562 ----------
563 objects : `list` [`str`], optional
564 The objects to plot. If not provided, all objects are plotted.
565 airmassOneAtTop : `bool`, optional
566 Put the airmass of 1 at the top of the plot, like astronomers
567 expect.
568 saveFig : `str`, optional
569 Save the figure to this file path?
570 """
571 if not objects:
572 objects = self.stars
574 objects = ensure_iterable(objects)
576 plt.figure(figsize=(10, 6))
577 for star in objects:
578 if star in CALIB_VALUES:
579 continue
580 seqNums = self.getSeqNumsMatching(target_name_short=star)
581 airMasses = [self.data[seqNum]['boresight_airmass'] for seqNum in seqNums]
582 obsTimes = [self.getExposureMidpoint(seqNum) for seqNum in seqNums]
583 color = self.cMap[star].color
584 marker = self.cMap[star].marker
585 plt.plot(obsTimes, airMasses, color=color, marker=marker, label=star, ms=10, ls='')
587 plt.ylabel('Airmass', fontsize=20)
588 plt.xlabel('Time (UTC)', fontsize=20)
589 plt.xticks(rotation=25, horizontalalignment='right')
591 ax = plt.gca()
592 xfmt = matplotlib.dates.DateFormatter('%m-%d %H:%M:%S')
593 ax.xaxis.set_major_formatter(xfmt)
595 if airmassOneAtTop:
596 ax.set_ylim(ax.get_ylim()[::-1])
598 plt.legend(bbox_to_anchor=(1, 1.025), prop={'size': 15}, loc='upper left')
600 plt.tight_layout()
601 if saveFig:
602 plt.savefig(saveFig)
603 plt.show()
604 plt.close()
606 def _makePolarPlot(self, azimuthsInDegrees, zenithAngles, marker="*-",
607 title=None, makeFig=True, color=None, objName=None):
608 """Private method to actually do the polar plotting.
610 azimuthsInDegrees : `list` [`float`]
611 The azimuth values, in degrees.
612 zenithAngles : `list` [`float`]
613 The zenith angle values, but more generally, the values on the
614 radial axis, so can be in whatever units you want.
615 marker : `str`, optional
616 The marker to use.
617 title : `str`, optional
618 The plot title.
619 makeFig : `bool`, optional
620 Make a new figure?
621 color : `str`, optional
622 The marker color.
623 objName : `str`, optional
624 The object name, for the legend.
626 Returns
627 -------
628 ax : `matplotlib.axes.Axes`
629 The axes on which the plot was made.
630 """
631 if makeFig:
632 _ = plt.figure(figsize=(10, 10))
633 ax = plt.subplot(111, polar=True)
634 ax.plot([a*np.pi/180 for a in azimuthsInDegrees], zenithAngles, marker, c=color, label=objName)
635 if title:
636 ax.set_title(title, va='bottom')
637 ax.set_theta_zero_location("N")
638 ax.set_theta_direction(-1)
639 ax.set_rlim(0, 90)
640 return ax
642 def makeAltAzCoveragePlot(self, objects=None, withLines=False, saveFig=''):
643 """Make a polar plot of the azimuth and zenith angle for each object.
645 Plots the azimuth on the theta axis, and zenith angle (not altitude!)
646 on the radius axis, such that 0 is at the centre, like you're looking
647 top-down on the telescope.
649 Parameters
650 ----------
651 objects : `list` [`str`], optional
652 The objects to plot. If not provided, all objects are plotted.
653 withLines : `bool`, optional
654 Connect the points with lines?
655 saveFig : `str`, optional
656 Save the figure to this file path?
657 """
658 if not objects:
659 objects = self.stars
660 objects = ensure_iterable(objects)
662 _ = plt.figure(figsize=(14, 10))
664 for obj in objects:
665 if obj in CALIB_VALUES:
666 continue
667 seqNums = self.getSeqNumsMatching(target_name_short=obj)
668 altAzes = [self.data[seqNum]['altaz_begin'] for seqNum in seqNums]
669 alts = [altAz.alt.deg for altAz in altAzes if altAz is not None]
670 azes = [altAz.az.deg for altAz in altAzes if altAz is not None]
671 assert len(alts) == len(azes)
672 if len(azes) == 0:
673 self.log.warning(f"Found no alt/az data for {obj}")
674 zens = [90 - alt for alt in alts]
675 color = self.cMap[obj].color
676 marker = self.cMap[obj].marker
677 if withLines:
678 marker += '-'
680 ax = self._makePolarPlot(azes, zens, marker=marker, title=None, makeFig=False,
681 color=color, objName=obj)
682 lgnd = ax.legend(bbox_to_anchor=(1.05, 1), prop={'size': 15}, loc='upper left')
683 ax.set_title("Axial coverage - azimuth (theta, deg) vs zenith angle (r, deg)", size=20)
684 for h in lgnd.legendHandles:
685 size = 14
686 if '-' in marker:
687 size += 5
688 h.set_markersize(size)
690 plt.tight_layout()
691 if saveFig:
692 plt.savefig(saveFig)
693 plt.show()
694 plt.close()