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