Coverage for python/lsst/summit/utils/nightReport.py: 12%

343 statements  

« prev     ^ index     » next       coverage.py v7.5.0, created at 2024-05-03 04:43 -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/>. 

21 

22import datetime 

23import logging 

24import pickle 

25from collections.abc import Callable, Iterable 

26from dataclasses import dataclass 

27from typing import Any 

28 

29import matplotlib 

30import matplotlib.pyplot as plt 

31import numpy as np 

32from astro_metadata_translator import ObservationInfo 

33from matplotlib.pyplot import cm 

34 

35import lsst.daf.butler as dafButler 

36from lsst.utils.iteration import ensure_iterable 

37 

38from .utils import getFieldNameAndTileNumber, obsInfoToDict 

39 

40try: # TODO: Remove post RFC-896: add humanize to rubin-env 

41 precisedelta: "Callable[[Any], str]" 

42 from humanize.time import precisedelta 

43 

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 

50 

51 

52__all__ = ["NightReport"] 

53 

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" 

96 

97 

98@dataclass 

99class ColorAndMarker: 

100 """Class for holding colors and marker symbols""" 

101 

102 color: list 

103 marker: str = "*" 

104 

105 

106class NightReport: 

107 _version = 1 

108 

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 

122 

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) 

130 

131 def save(self, filename: str) -> None: 

132 """Save the internal data to a file. 

133 

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) 

148 

149 def _load(self, filename: str) -> None: 

150 """Load the report data from a file. 

151 

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__. 

154 

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) 

167 

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}") 

181 

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())} 

189 

190 def getExpRecordDictForDayObs(self, dayObs: int) -> dict: 

191 """Get all the exposureRecords as dicts for the current dayObs. 

192 

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) 

210 

211 def getObsInfoAndMetadataForSeqNum(self, seqNum: int) -> tuple[ObservationInfo, dict]: 

212 """Get the obsInfo and metadata for a given seqNum. 

213 

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. 

218 

219 Parameters 

220 ---------- 

221 seqNum : `int` 

222 The seqNum. 

223 

224 Returns 

225 ------- 

226 obsInfo : `astro_metadata_translator.ObservationInfo` 

227 The obsInfo. 

228 md : `dict` 

229 The raw metadata. 

230 

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() 

239 

240 def rebuild(self, full: bool = False) -> None: 

241 """Scrape new data if there is any, otherwise is a no-op. 

242 

243 If full is True, then all data is reloaded. 

244 

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() 

254 

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) 

266 

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) 

282 

283 self.data = self._getSortedData(self.data) # make sure we stay sorted 

284 self.stars = self.getObservedObjects() 

285 self.cMap = self.makeStarColorAndMarkerMap(self.stars) 

286 

287 def getDatesForSeqNums(self) -> dict[int, datetime.datetime]: 

288 """Get a dict of {seqNum: date} for the report. 

289 

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 } 

298 

299 def getObservedObjects(self, ignoreTileNum: bool = True) -> list[str]: 

300 """Get a list of the observed objects for the night. 

301 

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. 

305 

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 

314 

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. 

319 

320 report.getSeqNumsMatching(exposure_time=30, 

321 target_name='ETA1 DOR') 

322 

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 

325 

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)} 

330 

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] 

343 

344 return sorted(local.keys()) 

345 

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. 

349 

350 Note that there is a big mix of quantities, some are int/float/string 

351 but some are astropy quantities. 

352 

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 

368 

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 

379 

380 def calcShutterTimes(self) -> dict | None: 

381 """Calculate the total time spent on science, engineering and readout. 

382 

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%. 

386 

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()) 

398 

399 begin = self.data[firstObs]["datetime_begin"] 

400 end = self.data[lastObs]["datetime_end"] 

401 

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)]) 

405 

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) 

409 

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 

420 

421 return result 

422 

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 

433 

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}%") 

452 

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. 

457 

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) 

468 

469 return {s: dt for s, dt in zip(seqNums, dts)} 

470 

471 def printObsGaps(self, threshold: float | int = 100, includeCalibs: bool = False) -> None: 

472 """Print out the gaps between observations in a human-readable format. 

473 

474 Prints the most recent gaps first. 

475 

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() 

487 

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:] 

499 

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") 

505 

506 if messages: 

507 print(f"Gaps between observations greater than {threshold}s:") 

508 for line in messages: 

509 print(line) 

510 

511 def getObservingStartSeqNum(self, method: str = "safe") -> int | None: 

512 """Get the seqNum at which on-sky observations started. 

513 

514 If no on-sky observations were taken ``None`` is returned. 

515 

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``. 

531 

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}") 

540 

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 

550 

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 

559 

560 def printObsTable(self, **kwargs: Any) -> None: 

561 """Print a table of the days observations. 

562 

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 

572 

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"] 

582 

583 msg = f"{seqNum} {target} {expTime:.1f} {deadtime:.02f} {imageType} {filt}" 

584 except Exception: 

585 msg = f"Error parsing {seqNum}!" 

586 lines.append(msg) 

587 

588 print(r"seqNum target expTime deadtime imageType filt") 

589 print(r"------ ------ ------- -------- --------- ----") 

590 for line in lines: 

591 print(line) 

592 

593 def getExposureMidpoint(self, seqNum: int) -> datetime.datetime: 

594 """Return the midpoint of the exposure as a float in MJD. 

595 

596 Parameters 

597 ---------- 

598 seqNum : `int` 

599 The seqNum to get the midpoint for. 

600 

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() 

609 

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. 

614 

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 

627 

628 objects = ensure_iterable(objects) 

629 

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="") 

640 

641 plt.ylabel("Airmass", fontsize=20) 

642 plt.xlabel("Time (UTC)", fontsize=20) 

643 plt.xticks(rotation=25, horizontalalignment="right") 

644 

645 ax = plt.gca() 

646 xfmt = matplotlib.dates.DateFormatter("%m-%d %H:%M:%S") 

647 ax.xaxis.set_major_formatter(xfmt) 

648 

649 if airmassOneAtTop: 

650 ax.set_ylim(ax.get_ylim()[::-1]) 

651 

652 plt.legend(bbox_to_anchor=(1, 1.025), prop={"size": 15}, loc="upper left") 

653 

654 plt.tight_layout() 

655 if saveFig: 

656 plt.savefig(saveFig) 

657 plt.show() 

658 plt.close() 

659 

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. 

671 

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. 

687 

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 

703 

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. 

708 

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. 

712 

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) 

725 

726 _ = plt.figure(figsize=(14, 10)) 

727 

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 += "-" 

743 

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) 

754 

755 plt.tight_layout() 

756 if saveFig: 

757 plt.savefig(saveFig) 

758 plt.show() 

759 plt.close()