Hide keyboard shortcuts

Hot-keys on this page

r m x p   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

1# This file is currently part of obs_lsst but is written to allow it 

2# to be migrated to the astro_metadata_translator package at a later date. 

3# 

4# This product includes software developed by the LSST Project 

5# (http://www.lsst.org). 

6# See the LICENSE file in this directory for details of code ownership. 

7# 

8# Use of this source code is governed by a 3-clause BSD-style 

9# license that can be found in the LICENSE file. 

10 

11"""Metadata translation support code for LSST headers""" 

12 

13__all__ = ("TZERO", "SIMONYI_LOCATION", "read_detector_ids", 

14 "compute_detector_exposure_id_generic", "LsstBaseTranslator", 

15 "SIMONYI_TELESCOPE") 

16 

17import os.path 

18import yaml 

19import logging 

20import re 

21import datetime 

22import hashlib 

23 

24import astropy.coordinates 

25import astropy.units as u 

26from astropy.time import Time, TimeDelta 

27from astropy.coordinates import EarthLocation 

28 

29from lsst.utils import getPackageDir 

30 

31from astro_metadata_translator import cache_translation, FitsTranslator 

32from astro_metadata_translator.translators.helpers import tracking_from_degree_headers, \ 

33 altaz_from_degree_headers 

34 

35 

36TZERO = Time("2015-01-01T00:00", format="isot", scale="utc") 

37TZERO_DATETIME = TZERO.to_datetime() 

38 

39# Delimiter to use for multiple filters/gratings 

40FILTER_DELIMITER = "~" 

41 

42# Regex to use for parsing a GROUPID string 

43GROUP_RE = re.compile(r"^(\d\d\d\d\-\d\d\-\d\dT\d\d:\d\d:\d\d)\.(\d\d\d)(?:[\+#](\d+))?$") 

44 

45# LSST Default location in the absence of headers 

46SIMONYI_LOCATION = EarthLocation.from_geodetic(-70.749417, -30.244639, 2663.0) 

47 

48# Name of the main survey telescope 

49SIMONYI_TELESCOPE = "Simonyi Survey Telescope" 

50 

51obs_lsst_packageDir = getPackageDir("obs_lsst") 

52 

53log = logging.getLogger(__name__) 

54 

55 

56def read_detector_ids(policyFile): 

57 """Read a camera policy file and retrieve the mapping from CCD name 

58 to ID. 

59 

60 Parameters 

61 ---------- 

62 policyFile : `str` 

63 Name of YAML policy file to read, relative to the obs_lsst 

64 package. 

65 

66 Returns 

67 ------- 

68 mapping : `dict` of `str` to (`int`, `str`) 

69 A `dict` with keys being the full names of the detectors, and the 

70 value is a `tuple` containing the integer detector number and the 

71 detector serial number. 

72 

73 Notes 

74 ----- 

75 Reads the camera YAML definition file directly and extracts just the 

76 IDs and serials. This routine does not use the standard 

77 `~lsst.obs.base.yamlCamera.YAMLCamera` infrastructure or 

78 `lsst.afw.cameraGeom`. This is because the translators are intended to 

79 have minimal dependencies on LSST infrastructure. 

80 """ 

81 

82 file = os.path.join(obs_lsst_packageDir, policyFile) 

83 try: 

84 with open(file) as fh: 

85 # Use the fast parser since these files are large 

86 camera = yaml.load(fh, Loader=yaml.CSafeLoader) 

87 except OSError as e: 

88 raise ValueError(f"Could not load camera policy file {file}") from e 

89 

90 mapping = {} 

91 for ccd, value in camera["CCDs"].items(): 

92 mapping[ccd] = (int(value["id"]), value["serial"]) 

93 

94 return mapping 

95 

96 

97def compute_detector_exposure_id_generic(exposure_id, detector_num, max_num=1000, mode="concat"): 

