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

337 statements  

« prev     ^ index     » next       coverage.py v6.5.0, created at 2023-04-15 04:03 -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 pickle 

23import logging 

24 

25from dataclasses import dataclass 

26import numpy as np 

27import matplotlib 

28import matplotlib.pyplot as plt 

29from matplotlib.pyplot import cm 

30 

31from lsst.utils.iteration import ensure_iterable 

32from astro_metadata_translator import ObservationInfo 

33from .utils import obsInfoToDict, getFieldNameAndTileNumber 

34 

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 

43 

44 

45__all__ = ['NightReport'] 

46 

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' 

54 

55 

56@dataclass 

57class ColorAndMarker: 

58 '''Class for holding colors and marker symbols''' 

59 color: list 

60 marker: str = '*' 

61 

62 

63class NightReport(): 

64 _version = 1 

65 

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 

79 

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) 

87 

88 def save(self, filename): 

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

90 

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) 

103 

104 def _load(self, filename): 

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

106 

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

109 

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) 

122 

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

134 

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

143 

144 def getExpRecordDictForDayObs(self, dayObs): 

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

146 

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) 

165 

166 def getObsInfoAndMetadataForSeqNum(self, seqNum): 

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

168 

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. 

173 

174 Parameters 

175 ---------- 

176 seqNum : `int` 

177 The seqNum. 

178 

179 Returns 

180 ------- 

181 obsInfo : `astro_metadata_translator.ObservationInfo` 

182 The obsInfo. 

183 md : `dict` 

184 The raw metadata. 

185 

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

194 

195 def rebuild(self, full=False): 

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

197 

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

199 

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

209 

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) 

221 

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) 

237 

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

239 self.stars = self.getObservedObjects() 

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

241 

242 def getDatesForSeqNums(self): 

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

244 

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

252 

253 def getObservedObjects(self, ignoreTileNum=True): 

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

255 

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. 

259 

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 

269 

270 def getSeqNumsMatching(self, invert=False, subset=None, **kwargs): 

271 """Get seqNums which match/don't match all kwargs provided, e.g. 

272 

273 report.getSeqNumsMatching(exposure_time=30, 

274 target_name='ETA1 DOR') 

275 

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 

278 

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

283 

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] 

296 

297 return sorted(local.keys()) 

298 

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. 

302 

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

304 but some are astropy quantities. 

305 

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 

321 

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 

333 

334 def calcShutterTimes(self): 

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

336 

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

340 

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

352 

353 begin = self.data[firstObs]['datetime_begin'] 

354 end = self.data[lastObs]['datetime_end'] 

355 

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

359 

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) 

363 

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 

374 

375 return result 

376 

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 

386 

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

401 

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. 

406 

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) 

417 

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

419 

420 def printObsGaps(self, threshold=100, includeCalibs=False): 

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

422 

423 Prints the most recent gaps first. 

424 

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

436 

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

448 

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

454 

455 if messages: 

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

457 for line in messages: 

458 print(line) 

459 

460 def getObservingStartSeqNum(self, method='safe'): 

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

462 

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

464 

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

480 

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

489 

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 

499 

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) 

507 

508 def printObsTable(self, **kwargs): 

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

510 

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 

520 

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

530 

531 msg = f'{seqNum} {target} {expTime:.1f} {deadtime:.02f} {imageType} {filt}' 

532 except Exception: 

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

534 lines.append(msg) 

535 

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

537 print(r"------ ------ ------- -------- --------- ----") 

538 for line in lines: 

539 print(line) 

540 

541 def getExposureMidpoint(self, seqNum): 

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

543 

544 Parameters 

545 ---------- 

546 seqNum : `int` 

547 The seqNum to get the midpoint for. 

548 

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

557 

558 def plotPerObjectAirMass(self, objects=None, airmassOneAtTop=True, saveFig=''): 

559 """Plot the airmass for objects observed over the course of the night. 

560 

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 

573 

574 objects = ensure_iterable(objects) 

575 

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

586 

587 plt.ylabel('Airmass', fontsize=20) 

588 plt.xlabel('Time (UTC)', fontsize=20) 

589 plt.xticks(rotation=25, horizontalalignment='right') 

590 

591 ax = plt.gca() 

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

593 ax.xaxis.set_major_formatter(xfmt) 

594 

595 if airmassOneAtTop: 

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

597 

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

599 

600 plt.tight_layout() 

601 if saveFig: 

602 plt.savefig(saveFig) 

603 plt.show() 

604 plt.close() 

605 

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. 

609 

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. 

625 

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 

641 

642 def makeAltAzCoveragePlot(self, objects=None, withLines=False, saveFig=''): 

643 """Make a polar plot of the azimuth and zenith angle for each object. 

644 

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. 

648 

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) 

661 

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

663 

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

679 

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) 

689 

690 plt.tight_layout() 

691 if saveFig: 

692 plt.savefig(saveFig) 

693 plt.show() 

694 plt.close()