98 """Compute the detector_exposure_id from the exposure id and the 

99 detector number. 

100 

101 Parameters 

102 ---------- 

103 exposure_id : `int` 

104 The exposure ID. 

105 detector_num : `int` 

106 The detector number. 

107 max_num : `int`, optional 

108 Maximum number of detectors to make space for. Defaults to 1000. 

109 mode : `str`, optional 

110 Computation mode. Defaults to "concat". 

111 - concat : Concatenate the exposure ID and detector number, making 

112 sure that there is space for max_num and zero padding. 

113 - multiply : Multiply the exposure ID by the maximum detector 

114 number and add the detector number. 

115 

116 Returns 

117 ------- 

118 detector_exposure_id : `int` 

119 Computed ID. 

120 

121 Raises 

122 ------ 

123 ValueError 

124 The detector number is out of range. 

125 """ 

126 

127 if detector_num is None: 

128 raise ValueError("Detector number must be defined.") 

129 if detector_num > max_num or detector_num < 0: 

130 raise ValueError(f"Detector number out of range 0 <= {detector_num} <= {max_num}") 

131 

132 if mode == "concat": 

133 npad = len(str(max_num)) 

134 return int(f"{exposure_id}{detector_num:0{npad}d}") 

135 elif mode == "multiply": 

136 return max_num*exposure_id + detector_num 

137 else: 

138 raise ValueError(f"Computation mode of '{mode}' is not understood") 

139 

140 

141class LsstBaseTranslator(FitsTranslator): 

142 """Translation methods useful for all LSST-style headers.""" 

143 

144 _const_map = {} 

145 _trivial_map = {} 

146 

147 # Do not specify a name for this translator 

148 cameraPolicyFile = None 

149 """Path to policy file relative to obs_lsst root.""" 

150 

151 detectorMapping = None 

152 """Mapping of detector name to detector number and serial.""" 

153 

154 detectorSerials = None 

155 """Mapping of detector serial number to raft, number, and name.""" 

156 

157 DETECTOR_MAX = 999 

158 """Maximum number of detectors to use when calculating the 

159 detector_exposure_id.""" 

160 

161 _DEFAULT_LOCATION = SIMONYI_LOCATION 

162 """Default telescope location in absence of relevant FITS headers.""" 

163 

164 _ROLLOVER_TIME = TimeDelta(12*60*60, scale="tai", format="sec") 

165 """Time delta for the definition of a Rubin Observatory start of day. 

166 Used when the header is missing. See LSE-400 for details.""" 

167 

168 @classmethod 

169 def __init_subclass__(cls, **kwargs): 

170 """Ensure that subclasses clear their own detector mapping entries 

171 such that subclasses of translators that use detector mappings 

172 do not pick up the incorrect values from a parent.""" 

173 

174 cls.detectorMapping = None 

175 cls.detectorSerials = None 

176 

177 super().__init_subclass__(**kwargs) 

178 

179 def search_paths(self): 

180 """Search paths to use for LSST data when looking for header correction 

181 files. 

182 

183 Returns 

184 ------- 

185 path : `list` 

186 List with a single element containing the full path to the 

187 ``corrections`` directory within the ``obs_lsst`` package. 

188 """ 

189 return [os.path.join(obs_lsst_packageDir, "corrections")] 

190 

191 @classmethod 

192 def compute_detector_exposure_id(cls, exposure_id, detector_num): 

193 """Compute the detector exposure ID from detector number and 

194 exposure ID. 

195 

196 This is a helper method to allow code working outside the translator 

197 infrastructure to use the same algorithm. 

198 

199 Parameters 

200 ---------- 

201 exposure_id : `int` 

202 Unique exposure ID. 

203 detector_num : `int` 

204 Detector number. 

205 

206 Returns 

207 ------- 

208 detector_exposure_id : `int` 

209 The calculated ID. 

210 """ 

211 return compute_detector_exposure_id_generic(exposure_id, detector_num, 

212 max_num=cls.DETECTOR_MAX, 

213 mode="concat") 

214 

215 @classmethod 

216 def max_detector_exposure_id(cls): 

217 """The maximum detector exposure ID expected to be generated by 

218 this instrument. 

219 

220 Returns 

221 ------- 

222 max_id : `int` 

223 The maximum value. 

224 """ 

225 max_exposure_id = cls.max_exposure_id() 

226 return cls.compute_detector_exposure_id(max_exposure_id, cls.DETECTOR_MAX) 

227 

228 @classmethod 

229 def max_exposure_id(cls): 

230 """The maximum exposure ID expected from this instrument. 

231 

232 Returns 

233 ------- 

234 max_exposure_id : `int` 

235 The maximum value. 

236 """ 

237 max_date = "2050-12-31T23:59.999" 

238 max_seqnum = 99_999 

239 max_controller = "C" # This controller triggers the largest numbers 

240 return cls.compute_exposure_id(max_date, max_seqnum, max_controller) 

241 

242 @classmethod 

243 def detector_mapping(cls): 

244 """Returns the mapping of full name to detector ID and serial. 

245 

246 Returns 

247 ------- 

248 mapping : `dict` of `str`:`tuple` 

249 Returns the mapping of full detector name (group+detector) 

250 to detector number and serial. 

251 

252 Raises 

253 ------ 

254 ValueError 

255 Raised if no camera policy file has been registered with this 

256 translation class. 

257 

258 Notes 

259 ----- 

260 Will construct the mapping if none has previously been constructed. 

261 """ 

262 if cls.cameraPolicyFile is not None: 

263 if cls.detectorMapping is None: 

264 cls.detectorMapping = read_detector_ids(cls.cameraPolicyFile) 

265 else: 

266 raise ValueError(f"Translation class '{cls.__name__}' has no registered camera policy file") 

267 

268 return cls.detectorMapping 

269 

270 @classmethod 

271 def detector_serials(cls): 

272 """Obtain the mapping of detector serial to detector group, name, 

273 and number. 

274 

275 Returns 

276 ------- 

277 info : `dict` of `tuple` of (`str`, `str`, `int`) 

278 A `dict` with the serial numbers as keys and values of detector 

279 group, name, and number. 

280 """ 

281 if cls.detectorSerials is None: 

282 detector_mapping = cls.detector_mapping() 

283 

284 if detector_mapping is not None: 

285 # Form mapping to go from serial number to names/numbers 

286 serials = {} 

287 for fullname, (id, serial) in cls.detectorMapping.items(): 

288 raft, detector_name = fullname.split("_") 

289 if serial in serials: 

290 raise RuntimeError(f"Serial {serial} is defined in multiple places") 

291 serials[serial] = (raft, detector_name, id) 

292 cls.detectorSerials = serials 

293 else: 

294 raise RuntimeError("Unable to obtain detector mapping information") 

295 

296 return cls.detectorSerials 

297 

298 @classmethod 

299 def compute_detector_num_from_name(cls, detector_group, detector_name): 

300 """Helper method to return the detector number from the name. 

301 

302 Parameters 

303 ---------- 

304 detector_group : `str` 

305 Name of the detector grouping. This is generally the raft name. 

306 detector_name : `str` 

307 Detector name. 

308 

309 Returns 

310 ------- 

311 num : `int` 

312 Detector number. 

313 """ 

314 fullname = f"{detector_group}_{detector_name}" 

315 

316 num = None 

317 detector_mapping = cls.detector_mapping() 

318 if detector_mapping is None: 

319 raise RuntimeError("Unable to obtain detector mapping information") 

320 

321 if fullname in detector_mapping: 

322 num = detector_mapping[fullname] 

323 else: 

324 log.warning(f"Unable to determine detector number from detector name {fullname}") 

325 return None 

326 

327 return num[0] 

328 

329 @classmethod 

330 def compute_detector_info_from_serial(cls, detector_serial): 

331 """Helper method to return the detector information from the serial. 

332 

333 Parameters 

334 ---------- 

335 detector_serial : `str` 

336 Detector serial ID. 

337 

338 Returns 

339 ------- 

340 info : `tuple` of (`str`, `str`, `int`) 

341 Detector group, name, and number. 

342 """ 

343 serial_mapping = cls.detector_serials() 

344 if serial_mapping is None: 

345 raise RuntimeError("Unable to obtain serial mapping information") 

346 

347 if detector_serial in serial_mapping: 

348 info = serial_mapping[detector_serial] 

349 else: 

350 raise RuntimeError("Unable to determine detector information from detector serial" 

351 f" {detector_serial}") 

352 

353 return info 

354 

355 @staticmethod 

356 def compute_exposure_id(dayobs, seqnum, controller=None): 

357 """Helper method to calculate the exposure_id. 

358 

359 Parameters 

360 ---------- 

361 dayobs : `str` 

362 Day of observation in either YYYYMMDD or YYYY-MM-DD format. 

363 If the string looks like ISO format it will be truncated before the 

364 ``T`` before being handled. 

365 seqnum : `int` or `str` 

366 Sequence number. 

367 controller : `str`, optional 

368 Controller to use. If this is "O", no change is made to the 

369 exposure ID. If it is "C" a 1000 is added to the year component 

370 of the exposure ID. If it is "H" a 2000 is added to the year 

371 component. 

372 `None` indicates that the controller is not relevant to the 

373 exposure ID calculation (generally this is the case for test 

374 stand data). 

375 

376 Returns 

377 ------- 

378 exposure_id : `int` 

379 Exposure ID in form YYYYMMDDnnnnn form. 

380 """ 

381 if "T" in dayobs: 

382 dayobs = dayobs[:dayobs.find("T")] 

383 

384 dayobs = dayobs.replace("-", "") 

385 

386 if len(dayobs) != 8: 

387 raise ValueError(f"Malformed dayobs: {dayobs}") 

388 

389 # Expect no more than 99,999 exposures in a day 

390 maxdigits = 5 

391 if seqnum >= 10**maxdigits: 

392 raise ValueError(f"Sequence number ({seqnum}) exceeds limit") 

393 

394 # Camera control changes the exposure ID 

395 if controller is not None: 

396 if controller == "O": 

397 pass 

398 elif controller == "C": 

399 # Add 1000 to the year component 

400 dayobs = int(dayobs) 

401 dayobs += 1000_00_00 

402 elif controller == "H": 

403 # Add 2000 to the year component for pHosim 

404 dayobs = int(dayobs) 

405 dayobs += 2000_00_00 

406 else: 

407 raise ValueError(f"Supplied controller, '{controller}' is neither 'O' nor 'C' nor 'H'") 

408 

409 # Form the number as a string zero padding the sequence number 

410 idstr = f"{dayobs}{seqnum:0{maxdigits}d}" 

411 

412 # Exposure ID has to be an integer 

413 return int(idstr) 

414 

415 def _is_on_mountain(self): 

416 """Indicate whether these data are coming from the instrument 

417 installed on the mountain. 

418 

419 Returns 

420 ------- 

421 is : `bool` 

422 `True` if instrument is on the mountain. 

423 """ 

424 if "TSTAND" in self._header: 

425 return False 

426 return True 

427 

428 def is_on_sky(self): 

429 """Determine if this is an on-sky observation. 

430 

431 Returns 

432 ------- 

433 is_on_sky : `bool` 

434 Returns True if this is a observation on sky on the 

435 summit. 

436 """ 

437 # For LSST we think on sky unless tracksys is local 

438 if self.is_key_ok("TRACKSYS"): 

439 if self._header["TRACKSYS"].lower() == "local": 

440 # not on sky 

441 return False 

442 

443 # These are obviously not on sky 

444 if self.to_observation_type() in ("bias", "dark", "flat"): 

445 return False 

446 

447 return self._is_on_mountain() 

448 

449 @cache_translation 

450 def to_location(self): 

451 # Docstring will be inherited. Property defined in properties.py 

452 if not self._is_on_mountain(): 

453 return None 

454 try: 

455 # Try standard FITS headers 

456 return super().to_location() 

457 except KeyError: 

458 return self._DEFAULT_LOCATION 

459 

460 @cache_translation 

461 def to_datetime_begin(self): 

462 # Docstring will be inherited. Property defined in properties.py 

463 self._used_these_cards("MJD-OBS") 

464 return Time(self._header["MJD-OBS"], scale="tai", format="mjd") 

465 

466 @cache_translation 

467 def to_datetime_end(self): 

468 # Docstring will be inherited. Property defined in properties.py 

469 if self.is_key_ok("DATE-END"): 

470 return super().to_datetime_end() 

471 

472 return self.to_datetime_begin() + self.to_exposure_time() 

473 

474 @cache_translation 

475 def to_detector_num(self): 

476 # Docstring will be inherited. Property defined in properties.py 

477 raft = self.to_detector_group() 

478 detector = self.to_detector_name() 

479 return self.compute_detector_num_from_name(raft, detector) 

480 

481 @cache_translation 

482 def to_detector_exposure_id(self): 

483 # Docstring will be inherited. Property defined in properties.py 

484 exposure_id = self.to_exposure_id() 

485 num = self.to_detector_num() 

486 return self.compute_detector_exposure_id(exposure_id, num) 

487 

488 @cache_translation 

489 def to_observation_type(self): 

490 # Docstring will be inherited. Property defined in properties.py 

491 obstype = self._header["IMGTYPE"] 

492 self._used_these_cards("IMGTYPE") 

493 obstype = obstype.lower() 

494 if obstype in ("skyexp", "object"): 

495 obstype = "science" 

496 return obstype 

497 

498 @cache_translation 

499 def to_observation_reason(self): 

500 # Docstring will be inherited. Property defined in properties.py 

501 if self.is_key_ok("TESTTYPE"): 

502 reason = self._header["TESTTYPE"] 

503 self._used_these_cards("TESTTYPE") 

504 return reason.lower() 

505 # no specific header present so use the default translation 

506 return super().to_observation_reason() 

507 

508 @cache_translation 

509 def to_dark_time(self): 

510 """Calculate the dark time. 

511 

512 If a DARKTIME header is not found, the value is assumed to be 

513 identical to the exposure time. 

514 

515 Returns 

516 ------- 

517 dark : `astropy.units.Quantity` 

518 The dark time in seconds. 

519 """ 

520 if self.is_key_ok("DARKTIME"): 

521 darktime = self._header["DARKTIME"]*u.s 

522 self._used_these_cards("DARKTIME") 

523 else: 

524 log.warning("%s: Unable to determine dark time. Setting from exposure time.", 

525 self._log_prefix) 

526 darktime = self.to_exposure_time() 

527 return darktime 

528 

529 @cache_translation 

530 def to_exposure_id(self): 

531 """Generate a unique exposure ID number 

532 

533 This is a combination of DAYOBS and SEQNUM, and optionally 

534 CONTRLLR. 

535 

536 Returns 

537 ------- 

538 exposure_id : `int` 

539 Unique exposure number. 

540 """ 

541 if "CALIB_ID" in self._header: 

542 self._used_these_cards("CALIB_ID") 

543 return None 

544 

545 dayobs = self._header["DAYOBS"] 

546 seqnum = self._header["SEQNUM"] 

547 self._used_these_cards("DAYOBS", "SEQNUM") 

548 

549 if self.is_key_ok("CONTRLLR"): 

550 controller = self._header["CONTRLLR"] 

551 self._used_these_cards("CONTRLLR") 

552 else: 

553 controller = None 

554 

555 return self.compute_exposure_id(dayobs, seqnum, controller=controller) 

556 

557 @cache_translation 

558 def to_visit_id(self): 

559 """Calculate the visit associated with this exposure. 

560 

561 Notes 

562 ----- 

563 For LATISS and LSSTCam the default visit is derived from the 

564 exposure group. For other instruments we return the exposure_id. 

565 """ 

566 

567 exposure_group = self.to_exposure_group() 

568 # If the group is an int we return it 

569 try: 

570 visit_id = int(exposure_group) 

571 return visit_id 

572 except ValueError: 

573 pass 

574 

575 # A Group is defined as ISO date with an extension 

576 # The integer must be the same for a given group so we can never 

577 # use datetime_begin. 

578 # Nominally a GROUPID looks like "ISODATE+N" where the +N is 

579 # optional. This can be converted to seconds since epoch with 

580 # an adjustment for N. 

581 # For early data lacking that form we hash the group and return 

582 # the int. 

583 matches_date = GROUP_RE.match(exposure_group) 

584 if matches_date: 

585 iso_str = matches_date.group(1) 

586 fraction = matches_date.group(2) 

587 n = matches_date.group(3) 

588 if n is not None: 

589 n = int(n) 

590 else: 

591 n = 0 

592 iso = datetime.datetime.strptime(iso_str, "%Y-%m-%dT%H:%M:%S") 

593 

594 tdelta = iso - TZERO_DATETIME 

595 epoch = int(tdelta.total_seconds()) 

596 

597 # Form the integer from EPOCH + 3 DIGIT FRAC + 0-pad N 

598 visit_id = int(f"{epoch}{fraction}{n:04d}") 

599 else: 

600 # Non-standard string so convert to numbers 

601 # using a hash function. Use the first N hex digits 

602 group_bytes = exposure_group.encode("us-ascii") 

603 hasher = hashlib.blake2b(group_bytes) 

604 # Need to be big enough it does not possibly clash with the 

605 # date-based version above 

606 digest = hasher.hexdigest()[:14] 

607 visit_id = int(digest, base=16) 

608 

609 # To help with hash collision, append the string length 

610 visit_id = int(f"{visit_id}{len(exposure_group):02d}") 

611 

612 return visit_id 

613 

614 @cache_translation 

615 def to_physical_filter(self): 

616 """Calculate the physical filter name. 

617 

618 Returns 

619 ------- 

620 filter : `str` 

621 Name of filter. Can be a combination of FILTER, FILTER1 and FILTER2 

622 headers joined by a "~". Returns "unknown" if no filter is declared 

623 """ 

624 joined = self._join_keyword_values(["FILTER", "FILTER1", "FILTER2"], delim=FILTER_DELIMITER) 

625 if not joined: 

626 joined = "unknown" 

627 

628 return joined 

629 

630 @cache_translation 

631 def to_tracking_radec(self): 

632 if not self.is_on_sky(): 

633 return None 

634 

635 # RA/DEC are *derived* headers and for the case where the DATE-BEG 

636 # is 1970 they are garbage and should not be used. 

637 if self._header["DATE-OBS"] == self._header["DATE"]: 

638 # A fixed up date -- use AZEL as source of truth 

639 altaz = self.to_altaz_begin() 

640 radec = astropy.coordinates.SkyCoord(altaz.transform_to(astropy.coordinates.ICRS()), 

641 obstime=altaz.obstime, 

642 location=altaz.location) 

643 else: 

644 radecsys = ("RADESYS",) 

645 radecpairs = (("RASTART", "DECSTART"), ("RA", "DEC")) 

646 radec = tracking_from_degree_headers(self, radecsys, radecpairs) 

647 

648 return radec 

649 

650 @cache_translation 

651 def to_altaz_begin(self): 

652 if not self._is_on_mountain(): 

653 return None 

654 

655 # ALTAZ always relevant unless bias or dark 

656 if self.to_observation_type() in ("bias", "dark"): 

657 return None 

658 

659 return altaz_from_degree_headers(self, (("ELSTART", "AZSTART"),), 

660 self.to_datetime_begin(), is_zd=False) 

661 

662 @cache_translation 

663 def to_exposure_group(self): 

664 """Calculate the exposure group string. 

665 

666 For LSSTCam and LATISS this is read from the ``GROUPID`` header. 

667 If that header is missing the exposure_id is returned instead as 

668 a string. 

669 """ 

670 if self.is_key_ok("GROUPID"): 

671 exposure_group = self._header["GROUPID"] 

672 self._used_these_cards("GROUPID") 

673 return exposure_group 

674 return super().to_exposure_group() 

675 

676 @staticmethod 

677 def _is_filter_empty(filter): 

678 """Return true if the supplied filter indicates an empty filter slot 

679 

680 Parameters 

681 ---------- 

682 filter : `str` 

683 The filter string to check. 

684 

685 Returns 

686 ------- 

687 is_empty : `bool` 

688 `True` if the filter string looks like it is referring to an 

689 empty filter slot. For example this can be if the filter is 

690 "empty" or "empty_2". 

691 """ 

692 return bool(re.match(r"empty_?\d*$", filter.lower())) 

693 

694 def _determine_primary_filter(self): 

695 """Determine the primary filter from the ``FILTER`` header. 

696 

697 Returns 

698 ------- 

699 filter : `str` 

700 The contents of the ``FILTER`` header with some appropriate 

701 defaulting. 

702 """ 

703 

704 if self.is_key_ok("FILTER"): 

705 physical_filter = self._header["FILTER"] 

706 self._used_these_cards("FILTER") 

707 

708 if self._is_filter_empty(physical_filter): 

709 physical_filter = "empty" 

710 else: 

711 # Be explicit about having no knowledge of the filter 

712 # by setting it to "unknown". It should always have a value. 

713 physical_filter = "unknown" 

714 

715 # Warn if the filter being unknown is important 

716 obstype = self.to_observation_type() 

717 if obstype not in ("bias", "dark"): 

718 log.warning("%s: Unable to determine the filter", 

719 self._log_prefix) 

720 

721 return physical_filter 

722 

723 @cache_translation 

724 def to_observing_day(self): 

725 """Return the day of observation as YYYYMMDD integer. 

726 

727 For LSSTCam and other compliant instruments this is the value 

728 of the DAYOBS header. 

729 

730 Returns 

731 ------- 

732 obs_day : `int` 

733 The day of observation. 

734 """ 

735 if self.is_key_ok("DAYOBS"): 

736 self._used_these_cards("DAYOBS") 

737 return int(self._header["DAYOBS"]) 

738 

739 # Calculate it ourselves correcting for the Rubin offset 

740 date = self.to_datetime_begin().tai 

741 date -= self._ROLLOVER_TIME 

742 return int(date.strftime("%Y%m%d")) 

743 

744 @cache_translation 

745 def to_observation_counter(self): 

746 """Return the sequence number within the observing day. 

747 

748 Returns 

749 ------- 

750 counter : `int` 

751 The sequence number for this day. 

752 """ 

753 if self.is_key_ok("SEQNUM"): 

754 # Some older LATISS data may not have the header 

755 # but this is corrected in fix_header for LATISS. 

756 self._used_these_cards("SEQNUM") 

757 return int(self._header["SEQNUM"]) 

758 

759 # This indicates a problem so we warn and return a 0 

760 log.warning("%s: Unable to determine the observation counter so returning 0", 

761 self._log_prefix) 

762 return 0 

763 

764 @cache_translation 

765 def to_boresight_rotation_coord(self): 

766 """Boresight rotation angle. 

767 

768 Only relevant for science observations. 

769 """ 

770 unknown = "unknown" 

771 if not self.is_on_sky(): 

772 return unknown 

773 

774 self._used_these_cards("ROTCOORD") 

775 coord = self._header.get("ROTCOORD", unknown) 

776 if coord is None: 

777 coord = unknown 

778 return coord 

779 

780 @cache_translation 

781 def to_boresight_airmass(self): 

782 """Calculate airmass at boresight at start of observation. 

783 

784 Notes 

785 ----- 

786 Early data are missing AMSTART header so we fall back to calculating 

787 it from ELSTART. 

788 """ 

789 if not self.is_on_sky(): 

790 return None 

791 

792 # This observation should have AMSTART 

793 amkey = "AMSTART" 

794 if self.is_key_ok(amkey): 

795 self._used_these_cards(amkey) 

796 return self._header[amkey] 

797 

798 # Instead we need to look at azel 

799 altaz = self.to_altaz_begin() 

800 if altaz is not None: 

801 return altaz.secz.to_value() 

802 

803 log.warning("%s: Unable to determine airmass of a science observation, returning 1.", 

804 self._log_prefix) 

805 return 1.0